From b5a4978b24fdcb57405ef34280fdf28db8e64d53 Mon Sep 17 00:00:00 2001 From: Rudi Date: Mon, 24 Aug 2026 20:46:45 +0000 Subject: [PATCH] Keep par0dy with a3270 --- .../java/org/pubvm/j3270/ui/StatusBar.java | 4 + .../org/pubvm/j3270/ui/TerminalPanel.java | 155 ++++++---- .../datastream/QueryReplyBuilder.java | 28 +- .../org/lib3270j/graphics/GocaConstants.java | 1 + .../org/lib3270j/graphics/GocaDecoder.java | 274 ++++++++++++++---- .../graphics/GraphicInputBuilder.java | 45 ++- .../org/lib3270j/graphics/GraphicsPlane.java | 5 + .../org/lib3270j/input/InputProcessor.java | 30 +- .../lib3270j/graphics/GocaDecoderTest.java | 47 +++ 9 files changed, 458 insertions(+), 131 deletions(-) diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java b/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java index 8fb3dcd..64c512b 100644 --- a/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java +++ b/j3270/src/main/java/org/pubvm/j3270/ui/StatusBar.java @@ -86,6 +86,10 @@ public class StatusBar extends JPanel { public void setClient(Telnet3270Client client, TerminalPanel terminalPanel) { this.client = client; this.terminalPanel = terminalPanel; + if (terminalPanel != null) { + terminalPanel.setOnLightPenToggle(this::updateStatus); + } + updateStatus(); } public void updateStatus() { diff --git a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java index 5361eb0..91cac5b 100644 --- a/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/org/pubvm/j3270/ui/TerminalPanel.java @@ -159,11 +159,20 @@ public class TerminalPanel extends JPanel { col = Math.max(0, Math.min(col, displayCols - 1)); row = Math.max(0, Math.min(row, displayRows - 1)); - // Start selection - selectionStartRow = row; - selectionStartCol = col; - selectionEndRow = row; - selectionEndCol = col; + // IMPORTANT ARCHITECTURE NOTE: + // 1. In GDDM Graphic Cursor Mode (3179G / GOCA), mouse events represent graphic light-pen touches. + // Text drag-selection is explicitly suppressed so blue selection boxes do not artifact over graphics. + // 2. In Text Light Pen mode (Alt+L on 3270 formatted screens), clicks toggle selectable fields. + // 3. In standard alphanumeric mode, click-drag selects text for copy-paste. + boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive(); + if (isGraphic || lightPenMode) { + clearSelection(); + } else { + selectionStartRow = row; + selectionStartCol = col; + selectionEndRow = row; + selectionEndCol = col; + } isDragging = true; repaint(); } @@ -172,16 +181,29 @@ public class TerminalPanel extends JPanel { @Override public void mouseDragged(MouseEvent e) { if (isDragging && client != null && client.getConnectionState().isFullSession()) { - ScreenBuffer sb = client.getScreenBuffer(); int ox = getRenderOffsetX(); int oy = getRenderOffsetY(); - int col = (e.getX() - ox) / cellWidth; - int row = (e.getY() - oy) / cellHeight; - col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1)); - row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1)); + ScreenBuffer sb = client.getScreenBuffer(); + boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive(); + if (isGraphic && client.getGraphicsPlane() != null) { + 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); + px = Math.max(0, Math.min(px, gWidth - 1)); + py = Math.max(0, Math.min(py, gHeight - 1)); + client.getGocaDecoder().setGraphicCursorFromPixel(px, py); + } else if (!lightPenMode) { + int col = (e.getX() - ox) / cellWidth; + int row = (e.getY() - oy) / cellHeight; + col = Math.max(0, Math.min(col, sb.getDisplayCols() - 1)); + row = Math.max(0, Math.min(row, sb.getDisplayRows() - 1)); - selectionEndRow = row; - selectionEndCol = col; + selectionEndRow = row; + selectionEndCol = col; + } repaint(); } } @@ -198,6 +220,8 @@ public class TerminalPanel extends JPanel { 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); + px = Math.max(0, Math.min(px, gWidth - 1)); + py = Math.max(0, Math.min(py, gHeight - 1)); client.getGocaDecoder().setGraphicCursorFromPixel(px, py); } } @@ -206,51 +230,63 @@ public class TerminalPanel extends JPanel { public void mouseReleased(MouseEvent e) { if (isDragging) { isDragging = false; - // If start == end, treat as a click (position cursor, clear selection) - if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) { - if (client != null && client.getConnectionState().isFullSession()) { - ScreenBuffer sb = client.getScreenBuffer(); - int displayCols = sb.getDisplayCols(); - int newAddr = selectionStartRow * displayCols + selectionStartCol; - sb.setCursorAddress(newAddr); + if (client != null && client.getConnectionState().isFullSession()) { + ScreenBuffer sb = client.getScreenBuffer(); + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + int displayCols = sb.getDisplayCols(); + int displayRows = sb.getDisplayRows(); + int col = (e.getX() - ox) / cellWidth; + int row = (e.getY() - oy) / cellHeight; + col = Math.max(0, Math.min(col, displayCols - 1)); + row = Math.max(0, Math.min(row, displayRows - 1)); + int clickAddr = row * displayCols + col; + + boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive() && client.getGraphicsPlane() != null; + + if (isGraphic) { clearSelection(); - if (lightPenMode) { - System.out.println("LP click: row=" + selectionStartRow + " col=" + selectionStartCol - + " addr=" + newAddr + " mouseY=" + e.getY() + " oy=" + getRenderOffsetY() - + " cellH=" + cellHeight); - boolean result = client.getInputProcessor().lightPenSelect(newAddr); - System.out.println("LP lightPenSelect result: " + result); - if (result) { - refreshScreen(); - return; - } + int gridW = displayCols * cellWidth; + int gridH = displayRows * 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); + px = Math.max(0, Math.min(px, gWidth - 1)); + py = Math.max(0, Math.min(py, gHeight - 1)); + client.getGocaDecoder().setGraphicCursorFromPixel(px, py); + + int gx = client.getGocaDecoder().getGraphicCursorX(); + int gy = client.getGocaDecoder().getGraphicCursorY(); + 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 + )); + + client.getInputProcessor().sendGraphicMouseAid( + org.lib3270j.protocol.DS3270Constants.AID_ENTER, + button, + e.isShiftDown(), + e.isControlDown() + ); + refreshScreen(); + return; + } + + if (lightPenMode) { + clearSelection(); + sb.setCursorAddress(clickAddr); + boolean result = client.getInputProcessor().lightPenSelect(clickAddr); + if (result) { + refreshScreen(); + return; } - - 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; - } - } - + } + + if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) { + sb.setCursorAddress(clickAddr); + clearSelection(); refreshScreen(); return; } @@ -1140,9 +1176,18 @@ public class TerminalPanel extends JPanel { if (blinkTimer != null) blinkTimer.stop(); } + private Runnable onLightPenToggle; + + public void setOnLightPenToggle(Runnable callback) { + this.onLightPenToggle = callback; + } + public void toggleLightPen() { this.lightPenMode = !this.lightPenMode; System.out.println("Light Pen mode: " + (this.lightPenMode ? "ON" : "OFF")); + if (onLightPenToggle != null) { + onLightPenToggle.run(); + } repaint(); } diff --git a/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java b/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java index 8c0ab03..ca4888e 100644 --- a/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java +++ b/lib3270j/src/main/java/org/lib3270j/datastream/QueryReplyBuilder.java @@ -125,8 +125,8 @@ public class QueryReplyBuilder { // Vector Graphics QRs if enabled if (graphicsMode.isVectorGraphicsEnabled()) { appendQueryReply(out, QR_RPQ_NAMES, buildRpqNames()); // 0xA8 - appendQueryReply(out, QR_GRAPHICS, buildGraphics()); // 0xB0 - appendQueryReply(out, QR_GIMAGE, buildGImage()); // 0xB1 + appendQueryReply(out, QR_GRAPHICS, buildGraphics(maxCols, maxRows)); // 0xB0 + appendQueryReply(out, QR_GIMAGE, buildGImage(maxCols, maxRows)); // 0xB1 appendQueryReply(out, QR_AUX_DEV, buildAuxDev()); // 0xB2 appendOemFmt(out); // 0xB3 appendQueryReply(out, QR_GCOLOR, buildGColor()); // 0xB4 @@ -204,14 +204,14 @@ public class QueryReplyBuilder { break; case QR_GRAPHICS: if (graphicsMode.isVectorGraphicsEnabled()) { - appendQueryReply(out, QR_GRAPHICS, buildGraphics()); + appendQueryReply(out, QR_GRAPHICS, buildGraphics(maxCols, maxRows)); } else { appendQueryReply(out, QR_NULL, new byte[0]); } break; case QR_GIMAGE: if (graphicsMode.isVectorGraphicsEnabled()) { - appendQueryReply(out, QR_GIMAGE, buildGImage()); + appendQueryReply(out, QR_GIMAGE, buildGImage(maxCols, maxRows)); } else { appendQueryReply(out, QR_NULL, new byte[0]); } @@ -422,13 +422,25 @@ public class QueryReplyBuilder { return out.toByteArray(); } - private byte[] buildGraphics() { - return new byte[]{ (byte) 0x80, 0x02, 0x00, 0x00, 0x00, (byte) 0xFC, 0x00 }; + private byte[] buildGraphics(int maxCols, int maxRows) { + int width = maxCols * 9; + int height = maxRows * 12; + return new byte[]{ + (byte) 0x80, 0x02, + (byte) ((width >> 8) & 0xFF), (byte) (width & 0xFF), + (byte) ((height >> 8) & 0xFF), (byte) (height & 0xFF), + 0x00 + }; } - private byte[] buildGImage() { + private byte[] buildGImage(int maxCols, int maxRows) { + int width = maxCols * 9; + int height = maxRows * 12; return new byte[]{ - 0x00, 0x01, 0x00, 0x00, 0x00, (byte) 0xFC, 0x00, + 0x00, 0x01, + (byte) ((width >> 8) & 0xFF), (byte) (width & 0xFF), + (byte) ((height >> 8) & 0xFF), (byte) (height & 0xFF), + 0x00, 0x06, 0x40, 0x06, 0x40, 0x06, 0x01, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xF0 }; diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java b/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java index 97c9ec9..31bc45f 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GocaConstants.java @@ -61,6 +61,7 @@ public final class GocaConstants { public static final int G_GSMCEL = 0x37; // Set Marker Cell public static final int G_GSCS = 0x38; // Set Character Set public static final int G_GSMP = 0x39; // Set Marker Precision + public static final int G_GSETAG = 0x39; // Set Pick Identifier / Tag public static final int G_GSCD = 0x3A; // Set Character Direction public static final int G_GSCC = 0x3B; // Set Character Precision public static final int G_GSMS_SET = 0x3C; // Set Marker Set diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java b/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java index e169d12..48a3315 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GocaDecoder.java @@ -63,6 +63,39 @@ public class GocaDecoder { private final java.util.Set chainedTargets = new java.util.HashSet<>(); private int callDepth = 0; + /** + * Bounding box and pick correlation tracking for GOCA segments. + */ + public static class SegmentBounds { + public final int segId; + public int minX = Integer.MAX_VALUE; + public int maxX = Integer.MIN_VALUE; + public int minY = Integer.MAX_VALUE; + public int maxY = Integer.MIN_VALUE; + public int tag = 0; + + public SegmentBounds(int segId) { + this.segId = segId; + } + + public void include(int x, int y) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + public boolean contains(int x, int y, int margin) { + if (minX == Integer.MAX_VALUE) return false; + return x >= minX - margin && x <= maxX + margin && + y >= minY - margin && y <= maxY + margin; + } + } + + private final java.util.Map segmentBoundsMap = new java.util.HashMap<>(); + private final java.util.List activeSegmentsInOrder = new java.util.ArrayList<>(); + private int currentSegId = 0; + // Graphic Cursor (Light-Pen) state private boolean graphicsCursorActive = false; private int graphicCursorX = 0; @@ -88,6 +121,10 @@ public class GocaDecoder { return curY; } + public int getCharSet() { + return charSet; + } + public synchronized boolean isGraphicsCursorActive() { return graphicsCursorActive; } @@ -116,6 +153,35 @@ public class GocaDecoder { } } + public synchronized int findPickedSegment(int gx, int gy) { + for (int i = activeSegmentsInOrder.size() - 1; i >= 0; i--) { + SegmentBounds sb = activeSegmentsInOrder.get(i); + if (sb.contains(gx, gy, 12)) { + System.err.println(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 + )); + return sb.segId; + } + } + System.err.println(String.format("findPickedSegment: goca=(%d, %d) NO HIT (defaulting to 0/canvas)", gx, gy)); + return 0; + } + + public synchronized int getSegmentTag(int segId) { + SegmentBounds sb = segmentBoundsMap.get(segId); + return sb != null ? sb.tag : 0; + } + + private void trackPoint(int x, int y) { + if (currentSegId != 0) { + SegmentBounds sb = segmentBoundsMap.get(currentSegId); + if (sb != null) { + sb.include(x, y); + } + } + } + public synchronized void resetDefaults() { curX = 0; curY = 0; @@ -126,6 +192,9 @@ public class GocaDecoder { segmentChainMap.clear(); segmentOrderList.clear(); chainedTargets.clear(); + segmentBoundsMap.clear(); + activeSegmentsInOrder.clear(); + currentSegId = 0; resetAttributes(); } @@ -154,6 +223,19 @@ public class GocaDecoder { private byte[] partialOrderBuffer = new byte[0]; + /** + * Determines the total byte length of a GOCA drawing order starting at data[idx]. + * + * IMPORTANT ARCHITECTURE NOTE: + * GOCA orders follow IBM GA23-0059 architecture rules: + * 1. 1-byte standalone orders (NOP, etc.) -> length 1. + * 2. Delimiter orders (GEAR, ENDSEGM, ENDPROLOGUE, GEIMG) -> 1 or 2 bytes (with 0x00 trailing byte). + * 3. Fixed 1-byte immediate operand orders (0x00..0x1F range: GSCOL, GSLT, GSLW, GSMS, GSMC, GSPS, GSBMX) -> 2 bytes. + * 4. Orders with opcode >= 0x20 (including GCALL 0x2A, GSCS 0x38, GSCD 0x3A, GSPT 0x28, GSMT 0x29, GBAR 0x68, + * GLINE 0xC1, GARC 0xC6, GCHST 0xC3, etc.) are self-defining with a 1-byte length field data[idx+1], + * making total length = payloadLen + 2. + * Never hardcode orders >= 0x20 to 2 bytes, as that desynchronizes the GOCA order stream. + */ private int getOrderLength(byte[] data, int idx, int end) { int order = data[idx] & 0xFF; if (order == GocaConstants.G_NOP1 || order == 0xFF) { @@ -164,23 +246,26 @@ public class GocaDecoder { order == GocaConstants.G_GEIMG || order == GocaConstants.G_GPOP) { return (idx + 1 < end && data[idx + 1] == 0x00) ? 2 : 1; } - if (order == 0x04 || order == GocaConstants.G_GSMC || order == GocaConstants.G_GSPS || - order == GocaConstants.G_GSCOL || order == GocaConstants.G_GSMX || - 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_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_GCALL) { // Call Segment (0x2A <32-bit segment ID>) - return 5; - } if (idx + 1 >= end) { return -1; } + // Fixed 2-byte orders: 1-byte opcode + 1-byte operand + if (order == 0x04 || order == GocaConstants.G_GSMC || order == GocaConstants.G_GSPS || + order == GocaConstants.G_GSCOL || order == GocaConstants.G_GSMX || + order == GocaConstants.G_GSBMX || order == GocaConstants.G_GSLT || + order == GocaConstants.G_GSLW || order == GocaConstants.G_GSMS) { + return 2; + } + // Flexible 1-byte attribute orders (support both short 2-byte or long 3-byte if len byte == 1) + if (order == GocaConstants.G_GSPT || order == GocaConstants.G_GSMT || + order == GocaConstants.G_GSCS || order == GocaConstants.G_GSCD || + order == GocaConstants.G_GBAR) { + return (data[idx + 1] == 0x01 && idx + 2 < end) ? 3 : 2; + } + if (order == GocaConstants.G_GCALL) { + return (data[idx + 1] == 0x04 && idx + 5 < end) ? 6 : 5; + } + // Self-defining orders with 1-byte length field: order (1) + length byte (1) + payload (len) return (data[idx + 1] & 0xFF) + 2; } @@ -303,6 +388,16 @@ public class GocaDecoder { break; } case GocaConstants.G_BEGSEGM: { // Begin Segment (0x70) + if (idx + 5 < end) { + int segId = ((inputData[idx + 2] & 0xFF) << 24) | + ((inputData[idx + 3] & 0xFF) << 16) | + ((inputData[idx + 4] & 0xFF) << 8) | + (inputData[idx + 5] & 0xFF); + currentSegId = segId; + SegmentBounds sb = segmentBoundsMap.computeIfAbsent(segId, SegmentBounds::new); + activeSegmentsInOrder.remove(sb); + activeSegmentsInOrder.add(sb); + } if (idx + 7 < end && (inputData[idx + 7] & 0x06) == 0) { resetAttributes(); } @@ -310,6 +405,27 @@ public class GocaDecoder { break; } case GocaConstants.G_ENDSEGM: { // End Segment (0x71) + if (currentSegId != 0) { + SegmentBounds sb = segmentBoundsMap.get(currentSegId); + if (sb != null) { + System.err.println(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 + )); + } + } + currentSegId = 0; + idx += orderLen; + break; + } + case GocaConstants.G_GSETAG: { // Set Pick Identifier / Tag (0x39) + if (currentSegId != 0 && payloadLen >= 2 && idx + 3 < end) { + int tag = ((inputData[idx + 2] & 0xFF) << 8) | (inputData[idx + 3] & 0xFF); + SegmentBounds sb = segmentBoundsMap.get(currentSegId); + if (sb != null) { + sb.tag = tag; + } + } idx += orderLen; break; } @@ -344,16 +460,34 @@ public class GocaDecoder { break; } case GocaConstants.G_GCALL: { // Call Segment (0x2A) - if (callDepth < 16 && idx + 4 < end) { - int targetSegId = ((inputData[idx + 1] & 0xFF) << 24) | + if (callDepth < 16) { + int targetSegId; + if (orderLen == 6 && idx + 5 < end) { + targetSegId = ((inputData[idx + 2] & 0xFF) << 24) | + ((inputData[idx + 3] & 0xFF) << 16) | + ((inputData[idx + 4] & 0xFF) << 8) | + (inputData[idx + 5] & 0xFF); + } else if (idx + 4 < end) { + 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--; + } else { + targetSegId = -1; + } + if (targetSegId != -1) { + byte[] targetSeg = segmentStore.get(targetSegId); + if (targetSeg != null) { + int savedSegId = currentSegId; + currentSegId = targetSegId; + SegmentBounds targetSb = segmentBoundsMap.computeIfAbsent(targetSegId, SegmentBounds::new); + activeSegmentsInOrder.remove(targetSb); + activeSegmentsInOrder.add(targetSb); + callDepth++; + decodeStreamDirect(targetSeg, 0, targetSeg.length); + callDepth--; + currentSegId = savedSegId; + } } } idx += orderLen; @@ -420,7 +554,7 @@ public class GocaDecoder { break; } case GocaConstants.G_GSMT: { // Set Marker Type (0x29) - markerType = inputData[idx + 1] & 0xFF; + markerType = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); idx += orderLen; break; } @@ -430,24 +564,23 @@ public class GocaDecoder { break; } case GocaConstants.G_GSPT: { // Set Pattern Symbol (0x28) - pattern = inputData[idx + 1] & 0xFF; + pattern = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); idx += orderLen; break; } case GocaConstants.G_GSCD: { // Set Character Direction (0x3A) - charDir = inputData[idx + 1] & 0xFF; + charDir = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); idx += orderLen; break; } case GocaConstants.G_GSCS: { // Set Character Set (0x38) - charSet = inputData[idx + 1] & 0xFF; + charSet = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); idx += orderLen; break; } case 0x04: case GocaConstants.G_GSMX: case GocaConstants.G_GSFLW: - case GocaConstants.G_GSMP: case GocaConstants.G_GSCC: case GocaConstants.G_GSMS_SET: case GocaConstants.G_GPOP: { @@ -460,7 +593,7 @@ public class GocaDecoder { break; } case GocaConstants.G_GBAR: { // Begin Area (0x68) - int flags = inputData[idx + 1] & 0xFF; + int flags = (orderLen == 3) ? (inputData[idx + 2] & 0xFF) : (inputData[idx + 1] & 0xFF); boolean drawBoundary = (flags & 0x80) != 0 || (flags == 0); boolean fill = (flags == 0) || (flags & 0x40) != 0 || (pattern >= 1 && pattern <= 14); @@ -504,21 +637,21 @@ public class GocaDecoder { break; } case GocaConstants.G_GCFARC: { // Full Arc Current Position (0x87) - if (payloadLen >= 2) { + if (payloadLen >= 0) { processArc(inputData, idx + 2, payloadLen, true, true); } idx += orderLen; break; } case GocaConstants.G_GARC: { // Partial Arc Absolute (0xC6) - if (payloadLen >= 8) { + if (payloadLen >= 4) { processArc(inputData, idx + 2, payloadLen, false, false); } idx += orderLen; break; } case GocaConstants.G_GCARC: { // Partial Arc Current Position (0x86) - if (payloadLen >= 4) { + if (payloadLen >= 0) { processArc(inputData, idx + 2, payloadLen, true, false); } idx += orderLen; @@ -761,6 +894,8 @@ public class GocaDecoder { pos += 4; } + trackPoint(startX, startY); + if (inArea) { addAreaPoint(startX, startY); } @@ -770,6 +905,8 @@ public class GocaDecoder { int nextY = readCoord(data, pos + 2); pos += 4; + trackPoint(nextX, nextY); + if (inArea) { addAreaPoint(nextX, nextY); } else { @@ -799,6 +936,8 @@ public class GocaDecoder { pos += 4; } + trackPoint(startX, startY); + if (inArea) { addAreaPoint(startX, startY); } @@ -811,6 +950,8 @@ public class GocaDecoder { int nextX = startX + dx; int nextY = startY + dy; + trackPoint(nextX, nextY); + if (inArea) { addAreaPoint(nextX, nextY); } else { @@ -859,36 +1000,63 @@ public class GocaDecoder { // Semi-axis lengths in GOCA coordinate space double semiAxis1 = Math.sqrt(pScaled * pScaled + qScaled * qScaled); double semiAxis2 = Math.sqrt(rScaled * rScaled + sScaled * sScaled); + + // If arc parameters are at default (1,0,0,1) and curPos != center, use distance to center as radius + double dist = Math.hypot(curX - centerX, curY - centerY); + if (semiAxis1 <= 1.01 && dist > 1.0) { + semiAxis1 = dist * multiplier; + semiAxis2 = dist * multiplier; + } + if (semiAxis1 < 1.0) semiAxis1 = 1.0; if (semiAxis2 < 1.0) semiAxis2 = 1.0; // Map to pixel space int rx = Math.abs(plane.mapX((int) Math.round(semiAxis1)) - plane.mapX(0)); int ry = Math.abs(plane.mapY(0) - plane.mapY((int) Math.round(semiAxis2))); - if (rx <= 0) rx = 1; - if (ry <= 0) ry = 1; + if (rx <= 0) rx = Math.max(1, (int) Math.round(semiAxis1)); + if (ry <= 0) ry = Math.max(1, (int) Math.round(semiAxis2)); - // For partial arcs, read sweep start and sweep angle + // For partial arcs, determine start angle and sweep angle double startAngleDeg = 0.0; double sweepAngleDeg = 360.0; - if (!isFull && pos + 4 <= off + len) { - // Sweep start: integer.fraction of full revolution - int startInt = data[pos]; - int startFrac = data[pos + 1] & 0xFF; - startAngleDeg = (startInt + startFrac / 256.0) * 360.0; - pos += 2; - // Sweep angle: integer.fraction of full revolution - int sweepInt = data[pos]; - int sweepFrac = data[pos + 1] & 0xFF; - sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; - } else if (!isFull) { - // No sweep data — default to full circle - sweepAngleDeg = 360.0; + if (!isFull) { + if (pos + 4 <= off + len) { + // Sweep start: integer.fraction of full revolution + int startInt = data[pos]; + int startFrac = data[pos + 1] & 0xFF; + startAngleDeg = (startInt + startFrac / 256.0) * 360.0; + pos += 2; + // Sweep angle: integer.fraction of full revolution + int sweepInt = data[pos]; + int sweepFrac = data[pos + 1] & 0xFF; + sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; + } else if (pos + 2 <= off + len) { + // Single sweep parameter + int sweepInt = data[pos]; + int sweepFrac = data[pos + 1] & 0xFF; + sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; + if (curX != centerX || curY != centerY) { + double startRad = Math.atan2(plane.mapY(centerY) - plane.mapY(curY), plane.mapX(curX) - plane.mapX(centerX)); + startAngleDeg = Math.toDegrees(startRad); + if (startAngleDeg < 0) startAngleDeg += 360.0; + } + } else { + // No sweep data — if we have a current point, start there and sweep full circle + if (curX != centerX || curY != centerY) { + double startRad = Math.atan2(plane.mapY(centerY) - plane.mapY(curY), plane.mapX(curX) - plane.mapX(centerX)); + startAngleDeg = Math.toDegrees(startRad); + if (startAngleDeg < 0) startAngleDeg += 360.0; + } + sweepAngleDeg = 360.0; + } } - System.out.println("processArc: center=(" + centerX + "," + centerY + ") mult=" + multiplier - + " P=" + arcParamP + " Q=" + arcParamQ + " R=" + arcParamR + " S=" + arcParamS - + " rx=" + rx + " ry=" + ry + " start=" + startAngleDeg + " sweep=" + sweepAngleDeg + trackPoint(centerX - (int) Math.round(semiAxis1), centerY - (int) Math.round(semiAxis2)); + trackPoint(centerX + (int) Math.round(semiAxis1), centerY + (int) Math.round(semiAxis2)); + + System.out.println("processArc: center=(" + centerX + "," + centerY + ") cur=(" + curX + "," + curY + + ") rx=" + rx + " ry=" + ry + " start=" + startAngleDeg + " sweep=" + sweepAngleDeg + " isFull=" + isFull); plane.drawArc(plane.mapX(centerX), plane.mapY(centerY), rx, ry, startAngleDeg, sweepAngleDeg, @@ -937,12 +1105,14 @@ public class GocaDecoder { int end = off + len; if (fromCurPos) { + trackPoint(curX, curY); plane.drawMarker(plane.mapX(curX), plane.mapY(curY), markerType, markerSize, markerColor); } while (pos + 4 <= end) { int x = readCoord(data, pos); int y = readCoord(data, pos + 2); + trackPoint(x, y); plane.drawMarker(plane.mapX(x), plane.mapY(y), markerType, markerSize, markerColor); curX = x; curY = y; @@ -970,6 +1140,12 @@ public class GocaDecoder { int cw = charWidth > 0 ? (int) Math.round((double) charWidth * plane.getCanvasWidth() / (plane.getScreenCols() * 9.0)) : 10; int ch = charHeight > 0 ? (int) Math.round((double) charHeight * plane.getCanvasHeight() / (plane.getScreenRows() * 12.0)) : 14; + int totalW = textLen * (charWidth > 0 ? charWidth : 9); + int totalH = (charHeight > 0 ? charHeight : 14); + trackPoint(startX, startY); + trackPoint(startX + totalW, startY + totalH); + trackPoint(startX + totalW, startY - totalH); + if (charSet != 0 && programSymbolManager != null) { for (int i = 0; i < textLen; i++) { int code = data[pos + i] & 0xFF; diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicInputBuilder.java b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicInputBuilder.java index dab76f6..99907f1 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicInputBuilder.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicInputBuilder.java @@ -30,6 +30,34 @@ public class GraphicInputBuilder { */ public static byte[] buildGraphicInput(int gocaX, int gocaY, int aidCode, boolean isMouseAction, boolean isShift, boolean isCtrl) { + return buildGraphicInput(gocaX, gocaY, aidCode, isMouseAction, isShift, isCtrl, 0, 0); + } + + /** + * Builds the 56-byte Graphic Input Structured Field with picked segment ID and correlation tag. + * + * IMPORTANT ARCHITECTURE NOTE: + * Per IBM GA23-0059 / GDDM specifications: + * - Bytes 24-27: (gocaX, gocaY) cursor coordinates. + * - Bytes 28-31: Picked Segment Identifier (32-bit big-endian). When clicking on a menu item or + * interactive element (e.g. DRAW button = Segment 2, EXIT button = Segment 5), GDDM requires + * the exact segment ID in bytes 28-31. If hardcoded or mismatched, GDDM rejects the click + * with a WCC 0xF7 alarm beep. + * - Bytes 32-33: Pick Correlation Tag (16-bit big-endian) set by G_GSETAG (0x39). + * + * @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 + * @param pickedSegId Picked GOCA segment ID (0 if none) + * @param pickTag Pick correlation tag + * @return 56-byte payload + */ + public static byte[] buildGraphicInput(int gocaX, int gocaY, int aidCode, + boolean isMouseAction, boolean isShift, boolean isCtrl, + int pickedSegId, int pickTag) { byte[] sf = new byte[MASK.length]; System.arraycopy(MASK, 0, sf, 0, MASK.length); @@ -41,11 +69,22 @@ public class GraphicInputBuilder { sf[26] = (byte) ((gocaY >> 8) & 0xFF); sf[27] = (byte) (gocaY & 0xFF); + if (pickedSegId != 0) { + // 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); + } + if (isMouseAction) { - sf[31] = 0x04; - sf[33] = 0x04; + if (pickTag != 0) { + // Byte 32-33: Pick Tag / Correlation (2 bytes big-endian) + sf[32] = (byte) ((pickTag >> 8) & 0xFF); + sf[33] = (byte) (pickTag & 0xFF); + } sf[34] = isShift ? (byte) 0x80 : (isCtrl ? (byte) 0x40 : 0x00); - sf[35] = (byte) (aidCode == 2 ? 0x02 : 0x01); // Button 1 = Pick + sf[35] = (byte) (aidCode == 2 ? 0x02 : 0x01); // Button 1 = Pick, Button 2 = Action } else { // Keyboard AID (Enter, PF keys) sf[31] = 0x07; diff --git a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java index 2787c45..72547d5 100644 --- a/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/org/lib3270j/graphics/GraphicsPlane.java @@ -123,6 +123,7 @@ public class GraphicsPlane { /** * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint) to canvas pixel X. + * Coordinate space is symmetric: -xMax to +xMax, where nominalWidth = cols * 9 (e.g. 720 for 80 cols). */ public int mapX(int gocaX) { int nominalWidth = screenCols * 9; @@ -133,6 +134,8 @@ public class GraphicsPlane { /** * Maps a 3179G / GOCA signed coordinate (centered at screen midpoint, bottom-up) to canvas pixel Y (top-down). + * Coordinate space is symmetric: -yMax to +yMax, where nominalHeight = rows * 12 (e.g. 516 for 43 rows). + * NOTE: Do not apply arbitrary offsets here. The GOCA coordinate system is 1:1 synchronized with host GDDM. */ public int mapY(int gocaY) { int nominalHeight = screenRows * 12; @@ -143,6 +146,7 @@ public class GraphicsPlane { /** * Maps a canvas pixel X coordinate back to GOCA signed coordinate (-xMax..+xMax). + * Invariant: unmapX(mapX(x)) == x for all valid canvas pixels. */ public int unmapX(int px) { int nominalWidth = screenCols * 9; @@ -153,6 +157,7 @@ public class GraphicsPlane { /** * Maps a canvas pixel Y coordinate (top-down) back to GOCA signed coordinate (bottom-up). + * Invariant: unmapY(mapY(y)) == y for all valid canvas pixels. */ public int unmapY(int py) { int nominalHeight = screenRows * 12; diff --git a/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java index 1f629fa..db8c834 100644 --- a/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/org/lib3270j/input/InputProcessor.java @@ -239,17 +239,6 @@ public class InputProcessor { // 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()); @@ -336,16 +325,25 @@ public class InputProcessor { 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); + int pickedSeg = gocaDecoder.findPickedSegment(gx, gy); + int pickTag = gocaDecoder.getSegmentTag(pickedSeg); + byte[] sf = org.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( + gx, gy, aidCode, true, isShift, isCtrl, pickedSeg, pickTag + ); + System.err.println(String.format( + "sendGraphicMouseAid: goca=(%d, %d) pickedSeg=%d pickTag=%d btn=%d shift=%b ctrl=%b", + gx, gy, pickedSeg, pickTag, button, isShift, isCtrl + )); out.write(AID_SF); try { out.write(sf); } catch (java.io.IOException ignored) {} + } else { + out.write(aidCode); + byte[] caddr = encodeAddress(screen.getCursorAddress(), screen.getRows(), screen.getCols()); + out.write(caddr[0] & 0xFF); + out.write(caddr[1] & 0xFF); } - 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()); } diff --git a/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java b/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java index 3848b96..e50302b 100644 --- a/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java +++ b/lib3270j/src/test/java/org/lib3270j/graphics/GocaDecoderTest.java @@ -234,6 +234,12 @@ public class GocaDecoderTest { assertEquals(0, plane.mapY(257)); // Bottom edge (-258) should map to 515 assertEquals(515, plane.mapY(-258)); + + // Bidirectional roundtrip mapping must be exact + assertEquals(0, plane.unmapX(plane.mapX(0))); + assertEquals(0, plane.unmapY(plane.mapY(0))); + assertEquals(100, plane.unmapY(plane.mapY(100))); + assertEquals(-200, plane.unmapY(plane.mapY(-200))); } @Test @@ -288,4 +294,45 @@ public class GocaDecoderTest { assertFalse(dsp.getGraphicsPlane().hasContent(), "Expected graphics plane to be cleared after SF_OBJCNTL P_ERASE"); } + + @Test + public void testGcallAndGscsWithLengthBytes() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // Construct Segment 2: Begin Segment (id=2), Set Color, draw line, End Segment + ByteArrayOutputStream s2 = new ByteArrayOutputStream(); + s2.write(GocaConstants.G_BEGSEGM); + s2.write(0x0C); + s2.write(0x00); s2.write(0x00); s2.write(0x00); s2.write(0x02); // id=2 + s2.write(0x00); s2.write(0x00); s2.write(0x00); s2.write(0x00); + s2.write(0x00); s2.write(0x00); s2.write(0x00); s2.write(0x00); + s2.write(GocaConstants.G_GSCS); s2.write(0x01); s2.write(0x40); // charSet = 0x40 + s2.write(GocaConstants.G_GSCOL); s2.write(0x02); // Color = Red + s2.write(GocaConstants.G_GLINE); s2.write(0x08); + s2.write(0x00); s2.write(0x00); s2.write(0x00); s2.write(0x00); + s2.write(0x00); s2.write(0x32); s2.write(0x00); s2.write(0x32); + s2.write(GocaConstants.G_ENDSEGM); s2.write(0x00); + + byte[] s2Bytes = s2.toByteArray(); + decoder.decodeStream(s2Bytes, 0, s2Bytes.length); + + // Segment 1 calls Segment 2 via GCALL (0x2A len=4 segId=2) + ByteArrayOutputStream s1 = new ByteArrayOutputStream(); + s1.write(GocaConstants.G_BEGSEGM); + s1.write(0x0C); + s1.write(0x00); s1.write(0x00); s1.write(0x00); s1.write(0x01); // id=1 + s1.write(0x00); s1.write(0x00); s1.write(0x00); s1.write(0x00); + s1.write(0x00); s1.write(0x00); s1.write(0x00); s1.write(0x00); + // GCALL (0x2A len=4 segId=2) + s1.write(GocaConstants.G_GCALL); s1.write(0x04); + s1.write(0x00); s1.write(0x00); s1.write(0x00); s1.write(0x02); + s1.write(GocaConstants.G_ENDSEGM); s1.write(0x00); + + byte[] s1Bytes = s1.toByteArray(); + decoder.decodeStream(s1Bytes, 0, s1Bytes.length); + + assertEquals(0x40, decoder.getCharSet()); + assertTrue(plane.hasContent(), "Expected plane to have content after GCALL segment execution"); + } }