From ef70ea0f5ab516218a7fb485187495e48b604507 Mon Sep 17 00:00:00 2001 From: Rudi Date: Thu, 17 Sep 2026 23:09:43 +0000 Subject: [PATCH] Bugfixes from zOS testing --- .../java/haus/nightmare/j3270/J3270App.java | 27 + .../nightmare/j3270/storage/HostStorage.java | 1 - .../nightmare/j3270/ui/ConnectDialog.java | 39 +- .../nightmare/j3270/ui/TerminalPanel.java | 92 +++- .../storage/HostStorageAndProxyTest.java | 36 ++ .../j3270/ui/TerminalPanelSelectionTest.java | 101 ++++ .../haus/nightmare/lib3270j/ecl/ECLXfer.java | 36 +- .../haus/nightmare/lib3270j/ft/FTConfig.java | 77 ++- .../nightmare/lib3270j/ft/FTConstants.java | 58 +- .../haus/nightmare/lib3270j/ft/FTCut.java | 66 ++- .../haus/nightmare/lib3270j/ft/FTDft.java | 507 ++++++++++++++---- .../lib3270j/graphics/FilletPts.java | 120 ++++- .../graphics/GddmCoordinateTransform.java | 77 +++ .../lib3270j/graphics/GocaDecoder.java | 355 +++++++++--- .../lib3270j/graphics/GraphicsPlane.java | 65 ++- .../lib3270j/graphics/HODEllipse.java | 151 ++++++ .../graphics/ProgramSymbolManager.java | 66 +-- .../lib3270j/graphics/ProgramSymbolSet.java | 24 +- .../lib3270j/input/InputProcessor.java | 92 ++-- .../nightmare/lib3270j/telnet/TelnetFSM.java | 5 + .../nightmare/lib3270j/xfer3270/Xfer3270.java | 93 +++- .../ft/FTProtocolHardeningPhase16Test.java | 476 ++++++++++++++++ .../Phase17GocaGraphicsParityTest.java | 303 +++++++++++ .../graphics/ProgramSymbolManagerTest.java | 128 ++++- .../lib3270j/input/InputProcessorTest.java | 26 + 25 files changed, 2686 insertions(+), 335 deletions(-) create mode 100644 j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java create mode 100644 lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODEllipse.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java create mode 100644 lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase17GocaGraphicsParityTest.java diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index ddf946f..f10fe1d 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -104,6 +104,29 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate } } }); + + // Ensure clicking anywhere inside the frame brings focus to window and terminalPanel + Toolkit.getDefaultToolkit().addAWTEventListener(event -> { + if (event instanceof MouseEvent && event.getID() == MouseEvent.MOUSE_PRESSED) { + MouseEvent me = (MouseEvent) event; + Object src = me.getSource(); + if (src instanceof Component) { + Window win = SwingUtilities.getWindowAncestor((Component) src); + if (win == J3270App.this && !isAnyChildDialogActive()) { + if (!J3270App.this.isFocused() || !J3270App.this.isActive()) { + J3270App.this.toFront(); + J3270App.this.requestFocus(); + } + boolean isMenu = (src instanceof JMenuBar) || (src instanceof JMenuItem) || (src instanceof JPopupMenu) + || SwingUtilities.getAncestorOfClass(JMenuBar.class, (Component) src) != null + || SwingUtilities.getAncestorOfClass(JPopupMenu.class, (Component) src) != null; + if (!isMenu && terminalPanel != null && !terminalPanel.hasFocus()) { + terminalPanel.requestFocusInWindow(); + } + } + } + } + }, AWTEvent.MOUSE_EVENT_MASK); } private boolean isAnyChildDialogActive() { @@ -694,6 +717,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate dialog.setInitialTls(haus.nightmare.j3270.config.Settings.getAutoConnectTls()); dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert()); dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage()); + dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode()); dialog.setVisible(true); if (dialog.isConfirmed()) { @@ -730,6 +754,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate haus.nightmare.j3270.config.Settings.setAutoConnectTls(config.isUseTls()); haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert()); haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage()); + if (config.getGraphicsMode() != null) { + haus.nightmare.j3270.config.Settings.setGraphicsMode(config.getGraphicsMode()); + } disconnect(); diff --git a/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java index 3417ae1..30a1b16 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java +++ b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java @@ -234,7 +234,6 @@ public class HostStorage { if (map.containsKey("tn3270e")) h.setTn3270e(parseBool(map.get("tn3270e"), true)); if (map.containsKey("graphicsMode")) { String gm = String.valueOf(map.get("graphicsMode")); - if ("PROGRAMMED_SYMBOLS".equalsIgnoreCase(gm)) gm = "BOTH"; h.setGraphicsMode(gm); } if (map.containsKey("codePage")) h.setCodePage(String.valueOf(map.get("codePage"))); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java index 1b14a08..ae144cb 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java @@ -6,6 +6,7 @@ import haus.nightmare.lib3270j.ConnectionConfig; import haus.nightmare.lib3270j.TerminalModel; import javax.swing.*; +import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; import java.awt.*; import java.util.List; @@ -226,6 +227,24 @@ public class ConnectDialog extends JDialog { graphicsCombo.setSelectedItem(haus.nightmare.j3270.config.Settings.getGraphicsMode()); graphicsCombo.setFont(ThemeManager.getUiFont()); ThemeManager.styleComboBox(graphicsCombo); + graphicsCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + setBorder(new EmptyBorder(4, 8, 4, 8)); + if (value instanceof haus.nightmare.lib3270j.graphics.GraphicsMode) { + setText(((haus.nightmare.lib3270j.graphics.GraphicsMode) value).getDescription()); + } + if (isSelected) { + setBackground(ThemeManager.getSelectionBg()); + setForeground(ThemeManager.getSelectionFg()); + } else { + setBackground(ThemeManager.getBgComponent()); + setForeground(ThemeManager.getFgMain()); + } + return this; + } + }); contentPanel.add(graphicsCombo, gbc); gridy++; @@ -664,7 +683,7 @@ public class ConnectDialog extends JDialog { String gfx = "BOTH"; if (graphicsCombo.getSelectedItem() != null) { - gfx = graphicsCombo.getSelectedItem().toString(); + gfx = ((haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem()).name(); } String cp = "037"; @@ -731,7 +750,9 @@ public class ConnectDialog extends JDialog { result.setLuName(lu); } if (graphicsCombo.getSelectedItem() != null) { - result.setGraphicsMode((haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem()); + haus.nightmare.lib3270j.graphics.GraphicsMode gm = (haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem(); + result.setGraphicsMode(gm); + haus.nightmare.j3270.config.Settings.setGraphicsMode(gm); } Object cpSelection = codePageCombo.getSelectedItem(); @@ -776,6 +797,14 @@ public class ConnectDialog extends JDialog { result.setProxy(pType, pHost, pPort, pUser.isEmpty() ? null : pUser, pPass.isEmpty() ? null : pPass); } + if (selectedProfileId != null) { + SavedHost existing = HostStorage.findById(selectedProfileId); + String pName = (existing != null && existing.getName() != null && !existing.getName().trim().isEmpty()) + ? existing.getName() : host; + SavedHost updated = buildCurrentSavedHost(pName); + HostStorage.saveHost(updated); + } + confirmed = true; dispose(); } @@ -810,6 +839,12 @@ public class ConnectDialog extends JDialog { tn3270eCheckBox.setSelected(tn3270e); } + public void setInitialGraphicsMode(haus.nightmare.lib3270j.graphics.GraphicsMode mode) { + if (mode != null && selectedProfileId == null && graphicsCombo != null) { + graphicsCombo.setSelectedItem(mode); + } + } + public void setInitialCodePage(String cp) { if (cp != null && codePageCombo != null) { for (int i = 0; i < codePageCombo.getItemCount(); i++) { 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 d4bc17d..b5ba113 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -25,8 +25,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { // Font and cell dimensions private Font terminalFont; private Font boldTerminalFont; - private int cellWidth; - private int cellHeight; + int cellWidth; + int cellHeight; private int fontAscent; private int fontDescent; private int padding = 4; @@ -74,6 +74,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { private int selectionStartRow = -1, selectionStartCol = -1; private int selectionEndRow = -1, selectionEndCol = -1; private boolean isDragging = false; + private boolean isActivationClick = false; private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend // ========== Search Highlight state ========== @@ -117,7 +118,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { /** * Compute the horizontal render offset to center the grid within the panel. */ - private int getRenderOffsetX() { + int getRenderOffsetX() { int termCols = 80; if (client != null) termCols = client.getScreenBuffer().getDisplayCols(); int gridW = termCols * cellWidth; @@ -128,7 +129,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { /** * Compute the vertical render offset to center the grid within the panel. */ - private int getRenderOffsetY() { + int getRenderOffsetY() { int termRows = 24; if (client != null) termRows = client.getScreenBuffer().getDisplayRows(); int gridH = termRows * cellHeight; @@ -186,7 +187,16 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { MouseAdapter mouseHandler = new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { - requestFocusInWindow(); + Window win = SwingUtilities.getWindowAncestor(TerminalPanel.this); + boolean wasWindowInactive = (win != null && (!win.isActive() || !win.isFocused())); + boolean wasPanelUnfocused = !hasFocus(); + isActivationClick = wasWindowInactive || wasPanelUnfocused; + + if (wasWindowInactive) { + win.toFront(); + win.requestFocus(); + } + requestFocus(); // Right-click context menu if (SwingUtilities.isRightMouseButton(e)) { @@ -206,16 +216,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { row = Math.max(0, Math.min(row, displayRows - 1)); boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive(); - boolean isSelectableField = false; - if (sb.isFormatted()) { - int faPos = sb.findFieldAttribute(row * displayCols + col); - if (faPos >= 0) { - int fa = sb.getCell(faPos).fa & 0xFF; - isSelectableField = haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) && - !haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa); - } - } - if (isGraphic || lightPenMode || isSelectableField) { + if (isGraphic || lightPenMode) { clearSelection(); } else { selectionStartRow = row; @@ -292,7 +293,9 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { 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; + boolean isGraphic = client.getGocaDecoder() != null && + (client.getGocaDecoder().isGraphicsCursorActive() || + (client.getGraphicsPlane() != null && client.getGraphicsPlane().hasContent())); if (isGraphic) { clearSelection(); @@ -307,8 +310,11 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { 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; client.getInputProcessor().sendGraphicMouseAid( + gx, gy, haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER, button, e.isShiftDown(), @@ -318,25 +324,57 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { return; } - // Automatic Light-Pen / Selectable Field detection on mouse click: + boolean wasDrag = (selectionStartRow != -1 && selectionEndRow != -1 && + !(selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol)); + boolean activation = isActivationClick; + isActivationClick = false; + + if (wasDrag) { + // Preserve drag/block selection so user can interact with menus or press Ctrl+C + repaint(); + return; + } + + // Single click (not a drag): + clearSelection(); + sb.setCursorAddress(clickAddr); + + // If this was an activation click (e.g. clicking to resume focus), + // do not fire field actions or send AIDs + if (activation) { + refreshScreen(); + return; + } + + // Automatic Light-Pen / Selectable Field detection on mouse click (HoD parity): if (sb.isFormatted()) { int faPos = sb.findFieldAttribute(clickAddr); if (faPos >= 0) { int fa = sb.getCell(faPos).fa & 0xFF; if (haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) && !haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa)) { - clearSelection(); - if (client.lightPenSelect(clickAddr)) { - refreshScreen(); - return; + int size = displayRows * displayCols; + int desPos = (faPos + 1) % size; + char desAscii = (char) sb.getCell(desPos).ucs4; + int desEbcdic = sb.getCell(desPos).ec & 0xFF; + + boolean isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' || + desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E'); + boolean isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06'); + boolean isProtected = haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected(fa); + + // Only auto-select toggle fields (?/>), enter fields (&), or unprotected space fields + if (lightPenMode || isToggleField || isEnterField || !isProtected) { + if (client.lightPenSelect(clickAddr)) { + refreshScreen(); + return; + } } } } } if (lightPenMode) { - clearSelection(); - sb.setCursorAddress(clickAddr); boolean result = client.lightPenSelect(clickAddr); if (result) { refreshScreen(); @@ -346,12 +384,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } - if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) { - sb.setCursorAddress(clickAddr); - clearSelection(); - refreshScreen(); - return; - } + refreshScreen(); + return; } repaint(); } diff --git a/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java b/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java index 06b6777..6590ce9 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/storage/HostStorageAndProxyTest.java @@ -304,4 +304,40 @@ public class HostStorageAndProxyTest { dialog.dispose(); } } + + @Test + @DisplayName("SavedHost preserves all GraphicsMode settings across JSON serialization without clobbering to BOTH") + public void testSavedHostGraphicsModeSerializationRoundtrip() { + for (haus.nightmare.lib3270j.graphics.GraphicsMode gm : haus.nightmare.lib3270j.graphics.GraphicsMode.values()) { + SavedHost host = new SavedHost( + UUID.randomUUID().toString(), + "Gfx " + gm.name(), + "test.host.com", + 23, + 4, + 24, + 80, + "", + false, + "TSO", + false, + false, + true, + gm.name(), + "037", + "NONE", + "", + 0, + "", + "" + ); + String json = HostStorage.serializeHostsJson(List.of(host)); + List parsed = HostStorage.parseHostsJson(json); + assertEquals(1, parsed.size()); + assertEquals(gm.name(), parsed.get(0).getGraphicsMode(), + "Graphics mode " + gm.name() + " must be preserved and not coerced to BOTH"); + assertEquals(gm, parsed.get(0).toConnectionConfig().getGraphicsMode(), + "ConnectionConfig must reflect GraphicsMode." + gm.name()); + } + } } diff --git a/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java b/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java new file mode 100644 index 0000000..7545a6e --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ui/TerminalPanelSelectionTest.java @@ -0,0 +1,101 @@ +package haus.nightmare.j3270.ui; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.HeadlessException; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.awt.event.MouseMotionListener; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class TerminalPanelSelectionTest { + + @Test + @DisplayName("Selection drag over protected/intensified fields is preserved on mouse release (Bugs 3 & 4)") + public void testSelectionOverProtectedFieldsAndReleasePreservation() { + try { + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + + ScreenBuffer sb = client.getScreenBuffer(); + sb.erase(false); + + // Put a protected, intensified selectable field at pos 0 (ISPF header simulation) + sb.setCellFA(0, (byte) (FA_PRINTABLE | FA_PROTECT | FA_INT_HIGH_SEL)); + String headerText = " ISPF PRIMARY OPTION MENU"; + for (int i = 0; i < headerText.length(); i++) { + sb.getCell(1 + i).ucs4 = headerText.charAt(i); + sb.getCell(1 + i).ec = (byte) (0x40 + i); + } + + panel.setClient(client); + + // Cell dimensions in panel + int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10; + int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18; + int ox = panel.getRenderOffsetX(); + int oy = panel.getRenderOffsetY(); + + MouseListener[] mouseListeners = panel.getMouseListeners(); + MouseMotionListener[] motionListeners = panel.getMouseMotionListeners(); + assertTrue(mouseListeners.length > 0); + assertTrue(motionListeners.length > 0); + + MouseListener ml = mouseListeners[0]; + MouseMotionListener mml = motionListeners[0]; + + // 1. Mouse Pressed at cell (row 0, col 2) + int startX = ox + 2 * cellW + cellW / 2; + int startY = oy + cellH / 2; + MouseEvent pressEvent = new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), + 0, startX, startY, 1, false, MouseEvent.BUTTON1); + ml.mousePressed(pressEvent); + + // 2. Drag to cell (row 0, col 15) + int dragX = ox + 15 * cellW + cellW / 2; + int dragY = oy + cellH / 2; + MouseEvent dragEvent = new MouseEvent(panel, MouseEvent.MOUSE_DRAGGED, System.currentTimeMillis(), + 0, dragX, dragY, 1, false, MouseEvent.BUTTON1); + mml.mouseDragged(dragEvent); + + assertTrue(panel.hasSelection(), "Drag over protected field must create active selection (Bug 3)"); + + // 3. Mouse Released at cell (row 0, col 15) + MouseEvent releaseEvent = new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), + 0, dragX, dragY, 1, false, MouseEvent.BUTTON1); + ml.mouseReleased(releaseEvent); + + // Selection MUST NOT be dropped upon mouse release! (Bug 4) + assertTrue(panel.hasSelection(), "Selection must remain active after mouse release (Bug 4)"); + String selected = panel.getSelectedText(); + assertNotNull(selected); + assertTrue(selected.length() > 0, "Selected text must not be empty"); + + // 4. A subsequent single click without drag should clear the selection and move the cursor + int clickX = ox + 5 * cellW + cellW / 2; + int clickY = oy + cellH / 2; + MouseEvent clickPress = new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), + 0, clickX, clickY, 1, false, MouseEvent.BUTTON1); + ml.mousePressed(clickPress); + MouseEvent clickRelease = new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), + 0, clickX, clickY, 1, false, MouseEvent.BUTTON1); + ml.mouseReleased(clickRelease); + + assertFalse(panel.hasSelection(), "Single click must clear previous selection"); + assertEquals(5, sb.getCursorAddress(), "Single click must position cursor at clicked cell"); + + } catch (HeadlessException e) { + // Handled gracefully in headless environments + } + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java index e406d8d..0341a1b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ecl/ECLXfer.java @@ -456,11 +456,26 @@ public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener { this.bytesTransferred = 0; this.totalBytes = (config.isSend() && localFile.exists()) ? localFile.length() : 0; + // Auto-detect DBCS from translator codepage if not explicitly set + if (!config.isDbcs() && translator != null && translator.getCodePage() != null && translator.getCodePage().isDBCS()) { + config.setDbcs(true); + } + + // Wait for keyboard unlock before typing command + waitForKeyboardUnlock(2000); + + // If clear is requested, send Clear AID first to wipe intermediate screen status + if (config.isClear() && input != null && !input.isNvtMode()) { + input.sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_CLEAR); + try { Thread.sleep(150); } catch (InterruptedException ignored) {} + waitForKeyboardUnlock(2000); + } + String command = config.buildCommand(); log.info("ECLXfer: initiating transfer: " + command); - int capacity = input.kybdPrime(); - if (capacity < command.length()) { + int capacity = input != null ? input.kybdPrime() : 0; + if (input != null && capacity < command.length()) { String err = "Input field capacity insufficient for command (" + capacity + " chars)"; fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, err); return FTConstants.ECL_ERR_XFER_ABORT; @@ -469,10 +484,25 @@ public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener { setState(FTState.AWAIT_ACK); fireEvent(ECLXferEvent.XFER_STARTED, 0, "Transfer initiated"); - input.emulateInput(command + "\n"); + if (input != null) { + input.emulateInput(command + "\n"); + } return 0; } + private void waitForKeyboardUnlock(long timeoutMs) { + if (input == null) return; + long start = System.currentTimeMillis(); + while (input.isKeyboardLocked() && (System.currentTimeMillis() - start) < timeoutMs) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + private void parseOptionsIntoConfig(FTConfig config, String options) { if (options == null || options.trim().isEmpty()) return; config.parseOptions(options); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java index 569b8a6..c0008dd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java @@ -74,6 +74,11 @@ public class FTConfig { private String codePage = null; private boolean unicode = false; private int unicodeType = 0; // 0 = UCS2, 1 = UTF8 + private String programName = null; + private boolean dbcs = false; + private boolean optionNew = false; + private boolean clear = true; + private boolean optionNoso = false; // ========== Derived convenience getters ========== @@ -91,6 +96,37 @@ public class FTConfig { } public boolean isAppend() { return existAction == ExistAction.APPEND; } public boolean isOverwrite() { return existAction == ExistAction.REPLACE; } + public boolean isOptionNew() { return optionNew; } + public void setOptionNew(boolean optionNew) { this.optionNew = optionNew; } + public boolean isOptionNoso() { return optionNoso; } + public void setOptionNoso(boolean optionNoso) { this.optionNoso = optionNoso; } + public boolean isDbcs() { return dbcs; } + public void setDbcs(boolean dbcs) { this.dbcs = dbcs; } + public boolean isClear() { return clear; } + public void setClear(boolean clear) { this.clear = clear; } + + public String getProgramName() { + if (programName != null && !programName.trim().isEmpty()) { + return programName.trim(); + } + if (dbcs || isDbcsCodePage(codePage)) { + return "APVUFILE"; + } + return "IND$FILE"; + } + + public void setProgramName(String programName) { + this.programName = programName; + } + + private static boolean isDbcsCodePage(String cp) { + if (cp == null || cp.trim().isEmpty()) return false; + String clean = cp.trim().toUpperCase(); + if (clean.startsWith("CP")) clean = clean.substring(2); + return clean.equals("930") || clean.equals("939") || clean.equals("935") || + clean.equals("1388") || clean.equals("933") || clean.equals("937") || + clean.equals("1371"); + } // ========== Standard getters and setters ========== @@ -303,10 +339,22 @@ public class FTConfig { if (upper.contains("APPEND")) { this.existAction = ExistAction.APPEND; + } else if (upper.contains("NEW") && !upper.contains("NONEW")) { + this.optionNew = true; } else if (upper.contains("REPLACE") || upper.contains("OVERWRITE")) { this.existAction = ExistAction.REPLACE; } + if (upper.contains("NOSO")) { + this.optionNoso = true; + } + + if (upper.contains("APVUFILE")) { + this.programName = "APVUFILE"; + } else if (upper.contains("IND$FILE")) { + this.programName = "IND$FILE"; + } + if (upper.contains("CICS")) { this.hostType = HostType.CICS; } else if (upper.contains("CMS") || upper.contains("VM")) { @@ -332,6 +380,10 @@ public class FTConfig { String hostTrimmed = hostFilename.trim(); + if (hostType == HostType.TSO && hostTrimmed.contains(" ") && validateCmsFilename(hostTrimmed) == null) { + hostType = HostType.CMS; + } + if (hostType == HostType.TSO) { String tsoError = validateTsoDatasetName(hostTrimmed); if (tsoError != null) return tsoError; @@ -448,8 +500,8 @@ public class FTConfig { public String buildCommand() { StringBuilder cmd = new StringBuilder(); - // IND$FILE GET/PUT hostfile - cmd.append("IND$FILE "); + // Host program selection: IND$FILE or APVUFILE + cmd.append(getProgramName()).append(" "); cmd.append(isReceive() ? "GET " : "PUT "); cmd.append(hostFilename != null ? hostFilename.trim() : ""); @@ -477,16 +529,23 @@ public class FTConfig { opts.append("NOCRLF"); } - // Append (send only) - if (isAppend() && isSend()) { + if (isOptionNoso()) { if (opts.length() > 0) opts.append(" "); - opts.append("APPEND"); + opts.append("NOSO"); } - // Overwrite / Replace (if send or explicitly requested) - if (isOverwrite() && isSend()) { - if (opts.length() > 0) opts.append(" "); - opts.append("REPLACE"); + // Append / New / Replace (send only) + if (isSend()) { + if (isAppend()) { + if (opts.length() > 0) opts.append(" "); + opts.append("APPEND"); + } else if (isOptionNew()) { + if (opts.length() > 0) opts.append(" "); + opts.append("NEW"); + } else if (isOverwrite()) { + if (opts.length() > 0) opts.append(" "); + opts.append("REPLACE"); + } } // Host-specific send options diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java index 6b61a0d..a65d526 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConstants.java @@ -123,15 +123,69 @@ public final class FTConstants { // Error codes public static final int TR_ERR_EOF = 0x2200; public static final int TR_ERR_CMDFAIL = 0x0100; + public static final int TR_ERR_OPEN_FAILED = 0x0100; + public static final int TR_ERR_SEQ_NOT_ALLOWED = 0x0200; + public static final int TR_ERR_NOT_AUTHORIZED = 0x3E00; + public static final int TR_ERR_SYNTAX_ERROR = 0x6000; + public static final int TR_ERR_GET_PAST_EOF = 0x2200; - // DFT buffer size limits + // DFT buffer size limits (Default matching IBM HoD bmtuSize = 2500) public static final int DFT_MIN_BUF = 256; public static final int DFT_MAX_BUF = 32768; - public static final int DFT_BUF = 4096; + public static final int DFT_BUF = 2500; // AID for structured field response public static final int AID_SF = 0x88; + // DDM Stream Names (ASCII and EBCDIC) + public static final String STREAM_NAME_FT_DATA = "FT:DATA"; + public static final String STREAM_NAME_FT_MSG = "FT:MSG"; + public static final byte[] STREAM_NAME_FT_DATA_BYTES_ASCII = { + 0x46, 0x54, 0x3A, 0x44, 0x41, 0x54, 0x41 // 'F','T',':','D','A','T','A' + }; + public static final byte[] STREAM_NAME_FT_DATA_BYTES_EBCDIC = { + (byte) 0xC6, (byte) 0xE3, 0x7A, (byte) 0xC4, (byte) 0xC1, (byte) 0xE3, (byte) 0xC1 + }; + public static final byte[] STREAM_NAME_FT_MSG_BYTES_ASCII = { + 0x46, 0x54, 0x3A, 0x4D, 0x53, 0x47, 0x20 // 'F','T',':','M','S','G',' ' + }; + public static final byte[] STREAM_NAME_FT_MSG_BYTES_EBCDIC = { + (byte) 0xC6, (byte) 0xE3, 0x7A, (byte) 0xD4, (byte) 0xE2, (byte) 0xC7, 0x40 + }; + + // HoD Binary Packet Templates (Xfer3270.java) + public static final byte[] RB_OPEN_ACK = { + (byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x00, 0x09, (byte) 0xFF, (byte) 0xEF + }; + public static final byte[] RB_CLOSE_ACK = { + (byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x41, 0x09, (byte) 0xFF, (byte) 0xEF + }; + public static final byte[] RB_OPEN_FAILED = { + 0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x01, 0x00, (byte) 0xFF, (byte) 0xEF + }; + public static final byte[] RB_GET_PAST_EOF = { + (byte) 0x88, 0x00, 0x09, (byte) 0xD0, 0x46, 0x08, 0x69, 0x04, 0x22, 0x00, (byte) 0xFF, (byte) 0xEF + }; + public static final byte[] RB_UPLOAD_DATA_HEADER = { + 0x11, 0x11, (byte) 0xD0, 0x46, 0x05, 0x63, 0x06, 0x00, 0x00, 0x00, 0x00, + (byte) 0xC0, (byte) 0x80, 0x61, 0x00, 0x00 + }; + + /** + * Builds exact 14-byte Data Acknowledgement matching HoD Xfer3270: + * [0x88, 0x00, 0x0B, 0xD0, 0x47, 0x05, 0x63, 0x06, rec_b0, rec_b1, rec_b2, rec_b3, 0xFF, 0xEF] + */ + public static byte[] createDataAck(long recnum) { + return new byte[] { + (byte) 0x88, 0x00, 0x0B, (byte) 0xD0, 0x47, 0x05, 0x63, 0x06, + (byte) ((recnum >> 24) & 0xFF), + (byte) ((recnum >> 16) & 0xFF), + (byte) ((recnum >> 8) & 0xFF), + (byte) (recnum & 0xFF), + (byte) 0xFF, (byte) 0xEF + }; + } + // ========== Transfer State ========== public enum FTState { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java index e8a11df..c5bc2ac 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTCut.java @@ -215,11 +215,13 @@ public class FTCut { // CUT frames MUST have a skip field attribute at O_SF (1919) byte sfAttr = screen.getCellFAByte(O_SF); if (sfAttr == 0 || !isSkip(sfAttr)) { - // Not a CUT frame (likely local echo, menu return, or intermediate screen) + // Not a CUT frame (likely local echo, menu return, or host error screen) if (xferInProgress) { log.warning("CUT: Received non-CUT frame while transfer in progress. Host aborted."); xferInProgress = false; - listener.onTransferAborted("Host aborted transfer (returned to menu)"); + String hostErr = scanScreenForErrors(); + String abortMsg = hostErr != null ? hostErr : "Host aborted transfer (returned to menu)"; + listener.onTransferAborted(abortMsg); } return; } @@ -245,6 +247,38 @@ public class FTCut { } } + /** + * Scans rows 1-24 of the 3270 screen buffer for host error indicators. + * Checks for TSO (IKJ*), CMS (DMS*), CICS (DFH*), and generic transfer abort messages. + */ + public String scanScreenForErrors() { + if (screen == null) return null; + int rows = screen.getRows(); + int cols = screen.getCols(); + String[] errorPrefixes = { + "IKJ", "DMS", "DFH", "TRANS", "NOT FOUND", "ERROR", "REJECTED", "INVALID", "COMMAND NOT FOUND", "FILE NOT FOUND" + }; + for (int r = 0; r < rows; r++) { + StringBuilder lineSb = new StringBuilder(); + for (int c = 0; c < cols; c++) { + int pos = r * cols + c; + int ec = screen.getCellEC(pos); + if (ec != 0) { + lineSb.append(translator.ebcdicToUnicode(ec)); + } + } + String line = lineSb.toString().trim(); + if (line.isEmpty()) continue; + String lineUpper = line.toUpperCase(); + for (String prefix : errorPrefixes) { + if (lineUpper.contains(prefix)) { + return line; + } + } + } + return null; + } + private void checkForHostPrompts() { if (screen == null) return; int size = screen.getRows() * screen.getCols(); @@ -299,6 +333,10 @@ public class FTCut { cutAck(); String msg = extractHostMessage(); + if ("Host cancelled transfer".equals(msg)) { + String hostErr = scanScreenForErrors(); + if (hostErr != null) msg = hostErr; + } listener.onTransferAborted(msg); break; @@ -388,7 +426,7 @@ public class FTCut { log.fine("CUT: > DATA seq=" + seq + " len=" + count); expandedLength += count; listener.onBytesTransferred(expandedLength); - input.sendAidForFT(AID_ENTER); + paceAndSendAid(AID_ENTER); } // ========== Data (Download) ========== @@ -467,11 +505,11 @@ public class FTCut { screen.setCellFA(O_DR_SF, attr); log.fine("CUT: Retransmitting last upload frame (len=" + lastUploadCount + ")"); - input.sendAidForFT(AID_ENTER); + paceAndSendAid(AID_ENTER); } else { // In download mode, send ACK_RETRANSMIT (PF1) log.fine("CUT: Requesting frame retransmit from host via PF1"); - input.sendAidForFT(ACK_RETRANSMIT); + paceAndSendAid(ACK_RETRANSMIT); } } @@ -479,7 +517,7 @@ public class FTCut { private void cutAck() { log.fine("CUT: > ACK (Enter)"); - input.sendAidForFT(AID_ENTER); + paceAndSendAid(AID_ENTER); } // ========== Abort ========== @@ -492,10 +530,24 @@ public class FTCut { screen.setCell(RO_REASON_CODE, (reason >> 8) & 0xFF); screen.setCell(RO_REASON_CODE + 1, reason & 0xFF); - input.sendAidForFT(AID_PF2); + paceAndSendAid(AID_PF2); listener.onTransferAborted(message); } + private void paceAndSendAid(int aid) { + if (input == null) return; + long start = System.currentTimeMillis(); + while (input.isKeyboardLocked() && System.currentTimeMillis() - start < 1000) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + input.sendAidForFT((byte) aid); + } + // ========== Character Translation ========== /** diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java index 6508eaa..04c5958 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java @@ -49,6 +49,14 @@ public class FTDft { private int customMtuSize = 0; private int pendingByte = -1; + // Record formatting buffers and SO/SI tracking + private final ByteArrayOutputStream downloadRecordBuffer = new ByteArrayOutputStream(); + private boolean dbcsMode = false; + private boolean hasPendingByteInDBCS = false; + private byte pendingByteDBCS = 0; + private byte[] pendingUploadRecord = null; + private int pendingUploadRecordOffset = 0; + // Savebuf for Read Modified retransmit private byte[] dftSaveBuf = null; private int dftSaveBufLen = 0; @@ -159,6 +167,12 @@ public class FTDft { dftSaveBufLen = 0; lastCr = false; pendingByte = -1; + downloadRecordBuffer.reset(); + dbcsMode = false; + hasPendingByteInDBCS = false; + pendingByteDBCS = 0; + pendingUploadRecord = null; + pendingUploadRecordOffset = 0; } /** @@ -201,6 +215,12 @@ public class FTDft { log.fine("DFT: request code 0x" + Integer.toHexString(requestCode)); + // Check if this is a host error reply (e.g. TR_ERROR_REPLY 0x08, or contains TR_ERROR_HDR 0x6904) + if ((requestCode & 0xFF) == TR_ERROR_REPLY || isErrorField(data, payloadStart, offset + length - payloadStart)) { + handleHostErrorReply(data, payloadStart, offset + length - payloadStart); + return; + } + switch (requestCode) { case TR_OPEN_REQ: dftOpenRequest(data, offset, length); @@ -228,8 +248,74 @@ public class FTDft { } } + private boolean isErrorField(byte[] data, int start, int length) { + int end = Math.min(start + length, data.length); + for (int i = start; i + 3 < end; i++) { + int hdr = ((data[i] & 0xFF) << 8) | (data[i + 1] & 0xFF); + if (hdr == TR_ERROR_HDR) return true; + } + return false; + } + + private void handleHostErrorReply(byte[] data, int payloadStart, int length) { + int end = Math.min(payloadStart + length, data.length); + int errCode = 0; + for (int i = payloadStart; i + 3 < end; i++) { + int hdr = ((data[i] & 0xFF) << 8) | (data[i + 1] & 0xFF); + if (hdr == TR_ERROR_HDR) { + errCode = ((data[i + 2] & 0xFF) << 8) | (data[i + 3] & 0xFF); + break; + } + } + + String message; + switch (errCode) { + case TR_ERR_OPEN_FAILED: + message = "Host file open failed (file not found, cannot open, or dataset does not exist)"; + break; + case TR_ERR_SEQ_NOT_ALLOWED: + message = "Host transfer sequence not allowed (state violation)"; + break; + case TR_ERR_GET_PAST_EOF: + if (listener != null && listener.getCurrentState() == FTState.RUNNING) { + log.info("DFT: Host reported end of file (0x2200)"); + listener.onTransferComplete(null); + return; + } + message = "Host reached end of file"; + break; + case TR_ERR_NOT_AUTHORIZED: + message = "Host file access not authorized (security violation)"; + break; + case TR_ERR_SYNTAX_ERROR: + message = "Host command syntax error or invalid transfer options"; + break; + default: + message = "Host transfer error (code 0x" + Integer.toHexString(errCode) + ")"; + break; + } + log.warning("DFT host error reply: " + message + " (errCode=0x" + Integer.toHexString(errCode) + ")"); + dftAbort(message, TR_DATA_INSERT); + } + // ========== Open Request ========== + private static boolean containsBytes(byte[] data, int start, int end, byte[] pattern) { + if (data == null || pattern == null || pattern.length == 0) return false; + int max = Math.min(end, data.length) - pattern.length; + for (int i = Math.max(0, start); i <= max; i++) { + boolean match = true; + for (int j = 0; j < pattern.length; j++) { + if (data[i + j] != pattern[j]) { + match = false; + break; + } + } + if (match) return true; + } + return false; + } + private void dftOpenRequest(byte[] data, int sfOffset, int sfLength) { log.fine("DFT: Open request"); @@ -247,7 +333,10 @@ public class FTDft { // Check for host DDM file attributes embedded in open request parseDdmAttributes(data, sfOffset + 3, sfLength - 3); - if (isMessageStream(nameBuf)) { + int sfEnd = Math.min(sfOffset + sfLength, data.length); + if (containsBytes(data, sfOffset, sfEnd, STREAM_NAME_FT_MSG_BYTES_ASCII) || + containsBytes(data, sfOffset, sfEnd, STREAM_NAME_FT_MSG_BYTES_EBCDIC) || + isMessageStream(nameBuf)) { messageFlag = true; log.info("DFT: Open request for message stream (" + nameBuf + ")"); } else { @@ -258,7 +347,7 @@ public class FTDft { dftEof = false; recnum = 1; - // Acknowledge Open matching x3270 (SF_TRANSFER_DATA + 0x0009) + // Acknowledge Open matching IBM Host On-Demand dftOpenAck(); } @@ -322,12 +411,7 @@ public class FTDft { } private void dftOpenAck() { - ByteArrayOutputStream out = new ByteArrayOutputStream(6); - out.write(AID_SF); - out.write(0); out.write(5); // SF length - out.write(SF_TRANSFER_DATA); - out.write(0); out.write(9); // OpenAck response code 0x0009 - input.sendStructuredFieldData(out.toByteArray()); + input.sendStructuredFieldData(RB_OPEN_ACK.clone()); } // ========== Insert Request (host sending data for download) ========== @@ -441,31 +525,125 @@ public class FTDft { } /** - * Write download data to the local file, handling ASCII conversion. - * Matching x3270 upload_convert logic. + * Write download data to the local file, handling ASCII conversion, RECFM F/V formatting, and EOF stripping. + * Conforms to IBM Host On-Demand specification. */ private void writeDownloadData(byte[] data, int offset, int length, FTConfig config) throws IOException { - if (outputStream == null) return; + if (outputStream == null || length <= 0) return; if (config != null && config.isUnicode()) { outputStream.write(data, offset, length); return; } - if (!config.isAscii()) { + if (config != null && !config.isAscii()) { outputStream.write(data, offset, length); return; } + boolean isRecfmV = (config != null && config.getRecfm() == FTConfig.RecordFormat.VARIABLE) || + "V".equalsIgnoreCase(hostRecfm); + boolean isRecfmF = (config != null && config.getRecfm() == FTConfig.RecordFormat.FIXED) || + "F".equalsIgnoreCase(hostRecfm); + int lrecl = (config != null && config.getLrecl() > 0) ? config.getLrecl() : hostLrecl; + + if (isRecfmV) { + downloadRecordBuffer.write(data, offset, length); + processVariableDownloadRecords(config); + } else if (isRecfmF && lrecl > 0) { + downloadRecordBuffer.write(data, offset, length); + processFixedDownloadRecords(lrecl, config); + } else { + writeConvertedChunk(data, offset, length, config); + } + } + + private void processVariableDownloadRecords(FTConfig config) throws IOException { + while (downloadRecordBuffer.size() >= 4) { + byte[] buf = downloadRecordBuffer.toByteArray(); + int rdwLen = ((buf[0] & 0xFF) << 8) | (buf[1] & 0xFF); + if (rdwLen < 4) { + downloadRecordBuffer.reset(); + if (buf.length > 1) { + downloadRecordBuffer.write(buf, 1, buf.length - 1); + } + continue; + } + if (buf.length < rdwLen) { + break; + } + int recDataLen = rdwLen - 4; + writeConvertedChunk(buf, 4, recDataLen, config); + outputStream.write(System.lineSeparator().getBytes(StandardCharsets.UTF_8)); + + downloadRecordBuffer.reset(); + if (buf.length > rdwLen) { + downloadRecordBuffer.write(buf, rdwLen, buf.length - rdwLen); + } + } + } + + private void processFixedDownloadRecords(int lrecl, FTConfig config) throws IOException { + while (downloadRecordBuffer.size() >= lrecl) { + byte[] buf = downloadRecordBuffer.toByteArray(); + int end = lrecl; + while (end > 0) { + int b = buf[end - 1] & 0xFF; + if (b == 0x20 || b == 0x40 || (config != null && config.isRemapFlag() && b == FT2ASC[0x40])) { + end--; + } else { + break; + } + } + writeConvertedChunk(buf, 0, end, config); + outputStream.write(System.lineSeparator().getBytes(StandardCharsets.UTF_8)); + + downloadRecordBuffer.reset(); + if (buf.length > lrecl) { + downloadRecordBuffer.write(buf, lrecl, buf.length - lrecl); + } + } + } + + private void flushRemainingDownloadRecords() throws IOException { + if (outputStream == null || downloadRecordBuffer.size() == 0) return; + FTConfig config = listener != null ? listener.getConfig() : null; + byte[] remaining = downloadRecordBuffer.toByteArray(); + downloadRecordBuffer.reset(); + if (config != null && config.isAscii()) { + int end = remaining.length; + while (end > 0) { + int b = remaining[end - 1] & 0xFF; + if (b == 0x20 || b == 0x40 || (config.isRemapFlag() && b == FT2ASC[0x40])) { + end--; + } else { + break; + } + } + if (end > 0) { + writeConvertedChunk(remaining, 0, end, config); + } + } else { + outputStream.write(remaining); + } + outputStream.flush(); + } + + private void writeConvertedChunk(byte[] data, int offset, int length, FTConfig config) throws IOException { + if (outputStream == null || length <= 0) return; for (int i = 0; i < length; i++) { int b = data[offset + i] & 0xFF; - if (config.isCrFlag() && (b == '\r' || b == EOF_CTRL_Z || b == EOF_CTRL_D)) { - continue; // Strip CR and EOF (^Z / ^D) + if (b == EOF_CTRL_Z || b == EOF_CTRL_D) { + continue; // Strip EOF (^Z / ^D) } - if (!config.isRemapFlag()) { + if (config != null && config.isCrFlag() && b == '\r') { + continue; // Strip CR + } + + if (config == null || !config.isRemapFlag()) { outputStream.write(b); continue; } @@ -506,30 +684,42 @@ public class FTDft { byte[] readBuf = new byte[numbytes]; int totalRead = 0; + boolean isRecfmF = (config != null && config.getRecfm() == FTConfig.RecordFormat.FIXED) || + "F".equalsIgnoreCase(hostRecfm); + boolean isRecfmV = (config != null && config.getRecfm() == FTConfig.RecordFormat.VARIABLE) || + "V".equalsIgnoreCase(hostRecfm); + int lrecl = (config != null && config.getLrecl() > 0) ? config.getLrecl() : hostLrecl; + try { - while (!dftEof && totalRead < numbytes) { - if (config != null && config.isUnicode() && inputStream instanceof haus.nightmare.lib3270j.xfer.XferFileInputUnicode) { - int n = ((haus.nightmare.lib3270j.xfer.XferFileInputUnicode) inputStream).readData(readBuf, totalRead, numbytes - totalRead); - if (n <= 0) { - dftEof = true; - break; + if (config != null && config.isAscii() && isRecfmF && lrecl > 0) { + totalRead = readFixedRecordUpload(readBuf, numbytes, lrecl, config); + } else if (config != null && config.isAscii() && isRecfmV) { + totalRead = readVariableRecordUpload(readBuf, numbytes, config); + } else { + while (!dftEof && totalRead < numbytes) { + if (config != null && config.isUnicode() && inputStream instanceof haus.nightmare.lib3270j.xfer.XferFileInputUnicode) { + int n = ((haus.nightmare.lib3270j.xfer.XferFileInputUnicode) inputStream).readData(readBuf, totalRead, numbytes - totalRead); + if (n <= 0) { + dftEof = true; + break; + } + totalRead += n; + } else if (config != null && config.isAscii()) { + int b = dftAsciiRead(config); + if (b == -1) { + dftEof = true; + break; + } + readBuf[totalRead++] = (byte) b; + } else { + if (inputStream == null) { dftEof = true; break; } + int n = inputStream.read(readBuf, totalRead, numbytes - totalRead); + if (n <= 0) { + dftEof = true; + break; + } + totalRead += n; } - totalRead += n; - } else if (config.isAscii()) { - int b = dftAsciiRead(config); - if (b == -1) { - dftEof = true; - break; - } - readBuf[totalRead++] = (byte) b; - } else { - if (inputStream == null) { dftEof = true; break; } - int n = inputStream.read(readBuf, totalRead, numbytes - totalRead); - if (n <= 0) { - dftEof = true; - break; - } - totalRead += n; } } } catch (IOException e) { @@ -537,17 +727,17 @@ public class FTDft { return; } - ByteArrayOutputStream out = new ByteArrayOutputStream(bufferSize); - out.write(AID_SF); - - int sfLenPos = out.size(); - out.write(0); out.write(0); - - out.write(SF_TRANSFER_DATA); - if (totalRead > 0) { log.fine("DFT: > GetReply rec=" + recnum + " " + totalRead + " bytes"); + ByteArrayOutputStream out = new ByteArrayOutputStream(bufferSize); + out.write(AID_SF); + + int sfLenPos = out.size(); + out.write(0); out.write(0); + + out.write(SF_TRANSFER_DATA); + out.write((TR_GET_REPLY >> 8) & 0xFF); out.write(TR_GET_REPLY & 0xFF); @@ -570,31 +760,167 @@ public class FTDft { out.write(readBuf, 0, totalRead); bytesTransferred += totalRead; + + byte[] result = out.toByteArray(); + int sfLen = result.length - 1; + result[sfLenPos] = (byte) ((sfLen >> 8) & 0xFF); + result[sfLenPos + 1] = (byte) (sfLen & 0xFF); + + dftSaveBuf = result.clone(); + dftSaveBufLen = result.length; + + input.sendStructuredFieldData(result); + listener.onBytesTransferred(bytesTransferred); } else { log.fine("DFT: > GetReply EOF"); - - out.write((TR_GET_REQ >> 8) & 0xFF); - out.write(TR_ERROR_REPLY & 0xFF); - - out.write((TR_ERROR_HDR >> 8) & 0xFF); - out.write(TR_ERROR_HDR & 0xFF); - - out.write((TR_ERR_EOF >> 8) & 0xFF); - out.write(TR_ERR_EOF & 0xFF); - dftEof = true; + dftSaveBuf = RB_GET_PAST_EOF.clone(); + dftSaveBufLen = RB_GET_PAST_EOF.length; + input.sendStructuredFieldData(RB_GET_PAST_EOF.clone()); + listener.onBytesTransferred(bytesTransferred); + } + } + + private byte[] readNextUploadLineConverted(FTConfig config) throws IOException { + if (inputStream == null || dftEof) return null; + ByteArrayOutputStream lineBytes = new ByteArrayOutputStream(); + int c; + boolean hasData = false; + while ((c = inputStream.read()) != -1) { + hasData = true; + if (c == '\r') { + continue; // ignore CR + } + if (c == '\n') { + break; + } + if (c == EOF_CTRL_Z || c == EOF_CTRL_D) { + break; + } + lineBytes.write(c); + } + if (!hasData && c == -1) { + return null; } - byte[] result = out.toByteArray(); - int sfLen = result.length - 1; - result[sfLenPos] = (byte) ((sfLen >> 8) & 0xFF); - result[sfLenPos + 1] = (byte) (sfLen & 0xFF); + byte[] raw = lineBytes.toByteArray(); + return convertLineForUpload(raw, config); + } - dftSaveBuf = result.clone(); - dftSaveBufLen = result.length; + private byte[] convertLineForUpload(byte[] raw, FTConfig config) { + String lineStr = new String(raw, StandardCharsets.UTF_8); + ByteArrayOutputStream converted = new ByteArrayOutputStream(raw.length + 8); + boolean isDbcs = (config != null && config.isDbcs()) || (translator != null && translator.getCodePage() != null && translator.getCodePage().isDBCS()); + boolean noSo = config != null && config.isOptionNoso(); + boolean inDbcsState = false; - input.sendStructuredFieldData(result); - listener.onBytesTransferred(bytesTransferred); + for (int i = 0; i < lineStr.length(); i++) { + char ch = lineStr.charAt(i); + int ebc = translator != null ? translator.unicodeToEbcdic(ch) : -1; + boolean charIsDbcs = isDbcs && (ch > 0xFF || ebc > 0xFF); + if (charIsDbcs) { + if (!inDbcsState && !noSo) { + converted.write(0x0E); // Shift-Out + inDbcsState = true; + } + converted.write((ebc >> 8) & 0xFF); + converted.write(ebc & 0xFF); + } else { + if (inDbcsState && !noSo) { + converted.write(0x0F); // Shift-In + inDbcsState = false; + } + converted.write(convertAsciiByte(ch, config)); + } + } + if (inDbcsState && !noSo) { + converted.write(0x0F); // Shift-In at end of record + } + return converted.toByteArray(); + } + + private int readFixedRecordUpload(byte[] outBuf, int maxBytes, int lrecl, FTConfig config) throws IOException { + int totalRead = 0; + int padByte = (config != null && config.isRemapFlag()) ? FT2ASC[0x40] : 0x20; + + while (totalRead + lrecl <= maxBytes && !dftEof) { + byte[] rec; + if (pendingUploadRecord != null) { + rec = pendingUploadRecord; + pendingUploadRecord = null; + } else { + rec = readNextUploadLineConverted(config); + if (rec == null) { + dftEof = true; + break; + } + } + + if (rec.length <= lrecl) { + System.arraycopy(rec, 0, outBuf, totalRead, rec.length); + totalRead += rec.length; + int padCount = lrecl - rec.length; + for (int p = 0; p < padCount; p++) { + outBuf[totalRead++] = (byte) padByte; + } + } else { + // Longer than lrecl: split + System.arraycopy(rec, 0, outBuf, totalRead, lrecl); + totalRead += lrecl; + int rem = rec.length - lrecl; + pendingUploadRecord = new byte[rem]; + System.arraycopy(rec, lrecl, pendingUploadRecord, 0, rem); + } + } + return totalRead; + } + + private int readVariableRecordUpload(byte[] outBuf, int maxBytes, FTConfig config) throws IOException { + int totalRead = 0; + while (totalRead + 4 < maxBytes && !dftEof) { + byte[] rec; + if (pendingUploadRecord != null) { + rec = pendingUploadRecord; + pendingUploadRecord = null; + } else { + rec = readNextUploadLineConverted(config); + if (rec == null) { + dftEof = true; + break; + } + } + + int needed = rec.length + 4; + if (totalRead + needed <= maxBytes) { + outBuf[totalRead++] = (byte) ((needed >> 8) & 0xFF); + outBuf[totalRead++] = (byte) (needed & 0xFF); + outBuf[totalRead++] = 0; + outBuf[totalRead++] = 0; + System.arraycopy(rec, 0, outBuf, totalRead, rec.length); + totalRead += rec.length; + } else { + if (totalRead > 0) { + // Save for next MTU buffer + pendingUploadRecord = rec; + break; + } else { + // Single record exceeds entire MTU: split + int chunk = maxBytes - 4; + int rdwLen = chunk + 4; + outBuf[totalRead++] = (byte) ((rdwLen >> 8) & 0xFF); + outBuf[totalRead++] = (byte) (rdwLen & 0xFF); + outBuf[totalRead++] = 0; + outBuf[totalRead++] = 0; + System.arraycopy(rec, 0, outBuf, totalRead, chunk); + totalRead += chunk; + int rem = rec.length - chunk; + pendingUploadRecord = new byte[rem]; + System.arraycopy(rec, chunk, pendingUploadRecord, 0, rem); + break; + } + } + } + return totalRead; } /** @@ -717,14 +1043,13 @@ public class FTDft { private void dftCloseRequest() { log.fine("DFT: Close"); - ByteArrayOutputStream out = new ByteArrayOutputStream(6); - out.write(AID_SF); - out.write(0); out.write(5); - out.write(SF_TRANSFER_DATA); - out.write((TR_CLOSE_REPLY >> 8) & 0xFF); - out.write(TR_CLOSE_REPLY & 0xFF); + try { + flushRemainingDownloadRecords(); + } catch (IOException e) { + log.warning("DFT: Error flushing download buffer on close: " + e.getMessage()); + } - input.sendStructuredFieldData(out.toByteArray()); + input.sendStructuredFieldData(RB_CLOSE_ACK.clone()); if (listener.getCurrentState() != FTState.NONE) { log.info("DFT: File transfer completed on close request (" + bytesTransferred + " bytes)"); @@ -735,21 +1060,7 @@ public class FTDft { // ========== Data Acknowledgement ========== private void dftDataAck() { - ByteArrayOutputStream out = new ByteArrayOutputStream(12); - out.write(AID_SF); - out.write(0); out.write(11); // SF length = 11 (0x000B) - out.write(SF_TRANSFER_DATA); - out.write((TR_NORMAL_REPLY >> 8) & 0xFF); - out.write(TR_NORMAL_REPLY & 0xFF); - out.write((TR_RECNUM_HDR >> 8) & 0xFF); - out.write(TR_RECNUM_HDR & 0xFF); - out.write((int) ((recnum >> 24) & 0xFF)); - out.write((int) ((recnum >> 16) & 0xFF)); - out.write((int) ((recnum >> 8) & 0xFF)); - out.write((int) (recnum & 0xFF)); - - recnum++; - input.sendStructuredFieldData(out.toByteArray()); + input.sendStructuredFieldData(createDataAck(recnum++)); } // ========== Abort ========== @@ -757,18 +1068,22 @@ public class FTDft { private void dftAbort(String message, int code) { log.warning("DFT: ABORT — " + message); - ByteArrayOutputStream out = new ByteArrayOutputStream(10); - out.write(AID_SF); - out.write(0); out.write(9); - out.write(SF_TRANSFER_DATA); - out.write((code >> 8) & 0xFF); - out.write(TR_ERROR_REPLY & 0xFF); - out.write((TR_ERROR_HDR >> 8) & 0xFF); - out.write(TR_ERROR_HDR & 0xFF); - out.write((TR_ERR_CMDFAIL >> 8) & 0xFF); - out.write(TR_ERR_CMDFAIL & 0xFF); + if (code == TR_OPEN_REQ) { + input.sendStructuredFieldData(RB_OPEN_FAILED.clone()); + } else { + ByteArrayOutputStream out = new ByteArrayOutputStream(10); + out.write(AID_SF); + out.write(0); out.write(9); + out.write(SF_TRANSFER_DATA); + out.write((code >> 8) & 0xFF); + out.write(TR_ERROR_REPLY & 0xFF); + out.write((TR_ERROR_HDR >> 8) & 0xFF); + out.write(TR_ERROR_HDR & 0xFF); + out.write((TR_ERR_CMDFAIL >> 8) & 0xFF); + out.write(TR_ERR_CMDFAIL & 0xFF); - input.sendStructuredFieldData(out.toByteArray()); + input.sendStructuredFieldData(out.toByteArray()); + } listener.onTransferAborted(message); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FilletPts.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FilletPts.java index aff5e30..53c029b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FilletPts.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/FilletPts.java @@ -10,10 +10,14 @@ import java.util.List; *

