Bugfixes from zOS testing
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 37s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m16s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m52s

This commit is contained in:
2026-09-17 23:09:43 +00:00
parent 30b1a77b61
commit ef70ea0f5a
25 changed files with 2686 additions and 335 deletions
@@ -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() { private boolean isAnyChildDialogActive() {
@@ -694,6 +717,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
dialog.setInitialTls(haus.nightmare.j3270.config.Settings.getAutoConnectTls()); dialog.setInitialTls(haus.nightmare.j3270.config.Settings.getAutoConnectTls());
dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert()); dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert());
dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage()); dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage());
dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
dialog.setVisible(true); dialog.setVisible(true);
if (dialog.isConfirmed()) { 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.setAutoConnectTls(config.isUseTls());
haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert()); haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert());
haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage()); haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage());
if (config.getGraphicsMode() != null) {
haus.nightmare.j3270.config.Settings.setGraphicsMode(config.getGraphicsMode());
}
disconnect(); disconnect();
@@ -234,7 +234,6 @@ public class HostStorage {
if (map.containsKey("tn3270e")) h.setTn3270e(parseBool(map.get("tn3270e"), true)); if (map.containsKey("tn3270e")) h.setTn3270e(parseBool(map.get("tn3270e"), true));
if (map.containsKey("graphicsMode")) { if (map.containsKey("graphicsMode")) {
String gm = String.valueOf(map.get("graphicsMode")); String gm = String.valueOf(map.get("graphicsMode"));
if ("PROGRAMMED_SYMBOLS".equalsIgnoreCase(gm)) gm = "BOTH";
h.setGraphicsMode(gm); h.setGraphicsMode(gm);
} }
if (map.containsKey("codePage")) h.setCodePage(String.valueOf(map.get("codePage"))); if (map.containsKey("codePage")) h.setCodePage(String.valueOf(map.get("codePage")));
@@ -6,6 +6,7 @@ import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.TerminalModel; import haus.nightmare.lib3270j.TerminalModel;
import javax.swing.*; import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder; import javax.swing.border.TitledBorder;
import java.awt.*; import java.awt.*;
import java.util.List; import java.util.List;
@@ -226,6 +227,24 @@ public class ConnectDialog extends JDialog {
graphicsCombo.setSelectedItem(haus.nightmare.j3270.config.Settings.getGraphicsMode()); graphicsCombo.setSelectedItem(haus.nightmare.j3270.config.Settings.getGraphicsMode());
graphicsCombo.setFont(ThemeManager.getUiFont()); graphicsCombo.setFont(ThemeManager.getUiFont());
ThemeManager.styleComboBox(graphicsCombo); 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); contentPanel.add(graphicsCombo, gbc);
gridy++; gridy++;
@@ -664,7 +683,7 @@ public class ConnectDialog extends JDialog {
String gfx = "BOTH"; String gfx = "BOTH";
if (graphicsCombo.getSelectedItem() != null) { if (graphicsCombo.getSelectedItem() != null) {
gfx = graphicsCombo.getSelectedItem().toString(); gfx = ((haus.nightmare.lib3270j.graphics.GraphicsMode) graphicsCombo.getSelectedItem()).name();
} }
String cp = "037"; String cp = "037";
@@ -731,7 +750,9 @@ public class ConnectDialog extends JDialog {
result.setLuName(lu); result.setLuName(lu);
} }
if (graphicsCombo.getSelectedItem() != null) { 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(); 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); 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; confirmed = true;
dispose(); dispose();
} }
@@ -810,6 +839,12 @@ public class ConnectDialog extends JDialog {
tn3270eCheckBox.setSelected(tn3270e); 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) { public void setInitialCodePage(String cp) {
if (cp != null && codePageCombo != null) { if (cp != null && codePageCombo != null) {
for (int i = 0; i < codePageCombo.getItemCount(); i++) { for (int i = 0; i < codePageCombo.getItemCount(); i++) {
@@ -25,8 +25,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
// Font and cell dimensions // Font and cell dimensions
private Font terminalFont; private Font terminalFont;
private Font boldTerminalFont; private Font boldTerminalFont;
private int cellWidth; int cellWidth;
private int cellHeight; int cellHeight;
private int fontAscent; private int fontAscent;
private int fontDescent; private int fontDescent;
private int padding = 4; 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 selectionStartRow = -1, selectionStartCol = -1;
private int selectionEndRow = -1, selectionEndCol = -1; private int selectionEndRow = -1, selectionEndCol = -1;
private boolean isDragging = false; private boolean isDragging = false;
private boolean isActivationClick = false;
private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend private static final Color SELECTION_COLOR = new Color(75, 110, 175, 102); // 40% alpha blend
// ========== Search Highlight state ========== // ========== 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. * Compute the horizontal render offset to center the grid within the panel.
*/ */
private int getRenderOffsetX() { int getRenderOffsetX() {
int termCols = 80; int termCols = 80;
if (client != null) termCols = client.getScreenBuffer().getDisplayCols(); if (client != null) termCols = client.getScreenBuffer().getDisplayCols();
int gridW = termCols * cellWidth; 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. * Compute the vertical render offset to center the grid within the panel.
*/ */
private int getRenderOffsetY() { int getRenderOffsetY() {
int termRows = 24; int termRows = 24;
if (client != null) termRows = client.getScreenBuffer().getDisplayRows(); if (client != null) termRows = client.getScreenBuffer().getDisplayRows();
int gridH = termRows * cellHeight; int gridH = termRows * cellHeight;
@@ -186,7 +187,16 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
MouseAdapter mouseHandler = new MouseAdapter() { MouseAdapter mouseHandler = new MouseAdapter() {
@Override @Override
public void mousePressed(MouseEvent e) { 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 // Right-click context menu
if (SwingUtilities.isRightMouseButton(e)) { 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)); row = Math.max(0, Math.min(row, displayRows - 1));
boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive(); boolean isGraphic = client.getGocaDecoder() != null && client.getGocaDecoder().isGraphicsCursorActive();
boolean isSelectableField = false; if (isGraphic || lightPenMode) {
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) {
clearSelection(); clearSelection();
} else { } else {
selectionStartRow = row; 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)); row = Math.max(0, Math.min(row, displayRows - 1));
int clickAddr = row * displayCols + col; 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) { if (isGraphic) {
clearSelection(); clearSelection();
@@ -307,8 +310,11 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
py = Math.max(0, Math.min(py, gHeight - 1)); py = Math.max(0, Math.min(py, gHeight - 1));
client.getGocaDecoder().setGraphicCursorFromPixel(px, py); client.getGocaDecoder().setGraphicCursorFromPixel(px, py);
int gx = client.getGocaDecoder().getGraphicCursorX();
int gy = client.getGocaDecoder().getGraphicCursorY();
int button = javax.swing.SwingUtilities.isRightMouseButton(e) ? 2 : 1; int button = javax.swing.SwingUtilities.isRightMouseButton(e) ? 2 : 1;
client.getInputProcessor().sendGraphicMouseAid( client.getInputProcessor().sendGraphicMouseAid(
gx, gy,
haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER, haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER,
button, button,
e.isShiftDown(), e.isShiftDown(),
@@ -318,25 +324,57 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
return; 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()) { if (sb.isFormatted()) {
int faPos = sb.findFieldAttribute(clickAddr); int faPos = sb.findFieldAttribute(clickAddr);
if (faPos >= 0) { if (faPos >= 0) {
int fa = sb.getCell(faPos).fa & 0xFF; int fa = sb.getCell(faPos).fa & 0xFF;
if (haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) && if (haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa) &&
!haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa)) { !haus.nightmare.lib3270j.protocol.DS3270Constants.faIsZero(fa)) {
clearSelection(); int size = displayRows * displayCols;
if (client.lightPenSelect(clickAddr)) { int desPos = (faPos + 1) % size;
refreshScreen(); char desAscii = (char) sb.getCell(desPos).ucs4;
return; 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) { if (lightPenMode) {
clearSelection();
sb.setCursorAddress(clickAddr);
boolean result = client.lightPenSelect(clickAddr); boolean result = client.lightPenSelect(clickAddr);
if (result) { if (result) {
refreshScreen(); refreshScreen();
@@ -346,12 +384,8 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable {
} }
} }
if (selectionStartRow == selectionEndRow && selectionStartCol == selectionEndCol) { refreshScreen();
sb.setCursorAddress(clickAddr); return;
clearSelection();
refreshScreen();
return;
}
} }
repaint(); repaint();
} }
@@ -304,4 +304,40 @@ public class HostStorageAndProxyTest {
dialog.dispose(); 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<SavedHost> 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());
}
}
} }
@@ -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
}
}
}
@@ -456,11 +456,26 @@ public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener {
this.bytesTransferred = 0; this.bytesTransferred = 0;
this.totalBytes = (config.isSend() && localFile.exists()) ? localFile.length() : 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(); String command = config.buildCommand();
log.info("ECLXfer: initiating transfer: " + command); log.info("ECLXfer: initiating transfer: " + command);
int capacity = input.kybdPrime(); int capacity = input != null ? input.kybdPrime() : 0;
if (capacity < command.length()) { if (input != null && capacity < command.length()) {
String err = "Input field capacity insufficient for command (" + capacity + " chars)"; String err = "Input field capacity insufficient for command (" + capacity + " chars)";
fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, err); fireEvent(ECLXferEvent.XFER_ABORTED, FTConstants.ECL_ERR_XFER_ABORT, err);
return FTConstants.ECL_ERR_XFER_ABORT; return FTConstants.ECL_ERR_XFER_ABORT;
@@ -469,10 +484,25 @@ public class ECLXfer implements FTCut.FTCutListener, FTDft.FTDftListener {
setState(FTState.AWAIT_ACK); setState(FTState.AWAIT_ACK);
fireEvent(ECLXferEvent.XFER_STARTED, 0, "Transfer initiated"); fireEvent(ECLXferEvent.XFER_STARTED, 0, "Transfer initiated");
input.emulateInput(command + "\n"); if (input != null) {
input.emulateInput(command + "\n");
}
return 0; 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) { private void parseOptionsIntoConfig(FTConfig config, String options) {
if (options == null || options.trim().isEmpty()) return; if (options == null || options.trim().isEmpty()) return;
config.parseOptions(options); config.parseOptions(options);
@@ -74,6 +74,11 @@ public class FTConfig {
private String codePage = null; private String codePage = null;
private boolean unicode = false; private boolean unicode = false;
private int unicodeType = 0; // 0 = UCS2, 1 = UTF8 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 ========== // ========== Derived convenience getters ==========
@@ -91,6 +96,37 @@ public class FTConfig {
} }
public boolean isAppend() { return existAction == ExistAction.APPEND; } public boolean isAppend() { return existAction == ExistAction.APPEND; }
public boolean isOverwrite() { return existAction == ExistAction.REPLACE; } 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 ========== // ========== Standard getters and setters ==========
@@ -303,10 +339,22 @@ public class FTConfig {
if (upper.contains("APPEND")) { if (upper.contains("APPEND")) {
this.existAction = ExistAction.APPEND; this.existAction = ExistAction.APPEND;
} else if (upper.contains("NEW") && !upper.contains("NONEW")) {
this.optionNew = true;
} else if (upper.contains("REPLACE") || upper.contains("OVERWRITE")) { } else if (upper.contains("REPLACE") || upper.contains("OVERWRITE")) {
this.existAction = ExistAction.REPLACE; 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")) { if (upper.contains("CICS")) {
this.hostType = HostType.CICS; this.hostType = HostType.CICS;
} else if (upper.contains("CMS") || upper.contains("VM")) { } else if (upper.contains("CMS") || upper.contains("VM")) {
@@ -332,6 +380,10 @@ public class FTConfig {
String hostTrimmed = hostFilename.trim(); String hostTrimmed = hostFilename.trim();
if (hostType == HostType.TSO && hostTrimmed.contains(" ") && validateCmsFilename(hostTrimmed) == null) {
hostType = HostType.CMS;
}
if (hostType == HostType.TSO) { if (hostType == HostType.TSO) {
String tsoError = validateTsoDatasetName(hostTrimmed); String tsoError = validateTsoDatasetName(hostTrimmed);
if (tsoError != null) return tsoError; if (tsoError != null) return tsoError;
@@ -448,8 +500,8 @@ public class FTConfig {
public String buildCommand() { public String buildCommand() {
StringBuilder cmd = new StringBuilder(); StringBuilder cmd = new StringBuilder();
// IND$FILE GET/PUT hostfile // Host program selection: IND$FILE or APVUFILE
cmd.append("IND$FILE "); cmd.append(getProgramName()).append(" ");
cmd.append(isReceive() ? "GET " : "PUT "); cmd.append(isReceive() ? "GET " : "PUT ");
cmd.append(hostFilename != null ? hostFilename.trim() : ""); cmd.append(hostFilename != null ? hostFilename.trim() : "");
@@ -477,16 +529,23 @@ public class FTConfig {
opts.append("NOCRLF"); opts.append("NOCRLF");
} }
// Append (send only) if (isOptionNoso()) {
if (isAppend() && isSend()) {
if (opts.length() > 0) opts.append(" "); if (opts.length() > 0) opts.append(" ");
opts.append("APPEND"); opts.append("NOSO");
} }
// Overwrite / Replace (if send or explicitly requested) // Append / New / Replace (send only)
if (isOverwrite() && isSend()) { if (isSend()) {
if (opts.length() > 0) opts.append(" "); if (isAppend()) {
opts.append("REPLACE"); 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 // Host-specific send options
@@ -123,15 +123,69 @@ public final class FTConstants {
// Error codes // Error codes
public static final int TR_ERR_EOF = 0x2200; public static final int TR_ERR_EOF = 0x2200;
public static final int TR_ERR_CMDFAIL = 0x0100; 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_MIN_BUF = 256;
public static final int DFT_MAX_BUF = 32768; 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 // AID for structured field response
public static final int AID_SF = 0x88; 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 ========== // ========== Transfer State ==========
public enum FTState { public enum FTState {
@@ -215,11 +215,13 @@ public class FTCut {
// CUT frames MUST have a skip field attribute at O_SF (1919) // CUT frames MUST have a skip field attribute at O_SF (1919)
byte sfAttr = screen.getCellFAByte(O_SF); byte sfAttr = screen.getCellFAByte(O_SF);
if (sfAttr == 0 || !isSkip(sfAttr)) { 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) { if (xferInProgress) {
log.warning("CUT: Received non-CUT frame while transfer in progress. Host aborted."); log.warning("CUT: Received non-CUT frame while transfer in progress. Host aborted.");
xferInProgress = false; 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; 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() { private void checkForHostPrompts() {
if (screen == null) return; if (screen == null) return;
int size = screen.getRows() * screen.getCols(); int size = screen.getRows() * screen.getCols();
@@ -299,6 +333,10 @@ public class FTCut {
cutAck(); cutAck();
String msg = extractHostMessage(); String msg = extractHostMessage();
if ("Host cancelled transfer".equals(msg)) {
String hostErr = scanScreenForErrors();
if (hostErr != null) msg = hostErr;
}
listener.onTransferAborted(msg); listener.onTransferAborted(msg);
break; break;
@@ -388,7 +426,7 @@ public class FTCut {
log.fine("CUT: > DATA seq=" + seq + " len=" + count); log.fine("CUT: > DATA seq=" + seq + " len=" + count);
expandedLength += count; expandedLength += count;
listener.onBytesTransferred(expandedLength); listener.onBytesTransferred(expandedLength);
input.sendAidForFT(AID_ENTER); paceAndSendAid(AID_ENTER);
} }
// ========== Data (Download) ========== // ========== Data (Download) ==========
@@ -467,11 +505,11 @@ public class FTCut {
screen.setCellFA(O_DR_SF, attr); screen.setCellFA(O_DR_SF, attr);
log.fine("CUT: Retransmitting last upload frame (len=" + lastUploadCount + ")"); log.fine("CUT: Retransmitting last upload frame (len=" + lastUploadCount + ")");
input.sendAidForFT(AID_ENTER); paceAndSendAid(AID_ENTER);
} else { } else {
// In download mode, send ACK_RETRANSMIT (PF1) // In download mode, send ACK_RETRANSMIT (PF1)
log.fine("CUT: Requesting frame retransmit from host via 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() { private void cutAck() {
log.fine("CUT: > ACK (Enter)"); log.fine("CUT: > ACK (Enter)");
input.sendAidForFT(AID_ENTER); paceAndSendAid(AID_ENTER);
} }
// ========== Abort ========== // ========== Abort ==========
@@ -492,10 +530,24 @@ public class FTCut {
screen.setCell(RO_REASON_CODE, (reason >> 8) & 0xFF); screen.setCell(RO_REASON_CODE, (reason >> 8) & 0xFF);
screen.setCell(RO_REASON_CODE + 1, reason & 0xFF); screen.setCell(RO_REASON_CODE + 1, reason & 0xFF);
input.sendAidForFT(AID_PF2); paceAndSendAid(AID_PF2);
listener.onTransferAborted(message); 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 ========== // ========== Character Translation ==========
/** /**
@@ -49,6 +49,14 @@ public class FTDft {
private int customMtuSize = 0; private int customMtuSize = 0;
private int pendingByte = -1; 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 // Savebuf for Read Modified retransmit
private byte[] dftSaveBuf = null; private byte[] dftSaveBuf = null;
private int dftSaveBufLen = 0; private int dftSaveBufLen = 0;
@@ -159,6 +167,12 @@ public class FTDft {
dftSaveBufLen = 0; dftSaveBufLen = 0;
lastCr = false; lastCr = false;
pendingByte = -1; 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)); 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) { switch (requestCode) {
case TR_OPEN_REQ: case TR_OPEN_REQ:
dftOpenRequest(data, offset, length); 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 ========== // ========== 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) { private void dftOpenRequest(byte[] data, int sfOffset, int sfLength) {
log.fine("DFT: Open request"); log.fine("DFT: Open request");
@@ -247,7 +333,10 @@ public class FTDft {
// Check for host DDM file attributes embedded in open request // Check for host DDM file attributes embedded in open request
parseDdmAttributes(data, sfOffset + 3, sfLength - 3); 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; messageFlag = true;
log.info("DFT: Open request for message stream (" + nameBuf + ")"); log.info("DFT: Open request for message stream (" + nameBuf + ")");
} else { } else {
@@ -258,7 +347,7 @@ public class FTDft {
dftEof = false; dftEof = false;
recnum = 1; recnum = 1;
// Acknowledge Open matching x3270 (SF_TRANSFER_DATA + 0x0009) // Acknowledge Open matching IBM Host On-Demand
dftOpenAck(); dftOpenAck();
} }
@@ -322,12 +411,7 @@ public class FTDft {
} }
private void dftOpenAck() { private void dftOpenAck() {
ByteArrayOutputStream out = new ByteArrayOutputStream(6); input.sendStructuredFieldData(RB_OPEN_ACK.clone());
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());
} }
// ========== Insert Request (host sending data for download) ========== // ========== Insert Request (host sending data for download) ==========
@@ -441,31 +525,125 @@ public class FTDft {
} }
/** /**
* Write download data to the local file, handling ASCII conversion. * Write download data to the local file, handling ASCII conversion, RECFM F/V formatting, and EOF stripping.
* Matching x3270 upload_convert logic. * Conforms to IBM Host On-Demand specification.
*/ */
private void writeDownloadData(byte[] data, int offset, int length, private void writeDownloadData(byte[] data, int offset, int length,
FTConfig config) throws IOException { FTConfig config) throws IOException {
if (outputStream == null) return; if (outputStream == null || length <= 0) return;
if (config != null && config.isUnicode()) { if (config != null && config.isUnicode()) {
outputStream.write(data, offset, length); outputStream.write(data, offset, length);
return; return;
} }
if (!config.isAscii()) { if (config != null && !config.isAscii()) {
outputStream.write(data, offset, length); outputStream.write(data, offset, length);
return; 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++) { for (int i = 0; i < length; i++) {
int b = data[offset + i] & 0xFF; int b = data[offset + i] & 0xFF;
if (config.isCrFlag() && (b == '\r' || b == EOF_CTRL_Z || b == EOF_CTRL_D)) { if (b == EOF_CTRL_Z || b == EOF_CTRL_D) {
continue; // Strip CR and EOF (^Z / ^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); outputStream.write(b);
continue; continue;
} }
@@ -506,30 +684,42 @@ public class FTDft {
byte[] readBuf = new byte[numbytes]; byte[] readBuf = new byte[numbytes];
int totalRead = 0; 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 { try {
while (!dftEof && totalRead < numbytes) { if (config != null && config.isAscii() && isRecfmF && lrecl > 0) {
if (config != null && config.isUnicode() && inputStream instanceof haus.nightmare.lib3270j.xfer.XferFileInputUnicode) { totalRead = readFixedRecordUpload(readBuf, numbytes, lrecl, config);
int n = ((haus.nightmare.lib3270j.xfer.XferFileInputUnicode) inputStream).readData(readBuf, totalRead, numbytes - totalRead); } else if (config != null && config.isAscii() && isRecfmV) {
if (n <= 0) { totalRead = readVariableRecordUpload(readBuf, numbytes, config);
dftEof = true; } else {
break; 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) { } catch (IOException e) {
@@ -537,17 +727,17 @@ public class FTDft {
return; 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) { if (totalRead > 0) {
log.fine("DFT: > GetReply rec=" + recnum + " " + totalRead + " bytes"); 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 >> 8) & 0xFF);
out.write(TR_GET_REPLY & 0xFF); out.write(TR_GET_REPLY & 0xFF);
@@ -570,31 +760,167 @@ public class FTDft {
out.write(readBuf, 0, totalRead); out.write(readBuf, 0, totalRead);
bytesTransferred += 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 { } else {
log.fine("DFT: > GetReply EOF"); 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; 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(); byte[] raw = lineBytes.toByteArray();
int sfLen = result.length - 1; return convertLineForUpload(raw, config);
result[sfLenPos] = (byte) ((sfLen >> 8) & 0xFF); }
result[sfLenPos + 1] = (byte) (sfLen & 0xFF);
dftSaveBuf = result.clone(); private byte[] convertLineForUpload(byte[] raw, FTConfig config) {
dftSaveBufLen = result.length; 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); for (int i = 0; i < lineStr.length(); i++) {
listener.onBytesTransferred(bytesTransferred); 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() { private void dftCloseRequest() {
log.fine("DFT: Close"); log.fine("DFT: Close");
ByteArrayOutputStream out = new ByteArrayOutputStream(6); try {
out.write(AID_SF); flushRemainingDownloadRecords();
out.write(0); out.write(5); } catch (IOException e) {
out.write(SF_TRANSFER_DATA); log.warning("DFT: Error flushing download buffer on close: " + e.getMessage());
out.write((TR_CLOSE_REPLY >> 8) & 0xFF); }
out.write(TR_CLOSE_REPLY & 0xFF);
input.sendStructuredFieldData(out.toByteArray()); input.sendStructuredFieldData(RB_CLOSE_ACK.clone());
if (listener.getCurrentState() != FTState.NONE) { if (listener.getCurrentState() != FTState.NONE) {
log.info("DFT: File transfer completed on close request (" + bytesTransferred + " bytes)"); log.info("DFT: File transfer completed on close request (" + bytesTransferred + " bytes)");
@@ -735,21 +1060,7 @@ public class FTDft {
// ========== Data Acknowledgement ========== // ========== Data Acknowledgement ==========
private void dftDataAck() { private void dftDataAck() {
ByteArrayOutputStream out = new ByteArrayOutputStream(12); input.sendStructuredFieldData(createDataAck(recnum++));
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());
} }
// ========== Abort ========== // ========== Abort ==========
@@ -757,18 +1068,22 @@ public class FTDft {
private void dftAbort(String message, int code) { private void dftAbort(String message, int code) {
log.warning("DFT: ABORT — " + message); log.warning("DFT: ABORT — " + message);
ByteArrayOutputStream out = new ByteArrayOutputStream(10); if (code == TR_OPEN_REQ) {
out.write(AID_SF); input.sendStructuredFieldData(RB_OPEN_FAILED.clone());
out.write(0); out.write(9); } else {
out.write(SF_TRANSFER_DATA); ByteArrayOutputStream out = new ByteArrayOutputStream(10);
out.write((code >> 8) & 0xFF); out.write(AID_SF);
out.write(TR_ERROR_REPLY & 0xFF); out.write(0); out.write(9);
out.write((TR_ERROR_HDR >> 8) & 0xFF); out.write(SF_TRANSFER_DATA);
out.write(TR_ERROR_HDR & 0xFF); out.write((code >> 8) & 0xFF);
out.write((TR_ERR_CMDFAIL >> 8) & 0xFF); out.write(TR_ERROR_REPLY & 0xFF);
out.write(TR_ERR_CMDFAIL & 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); listener.onTransferAborted(message);
} }
@@ -10,10 +10,14 @@ import java.util.List;
* <p>A GOCA fillet curve passes from the initial point P_0 to the terminal point P_{N-1}, * <p>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. * 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 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. * 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. * Matches IBM Host On-Demand FilletPts.getFilletPoints.
*/ */
public int getFilletPoints(int[] xIn, int[] yIn, int n, int[] xOut, int[] yOut) { public int getFilletPoints(int[] nArray, int[] nArray2, int n, int[] nArray3, int[] nArray4) {
if (xIn == null || yIn == null || n <= 0 || xOut == null || yOut == null) { if (nArray == null || nArray2 == null || n <= 0 || nArray3 == null || nArray4 == null) {
return 0; return 0;
} }
int[][] pts = calculate(xIn, yIn, n, 16);
int count = Math.min(pts[0].length, Math.min(xOut.length, yOut.length)); int n2;
System.arraycopy(pts[0], 0, xOut, 0, count); int n3 = 0;
System.arraycopy(pts[1], 0, yOut, 0, count); int n4 = 0;
return count; 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++];
}
} }
} }
@@ -50,6 +50,19 @@ public class GddmCoordinateTransform {
private double transformX = 1.0; private double transformX = 1.0;
private double transformY = 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() { public GddmCoordinateTransform() {
this(80, 24, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16); this(80, 24, DEFAULT_CHAR_WIDTH, DEFAULT_CHAR_HEIGHT_16);
} }
@@ -270,4 +283,68 @@ public class GddmCoordinateTransform {
public int convertYtoRow(int y) { public int convertYtoRow(int y) {
return (defaultCharHeight > 0) ? (y / defaultCharHeight) + 1 : 1; 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);
}
} }
@@ -40,10 +40,15 @@ public class GocaDecoder {
private double charHeight = 16.0; private double charHeight = 16.0;
private int charSet = 0; private int charSet = 0;
private int charPrecision = GocaConstants.CP_STRING; private int charPrecision = GocaConstants.CP_STRING;
private int arcParamP = 1; public static final int DEFAULT_ARC_P = 1;
private int arcParamQ = 0; public static final int DEFAULT_ARC_Q = 1;
private int arcParamR = 0; public static final int DEFAULT_ARC_R = 0;
private int arcParamS = 1; 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 segChained = false;
private boolean segDynamic = false; private boolean segDynamic = false;
@@ -77,6 +82,7 @@ public class GocaDecoder {
private int imgHeight = 0; private int imgHeight = 0;
private int imgBitDepth = GocaConstants.BPP_1; private int imgBitDepth = GocaConstants.BPP_1;
private int imgCompression = GocaConstants.IMG_UNCOMPRESSED; private int imgCompression = GocaConstants.IMG_UNCOMPRESSED;
private int imgMultiplier = 1;
private final List<Byte> imgBuffer = new ArrayList<>(); private final List<Byte> imgBuffer = new ArrayList<>();
// Segment Store for retained graphics / segment calling (G_GCALL 0x2A) // Segment Store for retained graphics / segment calling (G_GCALL 0x2A)
@@ -368,7 +374,15 @@ public class GocaDecoder {
inImage = false; inImage = false;
imgBitDepth = GocaConstants.BPP_1; imgBitDepth = GocaConstants.BPP_1;
imgCompression = GocaConstants.IMG_UNCOMPRESSED; imgCompression = GocaConstants.IMG_UNCOMPRESSED;
imgMultiplier = 1;
imgBuffer.clear(); 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]; private byte[] partialOrderBuffer = new byte[0];
@@ -567,7 +581,10 @@ public class GocaDecoder {
int w = 0, h = 0; int w = 0, h = 0;
int bitDepth = GocaConstants.BPP_1; int bitDepth = GocaConstants.BPP_1;
int compression = GocaConstants.IMG_UNCOMPRESSED; int compression = GocaConstants.IMG_UNCOMPRESSED;
int imult = 1;
if (payloadLen >= 6 && idx + 2 + payloadLen <= end) { if (payloadLen >= 6 && idx + 2 + payloadLen <= end) {
imult = readCoord(inputData, idx + 2);
if (imult <= 0) imult = 1;
w = readCoord(inputData, idx + 4); w = readCoord(inputData, idx + 4);
h = readCoord(inputData, idx + 6); h = readCoord(inputData, idx + 6);
if (payloadLen >= 7) { if (payloadLen >= 7) {
@@ -592,7 +609,7 @@ public class GocaDecoder {
compression = inputData[idx + 7] & 0xFF; compression = inputData[idx + 7] & 0xFF;
} }
} }
beginImage(curX, curY, w, h, bitDepth, compression); beginImage(curX, curY, w, h, bitDepth, compression, imult);
idx += orderLen; idx += orderLen;
break; break;
} }
@@ -672,13 +689,25 @@ public class GocaDecoder {
idx += orderLen; idx += orderLen;
break; break;
} }
case GocaConstants.G_GSAP: { // Arc Parameters (0x22) case GocaConstants.G_GSAP: { // Arc Parameters / Set Aspect Ratio (0x22)
if (idx + 9 < end) { if (idx + 9 < end) {
arcParamP = readCoord(inputData, idx + 2); arcParamP = readCoord(inputData, idx + 2);
arcParamQ = readCoord(inputData, idx + 4); arcParamQ = readCoord(inputData, idx + 4);
arcParamR = readCoord(inputData, idx + 6); arcParamR = readCoord(inputData, idx + 6);
arcParamS = readCoord(inputData, idx + 8); 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; idx += orderLen;
break; break;
} }
@@ -1012,7 +1041,10 @@ public class GocaDecoder {
int w, h; int w, h;
int bitDepth = GocaConstants.BPP_1; int bitDepth = GocaConstants.BPP_1;
int compression = GocaConstants.IMG_UNCOMPRESSED; int compression = GocaConstants.IMG_UNCOMPRESSED;
int imult = 1;
if (payloadLen >= 10) { if (payloadLen >= 10) {
imult = readCoord(inputData, idx + 6);
if (imult <= 0) imult = 1;
w = readCoord(inputData, idx + 8); w = readCoord(inputData, idx + 8);
h = readCoord(inputData, idx + 10); h = readCoord(inputData, idx + 10);
if (payloadLen >= 11) { if (payloadLen >= 11) {
@@ -1037,7 +1069,7 @@ public class GocaDecoder {
compression = inputData[idx + 11] & 0xFF; compression = inputData[idx + 11] & 0xFF;
} }
} }
beginImage(x, y, w, h, bitDepth, compression); beginImage(x, y, w, h, bitDepth, compression, imult);
} }
idx += orderLen; idx += orderLen;
break; break;
@@ -1309,10 +1341,14 @@ public class GocaDecoder {
} }
private void beginImage(int x, int y, int w, int h) { 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) { 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.inImage = true;
this.imgX = x; this.imgX = x;
this.imgY = y; this.imgY = y;
@@ -1320,6 +1356,7 @@ public class GocaDecoder {
this.imgHeight = h; this.imgHeight = h;
this.imgBitDepth = bitDepth; this.imgBitDepth = bitDepth;
this.imgCompression = compression; this.imgCompression = compression;
this.imgMultiplier = (multiplier > 0) ? multiplier : 1;
this.imgBuffer.clear(); this.imgBuffer.clear();
} }
@@ -1335,7 +1372,7 @@ public class GocaDecoder {
int px = plane.mapX(imgX); int px = plane.mapX(imgX);
int py = plane.mapY(imgY); 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; inImage = false;
imgBuffer.clear(); imgBuffer.clear();
} }
@@ -1429,38 +1466,191 @@ public class GocaDecoder {
private void processArc(byte[] data, int off, int len, boolean fromCurPos, boolean isFull) { private void processArc(byte[] data, int off, int len, boolean fromCurPos, boolean isFull) {
int pos = off; 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 centerX = curX;
int centerY = curY; int centerY = curY;
if (!fromCurPos && pos + 4 <= end) {
// For absolute variants, read the center point from data
if (!fromCurPos && pos + 4 <= off + len) {
centerX = readCoord(data, pos); centerX = readCoord(data, pos);
centerY = readCoord(data, pos + 2); centerY = readCoord(data, pos + 2);
pos += 4; pos += 4;
} }
// Read the multiplier (integer.fraction format)
double multiplier = 1.0; double multiplier = 1.0;
if (pos + 2 <= off + len) { if (pos + 2 <= end) {
int intPart = data[pos]; // signed byte int intPart = data[pos];
int fracPart = data[pos + 1] & 0xFF; int fracPart = data[pos + 1] & 0xFF;
multiplier = intPart + fracPart / 256.0; multiplier = intPart + fracPart / 256.0;
if (multiplier == 0.0) multiplier = 1.0; if (multiplier == 0.0) multiplier = 1.0;
pos += 2; 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 pScaled = arcParamP * multiplier;
double qScaled = arcParamQ * multiplier; double qScaled = arcParamQ * multiplier;
double rScaled = arcParamR * multiplier; double rScaled = arcParamR * multiplier;
double sScaled = arcParamS * multiplier; double sScaled = arcParamS * multiplier;
// Semi-axis lengths in GOCA coordinate space
double semiAxis1 = Math.sqrt(pScaled * pScaled + qScaled * qScaled); double semiAxis1 = Math.sqrt(pScaled * pScaled + qScaled * qScaled);
double semiAxis2 = Math.sqrt(rScaled * rScaled + sScaled * sScaled); 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); double dist = Math.hypot(curX - centerX, curY - centerY);
if (semiAxis1 <= 1.01 && dist > 1.0) { if (semiAxis1 <= 1.01 && dist > 1.0) {
semiAxis1 = dist * multiplier; semiAxis1 = dist * multiplier;
@@ -1470,7 +1660,6 @@ public class GocaDecoder {
if (semiAxis1 < 1.0) semiAxis1 = 1.0; if (semiAxis1 < 1.0) semiAxis1 = 1.0;
if (semiAxis2 < 1.0) semiAxis2 = 1.0; if (semiAxis2 < 1.0) semiAxis2 = 1.0;
// Map to pixel space with double precision
double cx = plane.mapXDouble(centerX); double cx = plane.mapXDouble(centerX);
double cy = plane.mapYDouble(centerY); double cy = plane.mapYDouble(centerY);
double rx = Math.abs(plane.mapXDouble(semiAxis1) - plane.mapXDouble(0)); 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 (rx <= 0.0) rx = Math.max(1.0, semiAxis1);
if (ry <= 0.0) ry = Math.max(1.0, semiAxis2); if (ry <= 0.0) ry = Math.max(1.0, semiAxis2);
// For partial arcs, determine start angle and sweep angle
double startAngleDeg = 0.0; double startAngleDeg = 0.0;
double sweepAngleDeg = 360.0; double sweepAngleDeg = 360.0;
if (!isFull) { if (pos + 4 <= end) {
if (pos + 4 <= off + len) { int startInt = data[pos];
// Sweep start: integer.fraction of full revolution int startFrac = data[pos + 1] & 0xFF;
int startInt = data[pos]; startAngleDeg = (startInt + startFrac / 256.0) * 360.0;
int startFrac = data[pos + 1] & 0xFF; pos += 2;
startAngleDeg = (startInt + startFrac / 256.0) * 360.0; int sweepInt = data[pos];
pos += 2; int sweepFrac = data[pos + 1] & 0xFF;
// Sweep angle: integer.fraction of full revolution sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0;
int sweepInt = data[pos]; } else if (pos + 2 <= end) {
int sweepFrac = data[pos + 1] & 0xFF; int sweepInt = data[pos];
sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; int sweepFrac = data[pos + 1] & 0xFF;
} else if (pos + 2 <= off + len) { sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0;
// Single sweep parameter if (curX != centerX || curY != centerY) {
int sweepInt = data[pos]; double startRad = Math.atan2(plane.mapYDouble(centerY) - plane.mapYDouble(curY), plane.mapXDouble(curX) - plane.mapXDouble(centerX));
int sweepFrac = data[pos + 1] & 0xFF; startAngleDeg = Math.toDegrees(startRad);
sweepAngleDeg = (sweepInt + sweepFrac / 256.0) * 360.0; if (startAngleDeg < 0) startAngleDeg += 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;
} }
} }
@@ -1533,7 +1708,7 @@ public class GocaDecoder {
} }
plane.drawArc(cx, cy, rx, ry, startAngleDeg, sweepAngleDeg, plane.drawArc(cx, cy, rx, ry, startAngleDeg, sweepAngleDeg,
curColor, lineType, lineWidth, isFull); curColor, lineType, lineWidth, false);
curX = centerX; curX = centerX;
curY = centerY; curY = centerY;
@@ -1543,63 +1718,62 @@ public class GocaDecoder {
int pos = off; int pos = off;
int end = off + len; int end = off + len;
List<Double> ptsX = new ArrayList<>();
List<Double> ptsY = new ArrayList<>();
List<Integer> gocaPtsX = new ArrayList<>(); List<Integer> gocaPtsX = new ArrayList<>();
List<Integer> gocaPtsY = new ArrayList<>(); List<Integer> gocaPtsY = new ArrayList<>();
if (fromCurPos) { if (fromCurPos) {
trackPoint(curX, curY);
ptsX.add(plane.mapXDouble(curX));
ptsY.add(plane.mapYDouble(curY));
gocaPtsX.add(curX); gocaPtsX.add(curX);
gocaPtsY.add(curY); gocaPtsY.add(curY);
if (inArea && currentPolyPts == 0) {
addAreaLineStart(curX, curY);
}
} }
while (pos + 4 <= end) { while (pos + 4 <= end) {
int x = readCoord(data, pos); int x = readCoord(data, pos);
int y = readCoord(data, pos + 2); int y = readCoord(data, pos + 2);
trackPoint(x, y);
ptsX.add(plane.mapXDouble(x));
ptsY.add(plane.mapYDouble(y));
gocaPtsX.add(x); gocaPtsX.add(x);
gocaPtsY.add(y); gocaPtsY.add(y);
if (inArea && ptsX.size() == 1 && currentPolyPts == 0) {
addAreaLineStart(x, y);
}
curX = x;
curY = y;
pos += 4; pos += 4;
} }
if (ptsX.size() >= 2) { int n = gocaPtsX.size();
int n = ptsX.size(); if (n < 2) return;
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[] 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) { if (inArea) {
int gn = gocaPtsX.size(); for (int i = 0; i < nPoints; i++) {
int[] gx = new int[gn]; if (i == 0 && currentPolyPts == 0) {
int[] gy = new int[gn]; addAreaLineStart(outX[0], outY[0]);
for (int i = 0; i < gn; i++) { } else {
gx[i] = gocaPtsX.get(i); addAreaPoint(outX[i], outY[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]);
} }
} else { } 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) { private void processMarker(byte[] data, int off, int len, boolean fromCurPos) {
@@ -1824,6 +1998,15 @@ public class GocaDecoder {
resetDefaults(); 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) { private int readCoord(byte[] data, int off) {
return (short) (((data[off] & 0xFF) << 8) | (data[off + 1] & 0xFF)); return (short) (((data[off] & 0xFF) << 8) | (data[off + 1] & 0xFF));
} }
@@ -1221,12 +1221,12 @@ public class GraphicsPlane implements PixelBuffer {
for (int i = 0; i < pLen - 1; i++) { for (int i = 0; i < pLen - 1; i++) {
drawLine((double) px[offset + i], (double) py[offset + i], drawLine((double) px[offset + i], (double) py[offset + i],
(double) px[offset + i + 1], (double) py[offset + i + 1], (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])) { 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], drawLine((double) px[offset + pLen - 1], (double) py[offset + pLen - 1],
(double) px[offset], (double) py[offset], (double) px[offset], (double) py[offset],
boundaryColorArgb, lineType, GocaConstants.LW_NORMAL); boundaryColorArgb, lineType, lineWidth);
} }
} }
offset += pLen; 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); 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) { 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); 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, public synchronized void drawImage(int x, int y, int width, int height, byte[] imageData, int fgColorArgb,
int bitDepth, int compressionMode) { 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; if (imageData == null || width <= 0 || height <= 0) return;
int fgColor = (fgColorArgb != 0) ? fgColorArgb : GocaConstants.GOCA_COLORS[0]; int fgColor = (fgColorArgb != 0) ? fgColorArgb : GocaConstants.GOCA_COLORS[0];
int m = (multiplier > 0) ? multiplier : 1;
byte[] rawData = imageData; byte[] rawData = imageData;
if (compressionMode == GocaConstants.IMG_RLE) { if (compressionMode == GocaConstants.IMG_RLE) {
@@ -1549,7 +1568,15 @@ public class GraphicsPlane implements PixelBuffer {
if (byteIdx < rawData.length) { if (byteIdx < rawData.length) {
boolean bit = ((rawData[byteIdx] >> (7 - (col % 8))) & 1) != 0; boolean bit = ((rawData[byteIdx] >> (7 - (col % 8))) & 1) != 0;
if (bit) { 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 case 3: pixelColor = GocaConstants.GOCA_COLORS[4]; break; // Green
default: pixelColor = fgColor; break; 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 shift = (1 - (col % 2)) * 4;
int val = (rawData[byteIdx] >> shift) & 0x0F; int val = (rawData[byteIdx] >> shift) & 0x0F;
if (val != 0) { 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) { if (byteIdx < rawData.length) {
int val = rawData[byteIdx] & 0xFF; int val = rawData[byteIdx] & 0xFF;
if (val != 0) { 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);
}
}
}
} }
} }
} }
@@ -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);
}
}
@@ -18,11 +18,12 @@ public class ProgramSymbolManager {
public static final int NUMBER_GRAPHICS_SYMBOL_SETS = 4; // RWS 8..11 (Sets 6..9) public static final int NUMBER_GRAPHICS_SYMBOL_SETS = 4; // RWS 8..11 (Sets 6..9)
private int defaultCellWidth = 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) { public void setDefaultCellDimensions(int width, int height) {
this.defaultCellWidth = (width > 0) ? width : 9; this.defaultCellWidth = (width > 0) ? width : 9;
this.defaultCellHeight = (height > 0) ? height : 12; this.defaultCellHeight = (height > 0) ? height : 16;
} }
public int getDefaultCellWidth() { public int getDefaultCellWidth() {
@@ -194,20 +195,24 @@ public class ProgramSymbolManager {
boolean isTriplePlane = (rws >= 4 && rws <= 7); boolean isTriplePlane = (rws >= 4 && rws <= 7);
ProgramSymbolSet set = sets[setIndex]; ProgramSymbolSet set = sets[setIndex];
if (clearAll) {
set.clear();
}
int extHeaderLen = 0; int extHeaderLen = 0;
int cellWidth = defaultCellWidth; int cellWidth = (loadFormat == 1 || rws < 8) ? 9 : defaultCellWidth;
int cellHeight = defaultCellHeight; int cellHeight = (loadFormat == 1 || rws < 8) ? 16 : defaultCellHeight;
int colorPlane = 0; // 0 = all planes, 1 = Red, 2 = Green, 4 = Blue int colorPlane = 0; // 0 = all planes, 1 = Blue, 2 = Red, 4 = Green
if (hasExtHeader && data.length > 4) { if (hasExtHeader && data.length > 4) {
extHeaderLen = data[4] & 0xFF; extHeaderLen = data[4] & 0xFF;
if (extHeaderLen > 3 && data.length > 6) { if (extHeaderLen > 3 && data.length > 6) {
int lw = data[6] & 0xFF; int lw = data[6] & 0xFF;
if (lw > 0) cellWidth = lw; if (lw > 0 && loadFormat != 1) cellWidth = lw;
} }
if (extHeaderLen > 4 && data.length > 7) { if (extHeaderLen > 4 && data.length > 7) {
int lh = data[7] & 0xFF; int lh = data[7] & 0xFF;
if (lh > 0) cellHeight = lh; if (lh > 0 && loadFormat != 1) cellHeight = lh;
} }
if (extHeaderLen >= 6 && data.length > 9) { if (extHeaderLen >= 6 && data.length > 9) {
colorPlane = data[9] & 0xFF; colorPlane = data[9] & 0xFF;
@@ -223,55 +228,34 @@ public class ProgramSymbolManager {
int remaining = data.length - offset; int remaining = data.length - offset;
int codeIndex = (startCodePoint >= 0x40) ? (startCodePoint - 0x40) : startCodePoint; int codeIndex = (startCodePoint >= 0x40) ? (startCodePoint - 0x40) : startCodePoint;
int sliceBytes = (loadFormat == 1) ? 18 : (cellWidth * cellHeight + 7) / 8; int bytesPerSymbol = (loadFormat == 1) ? 18 : (cellWidth * cellHeight + 7) / 8;
if (sliceBytes <= 0) sliceBytes = 18; if (bytesPerSymbol <= 0) bytesPerSymbol = 18;
int bytesPerSymbol;
if (isTriplePlane && colorPlane == 0 && remaining >= sliceBytes * 3) {
bytesPerSymbol = sliceBytes * 3;
} else {
bytesPerSymbol = sliceBytes;
}
while (remaining >= bytesPerSymbol && codeIndex < ProgramSymbolSet.NUM_SLOTS) { while (remaining >= bytesPerSymbol && codeIndex < ProgramSymbolSet.NUM_SLOTS) {
byte[] pixelData = new byte[cellWidth * cellHeight]; byte[] pixelData = new byte[cellWidth * cellHeight];
ProgramSymbolSet.SymbolSlot existing = set.getSlot(codeIndex); 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) { if (loadFormat == 1) {
// 3 consecutive slices: Red (plane 1), Green (plane 2), Blue (plane 4) unpackFormat1(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane);
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);
}
} else { } else {
if (loadFormat == 1) { unpackFormat3(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane);
unpackFormat1(data, offset, pixelData, cellWidth, cellHeight, isTriplePlane, colorPlane);
} else {
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++; codeIndex++;
offset += bytesPerSymbol; offset += bytesPerSymbol;
remaining -= bytesPerSymbol; remaining -= bytesPerSymbol;
} }
if (clearAll) {
for (int i = codeIndex; i < ProgramSymbolSet.NUM_SLOTS; i++) {
set.clearSlot(i);
}
}
if (logger.isLoggable(Level.FINE)) { if (logger.isLoggable(Level.FINE)) {
logger.fine(String.format("LOADPS: Loaded PS Set LCID=0x%02X (RWS=%d, %s, %dx%d, %d glyphs)", 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", lcid, rws, isTriplePlane ? "Triple-Plane" : "Single-Plane",
@@ -62,6 +62,7 @@ public class ProgramSymbolSet {
private final int height; private final int height;
private final byte[] pixelData; // 1 byte per pixel: 0 = background, 1..7 = color index (or 1 for monochrome) 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 isTriplePlane;
private final boolean allPlanesLoaded;
private int[] cachedRgbArray; private int[] cachedRgbArray;
private int cachedFgRgb = -1; private int cachedFgRgb = -1;
private int cachedBgRgb = -1; private int cachedBgRgb = -1;
@@ -73,10 +74,19 @@ public class ProgramSymbolSet {
private int cachedScaledBgRgb = -1; private int cachedScaledBgRgb = -1;
public SymbolSlot(int width, int height, byte[] pixelData, boolean isTriplePlane) { 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.width = width > 0 ? width : 9;
this.height = height > 0 ? height : 16; this.height = height > 0 ? height : 16;
this.pixelData = pixelData; this.pixelData = pixelData;
this.isTriplePlane = isTriplePlane; this.isTriplePlane = isTriplePlane;
this.allPlanesLoaded = allPlanesLoaded;
}
public boolean isAllPlanesLoaded() {
return allPlanesLoaded;
} }
public int getWidth() { public int getWidth() {
@@ -183,18 +193,18 @@ public class ProgramSymbolSet {
} else if (!isTriplePlane) { } else if (!isTriplePlane) {
rgbArray[idx] = fgArgb; rgbArray[idx] = fgArgb;
} else { } else {
// Triple-Plane RGB composite aligned with IBM Host On-Demand 16-color GOCA palette: // Triple-Plane RGB composite aligned with IBM 3179G / HoD GOCA palette:
// val is bitmask: bit 0 (0x01) = Red, bit 1 (0x02) = Green, bit 2 (0x04) = Blue // val bitmask: bit 0 (0x01) = Red, bit 1 (0x02) = Green, bit 2 (0x04) = Blue
int colorMask = val & 0x07; int colorMask = val & 0x07;
int colorArgb; int colorArgb;
switch (colorMask) { switch (colorMask) {
case 1: colorArgb = GocaConstants.GOCA_COLORS[2]; break; // Red case 1: colorArgb = GocaConstants.GOCA_COLORS[2]; break; // Red
case 2: colorArgb = GocaConstants.GOCA_COLORS[4]; break; // Green case 2: colorArgb = GocaConstants.GOCA_COLORS[4]; break; // Green
case 3: colorArgb = GocaConstants.GOCA_COLORS[6]; break; // Yellow case 3: colorArgb = GocaConstants.GOCA_COLORS[6]; break; // Yellow (Red + Green)
case 4: colorArgb = GocaConstants.GOCA_COLORS[1]; break; // Blue (CUSTOMBLUE) case 4: colorArgb = GocaConstants.GOCA_COLORS[1]; break; // Blue
case 5: colorArgb = GocaConstants.GOCA_COLORS[3]; break; // Pink case 5: colorArgb = GocaConstants.GOCA_COLORS[3]; break; // Pink (Red + Blue)
case 6: colorArgb = GocaConstants.GOCA_COLORS[5]; break; // Turquoise case 6: colorArgb = GocaConstants.GOCA_COLORS[5]; break; // Turquoise (Green + Blue)
case 7: colorArgb = GocaConstants.GOCA_COLORS[7]; break; // White case 7: colorArgb = GocaConstants.GOCA_COLORS[7]; break; // White (Red + Green + Blue)
default: colorArgb = bgArgb; break; default: colorArgb = bgArgb; break;
} }
rgbArray[idx] = colorArgb; rgbArray[idx] = colorArgb;
@@ -846,6 +846,8 @@ public class InputProcessor {
if (graphicsPlane != null) { if (graphicsPlane != null) {
graphicsPlane.clear(); graphicsPlane.clear();
} }
lastAid = aidCode;
setKeyboardLocked(true);
byte[] data = new byte[] { (byte) aidCode }; byte[] data = new byte[] { (byte) aidCode };
sendAidResponse(data); sendAidResponse(data);
return; return;
@@ -948,6 +950,15 @@ public class InputProcessor {
* Transmit a mouse / light-pen graphic input AID matching IBM Host On-Demand PS3179G and DS3270. * 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) { 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()) { if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) {
return; return;
} }
@@ -957,41 +968,51 @@ public class InputProcessor {
setKeyboardLocked(true); setKeyboardLocked(true);
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive()) { byte[] rma = buildReadModifiedInboundData(aidCode, false);
int gx = gocaDecoder.getGraphicCursorX(); try {
int gy = gocaDecoder.getGraphicCursorY(); out.write(rma);
int cols = (screen != null && screen.getCols() > 0) ? screen.getCols() : 80; } catch (java.io.IOException ignored) {}
int numRows = (screen != null && screen.getRows() > 0) ? screen.getRows() : 24; sendAidResponse(out.toByteArray());
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 * 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) {
StringBuilder sfHex = new StringBuilder(); if (fsm != null && fsm.getConnectionState() != null && !fsm.getConnectionState().isFullSession()) {
for (byte b : sf) { return;
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) {}
} }
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()); sendAidResponse(out.toByteArray());
} }
@@ -1030,7 +1051,12 @@ public class InputProcessor {
screen.setCursorAddress(address); screen.setCursorAddress(address);
// Immediate Selection (Space or Null) -> AID_SELECT (0x7E) // 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 (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; faCell.fa |= haus.nightmare.lib3270j.protocol.DS3270Constants.FA_MODIFY;
sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT); sendAid(haus.nightmare.lib3270j.protocol.DS3270Constants.AID_SELECT);
return true; return true;
@@ -1448,6 +1448,11 @@ public class TelnetFSM {
* Send a 3270 data record, with TN3270E header if in TN3270E mode. * Send a 3270 data record, with TN3270E header if in TN3270E mode.
*/ */
public void send3270Data(byte[] data) { 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) { if (tn3270eNegotiated) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE); ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + EH_SIZE);
// TN3270E header // TN3270E header
@@ -376,6 +376,14 @@ public class Xfer3270 implements FileTransferInterface {
config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY);
config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP);
config.setExistAction(this.option_APPEND ? FTConfig.ExistAction.APPEND : FTConfig.ExistAction.REPLACE); 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); config.setDftBufferSize(bmtuSize);
if (options != null && !options.isEmpty()) { if (options != null && !options.isEmpty()) {
config.parseOptions(options); config.parseOptions(options);
@@ -387,7 +395,38 @@ public class Xfer3270 implements FileTransferInterface {
public void getFile(String command, String hostFile, String localFile, int mode, public void getFile(String command, String hostFile, String localFile, int mode,
String options, ECLXferListener listener) throws ECLErr { String options, ECLXferListener listener) throws ECLErr {
FileTransferStatusInterface statusAdapter = createStatusAdapter(listener, localFile, hostFile); 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 @Override
@@ -408,6 +447,15 @@ public class Xfer3270 implements FileTransferInterface {
(hostType == CICS ? FTConfig.HostType.CICS : FTConfig.HostType.TSO)); (hostType == CICS ? FTConfig.HostType.CICS : FTConfig.HostType.TSO));
config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY); config.setTransferMode(this.option_ASCII ? FTConfig.TransferMode.ASCII : FTConfig.TransferMode.BINARY);
config.setCrAction(this.option_CRLF ? FTConfig.CrAction.REMOVE : FTConfig.CrAction.KEEP); 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.setRecfm(recfm);
config.setLrecl(String.valueOf(lrecl)); config.setLrecl(String.valueOf(lrecl));
config.setBlksize(String.valueOf(blksize)); 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, public void putFile(String command, String hostFile, String localFile, int mode,
String options, ECLXferListener listener) throws ECLErr { String options, ECLXferListener listener) throws ECLErr {
FileTransferStatusInterface statusAdapter = createStatusAdapter(listener, localFile, hostFile); 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, private void executeTransfer(FTConfig config, FileTransferStatusInterface status,
@@ -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<byte[]> 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"));
}
}
@@ -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<byte[]> 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]);
}
}
@@ -51,7 +51,7 @@ public class ProgramSymbolManagerTest {
ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41); ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41);
assertNotNull(slot); assertNotNull(slot);
assertEquals(9, slot.getWidth()); assertEquals(9, slot.getWidth());
assertEquals(12, slot.getHeight()); assertEquals(16, slot.getHeight());
assertFalse(slot.isTriplePlane()); assertFalse(slot.isTriplePlane());
// Verify pixel data (Row 0 Col 0 is 1) // Verify pixel data (Row 0 Col 0 is 1)
@@ -64,7 +64,7 @@ public class ProgramSymbolManagerTest {
int bg = 0xFF000000; // Black int bg = 0xFF000000; // Black
int[] rgb = slot.getRgbPixels(fg, bg); int[] rgb = slot.getRgbPixels(fg, bg);
assertNotNull(rgb); 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 assertEquals(fg, rgb[0]); // Row 0 Col 0 pixel should be foreground Green
} }
@@ -164,4 +164,128 @@ public class ProgramSymbolManagerTest {
assertNotNull(slot); assertNotNull(slot);
assertEquals(1, slot.getPixelData()[0]); 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());
}
} }
@@ -218,6 +218,32 @@ public class InputProcessorTest {
assertEquals(2, screen.getCursorAddress()); 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 @Test
public void testLightPenSelectEnterImmediateAmpersand() { public void testLightPenSelectEnterImmediateAmpersand() {
screen.erase(false); screen.erase(false);