diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java index b5ba113..c7a4453 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/TerminalPanel.java @@ -363,8 +363,11 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { 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) { + boolean isDoubleClick = (e.getClickCount() >= 2); + // For detectable fields (FA bits 4-5 = 01 or 11): + // Double-clicking or invoking mnemonic [cursel] toggles the designator character (? -> >). + // If field has immediate select attribute, immediately transmit Cursor Select AID (0x7E). + if (lightPenMode || isDoubleClick || isToggleField || isEnterField || !isProtected) { if (client.lightPenSelect(clickAddr)) { refreshScreen(); return; @@ -1343,7 +1346,41 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { blinkTimer = new Timer(500, e -> { cursorVisible = !cursorVisible; textBlinkVisible = !textBlinkVisible; - repaint(); + + // Check if there are any blinking cells or fields on the screen + boolean hasBlinkingCells = false; + ScreenBuffer sb = (client != null) ? client.getScreenBuffer() : null; + if (sb != null) { + int total = sb.getDisplayRows() * sb.getDisplayCols(); + byte curFaGr = 0; + for (int i = 0; i < total; i++) { + ExtendedAttribute c = sb.getDisplayCell(i); + if (c.isFieldAttribute()) { + curFaGr = c.gr; + } + if ((c.gr & ExtendedAttribute.GR_BLINK) != 0 || (curFaGr & ExtendedAttribute.GR_BLINK) != 0) { + hasBlinkingCells = true; + break; + } + } + } + + if (hasBlinkingCells) { + repaint(); + } else if (sb != null && client != null && client.getConnectionState() != null && client.getConnectionState().isFullSession()) { + // Only repaint the cursor cell bounding rect + int cols = sb.getDisplayCols(); + int ox = getRenderOffsetX(); + int oy = getRenderOffsetY(); + int curAddr = sb.getDisplayCursorAddress(); + int curRow = curAddr / cols; + int curCol = curAddr % cols; + int cx = ox + curCol * cellWidth; + int cy = oy + curRow * cellHeight; + repaint(cx, cy, cellWidth, cellHeight); + } else { + repaint(); + } }); blinkTimer.start(); } @@ -1609,6 +1646,9 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } + // Field Outlining (0xC2 / TY_FOUTLINE) + drawOutlining(g2, ea, currentFieldEa, baddr, row, col, x, y, cellWidth, cellHeight, rows, cols, sb, fgColor); + if (isCellSelected(row, col)) { g2.setColor(SELECTION_COLOR); g2.fillRect(x, y, cellWidth, cellHeight); @@ -1628,7 +1668,7 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } // Check text blink (500ms cycle) - boolean blink = (gr & GR_BLINK) != 0; + boolean blink = (gr & GR_BLINK) != 0 || (currentFieldEa != null && (currentFieldEa.gr & GR_BLINK) != 0); boolean suppressGlyph = blink && !textBlinkVisible; // Draw character or Programmed Symbol @@ -1662,12 +1702,15 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { } } - if (underline) { + if (underline && !suppressGlyph) { g2.setColor(fgColor); int ulY = Math.min(y + cellHeight - 1, y + fontAscent + Math.max(0, (cellHeight - (fontAscent + fontDescent)) / 2) + 2); g2.drawLine(x, ulY, x + cellWidth - 1, ulY); } + // Field Outlining (0xC2 / TY_FOUTLINE) + drawOutlining(g2, ea, currentFieldEa, baddr, row, col, x, y, cellWidth, cellHeight, rows, cols, sb, fgColor); + // Draw search highlight if (isCellSearchHighlighted(row, col)) { g2.setColor(SEARCH_HIGHLIGHT_COLOR); @@ -1835,6 +1878,57 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable { return faIsHigh(fa & 0xFF) ? monoIntensify : monoNormal; } + /** + * Render field outline lines around cells based on ea.ol or field attribute outline. + * Conforms to IBM 3270 Extended Data Stream / Host On-Demand DS3270 TY_FOUTLINE (0xC2). + * + * Bit 0 (0x01): Underline -> draw line at y + cellHeight - 1 + * Bit 1 (0x02): Right vertical line -> draw line at x + cellWidth - 1 + * Bit 2 (0x04): Overline -> draw line at y + * Bit 3 (0x08): Left vertical line -> draw line at x + */ + private void drawOutlining(Graphics2D g2, ExtendedAttribute ea, ExtendedAttribute currentFieldEa, + int baddr, int row, int col, int x, int y, + int cellWidth, int cellHeight, int rows, int cols, + ScreenBuffer sb, Color fgColor) { + int outline = ea.ol & 0xFF; + if (outline == 0 && currentFieldEa != null && currentFieldEa.ol != 0) { + int fol = currentFieldEa.ol & 0xFF; + if ((fol & ExtendedAttribute.OL_UNDERLINE) != 0) outline |= ExtendedAttribute.OL_UNDERLINE; + if ((fol & ExtendedAttribute.OL_OVERLINE) != 0) outline |= ExtendedAttribute.OL_OVERLINE; + + int size = rows * cols; + int prevAddr = (baddr - 1 + size) % size; + if (sb.getDisplayCell(prevAddr).isFieldAttribute() || col == 0) { + if ((fol & ExtendedAttribute.OL_LEFT) != 0) outline |= ExtendedAttribute.OL_LEFT; + } + int nextAddr = (baddr + 1) % size; + if (sb.getDisplayCell(nextAddr).isFieldAttribute() || col == cols - 1) { + if ((fol & ExtendedAttribute.OL_RIGHT) != 0) outline |= ExtendedAttribute.OL_RIGHT; + } + } + + if (outline != 0) { + g2.setColor(fgColor); + // Bit 0 (0x01): Underline -> draw line at y + cellHeight - 1 + if ((outline & ExtendedAttribute.OL_UNDERLINE) != 0) { + g2.drawLine(x, y + cellHeight - 1, x + cellWidth - 1, y + cellHeight - 1); + } + // Bit 1 (0x02): Right vertical line -> draw line at x + cellWidth - 1 + if ((outline & ExtendedAttribute.OL_RIGHT) != 0) { + g2.drawLine(x + cellWidth - 1, y, x + cellWidth - 1, y + cellHeight - 1); + } + // Bit 2 (0x04): Overline -> draw line at y + if ((outline & ExtendedAttribute.OL_OVERLINE) != 0) { + g2.drawLine(x, y, x + cellWidth - 1, y); + } + // Bit 3 (0x08): Left vertical line -> draw line at x + if ((outline & ExtendedAttribute.OL_LEFT) != 0) { + g2.drawLine(x, y, x, y + cellHeight - 1); + } + } + } + public void dispose() { if (blinkTimer != null) blinkTimer.stop(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index d25a790..70a8c54 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -59,6 +59,7 @@ public class DataStreamProcessor { private byte currentBg = 0; private byte currentGr = 0; private byte currentCs = 0; + private byte currentOl = 0; /** Functional interface for sending output back through the telnet stack. */ @FunctionalInterface @@ -250,6 +251,7 @@ public class DataStreamProcessor { currentBg = 0; currentGr = 0; currentCs = 0; + currentOl = 0; break; case CMD_WSF: case SNA_CMD_WSF: @@ -392,6 +394,7 @@ public class DataStreamProcessor { currentBg = 0; currentGr = 0; currentCs = 0; + currentOl = 0; } if (eraseFirst) { @@ -401,6 +404,7 @@ public class DataStreamProcessor { currentBg = 0; currentGr = 0; currentCs = 0; + currentOl = 0; } // Process orders and data starting at byte 2 @@ -497,6 +501,7 @@ public class DataStreamProcessor { currentBg = 0; currentGr = 0; currentCs = 0; + currentOl = 0; break; case XA_FOREGROUND: currentFg = (byte) attrValue; @@ -521,6 +526,10 @@ public class DataStreamProcessor { case XA_CHARSET: currentCs = (byte) attrValue; break; + case XA_OUTLINING: + case 0x84: + currentOl = (byte) attrValue; + break; } pos += 3; lastWasOrder = true; @@ -605,6 +614,7 @@ public class DataStreamProcessor { ea.bg = currentBg; ea.gr = (byte) currentGr; ea.cs = fillCs; + ea.ol = currentOl; baddr = (baddr + 1) % size; } while (baddr != toAddr); screen.setBufferAddress(baddr); @@ -633,6 +643,7 @@ public class DataStreamProcessor { ea.bg = 0; ea.gr = 0; ea.cs = 0; + ea.ol = 0; } } baddr = (baddr + 1) % size; @@ -657,6 +668,7 @@ public class DataStreamProcessor { ea.fg = currentFg; ea.bg = currentBg; ea.gr = (byte) currentGr; + ea.ol = currentOl; baddr = (baddr + 1) % size; screen.setBufferAddress(baddr); pos += 2; @@ -683,6 +695,7 @@ public class DataStreamProcessor { ea.bg = currentBg; ea.gr = (byte) currentGr; ea.cs = currentCs; + ea.ol = currentOl; baddr = (baddr + 1) % size; screen.setBufferAddress(baddr); @@ -995,6 +1008,7 @@ public class DataStreamProcessor { byte[] psData = new byte[fieldLen - 3]; System.arraycopy(data, pos + 3, psData, 0, psData.length); programSymbolManager.loadps(psData); + notifyScreenUpdated(); } break; case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA: // 0x85: Graphics Object Data / GOCA @@ -1305,6 +1319,7 @@ public class DataStreamProcessor { byte[] psData = new byte[fieldLen - 4]; System.arraycopy(data, offset + 4, psData, 0, psData.length); programSymbolManager.loadps(psData); + notifyScreenUpdated(); } break; case haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJDATA_SUB: { // 0x0F: Object Control / Data Unit diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/QueryReplyBuilder.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/QueryReplyBuilder.java index bc66b49..cda0062 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/QueryReplyBuilder.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/QueryReplyBuilder.java @@ -482,8 +482,8 @@ public class QueryReplyBuilder { cpgid = screen.getTranslator().getCpgid(); } - if (graphicsMode == GraphicsMode.PROGRAMMED_SYMBOLS) { - // Programmed Symbols only mode (3279 PS with LoadPS 0x0A, flags1 = 0xA2 for GE + PS + CGCSGID) + if (graphicsMode.isProgrammedSymbolsEnabled()) { + // Programmed Symbols mode (3279 PS / 3179G with LoadPS 0x0A, flags1 = 0xA2 for GE + PS + CGCSGID) ByteArrayOutputStream out = new ByteArrayOutputStream(65); out.write(0xa2); // flags: GE (0x80), PS/Loadable Charsets (0x20), CGCSGID present (0x02) out.write(0x00); // more flags @@ -681,16 +681,18 @@ public class QueryReplyBuilder { public byte[] buildGrSymbolSet() { int charW = getCharWidth(); int charH = getCharHeight(); - int cgcsgid = 0x02B9; - int cpgid = 0x0025; - if (screen != null && screen.getTranslator() != null) { - cgcsgid = screen.getTranslator().getCgcsgid(); - cpgid = screen.getTranslator().getCpgid(); + if (screen != null && screen.getTranslator() != null && screen.getTranslator().isDBCS()) { + return new byte[]{ + 0x00, 0x00, (byte) charW, (byte) charH, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x12, + 0x01, 0x00, 0x00, (byte) 0xF0, (byte) 0xC1, (byte) 0xC1, 0x00, 0x00, (byte) charW, (byte) charH, + 0x00, 0x01, 0x4C, 0x01, 0x22, 0x00, 0x00, 0x12, 0x01, 0x00, 0x20, (byte) 0xF8, (byte) 0xC1, (byte) 0xC1, + 0x00, 0x00, 0x18, 0x18, 0x00, 0x01, 0x72, 0x01, 0x2C, 0x00, (byte) 0xFF + }; } return new byte[]{ 0x00, 0x00, (byte) charW, (byte) charH, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x12, - 0x01, 0x00, 0x00, (byte) 0xF0, (byte) ((cgcsgid >> 8) & 0xFF), (byte) (cgcsgid & 0xFF), 0x00, 0x00, - (byte) charW, (byte) charH, 0x00, 0x00, (byte) 0xF0, (byte) ((cgcsgid >> 8) & 0xFF), (byte) (cgcsgid & 0xFF), 0x00, 0x00 + 0x01, 0x00, 0x00, (byte) 0xF0, (byte) 0xC1, (byte) 0xC1, 0x00, 0x00, + (byte) charW, (byte) charH, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java index e0bfe26..0d3d142 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/GraphicsPlane.java @@ -1159,7 +1159,9 @@ public class GraphicsPlane implements PixelBuffer { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - setPixelInFill(x, y, fill, isMixOr); + int pixColor = (psSlot.isTriplePlane() && psSlot.getRgbPixels(fill, bg) != null) + ? psSlot.getRgbPixels(fill, bg)[pIdx] : fill; + setPixelInFill(x, y, pixColor, isMixOr); } else if (isOpaqueBg) { setPixelInFill(x, y, bg, isMixOr); } @@ -1192,7 +1194,9 @@ public class GraphicsPlane implements PixelBuffer { int pIdx = psY * psW + psX; boolean bit = (psPix != null && pIdx < psPix.length && psPix[pIdx] != 0); if (bit) { - setPixelInFill(x, y, fill, isMixOr); + int pixColor = (psSlot.isTriplePlane() && psSlot.getRgbPixels(fill, bg) != null) + ? psSlot.getRgbPixels(fill, bg)[pIdx] : fill; + setPixelInFill(x, y, pixColor, isMixOr); } else if (isOpaqueBg) { setPixelInFill(x, y, bg, isMixOr); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java index 3c45596..20baae4 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/graphics/ProgramSymbolManager.java @@ -175,34 +175,46 @@ public class ProgramSymbolManager { int startCodePoint = data[2] & 0xFF; int rws = data[3] & 0xFF; + int normRws = rws; + if (normRws >= 0x40 && normRws <= 0x49) { + normRws = normRws - 0x40 + 2; // 0x40 -> 2 (PSA), 0x41 -> 3 (PSB), ..., 0x45 -> 7 (PSF) + } else if ((normRws < 2 || normRws > 11) && (lcid >= 0x40 && lcid <= 0x49)) { + normRws = lcid - 0x40 + 2; + } + int setIndex; - switch (rws) { - case 2: setIndex = 0; break; - case 3: setIndex = 1; break; - case 4: setIndex = 2; break; - case 5: setIndex = 3; break; - case 6: setIndex = 4; break; - case 7: setIndex = 5; break; + switch (normRws) { + case 2: setIndex = 0; break; // PSA (0x40) + case 3: setIndex = 1; break; // PSB (0x41) + case 4: setIndex = 2; break; // PSC (0x42) + case 5: setIndex = 3; break; // PSD (0x43) + case 6: setIndex = 4; break; // PSE (0x44) + case 7: setIndex = 5; break; // PSF (0x45) case 8: setIndex = 6; break; case 9: setIndex = 7; break; case 10: setIndex = 8; break; case 11: setIndex = 9; break; default: - logger.warning("LOADPS: Invalid RWS slot 0x" + Integer.toHexString(rws)); - return; + if (lcid >= 0x40 && lcid < 256) { + setIndex = (lcid - 0x40) % NUMBER_SYMBOL_SETS; + } else { + logger.warning("LOADPS: Invalid RWS slot 0x" + Integer.toHexString(rws)); + return; + } + break; } - boolean isTriplePlane = (rws >= 4 && rws <= 7); ProgramSymbolSet set = sets[setIndex]; + boolean isTriplePlane = set.isTriplePlane() || (normRws >= 4 && normRws <= 7); if (clearAll) { set.clear(); } int extHeaderLen = 0; - int cellWidth = (loadFormat == 1 || rws < 8) ? 9 : defaultCellWidth; - int cellHeight = (loadFormat == 1 || rws < 8) ? 16 : defaultCellHeight; - int colorPlane = 0; // 0 = all planes, 1 = Blue, 2 = Red, 4 = Green + int cellWidth = (loadFormat == 1) ? 9 : (defaultCellWidth > 0 ? defaultCellWidth : 9); + int cellHeight = (loadFormat == 1) ? 16 : (defaultCellHeight > 0 ? defaultCellHeight : 16); + int colorPlane = 0; // 0 = all planes, 1 = Red, 2 = Green, 4 = Blue if (hasExtHeader && data.length > 4) { extHeaderLen = data[4] & 0xFF; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java index 6311762..52ac94b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/input/InputProcessor.java @@ -334,11 +334,17 @@ public class InputProcessor { } // Set MDT on field attribute + boolean isLastFieldChar = false; + ExtendedAttribute currentFaEa = null; if (screen.isFormatted()) { int faAddr = screen.findFieldAttribute(baddr); if (faAddr >= 0) { - ExtendedAttribute faEa = screen.getCell(faAddr); - faEa.fa = (byte) (faEa.fa | FA_MODIFY); + currentFaEa = screen.getCell(faAddr); + currentFaEa.fa = (byte) (currentFaEa.fa | FA_MODIFY); + } + int nextAddr = screen.incrementAddress(baddr); + if (screen.getCell(nextAddr).isFieldAttribute()) { + isLastFieldChar = true; } } @@ -391,6 +397,11 @@ public class InputProcessor { screen.markAllChanged(); screen.updateDisplaySnapshot(); + + // Field Validation: Trigger field (0x04) — automatically send AID upon filling the last character position + if (isLastFieldChar && currentFaEa != null && (currentFaEa.vl & ExtendedAttribute.VL_TRIGGER) != 0) { + sendAid(AID_ENTER); + } } /** @@ -858,6 +869,13 @@ public class InputProcessor { return; } + if (aidCode != AID_CLEAR && aidCode != AID_PA1 && aidCode != AID_PA2 && aidCode != AID_PA3 && aidCode != AID_SYSREQ && aidCode != AID_SELECT) { + if (!validateInput()) { + log.fine("Input validation failed for AID 0x" + Integer.toHexString(aidCode)); + return; + } + } + if (insertOffOnAid && insertMode) { setInsertMode(false); } @@ -1017,6 +1035,141 @@ public class InputProcessor { sendAidResponse(out.toByteArray()); } + // ========== 3270 EDS Field Validation (0xC1 / TY_FVALIDATE) ========== + + /** + * Enforce 3270 Extended Data Stream field validation attributes (0xC1 / TY_FVALIDATE) + * prior to transmitting AID keys (Mandatory Fill: 0x01, Mandatory Entry: 0x02). + * + * @return true if all fields pass validation, false if validation failed and input inhibited + */ + public boolean validateInput() { + if (screen == null || !screen.isFormatted()) { + return true; + } + int size = screen.getRows() * screen.getCols(); + if (size <= 0) return true; + + for (int i = 0; i < size; i++) { + ExtendedAttribute faCell = screen.getCell(i); + if (!faCell.isFieldAttribute()) continue; + + int vl = faCell.vl & 0xFF; + if (vl == 0) continue; + + // Only validate unprotected input fields + if (faIsProtected(faCell.fa & 0xFF)) continue; + + int fieldStart = (i + 1) % size; + int totalChars = 0; + int filledChars = 0; + int firstEmptyPos = -1; + + int p = fieldStart; + while (p != i && !screen.getCell(p).isFieldAttribute()) { + ExtendedAttribute cell = screen.getCell(p); + totalChars++; + int ec = cell.ec & 0xFF; + char ucs = cell.ucs4; + boolean isFilled = (ec != 0x00 && ec != 0x40 && ucs != 0 && ucs != ' ' && ucs != '\u3000'); + if (isFilled) { + filledChars++; + } else if (firstEmptyPos < 0) { + firstEmptyPos = p; + } + p = (p + 1) % size; + } + + if (totalChars == 0) continue; + + boolean isModified = faIsModified(faCell.fa & 0xFF); + boolean mandatoryFill = (vl & ExtendedAttribute.VL_MANDATORY_FILL) != 0; + boolean mandatoryEntry = (vl & ExtendedAttribute.VL_MANDATORY_ENTRY) != 0; + + // Mandatory Entry (0x02): Field must contain at least one user-entered character + if (mandatoryEntry && filledChars == 0) { + failValidation(fieldStart); + return false; + } + + // Mandatory Fill (0x01): Field must be completely filled with characters (no nulls/blanks). + // If the field is modified, has characters entered, or has Mandatory Entry also set: + if (mandatoryFill) { + if (mandatoryEntry || isModified || filledChars > 0) { + if (filledChars < totalChars) { + failValidation(firstEmptyPos >= 0 ? firstEmptyPos : fieldStart); + return false; + } + } + } + } + return true; + } + + /** + * Validates the field at the current cursor position when the user attempts to tab away. + * If the field has Mandatory Fill (0x01) and is partially filled, inhibits keyboard with input error. + */ + public boolean validateCurrentFieldOnExit() { + if (screen == null || !screen.isFormatted()) { + return true; + } + int curAddr = screen.getCursorAddress(); + int faPos = screen.findFieldAttribute(curAddr); + if (faPos < 0) return true; + + ExtendedAttribute faCell = screen.getCell(faPos); + int vl = faCell.vl & 0xFF; + if ((vl & ExtendedAttribute.VL_MANDATORY_FILL) == 0) { + return true; + } + if (faIsProtected(faCell.fa & 0xFF)) { + return true; + } + + int size = screen.getRows() * screen.getCols(); + int fieldStart = (faPos + 1) % size; + int totalChars = 0; + int filledChars = 0; + int firstEmptyPos = -1; + + int p = fieldStart; + while (p != faPos && !screen.getCell(p).isFieldAttribute()) { + ExtendedAttribute cell = screen.getCell(p); + totalChars++; + int ec = cell.ec & 0xFF; + char ucs = cell.ucs4; + boolean isFilled = (ec != 0x00 && ec != 0x40 && ucs != 0 && ucs != ' ' && ucs != '\u3000'); + if (isFilled) { + filledChars++; + } else if (firstEmptyPos < 0) { + firstEmptyPos = p; + } + p = (p + 1) % size; + } + + // If partially filled (incomplete): + if (filledChars > 0 && filledChars < totalChars) { + failValidation(firstEmptyPos >= 0 ? firstEmptyPos : curAddr); + return false; + } + return true; + } + + private void failValidation(int errorCursorPos) { + if (errorCursorPos >= 0 && screen != null) { + screen.setCursorAddress(errorCursorPos); + screen.updateDisplaySnapshot(); + } + if (oia != null) { + oia.setInputInhibited(ECLConstants.INHIBIT_OPERATOR_DUE); + } + setKeyboardLocked(true); + if (bellListener != null) { + bellListener.onBell(); + } + } + // ========== Cursor movement ========== /** @@ -1144,6 +1297,9 @@ public class InputProcessor { screen.processWordTab(true); return; } + if (!validateCurrentFieldOnExit()) { + return; + } int addr = screen.findNextUnprotected(screen.getCursorAddress()); screen.setCursorAddress(addr); screen.updateDisplaySnapshot(); @@ -1163,6 +1319,9 @@ public class InputProcessor { return; } if (!screen.isFormatted()) return; + if (!validateCurrentFieldOnExit()) { + return; + } int size = screen.getRows() * screen.getCols(); if (size <= 0) return; int addr = screen.getCursorAddress(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java index 1bbf892..7948223 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/screen/ExtendedAttribute.java @@ -62,6 +62,20 @@ public class ExtendedAttribute { public static final byte GR_UNDERLINE = 0x04; public static final byte GR_INTENSIFY = 0x08; + // Outlining attribute bits (0xC2 / TY_FOUTLINE) + public static final byte OL_NONE = 0x00; + public static final byte OL_UNDERLINE = 0x01; // Bit 0: Underline (bottom border) + public static final byte OL_RIGHT = 0x02; // Bit 1: Right vertical border + public static final byte OL_OVERLINE = 0x04; // Bit 2: Overline (top border) + public static final byte OL_LEFT = 0x08; // Bit 3: Left vertical border + public static final byte OL_BOX = 0x0F; // All four borders + + // Field Validation attribute bits (0xC1 / TY_FVALIDATE) + public static final byte VL_NONE = 0x00; + public static final byte VL_MANDATORY_FILL = 0x01; // Bit 0: Mandatory fill + public static final byte VL_MANDATORY_ENTRY = 0x02; // Bit 1: Mandatory entry + public static final byte VL_TRIGGER = 0x04; // Bit 2: Trigger field + /** * Graphics rendition bits. * GR_BLINK=0x01, GR_REVERSE=0x02, GR_UNDERLINE=0x04, GR_INTENSIFY=0x08 diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase19ExtendedAttributesTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase19ExtendedAttributesTest.java new file mode 100644 index 0000000..3da4572 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/Phase19ExtendedAttributesTest.java @@ -0,0 +1,258 @@ +package haus.nightmare.lib3270j.datastream; + +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.ecl.ECLConstants; +import haus.nightmare.lib3270j.ecl.ECLOIA; +import haus.nightmare.lib3270j.input.InputProcessor; +import haus.nightmare.lib3270j.screen.ExtendedAttribute; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.atomic.AtomicInteger; + +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 19 verification test suite: + * - Field Outlining Extended Attribute (0xC2 / TY_FOUTLINE) + * - Field Validation Extended Attribute (0xC1 / TY_FVALIDATE) + * - Blinking attributes and suppression + * - Light Pen & Cursor Select Field Interaction + */ +public class Phase19ExtendedAttributesTest { + + private ScreenBuffer screen; + private DataStreamProcessor processor; + private InputProcessor inputProcessor; + private ECLOIA oia; + private ByteArrayOutputStream output; + private AtomicInteger sentAid; + + @BeforeEach + public void setUp() { + EbcdicTranslator translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_2, translator); + processor = new DataStreamProcessor(screen, translator); + output = new ByteArrayOutputStream(); + processor.setOutputSender(bytes -> output.write(bytes, 0, bytes.length)); + + inputProcessor = new InputProcessor(screen, translator, null); + oia = new ECLOIA(screen, inputProcessor, null); + inputProcessor.setOIA(oia); + + sentAid = new AtomicInteger(-1); + inputProcessor.setOutputSender(bytes -> { + if (bytes.length > 0) { + sentAid.set(bytes[0] & 0xFF); + } + }); + } + + @Test + public void testOutliningConstantsAndParsing() { + // Verify bitmask constants in ExtendedAttribute + assertEquals((byte) 0x01, ExtendedAttribute.OL_UNDERLINE); + assertEquals((byte) 0x02, ExtendedAttribute.OL_RIGHT); + assertEquals((byte) 0x04, ExtendedAttribute.OL_OVERLINE); + assertEquals((byte) 0x08, ExtendedAttribute.OL_LEFT); + assertEquals((byte) 0x0F, ExtendedAttribute.OL_BOX); + + // Parse SFE record with Outlining (0xC2) = 0x0F (Full box) + byte[] sfeRecord = new byte[] { + (byte) CMD_EW, 0x00, + (byte) ORDER_SFE, 0x02, + (byte) XA_3270, (byte) FA_PRINTABLE, + (byte) XA_OUTLINING, (byte) ExtendedAttribute.OL_BOX + }; + processor.processRecord(sfeRecord, 0, sfeRecord.length, true); + + ExtendedAttribute cell = screen.getCell(0); + assertTrue(cell.isFieldAttribute()); + assertEquals(ExtendedAttribute.OL_BOX, cell.ol); + + // Parse SA order with Outlining (0xC2) = 0x05 (Underline + Overline) + byte[] saRecord = new byte[] { + (byte) CMD_W, 0x00, + (byte) ORDER_SBA, 0x00, 0x01, + (byte) ORDER_SA, (byte) XA_OUTLINING, (byte) (ExtendedAttribute.OL_UNDERLINE | ExtendedAttribute.OL_OVERLINE), + (byte) 0xC1 // 'A' + }; + processor.processRecord(saRecord, 0, saRecord.length, true); + + ExtendedAttribute charCell = screen.getCell(1); + assertFalse(charCell.isFieldAttribute()); + assertEquals((byte) (ExtendedAttribute.OL_UNDERLINE | ExtendedAttribute.OL_OVERLINE), charCell.ol); + } + + @Test + public void testMandatoryEntryValidation() { + // Set up field 1 at pos 0 (unprotected, length 5, Mandatory Entry: 0x02) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.getCell(0).vl = ExtendedAttribute.VL_MANDATORY_ENTRY; + + // Set up field 2 at pos 6 (terminator / protected) + screen.setFieldAttribute(6, (byte) (FA_PROTECT | FA_NUMERIC)); + + // Screen cursor at start of field 1 + screen.setCursorAddress(1); + + // Attempt to send AID_ENTER while field is empty -> Must fail validation + inputProcessor.sendAid(AID_ENTER); + + assertEquals(-1, sentAid.get(), "AID_ENTER must NOT be transmitted when Mandatory Entry is violated"); + assertTrue(inputProcessor.isKeyboardLocked(), "Keyboard must be locked on validation error"); + assertEquals(ECLConstants.INHIBIT_OPERATOR_DUE, oia.getInputInhibited(), "OIA must indicate Operator Due inhibit"); + + // Unlock / reset + inputProcessor.reset(); + assertEquals(ECLConstants.INHIBIT_NOT_INHIBITED, oia.getInputInhibited()); + assertFalse(inputProcessor.isKeyboardLocked()); + + // Now enter a character in field 1 + screen.setCursorAddress(1); + inputProcessor.processChar('X'); + + // Attempt to send AID_ENTER again -> Should succeed + inputProcessor.sendAid(AID_ENTER); + assertEquals(AID_ENTER, sentAid.get(), "AID_ENTER must be transmitted after entering required character"); + } + + @Test + public void testMandatoryFillValidation() { + // Set up field 1 at pos 0 (unprotected, length 4: pos 1, 2, 3, 4, Mandatory Fill: 0x01) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.getCell(0).vl = ExtendedAttribute.VL_MANDATORY_FILL; + + // Set up field 2 at pos 5 (unprotected, length 4: pos 6, 7, 8, 9) + screen.setFieldAttribute(5, (byte) FA_PRINTABLE); + + // Terminator field at pos 10 + screen.setFieldAttribute(10, (byte) (FA_PROTECT | FA_NUMERIC)); + + // Type only 2 characters out of 4 into field 1 (partial fill) + screen.setCursorAddress(1); + inputProcessor.processChar('A'); + inputProcessor.processChar('B'); + + // 1. Attempt to tab away from incomplete field 1 -> Must inhibit + inputProcessor.tab(); + assertEquals(ECLConstants.INHIBIT_OPERATOR_DUE, oia.getInputInhibited(), "Tab away from incomplete Mandatory Fill must inhibit"); + assertTrue(inputProcessor.isKeyboardLocked()); + + // Cursor should remain in field 1 + int curAddr = screen.getCursorAddress(); + assertTrue(curAddr >= 1 && curAddr <= 4, "Cursor must remain within incomplete field"); + + // Reset + inputProcessor.reset(); + + // 2. Attempt to send AID_ENTER with incomplete field 1 -> Must inhibit + inputProcessor.sendAid(AID_ENTER); + assertEquals(-1, sentAid.get(), "AID_ENTER must NOT transmit when Mandatory Fill field is incomplete"); + assertEquals(ECLConstants.INHIBIT_OPERATOR_DUE, oia.getInputInhibited()); + assertTrue(inputProcessor.isKeyboardLocked()); + + // Reset + inputProcessor.reset(); + + // 3. Complete field 1 (type remaining 2 characters) + screen.setCursorAddress(3); + inputProcessor.processChar('C'); + inputProcessor.processChar('D'); + + // Set cursor back to field 1 (pos 1) to test tabbing to field 2 (pos 6) + screen.setCursorAddress(1); + inputProcessor.tab(); + assertEquals(6, screen.getCursorAddress(), "Tab must succeed to next field once Mandatory Fill is complete"); + + // Sending AID_ENTER should now succeed + inputProcessor.sendAid(AID_ENTER); + assertEquals(AID_ENTER, sentAid.get(), "AID_ENTER must transmit once all validation requirements are met"); + } + + @Test + public void testTriggerFieldAutoAid() { + // Set up field at pos 0 (unprotected, length 3: pos 1, 2, 3, Trigger: 0x04) + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.getCell(0).vl = ExtendedAttribute.VL_TRIGGER; + + // Terminator at pos 4 + screen.setFieldAttribute(4, (byte) (FA_PROTECT | FA_NUMERIC)); + + screen.setCursorAddress(1); + + // Type 1st char: no AID + inputProcessor.processChar('1'); + assertEquals(-1, sentAid.get()); + + // Type 2nd char: no AID + inputProcessor.processChar('2'); + assertEquals(-1, sentAid.get()); + + // Type 3rd (last) char: should automatically trigger AID_ENTER! + inputProcessor.processChar('3'); + assertEquals(AID_ENTER, sentAid.get(), "Typing into the last position of a Trigger field must automatically send AID_ENTER"); + } + + @Test + public void testShortReadAidBypassesValidation() { + // Field with Mandatory Entry at pos 0 + screen.setFieldAttribute(0, (byte) FA_PRINTABLE); + screen.getCell(0).vl = ExtendedAttribute.VL_MANDATORY_ENTRY; + screen.setFieldAttribute(5, (byte) (FA_PROTECT | FA_NUMERIC)); + + screen.setCursorAddress(1); + + // CLEAR AID must bypass validation + inputProcessor.sendAid(AID_CLEAR); + assertEquals(AID_CLEAR, sentAid.get()); + + // Reset + inputProcessor.reset(); + sentAid.set(-1); + + // PA1 AID must bypass validation + inputProcessor.sendAid(AID_PA1); + assertEquals(AID_PA1, sentAid.get()); + } + + @Test + public void testCursorSelectAndDeferredToggle() { + // Detectable selectable field at pos 0 (FA_SELECTABLE: bits 4-5 = 01 or 11) + screen.setFieldAttribute(0, (byte) (FA_PRINTABLE | 0x08)); // FA_PRINTABLE (0xC0 | 0x00) with detectable bits + + // Designator at pos 1: '?' (0x6F) + ExtendedAttribute desCell = screen.getCell(1); + desCell.ec = 0x6F; + desCell.ucs4 = '?'; + + screen.setCursorAddress(1); + + // Toggle via cursorSelect() + boolean selected = inputProcessor.cursorSelect(); + assertTrue(selected); + assertEquals('>', desCell.ucs4, "Cursor select on '?' must toggle to '>'"); + assertTrue((screen.getCell(0).fa & FA_MODIFY) != 0, "Toggling to '>' must set MDT"); + + // Toggle back to '?' + selected = inputProcessor.cursorSelect(); + assertTrue(selected); + assertEquals('?', desCell.ucs4, "Cursor select on '>' must toggle back to '?'"); + assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0, "Toggling to '?' must clear MDT"); + + // Immediate select space designator at pos 5 + screen.setFieldAttribute(5, (byte) (FA_PRINTABLE | 0x08)); + ExtendedAttribute spaceDesCell = screen.getCell(6); + spaceDesCell.ec = 0x40; + spaceDesCell.ucs4 = ' '; + + screen.setCursorAddress(6); + selected = inputProcessor.cursorSelect(); + assertTrue(selected); + assertEquals(AID_SELECT, sentAid.get(), "Immediate space designator selection must transmit AID_SELECT (0x7E)"); + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/QueryReplyPhase18Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/QueryReplyPhase18Test.java new file mode 100644 index 0000000..92c0dd2 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/QueryReplyPhase18Test.java @@ -0,0 +1,343 @@ +package haus.nightmare.lib3270j.datastream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.graphics.*; +import haus.nightmare.lib3270j.screen.ScreenBuffer; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; +import static haus.nightmare.lib3270j.protocol.DS3270Constants.*; + +public class QueryReplyPhase18Test { + + private EbcdicTranslator translator; + private ScreenBuffer screen; + private QueryReplyBuilder qrBuilder; + + @BeforeEach + public void setUp() { + translator = new EbcdicTranslator(); + screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + qrBuilder = new QueryReplyBuilder(screen); + } + + @Test + public void testBuildCharsetsInBothAndProgrammedSymbolsModes() { + // 1. In GraphicsMode.BOTH, buildCharsets must advertise LoadPS 0x0A (Format 1 & 3) and PSA..PSF + qrBuilder.setGraphicsMode(GraphicsMode.BOTH); + byte[] bothCharsets = qrBuilder.buildCharsets(); + assertNotNull(bothCharsets); + assertEquals(65, bothCharsets.length, "buildCharsets in BOTH mode must be 65 bytes"); + assertEquals((byte) 0xA2, bothCharsets[0], "Flags1 must be 0xA2 (GE | PS | CGCSGID)"); + assertEquals(0x0A, bothCharsets[4] & 0xFF, "LoadPS format must be 0x0A (Format 1 and 3)"); + assertEquals(7, bothCharsets[8] & 0xFF, "Descriptor length must be 7"); + + // Verify all 6 loadable sets PSA..PSF + // Descriptor offset starts at index 9: + // Set 0 (Base): idx 9 + assertEquals(0x00, bothCharsets[9] & 0xFF); + assertEquals(0x10, bothCharsets[10] & 0xFF); + // Set 1 (APL): idx 16 + assertEquals(0x01, bothCharsets[16] & 0xFF); + assertEquals(0x00, bothCharsets[17] & 0xFF); + // Slot 2: PSA (LCID 0x40, single plane 0x80) + assertEquals(0x02, bothCharsets[23] & 0xFF); + assertEquals((byte) 0x80, bothCharsets[24]); + assertEquals(0x40, bothCharsets[25] & 0xFF); + // Slot 3: PSB (LCID 0x41, single plane 0x80) + assertEquals(0x03, bothCharsets[30] & 0xFF); + assertEquals((byte) 0x80, bothCharsets[31]); + assertEquals(0x41, bothCharsets[32] & 0xFF); + // Slot 4: PSC (LCID 0x42, triple plane 0xC0) + assertEquals(0x04, bothCharsets[37] & 0xFF); + assertEquals((byte) 0xC0, bothCharsets[38]); + assertEquals(0x42, bothCharsets[39] & 0xFF); + // Slot 5: PSD (LCID 0x43, triple plane 0xC0) + assertEquals(0x05, bothCharsets[44] & 0xFF); + assertEquals((byte) 0xC0, bothCharsets[45]); + assertEquals(0x43, bothCharsets[46] & 0xFF); + // Slot 6: PSE (LCID 0x44, triple plane 0xC0) + assertEquals(0x06, bothCharsets[51] & 0xFF); + assertEquals((byte) 0xC0, bothCharsets[52]); + assertEquals(0x44, bothCharsets[53] & 0xFF); + // Slot 7: PSF (LCID 0x45, triple plane 0xC0) + assertEquals(0x07, bothCharsets[58] & 0xFF); + assertEquals((byte) 0xC0, bothCharsets[59]); + assertEquals(0x45, bothCharsets[60] & 0xFF); + + // 2. In GraphicsMode.PROGRAMMED_SYMBOLS, buildCharsets must also be 65 bytes with LoadPS 0x0A, + // but default charHeight is 12 (3279-2) instead of 16 (3179G) + qrBuilder.setGraphicsMode(GraphicsMode.PROGRAMMED_SYMBOLS); + byte[] psCharsets = qrBuilder.buildCharsets(); + assertEquals(65, psCharsets.length); + assertEquals((byte) 0xA2, psCharsets[0]); + assertEquals(12, psCharsets[3] & 0xFF, "Programmed Symbols only charHeight is 12 (3279-2)"); + assertEquals(0x0A, psCharsets[4] & 0xFF); + // Descriptors (bytes 8..64) should be identical + for (int i = 8; i < 65; i++) { + assertEquals(bothCharsets[i], psCharsets[i], "Descriptor byte at index " + i + " must match"); + } + } + + @Test + public void testBuildCharsetsInNoneAndVectorOnlyModes() { + qrBuilder.setGraphicsMode(GraphicsMode.NONE); + byte[] noneCharsets = qrBuilder.buildCharsets(); + assertEquals(23, noneCharsets.length); + assertEquals((byte) 0x82, noneCharsets[0]); + assertEquals(0x00, noneCharsets[4] & 0xFF, "LoadPS format must be 0x00 in NONE mode"); + + qrBuilder.setGraphicsMode(GraphicsMode.VECTOR_GRAPHICS); + byte[] vecCharsets = qrBuilder.buildCharsets(); + assertEquals(23, vecCharsets.length); + assertEquals((byte) 0x82, vecCharsets[0]); + assertEquals(0x00, vecCharsets[4] & 0xFF, "LoadPS format must be 0x00 in VECTOR_GRAPHICS mode"); + } + + @Test + public void testLoadpsWithRwsAsLcidOrSlotIds() { + ProgramSymbolManager psm = new ProgramSymbolManager(); + + // 1. Load PSA (LCID 0x40) with RWS = 0x40 (using LCID as slot ID) + byte[] payload1 = new byte[4 + 18]; + payload1[0] = 0x01; // Format 1 + payload1[1] = 0x40; // LCID 0x40 + payload1[2] = 0x41; // Codepoint 0x41 + payload1[3] = 0x40; // RWS passed as 0x40 (PSA) + payload1[4] = (byte) 0x80; // Pixel (0,0) = 1 + psm.loadps(payload1); + + ProgramSymbolSet setA = psm.getSymbolSet(0x40); + assertNotNull(setA, "PSA symbol set must be allocated"); + assertFalse(setA.isTriplePlane()); + ProgramSymbolSet.SymbolSlot slotA = psm.getSymbol(0x40, 0x41); + assertNotNull(slotA); + assertEquals(1, slotA.getPixelData()[0] & 0xFF); + + // 2. Load PSC (LCID 0x42) with RWS = 0x42 (Triple Plane) + byte[] payload2 = new byte[4 + 18]; + payload2[0] = 0x01; // Format 1 + payload2[1] = 0x42; // LCID 0x42 + payload2[2] = 0x41; // Codepoint 0x41 + payload2[3] = 0x42; // RWS passed as 0x42 (PSC) + payload2[4] = (byte) 0x80; // Pixel (0,0) = 1 + psm.loadps(payload2); + + ProgramSymbolSet setC = psm.getSymbolSet(0x42); + assertNotNull(setC, "PSC symbol set must be allocated"); + assertTrue(setC.isTriplePlane(), "PSC must be recognized as triple-plane"); + ProgramSymbolSet.SymbolSlot slotC = psm.getSymbol(0x42, 0x41); + assertNotNull(slotC); + assertTrue(slotC.isTriplePlane()); + } + + @Test + public void testLoadpsFormat3VariableDimensions() { + ProgramSymbolManager psm = new ProgramSymbolManager(); + + // Load symbol set PSE (LCID 0x44) with Format 3 and custom cell dimensions 12x24 + int w = 12; + int h = 24; + int bytesPerSymbol = (w * h + 7) / 8; // (288 + 7) / 8 = 36 bytes + byte[] payload = new byte[4 + 6 + bytesPerSymbol]; + payload[0] = (byte) 0x83; // Format 3, extHeader = true + payload[1] = 0x44; // LCID 0x44 (PSE) + payload[2] = 0x41; // Codepoint 0x41 + payload[3] = 0x06; // RWS 6 (PSE) + // Ext header (6 bytes): + payload[4] = 0x06; // Ext header length = 6 + payload[5] = 0x00; // reserved + payload[6] = (byte) w; // LW = 12 + payload[7] = (byte) h; // LH = 24 + payload[8] = 0x00; // reserved + payload[9] = 0x00; // colorPlane = 0 (all planes) + + // Set pixel at (0, 0) + payload[10] = (byte) 0x80; + // Set pixel at (11, 23) -> index 12 * 24 - 1 = 287 -> byte 287 / 8 = 35, bit 7 - (287 % 8) = 0 + payload[10 + 35] = (byte) 0x01; + + psm.loadps(payload); + + ProgramSymbolSet set = psm.getSymbolSet(0x44); + assertNotNull(set); + ProgramSymbolSet.SymbolSlot slot = psm.getSymbol(0x44, 0x41); + assertNotNull(slot); + assertEquals(12, slot.getWidth()); + assertEquals(24, slot.getHeight()); + assertTrue(slot.isTriplePlane()); + + byte[] pix = slot.getPixelData(); + assertEquals(12 * 24, pix.length); + assertEquals(7, pix[0] & 0xFF, "Pixel (0,0) should be 7 (all planes)"); + assertEquals(7, pix[12 * 24 - 1] & 0xFF, "Pixel (11,23) should be 7 (all planes)"); + assertEquals(0, pix[1] & 0xFF, "Unset pixel (1,0) should be 0"); + } + + @Test + public void testTriplePlaneMultiColorCompositeMerging() { + ProgramSymbolManager psm = new ProgramSymbolManager(); + + // Load 9x16 Format 1 glyph for PSC (LCID 0x42) using 3 planes: + // Plane 1: Red at pixel (0,0) and (1,0) + byte[] p1 = new byte[4 + 6 + 18]; + p1[0] = (byte) 0x81; p1[1] = 0x42; p1[2] = 0x41; p1[3] = 0x04; p1[4] = 0x06; + p1[9] = 0x01; // Plane 1 = Red + p1[10] = (byte) 0x80; // Row 0 Col 0 + p1[12] = (byte) 0x80; // Row 0 Col 1 + psm.loadps(p1); + + // Plane 2: Green at pixel (1,0) and (2,0) + byte[] p2 = new byte[4 + 6 + 18]; + p2[0] = (byte) 0x81; p2[1] = 0x42; p2[2] = 0x41; p2[3] = 0x04; p2[4] = 0x06; + p2[9] = 0x02; // Plane 2 = Green + p2[12] = (byte) 0xC0; // Row 0 Col 1, 2 + psm.loadps(p2); + + // Plane 4: Blue at pixel (0,0), (1,0), (3,0) + byte[] p3 = new byte[4 + 6 + 18]; + p3[0] = (byte) 0x81; p3[1] = 0x42; p3[2] = 0x41; p3[3] = 0x04; p3[4] = 0x06; + p3[9] = 0x04; // Plane 4 = Blue + p3[10] = (byte) 0x80; // Row 0 Col 0 + p3[12] = (byte) 0xA0; // Row 0 Col 1, 3 + psm.loadps(p3); + + ProgramSymbolSet.SymbolSlot slot = psm.getSymbol(0x42, 0x41); + assertNotNull(slot); + byte[] pix = slot.getPixelData(); + + // Pixel (0,0): Red (1) | Blue (4) = 5 (Pink) + assertEquals(5, pix[0] & 0xFF); + // Pixel (1,0): Red (1) | Green (2) | Blue (4) = 7 (White) + assertEquals(7, pix[1] & 0xFF); + // Pixel (2,0): Green (2) alone + assertEquals(2, pix[2] & 0xFF); + // Pixel (3,0): Blue (4) alone + assertEquals(4, pix[3] & 0xFF); + + // Verify composite ARGB colors: + int[] rgb = slot.getRgbPixels(0, 0); + assertEquals(GocaConstants.GOCA_COLORS[3], rgb[0], "Color at (0,0) should be Pink (GOCA index 3)"); + assertEquals(GocaConstants.GOCA_COLORS[7], rgb[1], "Color at (1,0) should be White (GOCA index 7)"); + assertEquals(GocaConstants.GOCA_COLORS[4], rgb[2], "Color at (2,0) should be Green (GOCA index 4)"); + assertEquals(GocaConstants.GOCA_COLORS[1], rgb[3], "Color at (3,0) should be Blue (GOCA index 1)"); + } + + @Test + public void testGocaQueryRepliesBinaryLayout() { + qrBuilder.setGraphicsMode(GraphicsMode.BOTH); + + // 1. QR_SEGMENT (0xB0) + byte[] seg = qrBuilder.buildSegment(); + assertEquals(7, seg.length); + assertEquals((byte) 0x80, seg[0]); + assertEquals(0x02, seg[1]); + assertEquals(0x00, seg[2]); + assertEquals(0x00, seg[3]); + assertEquals(0x00, seg[4]); + assertEquals((byte) 0xFC, seg[5]); + assertEquals(0x00, seg[6]); + + // 2. QR_PROCEDURE (0xB1) + byte[] proc = qrBuilder.buildProcedure(); + assertEquals(17, proc.length); + assertEquals(0x00, proc[0]); + assertEquals(0x01, proc[1]); + assertEquals(0x00, proc[2]); + assertEquals(0x00, proc[3]); + assertEquals(0x00, proc[4]); + assertEquals((byte) 0xFC, proc[5]); + assertEquals(0x00, proc[6]); + + // 3. QR_LINETYPE (0xB2) + byte[] lt = qrBuilder.buildLineType(); + assertEquals(20, lt.length); + assertEquals(0x00, lt[0]); + assertEquals(0x09, lt[1]); + assertEquals(0x00, lt[2]); + assertEquals(0x07, lt[3]); + for (int i = 1; i <= 8; i++) { + assertEquals(i, lt[4 + (i - 1) * 2] & 0xFF); + assertEquals(i, lt[4 + (i - 1) * 2 + 1] & 0xFF); + } + + // 4. QR_PORT (0xB3) + byte[] port = qrBuilder.buildPort(); + assertEquals(64, port.length); + + // 5. QR_GRCOLOR (0xB4) + byte[] grColor = qrBuilder.buildGrColor(); + assertEquals(105, grColor.length); // 9 header + 16 * 6 = 105 bytes payload + + // 6. QR_GRSYMBOLSET (0xB6) + byte[] grSym = qrBuilder.buildGrSymbolSet(); + assertEquals(29, grSym.length); + // Trailing 7 bytes must be zero matching HoD QR_GRSYMBOLSET_S_STRING + for (int i = 22; i < 29; i++) { + assertEquals(0x00, grSym[i], "Trailing byte at index " + i + " must be 0"); + } + } + + @Test + public void testDataStreamProcessorStructuredField0x06Dispatch() { + ScreenBuffer s = new ScreenBuffer(TerminalModel.IBM_3279_4, translator); + DataStreamProcessor dsp = new DataStreamProcessor(s, translator); + AtomicBoolean updated = new AtomicBoolean(false); + dsp.addScreenUpdateListener(() -> updated.set(true)); + + // Construct WSF with SF 0x06 (LoadPS direct) + // Length = 2 bytes (3 + 4 + 18 = 25 = 0x0019), SFID = 0x06, followed by 4+18 byte LoadPS payload + byte[] wsf = new byte[1 + 25]; + wsf[0] = (byte) CMD_WSF; // WSF command (0x11) + wsf[1] = 0x00; + wsf[2] = 0x19; // length = 25 + wsf[3] = 0x06; // SF_LOADPS_DIRECT (0x06) + // Payload: + wsf[4] = 0x01; // Format 1 + wsf[5] = 0x40; // LCID 0x40 (PSA) + wsf[6] = 0x41; // Codepoint 0x41 + wsf[7] = 0x02; // RWS 2 + wsf[8] = (byte) 0x80; // Row 0 Col 0 bit set + + dsp.processRecord(wsf, 0, wsf.length, true); + + assertTrue(updated.get(), "Screen update listener must be triggered after SF 0x06"); + ProgramSymbolSet.SymbolSlot slot = dsp.getProgramSymbolManager().getSymbol(0x40, 0x41); + assertNotNull(slot, "Symbol 0x41 must be loaded in PSA"); + assertEquals(1, slot.getPixelData()[0] & 0xFF); + } + + @Test + public void testGraphicsPlanePatternFillWithTriplePlanePss() { + GraphicsPlane plane = new GraphicsPlane(80, 24); + ProgramSymbolManager psm = new ProgramSymbolManager(); + plane.setProgramSymbolManager(psm); + + // Load 9x16 Format 1 glyph for PSC (LCID 0x42) with Red (1) and Blue (4) = Pink (5) + byte[] p = new byte[4 + 6 + 18]; + p[0] = (byte) 0x81; p[1] = 0x42; p[2] = 0x41; p[3] = 0x04; p[4] = 0x06; + p[9] = 0x01; // Plane 1 = Red + p[10] = (byte) 0xC0; // Row 0 and Row 1, Col 0 + psm.loadps(p); + + byte[] pBlue = new byte[4 + 6 + 18]; + pBlue[0] = (byte) 0x81; pBlue[1] = 0x42; pBlue[2] = 0x41; pBlue[3] = 0x04; pBlue[4] = 0x06; + pBlue[9] = 0x04; // Plane 4 = Blue + pBlue[10] = (byte) 0xC0; // Row 0 and Row 1, Col 0 + psm.loadps(pBlue); + + // Fill a small 9x16 rectangle using patternSet = 0x42, pattern = 0x41 + int[] px = new int[]{0, 9, 9, 0}; + int[] py = new int[]{0, 0, 16, 16}; + plane.fillArea(px, py, 4, new int[]{4}, 1, 0xFFFFFFFF, 0x42, 0x41, false, 0, 0, 1, 0, 0xFF000000, GocaConstants.FILL_RULE_EVEN_ODD); + + // Pixel (0,1) must have been rendered with Pink composite color (GOCA_COLORS[3]), not flat foreground 0xFFFFFFFF! + int renderedPixel = plane.getPixel(0, 1); + assertEquals(GocaConstants.GOCA_COLORS[3], renderedPixel, "Triple plane pattern fill must render with composite color (Pink)"); + } +}