A GOCA fillet curve passes from the initial point P_0 to the terminal point P_{N-1}, * bending tangentially toward each intermediate control point P_i. */ -public class FilletPts { +public class FilletPts extends HODEllipse { public static final int DEFAULT_STEPS_PER_SEGMENT = 24; + public FilletPts() { + super(0.0, 0.0, 0.0, 0.0); + } + /** * Calculates interpolated spline vertices across control points using double precision. * @@ -116,17 +120,115 @@ public class FilletPts { } /** - * Interpolates fillet points into provided output buffers. + * Interpolates fillet points into provided output buffers using HoD's exact rational quadratic + * polynomial spline algorithm. * Matches IBM Host On-Demand FilletPts.getFilletPoints. */ - public int getFilletPoints(int[] xIn, int[] yIn, int n, int[] xOut, int[] yOut) { - if (xIn == null || yIn == null || n <= 0 || xOut == null || yOut == null) { + public int getFilletPoints(int[] nArray, int[] nArray2, int n, int[] nArray3, int[] nArray4) { + if (nArray == null || nArray2 == null || n <= 0 || nArray3 == null || nArray4 == null) { return 0; } - int[][] pts = calculate(xIn, yIn, n, 16); - int count = Math.min(pts[0].length, Math.min(xOut.length, yOut.length)); - System.arraycopy(pts[0], 0, xOut, 0, count); - System.arraycopy(pts[1], 0, yOut, 0, count); - return count; + + int n2; + int n3 = 0; + int n4 = 0; + boolean bl = false; + int n5 = 0; + for (n2 = 1; n2 < n; ++n2) { + if (nArray[n2] != nArray[n5] || nArray2[n2] != nArray2[n5]) continue; + bl = true; + } + if (bl) { + int[] nArray5 = new int[n]; + int[] nArray6 = new int[n]; + nArray5[0] = nArray[0]; + nArray6[0] = nArray2[0]; + n5 = 0; + for (n2 = 1; n2 < n; ++n2) { + if (nArray[n2] == nArray[n5] && nArray2[n2] == nArray2[n5]) continue; + nArray5[++n5] = nArray[n2]; + nArray6[n5] = nArray2[n2]; + } + nArray = nArray5; + nArray2 = nArray6; + n = n5 + 1; + } + nArray3[n3] = nArray[0]; + nArray4[n3] = nArray2[0]; + int n6 = nArray[0]; + int n9 = nArray2[0]; + n3++; + + if (n == 1) { + nArray3[n3] = nArray3[0]; + nArray4[n3++] = nArray4[0]; + return n3; + } + if (n == 2) { + nArray3[n3] = nArray[1]; + nArray4[n3++] = nArray2[1]; + return n3; + } + int[] nArray7 = new int[3]; + int[] nArray8 = new int[3]; + nArray7[0] = nArray[n4]; + nArray8[0] = nArray2[n4++]; + --n; + nArray7[1] = nArray[n4]; + nArray8[1] = nArray2[n4++]; + --n; + while (true) { + if (n == 0) { + nArray3[n3] = nArray7[1]; + nArray4[n3++] = nArray8[1]; + return n3; + } + nArray7[2] = nArray[n4]; + nArray8[2] = nArray2[n4]; + if (n > 1) { + nArray7[2] = (nArray7[2] + nArray7[1]) / 2; + nArray8[2] = (nArray8[2] + nArray8[1]) / 2; + } + --n; + this.dCenterX = nArray7[0] + nArray7[2] - nArray7[1]; + this.dCenterY = nArray8[0] + nArray8[2] - nArray8[1]; + this.dP = (double) nArray7[0] - this.dCenterX; + this.dQ = (double) nArray8[2] - this.dCenterY; + this.dR = (double) nArray7[2] - this.dCenterX; + this.dS = (double) nArray8[0] - this.dCenterY; + int n10 = (int) this.dP; + int n11 = (int) this.dQ; + int n12 = (int) this.dR; + int n13 = (int) this.dS; + int n14 = Math.max(Math.abs(n10) + Math.abs(n13), Math.abs(n12) + Math.abs(n11)); + this.dRad = n14; + if (n14 == 0) { + return n3; + } + int n15 = this.bres(this.dRad); + for (n2 = 0; n2 <= n15; ++n2) { + this.filletHODSegment2(nArray3, nArray4, n3, this.dXq[n2], this.dYq[n2]); + if (n6 == nArray3[n3] && n9 == nArray4[n3]) continue; + n6 = nArray3[n3]; + n9 = nArray4[n3++]; + } + for (n2 = n15; n2 >= 0; --n2) { + this.filletHODSegment2(nArray3, nArray4, n3, this.dYq[n2], this.dXq[n2]); + if (n6 == nArray3[n3] && n9 == nArray4[n3]) continue; + n6 = nArray3[n3]; + n9 = nArray4[n3++]; + } + if (n6 != nArray7[2] || n9 != nArray8[2]) { + nArray3[n3] = nArray7[2]; + nArray4[n3++] = nArray8[2]; + } + if (n == 0) { + return n3; + } + nArray7[0] = n6 = nArray7[2]; + nArray8[0] = n9 = nArray8[2]; + nArray7[1] = nArray[n4]; + nArray8[1] = nArray2[n4++]; + } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java index 33878b9..fd99b41 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GddmCoordinateTransform.java @@ -50,6 +50,19 @@ public class GddmCoordinateTransform { private double transformX = 1.0; private double transformY = 1.0; + // Aspect Ratio parameters (P, Q, R, S) per IBM Host On-Demand (HODDecoder / GSAP) + public static final int DEFAULT_P = 1; + public static final int DEFAULT_Q = 1; + public static final int DEFAULT_R = 0; + public static final int DEFAULT_S = 0; + + private int paramP = DEFAULT_P; + private int paramQ = DEFAULT_Q; + private int paramR = DEFAULT_R; + private int paramS = DEFAULT_S; + private double scaleX = 1.0; + private double scaleY = 1.0; + public GddmCoordinateTransform() { this(80, 24, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16); } @@ -270,4 +283,68 @@ public class GddmCoordinateTransform { public int convertYtoRow(int y) { return (defaultCharHeight > 0) ? (y / defaultCharHeight) + 1 : 1; } + + // ========================================================================= + // Aspect Ratio & Coordinate Inversion (Phase 17.2) + // ========================================================================= + + /** + * Sets GOCA aspect ratio parameters P, Q, R, S and updates scaling factors. + * ScaleX = P/Q, ScaleY = R/S. Defaults: P=1, Q=1, R=0, S=0. + */ + public void setAspectRatio(int p, int q, int r, int s) { + this.paramP = p; + this.paramQ = q; + this.paramR = r; + this.paramS = s; + this.scaleX = (q != 0) ? ((double) p / (double) q) : 1.0; + this.scaleY = (s != 0) ? ((double) r / (double) s) : 1.0; + if (this.scaleX <= 0.0) this.scaleX = 1.0; + if (this.scaleY <= 0.0) this.scaleY = 1.0; + } + + /** + * Resets aspect ratio parameters to defaults: P=1, Q=1, R=0, S=0. + */ + public void resetAspectRatio() { + setAspectRatio(DEFAULT_P, DEFAULT_Q, DEFAULT_R, DEFAULT_S); + } + + public int getParamP() { return paramP; } + public int getParamQ() { return paramQ; } + public int getParamR() { return paramR; } + public int getParamS() { return paramS; } + + public double getScaleX() { return scaleX; } + public double getScaleY() { return scaleY; } + + /** + * Inverts GOCA Cartesian Y (positive upward from bottom-left) to raster Y (top-down): + * Y_screen = (originY - gocaY) * ScaleY. + */ + public int mapGocaYToScreen(int originY, int gocaY) { + return (int) Math.round((originY - gocaY) * scaleY); + } + + /** + * Inverts GOCA Cartesian Y to raster screen Y using the transform's default origin YMax. + */ + public int mapGocaYToScreen(int gocaY) { + return mapGocaYToScreen(getYMax(), gocaY); + } + + /** + * Inverts raster Y (top-down) back to GOCA Cartesian Y (positive upward from bottom-left): + * Y_goca = originY - (screenY / ScaleY). + */ + public int mapScreenYToGoca(int originY, int screenY) { + return originY - (scaleY > 0 ? (int) Math.round(screenY / scaleY) : screenY); + } + + /** + * Inverts raster screen Y back to GOCA Cartesian Y using the transform's default origin YMax. + */ + public int mapScreenYToGoca(int screenY) { + return mapScreenYToGoca(getYMax(), screenY); + } } 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 a5140d6..3c1856b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GocaDecoder.java @@ -40,10 +40,15 @@ public class GocaDecoder { private double charHeight = 16.0; private int charSet = 0; private int charPrecision = GocaConstants.CP_STRING; - private int arcParamP = 1; - private int arcParamQ = 0; - private int arcParamR = 0; - private int arcParamS = 1; + public static final int DEFAULT_ARC_P = 1; + public static final int DEFAULT_ARC_Q = 1; + public static final int DEFAULT_ARC_R = 0; + public static final int DEFAULT_ARC_S = 0; + + private int arcParamP = DEFAULT_ARC_P; + private int arcParamQ = DEFAULT_ARC_Q; + private int arcParamR = DEFAULT_ARC_R; + private int arcParamS = DEFAULT_ARC_S; private boolean segChained = false; private boolean segDynamic = false; @@ -77,6 +82,7 @@ public class GocaDecoder { private int imgHeight = 0; private int imgBitDepth = GocaConstants.BPP_1; private int imgCompression = GocaConstants.IMG_UNCOMPRESSED; + private int imgMultiplier = 1; private final List imgBuffer = new ArrayList<>(); // Segment Store for retained graphics / segment calling (G_GCALL 0x2A) @@ -368,7 +374,15 @@ public class GocaDecoder { inImage = false; imgBitDepth = GocaConstants.BPP_1; imgCompression = GocaConstants.IMG_UNCOMPRESSED; + imgMultiplier = 1; imgBuffer.clear(); + arcParamP = DEFAULT_ARC_P; + arcParamQ = DEFAULT_ARC_Q; + arcParamR = DEFAULT_ARC_R; + arcParamS = DEFAULT_ARC_S; + if (plane != null && plane.getTransform() != null) { + plane.getTransform().setAspectRatio(arcParamP, arcParamQ, arcParamR, arcParamS); + } } private byte[] partialOrderBuffer = new byte[0]; @@ -567,7 +581,10 @@ public class GocaDecoder { int w = 0, h = 0; int bitDepth = GocaConstants.BPP_1; int compression = GocaConstants.IMG_UNCOMPRESSED; + int imult = 1; if (payloadLen >= 6 && idx + 2 + payloadLen <= end) { + imult = readCoord(inputData, idx + 2); + if (imult <= 0) imult = 1; w = readCoord(inputData, idx + 4); h = readCoord(inputData, idx + 6); if (payloadLen >= 7) { @@ -592,7 +609,7 @@ public class GocaDecoder { compression = inputData[idx + 7] & 0xFF; } } - beginImage(curX, curY, w, h, bitDepth, compression); + beginImage(curX, curY, w, h, bitDepth, compression, imult); idx += orderLen; break; } @@ -672,13 +689,25 @@ public class GocaDecoder { idx += orderLen; break; } - case GocaConstants.G_GSAP: { // Arc Parameters (0x22) + case GocaConstants.G_GSAP: { // Arc Parameters / Set Aspect Ratio (0x22) if (idx + 9 < end) { arcParamP = readCoord(inputData, idx + 2); arcParamQ = readCoord(inputData, idx + 4); arcParamR = readCoord(inputData, idx + 6); arcParamS = readCoord(inputData, idx + 8); + } else { + arcParamP = DEFAULT_ARC_P; + arcParamQ = DEFAULT_ARC_Q; + arcParamR = DEFAULT_ARC_R; + arcParamS = DEFAULT_ARC_S; } + if (plane != null && plane.getTransform() != null) { + plane.getTransform().setAspectRatio(arcParamP, arcParamQ, arcParamR, arcParamS); + } + logger.info(String.format("GOCA GSAP: P=%d Q=%d R=%d S=%d (ScaleX=%.2f ScaleY=%.2f)", + arcParamP, arcParamQ, arcParamR, arcParamS, + (arcParamQ != 0 ? (double) arcParamP / arcParamQ : 1.0), + (arcParamS != 0 ? (double) arcParamR / arcParamS : 1.0))); idx += orderLen; break; } @@ -1012,7 +1041,10 @@ public class GocaDecoder { int w, h; int bitDepth = GocaConstants.BPP_1; int compression = GocaConstants.IMG_UNCOMPRESSED; + int imult = 1; if (payloadLen >= 10) { + imult = readCoord(inputData, idx + 6); + if (imult <= 0) imult = 1; w = readCoord(inputData, idx + 8); h = readCoord(inputData, idx + 10); if (payloadLen >= 11) { @@ -1037,7 +1069,7 @@ public class GocaDecoder { compression = inputData[idx + 11] & 0xFF; } } - beginImage(x, y, w, h, bitDepth, compression); + beginImage(x, y, w, h, bitDepth, compression, imult); } idx += orderLen; break; @@ -1309,10 +1341,14 @@ public class GocaDecoder { } private void beginImage(int x, int y, int w, int h) { - beginImage(x, y, w, h, GocaConstants.BPP_1, GocaConstants.IMG_UNCOMPRESSED); + beginImage(x, y, w, h, GocaConstants.BPP_1, GocaConstants.IMG_UNCOMPRESSED, 1); } private void beginImage(int x, int y, int w, int h, int bitDepth, int compression) { + beginImage(x, y, w, h, bitDepth, compression, 1); + } + + private void beginImage(int x, int y, int w, int h, int bitDepth, int compression, int multiplier) { this.inImage = true; this.imgX = x; this.imgY = y; @@ -1320,6 +1356,7 @@ public class GocaDecoder { this.imgHeight = h; this.imgBitDepth = bitDepth; this.imgCompression = compression; + this.imgMultiplier = (multiplier > 0) ? multiplier : 1; this.imgBuffer.clear(); } @@ -1335,7 +1372,7 @@ public class GocaDecoder { int px = plane.mapX(imgX); int py = plane.mapY(imgY); - plane.drawImage(px, py, imgWidth, imgHeight, bytes, curColor, imgBitDepth, imgCompression); + plane.drawImage(px, py, imgWidth, imgHeight, bytes, curColor, imgBitDepth, imgCompression, imgMultiplier); inImage = false; imgBuffer.clear(); } @@ -1429,38 +1466,191 @@ public class GocaDecoder { private void processArc(byte[] data, int off, int len, boolean fromCurPos, boolean isFull) { int pos = off; + int end = off + len; + + if (isFull) { + // GFARC (0xC7) or GCFARC (0x87) Full Arc / Ellipse + int centerX = curX; + int centerY = curY; + if (!fromCurPos && pos + 4 <= end) { + centerX = readCoord(data, pos); + centerY = readCoord(data, pos + 2); + pos += 4; + } + + double multiplier = 1.0; + if (pos + 2 <= end) { + int intPart = data[pos]; + int fracPart = data[pos + 1] & 0xFF; + multiplier = intPart + fracPart / 256.0; + if (multiplier <= 0.0) multiplier = 1.0; + pos += 2; + } + + double dP = arcParamP * multiplier; + double dQ = arcParamQ * multiplier; + double dR = arcParamR * multiplier; + double dS = arcParamS * multiplier; + + int[] arcX = new int[256]; + int[] arcY = new int[256]; + int nPts = HODEllipse.getHODEllipse(dP, dQ, dR, dS, arcX, arcY, centerX, centerY); + + for (int i = 0; i < nPts; i++) { + trackPoint(arcX[i], arcY[i]); + } + + if (inArea) { + for (int i = 0; i < nPts; i++) { + if (i == 0 && currentPolyPts == 0) { + addAreaLineStart(arcX[i], arcY[i]); + } else { + addAreaPoint(arcX[i], arcY[i]); + } + } + } else { + for (int i = 0; i < nPts - 1; i++) { + plane.drawLine(plane.mapXDouble(arcX[i]), plane.mapYDouble(arcY[i]), + plane.mapXDouble(arcX[i + 1]), plane.mapYDouble(arcY[i + 1]), + curColor, lineType, lineWidth); + } + } + + curX = centerX; + curY = centerY; + return; + } + + // Partial Arc: GARC (0xC6) or GCARC (0x86) + // 3-point circular arc definition per GOCA specs: start, intermediate, end + int x0 = curX; + int y0 = curY; + boolean has3Point = false; + int x1 = 0, y1 = 0, x2 = 0, y2 = 0; + + if (!fromCurPos && len >= 12 && pos + 12 <= end) { + x0 = readCoord(data, pos); + y0 = readCoord(data, pos + 2); + x1 = readCoord(data, pos + 4); + y1 = readCoord(data, pos + 6); + x2 = readCoord(data, pos + 8); + y2 = readCoord(data, pos + 10); + has3Point = true; + } else if (fromCurPos && len >= 8 && pos + 8 <= end) { + x1 = readCoord(data, pos); + y1 = readCoord(data, pos + 2); + x2 = readCoord(data, pos + 4); + y2 = readCoord(data, pos + 6); + has3Point = true; + } else if (!fromCurPos && len >= 8 && pos + 8 <= end) { + x1 = readCoord(data, pos); + y1 = readCoord(data, pos + 2); + x2 = readCoord(data, pos + 4); + y2 = readCoord(data, pos + 6); + has3Point = true; + } + + if (has3Point) { + double d = 2.0 * (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1)); + if (Math.abs(d) < 1e-6) { + // Collinear points: draw straight line segments P0 -> P1 -> P2 + trackPoint(x0, y0); + trackPoint(x1, y1); + trackPoint(x2, y2); + if (inArea) { + if (currentPolyPts == 0) addAreaLineStart(x0, y0); + addAreaPoint(x1, y1); + addAreaPoint(x2, y2); + } else { + plane.drawLine(plane.mapXDouble(x0), plane.mapYDouble(y0), + plane.mapXDouble(x1), plane.mapYDouble(y1), + curColor, lineType, lineWidth); + plane.drawLine(plane.mapXDouble(x1), plane.mapYDouble(y1), + plane.mapXDouble(x2), plane.mapYDouble(y2), + curColor, lineType, lineWidth); + } + curX = x2; + curY = y2; + return; + } + + double x0sq = (double) x0 * x0 + (double) y0 * y0; + double x1sq = (double) x1 * x1 + (double) y1 * y1; + double x2sq = (double) x2 * x2 + (double) y2 * y2; + double ccx = (x0sq * (y1 - y2) + x1sq * (y2 - y0) + x2sq * (y0 - y1)) / d; + double ccy = (x0sq * (x2 - x1) + x1sq * (x0 - x2) + x2sq * (x1 - x0)) / d; + double rad = Math.hypot(x0 - ccx, y0 - ccy); + + double theta0 = Math.atan2(y0 - ccy, x0 - ccx); + double theta1 = Math.atan2(y1 - ccy, x1 - ccx); + double theta2 = Math.atan2(y2 - ccy, x2 - ccx); + if (theta0 < 0) theta0 += 2 * Math.PI; + if (theta1 < 0) theta1 += 2 * Math.PI; + if (theta2 < 0) theta2 += 2 * Math.PI; + + double d01 = theta1 - theta0; + while (d01 < 0) d01 += 2 * Math.PI; + double d02 = theta2 - theta0; + while (d02 < 0) d02 += 2 * Math.PI; + + double sweep = (d01 < d02) ? d02 : (d02 - 2 * Math.PI); + + int steps = Math.max(8, (int) Math.round(Math.abs(Math.toDegrees(sweep)) / 5.0)); + int prevX = x0; + int prevY = y0; + trackPoint(x0, y0); + if (inArea && currentPolyPts == 0) { + addAreaLineStart(x0, y0); + } + + for (int s = 1; s <= steps; s++) { + double t = (double) s / steps; + double angle = theta0 + t * sweep; + int px = (s == steps) ? x2 : (int) Math.round(ccx + rad * Math.cos(angle)); + int py = (s == steps) ? y2 : (int) Math.round(ccy + rad * Math.sin(angle)); + trackPoint(px, py); + if (inArea) { + addAreaPoint(px, py); + } else { + plane.drawLine(plane.mapXDouble(prevX), plane.mapYDouble(prevY), + plane.mapXDouble(px), plane.mapYDouble(py), + curColor, lineType, lineWidth); + } + prevX = px; + prevY = py; + } + + curX = x2; + curY = y2; + return; + } + + // Fallback: sweep angle parameter decoding int centerX = curX; int centerY = curY; - - // For absolute variants, read the center point from data - if (!fromCurPos && pos + 4 <= off + len) { + if (!fromCurPos && pos + 4 <= end) { centerX = readCoord(data, pos); centerY = readCoord(data, pos + 2); pos += 4; } - // Read the multiplier (integer.fraction format) double multiplier = 1.0; - if (pos + 2 <= off + len) { - int intPart = data[pos]; // signed byte + if (pos + 2 <= end) { + int intPart = data[pos]; int fracPart = data[pos + 1] & 0xFF; multiplier = intPart + fracPart / 256.0; if (multiplier == 0.0) multiplier = 1.0; pos += 2; } - // Compute semi-axes from arc parameters scaled by multiplier - // P,Q and R,S are conjugate semi-diameter vectors double pScaled = arcParamP * multiplier; double qScaled = arcParamQ * multiplier; double rScaled = arcParamR * multiplier; double sScaled = arcParamS * multiplier; - // 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; @@ -1470,7 +1660,6 @@ public class GocaDecoder { if (semiAxis1 < 1.0) semiAxis1 = 1.0; if (semiAxis2 < 1.0) semiAxis2 = 1.0; - // Map to pixel space with double precision double cx = plane.mapXDouble(centerX); double cy = plane.mapYDouble(centerY); double rx = Math.abs(plane.mapXDouble(semiAxis1) - plane.mapXDouble(0)); @@ -1478,38 +1667,24 @@ public class GocaDecoder { if (rx <= 0.0) rx = Math.max(1.0, semiAxis1); if (ry <= 0.0) ry = Math.max(1.0, semiAxis2); - // For partial arcs, determine start angle and sweep angle double startAngleDeg = 0.0; double 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.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(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.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(centerX)); - startAngleDeg = Math.toDegrees(startRad); - if (startAngleDeg < 0) startAngleDeg += 360.0; - } - sweepAngleDeg = 360.0; + if (pos + 4 <= end) { + int startInt = data[pos]; + int startFrac = data[pos + 1] & 0xFF; + startAngleDeg = (startInt + startFrac / 256.0) * 360.0; + pos += 2; + int sweepInt = data[pos]; + int sweepFrac = data[pos + 1] & 0xFF; + sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; + } else if (pos + 2 <= end) { + 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.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(centerX)); + startAngleDeg = Math.toDegrees(startRad); + if (startAngleDeg < 0) startAngleDeg += 360.0; } } @@ -1533,7 +1708,7 @@ public class GocaDecoder { } plane.drawArc(cx, cy, rx, ry, startAngleDeg, sweepAngleDeg, - curColor, lineType, lineWidth, isFull); + curColor, lineType, lineWidth, false); curX = centerX; curY = centerY; @@ -1543,63 +1718,62 @@ public class GocaDecoder { int pos = off; int end = off + len; - List ptsX = new ArrayList<>(); - List ptsY = new ArrayList<>(); List gocaPtsX = new ArrayList<>(); List gocaPtsY = new ArrayList<>(); if (fromCurPos) { - trackPoint(curX, curY); - ptsX.add(plane.mapXDouble(curX)); - ptsY.add(plane.mapYDouble(curY)); gocaPtsX.add(curX); gocaPtsY.add(curY); - if (inArea && currentPolyPts == 0) { - addAreaLineStart(curX, curY); - } } while (pos + 4 <= end) { int x = readCoord(data, pos); int y = readCoord(data, pos + 2); - trackPoint(x, y); - ptsX.add(plane.mapXDouble(x)); - ptsY.add(plane.mapYDouble(y)); gocaPtsX.add(x); gocaPtsY.add(y); - if (inArea && ptsX.size() == 1 && currentPolyPts == 0) { - addAreaLineStart(x, y); - } - curX = x; - curY = y; pos += 4; } - if (ptsX.size() >= 2) { - int n = ptsX.size(); - double[] px = new double[n]; - double[] py = new double[n]; - for (int i = 0; i < n; i++) { - px[i] = ptsX.get(i); - py[i] = ptsY.get(i); - } + int n = gocaPtsX.size(); + if (n < 2) return; + int[] ctrlX = new int[n]; + int[] ctrlY = new int[n]; + for (int i = 0; i < n; i++) { + ctrlX[i] = gocaPtsX.get(i); + ctrlY[i] = gocaPtsY.get(i); + trackPoint(ctrlX[i], ctrlY[i]); + } + + FilletPts fillet = new FilletPts(); + int req = fillet.getPointsRequired(n); + int[] outX = new int[Math.max(req, n * 32)]; + int[] outY = new int[Math.max(req, n * 32)]; + int nPoints = fillet.getFilletPoints(ctrlX, ctrlY, n, outX, outY); + + if (nPoints > 0) { + for (int i = 0; i < nPoints; i++) { + trackPoint(outX[i], outY[i]); + } if (inArea) { - int gn = gocaPtsX.size(); - int[] gx = new int[gn]; - int[] gy = new int[gn]; - for (int i = 0; i < gn; i++) { - gx[i] = gocaPtsX.get(i); - gy[i] = gocaPtsY.get(i); - } - int[][] curve = FilletPts.calculate(gx, gy, gn, 16); - for (int i = 1; i < curve[0].length; i++) { - addAreaPoint(curve[0][i], curve[1][i]); + for (int i = 0; i < nPoints; i++) { + if (i == 0 && currentPolyPts == 0) { + addAreaLineStart(outX[0], outY[0]); + } else { + addAreaPoint(outX[i], outY[i]); + } } } else { - plane.drawFillet(px, py, n, curColor, lineType, lineWidth); + for (int i = 0; i < nPoints - 1; i++) { + plane.drawLine(plane.mapXDouble(outX[i]), plane.mapYDouble(outY[i]), + plane.mapXDouble(outX[i + 1]), plane.mapYDouble(outY[i + 1]), + curColor, lineType, lineWidth); + } } } + + curX = ctrlX[n - 1]; + curY = ctrlY[n - 1]; } private void processMarker(byte[] data, int off, int len, boolean fromCurPos) { @@ -1824,6 +1998,15 @@ public class GocaDecoder { resetDefaults(); } + public synchronized int getArcParamP() { return arcParamP; } + public synchronized int getArcParamQ() { return arcParamQ; } + public synchronized int getArcParamR() { return arcParamR; } + public synchronized int getArcParamS() { return arcParamS; } + public synchronized int getImageMultiplier() { return imgMultiplier; } + public synchronized boolean hasActiveGraphics() { + return graphicsCursorActive || (plane != null && plane.hasContent()); + } + private int readCoord(byte[] data, int off) { return (short) (((data[off] & 0xFF) << 8) | (data[off + 1] & 0xFF)); } 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 bbad5f0..e0bfe26 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java @@ -1221,12 +1221,12 @@ public class GraphicsPlane implements PixelBuffer { for (int i = 0; i < pLen - 1; i++) { drawLine((double) px[offset + i], (double) py[offset + i], (double) px[offset + i + 1], (double) py[offset + i + 1], - boundaryColorArgb, lineType, GocaConstants.LW_NORMAL); + boundaryColorArgb, lineType, lineWidth); } if (pLen >= 3 && (px[offset] != px[offset + pLen - 1] || py[offset] != py[offset + pLen - 1])) { drawLine((double) px[offset + pLen - 1], (double) py[offset + pLen - 1], (double) px[offset], (double) py[offset], - boundaryColorArgb, lineType, GocaConstants.LW_NORMAL); + boundaryColorArgb, lineType, lineWidth); } } offset += pLen; @@ -1417,6 +1417,16 @@ public class GraphicsPlane implements PixelBuffer { drawVectorText((double) x, (double) y, text, colorArgb, (double) cellWidth, (double) cellHeight, dir, angle, shearAngle); } + public synchronized void drawVectorString(double x, double y, String text, int colorArgb, + double cellWidth, double cellHeight, int dir, double angle, double shearAngle) { + drawVectorText(x, y, text, colorArgb, cellWidth, cellHeight, dir, angle, shearAngle); + } + + public synchronized void drawVectorString(int x, int y, String text, int colorArgb, + int cellWidth, int cellHeight, int dir, double angle) { + drawVectorText((double) x, (double) y, text, colorArgb, (double) cellWidth, (double) cellHeight, dir, angle, 0.0); + } + private void drawVssChar(double x, double y, char c, int color, double cw, double ch) { drawVssChar(x, y, c, color, cw, ch, 0.0, 0.0); } @@ -1530,8 +1540,17 @@ public class GraphicsPlane implements PixelBuffer { */ public synchronized void drawImage(int x, int y, int width, int height, byte[] imageData, int fgColorArgb, int bitDepth, int compressionMode) { + drawImage(x, y, width, height, imageData, fgColorArgb, bitDepth, compressionMode, 1); + } + + /** + * Draws bitmap image with support for bit depth, RLE decompression, and image multiplier (imult). + */ + public synchronized void drawImage(int x, int y, int width, int height, byte[] imageData, int fgColorArgb, + int bitDepth, int compressionMode, int multiplier) { if (imageData == null || width <= 0 || height <= 0) return; int fgColor = (fgColorArgb != 0) ? fgColorArgb : GocaConstants.GOCA_COLORS[0]; + int m = (multiplier > 0) ? multiplier : 1; byte[] rawData = imageData; if (compressionMode == GocaConstants.IMG_RLE) { @@ -1549,7 +1568,15 @@ public class GraphicsPlane implements PixelBuffer { if (byteIdx < rawData.length) { boolean bit = ((rawData[byteIdx] >> (7 - (col % 8))) & 1) != 0; if (bit) { - setPixel(x + col, y + row, fgColor); + if (m <= 1) { + setPixel(x + col, y + row, fgColor); + } else { + for (int dy = 0; dy < m; dy++) { + for (int dx = 0; dx < m; dx++) { + setPixel(x + col * m + dx, y + row * m + dy, fgColor); + } + } + } } } } @@ -1572,7 +1599,15 @@ public class GraphicsPlane implements PixelBuffer { case 3: pixelColor = GocaConstants.GOCA_COLORS[4]; break; // Green default: pixelColor = fgColor; break; } - setPixel(x + col, y + row, pixelColor); + if (m <= 1) { + setPixel(x + col, y + row, pixelColor); + } else { + for (int dy = 0; dy < m; dy++) { + for (int dx = 0; dx < m; dx++) { + setPixel(x + col * m + dx, y + row * m + dy, pixelColor); + } + } + } } } } @@ -1588,7 +1623,16 @@ public class GraphicsPlane implements PixelBuffer { int shift = (1 - (col % 2)) * 4; int val = (rawData[byteIdx] >> shift) & 0x0F; if (val != 0) { - setPixel(x + col, y + row, GocaConstants.getGocaColorArgb(val)); + int pixelColor = GocaConstants.getGocaColorArgb(val); + if (m <= 1) { + setPixel(x + col, y + row, pixelColor); + } else { + for (int dy = 0; dy < m; dy++) { + for (int dx = 0; dx < m; dx++) { + setPixel(x + col * m + dx, y + row * m + dy, pixelColor); + } + } + } } } } @@ -1602,7 +1646,16 @@ public class GraphicsPlane implements PixelBuffer { if (byteIdx < rawData.length) { int val = rawData[byteIdx] & 0xFF; if (val != 0) { - setPixel(x + col, y + row, GocaConstants.getGocaColorArgb(val)); + int pixelColor = GocaConstants.getGocaColorArgb(val); + if (m <= 1) { + setPixel(x + col, y + row, pixelColor); + } else { + for (int dy = 0; dy < m; dy++) { + for (int dx = 0; dx < m; dx++) { + setPixel(x + col * m + dx, y + row * m + dy, pixelColor); + } + } + } } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODEllipse.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODEllipse.java new file mode 100644 index 0000000..a2ba7e0 --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/HODEllipse.java @@ -0,0 +1,151 @@ +package haus.nightmare.lib3270j.graphics; + +/** + * Calculates intermediate points for IBM GOCA ellipses and arcs using conjugate semi-axes P, Q, R, S. + * Matches IBM Host On-Demand (com.ibm.eNetwork.ECL.hostgraphics.HODEllipse and HODGraphUtil). + */ +public class HODEllipse { + + protected static final double[] dXgxdq = new double[] { + 1.0, 0.999847695, 0.999390827, 0.998629534, 0.99756405, 0.996194698, + 0.994521895, 0.992546151, 0.990268068, 0.98768834, 0.984807753, 0.981627183, + 0.9781476, 0.974370064, 0.970295726, 0.965925826, 0.961261695, 0.956304756, + 0.951056516, 0.945518575, 0.93969262, 0.933580426, 0.927183854, 0.920504853, + 0.913545457, 0.906307787, 0.898794046, 0.891006524, 0.882947592, 0.874619707, + 0.866025403, 0.8571673, 0.848048096, 0.838670567, 0.829037572, 0.819152044, + 0.809016994, 0.79863551, 0.788010753, 0.777145961, 0.766044443, 0.75470958, + 0.743144825, 0.731353701, 0.7193398, 0.707106781 + }; + + protected static final double[] dXgydq = new double[] { + 0.0, 0.017452406, 0.034899496, 0.052335956, 0.069756473, 0.087155742, + 0.104528463, 0.121869343, 0.139173101, 0.156434465, 0.173648177, 0.190808995, + 0.20791169, 0.224951054, 0.241921895, 0.258819045, 0.275637355, 0.292371704, + 0.309016994, 0.325568154, 0.342020143, 0.358367949, 0.374606593, 0.390731128, + 0.406736643, 0.422618261, 0.438371146, 0.453990499, 0.469471562, 0.48480962, + 0.5, 0.515038074, 0.529919264, 0.544639035, 0.559192903, 0.573576436, + 0.587785252, 0.601815023, 0.615661475, 0.629320391, 0.642787609, 0.656059029, + 0.669130606, 0.68199836, 0.69465837, 0.707106781 + }; + + protected double dP; + protected double dQ; + protected double dR; + protected double dS; + protected int iP; + protected int iQ; + protected int iR; + protected int iS; + protected double dRad; + protected double dCenterX; + protected double dCenterY; + protected double[] dXq = new double[9]; + protected double[] dYq = new double[9]; + + public HODEllipse(double p, double q, double r, double s) { + this.dP = p; + this.dQ = q; + this.dR = r; + this.dS = s; + this.iP = (int) Math.round(p); + this.iQ = (int) Math.round(q); + this.iR = (int) Math.round(r); + this.iS = (int) Math.round(s); + } + + public int getHODPoints(int[] outX, int[] outY, int centerX, int centerY) { + if (outX == null || outY == null || outX.length < 128 || outY.length < 128) { + return 0; + } + int ptIdx = 0; + int n5 = Math.max(Math.abs(this.iP) + Math.abs(this.iS), Math.abs(this.iR) + Math.abs(this.iQ)); + this.dRad = n5; + this.dCenterX = centerX; + this.dCenterY = centerY; + if (n5 == 0) { + outX[0] = centerX; + outY[0] = centerY; + return 1; + } + + int n6 = this.bres(this.dRad); + int lastX = this.iP + centerX; + int lastY = this.iS + centerY; + + for (int i = 0; i <= n6; ++i) { + this.filletHODSegment(outX, outY, ptIdx, this.dXq[i], this.dYq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = n6; i >= 0; --i) { + this.filletHODSegment(outX, outY, ptIdx, this.dYq[i], this.dXq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = 0; i <= n6; ++i) { + this.filletHODSegment(outX, outY, ptIdx, -this.dYq[i], this.dXq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = n6; i >= 0; --i) { + this.filletHODSegment(outX, outY, ptIdx, -this.dXq[i], this.dYq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = 0; i <= n6; ++i) { + this.filletHODSegment(outX, outY, ptIdx, -this.dXq[i], -this.dYq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = n6; i >= 0; --i) { + this.filletHODSegment(outX, outY, ptIdx, -this.dYq[i], -this.dXq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = 0; i <= n6; ++i) { + this.filletHODSegment(outX, outY, ptIdx, this.dYq[i], -this.dXq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + for (int i = n6; i >= 0; --i) { + this.filletHODSegment(outX, outY, ptIdx, this.dXq[i], -this.dYq[i]); + if (lastX == outX[ptIdx] && lastY == outY[ptIdx]) continue; + lastX = outX[ptIdx]; + lastY = outY[ptIdx++]; + } + + outX[ptIdx] = outX[0]; + outY[ptIdx++] = outY[0]; + return ptIdx; + } + + protected int bres(double rad) { + for (int i = 0; i < 9; ++i) { + this.dXq[i] = rad * dXgxdq[i * 5 + 2]; + this.dYq[i] = rad * dXgydq[i * 5 + 2]; + } + return 8; + } + + protected void filletHODSegment(int[] outX, int[] outY, int idx, double d, double d2) { + outX[idx] = (int) Math.round(this.dP * d / this.dRad + this.dR * d2 / this.dRad + this.dCenterX); + outY[idx] = (int) Math.round(-(this.dS * d / this.dRad + this.dQ * d2 / this.dRad) + this.dCenterY); + } + + protected void filletHODSegment2(int[] outX, int[] outY, int idx, double d, double d2) { + outX[idx] = (int) Math.round(this.dP * d / this.dRad + this.dR * d2 / this.dRad + this.dCenterX); + outY[idx] = (int) Math.round(this.dS * d / this.dRad + this.dQ * d2 / this.dRad + this.dCenterY); + } + + public static int getHODEllipse(double p, double q, double r, double s, int[] outX, int[] outY, int cx, int cy) { + HODEllipse ellipse = new HODEllipse(p, q, r, s); + return ellipse.getHODPoints(outX, outY, cx, cy); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java index 17df9c9..3c45596 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java @@ -18,11 +18,12 @@ public class ProgramSymbolManager { public static final int NUMBER_GRAPHICS_SYMBOL_SETS = 4; // RWS 8..11 (Sets 6..9) private int defaultCellWidth = 9; - private int defaultCellHeight = 12; // Standard IBM 3279 PS Slot Default Height (SDH = 0x0C = 12) + private int defaultCellHeight = 16; // Standard IBM 9x16 Programmed Symbol cell matrix + public void setDefaultCellDimensions(int width, int height) { this.defaultCellWidth = (width > 0) ? width : 9; - this.defaultCellHeight = (height > 0) ? height : 12; + this.defaultCellHeight = (height > 0) ? height : 16; } public int getDefaultCellWidth() { @@ -194,20 +195,24 @@ public class ProgramSymbolManager { boolean isTriplePlane = (rws >= 4 && rws <= 7); ProgramSymbolSet set = sets[setIndex]; + if (clearAll) { + set.clear(); + } + int extHeaderLen = 0; - int cellWidth = defaultCellWidth; - int cellHeight = defaultCellHeight; - int colorPlane = 0; // 0 = all planes, 1 = Red, 2 = Green, 4 = Blue + int cellWidth = (loadFormat == 1 || rws < 8) ? 9 : defaultCellWidth; + int cellHeight = (loadFormat == 1 || rws < 8) ? 16 : defaultCellHeight; + int colorPlane = 0; // 0 = all planes, 1 = Blue, 2 = Red, 4 = Green if (hasExtHeader && data.length > 4) { extHeaderLen = data[4] & 0xFF; if (extHeaderLen > 3 && data.length > 6) { int lw = data[6] & 0xFF; - if (lw > 0) cellWidth = lw; + if (lw > 0 && loadFormat != 1) cellWidth = lw; } if (extHeaderLen > 4 && data.length > 7) { int lh = data[7] & 0xFF; - if (lh > 0) cellHeight = lh; + if (lh > 0 && loadFormat != 1) cellHeight = lh; } if (extHeaderLen >= 6 && data.length > 9) { colorPlane = data[9] & 0xFF; @@ -223,55 +228,34 @@ public class ProgramSymbolManager { int remaining = data.length - offset; int codeIndex = (startCodePoint >= 0x40) ? (startCodePoint - 0x40) : startCodePoint; - int sliceBytes = (loadFormat == 1) ? 18 : (cellWidth * cellHeight + 7) / 8; - if (sliceBytes <= 0) sliceBytes = 18; - - int bytesPerSymbol; - if (isTriplePlane && colorPlane == 0 && remaining >= sliceBytes * 3) { - bytesPerSymbol = sliceBytes * 3; - } else { - bytesPerSymbol = sliceBytes; - } + int bytesPerSymbol = (loadFormat == 1) ? 18 : (cellWidth * cellHeight + 7) / 8; + if (bytesPerSymbol <= 0) bytesPerSymbol = 18; while (remaining >= bytesPerSymbol && codeIndex < ProgramSymbolSet.NUM_SLOTS) { byte[] pixelData = new byte[cellWidth * cellHeight]; ProgramSymbolSet.SymbolSlot existing = set.getSlot(codeIndex); - if (!clearAll && existing != null && existing.getWidth() == cellWidth && existing.getHeight() == cellHeight) { - System.arraycopy(existing.getPixelData(), 0, pixelData, 0, Math.min(existing.getPixelData().length, pixelData.length)); + + // If loading a specific color plane (colorPlane != 0), merge with existing ONLY IF existing was not all-planes + if (isTriplePlane && colorPlane != 0 && existing != null && !existing.isAllPlanesLoaded() + && existing.getWidth() == cellWidth && existing.getHeight() == cellHeight) { + System.arraycopy(existing.getPixelData(), 0, pixelData, 0, + Math.min(existing.getPixelData().length, pixelData.length)); } - if (isTriplePlane && colorPlane == 0 && bytesPerSymbol == sliceBytes * 3) { - // 3 consecutive slices: Red (plane 1), Green (plane 2), Blue (plane 4) - if (loadFormat == 1) { - unpackFormat1(data, offset, pixelData, cellWidth, cellHeight, true, 1); - unpackFormat1(data, offset + sliceBytes, pixelData, cellWidth, cellHeight, true, 2); - unpackFormat1(data, offset + sliceBytes * 2, pixelData, cellWidth, cellHeight, true, 4); - } else { - unpackFormat3(data, offset, pixelData, cellWidth, cellHeight, true, 1); - unpackFormat3(data, offset + sliceBytes, pixelData, cellWidth, cellHeight, true, 2); - unpackFormat3(data, offset + sliceBytes * 2, pixelData, cellWidth, cellHeight, true, 4); - } + if (loadFormat == 1) { + unpackFormat1(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane); } else { - if (loadFormat == 1) { - unpackFormat1(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane); - } else { - unpackFormat3(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane); - } + unpackFormat3(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane); } - set.setSlot(codeIndex, new ProgramSymbolSet.SymbolSlot(cellWidth, cellHeight, pixelData, isTriplePlane)); + boolean allPlanes = (colorPlane == 0); + set.setSlot(codeIndex, new ProgramSymbolSet.SymbolSlot(cellWidth, cellHeight, pixelData, isTriplePlane, allPlanes)); codeIndex++; offset += bytesPerSymbol; remaining -= bytesPerSymbol; } - if (clearAll) { - for (int i = codeIndex; i < ProgramSymbolSet.NUM_SLOTS; i++) { - set.clearSlot(i); - } - } - if (logger.isLoggable(Level.FINE)) { logger.fine(String.format("LOADPS: Loaded PS Set LCID=0x%02X (RWS=%d, %s, %dx%d, %d glyphs)", lcid, rws, isTriplePlane ? "Triple-Plane" : "Single-Plane", diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java index 85e7b3d..13e42c6 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolSet.java @@ -62,6 +62,7 @@ public class ProgramSymbolSet { private final int height; private final byte[] pixelData; // 1 byte per pixel: 0 = background, 1..7 = color index (or 1 for monochrome) private final boolean isTriplePlane; + private final boolean allPlanesLoaded; private int[] cachedRgbArray; private int cachedFgRgb = -1; private int cachedBgRgb = -1; @@ -73,10 +74,19 @@ public class ProgramSymbolSet { private int cachedScaledBgRgb = -1; public SymbolSlot(int width, int height, byte[] pixelData, boolean isTriplePlane) { + this(width, height, pixelData, isTriplePlane, false); + } + + public SymbolSlot(int width, int height, byte[] pixelData, boolean isTriplePlane, boolean allPlanesLoaded) { this.width = width > 0 ? width : 9; this.height = height > 0 ? height : 16; this.pixelData = pixelData; this.isTriplePlane = isTriplePlane; + this.allPlanesLoaded = allPlanesLoaded; + } + + public boolean isAllPlanesLoaded() { + return allPlanesLoaded; } public int getWidth() { @@ -183,18 +193,18 @@ public class ProgramSymbolSet { } else if (!isTriplePlane) { rgbArray[idx] = fgArgb; } else { - // Triple-Plane RGB composite aligned with IBM Host On-Demand 16-color GOCA palette: - // val is bitmask: bit 0 (0x01) = Red, bit 1 (0x02) = Green, bit 2 (0x04) = Blue + // Triple-Plane RGB composite aligned with IBM 3179G / HoD GOCA palette: + // val bitmask: bit 0 (0x01) = Red, bit 1 (0x02) = Green, bit 2 (0x04) = Blue int colorMask = val & 0x07; int colorArgb; switch (colorMask) { case 1: colorArgb = GocaConstants.GOCA_COLORS[2]; break; // Red case 2: colorArgb = GocaConstants.GOCA_COLORS[4]; break; // Green - case 3: colorArgb = GocaConstants.GOCA_COLORS[6]; break; // Yellow - case 4: colorArgb = GocaConstants.GOCA_COLORS[1]; break; // Blue (CUSTOMBLUE) - case 5: colorArgb = GocaConstants.GOCA_COLORS[3]; break; // Pink - case 6: colorArgb = GocaConstants.GOCA_COLORS[5]; break; // Turquoise - case 7: colorArgb = GocaConstants.GOCA_COLORS[7]; break; // White + case 3: colorArgb = GocaConstants.GOCA_COLORS[6]; break; // Yellow (Red + Green) + case 4: colorArgb = GocaConstants.GOCA_COLORS[1]; break; // Blue + case 5: colorArgb = GocaConstants.GOCA_COLORS[3]; break; // Pink (Red + Blue) + case 6: colorArgb = GocaConstants.GOCA_COLORS[5]; break; // Turquoise (Green + Blue) + case 7: colorArgb = GocaConstants.GOCA_COLORS[7]; break; // White (Red + Green + Blue) default: colorArgb = bgArgb; break; } rgbArray[idx] = colorArgb; 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 186cd29..6311762 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -846,6 +846,8 @@ public class InputProcessor { if (graphicsPlane != null) { graphicsPlane.clear(); } + lastAid = aidCode; + setKeyboardLocked(true); byte[] data = new byte[] { (byte) aidCode }; sendAidResponse(data); return; @@ -948,6 +950,15 @@ public class InputProcessor { * 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) { + boolean hasGraphics = gocaDecoder != null && (gocaDecoder.isGraphicsCursorActive() || + (gocaDecoder.getGraphicsPlane() != null && gocaDecoder.getGraphicsPlane().hasContent())); + if (hasGraphics) { + int gx = gocaDecoder.getGraphicCursorX(); + int gy = gocaDecoder.getGraphicCursorY(); + sendGraphicMouseAid(gx, gy, aidCode, button, isShift, isCtrl); + return; + } + if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) { return; } @@ -957,41 +968,51 @@ public class InputProcessor { setKeyboardLocked(true); ByteArrayOutputStream out = new ByteArrayOutputStream(); - if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) { - int gx = gocaDecoder.getGraphicCursorX(); - int gy = gocaDecoder.getGraphicCursorY(); - int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80; - int numRows = (screen != null && screen.getRows() > 0) ? screen.getRows() : 24; - int cursorAddr = screen != null ? screen.getCursorAddress() : 0; - int row = cursorAddr / cols; - int col = cursorAddr % cols; + byte[] rma = buildReadModifiedInboundData(aidCode, false); + try { + out.write(rma); + } catch (java.io.IOException ignored) {} + sendAidResponse(out.toByteArray()); + } - byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( - gx, gy, row, col, button, true, 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 cursorAddr=%d btn=%d shift=%b ctrl=%b SF_HEX=[%s]", - gx, gy, row, col, cursorAddr, button, isShift, isCtrl, sfHex.toString().trim() - )); - - // Structured Field AID (0x88) + 56-byte Graphic Input SF - out.write(AID_SF); - try { - out.write(sf); - byte[] rma = buildReadModifiedInboundData(aidCode, false); - out.write(rma); - } catch (java.io.IOException ignored) {} - } else { - byte[] rma = buildReadModifiedInboundData(aidCode, false); - try { - out.write(rma); - } catch (java.io.IOException ignored) {} + /** + * Transmits a mouse / light-pen graphic input structured field AID with explicit GOCA coordinates. + */ + public void sendGraphicMouseAid(int gx, int gy, int aidCode, int button, boolean isShift, boolean isCtrl) { + if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) { + return; } + if (isKeyboardLocked()) { + return; + } + setKeyboardLocked(true); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80; + int cursorAddr = screen != null ? screen.getCursorAddress() : 0; + int row = cursorAddr / cols; + int col = cursorAddr % cols; + + byte[] sf = haus.nightmare.lib3270j.graphics.GraphicInputBuilder.buildGraphicInput( + gx, gy, row, col, button, true, 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 cursorAddr=%d btn=%d shift=%b ctrl=%b SF_HEX=[%s]", + gx, gy, row, col, cursorAddr, button, isShift, isCtrl, sfHex.toString().trim() + )); + + // Structured Field AID (0x88) + 56-byte Graphic Input SF + out.write(AID_SF); + try { + out.write(sf); + byte[] rma = buildReadModifiedInboundData(aidCode, false); + out.write(rma); + } catch (java.io.IOException ignored) {} sendAidResponse(out.toByteArray()); } @@ -1030,7 +1051,12 @@ public class InputProcessor { screen.setCursorAddress(address); // Immediate Selection (Space or Null) -> AID_SELECT (0x7E) + // Per IBM 3270 Reference (GA23-0059), light-pen selection has no effect + // on protected fields with a space or null designator character. if (ebcdic == 0x00 || ebcdic == 0x40 || ascii == ' ' || ascii == 0 || ascii == '\u3000') { + if (haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected(fa)) { + return false; + } faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY; sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT); return true; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index 1f0af46..231f7fd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -1448,6 +1448,11 @@ public class TelnetFSM { * Send a 3270 data record, with TN3270E header if in TN3270E mode. */ public void send3270Data(byte[] data) { + if (data != null && data.length >= 2 && (data[data.length - 2] & 0xFF) == IAC && (data[data.length - 1] & 0xFF) == EOR) { + byte[] trimmed = new byte[data.length - 2]; + System.arraycopy(data, 0, trimmed, 0, trimmed.length); + data = trimmed; + } if (tn3270eNegotiated) { ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE); // TN3270E header diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java index a9aaaa8..a1352fc 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java @@ -376,6 +376,14 @@ public class Xfer3270 implements FileTransferInterface { config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); config.setExistAction(this.option_APPEND ? FTConfig.ExistAction.APPEND : FTConfig.ExistAction.REPLACE); + config.setClear(this.bSendClear); + config.setDbcs(this.currentCodePage != null && this.currentCodePage.isDBCS()); + config.setOptionNew(this.option_NEW); + config.setOptionNoso(this.option_NOSO); + if (this.option_UNICODE) { + config.setUnicode(true); + config.setUnicodeType(this.unicodeType); + } config.setDftBufferSize(bmtuSize); if (options != null && !options.isEmpty()) { config.parseOptions(options); @@ -387,7 +395,38 @@ public class Xfer3270 implements FileTransferInterface { public void getFile(String command, String hostFile, String localFile, int mode, String options, ECLXferListener listener) throws ECLErr { FileTransferStatusInterface statusAdapter = createStatusAdapter(listener, localFile, hostFile); - getFile(hostFile, MVS_TSO, localFile, mode, statusAdapter, options); + setOptionFlags(options); + if (mode == BINARY) { + this.option_ASCII = false; + } else { + this.option_ASCII = true; + } + + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.RECEIVE); + config.setLocalFilename(localFile); + config.setHostFilename(hostFile); + config.setHostType(FTConfig.HostType.TSO); + config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); + config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); + config.setExistAction(this.option_APPEND ? FTConfig.ExistAction.APPEND : FTConfig.ExistAction.REPLACE); + config.setClear(this.bSendClear); + config.setDbcs(this.currentCodePage != null && this.currentCodePage.isDBCS()); + config.setOptionNew(this.option_NEW); + config.setOptionNoso(this.option_NOSO); + if (command != null && !command.trim().isEmpty()) { + config.setProgramName(command.trim()); + } + if (this.option_UNICODE) { + config.setUnicode(true); + config.setUnicodeType(this.unicodeType); + } + config.setDftBufferSize(bmtuSize); + if (options != null && !options.isEmpty()) { + config.parseOptions(options); + } + + executeTransfer(config, statusAdapter, localFile, hostFile); } @Override @@ -408,6 +447,15 @@ public class Xfer3270 implements FileTransferInterface { (hostType == CICS ? FTConfig.HostType.CICS : FTConfig.HostType.TSO)); config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); + config.setExistAction(this.option_APPEND ? FTConfig.ExistAction.APPEND : FTConfig.ExistAction.REPLACE); + config.setClear(this.bSendClear); + config.setDbcs(this.currentCodePage != null && this.currentCodePage.isDBCS()); + config.setOptionNew(this.option_NEW); + config.setOptionNoso(this.option_NOSO); + if (this.option_UNICODE) { + config.setUnicode(true); + config.setUnicodeType(this.unicodeType); + } config.setRecfm(recfm); config.setLrecl(String.valueOf(lrecl)); config.setBlksize(String.valueOf(blksize)); @@ -429,7 +477,48 @@ public class Xfer3270 implements FileTransferInterface { public void putFile(String command, String hostFile, String localFile, int mode, String options, ECLXferListener listener) throws ECLErr { FileTransferStatusInterface statusAdapter = createStatusAdapter(listener, localFile, hostFile); - putFile(localFile, MVS_TSO, hostFile, mode, statusAdapter, options); + setOptionFlags(options); + if (mode == BINARY) { + this.option_ASCII = false; + } else { + this.option_ASCII = true; + } + + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.SEND); + config.setLocalFilename(localFile); + config.setHostFilename(hostFile); + config.setHostType(FTConfig.HostType.TSO); + config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); + config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); + config.setExistAction(this.option_APPEND ? FTConfig.ExistAction.APPEND : FTConfig.ExistAction.REPLACE); + config.setClear(this.bSendClear); + config.setDbcs(this.currentCodePage != null && this.currentCodePage.isDBCS()); + config.setOptionNew(this.option_NEW); + config.setOptionNoso(this.option_NOSO); + if (command != null && !command.trim().isEmpty()) { + config.setProgramName(command.trim()); + } + if (this.option_UNICODE) { + config.setUnicode(true); + config.setUnicodeType(this.unicodeType); + } + config.setRecfm(recfm); + config.setLrecl(String.valueOf(lrecl)); + config.setBlksize(String.valueOf(blksize)); + config.setDftBufferSize(bmtuSize); + if (primarySpace > 0) { + config.setSpace(primarySpace + (secondarySpace > 0 ? "," + secondarySpace : "")); + } + if ("TRACKS".equalsIgnoreCase(spaceUnits)) config.setUnits(FTConfig.AllocationUnit.TRACKS); + else if ("CYLINDERS".equalsIgnoreCase(spaceUnits)) config.setUnits(FTConfig.AllocationUnit.CYLINDERS); + else if ("AVBLOCK".equalsIgnoreCase(spaceUnits)) config.setUnits(FTConfig.AllocationUnit.AVBLOCK); + + if (options != null && !options.isEmpty()) { + config.parseOptions(options); + } + + executeTransfer(config, statusAdapter, localFile, hostFile); } private void executeTransfer(FTConfig config, FileTransferStatusInterface status, diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java new file mode 100644 index 0000000..0b17a82 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java @@ -0,0 +1,476 @@ +package haus.nightmare.lib3270j.ft; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.CodePageRegistry; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import static haus.nightmare.lib3270j.ft.FTConstants.*; + +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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 16 Test Suite: IND$FILE Host File Transfer Protocol Hardening. + * Validates 1:1 packet, timing, and record-layout compatibility with IBM Host On-Demand v14. + */ +public class FTProtocolHardeningPhase16Test { + + private EbcdicTranslator translator; + private ScreenBuffer screen; + private MockInputProcessor input; + private MockDftListener listener; + private FTDft ftDft; + + static class MockInputProcessor extends InputProcessor { + final List sentStructuredFields = new ArrayList<>(); + + public MockInputProcessor(ScreenBuffer screen, EbcdicTranslator translator) { + super(screen, translator, null); + } + + @Override + public void sendStructuredFieldData(byte[] data) { + sentStructuredFields.add(data.clone()); + } + } + + static class MockDftListener implements FTDft.FTDftListener { + FTConfig config = new FTConfig(); + FTState state = FTState.RUNNING; + long bytesTransferred = 0; + boolean completeCalled = false; + boolean abortCalled = false; + String abortMessage = null; + + @Override + public void onDftRunning() { + state = FTState.RUNNING; + } + + @Override + public void onTransferComplete(String errorMessage) { + state = FTState.NONE; + completeCalled = true; + } + + @Override + public void onTransferAborted(String errorMessage) { + state = FTState.NONE; + abortCalled = true; + abortMessage = errorMessage; + } + + @Override + public void onBytesTransferred(long bytes) { + this.bytesTransferred = bytes; + } + + @Override + public FTState getCurrentState() { + return state; + } + + @Override + public void setState(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_3278_2, translator); + input = new MockInputProcessor(screen, translator); + listener = new MockDftListener(); + ftDft = new FTDft(input, translator, listener); + } + + @Test + public void testHoDBinaryPacketTemplates() { + // 1. RB_OPEN_ACK: 8 bytes matching HoD Xfer3270 lines 553-561 + byte[] expectedOpenAck = { + (byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x00, 0x09, (byte) 0xFF, (byte) 0xEF + }; + assertArrayEquals(expectedOpenAck, RB_OPEN_ACK); + + // 2. RB_CLOSE_ACK: 8 bytes matching HoD Xfer3270 lines 578-586 + byte[] expectedCloseAck = { + (byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x41, 0x09, (byte) 0xFF, (byte) 0xEF + }; + assertArrayEquals(expectedCloseAck, RB_CLOSE_ACK); + + // 3. RB_GET_PAST_EOF: 12 bytes matching HoD Xfer3270 lines 795-806 + byte[] expectedEof = { + (byte) 0x88, 0x00, 0x09, (byte) 0xD0, 0x46, 0x08, 0x69, 0x04, 0x22, 0x00, (byte) 0xFF, (byte) 0xEF + }; + assertArrayEquals(expectedEof, RB_GET_PAST_EOF); + + // 4. RB_OPEN_FAILED: 11 bytes matching HoD Xfer3270 lines 588-599 + byte[] expectedOpenFailed = { + 0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x01, 0x00, (byte) 0xFF, (byte) 0xEF + }; + assertArrayEquals(expectedOpenFailed, RB_OPEN_FAILED); + + // 5. createDataAck(recnum): 14 bytes matching HoD Xfer3270 lines 563-576 + doubleFoxFox + byte[] ack1 = createDataAck(1); + assertEquals(14, ack1.length); + assertEquals((byte) 0x88, ack1[0]); + assertEquals(0x00, ack1[1]); + assertEquals(0x0B, ack1[2]); + assertEquals((byte) 0xD0, ack1[3]); + assertEquals(0x47, ack1[4]); + assertEquals(0x05, ack1[5]); + assertEquals(0x63, ack1[6]); + assertEquals(0x06, ack1[7]); + assertEquals(0x00, ack1[8]); + assertEquals(0x00, ack1[9]); + assertEquals(0x00, ack1[10]); + assertEquals(0x01, ack1[11]); + assertEquals((byte) 0xFF, ack1[12]); + assertEquals((byte) 0xEF, ack1[13]); + + byte[] ackLarge = createDataAck(0x12345678L); + assertEquals(0x12, ackLarge[8] & 0xFF); + assertEquals(0x34, ackLarge[9] & 0xFF); + assertEquals(0x56, ackLarge[10] & 0xFF); + assertEquals(0x78, ackLarge[11] & 0xFF); + } + + @Test + public void testOpenRequestSendsExactHoDOpenAck() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Build Open Request SF + byte[] openSf = { + 0x00, 0x05, (byte) 0xD0, 0x00, 0x12 + }; + + ftDft.processStructuredField(openSf, 0, openSf.length); + + assertEquals(1, input.sentStructuredFields.size()); + assertArrayEquals(RB_OPEN_ACK, input.sentStructuredFields.get(0)); + } + + @Test + public void testCloseRequestFlushesAndSendsExactHoDCloseAck() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + byte[] closeSf = { + 0x00, 0x05, (byte) 0xD0, 0x41, 0x12 + }; + + ftDft.processStructuredField(closeSf, 0, closeSf.length); + + assertEquals(1, input.sentStructuredFields.size()); + assertArrayEquals(RB_CLOSE_ACK, input.sentStructuredFields.get(0)); + assertTrue(listener.completeCalled); + } + + @Test + public void testUploadEofSendsExactHoDGetPastEof() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Host sends TR_GET_REQ (0x4611) + byte[] getSf = { + 0x00, 0x05, (byte) 0xD0, 0x46, 0x11 + }; + + ftDft.processStructuredField(getSf, 0, getSf.length); + + assertEquals(1, input.sentStructuredFields.size()); + assertArrayEquals(RB_GET_PAST_EOF, input.sentStructuredFields.get(0)); + } + + @Test + public void testDataAckLayoutOnDownload() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Host sends Data Insert SF with 4 data bytes + byte[] dataInsert = { + 0x00, 0x0C, (byte) 0xD0, 0x47, 0x04, + 0x61, 0x00, 0x09, // TR_BEGIN_DATA + len (9 = 4 data + 5) + 'T', 'E', 'S', 'T' + }; + + ftDft.processStructuredField(dataInsert, 0, dataInsert.length); + + assertEquals(1, input.sentStructuredFields.size()); + byte[] expectedAck = createDataAck(1); + assertArrayEquals(expectedAck, input.sentStructuredFields.get(0)); + } + + @Test + public void testHostErrorRepliesMappedToDescriptions() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // 1. TR_ERR_OPEN_FAILED (0x0100) + byte[] errOpenFailed = { + 0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x01, 0x00 + }; + ftDft.processStructuredField(errOpenFailed, 0, errOpenFailed.length); + assertTrue(listener.abortCalled); + assertTrue(listener.abortMessage.toLowerCase().contains("file not found") || + listener.abortMessage.toLowerCase().contains("open failed")); + + // Reset listener + listener.abortCalled = false; + listener.abortMessage = null; + + // 2. TR_ERR_NOT_AUTHORIZED (0x3E00) + byte[] errNotAuth = { + 0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x3E, 0x00 + }; + ftDft.processStructuredField(errNotAuth, 0, errNotAuth.length); + assertTrue(listener.abortCalled); + assertTrue(listener.abortMessage.toLowerCase().contains("not authorized") || + listener.abortMessage.toLowerCase().contains("security")); + + // 3. TR_ERR_SYNTAX_ERROR (0x6000) + listener.abortCalled = false; + byte[] errSyntax = { + 0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x60, 0x00 + }; + ftDft.processStructuredField(errSyntax, 0, errSyntax.length); + assertTrue(listener.abortCalled); + assertTrue(listener.abortMessage.toLowerCase().contains("syntax error") || + listener.abortMessage.toLowerCase().contains("options")); + } + + @Test + public void testStreamNameDetectionAsciiAndEbcdic() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Open request containing EBCDIC FT:MSG + ByteArrayOutputStream sfMsg = new ByteArrayOutputStream(); + sfMsg.write(0x00); sfMsg.write(0x10); + sfMsg.write(SF_TRANSFER_DATA); + sfMsg.write(0x00); sfMsg.write(0x12); + sfMsg.write(STREAM_NAME_FT_MSG_BYTES_EBCDIC, 0, STREAM_NAME_FT_MSG_BYTES_EBCDIC.length); + + byte[] msgPacket = sfMsg.toByteArray(); + msgPacket[0] = (byte) ((msgPacket.length >> 8) & 0xFF); + msgPacket[1] = (byte) (msgPacket.length & 0xFF); + + ftDft.processStructuredField(msgPacket, 0, msgPacket.length); + assertEquals(FTState.RUNNING, listener.state); + } + + @Test + public void testRecfmFPaddingAndSplittingUpload() { + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.SEND); + config.setTransferMode(FTConfig.TransferMode.ASCII); + config.setRecfm(FTConfig.RecordFormat.FIXED); + config.setLrecl("10"); + listener.config = config; + + // Local content has two lines: "SHORT" (5 chars) and "LONGER LINE THAN TEN" (20 chars) + String localContent = "SHORT\nLONGER LINE THAN TEN\n"; + ByteArrayInputStream in = new ByteArrayInputStream(localContent.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Host sends TR_GET_REQ + byte[] getSf = { 0x00, 0x05, (byte) 0xD0, 0x46, 0x11 }; + ftDft.processStructuredField(getSf, 0, getSf.length); + + assertEquals(1, input.sentStructuredFields.size()); + byte[] reply = input.sentStructuredFields.get(0); + int headerLen = 17; + int dataLen = reply.length - headerLen; + assertTrue(dataLen >= 30, "Should have packed at least 3 fixed records of 10 bytes each"); + + String rec1 = new String(reply, headerLen, 10, StandardCharsets.UTF_8); + assertEquals("SHORT ", rec1); + + String rec2 = new String(reply, headerLen + 10, 10, StandardCharsets.UTF_8); + assertEquals("LONGER LIN", rec2); + + String rec3 = new String(reply, headerLen + 20, 10, StandardCharsets.UTF_8); + assertEquals("E THAN TEN", rec3); + } + + @Test + public void testRecfmFDownloadTrimmingAndNewline() throws Exception { + FTConfig config = new FTConfig(); + config.setDirection(FTConfig.Direction.RECEIVE); + config.setTransferMode(FTConfig.TransferMode.ASCII); + config.setRecfm(FTConfig.RecordFormat.FIXED); + config.setLrecl("10"); + listener.config = config; + + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + // Host sends 10-byte fixed record "HELLO " + byte[] hostRecord = "HELLO ".getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream sf = new ByteArrayOutputStream(); + sf.write(0x00); sf.write(hostRecord.length + 8); + sf.write(SF_TRANSFER_DATA); + sf.write(0x47); sf.write(0x04); + sf.write(TR_BEGIN_DATA); + int fieldLen = hostRecord.length + 5; + sf.write((fieldLen >> 8) & 0xFF); sf.write(fieldLen & 0xFF); + sf.write(hostRecord); + + byte[] pkt = sf.toByteArray(); + pkt[0] = (byte) ((pkt.length >> 8) & 0xFF); + pkt[1] = (byte) (pkt.length & 0xFF); + + ftDft.processStructuredField(pkt, 0, pkt.length); + + // Verify output has "HELLO" stripped of trailing spaces plus local line separator + String result = new String(out.toByteArray(), StandardCharsets.UTF_8); + assertEquals("HELLO" + System.lineSeparator(), result); + } + + @Test + public void testRecfmVRdwUploadAndDownload() throws Exception { + // 1. Upload RECFM V + FTConfig sendConfig = new FTConfig(); + sendConfig.setDirection(FTConfig.Direction.SEND); + sendConfig.setTransferMode(FTConfig.TransferMode.ASCII); + sendConfig.setRecfm(FTConfig.RecordFormat.VARIABLE); + listener.config = sendConfig; + + String testLine = "VARIABLE RECORD TEST"; + ByteArrayInputStream in = new ByteArrayInputStream((testLine + "\n").getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ftDft.initTransfer(in, out); + + byte[] getSf = { 0x00, 0x05, (byte) 0xD0, 0x46, 0x11 }; + ftDft.processStructuredField(getSf, 0, getSf.length); + + assertEquals(1, input.sentStructuredFields.size()); + byte[] reply = input.sentStructuredFields.get(0); + int headerLen = 17; + + // Verify 4-byte RDW header: length should be testLine.length() + 4 + int rdwLen = ((reply[headerLen] & 0xFF) << 8) | (reply[headerLen + 1] & 0xFF); + assertEquals(testLine.length() + 4, rdwLen); + assertEquals(0, reply[headerLen + 2]); + assertEquals(0, reply[headerLen + 3]); + String payload = new String(reply, headerLen + 4, testLine.length(), StandardCharsets.UTF_8); + assertEquals(testLine, payload); + + // 2. Download RECFM V + FTConfig recvConfig = new FTConfig(); + recvConfig.setDirection(FTConfig.Direction.RECEIVE); + recvConfig.setTransferMode(FTConfig.TransferMode.ASCII); + recvConfig.setRecfm(FTConfig.RecordFormat.VARIABLE); + listener.config = recvConfig; + + ByteArrayOutputStream downOut = new ByteArrayOutputStream(); + ftDft.initTransfer(new ByteArrayInputStream(new byte[0]), downOut); + + // Host sends RDW header + "TEST V DATA" + byte[] vData = "TEST V DATA".getBytes(StandardCharsets.UTF_8); + int vRdwLen = vData.length + 4; + ByteArrayOutputStream sfDown = new ByteArrayOutputStream(); + sfDown.write(0x00); sfDown.write(0x00); + sfDown.write(SF_TRANSFER_DATA); + sfDown.write(0x47); sfDown.write(0x04); + sfDown.write(TR_BEGIN_DATA); + int fLen = vRdwLen + 5; + sfDown.write((fLen >> 8) & 0xFF); sfDown.write(fLen & 0xFF); + sfDown.write((vRdwLen >> 8) & 0xFF); sfDown.write(vRdwLen & 0xFF); + sfDown.write(0x00); sfDown.write(0x00); + sfDown.write(vData); + + byte[] downPkt = sfDown.toByteArray(); + downPkt[0] = (byte) ((downPkt.length >> 8) & 0xFF); + downPkt[1] = (byte) (downPkt.length & 0xFF); + + ftDft.processStructuredField(downPkt, 0, downPkt.length); + + String downResult = new String(downOut.toByteArray(), StandardCharsets.UTF_8); + assertEquals("TEST V DATA" + System.lineSeparator(), downResult); + } + + @Test + public void testDbcsProgramSelectionAndOptions() { + FTConfig cfgDbcs = new FTConfig(); + cfgDbcs.setCodePage("930"); // Japanese DBCS + assertEquals("APVUFILE", cfgDbcs.getProgramName()); + + FTConfig cfgSbcs = new FTConfig(); + cfgSbcs.setCodePage("037"); // US English SBCS + assertEquals("IND$FILE", cfgSbcs.getProgramName()); + + // Option NEW and NOSO parsing + FTConfig optionsCfg = new FTConfig(); + optionsCfg.setHostType(FTConfig.HostType.TSO); + optionsCfg.setDirection(FTConfig.Direction.SEND); + optionsCfg.setHostFilename("'TEST.FILE'"); + optionsCfg.parseOptions("NEW NOSO APVUFILE"); + + assertTrue(optionsCfg.isOptionNew()); + assertTrue(optionsCfg.isOptionNoso()); + assertEquals("APVUFILE", optionsCfg.getProgramName()); + + String cmd = optionsCfg.buildCommand(); + assertTrue(cmd.startsWith("APVUFILE PUT 'TEST.FILE'")); + assertTrue(cmd.contains("NEW")); + assertTrue(cmd.contains("NOSO")); + } + + @Test + public void testCutErrorScreenScanning() { + FTCut cut = new FTCut(screen, input, translator, new FTCut.FTCutListener() { + @Override public void onCutRunning() {} + @Override public void onTransferComplete(String err) {} + @Override public void onTransferAborted(String err) {} + @Override public void onBytesTransferred(long b) {} + @Override public FTConstants.FTState getCurrentState() { return FTConstants.FTState.AWAIT_ACK; } + @Override public void setState(FTConstants.FTState s) {} + @Override public FTConfig getConfig() { return new FTConfig(); } + @Override public File getLocalFile() { return null; } + }); + + // Write an error line on row 2 of screen + String errLine = "IKJ56700I DATASET 'NONEXIST.FILE' NOT FOUND"; + int startPos = 80; // row 2, col 1 + for (int i = 0; i < errLine.length(); i++) { + screen.setCell(startPos + i, translator.unicodeToEbcdic(errLine.charAt(i))); + } + + String detected = cut.scanScreenForErrors(); + assertNotNull(detected); + assertTrue(detected.contains("IKJ56700I")); + assertTrue(detected.contains("NOT FOUND")); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase17GocaGraphicsParityTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase17GocaGraphicsParityTest.java new file mode 100644 index 0000000..63cbde5 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/Phase17GocaGraphicsParityTest.java @@ -0,0 +1,303 @@ +package haus.nightmare.lib3270j.graphics; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.protocol.DS3270Constants; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.TerminalModel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Targeted verification tests for Phase 17: + * - Multi-polygon area fills with boundary enforcement & winding/even-odd rules + * - GFARC/GCFARC full arc & ellipse generation with HODEllipse Bresenham tables + * - GARC/GCARC 3-point circular arc interpolation + * - FilletPts polynomial interpolation (HoD getFilletPoints) and area accumulation + * - GRLINE/GCRLIN relative lines with signed 8-bit deltas + * - GSAP (P/Q/R/S) aspect ratio and coordinate transformations + * - GBIMG image multiplier (imult) support + * - 56-byte Graphic Input structured field layout and sendGraphicMouseAid dispatch + */ +public class Phase17GocaGraphicsParityTest { + + @Test + public void testMultiPolygonAreaFillWithBoundaryAndFillRules() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // 1. Begin Area with boundary flag set (0x40) and Winding rule (0x20) + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSCOL); out.write(0x02); // Red + out.write(GocaConstants.G_GSLW); out.write(0x02); // Line width 2 + out.write(GocaConstants.G_GBAR); out.write(0x40 | 0x20); // flags: drawBoundary=true, winding=true + + // First polygon: Triangle + out.write(GocaConstants.G_GLINE); out.write(12); + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(0x00); out.write(50); out.write(0x00); out.write(10); + out.write(0x00); out.write(30); out.write(0x00); out.write(50); + + // End Area + out.write(GocaConstants.G_GEAR); + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertTrue(plane.hasContent(), "GraphicsPlane must have content after area fill"); + assertTrue(decoder.hasActiveGraphics(), "Decoder must report active graphics when plane has content"); + } + + @Test + public void testHODEllipseAndFullArcGeneration() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // Set GSAP aspect ratio / semi-axes: P=20, Q=1, R=10, S=1 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSAP); out.write(8); + out.write(0x00); out.write(20); // P + out.write(0x00); out.write(1); // Q + out.write(0x00); out.write(10); // R + out.write(0x00); out.write(1); // S + + // Full Arc Absolute (0xC7): Center=(100, 100), Multiplier=1.0 (1, 0) + out.write(GocaConstants.G_GFARC); out.write(6); + out.write(0x00); out.write(100); // centerX + out.write(0x00); out.write(100); // centerY + out.write(0x01); out.write(0x00); // Multiplier 1.00 + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertEquals(20, decoder.getArcParamP()); + assertEquals(1, decoder.getArcParamQ()); + assertEquals(10, decoder.getArcParamR()); + assertEquals(1, decoder.getArcParamS()); + assertTrue(plane.hasContent(), "Full arc must render on graphics plane"); + + // Verify direct HODEllipse Bresenham generation + int[] ptsX = new int[256]; + int[] ptsY = new int[256]; + int nPts = HODEllipse.getHODEllipse(20.0, 1.0, 10.0, 1.0, ptsX, ptsY, 50, 50); + assertTrue(nPts > 8, "HODEllipse must generate valid polygon vertices"); + assertEquals(ptsX[0], ptsX[nPts - 1], "HODEllipse polygon must close to initial vertex"); + assertEquals(ptsY[0], ptsY[nPts - 1], "HODEllipse polygon must close to initial vertex"); + } + + @Test + public void testThreePointCircularArcInterpolation() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // Current Position Arc (GCARC 0x86): + // Start at (0, 0), intermediate at (50, 50), end at (100, 0) -> upper semicircle + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GSCP); out.write(4); + out.write(0x00); out.write(0); + out.write(0x00); out.write(0); + + out.write(GocaConstants.G_GCARC); out.write(8); + out.write(0x00); out.write(50); // intermediate X + out.write(0x00); out.write(50); // intermediate Y + out.write(0x00); out.write(100); // end X + out.write(0x00); out.write(0); // end Y + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertTrue(plane.hasContent(), "3-point circular arc must render on graphics plane"); + assertEquals(100, decoder.getCurX(), "Terminal X must update to arc end point"); + assertEquals(0, decoder.getCurY(), "Terminal Y must update to arc end point"); + } + + @Test + public void testFilletPolynomialInterpolationAndAreaAccumulation() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // Direct test of FilletPts polynomial interpolation + FilletPts fillet = new FilletPts(); + int[] ctrlX = new int[] { 10, 50, 90 }; + int[] ctrlY = new int[] { 10, 80, 10 }; + int req = fillet.getPointsRequired(3); + int[] outX = new int[req]; + int[] outY = new int[req]; + int nPts = fillet.getFilletPoints(ctrlX, ctrlY, 3, outX, outY); + assertTrue(nPts >= 3, "FilletPts must interpolate curve points across control vertices"); + + // GFLT (0xC5 Fillet Absolute) inside an Area + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GBAR); out.write(0x40); // drawBoundary=true + out.write(GocaConstants.G_GFLT); out.write(12); + out.write(0x00); out.write(10); out.write(0x00); out.write(10); + out.write(0x00); out.write(50); out.write(0x00); out.write(80); + out.write(0x00); out.write(90); out.write(0x00); out.write(10); + out.write(GocaConstants.G_GEAR); + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertTrue(plane.hasContent(), "Area with fillet must render on graphics plane"); + assertEquals(90, decoder.getCurX(), "Current X must update to last fillet vertex"); + assertEquals(10, decoder.getCurY(), "Current Y must update to last fillet vertex"); + } + + @Test + public void testRelativeLinesWithSignedDeltas() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // GRLINE (0xE1): Start at (100, 100), dx=-20, dy=+30, dx=+40, dy=-15 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GRLINE); out.write(8); + out.write(0x00); out.write(100); // startX + out.write(0x00); out.write(100); // startY + out.write((byte) -20); // dx1 = -20 + out.write((byte) 30); // dy1 = +30 + out.write((byte) 40); // dx2 = +40 + out.write((byte) -15); // dy2 = -15 + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertTrue(plane.hasContent()); + // 100 - 20 + 40 = 120 + assertEquals(120, decoder.getCurX(), "Current X should be 120"); + // 100 + 30 - 15 = 115 + assertEquals(115, decoder.getCurY(), "Current Y should be 115"); + } + + @Test + public void testGsapAspectRatioAndCoordinateTransform() { + GddmCoordinateTransform transform = new GddmCoordinateTransform(400, 300); + transform.setAspectRatio(2, 1, 3, 1); + + assertEquals(2.0, transform.getScaleX(), 0.001); + assertEquals(3.0, transform.getScaleY(), 0.001); + + // Coordinate inversion tests + int gocaY = 50; + int screenY = transform.mapGocaYToScreen(gocaY); + int invertedGocaY = transform.mapScreenYToGoca(screenY); + assertEquals(gocaY, invertedGocaY, "Inversion round-trip must preserve GOCA Y"); + + transform.resetAspectRatio(); + assertEquals(1.0, transform.getScaleX(), 0.001); + assertEquals(1.0, transform.getScaleY(), 0.001); + } + + @Test + public void testImageMultiplierSupport() { + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + // GBIMG (0xD1): origin=(10, 10), imult=2, w=8, h=8, 1 bpp uncompressed + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(GocaConstants.G_GBIMG); out.write(10); + out.write(0x00); out.write(10); // X + out.write(0x00); out.write(10); // Y + out.write(0x00); out.write(2); // imult = 2 + out.write(0x00); out.write(8); // w = 8 + out.write(0x00); out.write(8); // h = 8 + + // GIMD (0x92): 8 bytes (8 rows of 8 bits each) + out.write(GocaConstants.G_GIMD); out.write(8); + for (int i = 0; i < 8; i++) { + out.write((byte) 0xFF); + } + + // GEIMG (0x93) + out.write(GocaConstants.G_GEIMG); + + byte[] stream = out.toByteArray(); + decoder.decodeGoca(stream, 0, stream.length); + + assertEquals(2, decoder.getImageMultiplier(), "Image multiplier must be parsed as 2"); + assertTrue(plane.hasContent(), "Image must render on graphics plane"); + } + + @Test + public void testGraphicInputStructuredField56BytesLayout() { + int gocaX = 1234; + int gocaY = -5678; + int row = 10; + int col = 20; + + // Button 1 (Pick), Shift=true, Ctrl=false + byte[] sf = GraphicInputBuilder.buildGraphicInput(gocaX, gocaY, row, col, 1, true, true, false); + + assertEquals(56, sf.length, "Structured field length must be 56 bytes"); + // Bytes 0-1: 0x0034 (52 decimal) + assertEquals(0x00, sf[0]); + assertEquals(0x34, sf[1]); + // Bytes 16-19: Class descriptor (0x23, 0x00, 0x23, 0x00) + assertEquals(0x23, sf[16]); + assertEquals(0x00, sf[17]); + assertEquals(0x23, sf[18]); + assertEquals(0x00, sf[19]); + // Bytes 24-25: 16-bit signed GOCA X + short x = (short) (((sf[24] & 0xFF) << 8) | (sf[25] & 0xFF)); + assertEquals(gocaX, x); + // Bytes 26-27: 16-bit signed GOCA Y + short y = (short) (((sf[26] & 0xFF) << 8) | (sf[27] & 0xFF)); + assertEquals(gocaY, y); + // Bytes 28-31: Fixed mouse trigger class (0x00000004) + assertEquals(0x00, sf[28]); + assertEquals(0x00, sf[29]); + assertEquals(0x00, sf[30]); + assertEquals(0x04, sf[31]); + // Bytes 32-33: Fixed correlation class (0x0004) + assertEquals(0x00, sf[32]); + assertEquals(0x04, sf[33]); + // Byte 34: Shift flag 0x80 + assertEquals((byte) 0x80, sf[34]); + // Byte 35: Button 1 (0x01) + assertEquals(0x01, sf[35]); + + // Button 2 (Action), Shift=false, Ctrl=true + byte[] sf2 = GraphicInputBuilder.buildGraphicInput(gocaX, gocaY, row, col, 2, true, false, true); + assertEquals((byte) 0x40, sf2[34]); + assertEquals(0x02, sf2[35]); + } + + @Test + public void testSendGraphicMouseAidInteractiveDispatch() { + EbcdicTranslator translator = new EbcdicTranslator(); + ScreenBuffer screen = new ScreenBuffer(TerminalModel.IBM_3279_2, translator); + GraphicsPlane plane = new GraphicsPlane(400, 300); + GocaDecoder decoder = new GocaDecoder(plane); + + AtomicReference sentData = new AtomicReference<>(); + InputProcessor inputProcessor = new InputProcessor(screen, translator, null) { + @Override + protected void sendAidResponse(byte[] data) { + sentData.set(data); + } + }; + inputProcessor.setGocaDecoder(decoder); + + // When no graphics are active, regular RMA is sent + inputProcessor.sendGraphicMouseAid(DS3270Constants.AID_ENTER, 1, false, false); + assertNotNull(sentData.get()); + assertNotEquals(DS3270Constants.AID_SF, sentData.get()[0], "Without active graphics, AID_SF must not be sent"); + + // Activate graphics cursor + sentData.set(null); + inputProcessor.setKeyboardLocked(false); + decoder.attachGraphicCursor(100, 200); + assertTrue(decoder.hasActiveGraphics()); + + inputProcessor.sendGraphicMouseAid(100, 200, DS3270Constants.AID_ENTER, 1, false, false); + byte[] out = sentData.get(); + assertNotNull(out); + assertEquals(DS3270Constants.AID_SF, out[0] & 0xFF, "With active graphics, Structured Field AID (0x88) must be sent"); + assertTrue(out.length >= 57, "Output must contain AID byte + 56-byte Graphic Input SF"); + assertEquals(0x00, out[1]); + assertEquals(0x34, out[2]); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManagerTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManagerTest.java index 62a7b27..988c99c 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManagerTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManagerTest.java @@ -51,7 +51,7 @@ public class ProgramSymbolManagerTest { ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41); assertNotNull(slot); assertEquals(9, slot.getWidth()); - assertEquals(12, slot.getHeight()); + assertEquals(16, slot.getHeight()); assertFalse(slot.isTriplePlane()); // Verify pixel data (Row 0 Col 0 is 1) @@ -64,7 +64,7 @@ public class ProgramSymbolManagerTest { int bg = 0xFF000000; // Black int[] rgb = slot.getRgbPixels(fg, bg); assertNotNull(rgb); - assertEquals(9 * 12, rgb.length); + assertEquals(9 * 16, rgb.length); assertEquals(fg, rgb[0]); // Row 0 Col 0 pixel should be foreground Green } @@ -164,4 +164,128 @@ public class ProgramSymbolManagerTest { assertNotNull(slot); assertEquals(1, slot.getPixelData()[0]); } + + @Test + public void testMultiSymbolLoadPsTriplePlane190Symbols() { + ProgramSymbolManager manager = new ProgramSymbolManager(); + + // Simulate bits image loader connect payload: 190 glyphs * 18 bytes = 3420 bytes + // Sent to a triple plane set (RWS 4, LCID 0x42) with colorPlane = 0 (no ext header) + byte[] payload = new byte[4 + 190 * 18]; + payload[0] = 0x41; // Format 1, clearAll = true + payload[1] = 0x42; // LCID 0x42 + payload[2] = 0x41; // Start codepoint 0x41 + payload[3] = 0x04; // RWS 4 (Triple Plane) + + for (int i = 0; i < 190; i++) { + // Set Row 0 Col 0 for each symbol + payload[4 + i * 18] = (byte) 0x80; + } + + manager.loadps(payload); + + ProgramSymbolSet set = manager.getSymbolSet(0x42); + assertNotNull(set); + + // Verify ALL 190 glyphs (from 0x41 to 0xFE) were loaded, NOT just 63! + for (int cp = 0x41; cp <= 0xFE; cp++) { + ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x42, cp); + assertNotNull(slot, "Glyph for codepoint 0x" + Integer.toHexString(cp) + " must not be null!"); + assertEquals(9, slot.getWidth()); + assertEquals(16, slot.getHeight()); + assertTrue(slot.isTriplePlane()); + assertEquals(7, slot.getPixelData()[0] & 0xFF, "Pixel (0,0) must have all 3 planes set (7)"); + } + } + + @Test + public void testColorPlaneCompositeMergingAndPalette() { + ProgramSymbolManager manager = new ProgramSymbolManager(); + + // 1. Send Plane 1 (Red) for codepoint 0x41 + byte[] sf1 = new byte[4 + 6 + 18]; + sf1[0] = (byte) 0x81; // Format 1, extHeader=true + sf1[1] = 0x42; // LCID 0x42 + sf1[2] = 0x41; // Codepoint 0x41 + sf1[3] = 0x04; // RWS 4 + sf1[4] = 0x06; // Ext header len = 6 + sf1[9] = 0x01; // Plane 1 = Red + sf1[10] = (byte) 0x80;// Row 0 Col 0 = bit 1 + manager.loadps(sf1); + + ProgramSymbolSet.SymbolSlot slot1 = manager.getSymbol(0x42, 0x41); + assertNotNull(slot1); + assertEquals(1, slot1.getPixelData()[0] & 0xFF); + // Plane 1 alone must be Red (GOCA_COLORS[2] = 0xFFF01818) + assertEquals(GocaConstants.GOCA_COLORS[2], slot1.getRgbPixels(0, 0)[0]); + + // 2. Send Plane 2 (Green) for codepoint 0x41 + byte[] sf2 = new byte[4 + 6 + 18]; + sf2[0] = (byte) 0x81; + sf2[1] = 0x42; + sf2[2] = 0x41; + sf2[3] = 0x04; + sf2[4] = 0x06; + sf2[9] = 0x02; // Plane 2 = Green + sf2[10] = (byte) 0x80;// Row 0 Col 0 = bit 1 + manager.loadps(sf2); + + ProgramSymbolSet.SymbolSlot slot2 = manager.getSymbol(0x42, 0x41); + assertNotNull(slot2); + // Red (1) | Green (2) = 3 (Yellow) + assertEquals(3, slot2.getPixelData()[0] & 0xFF); + assertEquals(GocaConstants.GOCA_COLORS[6], slot2.getRgbPixels(0, 0)[0]); + + // 3. Send Plane 4 (Blue) for codepoint 0x41 + byte[] sf3 = new byte[4 + 6 + 18]; + sf3[0] = (byte) 0x81; + sf3[1] = 0x42; + sf3[2] = 0x41; + sf3[3] = 0x04; + sf3[4] = 0x06; + sf3[9] = 0x04; // Plane 4 = Blue + sf3[10] = (byte) 0x80;// Row 0 Col 0 = bit 1 + manager.loadps(sf3); + + ProgramSymbolSet.SymbolSlot slot3 = manager.getSymbol(0x42, 0x41); + assertNotNull(slot3); + // Red (1) | Green (2) | Blue (4) = 7 (White) + assertEquals(7, slot3.getPixelData()[0] & 0xFF); + assertEquals(GocaConstants.GOCA_COLORS[7], slot3.getRgbPixels(0, 0)[0]); + } + + @Test + public void testTransitionMonochromeToColor() { + ProgramSymbolManager manager = new ProgramSymbolManager(); + + // 1. Initial monochrome load (colorPlane = 0) + byte[] sfMono = new byte[4 + 18]; + sfMono[0] = 0x01; // Format 1 + sfMono[1] = 0x42; + sfMono[2] = 0x41; + sfMono[3] = 0x04; // RWS 4 + sfMono[4] = (byte) 0x80; // Row 0 Col 0 set + manager.loadps(sfMono); + + ProgramSymbolSet.SymbolSlot monoSlot = manager.getSymbol(0x42, 0x41); + assertEquals(7, monoSlot.getPixelData()[0] & 0xFF); + assertTrue(monoSlot.isAllPlanesLoaded()); + + // 2. Host now sends color plane 1 (Red only) + byte[] sfRed = new byte[4 + 6 + 18]; + sfRed[0] = (byte) 0x81; + sfRed[1] = 0x42; + sfRed[2] = 0x41; + sfRed[3] = 0x04; + sfRed[4] = 0x06; + sfRed[9] = 0x01; // Plane 1 (Red) + sfRed[10] = (byte) 0x80; + manager.loadps(sfRed); + + ProgramSymbolSet.SymbolSlot colorSlot = manager.getSymbol(0x42, 0x41); + // Previous monochrome bits (7) must NOT bleed in! Only Red (1) should be present! + assertEquals(1, colorSlot.getPixelData()[0] & 0xFF); + assertEquals(GocaConstants.GOCA_COLORS[2], colorSlot.getRgbPixels(0, 0)[0]); + assertFalse(colorSlot.isAllPlanesLoaded()); + } } 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 5639563..c9abc99 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/input/InputProcessorTest.java @@ -218,6 +218,32 @@ public class InputProcessorTest { assertEquals(2, screen.getCursorAddress()); } + @Test + public void testProtectedFieldSpaceDesignatorNoLightPenSelection() { + screen.erase(false); + // Field attribute at pos 0: PROTECTED, intensified selectable (0x28) as found in ISPF screen titles + screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_PROTECT | FA_INT_HIGH_SEL)); + // Designator character at pos 1: space + 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); + } + }; + + // Per IBM GA23-0059, clicking/light-pen on protected field with space/null must have no effect + boolean result = input.lightPenSelect(2); + assertFalse(result, "Protected field with space designator must not trigger light-pen selection"); + assertEquals(-1, sentAid.get(), "Must not send AID_SELECT"); + assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0, "Must not set FA_MODIFY"); + } + @Test public void testLightPenSelectEnterImmediateAmpersand() { screen.erase(false);