green-screen-proxy 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/controller.d.ts +2 -0
  2. package/dist/controller.d.ts.map +1 -1
  3. package/dist/controller.js +37 -5
  4. package/dist/controller.js.map +1 -1
  5. package/dist/protocols/tn5250-handler.d.ts +14 -1
  6. package/dist/protocols/tn5250-handler.d.ts.map +1 -1
  7. package/dist/protocols/tn5250-handler.js +204 -16
  8. package/dist/protocols/tn5250-handler.js.map +1 -1
  9. package/dist/routes.js +2 -2
  10. package/dist/routes.js.map +1 -1
  11. package/dist/session.d.ts +2 -2
  12. package/dist/session.d.ts.map +1 -1
  13. package/dist/session.js +2 -2
  14. package/dist/session.js.map +1 -1
  15. package/dist/tn5250/connection.d.ts +2 -1
  16. package/dist/tn5250/connection.d.ts.map +1 -1
  17. package/dist/tn5250/connection.js +5 -3
  18. package/dist/tn5250/connection.js.map +1 -1
  19. package/dist/tn5250/constants.d.ts +15 -0
  20. package/dist/tn5250/constants.d.ts.map +1 -1
  21. package/dist/tn5250/constants.js +18 -0
  22. package/dist/tn5250/constants.js.map +1 -1
  23. package/dist/tn5250/encoder.d.ts +11 -0
  24. package/dist/tn5250/encoder.d.ts.map +1 -1
  25. package/dist/tn5250/encoder.js +107 -5
  26. package/dist/tn5250/encoder.js.map +1 -1
  27. package/dist/tn5250/parser.d.ts +64 -6
  28. package/dist/tn5250/parser.d.ts.map +1 -1
  29. package/dist/tn5250/parser.js +579 -134
  30. package/dist/tn5250/parser.js.map +1 -1
  31. package/dist/tn5250/screen.d.ts +93 -0
  32. package/dist/tn5250/screen.d.ts.map +1 -1
  33. package/dist/tn5250/screen.js +283 -12
  34. package/dist/tn5250/screen.js.map +1 -1
  35. package/dist/websocket.d.ts.map +1 -1
  36. package/dist/websocket.js +90 -19
  37. package/dist/websocket.js.map +1 -1
  38. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { CMD, ORDER, OPCODE, ATTR } from './constants.js';
1
+ import { CMD, ORDER, OPCODE, ATTR, WDSF_TYPE, WDSF_CLASS } from './constants.js';
2
2
  import { ebcdicToChar, ebcdicSymbolChar } from './ebcdic.js';
3
3
  /**
4
4
  * Parses 5250 data stream records and updates the screen buffer.
@@ -7,6 +7,15 @@ export class TN5250Parser {
7
7
  screen;
8
8
  /** When true, the next SF order should clear stale fields first */
9
9
  pendingFieldsClear = false;
10
+ /** Set when a WSF 5250 Query is received; handler should send query reply */
11
+ pendingQueryReply = false;
12
+ /** Set when an IC order was applied during the last parseOrders call.
13
+ * When true, calculateFieldLengths should NOT override the cursor. */
14
+ icApplied = false;
15
+ /** Active window offset — after CREATE_WINDOW, SBA/RA/EA addresses are
16
+ * relative to the window content area, not the full screen. */
17
+ winRowOff = 0;
18
+ winColOff = 0;
10
19
  constructor(screen) {
11
20
  this.screen = screen;
12
21
  }
