5 Commits

Author SHA1 Message Date
rudi 0837de8db3 ADMOPSLA and ADMCHART are happier
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 1m7s
2026-08-23 01:27:45 +00:00
rudi 42eef519a6 Repo clean
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 1m4s
2026-08-22 13:17:31 +00:00
rudi b57d8f960d How had I not tested on Linux before
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 45s
2026-08-21 21:00:10 -04:00
rudi 09600260b5 Add some cleaning
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 37s
2026-08-21 18:56:37 -04:00
rudi 00c7b0770c Missed a folder!
Build and Test j3270 / Build JAR & Run Tests (push) Successful in 46s
2026-08-21 16:35:18 -04:00
61 changed files with 1142 additions and 134 deletions
+1
View File
@@ -1,4 +1,5 @@
j3270.log.*
build/*
*.log
.DS_Store
*.jar
+2 -8
View File
@@ -3,7 +3,7 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/j3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/j3270/actions)
[![Releases](https://img.shields.io/badge/Releases-Gitea-blue.svg)](https://git.hugfreevikings.wtf/rudi/j3270/releases)
[![Java](https://img.shields.io/badge/Java-11%20%7C%2017%20%7C%2021-blue.svg)](https://adoptium.net)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE)
[![License](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator** written in pure Java. Designed for high compatibility with IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
@@ -76,7 +76,7 @@ The project includes self-contained, portable build scripts:
# Build executable JAR in 2 seconds:
sh ./build_all.sh
# Run all 56 automated unit tests in ~500ms:
# Run all 57 automated unit tests in ~500ms:
sh ./test_all.sh
```
@@ -90,12 +90,6 @@ The resulting standalone JAR is created at `build/j3270.jar`.
---
## 🤝 Contributing
Contributions and issue reports are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
---
## 📜 Acknowledgements
- [x3270](https://x3270.miraheze.org/wiki/Main_Page) — Reference C implementation and protocol specifications
+3
View File
@@ -8,3 +8,6 @@
- IND$FILE CMS and TSO:
Fixed command formatting options handling (empty parenthesis removal for CMS binary/default modes and option spacing) and added Query Reply filtering for DFT/DDM mode.
- Local keyboard input and cursor updates not rendering in terminal:
Fixed in ScreenBuffer, InputProcessor, and TerminalPanel by ensuring display snapshot and cursor address are updated synchronously on user input operations (typing, backspace, delete, cursor movement, erase) so the presentation layer immediately renders user keystrokes.
-6
View File
@@ -1,6 +0,0 @@
Manifest-Version: 1.0
Main-Class: org.pubvm.j3270.J3270App
Implementation-Title: j3270
Implementation-Version: 0.1.0
Created-By: j3270 build_all.sh
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -180,6 +180,22 @@ public class TerminalPanel extends JPanel {
}
}
@Override
public void mouseMoved(MouseEvent e) {
if (client != null && client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive() && client.getGraphicsPlane() != null) {
int ox = getRenderOffsetX();
int oy = getRenderOffsetY();
ScreenBuffer sb = client.getScreenBuffer();
int gridW = sb.getDisplayCols() * cellWidth;
int gridH = sb.getDisplayRows() * cellHeight;
int gWidth = client.getGraphicsPlane().getCanvasWidth();
int gHeight = client.getGraphicsPlane().getCanvasHeight();
int px = (gridW > 0 && gWidth > 0) ? (int) Math.round((double) (e.getX() - ox) * gWidth / gridW) : (e.getX() - ox);
int py = (gridH > 0 && gHeight > 0) ? (int) Math.round((double) (e.getY() - oy) * gHeight / gridH) : (e.getY() - oy);
client.getGocaDecoder().setGraphicCursorFromPixel(px, py);
}
}
@Override
public void mouseReleased(MouseEvent e) {
if (isDragging) {
@@ -188,8 +204,37 @@ public class TerminalPanel extends JPanel {
if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) {
if (client != null && client.getConnectionState().isFullSession()) {
ScreenBuffer sb = client.getScreenBuffer();
sb.setCursorAddress(selectionStartRow * sb.getCols() + selectionStartCol);
int newAddr = selectionStartRow * sb.getCols() + selectionStartCol;
sb.setCursorAddress(newAddr);
clearSelection();
if (client.getGocaDecoder() != null && client.getGraphicsPlane() != null) {
int ox = getRenderOffsetX();
int oy = getRenderOffsetY();
int gridW = sb.getDisplayCols() * cellWidth;
int gridH = sb.getDisplayRows() * cellHeight;
int gWidth = client.getGraphicsPlane().getCanvasWidth();
int gHeight = client.getGraphicsPlane().getCanvasHeight();
int px = (gridW > 0 && gWidth > 0) ? (int) Math.round((double) (e.getX() - ox) * gWidth / gridW) : (e.getX() - ox);
int py = (gridH > 0 && gHeight > 0) ? (int) Math.round((double) (e.getY() - oy) * gHeight / gridH) : (e.getY() - oy);
client.getGocaDecoder().setGraphicCursorFromPixel(px, py);
if (client.getGocaDecoder().isGraphicsCursorActive()) {
// Light-pen / Graphic cursor touch event (matches IBM Host On-Demand PS3179G)
int button = javax.swing.SwingUtilities.isRightMouseButton(e) ? 2 : 1;
client.getInputProcessor().sendGraphicMouseAid(
org.lib3270j.protocol.DS3270Constants.AID_ENTER,
button,
e.isShiftDown(),
e.isControlDown()
);
refreshScreen();
return;
}
}
refreshScreen();
return;
}
}
repaint();
@@ -723,6 +768,9 @@ public class TerminalPanel extends JPanel {
}
private void refreshScreen() {
if (client != null) {
client.getScreenBuffer().updateDisplaySnapshot();
}
repaint();
// Notify parent to update status bar too
Container parent = getParent();
@@ -844,6 +892,20 @@ public class TerminalPanel extends JPanel {
int cols = sb.getDisplayCols();
boolean isColorModel = client.getConfig().getModel().isColor();
// Draw Vector Graphics Plane under text if present
if (client.getGraphicsPlane() != null && client.getGraphicsPlane().hasContent()) {
int gridW = cols * cellWidth;
int gridH = rows * cellHeight;
int gWidth = client.getGraphicsPlane().getCanvasWidth();
int gHeight = client.getGraphicsPlane().getCanvasHeight();
int[] rgb = client.getGraphicsPlane().getRgbBuffer();
if (rgb != null && gWidth > 0 && gHeight > 0) {
java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(gWidth, gHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB);
img.setRGB(0, 0, gWidth, gHeight, rgb, 0, gWidth);
g2.drawImage(img, ox, oy, gridW, gridH, null);
}
}
// Track current field attribute for monochrome color decisions
byte currentFA = 0;
ExtendedAttribute currentFieldEa = null;
@@ -935,7 +997,8 @@ public class TerminalPanel extends JPanel {
if (cs >= 0x40 && client.getProgramSymbolManager() != null) {
org.lib3270j.graphics.ProgramSymbolSet.SymbolSlot slot = client.getProgramSymbolManager().getSymbol(cs, ea.ec & 0xFF);
if (slot != null) {
java.awt.image.BufferedImage img = slot.getScaledImage(cellWidth, cellHeight, fgColor.getRGB(), bgColor.getRGB());
int symBg = (!bgColor.equals(this.bgColor) || reverse) ? bgColor.getRGB() : 0;
java.awt.image.BufferedImage img = slot.getScaledImage(cellWidth, cellHeight, fgColor.getRGB(), symBg);
if (img != null) {
g2.drawImage(img, x, y, null);
drawnAsPs = true;
@@ -972,20 +1035,22 @@ public class TerminalPanel extends JPanel {
}
}
// Draw Vector Graphics Plane overlay if present
if (client.getGraphicsPlane() != null && client.getGraphicsPlane().hasContent()) {
int gridW = cols * cellWidth;
int gridH = rows * cellHeight;
client.getGraphicsPlane().resize(gridW, gridH);
int[] rgb = client.getGraphicsPlane().getRgbBuffer();
if (rgb != null) {
java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(gridW, gridH, java.awt.image.BufferedImage.TYPE_INT_ARGB);
img.setRGB(0, 0, gridW, gridH, rgb, 0, gridW);
g2.drawImage(img, ox, oy, gridW, gridH, null);
}
// Draw Graphic Cursor (Light-Pen / interactive graphics pointer) if active
if (client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive() && client.getGraphicsPlane() != null) {
int gocaX = client.getGocaDecoder().getGraphicCursorX();
int gocaY = client.getGocaDecoder().getGraphicCursorY();
int px = ox + client.getGraphicsPlane().mapX(gocaX);
int py = oy + client.getGraphicsPlane().mapY(gocaY);
g2.setColor(Color.WHITE);
g2.setXORMode(Color.BLACK);
// Draw a crosshair cursor for the light-pen / graphic cursor
g2.drawLine(px - 6, py, px + 6, py);
g2.drawLine(px, py - 6, px, py + 6);
g2.setPaintMode();
}
// Draw cursor
// Draw 3270 text cursor
if (cursorVisible && client.getConnectionState().isFullSession()) {
int curAddr = sb.getDisplayCursorAddress();
int curRow = curAddr / cols;
@@ -26,7 +26,7 @@ public class DiagnosticClient {
String host = args.length >= 1 ? args[0] : "192.168.0.30";
int port = args.length >= 2 ? Integer.parseInt(args[1]) : 3270;
ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_2);
ConnectionConfig config = new ConnectionConfig(host, port, TerminalModel.IBM_3279_4);
client = new Telnet3270Client(config);
client.addConnectionListener(new ConnectionListener() {
@@ -54,6 +54,7 @@ public class Telnet3270Client {
dsProcessor.setOutputSender(fsm::send3270Data);
dsProcessor.setInputProcessor(inputProcessor);
inputProcessor.setGraphicsPlane(dsProcessor.getGraphicsPlane());
inputProcessor.setGocaDecoder(dsProcessor.getGocaDecoder());
}
/**
@@ -89,6 +89,10 @@ public class DataStreamProcessor {
this.inputProcessor = inputProcessor;
}
public org.lib3270j.input.InputProcessor getInputProcessor() {
return inputProcessor;
}
public void addScreenUpdateListener(ScreenUpdateListener l) {
screenListeners.add(l);
}
@@ -175,6 +179,7 @@ public class DataStreamProcessor {
case CMD_WSF:
case SNA_CMD_WSF:
processWriteStructuredField(data, offset, length);
keyboardRestore = true;
break;
case CMD_NOP:
log.info(">>> NOP command");
@@ -190,6 +195,10 @@ public class DataStreamProcessor {
screen.updateDisplaySnapshot();
}
if (keyboardRestore && inputProcessor != null) {
inputProcessor.setKeyboardLocked(false);
}
// Debug: dump non-empty screen lines
if (log.isLoggable(Level.FINE) && (cmd == SNA_CMD_EWA || cmd == CMD_EWA || cmd == SNA_CMD_EW || cmd == CMD_EW)) {
int r = screen.getRows();
@@ -782,22 +791,27 @@ public class DataStreamProcessor {
case SF_READ_PART:
processSFReadPartition(data, pos, fieldLen);
break;
case SF_ERASE_RESET:
if (fieldLen >= 4) {
boolean alt = (data[pos + 3] & 0xFF) == SF_ER_ALT;
case SF_ERASE_RESET: {
boolean alt = (fieldLen >= 4) && ((data[pos + 3] & 0xFF) == SF_ER_ALT);
screen.erase(alt);
graphicsPlane.clear();
gocaDecoder.resetDefaults();
notifyScreenSizeChanged();
}
break;
}
case SF_SET_REPLY_MODE:
if (fieldLen >= 5) {
screen.setReplyMode((byte) (data[pos + 4] & 0xFF));
}
break;
case SF_CREATE_PART:
// Acknowledged — we use implicit partition
if (fieldLen >= 4) {
int pid = data[pos + 3] & 0xFF;
screen.setActivePartition(pid);
log.fine("Created active partition ID=" + pid);
}
graphicsPlane.clear();
gocaDecoder.resetDefaults();
break;
case SF_OUTBOUND_DS:
if (fieldLen > 5) {
@@ -879,6 +893,13 @@ public class DataStreamProcessor {
}
break;
}
case org.lib3270j.graphics.GocaConstants.SF_OBJCNTL: // 0x24: Object Control (Procedure orders)
if (fieldLen > 3) {
graphicsPlane.setScreenDimensions(screen.getCols(), screen.getRows());
gocaDecoder.processProcedureOrders(data, pos + 3, fieldLen - 3);
notifyScreenUpdated();
}
break;
case org.lib3270j.graphics.GocaConstants.SF_OBJDATA: // 0x85: Graphics Object Data / GOCA
case org.lib3270j.graphics.GocaConstants.SF_3270_G: // 0x20: 3270 Graphics
if (fieldLen > 3) {
@@ -915,6 +936,8 @@ public class DataStreamProcessor {
switch (type) {
case SF_RP_QUERY:
log.info("ReadPartition Query — sending all query replies");
graphicsPlane.clear();
gocaDecoder.resetDefaults();
sendAllQueryReplies();
break;
case SF_RP_QLIST:
@@ -197,7 +197,7 @@ public class QueryReplyBuilder {
case QR_RPQ_NAMES:
case QR_RPQNAMES:
if (graphicsMode.isVectorGraphicsEnabled()) {
appendQueryReply(out, QR_RPQ_NAMES, buildRpqNames());
appendQueryReply(out, code, buildRpqNames());
} else {
appendQueryReply(out, QR_NULL, new byte[0]);
}
@@ -54,6 +54,7 @@ public final class GocaConstants {
public static final int G_GSVW = 0x27; // Set Viewing Window
public static final int G_GSPT = 0x28; // Set Pattern Symbol
public static final int G_GSMT = 0x29; // Set Marker Symbol / Type
public static final int G_GCALL = 0x2A; // Call Segment
public static final int G_GSCH = 0x33; // Set Character Cell
public static final int G_GSCA = 0x34; // Set Character Angle
public static final int G_GSCR = 0x35; // Set Character Shear
@@ -176,6 +177,9 @@ public final class GocaConstants {
* Returns Green (0xFF00FF00) if the index is out of range.
*/
public static int getGocaColorArgb(int colorIndex) {
if (colorIndex == 0xFF) {
return GOCA_COLORS[7];
}
if (colorIndex >= 0 && colorIndex < GOCA_COLORS.length) {
return GOCA_COLORS[colorIndex];
}
@@ -20,6 +20,8 @@ public class GocaDecoder {
private int curX = 0;
private int curY = 0;
private int curColor = GocaConstants.GOCA_COLORS[0];
private int bgMix = 2; // BMX_OVERPAINT default
private int bgColor = GocaConstants.GOCA_COLORS[8]; // Black
private int lineType = GocaConstants.LT_SOLID;
private int lineWidth = GocaConstants.LW_NORMAL;
private int markerType = GocaConstants.MK_PLUS;
@@ -42,6 +44,7 @@ public class GocaDecoder {
// Area accumulation
private boolean inArea = false;
private boolean areaDrawBoundary = true;
private boolean areaFill = true;
private final List<Integer> areaPointsX = new ArrayList<>();
private final List<Integer> areaPointsY = new ArrayList<>();
@@ -53,6 +56,18 @@ public class GocaDecoder {
private int imgHeight = 0;
private final List<Byte> imgBuffer = new ArrayList<>();
// Segment Store for retained graphics / segment calling (G_GCALL 0x2A)
private final java.util.Map<Integer, byte[]> segmentStore = new java.util.HashMap<>();
private final java.util.Map<Integer, Integer> segmentChainMap = new java.util.HashMap<>();
private final java.util.List<Integer> segmentOrderList = new java.util.ArrayList<>();
private final java.util.Set<Integer> chainedTargets = new java.util.HashSet<>();
private int callDepth = 0;
// Graphic Cursor (Light-Pen) state
private boolean graphicsCursorActive = false;
private int graphicCursorX = 0;
private int graphicCursorY = 0;
public GocaDecoder(GraphicsPlane plane) {
this.plane = plane;
}
@@ -73,14 +88,51 @@ public class GocaDecoder {
return curY;
}
public synchronized boolean isGraphicsCursorActive() {
return graphicsCursorActive;
}
public synchronized void setGraphicsCursorActive(boolean active) {
this.graphicsCursorActive = active;
}
public synchronized int getGraphicCursorX() {
return graphicCursorX;
}
public synchronized int getGraphicCursorY() {
return graphicCursorY;
}
public synchronized void setGraphicCursorPosition(int x, int y) {
this.graphicCursorX = x;
this.graphicCursorY = y;
}
public synchronized void setGraphicCursorFromPixel(int px, int py) {
if (plane != null) {
this.graphicCursorX = plane.unmapX(px);
this.graphicCursorY = plane.unmapY(py);
}
}
public synchronized void resetDefaults() {
curX = 0;
curY = 0;
graphicsCursorActive = false;
graphicCursorX = 0;
graphicCursorY = 0;
segmentStore.clear();
segmentChainMap.clear();
segmentOrderList.clear();
chainedTargets.clear();
resetAttributes();
}
public synchronized void resetAttributes() {
curColor = getColor(0);
bgMix = 2; // BMX_OVERPAINT
bgColor = GocaConstants.GOCA_COLORS[8]; // Black
lineType = GocaConstants.LT_SOLID;
lineWidth = GocaConstants.LW_NORMAL;
markerType = GocaConstants.MK_PLUS;
@@ -92,6 +144,8 @@ public class GocaDecoder {
charAngle = 0.0;
charSet = 0;
inArea = false;
areaDrawBoundary = true;
areaFill = true;
areaPointsX.clear();
areaPointsY.clear();
inImage = false;
@@ -115,14 +169,14 @@ public class GocaDecoder {
order == GocaConstants.G_GSBMX || order == GocaConstants.G_GSFLW ||
order == GocaConstants.G_GSLT || order == GocaConstants.G_GSLW ||
order == GocaConstants.G_GSMS || order == GocaConstants.G_GSPT ||
order == GocaConstants.G_GSMT || order == GocaConstants.G_GSCS ||
order == GocaConstants.G_GSMP || order == GocaConstants.G_GSCD ||
order == GocaConstants.G_GSCC || order == GocaConstants.G_GSMS_SET ||
order == GocaConstants.G_GBAR) {
order == GocaConstants.G_GSMT || order == GocaConstants.G_GSMCEL ||
order == GocaConstants.G_GSCS || order == GocaConstants.G_GSMP ||
order == GocaConstants.G_GSCD || order == GocaConstants.G_GSCC ||
order == GocaConstants.G_GSMS_SET || order == GocaConstants.G_GBAR) {
return 2;
}
if (order == GocaConstants.G_GSAP || order == GocaConstants.G_GBIMG || order == 0x91) {
return 10;
if (order == GocaConstants.G_GCALL) { // Call Segment (0x2A <32-bit segment ID>)
return 5;
}
if (idx + 1 >= end) {
return -1;
@@ -130,6 +184,61 @@ public class GocaDecoder {
return (data[idx + 1] & 0xFF) + 2;
}
private void indexSegments(byte[] data, int offset, int length) {
int idx = offset;
int end = offset + length;
while (idx < end) {
int order = data[idx] & 0xFF;
if (order == GocaConstants.G_BEGSEGM) {
int segStart = idx;
int segLen = getOrderLength(data, idx, end);
if (segLen <= 0 || idx + 5 >= end) {
break;
}
int segId = ((data[idx + 2] & 0xFF) << 24) |
((data[idx + 3] & 0xFF) << 16) |
((data[idx + 4] & 0xFF) << 8) |
(data[idx + 5] & 0xFF);
int nextId = 0;
if (segLen >= 14 && (data[idx + 1] & 0xFF) >= 12) {
nextId = ((data[idx + 10] & 0xFF) << 24) |
((data[idx + 11] & 0xFF) << 16) |
((data[idx + 12] & 0xFF) << 8) |
(data[idx + 13] & 0xFF);
}
int searchIdx = idx + segLen;
while (searchIdx < end) {
int o = data[searchIdx] & 0xFF;
int oLen = getOrderLength(data, searchIdx, end);
if (oLen <= 0) break;
if (o == GocaConstants.G_ENDSEGM) {
searchIdx += oLen;
break;
}
searchIdx += oLen;
}
int fullSegLen = searchIdx - segStart;
if (fullSegLen > 0 && segStart + fullSegLen <= end) {
byte[] segBytes = new byte[fullSegLen];
System.arraycopy(data, segStart, segBytes, 0, fullSegLen);
segmentStore.put(segId, segBytes);
segmentOrderList.add(segId);
if (nextId != 0) {
segmentChainMap.put(segId, nextId);
chainedTargets.add(nextId);
}
}
idx = searchIdx;
} else {
int oLen = getOrderLength(data, idx, end);
if (oLen <= 0) break;
idx += oLen;
}
}
}
/**
* Decodes a stream of GOCA drawing orders.
*/
@@ -155,6 +264,15 @@ public class GocaDecoder {
end = offset + length;
}
if (callDepth == 0) {
indexSegments(inputData, idx, end - idx);
}
decodeStreamDirect(inputData, idx, end - idx);
}
private void decodeStreamDirect(byte[] inputData, int idx, int length) {
int end = idx + length;
while (idx < end) {
int order = inputData[idx] & 0xFF;
int orderLen = getOrderLength(inputData, idx, end);
@@ -225,6 +343,22 @@ public class GocaDecoder {
idx += orderLen;
break;
}
case GocaConstants.G_GCALL: { // Call Segment (0x2A)
if (callDepth < 16 && idx + 4 < end) {
int targetSegId = ((inputData[idx + 1] & 0xFF) << 24) |
((inputData[idx + 2] & 0xFF) << 16) |
((inputData[idx + 3] & 0xFF) << 8) |
(inputData[idx + 4] & 0xFF);
byte[] targetSeg = segmentStore.get(targetSegId);
if (targetSeg != null) {
callDepth++;
decodeStream(targetSeg, 0, targetSeg.length);
callDepth--;
}
}
idx += orderLen;
break;
}
case GocaConstants.G_GSCA: { // Set Character Angle (0x34)
if (payloadLen >= 4 && idx + 5 < end) {
int ax = readCoord(inputData, idx + 2);
@@ -312,7 +446,6 @@ public class GocaDecoder {
}
case 0x04:
case GocaConstants.G_GSMX:
case GocaConstants.G_GSBMX:
case GocaConstants.G_GSFLW:
case GocaConstants.G_GSMP:
case GocaConstants.G_GSCC:
@@ -321,9 +454,17 @@ public class GocaDecoder {
idx += orderLen;
break;
}
case GocaConstants.G_GSBMX: { // Set Background Mix (0x0D)
bgMix = inputData[idx + 1] & 0xFF;
idx += orderLen;
break;
}
case GocaConstants.G_GBAR: { // Begin Area (0x68)
int flags = inputData[idx + 1] & 0xFF;
beginArea((flags & 0x40) != 0);
boolean drawBoundary = (flags & 0x80) != 0 || (flags == 0);
boolean fill = (flags == 0) || (flags & 0x40) != 0 ||
(pattern >= 1 && pattern <= 14);
beginArea(drawBoundary, fill);
idx += orderLen;
break;
}
@@ -426,7 +567,7 @@ public class GocaDecoder {
break;
}
case GocaConstants.G_GBIMG: { // Begin Image (0xD1)
if (idx + 9 < end) {
if (payloadLen >= 8 && idx + 2 + payloadLen <= end) {
int x = readCoord(inputData, idx + 2);
int y = readCoord(inputData, idx + 4);
int w = readCoord(inputData, idx + 6);
@@ -468,41 +609,57 @@ public class GocaDecoder {
int order = data[idx] & 0xFF;
switch (order) {
case GocaConstants.P_NOP1:
case GocaConstants.P_ATTCUR:
case GocaConstants.P_DETCUR:
case GocaConstants.P_NOP1: {
idx += (idx + 1 < end && data[idx + 1] == 0) ? 2 : 1;
break;
}
case GocaConstants.P_ATTCUR: { // 0x08: Attach Graphic Cursor
this.graphicsCursorActive = true;
idx += 2;
break;
}
case GocaConstants.P_DETCUR: { // 0x09: Detach Graphic Cursor
this.graphicsCursorActive = false;
idx += 2;
break;
}
case GocaConstants.P_STOPDR: {
idx++;
idx += 2;
break;
}
case GocaConstants.P_ERASE: { // 0x0A: Erase Graphics Presentation Space
plane.clear();
resetDefaults();
idx++;
idx += 2;
break;
}
case GocaConstants.P_BEGPROC: { // 0x30: Begin Procedure (length 12)
int len = 12;
if (idx + 1 < end && data[idx + 1] != 0) {
len = (data[idx + 1] & 0xFF) + 2;
}
idx += len;
idx += 12;
break;
}
case GocaConstants.P_SCUDEF: { // 0x21: Set Current Defaults
case GocaConstants.P_SCUDEF: { // 0x21: Drawing Process Control / Segment Execute
if (idx + 1 < end) {
int len = (data[idx + 1] & 0xFF) + 2;
idx += len;
} else {
idx++;
}
break;
}
case GocaConstants.P_SETCUR: { // 0x31: Set Graphic Cursor Position
if (idx + 5 <= end) {
this.graphicCursorX = readCoord(data, idx + 2);
this.graphicCursorY = readCoord(data, idx + 4);
}
if (idx + 1 < end) {
int len = data[idx + 1] & 0xFF;
if (idx + 2 + len <= end) {
decodeStream(data, idx + 2, len);
}
idx += 2 + len;
} else {
idx++;
}
break;
}
case GocaConstants.P_COMT:
case GocaConstants.P_SETCUR: {
case GocaConstants.P_COMT: {
if (idx + 1 < end) {
int len = data[idx + 1] & 0xFF;
idx += 2 + len;
@@ -524,9 +681,10 @@ public class GocaDecoder {
}
}
private void beginArea(boolean drawBoundary) {
private void beginArea(boolean drawBoundary, boolean fill) {
this.inArea = true;
this.areaDrawBoundary = drawBoundary;
this.areaFill = fill;
this.fillColor = this.curColor;
this.areaPointsX.clear();
this.areaPointsY.clear();
@@ -547,7 +705,8 @@ public class GocaDecoder {
py[i] = plane.mapY(areaPointsY.get(i));
}
plane.fillArea(px, py, n, fillColor, pattern, areaDrawBoundary, curColor, lineType, lineWidth);
plane.fillArea(px, py, n, fillColor, areaFill ? pattern : GocaConstants.PT_EMPTY,
areaDrawBoundary, curColor, lineType, lineWidth, bgMix, bgColor);
inArea = false;
areaPointsX.clear();
areaPointsY.clear();
@@ -555,10 +714,13 @@ public class GocaDecoder {
private void addAreaPoint(int x, int y) {
if (inArea) {
int sz = areaPointsX.size();
if (sz == 0 || areaPointsX.get(sz - 1) != x || areaPointsY.get(sz - 1) != y) {
areaPointsX.add(x);
areaPointsY.add(y);
}
}
}
private void beginImage(int x, int y, int w, int h) {
this.inImage = true;
@@ -0,0 +1,59 @@
package org.lib3270j.graphics;
/**
* Builds the 56-byte IBM 3179G / 3270G Graphic Input Structured Field.
* Used for light-pen and graphics cursor interactive input per IBM HOD / GDDM specifications.
*/
public class GraphicInputBuilder {
// 56-byte template mask from IBM Host On-Demand (HODInput.java)
private static final byte[] MASK = new byte[] {
0x00, 0x34, 0x0F, 0x0F, 0x00, (byte) 0xC0, 0x00, 0x40,
0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x23, 0x00, 0x23, 0x00, 0x00, 0x00, 0x1F, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04,
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte) 0x80, 0x00
};
/**
* Builds the 56-byte Graphic Input Structured Field.
*
* @param gocaX GOCA signed X coordinate (-xMax..+xMax)
* @param gocaY GOCA signed Y coordinate (-yMax..+yMax)
* @param aidCode The 3270 AID code (e.g. 0x7D for ENTER, 0xF3 for PF3)
* @param isMouseAction true if triggered directly by mouse button press, false for keyboard AID
* @param isShift true if shift key was down
* @param isCtrl true if ctrl key was down
* @return 56-byte payload
*/
public static byte[] buildGraphicInput(int gocaX, int gocaY, int aidCode,
boolean isMouseAction, boolean isShift, boolean isCtrl) {
byte[] sf = new byte[MASK.length];
System.arraycopy(MASK, 0, sf, 0, MASK.length);
// Byte 24-25: GOCA X coordinate (signed 16-bit big-endian)
sf[24] = (byte) ((gocaX >> 8) & 0xFF);
sf[25] = (byte) (gocaX & 0xFF);
// Byte 26-27: GOCA Y coordinate (signed 16-bit big-endian)
sf[26] = (byte) ((gocaY >> 8) & 0xFF);
sf[27] = (byte) (gocaY & 0xFF);
if (isMouseAction) {
sf[31] = 0x04;
sf[33] = 0x04;
sf[34] = isShift ? (byte) 0x80 : (isCtrl ? (byte) 0x40 : 0x00);
sf[35] = (byte) (aidCode == 2 ? 0x02 : 0x01); // Button 1 = Pick
} else {
// Keyboard AID (Enter, PF keys)
sf[31] = 0x07;
sf[33] = 0x07;
sf[34] = (byte) 0xFF;
sf[35] = (byte) (aidCode & 0xFF);
}
return sf;
}
}
@@ -97,9 +97,14 @@ public class GraphicsPlane {
return canvasHeight;
}
public void setScreenDimensions(int cols, int rows) {
public synchronized void setScreenDimensions(int cols, int rows) {
this.screenCols = cols > 0 ? cols : 80;
this.screenRows = rows > 0 ? rows : 24;
int targetW = this.screenCols * 9;
int targetH = this.screenRows * 12;
if (this.canvasWidth != targetW || this.canvasHeight != targetH) {
resize(targetW, targetH);
}
}
public int getScreenCols() {
@@ -124,12 +129,32 @@ public class GraphicsPlane {
* Maps a 3179G / GOCA signed coordinate (centered at screen midpoint, bottom-up) to canvas pixel Y (top-down).
*/
public int mapY(int gocaY) {
int nominalHeight = screenRows * 16;
int nominalHeight = screenRows * 12;
int yMax = (nominalHeight - 1) / 2;
int ny = yMax - gocaY;
return (int) Math.round((double) ny * canvasHeight / nominalHeight);
}
/**
* Maps a canvas pixel X coordinate back to GOCA signed coordinate (-xMax..+xMax).
*/
public int unmapX(int px) {
int nominalWidth = screenCols * 9;
int xMax = (nominalWidth - 1) / 2 + ((nominalWidth - 1) % 2 != 0 ? 1 : 0);
int nx = (int) Math.round((double) px * nominalWidth / (canvasWidth > 0 ? canvasWidth : 1));
return nx - xMax;
}
/**
* Maps a canvas pixel Y coordinate (top-down) back to GOCA signed coordinate (bottom-up).
*/
public int unmapY(int py) {
int nominalHeight = screenRows * 12;
int yMax = (nominalHeight - 1) / 2;
int ny = (int) Math.round((double) py * nominalHeight / (canvasHeight > 0 ? canvasHeight : 1));
return yMax - ny;
}
/**
* Safely plots a pixel at (x, y).
*/
@@ -286,9 +311,19 @@ public class GraphicsPlane {
*/
public synchronized void fillArea(int[] px, int[] py, int numPoints, int fillColorArgb, int pattern,
boolean drawBoundary, int boundaryColorArgb, int lineType, int lineWidth) {
fillArea(px, py, numPoints, fillColorArgb, pattern, drawBoundary, boundaryColorArgb, lineType, lineWidth, 2, 0xFF000000);
}
/**
* Fills a closed polygon area with a solid color or hatching pattern and background mix.
*/
public synchronized void fillArea(int[] px, int[] py, int numPoints, int fillColorArgb, int pattern,
boolean drawBoundary, int boundaryColorArgb, int lineType, int lineWidth,
int bgMix, int bgColorArgb) {
if (px == null || py == null || numPoints < 3) return;
int fill = (fillColorArgb != 0) ? fillColorArgb : 0xFFFFFFFF;
int bg = bgColorArgb;
if (pattern != GocaConstants.PT_EMPTY) {
// Find polygon vertical bounds
@@ -323,12 +358,14 @@ public class GraphicsPlane {
int rightX = Math.min(canvasWidth - 1, nodeX.get(i + 1));
for (int x = leftX; x <= rightX; x++) {
if (pattern == GocaConstants.PT_SOLID || pattern == GocaConstants.PT_DEFAULT || pattern > 16) {
if (pattern == GocaConstants.PT_SOLID || pattern >= 16) {
setPixel(x, y, fill);
} else {
int b = patRows[y & 7] & 0xFF;
if (((b >> (7 - (x & 7))) & 1) != 0) {
setPixel(x, y, fill);
} else if (bgMix != 0) { // BMX_OVERPAINT (opaque background)
setPixel(x, y, bg);
}
}
}
@@ -506,13 +543,14 @@ public class GraphicsPlane {
public synchronized void drawImage(int x, int y, int width, int height, byte[] imageData, int fgColorArgb) {
if (imageData == null || width <= 0 || height <= 0) return;
int fgColor = (fgColorArgb != 0) ? fgColorArgb : 0xFFFFFFFF;
int bytesPerRow = (width + 7) / 8;
for (int row = 0; row < height; row++) {
int rowOffset = row * bytesPerRow;
for (int col = 0; col < width; col++) {
int bitIndex = row * width + col;
int byteIdx = bitIndex / 8;
int byteIdx = rowOffset + (col / 8);
if (byteIdx < imageData.length) {
boolean bit = ((imageData[byteIdx] >> (7 - (bitIndex % 8))) & 1) != 0;
boolean bit = ((imageData[byteIdx] >> (7 - (col % 8))) & 1) != 0;
if (bit) {
setPixel(x + col, y + row, fgColor);
}
@@ -87,8 +87,11 @@ public class ProgramSymbolManager {
}
ProgramSymbolSet set = lcidMap[lcid];
if (set == null) {
for (ProgramSymbolSet s : sets) {
if (s != null && s.getLcid() == lcid) return s;
}
for (ProgramSymbolSet s : stagingSets) {
if (s.getLcid() == lcid) return s;
if (s != null && s.getLcid() == lcid) return s;
}
}
return set;
@@ -98,18 +101,7 @@ public class ProgramSymbolManager {
* Retrieves a specific symbol glyph by LCID and character code point (0x40 - 0xFE).
*/
public ProgramSymbolSet.SymbolSlot getSymbol(int lcid, int codePoint) {
if (lcid <= 0 || lcid >= 256) {
return null;
}
ProgramSymbolSet set = lcidMap[lcid];
if (set == null) {
for (ProgramSymbolSet s : stagingSets) {
if (s.getLcid() == lcid) {
set = s;
break;
}
}
}
ProgramSymbolSet set = getSymbolSet(lcid);
if (set == null) {
return null;
}
@@ -153,7 +145,7 @@ public class ProgramSymbolManager {
}
boolean isTriplePlane = (rws >= 4 && rws <= 7);
ProgramSymbolSet set = isTriplePlane ? stagingSets[setIndex] : sets[setIndex];
ProgramSymbolSet set = sets[setIndex];
int extHeaderLen = 0;
int cellWidth = defaultCellWidth;
@@ -176,7 +168,7 @@ public class ProgramSymbolManager {
}
set.setLcid(lcid);
if (!isTriplePlane && lcid > 0 && lcid < 256) {
if (lcid > 0 && lcid < 256) {
lcidMap[lcid] = set;
}
@@ -32,11 +32,20 @@ public class InputProcessor {
}
private org.lib3270j.graphics.GraphicsPlane graphicsPlane;
private org.lib3270j.graphics.GocaDecoder gocaDecoder;
public void setGraphicsPlane(org.lib3270j.graphics.GraphicsPlane gp) {
this.graphicsPlane = gp;
}
public void setGocaDecoder(org.lib3270j.graphics.GocaDecoder gd) {
this.gocaDecoder = gd;
}
public org.lib3270j.graphics.GocaDecoder getGocaDecoder() {
return gocaDecoder;
}
public enum OiaStatus {
NOT_CONNECTED("OFFLINE"),
X_SYSTEM("X SYSTEM"),
@@ -152,13 +161,18 @@ public class InputProcessor {
}
screen.setCursorAddress(baddr);
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
/**
* Send an AID key (Enter, PF1-24, PA1-3, Clear).
*/
public void sendAid(int aidCode) {
if (keyboardLocked && aidCode != AID_CLEAR) return;
System.err.println("sendAid called: 0x" + Integer.toHexString(aidCode) + " locked=" + keyboardLocked);
if (keyboardLocked && aidCode != AID_CLEAR) {
System.err.println("Keyboard locked, dropping AID");
return;
}
lastAid = aidCode;
setKeyboardLocked(true);
@@ -206,20 +220,36 @@ public class InputProcessor {
int nextRowAddr = ((row + 1) % screen.getRows()) * cols;
screen.setCursorAddress(nextRowAddr);
screen.markAllChanged();
screen.updateDisplaySnapshot();
setKeyboardLocked(false);
return;
}
if (aidCode == AID_PA1 || aidCode == AID_PA2 || aidCode == AID_PA3) {
// PA keys: send AID + cursor address only (no modified data)
// PA keys: send AID + optional PID + cursor address only (no modified data)
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(aidCode);
byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols());
byte[] data = new byte[] { (byte) aidCode, caddr[0], caddr[1] };
sendAidResponse(data);
out.write(caddr[0] & 0xFF);
out.write(caddr[1] & 0xFF);
sendAidResponse(out.toByteArray());
return;
}
// Enter, PF keys: send AID + cursor address + modified field data
// Enter, PF keys, PA keys: send AID + optional PID + cursor address + modified field data
ByteArrayOutputStream out = new ByteArrayOutputStream();
// If GDDM attached graphic cursor (interactive graphics / light-pen mode):
if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive() && aidCode != AID_CLEAR) {
int gx = gocaDecoder.getGraphicCursorX();
int gy = gocaDecoder.getGraphicCursorY();
byte[] sf = org.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(gx, gy, aidCode, false, false, false);
out.write(AID_SF); // 0x88
try {
out.write(sf);
} catch (java.io.IOException ignored) {}
}
out.write(aidCode);
byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols());
@@ -265,28 +295,16 @@ public class InputProcessor {
}
}
} else {
// Unformatted screen in 3270 mode (e.g. line-mode console):
// Send AID + cursor address + only the active input line (row containing cursor)
int cols = screen.getCols();
int curAddr = screen.getCursorAddress();
int row = curAddr / cols;
int rowStart = row * cols;
int rowEnd = rowStart + cols;
// Find last non-null, non-blank character on the current line
int lastChar = rowStart - 1;
for (int i = rowEnd - 1; i >= rowStart; i--) {
int ec = screen.getCell(i).ec & 0xFF;
if (ec != 0x00 && ec != 0x40) {
lastChar = i;
break;
}
}
if (lastChar >= rowStart) {
for (int i = rowStart; i <= lastChar; i++) {
// Unformatted screen in 3270 mode:
// Send AID + cursor address + all non-null characters on the screen, suppressing trailing nulls per line
// or we can just send everything up to the last non-null on the screen.
// IBM spec: "all alphanumeric characters... Nulls are suppressed."
// Actually, the simplest is to send everything, but suppress nulls.
int size = screen.getRows() * screen.getCols();
for (int i = 0; i < size; i++) {
int b = screen.getCell(i).ec & 0xFF;
out.write(b != 0 ? b : 0x40);
if (b != 0x00) {
out.write(b);
}
}
}
@@ -302,6 +320,36 @@ public class InputProcessor {
}
}
/**
* Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G.
*/
public void sendGraphicMouseAid(int aidCode, int button, boolean isShift, boolean isCtrl) {
if (fsm == null || !fsm.getConnectionState().isFullSession()) {
return;
}
if (isKeyboardLocked()) {
return;
}
setKeyboardLocked(true);
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) {
int gx = gocaDecoder.getGraphicCursorX();
int gy = gocaDecoder.getGraphicCursorY();
byte[] sf = org.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(gx, gy, aidCode, true, isShift, isCtrl);
out.write(AID_SF);
try {
out.write(sf);
} catch (java.io.IOException ignored) {}
}
out.write(aidCode);
byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols());
out.write(caddr[0] & 0xFF);
out.write(caddr[1] & 0xFF);
sendAidResponse(out.toByteArray());
}
// ========== Cursor movement ==========
public void cursorUp() {
@@ -309,6 +357,7 @@ public class InputProcessor {
addr -= screen.getCols();
if (addr < 0) addr += screen.getRows() * screen.getCols();
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
}
public void cursorDown() {
@@ -316,18 +365,21 @@ public class InputProcessor {
addr += screen.getCols();
if (addr >= screen.getRows() * screen.getCols()) addr -= screen.getRows() * screen.getCols();
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
}
public void cursorLeft() {
int addr = screen.getCursorAddress();
addr = screen.decrementAddress(addr);
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
}
public void cursorRight() {
int addr = screen.getCursorAddress();
addr = screen.incrementAddress(addr);
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
}
public void cursorHome() {
@@ -336,11 +388,13 @@ public class InputProcessor {
} else {
screen.setCursorAddress(0);
}
screen.updateDisplaySnapshot();
}
public void tab() {
int addr = screen.findNextUnprotected(screen.getCursorAddress());
screen.setCursorAddress(addr);
screen.updateDisplaySnapshot();
}
public int getLastAid() { return lastAid; }
@@ -348,6 +402,7 @@ public class InputProcessor {
public void setCursorAddress(int baddr) {
screen.setCursorAddress(baddr);
screen.updateDisplaySnapshot();
}
public void backTab() {
@@ -361,6 +416,7 @@ public class InputProcessor {
if (screen.getCell(addr).isFieldAttribute()) {
if (!faIsProtected(screen.getCell(addr).fa & 0xFF)) {
screen.setCursorAddress(screen.incrementAddress(addr));
screen.updateDisplaySnapshot();
return;
}
}
@@ -377,6 +433,7 @@ public class InputProcessor {
ea.ucs4 = 0;
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
return;
}
int addr = screen.getCursorAddress();
@@ -398,6 +455,7 @@ public class InputProcessor {
screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY);
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
public void deleteChar() {
@@ -426,6 +484,7 @@ public class InputProcessor {
screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY);
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
public void backspace() {
@@ -439,6 +498,7 @@ public class InputProcessor {
if (keyboardLocked) return;
screen.eraseAllUnprotected();
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
/** Move cursor to first unprotected field on next line (Newline key in 3270). */
@@ -455,6 +515,7 @@ public class InputProcessor {
}
screen.setCursorAddress(target);
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
/** Insert Duplicate (DUP) code and advance to next field. */
@@ -472,6 +533,8 @@ public class InputProcessor {
screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY);
}
tab();
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
/** Insert Field Mark (FM) code. */
@@ -495,6 +558,7 @@ public class InputProcessor {
}
screen.setCursorAddress(baddr);
screen.markAllChanged();
screen.updateDisplaySnapshot();
}
/** Attention key (sends Telnet IP). */
@@ -571,6 +635,7 @@ public class InputProcessor {
screen.getCell(i).ucs4 = 0;
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
return size;
}
@@ -608,6 +673,7 @@ public class InputProcessor {
screen.getCell(faAddr).fa = (byte) (screen.getCell(faAddr).fa | FA_MODIFY);
}
screen.markAllChanged();
screen.updateDisplaySnapshot();
return fieldLen;
}
@@ -76,6 +76,7 @@ public final class TelnetConstants {
case TELOPT_BINARY: return "BINARY";
case TELOPT_ECHO: return "ECHO";
case TELOPT_SGA: return "SGA";
case TELOPT_TM: return "TIMING-MARK";
case TELOPT_TTYPE: return "TTYPE";
case TELOPT_EOR: return "EOR";
case TELOPT_NAWS: return "NAWS";
@@ -25,6 +25,9 @@ public class ScreenBuffer {
private boolean formatted; // Screen has at least one field attribute?
private byte replyMode = SF_SRM_FIELD;
private int activePartition = 0; // 0 = implicit partition
private boolean explicitPartitionActive = false;
// Change tracking
private boolean screenChanged;
private int firstChanged = -1;
@@ -60,6 +63,7 @@ public class ScreenBuffer {
defaultFA.ic = 1;
allocateBuffers();
updateDisplaySnapshot();
}
private void allocateBuffers() {
@@ -139,7 +143,7 @@ public class ScreenBuffer {
public boolean isScreenAlt() { return screenAlt; }
/** Update alternate dimensions from BIND image. Re-allocates buffers if needed. */
public void setAlternateDimensions(int newAltRows, int newAltCols) {
public synchronized void setAlternateDimensions(int newAltRows, int newAltCols) {
if (newAltRows == altRows && newAltCols == altCols) return;
this.altRows = newAltRows;
this.altCols = newAltCols;
@@ -149,11 +153,15 @@ public class ScreenBuffer {
this.maxCols = Math.max(maxCols, newAltCols);
allocateBuffers();
}
updateDisplaySnapshot();
}
// ========== Cursor ==========
public int getCursorAddress() { return cursorAddress; }
public void setCursorAddress(int addr) { this.cursorAddress = addr; }
public synchronized void setCursorAddress(int addr) {
this.cursorAddress = addr;
this.displayCursorAddress = addr;
}
public int getCursorRow() { return cursorAddress / cols; }
public int getCursorCol() { return cursorAddress % cols; }
@@ -163,31 +171,53 @@ public class ScreenBuffer {
public byte getReplyMode() { return replyMode; }
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 boolean isExplicitPartitionActive() { return explicitPartitionActive; }
// ========== Screen erase ==========
/**
* Perform an erase, optionally using the alternate screen size.
*/
public void erase(boolean alt) {
public synchronized void erase(boolean alt) {
clear();
int newRows = alt ? altRows : defRows;
int newCols = alt ? altCols : defCols;
if (alt == screenAlt && rows == newRows && cols == newCols) {
updateDisplaySnapshot();
return;
}
rows = newRows;
cols = newCols;
screenAlt = alt;
updateDisplaySnapshot();
}
public void setFieldAttribute(int pos, byte fa) {
ExtendedAttribute ea = buffer[pos];
ea.clear();
ea.fa = fa;
if (!formatted) {
System.err.println("SCREEN BECAME FORMATTED at pos " + pos);
}
formatted = true;
screenChanged = true;
// The display logic needs to render this attribute character
ea.ec = 0x00; // Typically null character for the attribute space itself
}
/** Clear the entire buffer. */
public void clear() {
public synchronized void clear() {
for (ExtendedAttribute ea : buffer) {
ea.clear();
}
cursorAddress = 0;
bufferAddress = 0;
formatted = false;
replyMode = SF_SRM_FIELD;
activePartition = 0;
explicitPartitionActive = false;
screenChanged = true;
defaultFg = 0x00;
@@ -196,12 +226,13 @@ public class ScreenBuffer {
defaultCs = 0x00;
defaultIc = 0x00;
replyMode = SF_SRM_FIELD;
updateDisplaySnapshot();
}
/**
* Erase all unprotected fields.
*/
public void eraseAllUnprotected() {
public synchronized void eraseAllUnprotected() {
int size = rows * cols;
boolean inUnprotected = false;
@@ -229,6 +260,7 @@ public class ScreenBuffer {
// Move cursor to first unprotected field
cursorAddress = findNextUnprotected(0);
screenChanged = true;
updateDisplaySnapshot();
}
// ========== Field attribute navigation ==========
@@ -322,7 +354,7 @@ public class ScreenBuffer {
// ========== Setters for model reconfiguration ==========
public void setDimensions(int maxRows, int maxCols, int defRows, int defCols,
public synchronized void setDimensions(int maxRows, int maxCols, int defRows, int defCols,
int altRows, int altCols) {
this.maxRows = maxRows;
this.maxCols = maxCols;
@@ -333,6 +365,7 @@ public class ScreenBuffer {
this.rows = defRows;
this.cols = defCols;
allocateBuffers();
updateDisplaySnapshot();
}
public void translateToUnicode() {
@@ -8,6 +8,7 @@ import org.lib3270j.listener.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
@@ -59,6 +60,32 @@ public class TelnetFSM {
private int responseRequired = RSF_NO_RESPONSE;
private boolean deferredWillTtype;
private boolean tn3270eDeviceTypeSent;
private int ttypeIndex = 0;
private List<String> getCandidateTerminalTypes() {
List<String> list = new ArrayList<>();
if (config.getTerminalName() != null && !config.getTerminalName().trim().isEmpty()) {
list.add(config.getTerminalName().trim());
return list;
}
TerminalModel model = config.getModel();
list.add(model.getTerminalType());
list.add(model.getBaseTerminalType());
if (model.isColor()) {
try {
TerminalModel mono = TerminalModel.forModel(model.getModelNumber(), false);
list.add(mono.getTerminalType());
list.add(mono.getBaseTerminalType());
} catch (Exception ignored) {}
}
if (model.getModelNumber() != 2) {
list.add("IBM-3279-2-E");
list.add("IBM-3279-2");
list.add("IBM-3278-2");
}
list.add("UNKNOWN");
return list;
}
// Connection references
private TelnetConnection connection;
@@ -109,6 +136,7 @@ public class TelnetFSM {
tn3270eBound = false;
eXmitSeq = 0;
deferredWillTtype = false;
ttypeIndex = 0;
ibuf.reset();
sbbuf.reset();
@@ -116,6 +144,9 @@ public class TelnetFSM {
eFuncs[FUNC_BIND_IMAGE] = true;
eFuncs[FUNC_RESPONSES] = true;
eFuncs[FUNC_SYSREQ] = true;
eFuncs[FUNC_SNA_SENSE] = true;
eFuncs[FUNC_DATA_STREAM_CTL] = true;
eFuncs[FUNC_CONTENTION_RESOLUTION] = true;
changeState(ConnectionState.TELNET_PENDING);
}
@@ -240,6 +271,11 @@ public class TelnetFSM {
log.fine("RCVD GA");
state = TNS_DATA;
break;
case AYT:
log.info("RCVD AYT - sending acknowledgment");
sendBytes(new byte[] { (byte) IAC, (byte) NOP });
state = TNS_DATA;
break;
case NOP:
log.fine("RCVD NOP");
state = TNS_DATA;
@@ -267,6 +303,11 @@ public class TelnetFSM {
}
break;
case TELOPT_TM:
// RFC 860: Timing Mark - reply with DO TM
sendCommand(DO, opt);
break;
case TELOPT_TN3270E:
if (!config.isTn3270eEnabled()) {
sendCommand(DONT, opt);
@@ -310,6 +351,11 @@ public class TelnetFSM {
}
break;
case TELOPT_TM:
// RFC 860: Timing Mark - reply with WILL TM
sendCommand(WILL, opt);
break;
case TELOPT_TTYPE:
if (!myOpts[opt]) {
myOpts[opt] = true;
@@ -426,8 +472,12 @@ public class TelnetFSM {
private void handleTTypeSB(byte[] data) {
if (data.length >= 2 && data[1] == TELQUAL_SEND) {
// Host asks for terminal type
String termType = config.getEffectiveTerminalType();
// Host asks for terminal type cycle per RFC 1091
List<String> candidates = getCandidateTerminalTypes();
String termType = candidates.get(Math.min(ttypeIndex, candidates.size() - 1));
if (ttypeIndex < candidates.size() - 1) {
ttypeIndex++;
}
log.info("RCVD SB TTYPE SEND - Responding with: " + termType);
ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -538,7 +588,7 @@ public class TelnetFSM {
if (reason == REASON_INV_DEVICE_TYPE || reason == REASON_UNSUPPORTED_REQ) {
// Try fallback model 2 if we were requesting something else
if (config.getModel() != TerminalModel.IBM_3278_2 &&
config.getModel() != TerminalModel.IBM_3279_2) {
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);
@@ -880,6 +930,23 @@ public class TelnetFSM {
tn3270eSubmode = TN3270ESubmode.E_NVT;
break;
case DT_REQUEST:
log.info("Received DT_REQUEST requestFlag=" + requestFlag);
if ((requestFlag & RQF_KEYBOARD_RESTORE) != 0) {
if (dsProcessor != null && dsProcessor.getInputProcessor() != null) {
dsProcessor.getInputProcessor().setKeyboardLocked(false);
}
}
if ((requestFlag & RQF_SIGNAL) != 0) {
for (ScreenUpdateListener l : screenListeners) {
l.onSoundAlarm();
}
}
if (eFuncs[FUNC_RESPONSES] && responseFlag == RSF_ALWAYS_RESPONSE) {
sendTN3270EPositiveResponse(seqNumber);
}
break;
case DT_RESPONSE:
log.fine("Received response, seq=" + seqNumber);
break;
@@ -903,7 +970,7 @@ public class TelnetFSM {
}
}
private void sendTN3270EPositiveResponse(int seqNumber) {
public void sendTN3270EPositiveResponse(int seqNumber) {
byte[] resp = new byte[EH_SIZE + 1];
resp[0] = (byte) DT_RESPONSE;
resp[1] = 0;
@@ -915,6 +982,31 @@ public class TelnetFSM {
sendRecord(resp);
}
public void sendTN3270ENegativeResponse(int seqNumber, int negCode) {
byte[] resp = new byte[EH_SIZE + 1];
resp[0] = (byte) DT_RESPONSE;
resp[1] = 0;
resp[2] = (byte) RSF_NEGATIVE_RESPONSE;
resp[3] = (byte) ((seqNumber >> 8) & 0xFF);
resp[4] = (byte) (seqNumber & 0xFF);
resp[5] = (byte) (negCode & 0xFF);
sendRecord(resp);
}
public void sendTN3270ESnaSenseResponse(int seqNumber, int sense1, int sense2) {
byte[] resp = new byte[EH_SIZE + 2];
resp[0] = (byte) DT_RESPONSE;
resp[1] = 0;
resp[2] = (byte) RSF_SNA_SENSE;
resp[3] = (byte) ((seqNumber >> 8) & 0xFF);
resp[4] = (byte) (seqNumber & 0xFF);
resp[5] = (byte) (sense1 & 0xFF);
resp[6] = (byte) (sense2 & 0xFF);
sendRecord(resp);
}
// ========== Check if we should transition to 3270 mode ==========
private void checkIn3270() {
@@ -232,4 +232,30 @@ public class QueryReplyBuilderTest {
assertEquals(0x07, replies[descOffset + 49] & 0xFF);
assertEquals(0xC0, replies[descOffset + 49 + 1] & 0xFF);
}
@Test
public void testQueryReplyImageAndRpqNames() {
qrBuilder.setGraphicsMode(GraphicsMode.BOTH);
byte[] requested = new byte[] { (byte) QR_IMAGE, (byte) QR_RPQNAMES, (byte) QR_GIMAGE };
byte[] replies = qrBuilder.buildQueryReplies(requested, 80, 43, 80 * 43);
assertNotNull(replies);
assertEquals((byte) AID_SF, replies[0]);
// First SF should be QR_NULL (0xFF) because 0x82 is 3270 Image SF (not supported on 3179G)
int len1 = ((replies[1] & 0xFF) << 8) | (replies[2] & 0xFF);
assertEquals(0x81, replies[3] & 0xFF); // SFID_QREPLY
assertEquals(QR_NULL, replies[4] & 0xFF); // 0xFF
// Second SF should be QR_RPQNAMES (0xA1) matching requested code
int pos2 = 1 + len1;
int len2 = ((replies[pos2] & 0xFF) << 8) | (replies[pos2 + 1] & 0xFF);
assertEquals(0x81, replies[pos2 + 2] & 0xFF);
assertEquals(QR_RPQNAMES, replies[pos2 + 3] & 0xFF); // 0xA1
// Third SF should be QR_GIMAGE (0xB1)
int pos3 = pos2 + len2;
assertEquals(0x81, replies[pos3 + 2] & 0xFF);
assertEquals(QR_GIMAGE, replies[pos3 + 3] & 0xFF); // 0xB1
}
}
@@ -158,6 +158,7 @@ public class GocaDecoderTest {
byte[] stream = out.toByteArray();
decoder.decodeStream(stream, 0, stream.length);
assertTrue(plane.hasContent());
}
@@ -203,7 +204,7 @@ public class GocaDecoderTest {
// 0xA1: Relative Line from Current Position: (+10, +20), (-5, -10)
out.write(GocaConstants.G_GCRLIN);
out.write(0x04); // len = 4 (2 steps)
out.write(0x04); // len = 4 (2 deltas * 2 bytes)
out.write(0x0A); out.write(0x14); // dx = +10, dy = +20
out.write(0xFB); out.write(0xF6); // dx = -5, dy = -10
@@ -217,21 +218,74 @@ public class GocaDecoderTest {
@Test
public void test3179GCoordinateMapping() {
GraphicsPlane plane = new GraphicsPlane(720, 688);
GraphicsPlane plane = new GraphicsPlane(720, 516);
plane.setScreenDimensions(80, 43);
// Screen center (0, 0) should map to canvas center (360, 343)
// Screen center (0, 0) should map to canvas center (360, 257)
assertEquals(360, plane.mapX(0));
assertEquals(343, plane.mapY(0));
assertEquals(257, plane.mapY(0));
// Left edge (-360) should map to 0
assertEquals(0, plane.mapX(-360));
// Right edge (+359) should map to 719
assertEquals(719, plane.mapX(359));
// Top edge (+343) should map to 0
assertEquals(0, plane.mapY(343));
// Bottom edge (-344) should map to 687
assertEquals(687, plane.mapY(-344));
// Top edge (+257) should map to 0
assertEquals(0, plane.mapY(257));
// Bottom edge (-258) should map to 515
assertEquals(515, plane.mapY(-258));
}
@Test
public void testImageOrdersGbimgGimdGeimg() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
ByteArrayOutputStream out = new ByteArrayOutputStream();
// G_GBIMG (0xD1): Begin Image: order, len=8, x=100, y=100, w=16, h=2
out.write(GocaConstants.G_GBIMG);
out.write(0x08);
out.write(0x00); out.write(0x64); // x = 100
out.write(0x00); out.write(0x64); // y = 100
out.write(0x00); out.write(0x10); // w = 16
out.write(0x00); out.write(0x02); // h = 2
// G_GIMD (0x92): Image Data: order, len=4, 4 bytes of bitmap (16x2 pixels = 32 bits = 4 bytes)
out.write(GocaConstants.G_GIMD);
out.write(0x04);
out.write(0xFF); out.write(0x00);
out.write(0xAA); out.write(0x55);
// G_GEIMG (0x91): End Image
out.write(GocaConstants.G_GEIMG);
out.write(0x00);
byte[] stream = out.toByteArray();
decoder.decodeStream(stream, 0, stream.length);
assertTrue(plane.hasContent(), "Expected plane to have content after image decoding");
}
@Test
public void testDirectObjectControlSf24() {
org.lib3270j.screen.ScreenBuffer sb = new org.lib3270j.screen.ScreenBuffer(
org.lib3270j.TerminalModel.IBM_3279_4, new org.lib3270j.charset.EbcdicTranslator());
org.lib3270j.datastream.DataStreamProcessor dsp = new org.lib3270j.datastream.DataStreamProcessor(
sb, new org.lib3270j.charset.EbcdicTranslator());
// First draw something on graphics plane
dsp.getGraphicsPlane().setPixel(10, 10, 0xFFFFFFFF);
assertTrue(dsp.getGraphicsPlane().hasContent());
// Send WSF with SF_OBJCNTL (0x24) containing P_ERASE (0x0A)
byte[] wsf = new byte[] {
(byte) org.lib3270j.protocol.DS3270Constants.CMD_WSF,
0x00, 0x04, // Field length = 4
(byte) org.lib3270j.graphics.GocaConstants.SF_OBJCNTL, // 0x24
(byte) org.lib3270j.graphics.GocaConstants.P_ERASE // 0x0A
};
dsp.processRecord(wsf, 0, wsf.length, false);
assertFalse(dsp.getGraphicsPlane().hasContent(), "Expected graphics plane to be cleared after SF_OBJCNTL P_ERASE");
}
}
@@ -44,10 +44,113 @@ public class InputProcessorTest {
assertEquals('A', screen.getCell(1).ucs4);
assertEquals('B', screen.getCell(2).ucs4);
assertEquals('A', screen.getDisplayCell(1).ucs4);
assertEquals('B', screen.getDisplayCell(2).ucs4);
input.eraseInput();
assertEquals(0, screen.getCell(1).ec);
assertEquals(0, screen.getCell(2).ec);
assertEquals(0, screen.getDisplayCell(1).ec);
assertEquals(0, screen.getDisplayCell(2).ec);
}
@Test
public void testTypeCharacterUpdatesDisplaySnapshot() {
InputProcessor input = new InputProcessor(screen, translator, null);
screen.erase(false);
screen.setCellFA(0, (byte) FA_PRINTABLE); // Unprotected field starting at 1
screen.setCursorAddress(1);
screen.updateDisplaySnapshot(); // Initial snapshot
// Type "logon"
input.typeCharacter('l');
input.typeCharacter('o');
input.typeCharacter('g');
input.typeCharacter('o');
input.typeCharacter('n');
// Verify underlying buffer
assertEquals('l', screen.getCell(1).ucs4);
assertEquals('o', screen.getCell(2).ucs4);
assertEquals('g', screen.getCell(3).ucs4);
assertEquals('o', screen.getCell(4).ucs4);
assertEquals('n', screen.getCell(5).ucs4);
// Verify display snapshot (what TerminalPanel renders)
assertEquals('l', screen.getDisplayCell(1).ucs4);
assertEquals('o', screen.getDisplayCell(2).ucs4);
assertEquals('g', screen.getDisplayCell(3).ucs4);
assertEquals('o', screen.getDisplayCell(4).ucs4);
assertEquals('n', screen.getDisplayCell(5).ucs4);
assertEquals(6, screen.getDisplayCursorAddress());
assertEquals(6, screen.getCursorAddress());
}
@Test
public void testCursorMovementUpdatesDisplayCursorAddress() {
InputProcessor input = new InputProcessor(screen, translator, null);
screen.erase(false);
screen.setCursorAddress(10);
assertEquals(10, screen.getDisplayCursorAddress());
input.cursorRight();
assertEquals(11, screen.getDisplayCursorAddress());
input.cursorLeft();
assertEquals(10, screen.getDisplayCursorAddress());
screen.setCursorAddress(45);
assertEquals(45, screen.getDisplayCursorAddress());
}
@Test
public void testDeleteAndEraseEofUpdateDisplaySnapshot() {
InputProcessor input = new InputProcessor(screen, translator, null);
screen.erase(false);
screen.setCellFA(0, (byte) FA_PRINTABLE);
screen.setCellFA(10, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setCursorAddress(1);
input.typeCharacter('A');
input.typeCharacter('B');
input.typeCharacter('C');
assertEquals('A', screen.getDisplayCell(1).ucs4);
assertEquals('B', screen.getDisplayCell(2).ucs4);
assertEquals('C', screen.getDisplayCell(3).ucs4);
// Backspace over 'C'
input.backspace();
assertEquals(3, screen.getCursorAddress());
assertEquals(3, screen.getDisplayCursorAddress());
assertEquals(0, screen.getDisplayCell(3).ucs4);
// Erase EOF from position 2 ('B')
screen.setCursorAddress(2);
input.eraseEof();
assertEquals(0, screen.getDisplayCell(2).ucs4);
assertEquals('A', screen.getDisplayCell(1).ucs4);
}
@Test
public void testGraphicInputBuilderStructure() {
byte[] sf = org.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(100, -50, AID_ENTER, false, false, false);
assertEquals(56, sf.length);
assertEquals(0x00, sf[0]);
assertEquals(0x34, sf[1]); // Length = 52
assertEquals(0x0F, sf[2]); // SF ID = 0x0F
assertEquals(0x0F, sf[3]); // SF ID = 0x0F
// Coordinates
int x = (sf[24] << 8) | (sf[25] & 0xFF);
int y = (sf[26] << 8) | (sf[27] & 0xFF);
assertEquals(100, (short) x);
assertEquals(-50, (short) y);
// Keyboard AID
assertEquals(0x07, sf[31]);
assertEquals(0x07, sf[33]);
assertEquals((byte) 0xFF, sf[34]);
assertEquals((byte) AID_ENTER, sf[35]);
}
}
@@ -0,0 +1,297 @@
package org.lib3270j.telnet;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.lib3270j.ConnectionConfig;
import org.lib3270j.ConnectionState;
import org.lib3270j.TerminalModel;
import org.lib3270j.charset.EbcdicTranslator;
import org.lib3270j.datastream.DataStreamProcessor;
import org.lib3270j.protocol.TelnetConstants;
import org.lib3270j.screen.ScreenBuffer;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class TelnetFSMTest {
private ConnectionConfig config;
private ScreenBuffer screenBuffer;
private DataStreamProcessor dsProcessor;
private TelnetFSM fsm;
private MockConnection connection;
private static class MockConnection extends TelnetConnection {
final List<byte[]> sentData = new ArrayList<>();
MockConnection(ConnectionConfig config, TelnetFSM fsm) {
super(config, fsm);
}
@Override
public synchronized void sendRaw(byte[] data) {
sentData.add(data.clone());
}
@Override
public synchronized void sendRaw(byte[] data, int offset, int length) {
byte[] b = new byte[length];
System.arraycopy(data, offset, b, 0, length);
sentData.add(b);
}
}
@BeforeEach
public void setup() {
config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, new EbcdicTranslator());
dsProcessor = new DataStreamProcessor(screenBuffer, new EbcdicTranslator());
fsm = new TelnetFSM(config, screenBuffer, dsProcessor);
connection = new MockConnection(config, fsm);
fsm.setConnection(connection);
}
private void feedBytes(int... bytes) {
for (int b : bytes) {
fsm.feedByte(b & 0xFF);
}
}
@Test
public void testHostPrefixParsingPlainTn3270() {
ConnectionConfig c1 = ConnectionConfig.parseHostString("P:mainframe.example.com", 23, TerminalModel.IBM_3279_4);
assertFalse(c1.isTn3270eEnabled(), "P: prefix should disable TN3270E");
assertFalse(c1.isUseTls());
assertEquals("mainframe.example.com", c1.getHost());
ConnectionConfig c2 = ConnectionConfig.parseHostString("plain:zos.net:2323", 23, TerminalModel.IBM_3279_4);
assertFalse(c2.isTn3270eEnabled());
assertEquals("zos.net", c2.getHost());
assertEquals(2323, c2.getPort());
ConnectionConfig c3 = ConnectionConfig.parseHostString("L:P:secure.mainframe.com:992", 0, TerminalModel.IBM_3279_4);
assertTrue(c3.isUseTls(), "L: should enable TLS");
assertFalse(c3.isTn3270eEnabled(), "P: should disable TN3270E");
assertEquals("secure.mainframe.com", c3.getHost());
assertEquals(992, c3.getPort());
ConnectionConfig c4 = ConnectionConfig.parseHostString("non-e:vm.ibm.com", 23, TerminalModel.IBM_3279_4);
assertFalse(c4.isTn3270eEnabled());
assertEquals("vm.ibm.com", c4.getHost());
}
@Test
public void testPlainTn3270NegotiationRejectsTn3270E() {
config.setTn3270eEnabled(false);
fsm.onConnected();
assertEquals(ConnectionState.TELNET_PENDING, fsm.getConnectionState());
// Host offers DO TN3270E -> client must reply WONT TN3270E
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E);
assertFalse(fsm.getMyOpts()[TelnetConstants.TELOPT_TN3270E]);
// Host offers WILL TN3270E -> client must reply DONT TN3270E
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E);
assertFalse(fsm.getHisOpts()[TelnetConstants.TELOPT_TN3270E]);
// Host negotiates standard 3270 options: DO BINARY, WILL BINARY, DO EOR, WILL EOR, DO TTYPE
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_BINARY);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_BINARY);
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_EOR);
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_EOR);
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TTYPE);
// Client should have transitioned to CONNECTED_3270
assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState());
// Host asks for TTYPE
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TTYPE,
TelnetConstants.TELQUAL_SEND, TelnetConstants.IAC, TelnetConstants.SE);
// Verify sent packets contain WONT TN3270E, DONT TN3270E, and TTYPE IS
boolean foundWontTn3270e = false;
boolean foundTtypeIs = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length == 3 && (pkt[0] & 0xFF) == TelnetConstants.IAC &&
(pkt[1] & 0xFF) == TelnetConstants.WONT && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TN3270E) {
foundWontTn3270e = true;
}
if (pkt.length >= 4 && (pkt[0] & 0xFF) == TelnetConstants.IAC &&
(pkt[1] & 0xFF) == TelnetConstants.SB && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TTYPE) {
foundTtypeIs = true;
}
}
assertTrue(foundWontTn3270e, "Expected WONT TN3270E response");
assertTrue(foundTtypeIs, "Expected TTYPE IS response");
}
@Test
public void testTn3270eFunctionsNegotiationWithoutBindImageTransitionsToConnected() {
config.setTn3270eEnabled(true);
fsm.onConnected();
assertEquals(ConnectionState.TELNET_PENDING, fsm.getConnectionState());
// Host offers DO TN3270E -> client accepts with WILL TN3270E
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E);
assertTrue(fsm.getMyOpts()[TelnetConstants.TELOPT_TN3270E]);
// Host responds with DEVICE-TYPE IS IBM-3279-4-E CONNECT LU01
byte[] devTypeIs = new byte[]{
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E,
0x02, 0x04, // OP_DEVICE_TYPE, OP_IS
'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E',
0x01, // OP_CONNECT
'L', 'U', '0', '1',
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SE
};
for (byte b : devTypeIs) fsm.feedByte(b & 0xFF);
// Host responds with FUNCTIONS REQUEST <empty> (e.g. pytn3270 / RFC 2355 server)
byte[] funcsReq = new byte[]{
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E,
0x03, 0x07, // OP_FUNCTIONS, OP_REQUEST
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SE
};
for (byte b : funcsReq) fsm.feedByte(b & 0xFF);
// Per RFC 2355: Client must reply with FUNCTIONS IS, and without BIND-IMAGE, session is bound immediately
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState(),
"Expected CONNECTED_TN3270E when BIND-IMAGE is not negotiated");
// Verify that client sent SB TN3270E FUNCTIONS IS SE
boolean foundFunctionsIs = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length >= 5 && (pkt[0] & 0xFF) == TelnetConstants.IAC &&
(pkt[1] & 0xFF) == TelnetConstants.SB && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TN3270E &&
(pkt[3] & 0xFF) == 0x03 && (pkt[4] & 0xFF) == 0x04) {
foundFunctionsIs = true;
}
}
assertTrue(foundFunctionsIs, "Expected FUNCTIONS IS reply from client upon receiving FUNCTIONS REQUEST");
}
@Test
public void testPlainDataStreamFallbackInTn3270eMode() {
config.setTn3270eEnabled(true);
fsm.onConnected();
// Negotiate TN3270E with empty functions
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E);
byte[] devTypeIs = new byte[]{
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E,
0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E',
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SE
};
for (byte b : devTypeIs) fsm.feedByte(b & 0xFF);
byte[] funcsReq = new byte[]{
(byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E,
0x03, 0x07, (byte) TelnetConstants.IAC, (byte) TelnetConstants.SE
};
for (byte b : funcsReq) fsm.feedByte(b & 0xFF);
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState());
// Now host sends raw 3270 data stream without 5-byte TN3270E header (e.g. EraseWrite 0xF5)
// 0xF5 = EraseWrite, 0xC3 = WCC, 0x11 = SBA, 0x40 0x40 = Pos 0, "HELLO"
EbcdicTranslator trans = new EbcdicTranslator();
String msg = "HELLO";
byte[] rawStream = new byte[3 + 2 + msg.length() + 2];
rawStream[0] = (byte) 0xF5; // EraseWrite
rawStream[1] = (byte) 0xC3; // WCC
rawStream[2] = 0x11; // SBA
rawStream[3] = 0x40; rawStream[4] = 0x40; // addr 0
for (int i = 0; i < msg.length(); i++) {
rawStream[5 + i] = (byte) trans.unicodeToEbcdic(msg.charAt(i));
}
rawStream[rawStream.length - 2] = (byte) TelnetConstants.IAC;
rawStream[rawStream.length - 1] = (byte) TelnetConstants.EOR;
for (byte b : rawStream) fsm.feedByte(b & 0xFF);
// State must have automatically switched to CONNECTED_3270 and screen updated
assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState());
assertEquals('H', trans.ebcdicToUnicode(screenBuffer.getCellEC(0)));
assertEquals('E', trans.ebcdicToUnicode(screenBuffer.getCellEC(1)));
assertEquals('L', trans.ebcdicToUnicode(screenBuffer.getCellEC(2)));
assertEquals('L', trans.ebcdicToUnicode(screenBuffer.getCellEC(3)));
assertEquals('O', trans.ebcdicToUnicode(screenBuffer.getCellEC(4)));
}
@Test
public void testTelnetTimingMarkOption6() {
fsm.onConnected();
connection.sentData.clear();
// Host sends DO TELOPT_TM (DO 6) -> Client must reply WILL TELOPT_TM (WILL 6) per RFC 860
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TM);
boolean foundWillTm = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length == 3 && (pkt[0] & 0xFF) == TelnetConstants.IAC &&
(pkt[1] & 0xFF) == TelnetConstants.WILL && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TM) {
foundWillTm = true;
}
}
assertTrue(foundWillTm, "Expected IAC WILL TELOPT_TM reply upon receiving IAC DO TELOPT_TM");
// Host sends WILL TELOPT_TM (WILL 6) -> Client must reply DO TELOPT_TM (DO 6)
connection.sentData.clear();
feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TM);
boolean foundDoTm = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length == 3 && (pkt[0] & 0xFF) == TelnetConstants.IAC &&
(pkt[1] & 0xFF) == TelnetConstants.DO && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TM) {
foundDoTm = true;
}
}
assertTrue(foundDoTm, "Expected IAC DO TELOPT_TM reply upon receiving IAC WILL TELOPT_TM");
}
@Test
public void testIACAYT() {
fsm.onConnected();
connection.sentData.clear();
// Host sends IAC AYT
feedBytes(TelnetConstants.IAC, TelnetConstants.AYT);
boolean foundNop = false;
for (byte[] pkt : connection.sentData) {
if (pkt.length == 2 && (pkt[0] & 0xFF) == TelnetConstants.IAC && (pkt[1] & 0xFF) == TelnetConstants.NOP) {
foundNop = true;
}
}
assertTrue(foundNop, "Expected IAC NOP acknowledgment for IAC AYT");
}
@Test
public void testTTypeCycling() {
fsm.onConnected();
connection.sentData.clear();
// Enable TTYPE
feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TTYPE);
// First SEND request -> Expect configured type IBM-3279-4-E
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TTYPE,
TelnetConstants.TELQUAL_SEND, TelnetConstants.IAC, TelnetConstants.SE);
byte[] lastPkt = connection.sentData.get(connection.sentData.size() - 1);
String resp1 = new String(lastPkt, 4, lastPkt.length - 6);
assertEquals("IBM-3279-4-E", resp1);
// Second SEND request -> Expect fallback IBM-3279-4
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TTYPE,
TelnetConstants.TELQUAL_SEND, TelnetConstants.IAC, TelnetConstants.SE);
lastPkt = connection.sentData.get(connection.sentData.size() - 1);
String resp2 = new String(lastPkt, 4, lastPkt.length - 6);
assertEquals("IBM-3279-4", resp2);
// Third SEND request -> Expect monochrome fallback IBM-3278-4-E
feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TTYPE,
TelnetConstants.TELQUAL_SEND, TelnetConstants.IAC, TelnetConstants.SE);
lastPkt = connection.sentData.get(connection.sentData.size() - 1);
String resp3 = new String(lastPkt, 4, lastPkt.length - 6);
assertEquals("IBM-3278-4-E", resp3);
}
}
@@ -31,7 +31,7 @@ public class TlsConfigTest {
assertEquals(992, c1.getPort());
// ssl: prefix with explicit port
ConnectionConfig c2 = ConnectionConfig.parseHostString("ssl:zos.local:2323", 0, TerminalModel.IBM_3279_2);
ConnectionConfig c2 = ConnectionConfig.parseHostString("ssl:zos.local:2323", 0, TerminalModel.IBM_3279_4);
assertTrue(c2.isUseTls());
assertEquals("zos.local", c2.getHost());
assertEquals(2323, c2.getPort());