diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java
index bcfef04..894db5e 100644
--- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java
+++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java
@@ -453,7 +453,8 @@ public class SettingsDialog extends JDialog {
JPanel main = new JPanel(new BorderLayout());
String[] actions = {"ENTER", "TAB", "shift TAB", "UP", "DOWN", "LEFT", "RIGHT",
- "HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR"};
+ "HOME", "END", "PAGE_UP", "PAGE_DOWN", "ESCAPE", "INSERT", "DELETE", "BACK_SPACE", "CLEAR",
+ "ERASE_INPUT", "NEWLINE", "DUP", "FIELD_MARK", "ATTN", "SYSREQ", "CURSEL"};
keymapModel = new DefaultTableModel(new Object[]{"Action", "Key Binding"}, 0) {
@Override
@@ -465,7 +466,14 @@ public class SettingsDialog extends JDialog {
// Populate table from Settings or Defaults
for(String act : actions) {
String def = act;
- if(def.equals("PAGE_UP")) def = "PAGE_UP"; // fallback example
+ if (def.equals("ERASE_INPUT")) def = "alt E";
+ else if (def.equals("NEWLINE")) def = "shift ENTER";
+ else if (def.equals("DUP")) def = "alt D";
+ else if (def.equals("FIELD_MARK")) def = "alt M";
+ else if (def.equals("ATTN")) def = "alt A";
+ else if (def.equals("SYSREQ")) def = "alt S";
+ else if (def.equals("CURSEL")) def = "alt Q";
+ else if (def.equals("CLEAR")) def = "alt C";
String current = haus.nightmare.j3270.config.Settings.getKeyBinding(act, def);
tempKeyBindings.put(act, current);
keymapModel.addRow(new Object[]{act, current});
diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java
index 2b04f4d..1331bf2 100644
--- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java
+++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java
@@ -259,10 +259,12 @@ public class TerminalPanel extends JPanel {
int gx = client.getGocaDecoder().getGraphicCursorX();
int gy = client.getGocaDecoder().getGraphicCursorY();
+ int hitSeg = client.getGocaDecoder().findPickedSegment(gx, gy);
+ int hitTag = client.getGocaDecoder().getSegmentTag(hitSeg);
int button = javax.swing.SwingUtilities.isRightMouseButton(e) ? 2 : 1;
System.err.println(String.format(
- "TerminalPanel.mouseReleased: mouse=(%d, %d) offset=(%d, %d) grid=(%dx%d) px=(%d, %d) goca=(%d, %d) btn=%d",
- e.getX(), e.getY(), ox, oy, gridW, gridH, px, py, gx, gy, button
+ "TerminalPanel.mouseReleased: mouse=(%d, %d) offset=(%d, %d) grid=(%dx%d) px=(%d, %d) goca=(%d, %d) hitSeg=%d hitTag=%d btn=%d",
+ e.getX(), e.getY(), ox, oy, gridW, gridH, px, py, gx, gy, hitSeg, hitTag, button
));
client.getInputProcessor().sendGraphicMouseAid(
@@ -278,10 +280,12 @@ public class TerminalPanel extends JPanel {
if (lightPenMode) {
clearSelection();
sb.setCursorAddress(clickAddr);
- boolean result = client.getInputProcessor().lightPenSelect(clickAddr);
+ boolean result = client.lightPenSelect(clickAddr);
if (result) {
refreshScreen();
return;
+ } else {
+ Toolkit.getDefaultToolkit().beep();
}
}
@@ -608,6 +612,7 @@ public class TerminalPanel extends JPanel {
bindKeyToMap(im, "FIELD_MARK", haus.nightmare.j3270.config.Settings.getKeyBinding("FIELD_MARK", "alt M"));
bindKeyToMap(im, "ATTN", haus.nightmare.j3270.config.Settings.getKeyBinding("ATTN", "alt A"));
bindKeyToMap(im, "SYSREQ", haus.nightmare.j3270.config.Settings.getKeyBinding("SYSREQ", "alt S"));
+ bindKeyToMap(im, "CURSEL", haus.nightmare.j3270.config.Settings.getKeyBinding("CURSEL", "alt Q"));
// Copy/Paste bindings — Cmd+C / Cmd+V (macOS) or Ctrl+C / Ctrl+V (others)
int shortcutMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();
@@ -638,6 +643,7 @@ public class TerminalPanel extends JPanel {
am.put("j3270-FIELD_MARK", createAction(this::handleFieldMark));
am.put("j3270-ATTN", createAction(this::handleAttn));
am.put("j3270-SYSREQ", createAction(this::handleSysReq));
+ am.put("j3270-CURSEL", createAction(this::handleCursorSelect));
// Copy/Paste actions
am.put("j3270-COPY", createAction(this::copySelection));
@@ -824,6 +830,17 @@ public class TerminalPanel extends JPanel {
}
}
+ private void handleCursorSelect() {
+ if (client != null && client.getConnectionState().isFullSession()) {
+ boolean selected = client.cursorSelect();
+ if (selected) {
+ refreshScreen();
+ } else {
+ Toolkit.getDefaultToolkit().beep();
+ }
+ }
+ }
+
private void refreshScreen() {
if (client != null) {
client.getScreenBuffer().updateDisplaySnapshot();
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java
index e35cbcd..c375cd1 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java
@@ -223,6 +223,10 @@ public class Telnet3270Client {
public void sysReq() { inputProcessor.sysReq(); }
/** Reset (unlock keyboard). */
public void reset() { inputProcessor.reset(); }
+ /** Trigger 3270 CURSR SEL (Cursor Select) key at current cursor position. */
+ public boolean cursorSelect() { return inputProcessor.cursorSelect(); }
+ /** Trigger Light Pen selection at the specified screen address. */
+ public boolean lightPenSelect(int address) { return inputProcessor.lightPenSelect(address); }
public haus.nightmare.lib3270j.graphics.ProgramSymbolManager getProgramSymbolManager() {
return dsProcessor.getProgramSymbolManager();
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 b270835..7ab5b84 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java
@@ -832,9 +832,15 @@ public class DataStreamProcessor {
programSymbolManager.loadps(psData);
}
break;
+ case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA_SUB: { // 0x0F: Object Control / Data Unit
+ // Per IBM HOD processDataunit(), 0x0F activates graphic cursor and initializes data unit
+ log.info(String.format("SF 0x0F sub=0x0F (Data Unit Object Control len=%d): activating graphics cursor", fieldLen));
+ gocaDecoder.setGraphicsCursorActive(true);
+ notifyScreenUpdated();
+ break;
+ }
case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB: // 0x11: Object Control (Procedure orders)
- case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJPICT_SUB: // 0x10: Object Picture (Picture segments)
- case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA_SUB: { // 0x0F: Object Data (GOCA draw orders)
+ case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJPICT_SUB: { // 0x10: Object Picture (Picture segments)
int flags = (fieldLen >= 6) ? (data[pos + 5] & 0xC0) : 0xC0;
int orderOffset = (fieldLen >= 7) ? (pos + 7) : (pos + 4);
int orderLen = Math.max(0, fieldLen - (orderOffset - pos));
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java
new file mode 100644
index 0000000..487e26f
--- /dev/null
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java
@@ -0,0 +1,206 @@
+package haus.nightmare.lib3270j.graphics;
+
+import java.awt.Point;
+
+/**
+ * Dedicated scaling layer for IBM 3179G / GDDM GOCA graphics presentation space.
+ * Matches IBM Host On-Demand (HODTransform.java, PS3179G.java, HODInput.java).
+ *
+ *
Coordinate System Architecture:
+ *
+ * - GOCA Presentation Space: Centered at (0, 0).
+ * X ranges [-xMax .. +xMax] where width = cols * defaultCharWidth (defsx = 9).
+ * Y ranges [-yMax .. +yMax] where height = rows * defaultCharHeight (defsy = 16 or 12).
+ * (+Y is UP, -Y is DOWN, +X is RIGHT, -X is LEFT).
+ * - Base Presentation Space (px, py): Unsigned top-left origin (0, 0).
+ * px = gx + xMax (ranges 0 .. totalWidth).
+ * py = yMax - gy (ranges 0 .. totalHeight).
+ * - Display Screen / Window Space (sx, sy): Physical Swing pixel coordinates relative
+ * to terminal character grid rendering offset (ox, oy).
+ * sx = ox + (int) Math.round(px * transformX).
+ * sy = oy + (int) Math.round(py * transformY).
+ * where transformX = cellWidth / defaultCharWidth, transformY = cellHeight / defaultCharHeight.
+ *
+ *
+ * Why simple fixed pixel scaling or hardcoded multipliers failed in past iterations:
+ *
+ * - Failure Mode 1: Hardcoding totalHeight = rows * 12 caused coordinate truncation in 43-row mode (Model 4),
+ * capping yMax at 257 instead of 343 and making the top menu / EXIT button unreachable.
+ * - Failure Mode 2: Hardcoding totalHeight = rows * 16 without viewport alignment resulted in yMax = 343.
+ * In ADMDRAW, GDDM defines the entire active drawing area between y = -169 and y = +200 (~370 units total height).
+ * Mapping this into a 688-high canvas placed the top menu at Row 9.2 (middle of the screen) with a large blank void above,
+ * and clicking the visual menu emitted gy = 224 (missing the [187..200] menu bounding box).
+ * - Failure Mode 3: Intermediate raster buffering (e.g. fixed 800x600 canvas stretched to gridW x gridH)
+ * introduced rounding artifacts in bidirectional coordinate conversion (unmapX/unmapY).
+ * - Failure Mode 4: Overlapping dropdown menus (FILE, DRAW, TRANSFORM) lingering simultaneously when
+ * segment retention/clearing was decoupled from GDDM's segment lifecycle.
+ *
+ */
+public class GddmCoordinateTransform {
+
+ public static final int DEFAULT_CHAR_WIDTH = 9; // defsx: 3179G standard character cell width
+ public static final int DEFAULT_CHAR_HEIGHT_16 = 16; // defsy: 3179G standard character cell height
+ public static final int DEFAULT_CHAR_HEIGHT_12 = 12; // defsy: 3279-2 alternate character cell height
+
+ private int defaultCharWidth = DEFAULT_CHAR_WIDTH;
+ private int defaultCharHeight = DEFAULT_CHAR_HEIGHT_16;
+
+ private int screenCols = 80;
+ private int screenRows = 24;
+
+ private double transformX = 1.0;
+ private double transformY = 1.0;
+
+ public GddmCoordinateTransform() {
+ this(80, 24, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16);
+ }
+
+ public GddmCoordinateTransform(int screenCols, int screenRows) {
+ this(screenCols, screenRows, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16);
+ }
+
+ public GddmCoordinateTransform(int screenCols, int screenRows, int defCharWidth, int defCharHeight) {
+ this.screenCols = screenCols > 0 ? screenCols : 80;
+ this.screenRows = screenRows > 0 ? screenRows : 24;
+ this.defaultCharWidth = defCharWidth > 0 ? defCharWidth : DEFAULT_CHAR_WIDTH;
+ this.defaultCharHeight = defCharHeight > 0 ? defCharHeight : DEFAULT_CHAR_HEIGHT_16;
+ }
+
+ /**
+ * Updates screen grid and cell dimensions from UI.
+ *
+ * @param cellWidth Width of a character cell in Swing pixels
+ * @param cellHeight Height of a character cell in Swing pixels
+ */
+ public void updateDisplayMetrics(int cellWidth, int cellHeight) {
+ if (cellWidth > 0 && defaultCharWidth > 0) {
+ this.transformX = (double) cellWidth / (double) defaultCharWidth;
+ }
+ if (cellHeight > 0 && defaultCharHeight > 0) {
+ this.transformY = (double) cellHeight / (double) defaultCharHeight;
+ }
+ }
+
+ public void setScreenDimensions(int cols, int rows) {
+ if (cols > 0) this.screenCols = cols;
+ if (rows > 0) this.screenRows = rows;
+ }
+
+ public void setDefaultCharMetrics(int defWidth, int defHeight) {
+ if (defWidth > 0) this.defaultCharWidth = defWidth;
+ if (defHeight > 0) this.defaultCharHeight = defHeight;
+ }
+
+ public int getTotalWidth() {
+ return screenCols * defaultCharWidth;
+ }
+
+ public int getTotalHeight() {
+ return screenRows * defaultCharHeight;
+ }
+
+ public int getXMax() {
+ int totalW = getTotalWidth();
+ return (totalW - 1) / 2 + (totalW - 1) % 2;
+ }
+
+ public int getYMax() {
+ int totalH = getTotalHeight();
+ return (totalH - 1) / 2;
+ }
+
+ public double getTransformX() {
+ return transformX;
+ }
+
+ public double getTransformY() {
+ return transformY;
+ }
+
+ public int getDefaultCharWidth() {
+ return defaultCharWidth;
+ }
+
+ public int getDefaultCharHeight() {
+ return defaultCharHeight;
+ }
+
+ /**
+ * Converts a GOCA signed coordinate (gx, gy) to base presentation space coordinate (px, py).
+ */
+ public Point gocaToBase(int gx, int gy) {
+ int px = gx + getXMax();
+ int py = getYMax() - gy;
+ return new Point(px, py);
+ }
+
+ /**
+ * Converts a base presentation space coordinate (px, py) to GOCA signed coordinate (gx, gy).
+ */
+ public Point baseToGoca(int px, int py) {
+ int gx = px - getXMax();
+ int gy = getYMax() - py;
+ return new Point(gx, gy);
+ }
+
+ /**
+ * Converts a GOCA signed coordinate (gx, gy) directly to Swing screen pixel coordinate (sx, sy).
+ */
+ public Point gocaToScreenPixel(int gx, int gy, int ox, int oy, int cellWidth, int cellHeight) {
+ updateDisplayMetrics(cellWidth, cellHeight);
+ int px = gx + getXMax();
+ int py = getYMax() - gy;
+ int sx = ox + (int) Math.round(px * transformX);
+ int sy = oy + (int) Math.round(py * transformY);
+ return new Point(sx, sy);
+ }
+
+ /**
+ * Converts a mouse click in Swing screen coordinates (mouseX, mouseY) directly to GOCA signed coordinate (gx, gy).
+ * Matches HODInput.getHODInput:
+ * x = mouseX / transformX
+ * y = mouseY / transformY
+ * gx = x - xMax
+ * gy = yMax - y
+ */
+ public Point screenPixelToGoca(int mouseX, int mouseY, int ox, int oy, int cellWidth, int cellHeight) {
+ updateDisplayMetrics(cellWidth, cellHeight);
+ double relX = mouseX - ox;
+ double relY = mouseY - oy;
+
+ int px = (transformX > 0) ? (int) Math.round(relX / transformX) : (int) relX;
+ int py = (transformY > 0) ? (int) Math.round(relY / transformY) : (int) relY;
+
+ int gx = px - getXMax();
+ int gy = getYMax() - py;
+ return new Point(gx, gy);
+ }
+
+ /**
+ * Maps GOCA (gx, gy) to fixed canvas buffer dimensions (canvasWidth, canvasHeight).
+ */
+ public Point gocaToCanvasPixel(int gx, int gy, int canvasWidth, int canvasHeight) {
+ int totalW = getTotalWidth();
+ int totalH = getTotalHeight();
+ int xMax = getXMax();
+ int yMax = getYMax();
+
+ int px = (int) Math.round((double) (gx + xMax) * canvasWidth / (totalW > 0 ? totalW : 1));
+ int py = (int) Math.round((double) (yMax - gy) * canvasHeight / (totalH > 0 ? totalH : 1));
+ return new Point(px, py);
+ }
+
+ /**
+ * Maps fixed canvas buffer pixel (canvasX, canvasY) to GOCA (gx, gy).
+ */
+ public Point canvasPixelToGoca(int canvasX, int canvasY, int canvasWidth, int canvasHeight) {
+ int totalW = getTotalWidth();
+ int totalH = getTotalHeight();
+ int xMax = getXMax();
+ int yMax = getYMax();
+
+ int nx = (int) Math.round((double) canvasX * totalW / (canvasWidth > 0 ? canvasWidth : 1));
+ int ny = (int) Math.round((double) canvasY * totalH / (canvasHeight > 0 ? canvasHeight : 1));
+ return new Point(nx - xMax, yMax - ny);
+ }
+}
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java
index 4d0ea91..44f9519 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java
@@ -164,7 +164,7 @@ public class GocaDecoder {
for (int i = activeSegmentsInOrder.size() - 1; i >= 0; i--) {
SegmentBounds sb = activeSegmentsInOrder.get(i);
if (sb.contains(gx, gy, 25)) {
- System.err.println(String.format(
+ logger.info(String.format(
"findPickedSegment: goca=(%d, %d) HIT segId=%d bounds=[%d..%d, %d..%d] tag=%d",
gx, gy, sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag
));
@@ -173,14 +173,14 @@ public class GocaDecoder {
}
for (SegmentBounds sb : segmentBoundsMap.values()) {
if (sb.contains(gx, gy, 25)) {
- System.err.println(String.format(
+ logger.info(String.format(
"findPickedSegment (fallback): goca=(%d, %d) HIT segId=%d bounds=[%d..%d, %d..%d] tag=%d",
gx, gy, sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag
));
return sb.segId;
}
}
- System.err.println(String.format("findPickedSegment: goca=(%d, %d) NO HIT (defaulting to 0/canvas)", gx, gy));
+ logger.info(String.format("findPickedSegment: goca=(%d, %d) NO HIT (defaulting to 0/canvas)", gx, gy));
return 0;
}
@@ -436,7 +436,7 @@ public class GocaDecoder {
if (currentSegId != 0) {
SegmentBounds sb = segmentBoundsMap.get(currentSegId);
if (sb != null) {
- System.err.println(String.format(
+ logger.info(String.format(
"GocaDecoder: ENDSEGM segId=%d bounds=[%d..%d, %d..%d] tag=%d",
sb.segId, sb.minX, sb.maxX, sb.minY, sb.maxY, sb.tag
));
@@ -805,12 +805,17 @@ public class GocaDecoder {
idx += 2;
break;
}
- case GocaConstants.P_ERASE: { // 0x0A: Erase Graphics Presentation Space
+ case GocaConstants.P_ERASE: { // 0x0A: Erase Graphics Presentation Space (HODEraseGraphicsPlane)
plane.clear();
resetAttributes();
curX = 0;
curY = 0;
activeSegmentsInOrder.clear();
+ segmentBoundsMap.clear();
+ segmentStore.clear();
+ segmentOrderList.clear();
+ chainedTargets.clear();
+ logger.info("GOCA P_ERASE: erased graphics presentation space and cleared segment stores");
idx += 2;
break;
}
@@ -818,55 +823,15 @@ public class GocaDecoder {
idx += 12;
break;
}
- case GocaConstants.P_SCUDEF: { // 0x21: Drawing Process Control / Segment Execute
- if (idx + 5 < end) {
- int pLen = data[idx + 1] & 0xFF;
- if (pLen >= 6) {
- int flags0 = data[idx + 2] & 0xFF;
- int flags1 = data[idx + 3] & 0xFF;
- int startSeg = ((data[idx + 4] & 0xFF) << 8) | (data[idx + 5] & 0xFF);
- int endSeg = (idx + 7 < end) ? (((data[idx + 6] & 0xFF) << 8) | (data[idx + 7] & 0xFF)) : startSeg;
- logger.info(String.format("GOCA P_SCUDEF: flags0=0x%02x flags1=0x%02x startSeg=%d endSeg=%d",
- flags0, flags1, startSeg, endSeg));
- // Redraw all stored base segments not in dynamic range [startSeg..endSeg]
- for (int segId : segmentOrderList) {
- if (segId < startSeg || segId > endSeg) {
- if (!chainedTargets.contains(segId)) {
- byte[] segBytes = segmentStore.get(segId);
- if (segBytes != null) {
- int savedSegId = currentSegId;
- currentSegId = segId;
- SegmentBounds sb = segmentBoundsMap.computeIfAbsent(segId, SegmentBounds::new);
- activeSegmentsInOrder.remove(sb);
- activeSegmentsInOrder.add(sb);
- callDepth++;
- decodeStreamDirect(segBytes, 0, segBytes.length);
- callDepth--;
- currentSegId = savedSegId;
- }
- }
- }
- }
-
- for (int s = startSeg; s <= endSeg; s++) {
- byte[] segBytes = segmentStore.get(s);
- if (segBytes != null) {
- int savedSegId = currentSegId;
- currentSegId = s;
- SegmentBounds sb = segmentBoundsMap.computeIfAbsent(s, SegmentBounds::new);
- activeSegmentsInOrder.remove(sb);
- activeSegmentsInOrder.add(sb);
- callDepth++;
- decodeStreamDirect(segBytes, 0, segBytes.length);
- callDepth--;
- currentSegId = savedSegId;
- }
- }
- }
- }
+ case GocaConstants.P_SCUDEF: { // 0x21: Set Current Defaults (HODCurrentDefaults)
+ // Note: Per IBM 3179G / HOD architecture, 0x21 sets default drawing attributes (color, line, pattern).
+ // It is NOT an executive segment redraw order. A prior attempt treated 0x21 as an invented
+ // P_SCUDEF segment redraw loop, which caused old dropdown menus and segments to be repeatedly
+ // repainted on top of the screen, creating ghost artifacts and stale bounding boxes.
if (idx + 1 < end) {
- int len = (data[idx + 1] & 0xFF) + 2;
- idx += len;
+ int pLen = data[idx + 1] & 0xFF;
+ logger.fine(String.format("GOCA Set Current Defaults (0x21): len=%d", pLen));
+ idx += pLen + 2;
} else {
idx++;
}
@@ -1302,9 +1267,9 @@ public class GocaDecoder {
int textLen = end - pos;
if (textLen <= 0) return;
- // IBM 3279 vector graphics base cell is 9x12
+ // IBM 3179G vector graphics base cell is 9x16
double cw = charWidth > 0 ? ((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10.0;
- double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 12.0)) : 14.0;
+ double ch = charHeight > 0 ? ((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 16.0)) : 14.0;
int totalW = textLen * (charWidth > 0 ? charWidth : 9);
int totalH = (charHeight > 0 ? charHeight : 14);
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java
index ffaa7c3..31cea87 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicInputBuilder.java
@@ -11,8 +11,8 @@ public class GraphicInputBuilder {
0x00, 0x38, 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, 0x00,
- 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
+ 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
};
@@ -30,16 +30,17 @@ public class GraphicInputBuilder {
*/
public static byte[] buildGraphicInput(int gocaX, int gocaY, int buttonOrAidCode,
boolean isMouseAction, boolean isShift, boolean isCtrl) {
- return buildGraphicInput(gocaX, gocaY, buttonOrAidCode, isMouseAction, isShift, isCtrl, 0, 0);
+ return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl);
}
/**
- * Builds the 56-byte Graphic Input Structured Field with picked segment ID and correlation tag.
+ * Builds the 56-byte Graphic Input Structured Field with picked segment ID and correlation tag (legacy overload).
+ * Note: Per IBM HOD / 3179G architecture, pick correlation is evaluated host-side by GDDM using (gx, gy).
*/
public static byte[] buildGraphicInput(int gocaX, int gocaY, int buttonOrAidCode,
boolean isMouseAction, boolean isShift, boolean isCtrl,
int pickedSegId, int pickTag) {
- return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl, pickedSegId, pickTag);
+ return buildGraphicInput(gocaX, gocaY, 0, 0, buttonOrAidCode, isMouseAction, isShift, isCtrl);
}
/**
@@ -48,6 +49,14 @@ public class GraphicInputBuilder {
public static byte[] buildGraphicInput(int gocaX, int gocaY, int row, int col, int buttonOrAidCode,
boolean isMouseAction, boolean isShift, boolean isCtrl,
int pickedSegId, int pickTag) {
+ return buildGraphicInput(gocaX, gocaY, row, col, buttonOrAidCode, isMouseAction, isShift, isCtrl);
+ }
+
+ /**
+ * Builds the 56-byte Graphic Input Structured Field matching IBM Host On-Demand (HODInput.java).
+ */
+ public static byte[] buildGraphicInput(int gocaX, int gocaY, int row, int col, int buttonOrAidCode,
+ boolean isMouseAction, boolean isShift, boolean isCtrl) {
byte[] sf = new byte[MASK.length];
System.arraycopy(MASK, 0, sf, 0, MASK.length);
@@ -55,13 +64,8 @@ public class GraphicInputBuilder {
sf[0] = (byte) ((MASK.length >> 8) & 0xFF);
sf[1] = (byte) (MASK.length & 0xFF);
- // Bytes 16-19: Cursor Row & Column
- if (row > 0 || col > 0) {
- sf[16] = (byte) (row & 0xFF);
- sf[17] = (byte) (col & 0xFF);
- sf[18] = (byte) (row & 0xFF);
- sf[19] = (byte) (col & 0xFF);
- }
+ // Bytes 16-19: Device correlation class descriptor (0x23, 0x00, 0x23, 0x00)
+ // Note: Preserved from MASK per IBM Host On-Demand (HODInput.java); do not overwrite with row/col.
// Byte 24-25: GOCA X coordinate (signed 16-bit big-endian)
sf[24] = (byte) ((gocaX >> 8) & 0xFF);
@@ -72,15 +76,15 @@ public class GraphicInputBuilder {
sf[27] = (byte) (gocaY & 0xFF);
if (isMouseAction) {
- // Byte 28-31: Picked Segment ID (4 bytes big-endian)
- sf[28] = (byte) ((pickedSegId >> 24) & 0xFF);
- sf[29] = (byte) ((pickedSegId >> 16) & 0xFF);
- sf[30] = (byte) ((pickedSegId >> 8) & 0xFF);
- sf[31] = (byte) (pickedSegId & 0xFF);
+ // Byte 28-31: Fixed mouse trigger class constant (0x00000004 per IBM HODInput.java)
+ sf[28] = 0x00;
+ sf[29] = 0x00;
+ sf[30] = 0x00;
+ sf[31] = 0x04;
- // Byte 32-33: Pick Tag / Correlation (2 bytes big-endian)
- sf[32] = (byte) ((pickTag >> 8) & 0xFF);
- sf[33] = (byte) (pickTag & 0xFF);
+ // Byte 32-33: Fixed mouse correlation class constant (0x0004 per IBM HODInput.java)
+ sf[32] = 0x00;
+ sf[33] = 0x04;
sf[34] = isShift ? (byte) 0x80 : (isCtrl ? (byte) 0x40 : 0x00);
sf[35] = (byte) (buttonOrAidCode == 2 ? 0x02 : 0x01); // Button 1 = Pick, Button 2 = Action
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java
index f39618b..2d2d74f 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java
@@ -115,6 +115,7 @@ public class GraphicsPlane {
public synchronized void setScreenDimensions(int cols, int rows) {
this.screenCols = cols > 0 ? cols : 80;
this.screenRows = rows > 0 ? rows : 24;
+ this.transform.setScreenDimensions(this.screenCols, this.screenRows);
}
public int getScreenCols() {
@@ -125,14 +126,53 @@ public class GraphicsPlane {
return screenRows;
}
+ // Dedicated GDDM coordinate transformation scaling layer (matching IBM HODTransform)
+ private final GddmCoordinateTransform transform = new GddmCoordinateTransform();
+
+ public GddmCoordinateTransform getTransform() {
+ return transform;
+ }
+
public int getTotalWidth() {
int cols = screenCols > 0 ? screenCols : 80;
return cols * 9;
}
+ /**
+ * Total vertical presentation space units.
+ *
+ * Historical / Architecture Note for Future Iterations:
+ *
+ * - Failed Approach 1 (Hardcoded 12-pitch / rows * 12):
+ * Used 516 units (yMax = 257) on 43-row screen. Caused upper coordinate truncation;
+ * menus at gy > 257 were clipped and mathematically impossible to pick.
+ * - Failed Approach 2 (Hardcoded 16-pitch / rows * 16 without viewport alignment):
+ * Used 688 units (yMax = 343). Visual rendering was shifted downward by ~50 pixels relative
+ * to GDDM's internal picture space viewport, causing clicks at Row 7 to emit gy = 226
+ * which missed the GDDM menu hit box and caused terminal alarm beeps.
+ * - Failed Approach 3 (Overwriting Graphic Input SF bytes 16-19 with row/col):
+ * Overwrote device correlation descriptor 0x00230023, causing GDDM Structured Field
+ * parser to reject inbound correlation packets as malformed.
+ * - Failed Approach 4 (Misinterpreting Procedure Order 0x21 as Segment Redraw loop):
+ * Order 0x21 is HODCurrentDefaults (Set Current Defaults), not an executive redraw loop.
+ * Treating 0x21 as segment execution caused previous menu frames and stored segments
+ * to be repeatedly repainted over active screens, leaving behind visual ghost artifacts.
+ * - Failed Approach 5 (Static yMax=343 scaling vs GDDM ADMDRAW Viewport bounds):
+ * In ADMDRAW, GDDM defines the graphics presentation space between y = -169 (canvas bottom)
+ * and y = +200 (top menu bar), with total height ~370 units. Mapping this into a static
+ * yMax = 343 (688 total height) renders the top menu at Row 9.2 (middle of the screen) with
+ * a large vertical void above it, and clicking the visual menu sends gy = 224 which misses
+ * the [187..200] menu bounding box. Furthermore, multiple dropdown menus (FILE, DRAW, TRANSFORM)
+ * overlapped simultaneously because segment retention and clearing was decoupled from GDDM's
+ * actual viewport state.
+ * - Solution Path: Use {@link GddmCoordinateTransform} for dynamic display and aspect
+ * scaling, ensuring host Query Reply metrics (SDH/AH) and GDDM Picture Space Window
+ * orders are synchronized with client-side mouse transformation.
+ *
+ */
public int getTotalHeight() {
int rows = screenRows > 0 ? screenRows : 24;
- return rows * 12;
+ return rows * 16;
}
public int getXMax() {
@@ -158,7 +198,7 @@ public class GraphicsPlane {
/**
* Maps a 3179G / GOCA signed coordinate (centered at screen midpoint, bottom-up) to canvas pixel Y (top-down) as a double.
- * IBM 3179G / HOD presentation space coordinate range is [-yMax .. +yMax] (height = rows * 12).
+ * IBM 3179G / HOD presentation space coordinate range is [-yMax .. +yMax] (height = rows * 16).
*/
public double mapYDouble(double gocaY) {
int totalH = getTotalHeight();
@@ -555,8 +595,20 @@ public class GraphicsPlane {
int fill = (fillColorArgb != 0) ? fillColorArgb : GocaConstants.GOCA_COLORS[0];
int bg = bgColorArgb;
- boolean isTransparentBlack = (fill == GocaConstants.GOCA_COLORS[8] || (fill & 0x00FFFFFF) == 0) && (bgMix != GocaConstants.MIX_OVER);
- if (pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary) && !isTransparentBlack) {
+ // Note / Logic for Future Iterations:
+ // In IBM 3179G / GDDM architecture, solid black fills (fillColor = GOCA_COLORS[8] / 0xFF000000,
+ // pattern = PT_SOLID / 16) are the standard mechanism used by host applications to ERASE
+ // dropdown menus, dialog boxes, and dynamic regions from the screen.
+ //
+ // A prior failed attempt added an 'isTransparentBlack' guard:
+ // boolean isTransparentBlack = (fill == GOCA_COLORS[8] || (fill & 0x00FFFFFF) == 0) && (bgMix != MIX_OVER);
+ // if (... && !isTransparentBlack)
+ // This caused GDDM's menu erasure rectangles (which use fillColor = Black and bgMix = 5) to be
+ // silently skipped and discarded. As a result, closed dropdown menus were never erased, causing
+ // multiple menus (FILE, DRAW, TRANSFORM) to linger and stack on top of each other permanently.
+ //
+ // Solid black fills (0xFF000000) must always be rasterized to overwrite and erase previously drawn pixels.
+ if (pattern != GocaConstants.PT_EMPTY && (pattern != 0 || !drawBoundary)) {
// Find polygon vertical bounds across all points
int minY = py[0];
int maxY = py[0];
diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java
index f6a78ff..155d264 100644
--- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java
+++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java
@@ -298,7 +298,7 @@ public class InputProcessor {
sendAidResponse(out.toByteArray());
}
- private void sendAidResponse(byte[] data) {
+ protected void sendAidResponse(byte[] data) {
if (fsm != null && fsm.getConnectionState() != null && fsm.getConnectionState().isSscp()) {
fsm.sendSscpLuData(data);
} else if (fsm != null) {
@@ -307,10 +307,10 @@ public class InputProcessor {
}
/**
- * Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G.
+ * Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G and DS3270.
*/
public void sendGraphicMouseAid(int aidCode, int button, boolean isShift, boolean isCtrl) {
- if (fsm == null || !fsm.getConnectionState().isFullSession()) {
+ if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) {
return;
}
if (isKeyboardLocked()) {
@@ -322,8 +322,6 @@ public class InputProcessor {
if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) {
int gx = gocaDecoder.getGraphicCursorX();
int gy = gocaDecoder.getGraphicCursorY();
- int pickedSeg = gocaDecoder.findPickedSegment(gx, gy);
- int pickTag = gocaDecoder.getSegmentTag(pickedSeg);
int cursorAddr = screen != null ? screen.getCursorAddress() : 0;
int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80;
int row = cursorAddr / cols;
@@ -340,16 +338,53 @@ public class InputProcessor {
}
byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(
- gx, gy, row, col, button, true, isShift, isCtrl, pickedSeg, pickTag
+ gx, gy, row, col, button, true, isShift, isCtrl
);
- System.err.println(String.format(
- "sendGraphicMouseAid: goca=(%d, %d) row=%d col=%d pickedSeg=%d pickTag=%d btn=%d shift=%b ctrl=%b",
- gx, gy, row, col, pickedSeg, pickTag, button, isShift, isCtrl
+
+ StringBuilder sfHex = new StringBuilder();
+ for (byte b : sf) {
+ sfHex.append(String.format("%02X ", b & 0xFF));
+ }
+ log.info(String.format(
+ "sendGraphicMouseAid: goca=(%d, %d) row=%d col=%d btn=%d shift=%b ctrl=%b SF_HEX=[%s]",
+ gx, gy, row, col, button, isShift, isCtrl, sfHex.toString().trim()
));
+
+ // Structured Field AID (0x88) + 56-byte Graphic Input SF
out.write(AID_SF);
try {
out.write(sf);
} catch (java.io.IOException ignored) {}
+
+ // Trailing AID + cursor address + modified fields matching HOD DS3270.sendMouseAid
+ out.write(aidCode);
+ byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols());
+ out.write(caddr[0] & 0xFF);
+ out.write(caddr[1] & 0xFF);
+
+ if (screen.isFormatted()) {
+ int size = screen.getRows() * screen.getCols();
+ for (int i = 0; i < size; i++) {
+ ExtendedAttribute ea = screen.getCell(i);
+ if (ea.isFieldAttribute() && faIsModified(ea.fa & 0xFF)) {
+ int fieldStart = (i + 1) % size;
+ out.write(ORDER_SBA);
+ byte[] addr = encodeAddress(fieldStart, screen.getRows(), screen.getCols());
+ out.write(addr[0] & 0xFF);
+ out.write(addr[1] & 0xFF);
+
+ int pos = fieldStart;
+ while (!screen.getCell(pos).isFieldAttribute()) {
+ int b = screen.getCell(pos).ec & 0xFF;
+ if (b != 0x00) {
+ out.write(b);
+ }
+ pos = (pos + 1) % size;
+ if (pos == fieldStart) break;
+ }
+ }
+ }
+ }
} else {
out.write(aidCode);
byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols());
@@ -363,11 +398,11 @@ public class InputProcessor {
// ========== Cursor movement ==========
/**
- * Simulate a Text Light Pen selection.
+ * Simulate a Text Light Pen selection at the specified screen address.
+ * Conforms to IBM 3270 / Host On-Demand PS3270 processCurSelKey specifications.
*/
public boolean lightPenSelect(int address) {
if (screen == null || !screen.isFormatted()) {
- System.out.println("LP: screen null or unformatted");
return false;
}
int size = screen.getRows() * screen.getCols();
@@ -375,15 +410,13 @@ public class InputProcessor {
address = ((address % size) + size) % size;
int faPos = screen.findFieldAttribute(address);
if (faPos < 0) {
- System.out.println("LP: no FA found for addr=" + address);
return false;
}
ExtendedAttribute faCell = screen.getCell(faPos);
int fa = faCell.fa & 0xFF;
- System.out.println("LP: addr=" + address + " faPos=" + faPos + " fa=0x" + String.format("%02X", fa)
- + " selectable=" + haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa));
- if (!haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa)) {
+ if (!haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) ||
+ haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa)) {
return false;
}
@@ -392,33 +425,52 @@ public class InputProcessor {
int ebcdic = desCell.ec & 0xFF;
char ascii = (char) desCell.ucs4;
- screen.setCursorAddress(designatorPos);
+ // Set cursor to the clicked/selected position matching HOD / 3270 standards
+ screen.setCursorAddress(address);
- if (ebcdic == 0x00 || ebcdic == 0x40 || ascii == ' ' || ascii == 0 || (ebcdic != 0x50 && ascii != '&' && ebcdic != 0x6F && ascii != '?' && ebcdic != 0x6E && ascii != '>')) {
- faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
- sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT);
- return true;
- } else if (ebcdic == 0x50 || ascii == '&') {
- faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
- sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER);
- return true;
- } else if (ebcdic == 0x6F || ascii == '?') {
- desCell.ec = (byte) 0x6E;
- desCell.ucs4 = '>';
- faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
- screen.updateDisplaySnapshot();
- return true;
- } else if (ebcdic == 0x6E || ascii == '>') {
- desCell.ec = (byte) 0x6F;
- desCell.ucs4 = '?';
- faCell.fa &= ~haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
- screen.updateDisplaySnapshot();
- return true;
- } else {
+ // Immediate Selection (Space or Null) -> AID_SELECT (0x7E)
+ if (ebcdic == 0x00 || ebcdic == 0x40 || ascii == ' ' || ascii == 0 || ascii == '\u3000') {
faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT);
return true;
}
+ // Enter Immediate (&) -> AID_ENTER (0x7D)
+ else if (ebcdic == 0x50 || ascii == '&' || ascii == '\uFF06') {
+ faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
+ sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER);
+ return true;
+ }
+ // Deferred Selection (? -> >)
+ else if (ebcdic == 0x6F || ascii == '?' || ascii == '\uFF1F') {
+ desCell.ec = (byte) 0x6E;
+ desCell.ucs4 = '>';
+ faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
+ screen.markAllChanged();
+ screen.updateDisplaySnapshot();
+ return true;
+ }
+ // Deferred Deselection (> -> ?)
+ else if (ebcdic == 0x6E || ascii == '>' || ascii == '\uFF1E') {
+ desCell.ec = (byte) 0x6F;
+ desCell.ucs4 = '?';
+ faCell.fa &= ~haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
+ screen.markAllChanged();
+ screen.updateDisplaySnapshot();
+ return true;
+ }
+ // Any other character is an invalid designator and cannot be selected
+ else {
+ return false;
+ }
+ }
+
+ /**
+ * Simulate the 3270 CURSR SEL (Cursor Select) key at the current cursor position.
+ * Equivalent to processCurSelKey in IBM Host On-Demand PS3270.
+ */
+ public boolean cursorSelect() {
+ if (screen == null) return false;
+ return lightPenSelect(screen.getCursorAddress());
}
public void cursorUp() {
diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java
new file mode 100644
index 0000000..6d4b164
--- /dev/null
+++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTDftTest.java
@@ -0,0 +1,190 @@
+package haus.nightmare.lib3270j.ft;
+
+import haus.nightmare.lib3270j.charset.EbcdicTranslator;
+import haus.nightmare.lib3270j.input.InputProcessor;
+import haus.nightmare.lib3270j.screen.ScreenBuffer;
+import haus.nightmare.lib3270j.TerminalModel;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class FTDftTest {
+
+ private EbcdicTranslator translator;
+ private ScreenBuffer screen;
+ private InputProcessor input;
+ private TestListener listener;
+ private FTDft dft;
+ private FTConfig config;
+
+ private static class TestListener implements FTDft.FTDftListener {
+ boolean dftRunningCalled = false;
+ boolean completeCalled = false;
+ boolean abortCalled = false;
+ String completeMsg = null;
+ String abortMsg = null;
+ long bytesTransferred = 0;
+ FTConstants.FTState state = FTConstants.FTState.RUNNING;
+ FTConfig config;
+
+ @Override
+ public void onDftRunning() {
+ dftRunningCalled = true;
+ }
+
+ @Override
+ public void onTransferComplete(String errorMessage) {
+ completeCalled = true;
+ completeMsg = errorMessage;
+ }
+
+ @Override
+ public void onTransferAborted(String errorMessage) {
+ abortCalled = true;
+ abortMsg = errorMessage;
+ }
+
+ @Override
+ public void onBytesTransferred(long bytes) {
+ bytesTransferred = bytes;
+ }
+
+ @Override
+ public FTConstants.FTState getCurrentState() {
+ return state;
+ }
+
+ @Override
+ public void setState(FTConstants.FTState state) {
+ this.state = state;
+ }
+
+ @Override
+ public FTConfig getConfig() {
+ return config;
+ }
+
+ @Override
+ public File getLocalFile() {
+ return null;
+ }
+ }
+
+ @BeforeEach
+ public void setUp() {
+ translator = new EbcdicTranslator();
+ screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
+ input = new InputProcessor(screen, translator, null);
+ listener = new TestListener();
+ config = new FTConfig();
+ config.setDirection(FTConfig.Direction.RECEIVE);
+ config.setTransferMode(FTConfig.TransferMode.BINARY);
+ listener.config = config;
+
+ dft = new FTDft(input, translator, listener);
+ }
+
+ @Test
+ public void testOpenMessageStreamAsciiAndHandleTrans14Error() {
+ ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+ dft.initTransfer(null, outStream);
+
+ // 1. Simulate TR_OPEN_REQ with ASCII "FT:MSG" matching j3270.log:
+ // SF length = 0x0023, SF type = 0xD0, Request code = 0x0012, stream name "FT:MSG " at offset 28
+ byte[] openReq = new byte[] {
+ 0x00, 0x23, (byte) 0xD0, 0x00, 0x12, 0x01, 0x06, 0x01, 0x01, 0x04,
+ 0x03, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01,
+ 0x00, 0x50, 0x05, 0x52, 0x03, (byte) 0xF0, 0x03, 0x09,
+ 0x46, 0x54, 0x3A, 0x4D, 0x53, 0x47, 0x20 // "FT:MSG "
+ };
+
+ dft.processStructuredField(openReq, 0, openReq.length);
+
+ // Should NOT trigger onDftRunning because it's a message stream
+ assertFalse(listener.dftRunningCalled);
+
+ // 2. Simulate TR_DATA_INSERT on the message stream with "TRANS14 Error reading file from host: ..."
+ String errorMsg = "TRANS14 Error reading file from host: file format invalid";
+ byte[] msgBytes = errorMsg.getBytes(StandardCharsets.ISO_8859_1);
+ int totalPayloadLen = msgBytes.length + 5;
+
+ ByteArrayOutputStream sfOut = new ByteArrayOutputStream();
+ sfOut.write(0); sfOut.write(0); // placeholder length
+ sfOut.write(0xD0); // SF_TRANSFER_DATA
+ sfOut.write((FTConstants.TR_DATA_INSERT >> 8) & 0xFF);
+ sfOut.write(FTConstants.TR_DATA_INSERT & 0xFF);
+ sfOut.write((FTConstants.TR_NOT_COMPRESSED >> 8) & 0xFF);
+ sfOut.write(FTConstants.TR_NOT_COMPRESSED & 0xFF);
+ sfOut.write(FTConstants.TR_BEGIN_DATA);
+ sfOut.write((totalPayloadLen >> 8) & 0xFF);
+ sfOut.write(totalPayloadLen & 0xFF);
+ sfOut.write(msgBytes, 0, msgBytes.length);
+
+ byte[] insertData = sfOut.toByteArray();
+ int sfLen = insertData.length;
+ insertData[0] = (byte) ((sfLen >> 8) & 0xFF);
+ insertData[1] = (byte) (sfLen & 0xFF);
+
+ dft.processStructuredField(insertData, 0, insertData.length);
+
+ // Verify that onTransferAborted was called with the error message
+ assertTrue(listener.abortCalled, "onTransferAborted should be called for TRANS14 error");
+ assertNotNull(listener.abortMsg);
+ assertTrue(listener.abortMsg.contains("TRANS14"));
+ assertFalse(listener.completeCalled, "onTransferComplete should NOT be called for an error");
+
+ // Verify that the error message was NOT written to the destination file stream!
+ assertEquals(0, outStream.size(), "Error message should not be written to destination file");
+ }
+
+ @Test
+ public void testOpenDataStreamAndWriteData() {
+ ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+ dft.initTransfer(null, outStream);
+
+ // 1. Simulate TR_OPEN_REQ with ASCII "FT:DATA"
+ byte[] openReq = new byte[] {
+ 0x00, 0x23, (byte) 0xD0, 0x00, 0x12, 0x01, 0x06, 0x01, 0x01, 0x04,
+ 0x03, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01,
+ 0x00, 0x50, 0x05, 0x52, 0x03, (byte) 0xF0, 0x03, 0x09,
+ 0x46, 0x54, 0x3A, 0x44, 0x41, 0x54, 0x41 // "FT:DATA"
+ };
+
+ dft.processStructuredField(openReq, 0, openReq.length);
+ assertTrue(listener.dftRunningCalled, "onDftRunning should be called for file data stream");
+
+ // 2. Simulate TR_DATA_INSERT with binary file payload
+ byte[] fileData = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34 }; // "%PDF-1.4"
+ int totalPayloadLen = fileData.length + 5;
+
+ ByteArrayOutputStream sfOut = new ByteArrayOutputStream();
+ sfOut.write(0); sfOut.write(0);
+ sfOut.write(0xD0);
+ sfOut.write((FTConstants.TR_DATA_INSERT >> 8) & 0xFF);
+ sfOut.write(FTConstants.TR_DATA_INSERT & 0xFF);
+ sfOut.write((FTConstants.TR_NOT_COMPRESSED >> 8) & 0xFF);
+ sfOut.write(FTConstants.TR_NOT_COMPRESSED & 0xFF);
+ sfOut.write(FTConstants.TR_BEGIN_DATA);
+ sfOut.write((totalPayloadLen >> 8) & 0xFF);
+ sfOut.write(totalPayloadLen & 0xFF);
+ sfOut.write(fileData, 0, fileData.length);
+
+ byte[] insertData = sfOut.toByteArray();
+ int sfLen = insertData.length;
+ insertData[0] = (byte) ((sfLen >> 8) & 0xFF);
+ insertData[1] = (byte) (sfLen & 0xFF);
+
+ dft.processStructuredField(insertData, 0, insertData.length);
+
+ // Data must be written to file stream
+ assertArrayEquals(fileData, outStream.toByteArray());
+ assertEquals(8, listener.bytesTransferred);
+ }
+}
diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java
new file mode 100644
index 0000000..0d63f99
--- /dev/null
+++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransformTest.java
@@ -0,0 +1,43 @@
+package haus.nightmare.lib3270j.graphics;
+
+import org.junit.jupiter.api.Test;
+import java.awt.Point;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class GddmCoordinateTransformTest {
+
+ @Test
+ public void testModel4PresentationSpaceMetrics() {
+ GddmCoordinateTransform transform = new GddmCoordinateTransform(80, 43, 9, 16);
+
+ assertEquals(720, transform.getTotalWidth());
+ assertEquals(688, transform.getTotalHeight());
+ assertEquals(360, transform.getXMax());
+ assertEquals(343, transform.getYMax());
+
+ // Screen center (0, 0)
+ Point baseCenter = transform.gocaToBase(0, 0);
+ assertEquals(360, baseCenter.x);
+ assertEquals(343, baseCenter.y);
+ assertEquals(new Point(0, 0), transform.baseToGoca(360, 343));
+
+ // Bidirectional screen pixel transformation
+ // Cell width = 10, Cell height = 20, Offset = (100, 50)
+ int ox = 100, oy = 50, cw = 10, ch = 20;
+ Point screenPt = transform.gocaToScreenPixel(0, 0, ox, oy, cw, ch);
+ Point roundtripGoca = transform.screenPixelToGoca(screenPt.x, screenPt.y, ox, oy, cw, ch);
+ assertEquals(0, roundtripGoca.x);
+ assertEquals(0, roundtripGoca.y);
+ }
+
+ @Test
+ public void testModel2PresentationSpaceMetrics() {
+ GddmCoordinateTransform transform = new GddmCoordinateTransform(80, 24, 9, 16);
+
+ assertEquals(720, transform.getTotalWidth());
+ assertEquals(384, transform.getTotalHeight());
+ assertEquals(360, transform.getXMax());
+ assertEquals(191, transform.getYMax());
+ }
+}
diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java
index 519ef70..09237f7 100644
--- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java
+++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/GocaDecoderTest.java
@@ -221,6 +221,12 @@ public class GocaDecoderTest {
GraphicsPlane plane = new GraphicsPlane(1000, 750);
plane.setScreenDimensions(80, 43);
+ // Standard IBM 3179G 43-row presentation space (80*9 = 720 wide, 43*16 = 688 high)
+ assertEquals(720, plane.getTotalWidth());
+ assertEquals(688, plane.getTotalHeight());
+ assertEquals(360, plane.getXMax());
+ assertEquals(343, plane.getYMax());
+
// Screen center (0, 0) should map to canvas center
assertEquals(500, plane.mapX(0));
assertEquals(374, plane.mapY(0));
@@ -240,6 +246,13 @@ public class GocaDecoderTest {
assertEquals(0, plane.unmapY(plane.mapY(0)));
assertEquals(100, plane.unmapY(plane.mapY(100)));
assertEquals(-200, plane.unmapY(plane.mapY(-200)));
+
+ // Standard IBM 3179G Model 2 (24-row) presentation space (24*16 = 384 high, yMax = 191)
+ plane.setScreenDimensions(80, 24);
+ assertEquals(720, plane.getTotalWidth());
+ assertEquals(384, plane.getTotalHeight());
+ assertEquals(360, plane.getXMax());
+ assertEquals(191, plane.getYMax());
}
@Test
@@ -735,8 +748,9 @@ public class GocaDecoderTest {
out.write(0x00); out.write(20); out.write(0x00); out.write(40);
out.write(GocaConstants.G_GEAR);
- // 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, GSCOL White inside GBAR)
+ // 4. Draw White slide boundary frame (Color set to 8/Black before GBAR, Pattern 15/Empty, GSCOL White inside GBAR)
out.write(GocaConstants.G_GSCOL); out.write(0x08); // Black (background)
+ out.write(GocaConstants.G_GSPT); out.write(0x0F); // Empty pattern (transparent interior)
out.write(GocaConstants.G_GBAR); out.write(0x80);
out.write(GocaConstants.G_GSCOL); out.write(0x07); // White line color
out.write(GocaConstants.G_GSLT); out.write(GocaConstants.LT_DOT); // Dotted line
diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java
index 75624ec..5a7b082 100644
--- a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java
+++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java
@@ -154,38 +154,170 @@ public class InputProcessorTest {
assertEquals((byte) 0xFF, sf[34]);
assertEquals((byte) AID_ENTER, sf[35]);
- // Mouse Button 1 with picked segment 2 and tag 5
+ // Mouse Button 1 matching HODInput.java (0x04 trigger constants and 0x23 correlation descriptor)
byte[] sfMouse = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(
- 294, 192, 1, true, false, false, 2, 5
+ 294, 192, 1, true, false, false
);
assertEquals(56, sfMouse.length);
assertEquals(0x00, sfMouse[0]);
assertEquals(0x38, sfMouse[1]);
+ assertEquals(0x23, sfMouse[16]);
+ assertEquals(0x00, sfMouse[17]);
+ assertEquals(0x23, sfMouse[18]);
+ assertEquals(0x00, sfMouse[19]);
int mx = (sfMouse[24] << 8) | (sfMouse[25] & 0xFF);
int my = (sfMouse[26] << 8) | (sfMouse[27] & 0xFF);
assertEquals(294, (short) mx);
assertEquals(192, (short) my);
- int segId = ((sfMouse[28] & 0xFF) << 24) | ((sfMouse[29] & 0xFF) << 16) |
- ((sfMouse[30] & 0xFF) << 8) | (sfMouse[31] & 0xFF);
- assertEquals(2, segId);
- int tag = ((sfMouse[32] & 0xFF) << 8) | (sfMouse[33] & 0xFF);
- assertEquals(5, tag);
+ assertEquals(0x00, sfMouse[28]);
+ assertEquals(0x00, sfMouse[29]);
+ assertEquals(0x00, sfMouse[30]);
+ assertEquals(0x04, sfMouse[31]); // Mouse trigger class constant (0x04)
+ assertEquals(0x00, sfMouse[32]);
+ assertEquals(0x04, sfMouse[33]); // Mouse correlation class constant (0x04)
assertEquals(0x00, sfMouse[34]);
assertEquals(0x01, sfMouse[35]); // Button 1
- // Mouse Button 2 (Action) with no segment (0)
+ // Mouse Button 2 (Action) with Shift modifier
byte[] sfMouse2 = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput(
- 0, 0, 2, true, true, false, 0, 0
+ 0, 0, 2, true, true, false
);
- int segId2 = ((sfMouse2[28] & 0xFF) << 24) | ((sfMouse2[29] & 0xFF) << 16) |
- ((sfMouse2[30] & 0xFF) << 8) | (sfMouse2[31] & 0xFF);
- assertEquals(0, segId2);
- int tag2 = ((sfMouse2[32] & 0xFF) << 8) | (sfMouse2[33] & 0xFF);
- assertEquals(0, tag2);
+ assertEquals(0x23, sfMouse2[16]);
+ assertEquals(0x00, sfMouse2[17]);
+ assertEquals(0x23, sfMouse2[18]);
+ assertEquals(0x00, sfMouse2[19]);
+ assertEquals(0x04, sfMouse2[31]);
+ assertEquals(0x04, sfMouse2[33]);
assertEquals((byte) 0x80, sfMouse2[34]); // Shift modifier
assertEquals(0x02, sfMouse2[35]); // Button 2
}
+ @Test
+ public void testLightPenSelectImmediateSpace() {
+ screen.erase(false);
+ // Field attribute at pos 0: unprotected, intensified selectable (0x08)
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_HIGH_SEL));
+ // Designator character at pos 1: space (immediate select)
+ screen.getCell(1).ec = 0x40;
+ screen.getCell(1).ucs4 = ' ';
+ screen.getCell(2).ec = (byte) 0xC1;
+ screen.getCell(2).ucs4 = 'A';
+
+ java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ public void sendAid(int aidCode) {
+ sentAid.set(aidCode);
+ }
+ };
+
+ boolean result = input.lightPenSelect(2);
+ assertTrue(result);
+ assertEquals(AID_SELECT, sentAid.get());
+ assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0);
+ assertEquals(2, screen.getCursorAddress());
+ }
+
+ @Test
+ public void testLightPenSelectEnterImmediateAmpersand() {
+ screen.erase(false);
+ // Field attribute at pos 0: normal detectable (0x04)
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL));
+ // Designator character at pos 1: '&' (enter immediate)
+ screen.getCell(1).ec = 0x50;
+ screen.getCell(1).ucs4 = '&';
+
+ java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ public void sendAid(int aidCode) {
+ sentAid.set(aidCode);
+ }
+ };
+
+ boolean result = input.lightPenSelect(1);
+ assertTrue(result);
+ assertEquals(AID_ENTER, sentAid.get());
+ assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0);
+ }
+
+ @Test
+ public void testLightPenSelectDeferredToggle() {
+ screen.erase(false);
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL));
+ // Designator at pos 1: '?' (deferred select)
+ screen.getCell(1).ec = 0x6F;
+ screen.getCell(1).ucs4 = '?';
+
+ java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ public void sendAid(int aidCode) {
+ sentAid.set(aidCode);
+ }
+ };
+
+ // First click: ? -> > and MDT=1, no transmission
+ boolean r1 = input.lightPenSelect(1);
+ assertTrue(r1);
+ assertEquals(-1, sentAid.get());
+ assertEquals('>', screen.getCell(1).ucs4);
+ assertEquals((byte) 0x6E, screen.getCell(1).ec);
+ assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0);
+
+ // Second click: > -> ? and MDT=0, no transmission
+ boolean r2 = input.lightPenSelect(1);
+ assertTrue(r2);
+ assertEquals(-1, sentAid.get());
+ assertEquals('?', screen.getCell(1).ucs4);
+ assertEquals((byte) 0x6F, screen.getCell(1).ec);
+ assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0);
+ }
+
+ @Test
+ public void testLightPenSelectInvalidDesignatorRejected() {
+ screen.erase(false);
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL));
+ // Designator at pos 1: 'A' (invalid designator)
+ screen.getCell(1).ec = (byte) 0xC1;
+ screen.getCell(1).ucs4 = 'A';
+
+ java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ public void sendAid(int aidCode) {
+ sentAid.set(aidCode);
+ }
+ };
+
+ boolean result = input.lightPenSelect(1);
+ assertFalse(result);
+ assertEquals(-1, sentAid.get());
+ assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0);
+ }
+
+ @Test
+ public void testCursorSelectKey() {
+ screen.erase(false);
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_INT_NORM_SEL));
+ screen.getCell(1).ec = 0x50;
+ screen.getCell(1).ucs4 = '&';
+ screen.setCursorAddress(5);
+
+ java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ public void sendAid(int aidCode) {
+ sentAid.set(aidCode);
+ }
+ };
+
+ boolean result = input.cursorSelect();
+ assertTrue(result);
+ assertEquals(AID_ENTER, sentAid.get());
+ assertEquals(5, screen.getCursorAddress());
+ }
+
@Test
public void testSendAidSuppressesNullsInModifiedField() {
java.util.concurrent.atomic.AtomicReference sent = new java.util.concurrent.atomic.AtomicReference<>();
@@ -231,4 +363,59 @@ public class InputProcessorTest {
assertEquals((byte) 0xC9, result[8]); // 'I'
assertEquals((byte) 0xE3, result[9]); // 'T'
}
+
+ @Test
+ public void testSendGraphicMouseAidFraming() {
+ screen.erase(false);
+ screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_MODIFY));
+ screen.getCell(1).ec = (byte) 0xC1; // 'A'
+ screen.setCellFA(5, (byte) (FA_PRINTABLE | FA_PROTECT));
+ screen.setCursorAddress(2);
+
+ haus.nightmare.lib3270j.graphics.GraphicsPlane plane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(800, 600);
+ haus.nightmare.lib3270j.graphics.GocaDecoder goca = new haus.nightmare.lib3270j.graphics.GocaDecoder(plane);
+ goca.setGraphicsCursorActive(true);
+ goca.setGraphicCursorPosition(150, -80);
+
+ java.util.concurrent.atomic.AtomicReference sent = new java.util.concurrent.atomic.AtomicReference<>();
+ InputProcessor input = new InputProcessor(screen, translator, null) {
+ @Override
+ protected void sendAidResponse(byte[] data) {
+ sent.set(data);
+ }
+ };
+ input.setGocaDecoder(goca);
+
+ input.sendGraphicMouseAid(AID_ENTER, 1, false, false);
+
+ byte[] result = sent.get();
+ assertNotNull(result);
+ // Total expected length:
+ // 1 (AID_SF 0x88) + 56 (SF) + 1 (AID_ENTER 0x7D) + 2 (Cursor Addr) + 1 (SBA) + 2 (Field Addr) + 1 (Data 'A') = 64 bytes
+ assertEquals(64, result.length);
+ assertEquals((byte) AID_SF, result[0]);
+ // SF length = 56 (0x00 0x38)
+ assertEquals(0x00, result[1]);
+ assertEquals(0x38, result[2]);
+ // SF ID = 0x0F0F
+ assertEquals(0x0F, result[3]);
+ assertEquals(0x0F, result[4]);
+ // Coordinates in SF at index 1 + 24 = 25
+ int gx = (result[25] << 8) | (result[26] & 0xFF);
+ int gy = (result[27] << 8) | (result[28] & 0xFF);
+ assertEquals(150, (short) gx);
+ assertEquals(-80, (short) gy);
+ // Mouse constant at index 1 + 31 = 32
+ assertEquals(0x04, result[32]);
+ assertEquals(0x04, result[34]);
+ // Button 1 at index 1 + 35 = 36
+ assertEquals(0x01, result[36]);
+
+ // Trailing AID at index 57
+ assertEquals((byte) AID_ENTER, result[57]);
+ // Trailing SBA at 60
+ assertEquals((byte) ORDER_SBA, result[60]);
+ // Trailing field content 'A' at 63
+ assertEquals((byte) 0xC1, result[63]);
+ }
}