@@ -15,6 +24,7 @@ export class TN5250Parser {
15
24
  * Returns true if the screen was modified.
16
25
  */
17
26
  parseRecord(record) {
27
+ this.icApplied = false;
18
28
  if (record.length < 2)
19
29
  return false;
20
30
  // 5250 record header:
@@ -67,10 +77,21 @@ export class TN5250Parser {
67
77
  case OPCODE.INVITE:
68
78
  break;
69
79
  case OPCODE.SAVE_SCREEN:
80
+ this.screen.saveState();
81
+ if (record.length > dataOffset) {
82
+ modified = this.parseCommandsFromOffset(record, dataOffset);
83
+ }
84
+ modified = true;
85
+ break;
70
86
  case OPCODE.RESTORE_SCREEN:
87
+ this.screen.restoreState();
88
+ this.screen.windowList = [];
89
+ this.winRowOff = 0;
90
+ this.winColOff = 0;
71
91
  if (record.length > dataOffset) {
72
92
  modified = this.parseCommandsFromOffset(record, dataOffset);
73
93
  }
94
+ modified = true;
74
95
  break;
75
96
  default:
76
97
  if (record.length > dataOffset) {
@@ -119,6 +140,9 @@ export class TN5250Parser {
119
140
  case CMD.CLEAR_UNIT:
120
141
  case CMD.CLEAR_UNIT_ALT:
121
142
  this.screen.clear();
143
+ this.screen.windowList = [];
144
+ this.winRowOff = 0;
145
+ this.winColOff = 0;
122
146
  pos++;
123
147
  modified = true;
124
148
  break;
@@ -133,25 +157,62 @@ export class TN5250Parser {
133
157
  if (pos + 1 < data.length) {
134
158
  const cc1 = data[pos++];
135
159
  const cc2 = data[pos++];
136
- // CC1 bit 5: Reset MDT flags
137
- if (cc1 & 0x20) {
138
- for (const f of this.screen.fields) {
139
- f.modified = false;
140
- }
141
- // Mark that subsequent SF orders in this WTD should clear stale fields
142
- this.pendingFieldsClear = true;
160
+ // CC1 upper 3 bits (0xE0 mask) control MDT reset + null fill.
161
+ // Per lib5250 session.c:820-851, this is a 7-value switch:
162
+ let resetNonBypassMdt = false;
163
+ let resetAllMdt = false;
164
+ let nullNonBypassMdt = false;
165
+ let nullNonBypass = false;
166
+ // CC1: lock keyboard for all values except 0x00
167
+ // Per lib5250 session.c:853-858
168
+ if ((cc1 & 0xE0) !== 0x00) {
169
+ this.screen.keyboardLocked = true;
143
170
  }
144
- // CC1 bit 6: Clear all input fields (null fill)
145
- if (cc1 & 0x40) {
146
- for (const f of this.screen.fields) {
147
- if (this.screen.isInputField(f)) {
148
- const start = this.screen.offset(f.row, f.col);
149
- for (let i = start; i < start + f.length; i++) {
150
- this.screen.buffer[i] = ' ';
151
- }
171
+ switch (cc1 & 0xE0) {
172
+ case 0x00: break; // no action (don't lock keyboard)
173
+ case 0x20: break; // reserved / no action in lib5250
174
+ case 0x40:
175
+ resetNonBypassMdt = true;
176
+ break;
177
+ case 0x60:
178
+ resetAllMdt = true;
179
+ break;
180
+ case 0x80:
181
+ nullNonBypassMdt = true;
182
+ break;
183
+ case 0xA0:
184
+ resetNonBypassMdt = true;
185
+ nullNonBypass = true;
186
+ break;
187
+ case 0xC0:
188
+ resetNonBypassMdt = true;
189
+ nullNonBypassMdt = true;
190
+ break;
191
+ case 0xE0:
192
+ resetAllMdt = true;
193
+ nullNonBypass = true;
194
+ break;
195
+ }
196
+ for (const f of this.screen.fields) {
197
+ const isInput = this.screen.isInputField(f);
198
+ // Null fill: clear input field content
199
+ if (isInput && (nullNonBypass || (nullNonBypassMdt && f.modified))) {
200
+ const start = this.screen.offset(f.row, f.col);
201
+ for (let i = start; i < start + f.length; i++) {
202
+ this.screen.buffer[i] = ' ';
152
203
  }
153
204
  }
205
+ // MDT reset
206
+ if (resetAllMdt || (resetNonBypassMdt && isInput)) {
207
+ f.modified = false;
208
+ }
209
+ }
210
+ // Mark that subsequent SF orders in this WTD should clear stale fields
211
+ if (resetAllMdt || resetNonBypassMdt) {
212
+ this.pendingFieldsClear = true;
154
213
  }
214
+ // CC2 handling per lib5250 session.c:1033-1066
215
+ this.handleCC2(cc2);
155
216
  }
156
217
  // Parse orders and data following WTD
157
218
  pos = this.parseOrders(data, pos);
@@ -160,33 +221,92 @@ export class TN5250Parser {
160
221
  }
161
222
  case CMD.WRITE_ERROR_CODE:
162
223
  case CMD.WRITE_ERROR_CODE_WIN: {
163
- pos++; // skip command byte
164
- // Skip the error line data — just advance past it
165
- // Error code commands are followed by data until next command
166
- pos = this.parseOrders(data, pos);
224
+ // Per lib5250 session.c:733-799: writes error message to message line
225
+ pos++;
226
+ // WRITE_ERROR_CODE_WIN has 2 extra bytes (start/end window)
227
+ if (cmd === CMD.WRITE_ERROR_CODE_WIN && pos + 1 < data.length) {
228
+ pos += 2; // skip startwin, endwin
229
+ }
230
+ // Save cursor so Reset can restore it after unlocking
231
+ this.screen.savedCursorBeforeError = { row: this.screen.cursorRow, col: this.screen.cursorCol };
232
+ // Message line is the last row (row = rows - 1)
233
+ const msgRow = this.screen.rows - 1;
234
+ let msgCol = 0;
235
+ let endY = this.screen.cursorRow;
236
+ let endX = this.screen.cursorCol;
237
+ // Parse error message data: printable chars go to message line,
238
+ // IC sets cursor position (but NOT pending insert per spec)
239
+ while (pos < data.length) {
240
+ const c = data[pos];
241
+ if (c === 0x27) { // ESC — end of error code data
242
+ break;
243
+ }
244
+ if (c === ORDER.IC && pos + 2 < data.length) {
245
+ // IC within error code: just move cursor, don't set pending insert
246
+ pos++;
247
+ endY = data[pos++] - 1;
248
+ endX = data[pos++] - 1;
249
+ continue;
250
+ }
251
+ // Printable EBCDIC character → write to message line
252
+ if (c >= 0x40 || c === 0x00) {
253
+ const ch = ebcdicToChar(c);
254
+ const addr = this.screen.offset(msgRow, msgCol);
255
+ if (addr < this.screen.size) {
256
+ this.screen.setCharAt(addr, ch);
257
+ }
258
+ msgCol++;
259
+ }
260
+ pos++;
261
+ }
262
+ // Set cursor to the IC position from within the error code
263
+ if (endY >= 0 && endY < this.screen.rows && endX >= 0 && endX < this.screen.cols) {
264
+ this.screen.cursorRow = endY;
265
+ this.screen.cursorCol = endX;
266
+ }
267
+ // Lock keyboard after error
268
+ this.screen.keyboardLocked = true;
167
269
  modified = true;
168
270
  break;
169
271
  }
170
272
  case CMD.WRITE_STRUCTURED_FIELD: {
171
273
  pos++;
172
- // Structured fields have their own length prefix
274
+ // WSF: length(2) + class(1) + type(1) + data
275
+ // Per lib5250 session.c:2220-2275
173
276
  if (pos + 1 < data.length) {
174
277
  const sfLen = (data[pos] << 8) | data[pos + 1];
175
- pos += sfLen; // skip the entire structured field
278
+ const sfEnd = pos + sfLen;
279
+ // Check for 5250 Query (class 0xD9, type 0x70)
280
+ if (pos + 3 < data.length && data[pos + 2] === WDSF_CLASS && data[pos + 3] === 0x70) {
281
+ this.pendingQueryReply = true;
282
+ }
283
+ pos = Math.min(sfEnd, data.length);
176
284
  }
177
285
  break;
178
286
  }
179
287
  case CMD.ROLL: {
180
- pos++; // skip command byte
181
- if (pos + 1 < data.length) {
182
- const rollCC = data[pos++];
183
- const rollCount = data[pos++];
184
- // Simple roll: move content up or down
185
- // For now just mark as modified
288
+ // ROLL: 3 bytes — direction, topRow(1-based), bottomRow(1-based)
289
+ // Per lib5250 session.c:1463-1487
290
+ pos++;
291
+ if (pos + 2 < data.length) {
292
+ const direction = data[pos++];
293
+ const top = data[pos++] - 1; // convert 1-based to 0-based
294
+ const bot = data[pos++] - 1;
295
+ let lines = direction & 0x1F;
296
+ if ((direction & 0x80) === 0) {
297
+ lines = -lines; // scroll up (negative)
298
+ }
299
+ if (lines !== 0 && top >= 0 && bot >= top && bot < this.screen.rows) {
300
+ this.rollBuffer(top, bot, lines);
301
+ }
186
302
  modified = true;
187
303
  }
188
304
  break;
189
305
  }
306
+ case 0x04:
307
+ // Sub-record marker — skip (appears between commands in some hosts)
308
+ pos++;
309
+ break;
190
310
  default:
191
311
  // Not a recognized command at this position
192
312
  // Try treating remaining data as orders/text
@@ -206,6 +326,8 @@ export class TN5250Parser {
206
326
  let currentAttr = ATTR.NORMAL;
207
327
  let useSymbolCharSet = false; // SA type 0x22 can switch to APL/symbol CGCS
208
328
  let afterSBA = false; // Track if we just processed an SBA order (field attrs follow SBA)
329
+ let pendingICRow = -1; // IC stores pending cursor position, applied after WTD
330
+ let pendingICCol = -1;
209
331
  while (pos < data.length) {
210
332
  const byte = data[pos];
211
333
  // Within a WTD, all bytes are orders or EBCDIC data.
@@ -214,83 +336,133 @@ export class TN5250Parser {
214
336
  // parseOrders consumes data until the end of the buffer.
215
337
  switch (byte) {
216
338
  case ORDER.SBA: {
217
- // Set Buffer Address: 2 bytes follow (row, col)
339
+ // Set Buffer Address: 2 bytes follow (row, col) — 1-based from host
218
340
  if (pos + 2 >= data.length)
219
341
  return data.length;
220
342
  pos++;
221
- const row = data[pos++];
222
- const col = data[pos++];
223
- currentAddr = this.screen.offset(row, col);
343
+ const rawRow = data[pos++];
344
+ const rawCol = data[pos++];
345
+ const row = rawRow - 1 + this.winRowOff;
346
+ const col = rawCol - 1 + this.winColOff;
347
+ if (row >= 0 && row < this.screen.rows && col >= 0 && col < this.screen.cols) {
348
+ currentAddr = this.screen.offset(row, col);
349
+ }
224
350
  afterSBA = true; // Field attribute may follow
225
351
  continue; // skip the afterSBA = false at end of loop
226
352
  }
227
353
  case ORDER.IC: {
228
- // Insert Cursor: set cursor to current address
354
+ // Insert Cursor: 2 bytes follow (row, col) — 1-based from host.
355
+ // Per lib5250, IC stores a pending position applied after WTD.
356
+ if (pos + 2 >= data.length)
357
+ return data.length;
229
358
  pos++;
230
- const { row, col } = this.screen.toRowCol(currentAddr);
231
- this.screen.cursorRow = row;
232
- this.screen.cursorCol = col;
359
+ const icRow = data[pos++] - 1 + this.winRowOff;
360
+ const icCol = data[pos++] - 1 + this.winColOff;
361
+ pendingICRow = icRow;
362
+ pendingICCol = icCol;
233
363
  break;
234
364
  }
235
365
  case ORDER.MC: {
236
- // Move Cursor: 2 bytes follow (row, col)
366
+ // Move Cursor: 2 bytes follow (row, col) — 1-based from host
237
367
  if (pos + 2 >= data.length)
238
368
  return data.length;
239
369
  pos++;
240
- const row = data[pos++];
241
- const col = data[pos++];
242
- this.screen.cursorRow = row;
243
- this.screen.cursorCol = col;
244
- currentAddr = this.screen.offset(row, col);
370
+ const mcRow = data[pos++] - 1 + this.winRowOff;
371
+ const mcCol = data[pos++] - 1 + this.winColOff;
372
+ if (mcRow >= 0 && mcRow < this.screen.rows && mcCol >= 0 && mcCol < this.screen.cols) {
373
+ this.screen.cursorRow = mcRow;
374
+ this.screen.cursorCol = mcCol;
375
+ currentAddr = this.screen.offset(mcRow, mcCol);
376
+ }
245
377
  break;
246
378
  }
247
379
  case ORDER.RA: {
248
- // Repeat to Address: repeat a char up to an address
380
+ // Repeat to Address: 3 bytes (row, col, char) 1-based address
249
381
  if (pos + 3 >= data.length)
250
382
  return data.length;
251
383
  pos++;
252
- const toRow = data[pos++];
253
- const toCol = data[pos++];
384
+ const raRow = data[pos++] - 1 + this.winRowOff;
385
+ const raCol = data[pos++] - 1 + this.winColOff;
254
386
  const charByte = data[pos++];
255
- const targetAddr = this.screen.offset(toRow, toCol);
387
+ const targetAddr = this.screen.offset(raRow, raCol);
256
388
  const ch = ebcdicToChar(charByte);
257
- while (currentAddr < targetAddr && currentAddr < this.screen.size) {
258
- this.screen.setCharAt(currentAddr, ch);
259
- currentAddr++;
389
+ // lib5250 uses addch which wraps; we fill up to target (inclusive of current pos)
390
+ if (targetAddr >= currentAddr) {
391
+ while (currentAddr < targetAddr && currentAddr < this.screen.size) {
392
+ this.screen.setCharAt(currentAddr, ch);
393
+ currentAddr++;
394
+ }
395
+ }
396
+ else {
397
+ // Wrap-around: fill to end of screen, then from start to target
398
+ while (currentAddr < this.screen.size) {
399
+ this.screen.setCharAt(currentAddr, ch);
400
+ currentAddr++;
401
+ }
402
+ currentAddr = 0;
403
+ while (currentAddr < targetAddr && currentAddr < this.screen.size) {
404
+ this.screen.setCharAt(currentAddr, ch);
405
+ currentAddr++;
406
+ }
260
407
  }
261
408
  break;
262
409
  }
263
410
  case ORDER.EA: {
264
- // Erase to Address: fill with spaces up to an address
411
+ // Erase to Address: 2 bytes row(1-based), col(1-based)
412
+ // Per 5250 spec (5494 Functions Reference) and lib5250: EA takes
413
+ // only row + col, erases from current address to target address.
265
414
  if (pos + 2 >= data.length)
266
415
  return data.length;
267
416
  pos++;
268
- const toRow = data[pos++];
269
- const toCol = data[pos++];
270
- const targetAddr = this.screen.offset(toRow, toCol);
271
- while (currentAddr < targetAddr && currentAddr < this.screen.size) {
272
- this.screen.setCharAt(currentAddr, ' ');
273
- currentAddr++;
417
+ const eaRow = data[pos++] - 1 + this.winRowOff;
418
+ const eaCol = data[pos++] - 1 + this.winColOff;
419
+ const eaTarget = this.screen.offset(eaRow, eaCol);
420
+ if (eaTarget >= currentAddr) {
421
+ while (currentAddr < eaTarget && currentAddr < this.screen.size) {
422
+ this.screen.setCharAt(currentAddr, ' ');
423
+ currentAddr++;
424
+ }
425
+ }
426
+ else {
427
+ // Wrap-around
428
+ while (currentAddr < this.screen.size) {
429
+ this.screen.setCharAt(currentAddr, ' ');
430
+ currentAddr++;
431
+ }
432
+ currentAddr = 0;
433
+ while (currentAddr < eaTarget && currentAddr < this.screen.size) {
434
+ this.screen.setCharAt(currentAddr, ' ');
435
+ currentAddr++;
436
+ }
274
437
  }
438
+ currentAddr = eaTarget;
275
439
  break;
276
440
  }
277
441
  case ORDER.SOH: {
278
442
  // Start of Header: variable-length header for input fields.
443
+ // Per lib5250: SOH clears format table and pending insert cursor.
279
444
  // Format: [0x01] [length] [data...]
280
- // The length byte includes SOH byte + itself, so remaining = length - 2.
445
+ // The length byte specifies the number of data bytes following it
446
+ // (NOT including SOH or length byte itself).
281
447
  if (pos + 1 >= data.length)
282
448
  return data.length;
283
449
  pos++;
450
+ this.screen.fields = [];
451
+ this.screen.selectionFields = [];
452
+ pendingICRow = -1;
453
+ pendingICCol = -1;
284
454
  const hdrLen = data[pos++];
285
- pos += Math.max(0, hdrLen - 2);
455
+ pos += Math.max(0, hdrLen);
286
456
  break;
287
457
  }
288
458
  case ORDER.TD: {
289
- // Transparent Data: length byte followed by raw data
290
- if (pos + 1 >= data.length)
459
+ // Transparent Data: 2-byte length followed by raw data
460
+ // Per lib5250 session.c:2002-2019
461
+ if (pos + 2 >= data.length)
291
462
  return data.length;
292
463
  pos++;
293
- const tdLen = data[pos++];
464
+ const tdLen = (data[pos] << 8) | data[pos + 1];
465
+ pos += 2;
294
466
  for (let i = 0; i < tdLen && pos < data.length; i++) {
295
467
  this.screen.setCharAt(currentAddr++, ebcdicToChar(data[pos++]));
296
468
  }
@@ -332,6 +504,46 @@ export class TN5250Parser {
332
504
  // Preserve afterSBA — SA sets color/highlight context before a field attribute
333
505
  continue;
334
506
  }
507
+ case ORDER.WDSF: {
508
+ // Write Display Structured Field: 2-byte length + class + type + data
509
+ // Per lib5250 session.c:1909-1984 (0x15 within WTD)
510
+ if (pos + 2 >= data.length)
511
+ return data.length;
512
+ pos++;
513
+ const wdsfLen = (data[pos] << 8) | data[pos + 1];
514
+ const wdsfEnd = pos + Math.max(2, wdsfLen);
515
+ pos += 2; // past length bytes
516
+ if (pos + 1 < data.length) {
517
+ const wdsfClass = data[pos++];
518
+ const wdsfType = data[pos++];
519
+ if (wdsfClass === WDSF_CLASS) {
520
+ switch (wdsfType) {
521
+ case WDSF_TYPE.CREATE_WINDOW:
522
+ this.parseCreateWindow(data, pos, wdsfEnd);
523
+ break;
524
+ case WDSF_TYPE.DEFINE_SELECTION_FIELD:
525
+ this.parseDefineSelectionField(data, pos, wdsfEnd);
526
+ break;
527
+ case WDSF_TYPE.REM_GUI_WINDOW:
528
+ if (this.screen.windowList.length > 0) {
529
+ this.screen.windowList.pop();
530
+ }
531
+ this.winRowOff = 0;
532
+ this.winColOff = 0;
533
+ break;
534
+ case WDSF_TYPE.REM_ALL_GUI_CONSTRUCTS:
535
+ this.screen.windowList = [];
536
+ this.winRowOff = 0;
537
+ this.winColOff = 0;
538
+ break;
539
+ default:
540
+ break;
541
+ }
542
+ }
543
+ }
544
+ pos = Math.min(wdsfEnd, data.length);
545
+ break;
546
+ }
335
547
  case ORDER.SF: {
336
548
  // Start Field: explicit SF order with FFW + optional FCW
337
549
  pos++;
@@ -351,8 +563,8 @@ export class TN5250Parser {
351
563
  if (currentAddr < this.screen.size) {
352
564
  this.screen.setCharAt(currentAddr, ch);
353
565
  this.screen.setAttrAt(currentAddr, currentAttr);
354
- currentAddr++;
355
566
  }
567
+ currentAddr++;
356
568
  pos++;
357
569
  }
358
570
  break;
@@ -360,6 +572,12 @@ export class TN5250Parser {
360
572
  }
361
573
  afterSBA = false;
362
574
  }
575
+ // Apply deferred IC cursor position (last IC wins, per lib5250)
576
+ if (pendingICRow >= 0 && pendingICCol >= 0) {
577
+ this.screen.cursorRow = pendingICRow;
578
+ this.screen.cursorCol = pendingICCol;
579
+ this.icApplied = true;
580
+ }
363
581
  return pos;
364
582
  }
365
583
  /**
@@ -397,64 +615,59 @@ export class TN5250Parser {
397
615
  return pos;
398
616
  }
399
617
  /**
400
- * Parse SF (Start Field) order with FFW and optional FCW.
401
- * Format: SF(0x1D) FFW1 FFW2 [FCW1 FCW2]
402
- * FFW1 always has bit 6 set (0x40+). No trailing attribute byte.
403
- * Display attribute is derived from FFW2.
618
+ * Parse SF (Start Field) order.
619
+ * Per lib5250 session.c:1499-1797:
620
+ * First byte: if (byte & 0xE0) != 0x20 input field with FFW
621
+ * if (byte & 0xE0) == 0x20 → output field, byte IS the attribute
622
+ * Input field: FFW1, FFW2, then loop reading FCW pairs while (byte & 0xE0) != 0x20,
623
+ * then attribute byte (0x20-0x3F), then 2-byte field length.
624
+ * Output field: attribute byte, then 2-byte field length.
404
625
  */
405
626
  parseStartField(data, pos, addr, displayAttr) {
406
627
  if (addr < this.screen.size) {
407
628
  this.screen.setCharAt(addr, ' ');
408
629
  }
409
- // Parse FFW (Field Format Word) — 2 bytes (FFW1 has bit 6 set)
410
- if (pos + 1 >= data.length)
630
+ if (pos >= data.length)
411
631
  return pos;
412
- const ffw1 = data[pos++];
413
- const ffw2 = data[pos++];
414
- // Check for FCW (Field Control Word) — optional, 2 bytes
415
- // FCW1 has bit 7 set (>= 0x80)
416
- let fcw1 = 0, fcw2 = 0;
417
- if (pos + 1 < data.length) {
418
- const maybeFcw = data[pos];
419
- if (maybeFcw >= 0x80 && maybeFcw !== 0xFF) {
420
- fcw1 = data[pos++];
632
+ let curByte = data[pos++];
633
+ let ffw1 = 0, ffw2 = 0, fcw1 = 0, fcw2 = 0;
634
+ let inputField = false;
635
+ if ((curByte & 0xE0) !== 0x20) {
636
+ // Input field: curByte is FFW1
637
+ inputField = true;
638
+ ffw1 = curByte;
639
+ if (pos >= data.length)
640
+ return pos;
641
+ ffw2 = data[pos++];
642
+ // Read FCW pairs: keep reading while next byte is NOT in attribute range
643
+ if (pos >= data.length)
644
+ return pos;
645
+ curByte = data[pos++];
646
+ while ((curByte & 0xE0) !== 0x20 && pos < data.length) {
647
+ fcw1 = curByte;
421
648
  fcw2 = data[pos++];
649
+ if (pos >= data.length)
650
+ return pos;
651
+ curByte = data[pos++];
422
652
  }
423
653
  }
424
- // Consume the trailing field attribute byte (always present after FFW/FCW).
425
- // This byte (0x20–0x3F) specifies the display attribute for the field.
426
- let fieldDisplayAttr = displayAttr;
427
- let rawAttrByte = 0;
428
- if (pos < data.length) {
429
- const attrByte = data[pos];
430
- if (attrByte >= 0x20 && attrByte <= 0x3F) {
431
- pos++;
432
- rawAttrByte = attrByte;
433
- fieldDisplayAttr = this.decodeDisplayAttr(attrByte, displayAttr);
434
- }
654
+ // else: output field, curByte is already the attribute byte
655
+ // curByte is now the attribute byte (0x20-0x3F)
656
+ const rawAttrByte = curByte;
657
+ const fieldDisplayAttr = this.decodeDisplayAttr(rawAttrByte, displayAttr);
658
+ // Read 2-byte field length (always present after attribute byte per lib5250)
659
+ let fieldLength = 0;
660
+ if (pos + 1 < data.length) {
661
+ fieldLength = (data[pos] << 8) | data[pos + 1];
662
+ pos += 2;
435
663
  }
436
664
  const fieldStartAddr = addr + 1;
437
665
  const { row, col } = this.screen.toRowCol(fieldStartAddr);
438
- // After SF + FFW + optional FCW + ATTR, the host may include a few stale
439
- // bytes (field content initializers like nulls) before the next SBA order.
440
- // These should not be displayed. Scan ahead (up to 4 bytes) for the next
441
- // SBA — if found, skip everything in between.
442
- {
443
- let scan = pos;
444
- const limit = Math.min(pos + 4, data.length);
445
- while (scan < limit) {
446
- if (data[scan] === ORDER.SBA) {
447
- pos = scan; // skip stale bytes
448
- break;
449
- }
450
- scan++;
451
- }
452
- }
453
666
  const field = {
454
667
  row,
455
668
  col,
456
- length: 0,
457
- ffw1,
669
+ length: fieldLength,
670
+ ffw1: inputField ? ffw1 : 0x20, // BYPASS bit for output fields
458
671
  ffw2,
459
672
  fcw1,
460
673
  fcw2,
@@ -468,19 +681,249 @@ export class TN5250Parser {
468
681
  }
469
682
  /**
470
683
  * Decode a display attribute byte (0x20–0x3F) into an ATTR constant.
471
- * Only recognises the bits that determine display type; falls back to
472
- * the SA context (displayAttr) for modifier-only bytes (0x30, 0x38, etc.).
684
+ *
685
+ * 5250 field attribute byte layout (bits of lower nibble):
686
+ * Lower 3 bits (0x07) determine display type:
687
+ * 0 = normal, 1 = column separator, 2 = high intensity,
688
+ * 3 = column separator + HI, 4 = underscore, 5 = underscore + reverse,
689
+ * 6 = underscore + HI, 7 = non-display
690
+ * Bit 3 (0x08): when set, indicates color field (RED, TURQ, etc.)
691
+ * Color is determined by the full byte value (0x28=RED, 0x30=TURQ, etc.)
692
+ * For color fields, the lower 3 bits still encode the display type.
473
693
  */
474
694
  decodeDisplayAttr(attrByte, displayAttr = ATTR.NORMAL) {
475
- if ((attrByte & 0x07) === 0x07)
695
+ const type = attrByte & 0x07;
696
+ if (type === 0x07)
476
697
  return ATTR.NON_DISPLAY;
477
- if (attrByte & 0x04)
478
- return ATTR.UNDERSCORE;
479
- if (attrByte & 0x02)
698
+ if (type >= 0x04)
699
+ return ATTR.UNDERSCORE; // 4, 5, 6 all have underscore
700
+ if (type === 0x02 || type === 0x03)
480
701
  return ATTR.HIGH_INTENSITY;
481
- if (attrByte & 0x08)
482
- return ATTR.HIGH_INTENSITY;
483
- return displayAttr;
702
+ if (type === 0x01)
703
+ return ATTR.COLUMN_SEPARATOR;
704
+ // type === 0x00: normal or color field
705
+ // Bit 3 (0x08) set = color field, treat as normal display (green on most terminals)
706
+ return ATTR.NORMAL;
707
+ }
708
+ /**
709
+ * Parse CREATE_WINDOW structured field data.
710
+ * Per lib5250 session.c:3129-3349.
711
+ * Window position = current cursor position. Renders border and erases content area.
712
+ */
713
+ parseCreateWindow(data, pos, end) {
714
+ if (pos + 4 >= end)
715
+ return;
716
+ const _flagbyte1 = data[pos++]; // 0x80=cursor restricted, 0x40=pull-down
717
+ pos += 2; // 2 reserved bytes
718
+ const depth = data[pos++]; // content height
719
+ const width = data[pos++]; // content width
720
+ // Parse border minor structures per lib5250 session.c:3129-3349
721
+ let borderChars = { ul: '.', top: '.', ur: '.', left: ':', right: ':', ll: ':', bot: '.', lr: ':' };
722
+ let titleText = '';
723
+ let footerText = '';
724
+ while (pos < end) {
725
+ if (pos + 2 > end)
726
+ break;
727
+ const minorLen = data[pos];
728
+ if (minorLen < 2 || pos + minorLen > end)
729
+ break;
730
+ const minorType = data[pos + 1];
731
+ if (minorType === 0x01 && minorLen >= 13) {
732
+ // Border Presentation: flags(1) + mono_attr(1) + color_attr(1) + 8 border chars
733
+ borderChars = {
734
+ ul: ebcdicToChar(data[pos + 5]),
735
+ top: ebcdicToChar(data[pos + 6]),
736
+ ur: ebcdicToChar(data[pos + 7]),
737
+ left: ebcdicToChar(data[pos + 8]),
738
+ right: ebcdicToChar(data[pos + 9]),
739
+ ll: ebcdicToChar(data[pos + 10]),
740
+ bot: ebcdicToChar(data[pos + 11]),
741
+ lr: ebcdicToChar(data[pos + 12]),
742
+ };
743
+ }
744
+ else if (minorType === 0x10 && minorLen > 6) {
745
+ // Window Title/Footer: flags(1) + mono_attr(1) + color_attr(1) + reserved(1) + text
746
+ const isFooter = (data[pos + 2] & 0x40) !== 0;
747
+ let text = '';
748
+ for (let i = 6; i < minorLen; i++) {
749
+ text += ebcdicToChar(data[pos + i]);
750
+ }
751
+ if (isFooter)
752
+ footerText = text;
753
+ else
754
+ titleText = text;
755
+ }
756
+ pos += minorLen;
757
+ }
758
+ const winRow = this.screen.cursorRow;
759
+ const winCol = this.screen.cursorCol;
760
+ this.screen.windowList.push({ row: winRow, col: winCol, height: depth, width: width });
761
+ // Render border with custom characters from host
762
+ this.screen.renderWindowBorderCustom(winRow, winCol, depth, width, borderChars, titleText, footerText);
763
+ // Erase content area inside the border
764
+ this.screen.eraseRegion(winRow + 1, winCol + 1, winRow + depth, winCol + width);
765
+ // Set window offset — subsequent SBA/RA/EA addresses are relative to
766
+ // the window content area (row+1, col+1)
767
+ this.winRowOff = winRow + 1;
768
+ this.winColOff = winCol + 1;
769
+ }
770
+ /**
771
+ * Parse DEFINE_SELECTION_FIELD structured field data.
772
+ * Per lib5250 session.c:2930-3128 (tn5250_session_handle_define_selection).
773
+ *
774
+ * 16-byte fixed header:
775
+ * [0] flagbyte1 [1] flagbyte2 [2] flagbyte3 [3] fieldtype
776
+ * [4-8] reserved (5 bytes)
777
+ * [9] itemsize [10] height [11] items [12] padding
778
+ * [13] separator [14] selectionchar [15] cancelaid
779
+ *
780
+ * Followed by minor structures (type 0x10 = choice text, etc.)
781
+ */
782
+ parseDefineSelectionField(data, pos, end) {
783
+ if (pos + 16 > end)
784
+ return;
785
+ const _flagbyte1 = data[pos++];
786
+ const _flagbyte2 = data[pos++];
787
+ const _flagbyte3 = data[pos++];
788
+ const fieldType = data[pos++]; // 0x01=menubar, 0x11=single, 0x12=multi, etc.
789
+ pos += 5; // 5 reserved bytes
790
+ const itemSize = data[pos++]; // width of each choice text
791
+ const numRows = data[pos++]; // visible rows
792
+ const numItems = data[pos++]; // total choices
793
+ const _padding = data[pos++];
794
+ const _separator = data[pos++];
795
+ const _selectionChar = data[pos++];
796
+ const _cancelAid = data[pos++];
797
+ const baseRow = this.screen.cursorRow;
798
+ const baseCol = this.screen.cursorCol;
799
+ const choices = [];
800
+ let choiceIndex = 0;
801
+ // Parse minor structures
802
+ while (pos < end) {
803
+ if (pos + 2 > end)
804
+ break;
805
+ const minorLen = data[pos];
806
+ if (minorLen < 2 || pos + minorLen > end)
807
+ break;
808
+ const minorType = data[pos + 1];
809
+ if (minorType === 0x10 && minorLen >= 5) {
810
+ // Choice text: flagbyte1(1) + flagbyte2(1) + flagbyte3(1) + optional fields + text
811
+ // Per lib5250: flagbyte3 bits 7-5 determine if this choice is used.
812
+ // If all zero, the entire minor structure is ignored.
813
+ const choiceFlagbyte1 = data[pos + 2];
814
+ const _choiceFlagbyte2 = data[pos + 3];
815
+ const choiceFlagbyte3 = data[pos + 4];
816
+ // Skip if flagbyte3 bits 7-5 are all zero (choice not applicable)
817
+ if ((choiceFlagbyte3 & 0xE0) !== 0) {
818
+ // Calculate text offset: skip flags, optional mnemonic/AID/numeric fields
819
+ let textOff = 5;
820
+ if (choiceFlagbyte1 & 0x08)
821
+ textOff++; // mnemonic offset
822
+ if (choiceFlagbyte1 & 0x04)
823
+ textOff++; // AID byte
824
+ const numericSel = choiceFlagbyte1 & 0x03;
825
+ if (numericSel === 1)
826
+ textOff++; // single-digit numeric
827
+ else if (numericSel === 2)
828
+ textOff += 2; // double-digit numeric
829
+ let text = '';
830
+ for (let i = textOff; i < minorLen; i++) {
831
+ text += ebcdicToChar(data[pos + i]);
832
+ }
833
+ const choiceRow = baseRow + choiceIndex;
834
+ choices.push({ text, row: choiceRow, col: baseCol });
835
+ choiceIndex++;
836
+ }
837
+ }
838
+ pos += minorLen;
839
+ }
840
+ this.screen.selectionFields.push({
841
+ row: baseRow,
842
+ col: baseCol,
843
+ numRows,
844
+ numCols: itemSize,
845
+ choices,
846
+ });
847
+ }
848
+ /**
849
+ * Roll (scroll) screen buffer rows within [top, bot] by `lines` rows.
850
+ * Negative = scroll up, positive = scroll down.
851
+ * Per lib5250 dbuffer.c:869-899.
852
+ */
853
+ rollBuffer(top, bot, lines) {
854
+ const cols = this.screen.cols;
855
+ if (lines < 0) {
856
+ // Scroll up: move rows upward
857
+ for (let r = top; r <= bot; r++) {
858
+ if (r + lines >= top) {
859
+ const dstOff = (r + lines) * cols;
860
+ const srcOff = r * cols;
861
+ for (let c = 0; c < cols; c++) {
862
+ this.screen.buffer[dstOff + c] = this.screen.buffer[srcOff + c];
863
+ }
864
+ }
865
+ }
866
+ // Clear vacated rows at bottom
867
+ for (let r = bot + lines + 1; r <= bot; r++) {
868
+ if (r >= top) {
869
+ const off = r * cols;
870
+ for (let c = 0; c < cols; c++) {
871
+ this.screen.buffer[off + c] = ' ';
872
+ }
873
+ }
874
+ }
875
+ }
876
+ else {
877
+ // Scroll down: move rows downward
878
+ for (let r = bot; r >= top; r--) {
879
+ if (r + lines <= bot) {
880
+ const dstOff = (r + lines) * cols;
881
+ const srcOff = r * cols;
882
+ for (let c = 0; c < cols; c++) {
883
+ this.screen.buffer[dstOff + c] = this.screen.buffer[srcOff + c];
884
+ }
885
+ }
886
+ }
887
+ // Clear vacated rows at top
888
+ for (let r = top; r < top + lines; r++) {
889
+ if (r <= bot) {
890
+ const off = r * cols;
891
+ for (let c = 0; c < cols; c++) {
892
+ this.screen.buffer[off + c] = ' ';
893
+ }
894
+ }
895
+ }
896
+ }
897
+ }
898
+ /**
899
+ * Handle CC2 (Control Character 2) from WTD.
900
+ * Per lib5250 session.c:1033-1066.
901
+ *
902
+ * CC2 bit flags:
903
+ * 0x40 IC_ULOCK — suppress IC cursor positioning when unlocking
904
+ * 0x20 CLR_BLINK — clear blink
905
+ * 0x10 SET_BLINK — set blink
906
+ * 0x08 UNLOCK — unlock keyboard
907
+ * 0x04 ALARM — sound audible alarm
908
+ * 0x02 MESSAGE_OFF — clear message waiting indicator
909
+ * 0x01 MESSAGE_ON — set message waiting indicator
910
+ */
911
+ handleCC2(cc2) {
912
+ // Message waiting indicator
913
+ if (cc2 & 0x01) {
914
+ this.screen.messageWaiting = true;
915
+ }
916
+ if ((cc2 & 0x02) && !(cc2 & 0x01)) {
917
+ this.screen.messageWaiting = false;
918
+ }
919
+ // Alarm
920
+ if (cc2 & 0x04) {
921
+ this.screen.pendingAlarm = true;
922
+ }
923
+ // Unlock keyboard
924
+ if (cc2 & 0x08) {
925
+ this.screen.keyboardLocked = false;
926
+ }
484
927
  }
485
928
  /** Clear stale fields when the first SF order arrives after a Reset MDT WTD */
486
929
  clearStaleFieldsOnce() {
@@ -505,6 +948,10 @@ export class TN5250Parser {
505
948
  });
506
949
  for (let i = 0; i < fields.length; i++) {
507
950
  const current = fields[i];
951
+ // If the field already has an explicit length from SF order, keep it
952
+ if (current.length > 0)
953
+ continue;
954
+ // Otherwise infer length from adjacent field positions (bare field attributes)
508
955
  const currentStart = this.screen.offset(current.row, current.col);
509
956
  if (i + 1 < fields.length) {
510
957
  const next = fields[i + 1];
@@ -526,28 +973,26 @@ export class TN5250Parser {
526
973
  if (current.length <= 0)
527
974
  current.length = 1;
528
975
  }
529
- // Ensure cursor is in a functional input field. Skip UIM framework
530
- // artifact fields whose OWN attribute byte doesn't indicate underscore
531
- // or non-display (they may inherit underscore from SA context but aren't
532
- // real interactive fields they exist in the panel header).
976
+ // Cursor homing per lib5250 display.c:614-635 (set_cursor_home):
977
+ // 1. If cursor is inside a field (input or protected) — trust it.
978
+ // 2. If cursor is outside all fields, nudge to the first functional
979
+ // input field (with native underscore). This avoids landing on
980
+ // UIM NON_DISPLAY artifact fields that aren't interactive.
981
+ // 3. If no functional fields exist, leave cursor where IC put it.
533
982
  {
534
- const allInputs = fields.filter(f => this.screen.isInputField(f));
535
- if (allInputs.length > 0) {
536
- const lastPos = this.screen.offset(allInputs[allInputs.length - 1].row, allInputs[allInputs.length - 1].col);
537
- const functional = allInputs.filter(f => this.screen.hasNativeUnderscore(f) || this.screen.hasNativeNonDisplay(f) ||
538
- this.screen.offset(f.row, f.col) === lastPos);
539
- const targets = functional.length > 0 ? functional : allInputs;
540
- const cursorAddr = this.screen.offset(this.screen.cursorRow, this.screen.cursorCol);
541
- const inTarget = targets.some(f => {
542
- const start = this.screen.offset(f.row, f.col);
543
- return cursorAddr >= start && cursorAddr < start + f.length;
544
- });
545
- if (!inTarget) {
546
- const after = targets.find(f => this.screen.offset(f.row, f.col) >= cursorAddr);
547
- const target = after || targets[targets.length - 1];
548
- this.screen.cursorRow = target.row;
549
- this.screen.cursorCol = target.col;
983
+ const cursorAddr = this.screen.offset(this.screen.cursorRow, this.screen.cursorCol);
984
+ const cursorInField = fields.some(f => {
985
+ const start = this.screen.offset(f.row, f.col);
986
+ return cursorAddr >= start && cursorAddr < start + f.length;
987
+ });
988
+ if (!cursorInField) {
989
+ const functional = fields.find(f => this.screen.isInputField(f) && this.screen.hasNativeUnderscore(f));
990
+ if (functional) {
991
+ this.screen.cursorRow = functional.row;
992
+ this.screen.cursorCol = functional.col;
550
993
  }
994
+ // If no functional fields, leave cursor at IC position — the screen
995
+ // may not have interactive input (e.g. DSPMSG with only F-key actions)
551
996
  }
552
997
  }
553
998
  // Deduplicate fields at the same position (keep the last one)