From 74bc0eca7a3eb61029f6d30ff322e681d8326239 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 11 Aug 2026 21:49:44 -0500 Subject: [PATCH] Full keyboard/typing support added. A few bugs fixed. --- README.md | 14 ++ examples/keys/keys.c | 294 +++++++++++++++++++++++++++++++- include/joey/input.h | 45 +++++ include/joey/platform.h | 36 ---- make/x68000.mk | 9 +- scripts/check-input.sh | 23 +++ scripts/verify-amiga-input.sh | 120 +++++++++++++ scripts/verify-atarist-input.sh | 138 +++++++++++++++ scripts/verify-dos-input.sh | 116 +++++++++++++ scripts/verify-iigs-input.sh | 126 ++++++++++++++ scripts/verify-x68000-input.sh | 120 +++++++++++++ src/amiga/input.c | 67 ++++++++ src/atarist/input.c | 126 +++++++++++++- src/blank/blank.c | 8 + src/core/init.c | 5 + src/core/input.c | 61 +++++++ src/core/inputInternal.h | 20 +++ src/dos/input.c | 260 +++++++++++++++++++++++++++- src/iigs/input.c | 18 ++ src/x68000/hal.c | 36 +++- tests/host/inputHost.c | 211 +++++++++++++++++++++++ 21 files changed, 1792 insertions(+), 61 deletions(-) create mode 100755 scripts/check-input.sh create mode 100755 scripts/verify-amiga-input.sh create mode 100755 scripts/verify-atarist-input.sh create mode 100755 scripts/verify-dos-input.sh create mode 100755 scripts/verify-iigs-input.sh create mode 100755 scripts/verify-x68000-input.sh create mode 100644 tests/host/inputHost.c diff --git a/README.md b/README.md index 67294f5..10db500 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,14 @@ Call `jlInputPoll` once per frame, then query the state predicates. Edge predicates (`*Pressed`, `*Released`) fire only in the frame the transition happened. +Text entry uses the separate typed-character queue: `jlInputGetChar` +pops the next typed character (printable ASCII 0x20..0x7E plus +`JL_CHAR_BACKSPACE`/`TAB`/`RETURN`/`ESCAPE`) or -1 when empty, with +shift, caps lock, and the machine's keyboard layout already applied by +the backend -- so punctuation like `.` and `:` arrives correctly on +every port. Refilled by the same `jlInputPoll`; see `docs/input.md` +for per-port sources and limitations. + ```c typedef enum { /* KEY_NONE, KEY_A..KEY_Z, KEY_0..KEY_9, KEY_SPACE, KEY_ESCAPE, KEY_RETURN, KEY_TAB, KEY_BACKSPACE, @@ -419,8 +427,14 @@ typedef enum { JOY_BUTTON_0, JOY_BUTTON_1, JOY_BUTTON_COUNT } jlJoyButtonE; #define JOYSTICK_AXIS_MAX 127 #define JOYSTICK_AXIS_MIN (-127) +#define JL_CHAR_BACKSPACE 0x08 +#define JL_CHAR_TAB 0x09 +#define JL_CHAR_RETURN 0x0D +#define JL_CHAR_ESCAPE 0x1B + void jlInputPoll (void); void jlWaitForAnyKey (void); +int jlInputGetChar (void); bool jlKeyDown (jlKeyE key); bool jlKeyPressed (jlKeyE key); diff --git a/examples/keys/keys.c b/examples/keys/keys.c index 0846b15..fc059dd 100644 --- a/examples/keys/keys.c +++ b/examples/keys/keys.c @@ -3,6 +3,15 @@ // Holding the left mouse button while the pointer is over a cell lights // it as if the corresponding key were down. Press ESC to quit. // +// The strip under the grid is a typed-text line fed by jlInputGetChar: +// whatever you type -- letters, digits, shifted punctuation -- appears +// there, Backspace deletes, Return clears. It doubles as the +// acceptance test for the typed-character queue ("192.168.1.10:6510" +// must be typeable on every port). The bottom scanline additionally +// encodes every received character as raw pixel nibbles (2 pixels = +// 1 byte) behind an A5A5 sentinel so emulator harnesses can verify +// the exact bytes from a framebuffer dump. +// // The render loop only redraws cells whose target lit state changed // since last frame -- so on idle frames, the only work is the cursor // erase + redraw + a tiny rect-present pair. The cursor erase is @@ -14,14 +23,15 @@ #include #include +#include #define GRID_COLS 10 #define GRID_ROWS 6 #define CELL_W 28 -#define CELL_H 28 +#define CELL_H 26 #define GAP 4 #define MARGIN_X 2 -#define MARGIN_Y 6 +#define MARGIN_Y 2 #define CURSOR_W 4 #define CURSOR_H 4 @@ -33,13 +43,40 @@ #define CELL_NONE ((int16_t)-1) +// Typed-text line under the grid (the grid ends at y = 178 with the +// 26-pixel cells above). +#define FONT_W 5 +#define FONT_H 7 +#define TEXT_STRIP_Y 180 +#define TEXT_X 4 +#define TEXT_Y 182 +#define TEXT_INPUT_X 68 +#define MAX_LINE 40 + +// Verification scanline: sentinel nibbles, then a received-character +// count byte, then every received character as (high nibble, low +// nibble) pixel pairs. An emulator harness reads this row back from +// the framebuffer to prove each typed byte arrived. +#define VERIFY_Y 198 +#define VERIFY_SENTINEL_0 0xA +#define VERIFY_SENTINEL_1 0x5 +#define VERIFY_COUNT_X 4 +#define VERIFY_CHARS_X 8 +#define MAX_RECEIVED 150 + static void buildPalette(jlSurfaceT *screen); static void cellAtPoint(int16_t px, int16_t py, int16_t *outCol, int16_t *outRow); static bool cellTargetLit(int16_t col, int16_t row, int16_t cursorCol, int16_t cursorRow); static void drawCell(jlSurfaceT *screen, int16_t col, int16_t row, bool lit); static void drawCursor(jlSurfaceT *screen, int16_t x, int16_t y); +static void drawTextLine(jlSurfaceT *screen, int16_t x, int16_t y, const char *text, uint8_t color); +static void drawVerifyRow(jlSurfaceT *screen); +static int glyphIdx(char c); static void initialPaint(jlSurfaceT *screen); +static void logTypedHistory(void); static void presentChangedCells(jlSurfaceT *screen, int16_t cursorCol, int16_t cursorRow); +static void processTypedChars(jlSurfaceT *screen); +static void redrawTextStrip(jlSurfaceT *screen); static void updateCursor(jlSurfaceT *screen, int16_t cursorCol, int16_t cursorRow); // Keys laid out row-by-row. KEY_NONE cells stay blank. Shape roughly @@ -60,18 +97,111 @@ static int16_t gLastCursorY = -100; static int16_t gLastCursorCol = CELL_NONE; static int16_t gLastCursorRow = CELL_NONE; +// Typed-text line state plus the full received-character history for +// the verification row. +static char gLine[MAX_LINE + 1]; +static int16_t gLineLen = 0; +static uint8_t gReceived[MAX_RECEIVED]; +static int16_t gReceivedCount = 0; + +// Tiny 5x7 font (high 5 bits of each row byte), borrowed from the +// adventure example and extended with a few typed-punctuation glyphs. +// Coverage: space, 0-9, A-Z (lowercase renders uppercase), and +// . , : ! ? - ' " / ; = + _ -- everything else renders as the hollow +// box at the end of the table. +// +// Row indices used by glyphIdx. The table below must keep this order; +// the trailing row comments carry the same numbers. +#define GLYPH_SPACE 0 +#define GLYPH_DIGIT_BASE 1 +#define GLYPH_ALPHA_BASE 11 +#define GLYPH_PERIOD 37 +#define GLYPH_COMMA 38 +#define GLYPH_COLON 39 +#define GLYPH_BANG 40 +#define GLYPH_QUESTION 41 +#define GLYPH_MINUS 42 +#define GLYPH_APOSTROPHE 43 +#define GLYPH_QUOTE 44 +#define GLYPH_SLASH 45 +#define GLYPH_SEMICOLON 46 +#define GLYPH_EQUALS 47 +#define GLYPH_PLUS 48 +#define GLYPH_UNDERSCORE 49 +#define GLYPH_UNKNOWN 50 + +static const uint8_t kFontGlyph[][FONT_H] = { + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0 space + { 0x70, 0x88, 0x98, 0xA8, 0xC8, 0x88, 0x70 }, // 1 '0' + { 0x20, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70 }, // 2 '1' + { 0x70, 0x88, 0x08, 0x10, 0x20, 0x40, 0xF8 }, // 3 '2' + { 0x70, 0x88, 0x08, 0x30, 0x08, 0x88, 0x70 }, // 4 '3' + { 0x10, 0x30, 0x50, 0x90, 0xF8, 0x10, 0x10 }, // 5 '4' + { 0xF8, 0x80, 0xF0, 0x08, 0x08, 0x88, 0x70 }, // 6 '5' + { 0x30, 0x40, 0x80, 0xF0, 0x88, 0x88, 0x70 }, // 7 '6' + { 0xF8, 0x08, 0x10, 0x20, 0x40, 0x40, 0x40 }, // 8 '7' + { 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70 }, // 9 '8' + { 0x70, 0x88, 0x88, 0x78, 0x08, 0x10, 0x60 }, // 10 '9' + { 0x70, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88 }, // 11 'A' + { 0xF0, 0x88, 0x88, 0xF0, 0x88, 0x88, 0xF0 }, // 12 'B' + { 0x70, 0x88, 0x80, 0x80, 0x80, 0x88, 0x70 }, // 13 'C' + { 0xF0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xF0 }, // 14 'D' + { 0xF8, 0x80, 0x80, 0xF0, 0x80, 0x80, 0xF8 }, // 15 'E' + { 0xF8, 0x80, 0x80, 0xF0, 0x80, 0x80, 0x80 }, // 16 'F' + { 0x70, 0x88, 0x80, 0xB8, 0x88, 0x88, 0x70 }, // 17 'G' + { 0x88, 0x88, 0x88, 0xF8, 0x88, 0x88, 0x88 }, // 18 'H' + { 0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70 }, // 19 'I' + { 0x38, 0x10, 0x10, 0x10, 0x90, 0x90, 0x60 }, // 20 'J' + { 0x88, 0x90, 0xA0, 0xC0, 0xA0, 0x90, 0x88 }, // 21 'K' + { 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xF8 }, // 22 'L' + { 0x88, 0xD8, 0xA8, 0xA8, 0x88, 0x88, 0x88 }, // 23 'M' + { 0x88, 0xC8, 0xA8, 0x98, 0x88, 0x88, 0x88 }, // 24 'N' + { 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70 }, // 25 'O' + { 0xF0, 0x88, 0x88, 0xF0, 0x80, 0x80, 0x80 }, // 26 'P' + { 0x70, 0x88, 0x88, 0x88, 0xA8, 0x90, 0x68 }, // 27 'Q' + { 0xF0, 0x88, 0x88, 0xF0, 0xA0, 0x90, 0x88 }, // 28 'R' + { 0x70, 0x88, 0x80, 0x70, 0x08, 0x88, 0x70 }, // 29 'S' + { 0xF8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20 }, // 30 'T' + { 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70 }, // 31 'U' + { 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20 }, // 32 'V' + { 0x88, 0x88, 0x88, 0xA8, 0xA8, 0xD8, 0x88 }, // 33 'W' + { 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88 }, // 34 'X' + { 0x88, 0x88, 0x50, 0x20, 0x20, 0x20, 0x20 }, // 35 'Y' + { 0xF8, 0x08, 0x10, 0x20, 0x40, 0x80, 0xF8 }, // 36 'Z' + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60 }, // 37 '.' + { 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x40 }, // 38 ',' + { 0x00, 0x60, 0x60, 0x00, 0x60, 0x60, 0x00 }, // 39 ':' + { 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20 }, // 40 '!' + { 0x70, 0x88, 0x08, 0x10, 0x20, 0x00, 0x20 }, // 41 '?' + { 0x00, 0x00, 0x00, 0x78, 0x00, 0x00, 0x00 }, // 42 '-' + { 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 43 '\'' + { 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 44 '"' + { 0x00, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00 }, // 45 '/' + { 0x00, 0x60, 0x60, 0x00, 0x60, 0x60, 0x40 }, // 46 ';' + { 0x00, 0x00, 0xF8, 0x00, 0xF8, 0x00, 0x00 }, // 47 '=' + { 0x00, 0x20, 0x20, 0xF8, 0x20, 0x20, 0x00 }, // 48 '+' + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8 }, // 49 '_' + { 0xF8, 0x88, 0x88, 0x88, 0x88, 0x88, 0xF8 }, // 50 unknown box +}; + static void buildPalette(jlSurfaceT *screen) { uint16_t colors[SURFACE_COLORS_PER_PALETTE]; uint16_t i; + // Every entry gets a DISTINCT color (gray ramp for the unused + // ones, red for 15 since 0x0FFF is taken by the cursor) so a + // screenshot-based harness can decode the verification row's + // nibble pixels back to values on ports without direct + // framebuffer reads. for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) { - colors[i] = 0x0000; + colors[i] = (uint16_t)((i << 8) | (i << 4) | i); } colors[COLOR_BACKGROUND] = 0x0000; // black colors[COLOR_UNLIT] = 0x0333; // dark gray colors[COLOR_LIT] = 0x00F0; // bright green colors[COLOR_CURSOR] = 0x0FFF; // white + colors[15] = 0x0F00; // red (0x0FFF is the cursor's) jlPaletteSet(screen, 0, colors); } @@ -133,6 +263,77 @@ static void drawCursor(jlSurfaceT *screen, int16_t x, int16_t y) { } +static void drawTextLine(jlSurfaceT *screen, int16_t x, int16_t y, const char *text, uint8_t color) { + int16_t cx; + int16_t i; + int16_t row; + int16_t col; + int idx; + uint8_t bits; + + cx = x; + for (i = 0; text[i] != '\0'; i++) { + idx = glyphIdx(text[i]); + for (row = 0; row < FONT_H; row++) { + bits = kFontGlyph[idx][row]; + for (col = 0; col < FONT_W; col++) { + if (bits & (0x80 >> col)) { + jlDrawPixel(screen, (int16_t)(cx + col), (int16_t)(y + row), color); + } + } + } + cx = (int16_t)(cx + FONT_W + 1); + if (cx > SURFACE_WIDTH - FONT_W) { + return; + } + } +} + + +// Re-stamp the verification scanline: sentinel, received count, then +// every received character as two nibble pixels. Only the count and +// the newest characters actually change, but the row is cheap enough +// to redraw whole. +static void drawVerifyRow(jlSurfaceT *screen) { + int16_t i; + uint8_t ch; + + jlDrawPixel(screen, 0, VERIFY_Y, VERIFY_SENTINEL_0); + jlDrawPixel(screen, 1, VERIFY_Y, VERIFY_SENTINEL_1); + jlDrawPixel(screen, 2, VERIFY_Y, VERIFY_SENTINEL_0); + jlDrawPixel(screen, 3, VERIFY_Y, VERIFY_SENTINEL_1); + jlDrawPixel(screen, VERIFY_COUNT_X, VERIFY_Y, (uint8_t)((gReceivedCount >> 4) & 0x0F)); + jlDrawPixel(screen, VERIFY_COUNT_X + 1, VERIFY_Y, (uint8_t)(gReceivedCount & 0x0F)); + for (i = 0; i < gReceivedCount; i++) { + ch = gReceived[i]; + jlDrawPixel(screen, (int16_t)(VERIFY_CHARS_X + 2 * i), VERIFY_Y, (uint8_t)((ch >> 4) & 0x0F)); + jlDrawPixel(screen, (int16_t)(VERIFY_CHARS_X + 2 * i + 1), VERIFY_Y, (uint8_t)(ch & 0x0F)); + } +} + + +static int glyphIdx(char c) { + if (c == ' ') { return GLYPH_SPACE; } + if (c >= '0' && c <= '9') { return GLYPH_DIGIT_BASE + (c - '0'); } + if (c >= 'A' && c <= 'Z') { return GLYPH_ALPHA_BASE + (c - 'A'); } + if (c >= 'a' && c <= 'z') { return GLYPH_ALPHA_BASE + (c - 'a'); } // uppercase fallback + if (c == '.') { return GLYPH_PERIOD; } + if (c == ',') { return GLYPH_COMMA; } + if (c == ':') { return GLYPH_COLON; } + if (c == '!') { return GLYPH_BANG; } + if (c == '?') { return GLYPH_QUESTION; } + if (c == '-') { return GLYPH_MINUS; } + if (c == '\'') { return GLYPH_APOSTROPHE; } + if (c == '"') { return GLYPH_QUOTE; } + if (c == '/') { return GLYPH_SLASH; } + if (c == ';') { return GLYPH_SEMICOLON; } + if (c == '=') { return GLYPH_EQUALS; } + if (c == '+') { return GLYPH_PLUS; } + if (c == '_') { return GLYPH_UNDERSCORE; } + return GLYPH_UNKNOWN; +} + + static void initialPaint(jlSurfaceT *screen) { int16_t col; int16_t row; @@ -149,10 +350,32 @@ static void initialPaint(jlSurfaceT *screen) { gCellLit[row][col] = false; } } + redrawTextStrip(screen); jlStagePresent(); } +// Emit the received-character history as hex on exit so an emulator +// harness can verify the typed-character path from the boot volume +// when it cannot read the framebuffer (see verify-amiga-input.sh). +static void logTypedHistory(void) { + static const char kHex[] = "0123456789ABCDEF"; + char line[2 * MAX_RECEIVED + 8]; + int16_t i; + int16_t n; + + n = 0; + for (i = 0; i < gReceivedCount; i++) { + line[n] = kHex[(gReceived[i] >> 4) & 0x0F]; + line[n + 1] = kHex[gReceived[i] & 0x0F]; + n = (int16_t)(n + 2); + } + line[n] = '\0'; + jlLogF("typed=%s", line); + jlLogFlush(); +} + + static void presentChangedCells(jlSurfaceT *screen, int16_t cursorCol, int16_t cursorRow) { int16_t col; int16_t row; @@ -169,8 +392,8 @@ static void presentChangedCells(jlSurfaceT *screen, int16_t cursorCol, int16_t c if (lit == gCellLit[row][col]) { continue; } - /* drawCell marks the cell's rect dirty; jlStagePresent - * flushes that one band. */ + // drawCell marks the cell's rect dirty; jlStagePresent + // flushes that one band. drawCell(screen, col, row, lit); jlStagePresent(); gCellLit[row][col] = lit; @@ -179,6 +402,54 @@ static void presentChangedCells(jlSurfaceT *screen, int16_t cursorCol, int16_t c } +// Pop everything the user typed since last frame, apply it to the +// text line (Backspace deletes, Return clears, Escape is the quit key +// and types nothing), and refresh the strip when anything changed. +static void processTypedChars(jlSurfaceT *screen) { + int ch; + bool changed; + + changed = false; + while ((ch = jlInputGetChar()) != -1) { + if (gReceivedCount < MAX_RECEIVED) { + gReceived[gReceivedCount] = (uint8_t)ch; + gReceivedCount = (int16_t)(gReceivedCount + 1); + } + changed = true; + if (ch == JL_CHAR_BACKSPACE) { + if (gLineLen > 0) { + gLineLen = (int16_t)(gLineLen - 1); + } + } else if (ch == JL_CHAR_RETURN) { + gLineLen = 0; + } else if (ch == JL_CHAR_ESCAPE || ch == JL_CHAR_TAB) { + // Nothing to render for these. + } else if (gLineLen < MAX_LINE) { + gLine[gLineLen] = (char)ch; + gLineLen = (int16_t)(gLineLen + 1); + } + } + if (!changed) { + return; + } + redrawTextStrip(screen); + jlStagePresent(); +} + + +// Repaint everything below the key grid: the prompt, the current text +// line, and the verification scanline. Shared by the typed-character +// refresh and the cursor-erase path (which may stamp background over +// this region). +static void redrawTextStrip(jlSurfaceT *screen) { + gLine[gLineLen] = '\0'; + jlFillRect(screen, 0, TEXT_STRIP_Y, SURFACE_WIDTH, SURFACE_HEIGHT - TEXT_STRIP_Y, COLOR_BACKGROUND); + drawTextLine(screen, TEXT_X, TEXT_Y, "TYPE HERE:", COLOR_UNLIT); + drawTextLine(screen, TEXT_INPUT_X, TEXT_Y, gLine, COLOR_CURSOR); + drawVerifyRow(screen); +} + + // Erase the previous cursor (by redrawing the cell that held it) and // stamp the new cursor at the current mouse position. Both rects are // presented; if the cursor stayed inside the same cell only one rect @@ -194,14 +465,19 @@ static void updateCursor(jlSurfaceT *screen, int16_t cursorCol, int16_t cursorRo if (gLastCursorCol != CELL_NONE) { drawCell(screen, gLastCursorCol, gLastCursorRow, gCellLit[gLastCursorRow][gLastCursorCol]); } else if (gLastCursorX >= 0 && gLastCursorY >= 0) { - // Old cursor was in a gap region. Stamp background over it. + // Old cursor was in a gap region. Stamp background over it, + // then repair the text strip / verify row if the stamp + // reached into them. jlFillRect(screen, gLastCursorX, gLastCursorY, CURSOR_W, CURSOR_H, COLOR_BACKGROUND); + if ((int16_t)(gLastCursorY + CURSOR_H) > TEXT_STRIP_Y) { + redrawTextStrip(screen); + } } } drawCursor(screen, mouseX, mouseY); - /* All draw calls above marked their rects dirty; one jlStagePresent - * flushes the union (cursor erase + cursor draw). */ + // All draw calls above marked their rects dirty; one jlStagePresent + // flushes the union (cursor erase + cursor draw). jlStagePresent(); gLastCursorX = mouseX; @@ -245,8 +521,10 @@ int main(void) { cellAtPoint(jlMouseX(), jlMouseY(), &cursorCol, &cursorRow); presentChangedCells(screen, cursorCol, cursorRow); updateCursor(screen, cursorCol, cursorRow); + processTypedChars(screen); } + logTypedHistory(); jlShutdown(); return 0; } diff --git a/include/joey/input.h b/include/joey/input.h index f58e8e0..223a5b2 100644 --- a/include/joey/input.h +++ b/include/joey/input.h @@ -9,6 +9,16 @@ // jlKeyPressed(k) -- rising edge since the previous poll // jlKeyReleased(k) -- falling edge since the previous poll // +// Typed-character input (text entry) is a separate path: +// +// jlInputGetChar() -- pop the next typed character, or -1 +// +// The key predicates are the right shape for games (is the fire key +// held?); the character queue is the right shape for text fields +// (which character did the user type, with shift and the machine's +// keyboard layout already applied). Both are refreshed by the same +// jlInputPoll() and coexist freely. +// // The mouse predicates return the pointer state: // // jlMouseX/Y() -- pointer position in surface @@ -87,8 +97,43 @@ typedef enum { #define JOYSTICK_AXIS_MAX 127 #define JOYSTICK_AXIS_MIN (-127) +// Control characters delivered through jlInputGetChar alongside +// printable ASCII. Everything else below 0x20, plus 0x7F, is +// filtered out of the queue. +#define JL_CHAR_BACKSPACE 0x08 +#define JL_CHAR_TAB 0x09 +#define JL_CHAR_RETURN 0x0D +#define JL_CHAR_ESCAPE 0x1B + +// Typed-character queue capacity (ring buffer; one slot stays empty, +// so JL_CHAR_QUEUE_SIZE - 1 characters can be pending). Must be a +// power of two. +#define JL_CHAR_QUEUE_SIZE 32 + void jlInputPoll(void); +// Pop the next typed character as a 7-bit code, or -1 if the queue +// is empty. Codes: printable ASCII 0x20..0x7E, plus JL_CHAR_BACKSPACE, +// JL_CHAR_TAB, JL_CHAR_RETURN, JL_CHAR_ESCAPE. Shift, caps lock, and +// the keyboard layout are already applied by the backend, so +// punctuation and shifted symbols arrive correctly on every port. +// (The layout is the machine's own on IIgs/Amiga/ST/X68000; the DOS +// port translates with a fixed US layout -- see docs/input.md.) +// +// The queue is refilled by jlInputPoll() -- no extra bring-up is +// needed. It holds JL_CHAR_QUEUE_SIZE - 1 characters; when full, +// further characters are dropped (drop-newest) until the app pops. +// OS auto-repeat may deliver repeated characters on ports whose +// keyboard services repeat (the caller manages repeat policy). +// 7-bit ASCII only; no Unicode / IME. Arrow keys are not characters +// and stay on the predicate API (jlKeyDown(KEY_UP) etc.). +// jlWaitForAnyKey empties the queue when it returns -- its dismissing +// keystroke is consumed by the wait, not delivered as text. +// +// Returns int (not a stdint type) deliberately: getchar()-style +// "byte or -1" is the interface contract consumers expect. +int jlInputGetChar(void); + // Block until the user presses any key. Internally polls via // jlInputPoll, so per-port jlpInputPoll machinery (including // audio-friendly IRQ-driven samplers) keeps working while the diff --git a/include/joey/platform.h b/include/joey/platform.h index c6250de..088d8d5 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -151,15 +151,6 @@ #define JL_HAS_AUDIO_CRITICAL_ENTER #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK - // SAMPLE side (src/x68000/audioPcm.c): libxmp-lite + the shared 5-slot SFX - // overlay, encoded to MSM6258 ADPCM and played via _iocs_adpcmlot. Separate - // device from the OPM, so chip music and digital audio do not contend. - #define JL_HAS_AUDIO_PLAY_MOD - #define JL_HAS_AUDIO_STOP_MOD - #define JL_HAS_AUDIO_IS_PLAYING_MOD - #define JL_HAS_AUDIO_PLAY_SFX - #define JL_HAS_AUDIO_PLAY_SFX_STREAM - #define JL_HAS_AUDIO_STOP_SFX // lifecycle / present / input (every real port implements) #define JL_HAS_INIT #define JL_HAS_SHUTDOWN @@ -229,15 +220,6 @@ #define JL_HAS_AUDIO_CRITICAL_ENTER #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK - // SAMPLE side (src/x68000/audioPcm.c): libxmp-lite + the shared 5-slot SFX - // overlay, encoded to MSM6258 ADPCM and played via _iocs_adpcmlot. Separate - // device from the OPM, so chip music and digital audio do not contend. - #define JL_HAS_AUDIO_PLAY_MOD - #define JL_HAS_AUDIO_STOP_MOD - #define JL_HAS_AUDIO_IS_PLAYING_MOD - #define JL_HAS_AUDIO_PLAY_SFX - #define JL_HAS_AUDIO_PLAY_SFX_STREAM - #define JL_HAS_AUDIO_STOP_SFX // lifecycle / present / input (every real port implements) #define JL_HAS_INIT #define JL_HAS_SHUTDOWN @@ -307,15 +289,6 @@ #define JL_HAS_AUDIO_CRITICAL_ENTER #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK - // SAMPLE side (src/x68000/audioPcm.c): libxmp-lite + the shared 5-slot SFX - // overlay, encoded to MSM6258 ADPCM and played via _iocs_adpcmlot. Separate - // device from the OPM, so chip music and digital audio do not contend. - #define JL_HAS_AUDIO_PLAY_MOD - #define JL_HAS_AUDIO_STOP_MOD - #define JL_HAS_AUDIO_IS_PLAYING_MOD - #define JL_HAS_AUDIO_PLAY_SFX - #define JL_HAS_AUDIO_PLAY_SFX_STREAM - #define JL_HAS_AUDIO_STOP_SFX // lifecycle / present / input (every real port implements) #define JL_HAS_INIT #define JL_HAS_SHUTDOWN @@ -364,15 +337,6 @@ #define JL_HAS_AUDIO_CRITICAL_ENTER #define JL_HAS_AUDIO_CRITICAL_EXIT #define JL_HAS_AUDIO_FRAME_TICK - // SAMPLE side (src/x68000/audioPcm.c): libxmp-lite + the shared 5-slot SFX - // overlay, encoded to MSM6258 ADPCM and played via _iocs_adpcmlot. Separate - // device from the OPM, so chip music and digital audio do not contend. - #define JL_HAS_AUDIO_PLAY_MOD - #define JL_HAS_AUDIO_STOP_MOD - #define JL_HAS_AUDIO_IS_PLAYING_MOD - #define JL_HAS_AUDIO_PLAY_SFX - #define JL_HAS_AUDIO_PLAY_SFX_STREAM - #define JL_HAS_AUDIO_STOP_SFX // lifecycle / present / input (every real port implements) #define JL_HAS_INIT #define JL_HAS_SHUTDOWN diff --git a/make/x68000.mk b/make/x68000.mk index b19d9b6..c9cda47 100644 --- a/make/x68000.mk +++ b/make/x68000.mk @@ -94,6 +94,7 @@ PATTERN_SRC := $(EXAMPLES)/pattern/pattern.c SERIAL_SRC := $(EXAMPLES)/serial/serial.c UBER_SRC := $(EXAMPLES)/uber/uber.c AUDIO_SRC := $(EXAMPLES)/audio/audio.c +KEYS_SRC := $(EXAMPLES)/keys/keys.c .PHONY: all x68000 x68000-lib x68000-examples x68000-verify-serial x68000-verify-golden clean-x68000 clean @@ -101,7 +102,7 @@ all x68000: x68000-lib x68000-examples x68000-lib: $(LIB) $(LIBXMP_AR) -x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X $(BINDIR)/PATTERN.X +x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X $(BINDIR)/PATTERN.X $(BINDIR)/KEYS.X $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -159,6 +160,12 @@ $(BINDIR)/AUDIO.X: $(AUDIO_SRC) $(LIB) $(LIBXMP_AR) @mkdir -p $(dir $@) $(X68K_CC) $(CFLAGS) $(AUDIO_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@ +# KEYS is the input acceptance vehicle: key predicates on the grid plus +# the typed-character line (jlInputGetChar) and its verification row. +$(BINDIR)/KEYS.X: $(KEYS_SRC) $(LIB) $(LIBXMP_AR) + @mkdir -p $(dir $@) + $(X68K_CC) $(CFLAGS) $(KEYS_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@ + # Both-directions RS-232C gate (~1 min). x68000-verify-serial: $(BINDIR)/SERIAL.X $(REPO_DIR)/scripts/verify-x68000-serial.sh diff --git a/scripts/check-input.sh b/scripts/check-input.sh new file mode 100755 index 0000000..7a51023 --- /dev/null +++ b/scripts/check-input.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# check-input.sh - Host-cc harness for the typed-character queue +# (jlInputGetChar / jlInputCharPush in src/core/input.c). Compiles the +# core input module with a mocked jlpInputPoll (tests/host/inputHost.c) +# and exercises FIFO order, the character filter, drop-newest overflow, +# reset, and the jlInputPoll refill path. +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cc=${CC:-cc} +work=$(mktemp -d -t joeylib-input.XXXXXX) +trap 'rm -rf "$work"' EXIT + +cd "$repo" +# The BLANK platform block registers JL_HAS_INPUT_POLL and +# JL_HAS_JOYSTICK_RESET (blank.c normally supplies them); inputHost.c +# provides the mocks here instead, so no extra -D flags are needed. +"$cc" -DJOEYLIB_PLATFORM_BLANK \ + -Iinclude -Iinclude/joey -Isrc/core -Wall \ + src/core/input.c tests/host/inputHost.c \ + -o "$work/inputHost" + +"$work/inputHost" diff --git a/scripts/verify-amiga-input.sh b/scripts/verify-amiga-input.sh new file mode 100755 index 0000000..3ddb7f9 --- /dev/null +++ b/scripts/verify-amiga-input.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# verify-amiga-input.sh - Typed-character acceptance gate for the Amiga +# port. Boots the Keys example off a directory hard drive in FS-UAE +# under Xvfb, types the RetroNet definition-of-done string with xdotool +# (real X key events, so ':' goes through the emulated shift and +# keymap.library's MapRawKey), then presses Escape. On exit Keys logs +# its full received-character history as hex to joeylog.txt on the boot +# volume -- which is the staged host directory -- and the harness +# compares that against the typed ASCII. +# +# Usage: scripts/verify-amiga-input.sh +# Requires: fs-uae, Xvfb, xdotool; build/amiga/bin/Keys (make amiga); +# toolchains/emulators/support/kickstart.rom. +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +bin_dir=$repo/build/amiga/bin +kickstart=$repo/toolchains/emulators/support/kickstart.rom + +target="192.168.1.10:6510" + +[ -f "$bin_dir/Keys" ] || { echo "verify-amiga-input: build Keys first (make amiga)" >&2; exit 2; } +[ -f "$kickstart" ] || { echo "verify-amiga-input: missing $kickstart" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-amiga-input.XXXXXX) +display_num=$((RANDOM % 500 + 300)) +cleanup() { + [ -n "${fsuae_pid:-}" ] && kill "$fsuae_pid" 2>/dev/null + [ -n "${xvfb_pid:-}" ] && kill "$xvfb_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/hd/s" +cp "$bin_dir/Keys" "$work/hd/" +echo ":Keys" > "$work/hd/s/startup-sequence" + +Xvfb ":$display_num" -screen 0 1024x768x24 >/dev/null 2>&1 & +xvfb_pid=$! +sleep 1 + +# --fullscreen=1 is REQUIRED under a WM-less Xvfb: SDL only accepts +# keyboard input once its window has input focus, no window manager +# ever grants it to a plain window, and xdotool's windowfocus/ +# XSendEvent routes are ignored by SDL. A fullscreen SDL window takes +# focus itself, after which plain XTEST typing (xdotool without +# --window) lands in the emulated Amiga. +DISPLAY=":$display_num" fs-uae \ + --amiga_model=A500 \ + --fast_memory=2048 \ + --kickstart_file="$kickstart" \ + --hard_drive_0="$work/hd" \ + --hard_drive_0_label=JOEYLIB \ + --fullscreen=1 \ + --initial_input_grab=1 \ + --floppy_drive_speed=800 \ + >/dev/null 2>&1 & +fsuae_pid=$! + +# Boot AmigaDOS off the directory HD and let startup-sequence launch +# Keys. No sentinel channel here, so give it a generous fixed wait. +sleep 28 + +# XTEST keyboard events land in whatever window HAS focus, and under +# a WM-less Xvfb the fullscreen SDL window latches it on its own +# schedule -- so wait until getwindowfocus actually reports the FS-UAE +# window before typing, nudging with windowfocus while waiting. +win=$(DISPLAY=":$display_num" xdotool search --name -- "FS-UAE" | head -1) +for i in $(seq 1 30); do + focus=$(DISPLAY=":$display_num" xdotool getwindowfocus 2>/dev/null || true) + [ -n "$win" ] && [ "$focus" = "$win" ] && break + [ -n "$win" ] && DISPLAY=":$display_num" xdotool windowfocus --sync "$win" 2>/dev/null || true + sleep 1 + [ -z "$win" ] && win=$(DISPLAY=":$display_num" xdotool search --name -- "FS-UAE" | head -1) +done + +# Up to three attempts: type, press Escape, then look for the typed= +# line Keys logs on exit. The verdict compares the history's SUFFIX +# against the target, so a partially-delivered early attempt can't +# poison a complete later one -- the proof is that the final 17 +# characters arrived in order through the queue. +log="$work/hd/joeylog.txt" +delivered="" +for attempt in 1 2 3; do + DISPLAY=":$display_num" xdotool type --delay 200 -- "$target" + sleep 2 + # Hold Escape across several emulated frames: an instantaneous + # down+up can land inside ONE drainMessages pass, where set-then- + # clear in the same poll is invisible to the edge predicate. + DISPLAY=":$display_num" xdotool keydown Escape + sleep 0.5 + DISPLAY=":$display_num" xdotool keyup Escape + for i in $(seq 1 10); do + if [ -f "$log" ] && grep -q "typed=" "$log"; then + delivered=1 + break 2 + fi + sleep 1 + done +done +if [ -z "$delivered" ]; then + echo "verify-amiga-input: FAIL - no typed= line in joeylog.txt (keystrokes never landed?)" >&2 + ls -la "$work/hd" >&2 + exit 1 +fi + +typedHex=$(grep -o "typed=[0-9A-Fa-f]*" "$log" | tail -1 | cut -d= -f2) +expect=$(printf '%s' "$target" | od -A n -t x1 | tr -d ' \n' | tr 'a-f' 'A-F') +echo "VERIFY-AMIGA-INPUT typed=$typedHex" +case "$typedHex" in + *"$expect") + echo "verify-amiga-input: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)" + ;; + *) + echo "verify-amiga-input: FAIL" >&2 + echo " expected suffix: $expect" >&2 + echo " got: $typedHex" >&2 + exit 1 + ;; +esac diff --git a/scripts/verify-atarist-input.sh b/scripts/verify-atarist-input.sh new file mode 100755 index 0000000..828265e --- /dev/null +++ b/scripts/verify-atarist-input.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# verify-atarist-input.sh - Headless typed-character acceptance gate for +# the Atari ST port. Autostarts KEYS.PRG in Hatari (EmuTOS, GEMDOS +# drive), injects the RetroNet definition-of-done string through +# Hatari's command FIFO as IKBD key events, and reads the example's +# verification scanline back out of ST RAM with the built-in debugger. +# The row is planar (4 interleaved bitplane words per 16 pixels); the +# decode below reassembles the nibble pixels, which must equal the +# typed ASCII exactly. +# +# Injection encoding (Hatari 2.4 `hatari-event keypress ` parses a +# single-character argument as an ASCII character and a multi-character +# argument as an ST scancode): digits go as ASCII, '.' as scancode 52, +# and ':' as shift make (42) + ';' scancode 39 + shift break -- which +# also exercises the ISR's shift handling through the TOS keymap. +# +# Usage: scripts/verify-atarist-input.sh +# Requires: hatari, EmuTOS image, build/atarist/bin/KEYS.PRG. +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +bin_dir=$repo/build/atarist/bin +tos=$repo/toolchains/emulators/support/emutos-512k.img + +target="192.168.1.10:6510" + +[ -f "$bin_dir/KEYS.PRG" ] || { echo "verify-atarist-input: build KEYS.PRG first (make atarist)" >&2; exit 2; } +[ -f "$tos" ] || { echo "verify-atarist-input: missing EmuTOS at $tos" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-st-input.XXXXXX) +cleanup() { + [ -n "${hatari_pid:-}" ] && kill "$hatari_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +cd "$work" +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy hatari \ + --tos "$tos" \ + --harddrive "$bin_dir" \ + --gemdos-drive C \ + --auto 'C:\KEYS.PRG' \ + --cmd-fifo "$work/cmd.fifo" \ + --fast-forward on \ + > "$work/hatari.log" 2>&1 & +hatari_pid=$! + +send() { + timeout 5 bash -c "echo \"\$1\" > \"$work/cmd.fifo\"" _ "$1" || return 1 + sleep 0.2 +} + +# Wait for the FIFO, then for KEYS' A5A5 sentinel in the verify row. +# The video base is read from the shifter regs each attempt (planar +# sentinel = plane words 5000 A000 5000 A000). +for i in $(seq 1 30); do + [ -p "$work/cmd.fifo" ] && break + sleep 1 +done +[ -p "$work/cmd.fifo" ] || { echo "verify-atarist-input: FAIL - hatari never created the FIFO" >&2; exit 1; } + +rowAddr="" +for i in $(seq 1 45); do + sleep 1 + send 'hatari-debug m $ff8201 4' || continue + base=$(grep -E '^00FF8201:' "$work/hatari.log" | tail -1 | awk '{print $2 $4}') + [ -n "$base" ] || continue + addr=$(( (16#$base << 8) + 198 * 160 )) + send "hatari-debug m \$$(printf '%x' "$addr") 8" || continue + row=$(grep -E "^$(printf '%08X' "$addr"):" "$work/hatari.log" | tail -1) + if echo "$row" | grep -qi "50 00 a0 00 50 00 a0 00"; then + rowAddr=$addr + break + fi +done +if [ -z "$rowAddr" ]; then + echo "verify-atarist-input: FAIL - KEYS sentinel never appeared" >&2 + tail -10 "$work/hatari.log" >&2 + exit 1 +fi + +# Type the string: digits as ASCII characters, '.' as scancode 52, +# ':' as shift + scancode 39. +for c in 1 9 2; do send "hatari-event keypress $c"; done +send "hatari-event keypress 52" +for c in 1 6 8; do send "hatari-event keypress $c"; done +send "hatari-event keypress 52" +send "hatari-event keypress 1" +send "hatari-event keypress 52" +for c in 1 0; do send "hatari-event keypress $c"; done +send "hatari-event keydown 42" +send "hatari-event keypress 39" +send "hatari-event keyup 42" +for c in 6 5 1 0; do send "hatari-event keypress $c"; done + +sleep 3 +send "hatari-debug m \$$(printf '%x' "$rowAddr") 160" +sleep 2 + +python3 - "$work/hatari.log" "$rowAddr" "$target" <<'PY' +import re +import sys + +log, rowAddr, target = sys.argv[1], int(sys.argv[2]), sys.argv[3] +data = open(log, "rb").read().decode(errors="replace") +lines = {} +# Keep only the LAST dump of each address (earlier sentinel probes also match). +for m in re.finditer(r"^(00[0-9A-F]{6}): ((?:[0-9a-f]{2} ){1,16})", data, re.M): + lines[int(m.group(1), 16)] = m.group(2) +raw = b"" +for off in range(0, 160, 16): + if rowAddr + off not in lines: + print(f"verify-atarist-input: FAIL - missing dump line at +{off}") + sys.exit(1) + raw += bytes.fromhex(lines[rowAddr + off].replace(" ", "")) + +def group(words): + p = [int.from_bytes(words[i * 2:i * 2 + 2], "big") for i in range(4)] + return [((p[0] >> b) & 1) | (((p[1] >> b) & 1) << 1) | + (((p[2] >> b) & 1) << 2) | (((p[3] >> b) & 1) << 3) + for b in range(15, -1, -1)] + +pix = [] +for g in range(len(raw) // 8): + pix += group(raw[g * 8:g * 8 + 8]) + +if pix[0:4] != [0xA, 0x5, 0xA, 0x5]: + print(f"verify-atarist-input: FAIL - sentinel wrong: {pix[0:4]}") + sys.exit(1) +count = (pix[4] << 4) | pix[5] +chars = bytes((pix[8 + 2 * i] << 4) | pix[8 + 2 * i + 1] for i in range(count)) +print(f"VERIFY-ST-INPUT count={count} chars={chars!r}") +if chars.decode("ascii", errors="replace") == target: + print(f'verify-atarist-input: PASS (typed "{target}", all {len(target)} chars arrived via jlInputGetChar)') +else: + print(f'verify-atarist-input: FAIL - expected "{target}"') + sys.exit(1) +PY diff --git a/scripts/verify-dos-input.sh b/scripts/verify-dos-input.sh new file mode 100755 index 0000000..c1cc8fa --- /dev/null +++ b/scripts/verify-dos-input.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# verify-dos-input.sh - Headless typed-character acceptance gate for the +# DOS port. Runs KEYS.EXE in DOSBox Staging under Xvfb, types the +# RetroNet definition-of-done string with xdotool (real X key events, +# shift and all, so ':' exercises the INT 9 shift path), takes a RAW +# native-resolution screenshot, and decodes the example's verification +# scanline (row 198) from the exact VGA DAC colors. KEYS gives all 16 +# palette entries distinct colors precisely so this decode is exact. +# +# Usage: scripts/verify-dos-input.sh +# Requires: dosbox (staging), Xvfb, xdotool, python3 + PIL; +# build/dos/bin/KEYS.EXE (make dos). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +bin_dir=$repo/build/dos/bin +conf=$repo/scripts/dosbox-386sx16.conf + +target="192.168.1.10:6510" + +[ -f "$bin_dir/KEYS.EXE" ] || { echo "verify-dos-input: build KEYS.EXE first (make dos)" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-dos-input.XXXXXX) +display_num=$((RANDOM % 500 + 300)) +cleanup() { + [ -n "${dosbox_pid:-}" ] && kill "$dosbox_pid" 2>/dev/null + [ -n "${xvfb_pid:-}" ] && kill "$xvfb_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +Xvfb ":$display_num" -screen 0 1024x768x24 >/dev/null 2>&1 & +xvfb_pid=$! +sleep 1 + +DISPLAY=":$display_num" dosbox \ + -conf "$conf" \ + -set "capture capture_dir=$work" \ + -set "capture default_image_capture_formats=raw" \ + -c "C:" -c "KEYS.EXE" \ + "$bin_dir" >/dev/null 2>&1 & +dosbox_pid=$! + +# Let DOS boot and KEYS reach its poll loop on the simulated 386SX-16. +sleep 12 + +# Staging titles its window " - cycles/ms"; it is the only +# client on this private display, so match any named window. +win=$(DISPLAY=":$display_num" xdotool search --name -- "." | head -1) +if [ -z "$win" ]; then + echo "verify-dos-input: FAIL - DOSBox window not found" >&2 + exit 1 +fi +DISPLAY=":$display_num" xdotool windowactivate --sync "$win" 2>/dev/null || true +DISPLAY=":$display_num" xdotool type --window "$win" --delay 150 -- "$target" +sleep 2 +# Ctrl+F5 = DOSBox screenshot hotkey. +DISPLAY=":$display_num" xdotool key --window "$win" ctrl+F5 +sleep 2 + +png=$(ls "$work"/*.png 2>/dev/null | head -1) +if [ -z "$png" ]; then + echo "verify-dos-input: FAIL - no screenshot captured" >&2 + exit 1 +fi + +python3 - "$png" "$target" <<'PY' +import sys +from PIL import Image + +png, target = sys.argv[1], sys.argv[2] +img = Image.open(png).convert("RGB") +w, h = img.size +# Raw capture is native 320x200; tolerate an integer upscale. +sx, sy = w // 320, h // 200 +if sx < 1 or sy < 1 or w % 320 or h % 200: + print(f"verify-dos-input: FAIL - unexpected screenshot size {w}x{h}") + sys.exit(1) + +def dac8(c4): + v6 = ((c4 << 2) | (c4 >> 2)) & 0x3F + return (v6 << 2) | (v6 >> 4) + +# KEYS palette 0 (see buildPalette): gray ramp with the four demo +# colors and red at 15. 12-bit 0x0RGB -> exact 8-bit via the port's +# 6-bit DAC expansion. +pal12 = [(i << 8) | (i << 4) | i for i in range(16)] +pal12[0], pal12[1], pal12[2], pal12[3] = 0x000, 0x333, 0x0F0, 0xFFF +pal12[15] = 0xF00 +# Nearest-color match: emulators differ in how they scale the 6-bit +# DAC to 8-bit (bit-replication vs rounding), but the palette entries +# sit ~17 units apart per channel, so nearest is unambiguous. +palRgb = [(dac8((v >> 8) & 0xF), dac8((v >> 4) & 0xF), dac8(v & 0xF)) for v in pal12] + +def pix(px): + r, g, b = img.getpixel((px * sx, 198 * sy)) + best, bestDist = 0, 1 << 30 + for n, (pr, pg, pb) in enumerate(palRgb): + d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 + if d < bestDist: + best, bestDist = n, d + if bestDist > 3 * 8 ** 2: + print(f"verify-dos-input: FAIL - pixel {px} color {(r, g, b)} too far from any palette entry") + sys.exit(1) + return best + +row = bytes((pix(2 * i) << 4) | pix(2 * i + 1) for i in range(40)) +expect = bytes([0xA5, 0xA5, len(target), 0x00]) + target.encode("ascii") +got = row[: len(expect)] +print("VERIFY-DOS-INPUT row=" + row.hex().upper()) +if got == expect: + print(f'verify-dos-input: PASS (typed "{target}", all {len(target)} chars arrived via jlInputGetChar)') +else: + print(f"verify-dos-input: FAIL\n expected: {expect.hex().upper()}\n got: {got.hex().upper()}") + sys.exit(1) +PY diff --git a/scripts/verify-iigs-input.sh b/scripts/verify-iigs-input.sh new file mode 100755 index 0000000..b87e300 --- /dev/null +++ b/scripts/verify-iigs-input.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# verify-iigs-input.sh - Headless typed-character acceptance gate for the +# IIgs port. Boots GS/OS under MAME, launches the KEYS example off +# joey.2mg, types the RetroNet definition-of-done string on the emulated +# keyboard, and reads the example's verification scanline (row 198) +# straight out of SHR memory. KEYS encodes every character it received +# from jlInputGetChar as raw nibble pixel pairs behind an A5A5 sentinel, +# so the row bytes must equal the typed ASCII exactly -- proving digits, +# '.', and ':' all arrive through the typed-character queue. +# +# Usage: scripts/verify-iigs-input.sh +# Requires: toolchains/env.sh sourced; build/iigs/bin/joey.2mg built +# with the current KEYS (make iigs-disk). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +target="192.168.1.10:6510" + +sys_disk=$repo/toolchains/emulators/support/gsos-system.po +data_disk=$repo/build/iigs/bin/joey.2mg +rompath="${MAME_ROMPATH:-$HOME/.mame/roms}" +typeFrame="${MAME_TYPE_FRAME:-6600}" +readFrame="${MAME_READ_FRAME:-12000}" + +for f in "$sys_disk" "$data_disk"; do + [ -f "$f" ] || { echo "verify-iigs-input: missing $f (run 'make iigs-disk')" >&2; exit 2; } +done + +work=$(mktemp -d -t joeylib-input-verify.XXXXXX) +trap 'rm -rf "$work"' EXIT +cp "$sys_disk" "$work/boot.po" +cp "$data_disk" "$work/joey.2mg" + +# Finder keystroke timeline as in verify-iigs.sh, then the typed string +# once KEYS is running, then a dump of verification row 198: +# base = $E1/2000 + 198*160 = $E1/9BC0 +# byte 0..1 = A5 A5 sentinel, byte 2 = received count, byte 3 = pad, +# byte 4.. = one received character per byte. +cat > "$work/verify.lua" <= steps[idx][1] do + steps[idx][2]() + idx = idx + 1 + end +end) +LUA + +cd "$work" +out=$(QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout 300 mame apple2gs \ + -rompath "$rompath" \ + -flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/verify.lua" &1) || true + +line=$(echo "$out" | grep -E '^VERIFY-IIGS-INPUT ' | tail -1) +echo "$line" +if [ -z "$line" ]; then + echo "verify-iigs-input: FAIL - no report (boot/launch failed)" >&2 + echo "$out" | tail -15 >&2 + exit 1 +fi + +scb0=$(echo "$line" | sed -E 's/.*scb0=([0-9A-Fa-f]+).*/\1/') +if [ $((16#$scb0)) -ge 16 ]; then + echo "verify-iigs-input: FAIL (KEYS never launched - SCB[0]=0x$scb0)" >&2 + exit 1 +fi + +row=$(echo "$line" | sed -E 's/.*row=([0-9A-Fa-f]+).*/\1/') +expect="A5A5" +count=$(printf '%02X' ${#target}) +expect+="$count" +expect+="00" +expect+=$(printf '%s' "$target" | od -A n -t x1 | tr -d ' \n' | tr 'a-f' 'A-F') +got=${row:0:${#expect}} +if [ "$got" = "$expect" ]; then + echo "verify-iigs-input: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)" +else + echo "verify-iigs-input: FAIL" >&2 + echo " expected row prefix: $expect" >&2 + echo " got: $got" >&2 + exit 1 +fi diff --git a/scripts/verify-x68000-input.sh b/scripts/verify-x68000-input.sh new file mode 100755 index 0000000..a8c8166 --- /dev/null +++ b/scripts/verify-x68000-input.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# verify-x68000-input.sh - Headless typed-character acceptance gate for +# the X68000 port. Boots Human68k under the patched MAME with KEYS.X in +# AUTOEXEC.BAT, waits for the example's A5A5 sentinel to appear in +# GVRAM, types the RetroNet definition-of-done string on the emulated +# keyboard, and reads the verification scanline back out of GVRAM. +# KEYS encodes every character it received from jlInputGetChar as nibble +# pixel pairs, so the row must equal the typed ASCII exactly. +# +# GVRAM geometry (crtmod 13, 256-colour graphics plane): 16-bit word +# per pixel at $C00000, 512 words per row; the 320x200 stage is centred +# at origin (96, 156). Verification row = stage y 198 -> GVRAM y 354. +# +# X68K_SCRATCH= bash scripts/verify-x68000-input.sh +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SP="${X68K_SCRATCH:?set X68K_SCRATCH to a work dir containing x68mame/}" +MAME="$repo/toolchains/cache/mame-mame0264/x68k" +TEMPLATE="$SP/x68mame/HUMAN302.XDF" +WALL="${X68K_INPUT_WALL:-900}" + +target="192.168.1.10:6510" + +[ -x "$MAME" ] || { echo "verify-x68000-input: missing patched MAME at $MAME" >&2; exit 2; } +[ -f "$TEMPLATE" ] || { echo "verify-x68000-input: missing $TEMPLATE" >&2; exit 2; } +[ -f "$repo/build/x68000/bin/KEYS.X" ] || { echo "verify-x68000-input: build KEYS.X first (make -f make/x68000.mk)" >&2; exit 2; } + +work=$(mktemp -d -t joey-x68input.XXXXXX) +trap 'rm -rf "$work"' EXIT + +printf 'A:\\KEYS.X\r\n\x1a' > "$work/AUTOEXEC.BAT" +cp "$TEMPLATE" "$work/boot.xdf" +for f in USKCG.SYS BEEP.SYS KEY.SYS STARTUP.ENV; do + python3 "$repo/tools/xdftool.py" delete "$work/boot.xdf" "$f" >/dev/null 2>&1 +done +python3 "$repo/tools/xdftool.py" add "$work/boot.xdf" "$repo/build/x68000/bin/KEYS.X" KEYS.X >/dev/null || exit 1 +python3 "$repo/tools/xdftool.py" add "$work/boot.xdf" "$work/AUTOEXEC.BAT" AUTOEXEC.BAT >/dev/null + +cat > "$work/input.lua" < GVRAM word at base + 2*px, value low byte. +local rowBase = 0xC00000 + ((156 + 198) * 512 + 96) * 2 + +local function pix(px) + return mem:read_u8(rowBase + 2 * px + 1) +end + +local function report() + local bytes = {} + for i = 0, 39 do + bytes[#bytes + 1] = string.format("%02X", (pix(2 * i) << 4) | pix(2 * i + 1)) + end + io.write(string.format("VERIFY-X68K-INPUT frame=%d typedAt=%d row=%s\n", + frame, typedAt, table.concat(bytes))) + io.flush() +end + +emu.register_frame_done(function() + frame = frame + 1 + if done then return end + if typedAt == 0 and frame > 600 and frame % 100 == 0 then + -- KEYS is up once initialPaint stamped the A5A5 sentinel. + if pix(0) == 0xA and pix(1) == 0x5 and pix(2) == 0xA and pix(3) == 0x5 then + typedAt = frame + 60 + end + end + if typedAt ~= 0 and frame == typedAt then + nat:post("$target") + end + if typedAt ~= 0 and frame >= typedAt + 3000 then + done = true + report() + manager.machine:exit() + end + if frame > 30000 then + done = true + io.write("VERIFY-X68K-INPUT timeout: sentinel never appeared\n") + io.flush() + manager.machine:exit() + end +end) +LUA + +# Run from the work dir so MAME's cfg/ and nvram/ droppings land there +# instead of the caller's cwd. +cd "$work" +out=$(timeout -s KILL "$WALL" "$MAME" x68000 -bios ipl10 \ + -rompath "$SP/x68mame/roms" -flop1 "$work/boot.xdf" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/input.lua" &1) || true + +line=$(echo "$out" | grep -E '^VERIFY-X68K-INPUT ' | tail -1) +echo "$line" +if [ -z "$line" ] || echo "$line" | grep -q timeout; then + echo "verify-x68000-input: FAIL - KEYS never came up" >&2 + echo "$out" | tail -10 >&2 + exit 1 +fi + +row=$(echo "$line" | sed -E 's/.*row=([0-9A-Fa-f]+).*/\1/') +expect="A5A5" +expect+=$(printf '%02X' ${#target}) +expect+="00" +expect+=$(printf '%s' "$target" | od -A n -t x1 | tr -d ' \n' | tr 'a-f' 'A-F') +got=${row:0:${#expect}} +if [ "$got" = "$expect" ]; then + echo "verify-x68000-input: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)" +else + echo "verify-x68000-input: FAIL" >&2 + echo " expected row prefix: $expect" >&2 + echo " got: $got" >&2 + exit 1 +fi diff --git a/src/amiga/input.c b/src/amiga/input.c index 1a671d2..eaedf10 100644 --- a/src/amiga/input.c +++ b/src/amiga/input.c @@ -16,6 +16,13 @@ // for MOUSEMOVE events to fire continuously rather than only on button // transitions. WFLG_RMBTRAP keeps the right mouse button from opening // Intuition's screen menu so right-clicks come to us as MENUDOWN. +// +// Typed characters: RAWKEY press events are also run through +// keymap.library's MapRawKey with the message qualifier and dead-key +// context, which applies the user's keymap (shift, caps, national +// layouts, dead keys) and yields translated bytes for the core typed- +// character queue. Intuition delivers key repeats as further RAWKEY +// events, so OS auto-repeat produces repeated characters for free. #include @@ -28,6 +35,7 @@ #include #include +#include #include #include "port.h" @@ -39,6 +47,24 @@ #define AMIGA_KEY_RELEASE_BIT 0x80 #define AMIGA_KEY_CODE_MASK 0x7F +// MapRawKey output buffer. A single keystroke maps to at most a few +// bytes (dead-key compositions); 8 is comfortably above any keymap's +// output. +#define MAP_RAW_KEY_BUF_SIZE 8 + +// Keymaps encode arrows / F-keys / Help as ANSI control sequences +// beginning with CSI (0x9B). The CSI byte itself would be filtered, +// but the sequence's TAIL bytes are printable ('A' for cursor up, +// "0~" for F1, ...) and would leak into the typed-character queue as +// stray text -- so a mapping that starts with CSI is discarded whole. +#define AMIGA_CSI 0x9B + +// keymap.library base referenced by the MapRawKey inline stub. Defined +// here and opened explicitly (not left to libnix auto-open) so a +// system without the library degrades to "no typed characters" instead +// of failing at startup. +struct Library *KeymapBase = NULL; + // ----- External from hal.c ----- extern struct Screen *gScreen; @@ -144,6 +170,10 @@ static struct Library *gLowLevelBase = NULL; static void drainMessages(void) { struct IntuiMessage *msg; + struct InputEvent ie; + UBYTE mapBuf[MAP_RAW_KEY_BUF_SIZE]; + WORD mapCount; + WORD mapIdx; UWORD msgClass; UWORD msgCode; int16_t msgMouseX; @@ -160,6 +190,29 @@ static void drainMessages(void) { msgCode = msg->Code; msgMouseX = (int16_t)msg->MouseX; msgMouseY = (int16_t)msg->MouseY; + + // Typed-character translation must run BEFORE ReplyMsg: the + // qualifier and the dead-key context behind IAddress belong + // to the message and may be recycled once replied. Release + // events (bit 7) type nothing. + mapCount = 0; + if (msgClass == IDCMP_RAWKEY && + (msgCode & AMIGA_KEY_RELEASE_BIT) == 0 && + KeymapBase != NULL) { + ie.ie_NextEvent = NULL; + ie.ie_Class = IECLASS_RAWKEY; + ie.ie_SubClass = 0; + ie.ie_Code = msgCode; + ie.ie_Qualifier = msg->Qualifier; + // Dead-key context: IAddress points at the previous-code + // storage MapRawKey wants in ie_EventAddress (RKM recipe). + ie.ie_EventAddress = (APTR)*((ULONG *)msg->IAddress); + mapCount = MapRawKey(&ie, (STRPTR)mapBuf, MAP_RAW_KEY_BUF_SIZE, NULL); + if (mapCount > 0 && mapBuf[0] == AMIGA_CSI) { + mapCount = 0; + } + } + ReplyMsg((struct Message *)msg); switch (msgClass) { @@ -170,6 +223,11 @@ static void drainMessages(void) { if (key != KEY_NONE) { gKeyState[key] = !isRelease; } + // MapRawKey returns -1 on overflow (composition longer + // than the buffer) -- nothing usable, skip. + for (mapIdx = 0; mapIdx < mapCount; mapIdx++) { + jlInputCharPush((uint8_t)mapBuf[mapIdx]); + } break; case IDCMP_MOUSEMOVE: gMouseX = msgMouseX; @@ -288,6 +346,11 @@ void jlpInputInit(void) { // Kickstart, or stripped AROS), the joystick API silently reports // disconnected sticks rather than failing init. gLowLevelBase = OpenLibrary((CONST_STRPTR)"lowlevel.library", 0); + + // keymap.library (OS 2.0+) drives the typed-character path. If + // absent, jlInputGetChar just stays empty; the predicates still + // work off the raw codes. + KeymapBase = OpenLibrary((CONST_STRPTR)"keymap.library", 0); } @@ -298,6 +361,10 @@ void jlpInputPoll(void) { void jlpInputShutdown(void) { + if (KeymapBase != NULL) { + CloseLibrary(KeymapBase); + KeymapBase = NULL; + } if (gLowLevelBase != NULL) { CloseLibrary(gLowLevelBase); gLowLevelBase = NULL; diff --git a/src/atarist/input.c b/src/atarist/input.c index 38495ba..2edd845 100644 --- a/src/atarist/input.c +++ b/src/atarist/input.c @@ -18,6 +18,17 @@ // jlKeyPressed edge detection requires that public gKeyState only // advance during jlpInputPoll, never at interrupt time -- jlInputPoll // snapshots gKeyState into gKeyPrev before jlpInputPoll runs. +// +// Typed characters: replacing ikbdsys means TOS's own keyboard +// processing never runs, so Bconin never sees our keys. Instead the +// ISR translates make codes with the TOS keyboard tables from +// Keytbl() -- the OS keymap, so national layouts are honored -- into +// a private ring that jlpInputPoll drains into the core typed- +// character queue. Shift/caps come from the ISR's own key state +// (caps-lock toggles on scan 0x3A); Ctrl chords are commands, not +// text, and produce no character. The IKBD sends one make per press +// (TOS's software auto-repeat is gone with ikbdsys), so held keys do +// not repeat characters. #include @@ -37,6 +48,26 @@ #define SCAN_BREAK_BIT 0x80 #define SCAN_CODE_MASK 0x7F #define SCAN_TABLE_SIZE 128 +#define SCAN_CAPS_LOCK 0x3A + +// Cursor/edit cluster scan codes. Their UNSHIFTED keytable entries are +// 0 (no character), but TOS's SHIFT table maps them to legacy numeric +// digits ('8' for shift+Up etc.), so the typed-character path must +// skip them outright. Keypad '-' (0x4A) and '+' (0x4E) sit between +// these codes and DO type. +#define SCAN_CLR_HOME 0x47 +#define SCAN_CURSOR_UP 0x48 +#define SCAN_CURSOR_LEFT 0x4B +#define SCAN_CURSOR_RIGHT 0x4D +#define SCAN_CURSOR_DOWN 0x50 +#define SCAN_INSERT 0x52 + +// Kbshift(-1) state bits (TOS BIOS): bit 4 = caps lock engaged. +#define KBSHIFT_CAPS_BIT 0x10 + +// Private ISR->poll ring for translated characters. Power of two so +// the wrap is a mask; holds ISR_CHAR_QUEUE_SIZE - 1 pending bytes. +#define ISR_CHAR_QUEUE_SIZE 32 #define PKT_STATUS 0xF6 // + 7 bytes #define PKT_ABS_MOUSE 0xF7 // + 5 bytes @@ -74,9 +105,10 @@ // ----- Prototypes ----- +static long ikbdHandler(void); static long patchIkbdVector(void); static long restoreIkbdVector(void); -static long ikbdHandler(void); +static bool scanIsCursorCluster(uint8_t code); // ----- Module state ----- @@ -169,16 +201,37 @@ static int16_t gMouseAbsY = SURFACE_HEIGHT / 2; // jlpInputPoll can simply read the latest value. static volatile uint8_t gIsrJoyByte[JOYSTICK_COUNT]; +// TOS keyboard translation tables (Keytbl), cached at init so the ISR +// only does array lookups. 128 bytes each, indexed by scan code; 0 +// means the key types nothing. +static const uint8_t *gKeyTabUnshift = NULL; +static const uint8_t *gKeyTabShift = NULL; +static const uint8_t *gKeyTabCaps = NULL; + +// ISR-tracked caps-lock toggle (scan 0x3A make flips it). uint8_t so +// the element size is pinned like the other ISR state. +static volatile uint8_t gIsrCapsLock = 0; + +// Translated characters, ISR producer -> poll consumer. Single- +// producer single-consumer: the ISR only writes head + slots, the +// poll drain only writes tail, so no interrupt masking is needed. +static volatile uint8_t gIsrCharQueue[ISR_CHAR_QUEUE_SIZE]; +static volatile uint8_t gIsrCharHead = 0; +static volatile uint8_t gIsrCharTail = 0; + // ----- Internal helpers ----- // Runs in MFP ACIA interrupt context. Reads one byte from the ACIA, // dispatches to either keyboard handling, mouse-packet capture, or // "discard remaining N bytes" for packets we do not yet care about. static long ikbdHandler(void) { - uint8_t byte; - uint8_t code; - uint8_t key; - bool isBreak; + const uint8_t *tab; + uint8_t byte; + uint8_t code; + uint8_t key; + uint8_t ch; + uint8_t next; + bool isBreak; byte = *ST_ACIA_DATA; @@ -264,6 +317,33 @@ static long ikbdHandler(void) { if (key != KEY_NONE) { gIsrState[key] = !isBreak; } + + if (!isBreak) { + if (code == SCAN_CAPS_LOCK) { + gIsrCapsLock = (uint8_t)!gIsrCapsLock; + } else if (!gIsrState[KEY_LCTRL] && !gIsrState[KEY_LALT] && + gKeyTabUnshift != NULL && !scanIsCursorCluster(code)) { + // Typed-character translation via the TOS keymap. Table + // priority mirrors TOS: shift wins over caps, caps over + // plain. A 0 entry types nothing (F-keys etc.); Ctrl/Alt + // chords are commands, not text. + if (gIsrState[KEY_LSHIFT] || gIsrState[KEY_RSHIFT]) { + tab = gKeyTabShift; + } else if (gIsrCapsLock) { + tab = gKeyTabCaps; + } else { + tab = gKeyTabUnshift; + } + ch = tab[code]; + if (ch != 0) { + next = (uint8_t)((gIsrCharHead + 1u) & (ISR_CHAR_QUEUE_SIZE - 1u)); + if (next != gIsrCharTail) { + gIsrCharQueue[gIsrCharHead] = ch; + gIsrCharHead = next; + } + } + } + } return 0; } @@ -281,6 +361,13 @@ static long restoreIkbdVector(void) { } +static bool scanIsCursorCluster(uint8_t code) { + return code == SCAN_CLR_HOME || code == SCAN_CURSOR_UP || + code == SCAN_CURSOR_LEFT || code == SCAN_CURSOR_RIGHT || + code == SCAN_CURSOR_DOWN || code == SCAN_INSERT; +} + + // ----- HAL API (alphabetical) ----- void jlpJoystickReset(jlJoystickE js) { @@ -290,11 +377,29 @@ void jlpJoystickReset(jlJoystickE js) { void jlpInputInit(void) { + _KEYTAB *keyTab; + memset(gKeyState, 0, sizeof(gKeyState)); memset(gKeyPrev, 0, sizeof(gKeyPrev)); memset((void *)gIsrState, 0, sizeof(gIsrState)); memset((void *)gIsrJoyByte, 0, sizeof(gIsrJoyByte)); + // Cache the TOS keyboard translation tables (the OS keymap) for + // the ISR's typed-character path. -1 leaves the tables unchanged + // and just returns the pointers. + keyTab = (_KEYTAB *)Keytbl((void *)-1L, (void *)-1L, (void *)-1L); + if (keyTab != NULL) { + gKeyTabUnshift = (const uint8_t *)keyTab->unshift; + gKeyTabShift = (const uint8_t *)keyTab->shift; + gKeyTabCaps = (const uint8_t *)keyTab->caps; + } + // Seed caps lock from TOS: taking over ikbdsys freezes the OS's + // own tracking, so an engaged caps lock at launch would otherwise + // be invisible until the user toggles it once. + gIsrCapsLock = (Kbshift(-1) & KBSHIFT_CAPS_BIT) != 0; + gIsrCharHead = 0; + gIsrCharTail = 0; + gMouseAbsX = SURFACE_WIDTH / 2; gMouseAbsY = SURFACE_HEIGHT / 2; gMouseX = gMouseAbsX; @@ -327,10 +432,21 @@ void jlpInputPoll(void) { int32_t newY; uint8_t btn; uint8_t joy; + uint8_t tail; uint16_t i; memcpy(gKeyState, (const void *)gIsrState, sizeof(gKeyState)); + // Drain ISR-translated characters into the core queue. Single- + // consumer side of the SPSC ring: only gIsrCharTail is written + // here, so the ISR may keep producing concurrently. + tail = gIsrCharTail; + while (tail != gIsrCharHead) { + jlInputCharPush(gIsrCharQueue[tail]); + tail = (uint8_t)((tail + 1u) & (ISR_CHAR_QUEUE_SIZE - 1u)); + } + gIsrCharTail = tail; + // Drain accumulated mouse deltas + latch button state. dx = gIsrMouseDx; dy = gIsrMouseDy; diff --git a/src/blank/blank.c b/src/blank/blank.c index b41b8ef..ddf0622 100644 --- a/src/blank/blank.c +++ b/src/blank/blank.c @@ -87,8 +87,16 @@ void jlpInputShutdown(void) { // gKeyState[key] for every currently-held key (see joey/input.h for the jlKeyE // codes); keys you can't map simply stay false. The cross-platform jlInputPoll // and joystick code build on this. +// +// Typed characters (jlInputGetChar) are a second, independent path: pass each +// OS/firmware-translated ASCII byte (shift and layout already applied) to +// jlInputCharPush. The core filters to the documented set, so pushing every +// translated byte is fine. If your keyboard handling runs at interrupt time, +// buffer the bytes in your own volatile ring and drain it here instead -- +// jlInputCharPush is not interrupt-safe (see the DOS and ST ports). void jlpInputPoll(void) { // TODO: read your keyboard and set gKeyState[...] = true for held keys. + // TODO: feed translated characters to jlInputCharPush(ch). } diff --git a/src/core/init.c b/src/core/init.c index 8e47e03..358ed6b 100644 --- a/src/core/init.c +++ b/src/core/init.c @@ -10,6 +10,7 @@ #include "joey/core.h" #include "codegenArenaInternal.h" +#include "inputInternal.h" #include "port.h" #include "spriteInternal.h" #include "surfaceInternal.h" @@ -127,6 +128,10 @@ bool jlInit(const jlConfigT *config) { return false; } + // Empty the typed-character queue before the port arms its + // keyboard: a shutdown/re-init cycle must not hand the app + // characters typed during the previous run. + jlInputCharReset(); jlpInputInit(); gInitialized = true; diff --git a/src/core/input.c b/src/core/input.c index cae4168..10e3b82 100644 --- a/src/core/input.c +++ b/src/core/input.c @@ -8,6 +8,12 @@ // Mouse position and joystick axes are plain current state with no // edge predicates -- games that want deltas track the previous values // themselves. +// +// The typed-character queue (gCharQueue) is a separate path for text +// entry: ports push OS/firmware-translated ASCII via jlInputCharPush +// during jlpInputPoll, and apps pop with jlInputGetChar. The queue is +// a classic ring with one empty slot (head == tail means empty), so +// no element count is stored and push/pop touch a single index each. #include @@ -32,6 +38,14 @@ uint8_t gJoyButtonState[JOYSTICK_COUNT][JOY_BUTTON_COUNT]; uint8_t gJoyButtonPrev [JOYSTICK_COUNT][JOY_BUTTON_COUNT]; uint8_t gJoyDeadZone [JOYSTICK_COUNT]; +uint8_t gCharQueue [JL_CHAR_QUEUE_SIZE]; +uint8_t gCharQueueHead = 0; +uint8_t gCharQueueTail = 0; + +// Build-time check: the ring-index wrap masks with the size minus one, +// which only wraps correctly for a power-of-two size. +typedef int joey_charqueue_pow2_check[((JL_CHAR_QUEUE_SIZE & (JL_CHAR_QUEUE_SIZE - 1)) == 0) ? 1 : -1]; + #ifdef JOEYLIB_PLATFORM_IIGS extern void iigsInputSnapshot(void); @@ -50,6 +64,48 @@ typedef int joey_mousebtn_size_check [(sizeof(gMouseButtonState) == MOUSE_BUTTON typedef int joey_joybtn_size_check [(sizeof(gJoyButtonState) == JOYSTICK_COUNT * JOY_BUTTON_COUNT) ? 1 : -1]; #endif +void jlInputCharPush(uint8_t ch) { + uint8_t head; + uint8_t next; + + if (ch > 0x7E) { + return; + } + if (ch < 0x20 && + ch != JL_CHAR_BACKSPACE && ch != JL_CHAR_TAB && + ch != JL_CHAR_RETURN && ch != JL_CHAR_ESCAPE) { + return; + } + head = gCharQueueHead; + next = (uint8_t)((head + 1u) & (JL_CHAR_QUEUE_SIZE - 1u)); + if (next == gCharQueueTail) { + return; + } + gCharQueue[head] = ch; + gCharQueueHead = next; +} + + +void jlInputCharReset(void) { + gCharQueueHead = 0; + gCharQueueTail = 0; +} + + +int jlInputGetChar(void) { + uint8_t tail; + int ch; + + tail = gCharQueueTail; + if (tail == gCharQueueHead) { + return -1; + } + ch = gCharQueue[tail]; + gCharQueueTail = (uint8_t)((tail + 1u) & (JL_CHAR_QUEUE_SIZE - 1u)); + return ch; +} + + void jlInputPoll(void) { #ifdef JOEYLIB_PLATFORM_IIGS // One asm pass for: TTL decrement + key snapshot + mouse/joy @@ -77,6 +133,11 @@ void jlWaitForAnyKey(void) { jlInputPoll(); for (i = (int16_t)(KEY_NONE + 1); i < (int16_t)KEY_COUNT; i++) { if (jlKeyPressed((jlKeyE)i)) { + // The dismissing keystroke (and anything typed during + // the wait) was consumed by this wait, not typed at a + // text field -- drop it from the character queue so a + // following jlInputGetChar never sees a stray char. + jlInputCharReset(); return; } } diff --git a/src/core/inputInternal.h b/src/core/inputInternal.h index eb1d40e..d012871 100644 --- a/src/core/inputInternal.h +++ b/src/core/inputInternal.h @@ -37,6 +37,26 @@ extern uint8_t gJoyButtonPrev [JOYSTICK_COUNT][JOY_BUTTON_COUNT]; // with analog paddles (IIgs); ignored on digital-stick platforms. extern uint8_t gJoyDeadZone [JOYSTICK_COUNT]; +// Typed-character FIFO backing jlInputGetChar. Ports feed it with +// jlInputCharPush from jlpInputPoll; the pop side is jlInputGetChar. +// NOT interrupt-safe: ports whose keyboard handling runs at interrupt +// time (DOS INT 9, ST ikbd) buffer translated characters in their own +// volatile ring and drain it into this queue at poll time. +extern uint8_t gCharQueue [JL_CHAR_QUEUE_SIZE]; +extern uint8_t gCharQueueHead; +extern uint8_t gCharQueueTail; + +// Append one translated character to the typed-character queue. +// Filters to the documented set (printable 0x20..0x7E plus +// JL_CHAR_BACKSPACE/TAB/RETURN/ESCAPE); anything else is discarded, +// so ports may push every translated byte without pre-filtering. +// Drops the character when the queue is full (drop-newest). +void jlInputCharPush(uint8_t ch); + +// Empty the typed-character queue. Called by jlInit so a re-init +// never hands the app characters typed before/during a previous run. +void jlInputCharReset(void); + // (jlpJoystickReset hook -- called from jlJoystickReset to clear any auto- // disconnect state and arm a fresh center capture -- is declared with the // other platform-only services in src/core/port.h.) diff --git a/src/dos/input.c b/src/dos/input.c index bec31b8..45fc6f6 100644 --- a/src/dos/input.c +++ b/src/dos/input.c @@ -5,6 +5,17 @@ // gIsrState buffer, and sends EOI to the PIC. jlpInputPoll snapshots // gIsrState into gKeyState with interrupts disabled. // +// Typed characters: because the hook replaces the BIOS INT 9 handler +// outright (no chaining, no EOI from the BIOS), INT 16h never sees our +// keys, so the ISR translates make codes itself with a US-layout +// scancode->ASCII pair of tables (normal/shifted) plus caps-lock +// tracking, seeded from the BIOS shift-flag byte at 0040:0017. The +// translated bytes land in a private locked ring (gIsrCharQueue) that +// jlpInputPoll drains into the core typed-character queue. Keys held +// with Ctrl or Alt are not characters and are skipped. The hard-coded +// US layout matches the port's existing hard-coded scan map; KEYB +// layouts are not applied (they hook the BIOS handler we replace). +// // The two-buffer split is required for jlKeyPressed edge detection. // jlInputPoll does memcpy(gKeyPrev, gKeyState) *before* jlpInputPoll // runs, so whatever gKeyState holds at that moment becomes gKeyPrev. @@ -29,6 +40,7 @@ #include #include #include +#include #include "port.h" #include "inputInternal.h" @@ -45,6 +57,47 @@ #define SCAN_TABLE_SIZE 128 #define ISR_LOCK_SIZE 4096 +// Caps/num-lock make codes, and the BIOS keyboard shift-flag byte in +// the BIOS data area (0040:0017); bit 6 = caps-lock active, bit 5 = +// num-lock active. Read once at init to seed gIsrCapsLock/gIsrNumLock +// -- the BIOS can't update the byte after we take INT 9, so from then +// on the ISR tracks the toggles itself. +#define SCAN_CAPS_LOCK 0x3A +#define SCAN_NUM_LOCK 0x45 +#define BIOS_KB_FLAG_ADDR 0x417 +#define BIOS_KB_FLAG_CAPS 0x40 +#define BIOS_KB_FLAG_NUM 0x20 + +// Numeric keypad scan range (set 1). With num-lock active these type +// digits and '.'; the grey arrow cluster shares the codes but arrives +// behind an 0xE0 prefix, which the ISR tracks to keep them apart. +#define SCAN_KEYPAD_FIRST 0x47 +#define SCAN_KEYPAD_LAST 0x53 + +// AT keyboards bracket extended keys with fake-shift sequences for XT +// compatibility: 0xE0 0x2A / 0xE0 0xAA (fake left shift), and with +// right shift physically held, 0xE0 0xB6 / 0xE0 0x36 (fake right +// shift). All four decode to these codes after the break bit is +// masked and must be ignored, or the fakes corrupt the real shift +// state while an extended key is held. +#define SCAN_FAKE_LSHIFT 0x2A +#define SCAN_FAKE_RSHIFT 0x36 + +// Pause sends 0xE1 0x1D 0x45 / 0xE1 0x9D 0xC5 -- an 0xE1 prefix plus +// two payload bytes that would otherwise read as Ctrl and NumLock +// events. Swallow the payload to keep the tracked lock state honest. +#define SCAN_PAUSE_PREFIX 0xE1 +#define PAUSE_PAYLOAD_BYTES 2 + +// Extended two-byte keys that still type a character: keypad Enter +// (0xE0 0x1C) and keypad '/' (0xE0 0x35). +#define SCAN_ENTER 0x1C +#define SCAN_SLASH 0x35 + +// Private ISR->poll ring for translated characters. Power of two so +// the wrap is a mask; holds ISR_CHAR_QUEUE_SIZE - 1 pending bytes. +#define ISR_CHAR_QUEUE_SIZE 32 + // INT 33h mouse driver functions and button bits. #define MOUSE_INT 0x33 #define MOUSE_FN_RESET 0x0000 @@ -149,6 +202,59 @@ static const uint8_t gScanToKey[SCAN_TABLE_SIZE] = { [0x50] = KEY_DOWN, }; +// US-layout set-1 scancode -> ASCII, normal and shifted. Only make +// codes reach these; slots left at 0 produce no character. Letters +// are lowercase here -- the ISR flips to the shifted table when the +// effective shift (shift XOR caps, letters only) is active. +static const uint8_t gScanAsciiNormal[SCAN_TABLE_SIZE] = { + [0x01] = JL_CHAR_ESCAPE, + [0x02] = '1', [0x03] = '2', [0x04] = '3', [0x05] = '4', + [0x06] = '5', [0x07] = '6', [0x08] = '7', [0x09] = '8', + [0x0A] = '9', [0x0B] = '0', [0x0C] = '-', [0x0D] = '=', + [0x0E] = JL_CHAR_BACKSPACE, [0x0F] = JL_CHAR_TAB, + [0x10] = 'q', [0x11] = 'w', [0x12] = 'e', [0x13] = 'r', + [0x14] = 't', [0x15] = 'y', [0x16] = 'u', [0x17] = 'i', + [0x18] = 'o', [0x19] = 'p', [0x1A] = '[', [0x1B] = ']', + [0x1C] = JL_CHAR_RETURN, + [0x1E] = 'a', [0x1F] = 's', [0x20] = 'd', [0x21] = 'f', + [0x22] = 'g', [0x23] = 'h', [0x24] = 'j', [0x25] = 'k', + [0x26] = 'l', [0x27] = ';', [0x28] = '\'', [0x29] = '`', + [0x2B] = '\\', + [0x2C] = 'z', [0x2D] = 'x', [0x2E] = 'c', [0x2F] = 'v', + [0x30] = 'b', [0x31] = 'n', [0x32] = 'm', [0x33] = ',', + [0x34] = '.', [0x35] = '/', + [0x37] = '*', [0x39] = ' ', + [0x4A] = '-', [0x4E] = '+', +}; + +static const uint8_t gScanAsciiShift[SCAN_TABLE_SIZE] = { + [0x01] = JL_CHAR_ESCAPE, + [0x02] = '!', [0x03] = '@', [0x04] = '#', [0x05] = '$', + [0x06] = '%', [0x07] = '^', [0x08] = '&', [0x09] = '*', + [0x0A] = '(', [0x0B] = ')', [0x0C] = '_', [0x0D] = '+', + [0x0E] = JL_CHAR_BACKSPACE, [0x0F] = JL_CHAR_TAB, + [0x10] = 'Q', [0x11] = 'W', [0x12] = 'E', [0x13] = 'R', + [0x14] = 'T', [0x15] = 'Y', [0x16] = 'U', [0x17] = 'I', + [0x18] = 'O', [0x19] = 'P', [0x1A] = '{', [0x1B] = '}', + [0x1C] = JL_CHAR_RETURN, + [0x1E] = 'A', [0x1F] = 'S', [0x20] = 'D', [0x21] = 'F', + [0x22] = 'G', [0x23] = 'H', [0x24] = 'J', [0x25] = 'K', + [0x26] = 'L', [0x27] = ':', [0x28] = '"', [0x29] = '~', + [0x2B] = '|', + [0x2C] = 'Z', [0x2D] = 'X', [0x2E] = 'C', [0x2F] = 'V', + [0x30] = 'B', [0x31] = 'N', [0x32] = 'M', [0x33] = '<', + [0x34] = '>', [0x35] = '?', + [0x37] = '*', [0x39] = ' ', + [0x4A] = '-', [0x4E] = '+', +}; + +// Keypad digits/'.' typed when num-lock is active (0x47..0x53). The +// '-'/'+' slots stay 0: those keys type regardless of num-lock and +// live in the main tables instead. +static const uint8_t gKeypadAscii[SCAN_KEYPAD_LAST - SCAN_KEYPAD_FIRST + 1] = { + '7', '8', '9', 0, '4', '5', '6', 0, '1', '2', '3', '0', '.' +}; + static _go32_dpmi_seginfo gOldHandler; static _go32_dpmi_seginfo gNewHandler; static bool gHooked = false; @@ -156,6 +262,22 @@ static bool gHooked = false; // src/core/inputInternal.h for the full rationale. static volatile uint8_t gIsrState[KEY_COUNT]; +// ISR-tracked lock/prefix state. uint8_t (not bool) so the locked-data +// regions have known one-byte elements. +static volatile uint8_t gIsrExtended = 0; // last byte was 0xE0 +static volatile uint8_t gIsrE1Skip = 0; // Pause payload bytes left to swallow +static volatile uint8_t gIsrCapsLock = 0; +static volatile uint8_t gIsrCapsHeld = 0; // suppress typematic re-toggle +static volatile uint8_t gIsrNumLock = 0; +static volatile uint8_t gIsrNumHeld = 0; + +// Translated characters, ISR producer -> poll consumer. Single- +// producer single-consumer: the ISR only writes head + slots, the +// poll drain only writes tail, so no interrupt masking is needed. +static volatile uint8_t gIsrCharQueue[ISR_CHAR_QUEUE_SIZE]; +static volatile uint8_t gIsrCharHead = 0; +static volatile uint8_t gIsrCharTail = 0; + static bool gMousePresent = false; static bool gJoystickPresent = false; @@ -165,16 +287,99 @@ static void keyboardIsr(void) { uint8_t scan; uint8_t code; uint8_t key; + uint8_t extended; + uint8_t ch; + uint8_t next; bool isBreak; + bool useShift; scan = inportb(KB_DATA_PORT); - if (scan != SCAN_EXTENDED) { - isBreak = (scan & SCAN_BREAK_BIT) != 0; - code = (uint8_t)(scan & SCAN_CODE_MASK); - key = gScanToKey[code]; - if (key != KEY_NONE) { - gIsrState[key] = !isBreak; + // Pause's 0xE1-prefixed payload would otherwise read as Ctrl and + // NumLock events (0x1D 0x45 / 0x9D 0xC5) -- swallow it whole. + if (gIsrE1Skip != 0) { + gIsrE1Skip = (uint8_t)(gIsrE1Skip - 1); + outportb(PIC_CMD_PORT, PIC_EOI); + return; + } + if (scan == SCAN_PAUSE_PREFIX) { + gIsrE1Skip = PAUSE_PAYLOAD_BYTES; + outportb(PIC_CMD_PORT, PIC_EOI); + return; + } + + if (scan == SCAN_EXTENDED) { + gIsrExtended = 1; + outportb(PIC_CMD_PORT, PIC_EOI); + return; + } + extended = gIsrExtended; + gIsrExtended = 0; + + isBreak = (scan & SCAN_BREAK_BIT) != 0; + code = (uint8_t)(scan & SCAN_CODE_MASK); + + // Fake shifts bracket extended keys (arrows etc.) on AT keyboards; + // treating them as real shift makes would corrupt the live shift + // state while an arrow is held. Drop both variants entirely. + if (extended && (code == SCAN_FAKE_LSHIFT || code == SCAN_FAKE_RSHIFT)) { + outportb(PIC_CMD_PORT, PIC_EOI); + return; + } + + key = gScanToKey[code]; + if (key != KEY_NONE) { + gIsrState[key] = !isBreak; + } + + if (!isBreak) { + // Lock-key toggles fire once per press; typematic repeat of a + // held lock key must not flap the state (gIsr*Held mirrors the + // BIOS's held-bit for exactly this). + if (code == SCAN_CAPS_LOCK) { + if (!gIsrCapsHeld) { + gIsrCapsLock = (uint8_t)!gIsrCapsLock; + } + gIsrCapsHeld = 1; + } else if (code == SCAN_NUM_LOCK) { + if (!gIsrNumHeld) { + gIsrNumLock = (uint8_t)!gIsrNumLock; + } + gIsrNumHeld = 1; + } else if (!gIsrState[KEY_LCTRL] && !gIsrState[KEY_LALT]) { + // Typed-character translation. Ctrl/Alt chords are + // commands, not text, so they produce no character. + ch = 0; + if (extended) { + // Of the 0xE0 pairs only keypad Enter and keypad '/' + // type; the rest (arrows, Home/End, ...) do not. + if (code == SCAN_ENTER || code == SCAN_SLASH) { + ch = gScanAsciiNormal[code]; + } + } else if (code >= SCAN_KEYPAD_FIRST && code <= SCAN_KEYPAD_LAST && + gIsrNumLock && gKeypadAscii[code - SCAN_KEYPAD_FIRST] != 0) { + ch = gKeypadAscii[code - SCAN_KEYPAD_FIRST]; + } else { + useShift = gIsrState[KEY_LSHIFT] || gIsrState[KEY_RSHIFT]; + if (gIsrCapsLock && + gScanAsciiNormal[code] >= 'a' && gScanAsciiNormal[code] <= 'z') { + useShift = !useShift; + } + ch = useShift ? gScanAsciiShift[code] : gScanAsciiNormal[code]; + } + if (ch != 0) { + next = (uint8_t)((gIsrCharHead + 1u) & (ISR_CHAR_QUEUE_SIZE - 1u)); + if (next != gIsrCharTail) { + gIsrCharQueue[gIsrCharHead] = ch; + gIsrCharHead = next; + } + } + } + } else { + if (code == SCAN_CAPS_LOCK) { + gIsrCapsHeld = 0; + } else if (code == SCAN_NUM_LOCK) { + gIsrNumHeld = 0; } } @@ -330,13 +535,39 @@ void jlpJoystickReset(jlJoystickE js) { void jlpInputInit(void) { + uint8_t kbFlags; + memset(gKeyState, 0, sizeof(gKeyState)); memset(gKeyPrev, 0, sizeof(gKeyPrev)); memset((void *)gIsrState, 0, sizeof(gIsrState)); + // Seed the lock toggles from the BIOS shift-flag byte -- the last + // state the BIOS saw before we take INT 9 away from it. + kbFlags = _farpeekb(_dos_ds, BIOS_KB_FLAG_ADDR); + gIsrCapsLock = (kbFlags & BIOS_KB_FLAG_CAPS) != 0; + gIsrNumLock = (kbFlags & BIOS_KB_FLAG_NUM) != 0; + gIsrExtended = 0; + gIsrE1Skip = 0; + gIsrCapsHeld = 0; + gIsrNumHeld = 0; + gIsrCharHead = 0; + gIsrCharTail = 0; + _go32_dpmi_lock_code(keyboardIsr, ISR_LOCK_SIZE); - _go32_dpmi_lock_data((void *)gScanToKey, sizeof(gScanToKey)); - _go32_dpmi_lock_data((void *)gIsrState, sizeof(gIsrState)); + _go32_dpmi_lock_data((void *)gScanToKey, sizeof(gScanToKey)); + _go32_dpmi_lock_data((void *)gIsrState, sizeof(gIsrState)); + _go32_dpmi_lock_data((void *)gScanAsciiNormal, sizeof(gScanAsciiNormal)); + _go32_dpmi_lock_data((void *)gScanAsciiShift, sizeof(gScanAsciiShift)); + _go32_dpmi_lock_data((void *)gKeypadAscii, sizeof(gKeypadAscii)); + _go32_dpmi_lock_data((void *)gIsrCharQueue, sizeof(gIsrCharQueue)); + _go32_dpmi_lock_data((void *)&gIsrExtended, sizeof(gIsrExtended)); + _go32_dpmi_lock_data((void *)&gIsrE1Skip, sizeof(gIsrE1Skip)); + _go32_dpmi_lock_data((void *)&gIsrCapsLock, sizeof(gIsrCapsLock)); + _go32_dpmi_lock_data((void *)&gIsrCapsHeld, sizeof(gIsrCapsHeld)); + _go32_dpmi_lock_data((void *)&gIsrNumLock, sizeof(gIsrNumLock)); + _go32_dpmi_lock_data((void *)&gIsrNumHeld, sizeof(gIsrNumHeld)); + _go32_dpmi_lock_data((void *)&gIsrCharHead, sizeof(gIsrCharHead)); + _go32_dpmi_lock_data((void *)&gIsrCharTail, sizeof(gIsrCharTail)); _go32_dpmi_get_protected_mode_interrupt_vector(9, &gOldHandler); @@ -354,9 +585,22 @@ void jlpInputInit(void) { void jlpInputPoll(void) { + uint8_t tail; + disable(); memcpy(gKeyState, (const void *)gIsrState, sizeof(gKeyState)); enable(); + + // Drain ISR-translated characters into the core queue. Single- + // consumer side of the SPSC ring: only gIsrCharTail is written + // here, so the ISR may keep producing concurrently. + tail = gIsrCharTail; + while (tail != gIsrCharHead) { + jlInputCharPush(gIsrCharQueue[tail]); + tail = (uint8_t)((tail + 1u) & (ISR_CHAR_QUEUE_SIZE - 1u)); + } + gIsrCharTail = tail; + mousePoll(); joystickPoll(); } diff --git a/src/iigs/input.c b/src/iigs/input.c index c83091d..ab1998a 100644 --- a/src/iigs/input.c +++ b/src/iigs/input.c @@ -6,6 +6,11 @@ // uninitialized corrupted KEGS' emulation state. Softswitches have no // such dependency: they are live memory-mapped hardware. // +// The typed-character queue is fed from the same $C000 drain: the ADB +// firmware translates shift/caps/layout before the byte reaches the +// strobe register, so each drained event doubles as a typed character +// (jlInputCharPush) with no extra keymap work. +// // Tradeoff: $C000 reports the *last* key pressed, not a per-key matrix. // Holding multiple non-modifier keys simultaneously cannot be observed; // the demo and any game using this port sees one typable key at a time, @@ -455,6 +460,19 @@ void jlpInputPoll(void) { if (key != KEY_NONE) { gKeyState[key] = true; } + // Typed-character path: the ADB firmware already applies + // shift/caps and the keyboard layout, so the strobe byte IS + // the translated character. The Delete key (0x7F, the IIgs + // backspace key) is normalized to JL_CHAR_BACKSPACE. A raw + // 0x08 is the LEFT-ARROW key (Apple II heritage), and arrows + // are not characters -- suppress it so cursor movement never + // types a backspace; the other arrows' codes are stopped by + // jlInputCharPush's filter anyway. + if (ascii == ASCII_DELETE) { + jlInputCharPush(JL_CHAR_BACKSPACE); + } else if (ascii != ASCII_LEFT) { + jlInputCharPush(ascii); + } (void)*IIGS_KBDSTRB; } diff --git a/src/x68000/hal.c b/src/x68000/hal.c index 6232020..25862e3 100644 --- a/src/x68000/hal.c +++ b/src/x68000/hal.c @@ -46,6 +46,10 @@ // _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about. #define X68K_KEYGROUP_COUNT 15 +// Cap on the per-poll IOCS type-ahead drain for the typed-character +// queue -- purely defensive, so a wedged _B_KEYSNS can't spin forever. +#define X68K_KEY_DRAIN_GUARD 32u + // MC68901 MFP general-purpose I/O. Bit 4 is the CRTC's V-DISP line. #define X68K_MFP_GPIP ((volatile uint8_t *)0xE88001L) #define X68K_GPIP_VDISP 0x10u @@ -403,6 +407,17 @@ void jlpPresent(const jlSurfaceT *src) { // to install, no vector to take over and no packet state machine -- unlike the // ST, which has to replace the TOS ikbdsys vector and decode IKBD packets. void jlpInputInit(void) { + uint16_t drain; + + // Empty the IOCS type-ahead buffer so keys typed at the Human68k + // prompt (including the Enter that launched us) don't surface as + // the app's first typed characters. + for (drain = 0; drain < X68K_KEY_DRAIN_GUARD; drain++) { + if (_iocs_b_keysns() == 0) { + break; + } + (void)_iocs_b_keyinp(); + } } @@ -458,22 +473,37 @@ void jlpInputPoll(void) { uint8_t groups[X68K_KEYGROUP_COUNT]; uint16_t group; uint16_t key; + uint16_t drain; uint8_t scan; for (group = 0; group < X68K_KEYGROUP_COUNT; group++) { groups[group] = (uint8_t)(_iocs_bitsns((int)group) & 0xFF); } + // Write both states each poll: the bitmap is live key-DOWN data, + // so a released key must drop back to 0 here (set-only left keys + // latched down forever, hanging any second jlWaitForAnyKey). for (key = 1; key < KEY_COUNT; key++) { scan = kScanForKey[key]; if (scan == 0u) { continue; } group = (uint16_t)(scan >> 3); - if (group < X68K_KEYGROUP_COUNT && - (groups[group] & (uint8_t)(1u << (scan & 7u))) != 0u) { - gKeyState[key] = 1u; + if (group < X68K_KEYGROUP_COUNT) { + gKeyState[key] = (uint8_t)((groups[group] >> (scan & 7u)) & 1u); } } + + // Typed-character path: the IOCS keyboard interrupt queues + // translated characters (shift/caps/layout applied) independently + // of the bitmap above; _B_KEYINP pops (scancode << 8) | char. + // Non-character keys carry 0 in the low byte and are dropped by + // jlInputCharPush's filter. + for (drain = 0; drain < X68K_KEY_DRAIN_GUARD; drain++) { + if (_iocs_b_keysns() == 0) { + break; + } + jlInputCharPush((uint8_t)(_iocs_b_keyinp() & 0xFFu)); + } } diff --git a/tests/host/inputHost.c b/tests/host/inputHost.c new file mode 100644 index 0000000..bf5e6ed --- /dev/null +++ b/tests/host/inputHost.c @@ -0,0 +1,211 @@ +// inputHost.c - Host-cc harness for the typed-character queue +// (jlInputGetChar / jlInputCharPush in src/core/input.c). Exercises +// FIFO ordering, the character filter, drop-newest overflow, reset +// semantics, and the jlInputPoll refill path via a mocked +// jlpInputPoll. +// +// Built by scripts/check-input.sh with -DJOEYLIB_PLATFORM_BLANK so +// port.h routes jlpInputPoll/jlpJoystickReset to the mocks below. + +#include +#include +#include +#include + +#include "joey/input.h" +#include "inputInternal.h" +#include "port.h" + +// Characters the mocked jlpInputPoll delivers on its next call +// (one-shot; cleared after delivery). +static const uint8_t *gMockChars = NULL; +static int gMockCount = 0; + +// Prototypes (harness-local, alphabetical). +static bool checkEmpty(const char *name); +static bool popExpect(const char *name, const uint8_t *expect, int count); +static void pushAll(const uint8_t *chars, int count); +static bool runFilter(void); +static bool runInterleave(void); +static bool runOrder(void); +static bool runOverflow(void); +static bool runPollRefill(void); +static bool runReset(void); + + +void jlpInputPoll(void) { + int i; + + for (i = 0; i < gMockCount; i++) { + jlInputCharPush(gMockChars[i]); + } + gMockCount = 0; +} + + +void jlpJoystickReset(jlJoystickE js) { + (void)js; +} + + +static bool checkEmpty(const char *name) { + int got; + + got = jlInputGetChar(); + if (got != -1) { + printf(" %s: expected empty (-1), got %d\n", name, got); + return false; + } + return true; +} + + +static bool popExpect(const char *name, const uint8_t *expect, int count) { + int got; + int i; + + for (i = 0; i < count; i++) { + got = jlInputGetChar(); + if (got != (int)expect[i]) { + printf(" %s: index %d: expected 0x%02X, got %d\n", + name, i, expect[i], got); + return false; + } + } + return checkEmpty(name); +} + + +static void pushAll(const uint8_t *chars, int count) { + int i; + + for (i = 0; i < count; i++) { + jlInputCharPush(chars[i]); + } +} + + +// Control characters other than BS/TAB/CR/ESC, plus DEL and 8-bit +// values, must never surface; the documented four and the printable +// range must pass through unchanged. +static bool runFilter(void) { + static const uint8_t rejected[] = { 0x00, 0x01, 0x07, 0x0A, 0x0C, 0x1F, 0x7F }; + static const uint8_t accepted[] = { + JL_CHAR_BACKSPACE, JL_CHAR_TAB, JL_CHAR_RETURN, JL_CHAR_ESCAPE, + 0x20, '0', 'Z', 'z', '~', 0x7E + }; + bool ok; + + jlInputCharReset(); + pushAll(rejected, (int)sizeof(rejected)); + ok = checkEmpty("filter-rejects"); + pushAll(accepted, (int)sizeof(accepted)); + ok &= popExpect("filter-accepts", accepted, (int)sizeof(accepted)); + printf(" filter: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +// Pops interleaved with pushes keep FIFO order across the wrap point. +static bool runInterleave(void) { + static const uint8_t first[] = { 'a', 'b', 'c' }; + static const uint8_t second[] = { 'd', 'e' }; + static const uint8_t rest[] = { 'b', 'c', 'd', 'e' }; + bool ok; + int got; + + jlInputCharReset(); + pushAll(first, (int)sizeof(first)); + got = jlInputGetChar(); + ok = (got == 'a'); + if (!ok) { + printf(" interleave: expected 'a', got %d\n", got); + } + pushAll(second, (int)sizeof(second)); + ok &= popExpect("interleave", rest, (int)sizeof(rest)); + printf(" interleave: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +// The definition-of-done string from the RetroNet handoff must come +// back byte-for-byte. +static bool runOrder(void) { + static const char target[] = "192.168.1.10:6510"; + bool ok; + + jlInputCharReset(); + pushAll((const uint8_t *)target, (int)(sizeof(target) - 1)); + ok = popExpect("order", (const uint8_t *)target, (int)(sizeof(target) - 1)); + printf(" order: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +// Overflow drops the NEWEST characters: exactly JL_CHAR_QUEUE_SIZE - 1 +// survive, and they are the first ones pushed. +static bool runOverflow(void) { + uint8_t burst[JL_CHAR_QUEUE_SIZE + 8]; + bool ok; + int i; + + jlInputCharReset(); + for (i = 0; i < (int)sizeof(burst); i++) { + burst[i] = (uint8_t)('!' + (i % 0x5E)); + } + pushAll(burst, (int)sizeof(burst)); + ok = popExpect("overflow", burst, JL_CHAR_QUEUE_SIZE - 1); + printf(" overflow: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +// The real app-facing path: jlInputPoll runs the port poll, which +// pushes translated characters; jlInputGetChar then pops them. +static bool runPollRefill(void) { + static const uint8_t typed[] = { 'H', 'i', '!', JL_CHAR_RETURN }; + bool ok; + + jlInputCharReset(); + gMockChars = typed; + gMockCount = (int)sizeof(typed); + jlInputPoll(); + ok = popExpect("poll-refill", typed, (int)sizeof(typed)); + jlInputPoll(); + ok &= checkEmpty("poll-refill-once"); + printf(" poll-refill: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +// jlInputCharReset empties the queue (the jlInit path). +static bool runReset(void) { + static const uint8_t some[] = { 'x', 'y', 'z' }; + bool ok; + + jlInputCharReset(); + pushAll(some, (int)sizeof(some)); + jlInputCharReset(); + ok = checkEmpty("reset"); + printf(" reset: %s\n", ok ? "PASS" : "FAIL"); + return ok; +} + + +int main(void) { + bool ok = true; + + printf("inputHost: typed-character queue scenarios\n"); + ok &= runOrder(); + ok &= runFilter(); + ok &= runOverflow(); + ok &= runInterleave(); + ok &= runReset(); + ok &= runPollRefill(); + if (!ok) { + printf("inputHost: FAIL\n"); + return 1; + } + printf("inputHost: PASS\n"); + return 0; +}