feat: run the full QB45 EXAMPLES suite (23 programs) oracle-verified
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:13:51 +0000 (23:13 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:13:51 +0000 (23:13 +0300)
Implements every language feature the classic QuickBASIC 4.5 example
programs (qbasicapps/QB45/EXAMPLES) exercise, verified pixel- and
byte-exact against QB.EXE /RUN under DOSBox (xdotool-driven key
scripts, DOSBox Ctrl-F5 captures as ground truth):

- Records: TYPE/END TYPE, fixed STRING*n fields, member access
  (x.field), arrays of records, LEN(recvar) = record byte size
- Files: OPEN FOR BINARY/RANDOM with LEN=n and bare channel numbers,
  FIELD, typed GET/PUT, SEEK statement/function, LOF, LOC, FREEFILE
  (bare form too), INPUT$(n,#ch), DOS-absolute paths resolve against
  the emulated drive root, INPUT$/GET/SEEK on text channels via lazy
  byte handle
- Statements: EXIT FOR/DO/SUB/FUNCTION, STATIC procedures (SHARED
  names resolve to module scope before static locals), LINE INPUT
  "prompt",var, MID$ assignment, PRINT USING (numeric field width =
  spec char count), NAME, KILL with DOS wildcards, SHELL "DIR > file"
  (WHEREIS.BAS-compatible 8.3 listing), ERR error codes, COMMAND$,
  INSTR, &H&/&O& long-suffixed literals, OPEN COM1 null-modem
- Graphics: WINDOW / WINDOW SCREEN (plain form is Cartesian),
  graphics VIEW with border that survives plain CLS, PMAP, DRAW macro
  language (U/D/L/R/E/F/G/H, M, B, N, C, S, TA, P), PALETTE /
  PALETTE USING, CLS 0/1/2, full 5-slot LOCATE, LINE -STEP and
  16-bit dash style, pattern-string PAINT, PRESET; CIRCLE under
  WINDOW keeps y-radius visually round (mode aspect, not y-scale);
  PUT under a Cartesian WINDOW anchors the region's logical top-left;
  same-mode SCREEN page flips preserve video memory
- Input: ALT+letter reaches programs as CHR$(0)+BIOS scancode
  (KeyboardQueue VK_ALT_BASE scheme)

Verified: BALLPSET, BALLXOR, BAR, CAL, CHECK, COLORS, CRLF, CUBE,
EDPAT, ENTAB, FILERR, FLPT, INDEX, MANDEL, PALETTE, PLOTTER,
QLBDUMP, SEARCH, SINEWAVE, STRTONUM, TERMINAL, TOKEN, WHEREIS —
screenshots match the oracle (many pixel-identical), file outputs
(CRLF TEST.BAK, ENTAB OUT.TXT, INDEX STOCK.DAT) byte-identical.
TERMINAL also hits "Device timeout" in DOSBox itself (no COM1) —
environment limit, not a program bug. 93/93 tests green.
Documentation/language/index.org updated with all new statements.

12 files changed:
Documentation/language/index.org
src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java
src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java

index 14b31ee..a42e845 100644 (file)
@@ -122,24 +122,28 @@ DIM names$(20) AS STRING     ' AS type form
 | =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count; default foreground becomes the mode's highest attribute (1/3/15), except SCREEN 13 where real QBasic defaults to 15; background black |
 | =PSET (x, y)[, color]= | One pixel; default color = current text foreground |
 | =POINT(x, y)= | Function: palette index of a pixel |
-| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original; start point may be omitted (=LINE -(x2,y2)= draws from the last point referenced) |
+| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original; start point may be omitted (=LINE -(x2,y2)= draws from the last point referenced); =STEP= makes an endpoint relative (=LINE -STEP(dx,dy)=); 4th argument is a 16-bit dash style, bit 15 plotted first |
 | =LINE …, , B= / =BF= | Rectangle outline / filled rectangle (inclusive corners) |
-| =CIRCLE (x,y), r[, color][,,,aspect]= | Ellipse via aspect; arcs (start/end angles) *not yet* |
-| =PAINT (x,y)[, fill[, border]]= | Flood fill: paints over any color up to the border (border defaults to fill) |
+| =CIRCLE (x,y), r[, color][,,,aspect]= | Ellipse via aspect; arcs (start/end angles) *not yet*; under =WINDOW= the radius follows the x-scale and the y-radius stays visually round (mode aspect), matching QB45 |
+| =PAINT (x,y)[, fill[, border]]= | Flood fill: paints over any color up to the border (border defaults to fill); =fill= may be a =CHR$= pattern string (tiling paint, EDPAT-style) |
 | =GET (x1,y1)-(x2,y2), array= | Captures a screen rectangle into a variable |
-| =PUT (x,y), array[, action]= | Blits a captured rectangle back; action is PSET, XOR, OR or AND |
+| =PUT (x,y), array[, action]= | Blits a captured rectangle back; action is PSET, XOR, OR, AND or PRESET; under a Cartesian =WINDOW= the anchor is the region's logical top-left, so the image extends upward on screen (BALLPSET resting-ball semantics) |
 | =PCOPY source, target= | Copies one video page onto another |
-| =PALETTE= via =OUT &H3C8/&H3C9= | DAC palette ports are modeled; other ports ignored |
+| =PALETTE attr, color= / =PALETTE USING array(0)= | Sets one DAC entry / all entries from an array (=-1= keeps the entry); OUT &H3C8/&H3C9 ports are modeled too; other ports ignored |
+| =WINDOW (x1,y1)-(x2,y2)= / =WINDOW SCREEN …= | Logical coordinate window over the viewport; plain form is Cartesian (y grows up), =SCREEN= keeps y down; =PMAP(x,0..3)= converts between logical and physical |
+| =VIEW (x1,y1)-(x2,y2)[, fill[, border]]= | Graphics viewport: clipping region with optional fill/border ring; plain =CLS= clears only the interior, the border survives |
+| =DRAW "cmds"= | Graphics macro language: U/D/L/R/E/F/G/H, M x,y, B (blank), N, C color, S scale, TA angle, P paint,border |
+| =CLS [0/1/2]= | 0 = text+graphics, 1 = graphics viewport only, 2 = text only; bare =CLS= clears to background and homes the cursor |
 
 ** Text
 
 | Statement | Notes |
 |-----------+-------|
 | =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline |
-| =LOCATE row[, col]= | 1-based cursor position; omitted col keeps the current column |
+| =PRINT USING fmt$; expr, …= | QB45 format string: =!=, =&=, =\…\= string fields; =#= digits, =.= radix, =$$= / =**$= etc.; numeric field width equals the spec's character count |
+| =LOCATE row[, col[, cursor[, start, stop]]]= | 1-based cursor position; =cursor= 0/1 hides/shows it, =start/stop= set the scanline range |
 | =COLOR fg[, bg]= | Text colors (palette indices) |
 | =WIDTH cols[, rows]= | Text grid: 80x25 (default), 40x25 or 80x50 in =SCREEN 0=; 40 columns render double-wide glyphs, 50 rows swap to the 8x8 font; clears the screen |
-| =CLS= | Clears to background, homes cursor |
 | =VIEW PRINT top TO bottom= | Scroll region: printing and scrolling confined to the band; bare =VIEW PRINT= restores the full screen |
 | =CSRLIN=, =POS(0)= | Functions: cursor row / column |
 
@@ -153,6 +157,7 @@ DIM names$(20) AS STRING     ' AS type form
 | =DO [WHILE/UNTIL c] … LOOP [WHILE/UNTIL c]= | Condition on either end |
 | =SELECT CASE x … CASE a, b … CASE ELSE … END SELECT= | Equality cases (=CASE IS=, =CASE a TO b= not yet) |
 | =GOTO label= | Procedure-wide label scope; may jump INTO a label nested inside IF/FOR/DO bodies (headers are not re-evaluated, loops resume with current variable values) |
+| =EXIT FOR= / =EXIT DO= / =EXIT SUB= / =EXIT FUNCTION= | Leaves the innermost matching construct immediately |
 | =GOSUB label= / =RETURN= | Nested calls OK; the label may live in an enclosing block of the same procedure — RETURN rebuilds the nested origin and loops keep iterating. Not supported: GOSUB to a label nested inside a block statement |
 | =END=, =SYSTEM= | Both end the program |
 
@@ -192,22 +197,32 @@ END FUNCTION
   plain grouping.
 - SUBs do *not* see the caller's variables (classic scoping), except
   names declared =DIM SHARED=.
+- =SUB name (…) STATIC= / =FUNCTION name (…) STATIC= keeps local
+  variables across calls in procedure-private storage; =SHARED= names
+  still resolve to module scope first.
 
 ** Data, files, errors, misc
 
 | Statement | Notes |
 |-----------+-------|
 | =DATA v, …= / =READ var, …= | DATA is module-wide, collected in program order; running out raises *Out of DATA* |
+| =TYPE name … END TYPE= / =DIM x AS type= | Record types: fixed =STRING * n= fields, member access =x.field=, arrays of records; =LEN(recvar)= is the record byte size |
 | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= |
-| =OPEN path FOR INPUT AS #n= | Text files; DOS paths resolve relative to the =.bas= file (or the =CHDIR= state): backslash separators and case-insensitive components like the DOS filesystem |
+| =OPEN path FOR INPUT AS #n= | Text files; DOS paths resolve relative to the =.bas= file (or the =CHDIR= state): backslash separators and case-insensitive components like the DOS filesystem; a leading =\dir\file= resolves against the emulated drive root |
 | =OPEN path FOR OUTPUT/APPEND AS #n=, =PRINT #n, ...= | Writing; OUTPUT truncates, APPEND adds at end; =PRINT #= uses console formatting with CRLF line endings |
+| =OPEN path FOR BINARY/RANDOM [LEN=n] AS #n= | Byte-addressable files; =GET #n, pos, var= / =PUT #n, pos, var= read/write whole values or records; =FIELD #n, w AS f$, …= overlays fixed strings on the record buffer; =SEEK #n, p= / =SEEK(n)= / =LOF(n)= / =LOC(n)= work |
+| =OPEN "COM1:…"= | Serial channel: emulated null modem (input never arrives, output accepted); TERMINAL.BAS runs but never receives |
 | =CHDIR path$=, =MKDIR path$= | Current-directory state and directory creation; =MKDIR= on an existing directory raises *Path/file access error* like real QBasic |
 | =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= |
+| =INPUT$(n[, #ch])= | Reads exactly n bytes (blocking); file form reads raw bytes from a channel; extended keys arrive as =CHR$(0)= + scancode, ALT+letter included |
 | =INPUT [;]["prompt"{;|,}] var, …= | Console line input: prompt echoed (="; "= adds =? =), typed line echo with Backspace editing, items comma-delimited like =INPUT #= |
-| =LINE INPUT #n, var$= | Whole line |
+| =LINE INPUT ["prompt",] var$= / =LINE INPUT #n, var$= | Whole line, console or file |
+| =MID$(s$, i[, n]) = repl$= | In-place substring replacement statement |
+| =NAME old$ AS new$= / =KILL spec$= | Rename / delete; =KILL= accepts DOS wildcards (=TWH.*=) |
+| =SHELL "DIR … > file"= | Directory listings redirected to a file, in the DOS 8.3 =DIR= format =WHEREIS.BAS= parses; other =SHELL= commands raise *File not found* |
 | =EOF(n)= | True at end of channel |
 | =CLOSE [#n]= | Bare form closes all |
-| =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping |
+| =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping; =ERR= holds the QB45 error code (=*File not found*= is 53, etc.) |
 | =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump |
 | =SOUND freq, duration= | Audible square wave (PC-speaker timbre) in GUI mode, one-note queue at 18.2 ticks/s; timing-only when headless or no audio device |
 | =BEEP= | Desugars to =SOUND 800, 4.55= (the classic ~1/4 s beep) |
@@ -263,12 +278,15 @@ Semantics worth knowing:
 | =STR$(n)=, =VAL(s$)= | number ↔ string; =VAL= parses the longest numeric prefix (junk tail ignored, junk start is 0) and understands =&H…= / =&O…= with the literal's signed semantics (=VAL("&HFFFD")= is -3) |
 | =HEX$(n)= | uppercase hex, no prefix; INTEGER-range negatives print as 16-bit two's complement (=-3= becomes ="FFFD") |
 | =PEEK(addr)= | byte at DEF SEG:addr in the emulated machine RAM |
-| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) |
+| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based); =LEN(recvar)= of a TYPE variable is the record byte size |
+| =INSTR([start,] hay$, needle$)= | 1-based substring search, 0 when absent; case-sensitive like QB45 |
 | =UCASE$=, =LCASE$= | case conversion |
 | =LTRIM$=, =RTRIM$= | strip spaces |
 | =SPACE$(n)= | string of n spaces |
+| =COMMAND$= | text after the program name on the DOS command line (settable via the launcher's argument) |
 | =INKEY$= | one key, non-blocking; =""= when idle |
 | =INPUT$(n)= | read up to n input bytes (extended keys arrive as =CHR$(0)= + scancode) |
+| =FREEFILE= | lowest free channel number (1-255); works with or without parentheses |
 | =EOF(n)= | -1 at end of file channel |
 
 * PRINT formatting
index e3d008f..dc91ac9 100644 (file)
@@ -25,8 +25,43 @@ public final class Ast {
     public record PcopyStmt(Expr sourcePage, Expr targetPage, int line) implements Stmt {
     }
 
-    /** {@code LOCATE row[, col]} — a null col keeps the current column. */
-    public record LocateStmt(Expr row, Expr col, int line) implements Stmt {
+    /** {@code LOCATE [row][, [col][, [cursor][, [start][, stop]]]]} — null slot = omitted. */
+    public record LocateStmt(Expr row, Expr col, Expr cursor, Expr cursorStart,
+                             Expr cursorStop, int line) implements Stmt {
+    }
+
+    /** {@code EXIT FOR|DO|SUB|FUNCTION} — leaves the innermost matching construct. */
+    public record ExitStmt(String what, int line) implements Stmt {
+    }
+
+    /**
+     * {@code STATIC name, name(i), ...} inside a procedure body: the named
+     * variables persist across calls of that procedure.
+     */
+    public record StaticStmt(List<ReadTarget> names, int line) implements Stmt {
+    }
+
+    /**
+     * {@code SHARED name[()][ AS type], ...} inside a procedure body: the
+     * named variables resolve against the module-level scope. Empty bounds
+     * mark an array; the type is null unless an AS clause was written.
+     */
+    public record SharedStmt(List<SharedEntry> entries, int line) implements Stmt {
+    }
+
+    /** One SHARED entry: a name, whether it is an array, and its AS type. */
+    public record SharedEntry(String name, boolean array, String type) {
+    }
+
+    /**
+     * {@code TYPE name ... END TYPE} — a user-defined record type. Field
+     * types: INTEGER, LONG, SINGLE, DOUBLE, STRING * n, or another TYPE.
+     */
+    public record TypeDefStmt(String name, List<TypeField> fields, int line) implements Stmt {
+    }
+
+    /** One field of a TYPE: name, type name, fixed string length (0 = not STRING*n). */
+    public record TypeField(String name, String type, int stringLength) {
     }
 
     /**
@@ -50,13 +85,19 @@ public final class Ast {
      * {@code newline} is false when the statement ends with ; or ,.
      * {@code channel} is non-null for {@code PRINT #n, ...} (file output).
      */
-    public record PrintStmt(Expr channel, List<Item> items, boolean newline, int line) implements Stmt {
+    public record PrintStmt(Expr channel, List<Item> items, boolean newline, int line,
+                            Expr usingFormat) implements Stmt {
         public record Item(Expr expr, char separatorAfter) {
         }
+
+        public PrintStmt(final Expr channel, final List<Item> items, final boolean newline,
+                         final int line) {
+            this(channel, items, newline, line, null);
+        }
     }
 
-    /** {@code CLS} */
-    public record ClsStmt(int line) implements Stmt {
+    /** {@code CLS [0|1|2]} — 1 clears the graphics viewport, 2 the text area. */
+    public record ClsStmt(Expr what, int line) implements Stmt {
     }
 
     /**
@@ -66,8 +107,69 @@ public final class Ast {
     public record RandomizeStmt(Expr seed, int line) implements Stmt {
     }
 
-    /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */
-    public record OpenStmt(Expr path, String mode, Expr channel, int line) implements Stmt {
+    /** {@code OPEN path FOR INPUT|OUTPUT|APPEND|BINARY|RANDOM AS [#]channel [LEN = n]}. */
+    public record OpenStmt(Expr path, String mode, Expr channel, Expr recordLength, int line) implements Stmt {
+    }
+
+    /** {@code NAME oldPath AS newPath} — renames a file. */
+    public record NameStmt(Expr oldPath, Expr newPath, int line) implements Stmt {
+    }
+
+    /** {@code KILL pathSpec} — deletes files; DOS wildcards * and ? work. */
+    public record KillStmt(Expr pathSpec, int line) implements Stmt {
+    }
+
+    /** {@code SHELL command$} — runs a DOS command (a small built-in set). */
+    public record ShellStmt(Expr command, int line) implements Stmt {
+    }
+
+    /**
+     * {@code SEEK #channel, position} — moves the file pointer of a
+     * BINARY/RANDOM channel; position is 1-based.
+     */
+    public record SeekStmt(Expr channel, Expr position, int line) implements Stmt {
+    }
+
+    /**
+     * {@code GET #channel, [position], variable} — file form: reads one
+     * record/bytes into a (possibly TYPEd) variable. Position is a record
+     * number for RANDOM files and a 1-based byte position for BINARY.
+     */
+    public record GetFileStmt(Expr channel, Expr position, ReadTarget target, int line) implements Stmt {
+    }
+
+    /** {@code PUT #channel, [position], variable} — file form: writes one record. */
+    public record PutFileStmt(Expr channel, Expr position, ReadTarget target, int line) implements Stmt {
+    }
+
+    /**
+     * {@code PALETTE [color, newColor | USING array[(i)]]} — null arguments
+     * = the USING form; a null array means "restore default palette".
+     */
+    public record PaletteStmt(Expr color, Expr newColor, String usingArray,
+                              List<Expr> usingIndices, int line) implements Stmt {
+    }
+
+    /** {@code DRAW macro$} — graphics macro language (U/D/L/R/E/F/G/H/M/B/N/C/P/TA/…). */
+    public record DrawStmt(Expr macro, int line) implements Stmt {
+    }
+
+    /**
+     * {@code VIEW (x1, y1)-(x2, y2)[, [fill][, border]]} — the graphics
+     * viewport: drawing is clipped to it and coordinates become relative.
+     */
+    public record ViewportStmt(Expr x1, Expr y1, Expr x2, Expr y2, Expr fill,
+                               Expr border, int line) implements Stmt {
+    }
+
+    /** {@code WINDOW [SCREEN] (x1, y1)-(x2, y2)} — logical coordinate mapping.
+     *  screenCoords = WINDOW SCREEN (y1 on top); default is Cartesian y-up. */
+    public record WindowStmt(Expr x1, Expr y1, Expr x2, Expr y2, boolean screenCoords,
+                             int line) implements Stmt {
+        public WindowStmt(final Expr x1, final Expr y1, final Expr x2, final Expr y2,
+                          final int line) {
+            this(x1, y1, x2, y2, false, line);
+        }
     }
 
     /** {@code CHDIR path$} — changes the current directory. */
@@ -78,14 +180,21 @@ public final class Ast {
     public record MkdirStmt(Expr path, int line) implements Stmt {
     }
 
-    /** {@code CLOSE [#channel]} — a null channel closes all files. */
-    public record CloseStmt(Expr channel, int line) implements Stmt {
+    /** {@code CLOSE [#channel], ...} — a null channel closes all files. */
+    public record CloseStmt(List<Expr> channels, int line) implements Stmt {
+        public CloseStmt(final Expr channel, final int line) {
+            this(channel == null ? null : List.of(channel), line);
+        }
     }
 
     /** {@code LINE INPUT #channel, target} — reads one line from an open file. */
     public record LineInputStmt(Expr channel, ReadTarget target, int line) implements Stmt {
     }
 
+    /** {@code LINE INPUT ["prompt"{;|,}] target} — whole-line console input. */
+    public record LineInputConsoleStmt(Expr prompt, ReadTarget target, int line) implements Stmt {
+    }
+
     /**
      * {@code INPUT #channel, var, array(i), ...} — reads comma-delimited data
      * items from an open file.
@@ -125,8 +234,12 @@ public final class Ast {
     public record DataStmt(List<Expr> values, int line) implements Stmt {
     }
 
-    /** One READ target: a scalar variable or an array element. */
-    public record ReadTarget(String name, List<Expr> indices) {
+    /** One READ target: a scalar variable, an array element, or a TYPE
+     *  member ({@code field} non-null) of either. */
+    public record ReadTarget(String name, List<Expr> indices, String field) {
+        public ReadTarget(final String name, final List<Expr> indices) {
+            this(name, indices, null);
+        }
     }
 
     /** {@code READ var, array(i), ...} — consumes DATA values in order. */
@@ -160,7 +273,18 @@ public final class Ast {
      * referenced).
      */
     public record LineStmt(Expr x1, Expr y1, Expr x2, Expr y2, Expr color,
-                           boolean box, boolean fill, int line) implements Stmt {
+                           boolean box, boolean fill, boolean step, Expr style, int line) implements Stmt {
+        public LineStmt(final Expr x1, final Expr y1, final Expr x2, final Expr y2,
+                        final Expr color, final boolean box, final boolean fill,
+                        final int line) {
+            this(x1, y1, x2, y2, color, box, fill, false, null, line);
+        }
+
+        public LineStmt(final Expr x1, final Expr y1, final Expr x2, final Expr y2,
+                        final Expr color, final boolean box, final boolean fill,
+                        final boolean step, final int line) {
+            this(x1, y1, x2, y2, color, box, fill, step, null, line);
+        }
     }
 
     /** {@code CIRCLE (x, y), radius[, color[, start[, end[, aspect]]]]} — null slot = omitted */
@@ -181,9 +305,15 @@ public final class Ast {
 
     /**
      * {@code variable = expression} or {@code array(i, j) = expression} —
-     * {@code indices} is empty for scalar assignment.
+     * {@code indices} is empty for scalar assignment. {@code field} is
+     * non-null for a TYPE member assignment ({@code var.field = x}).
      */
-    public record AssignStmt(String variable, List<Expr> indices, Expr value, int line) implements Stmt {
+    public record AssignStmt(String variable, List<Expr> indices, Expr value, int line,
+                             String field) implements Stmt {
+        public AssignStmt(final String variable, final List<Expr> indices, final Expr value,
+                          final int line) {
+            this(variable, indices, value, line, null);
+        }
     }
 
     /** {@code FOR v = a TO b [STEP s] ... NEXT [v]} — body is nested */
@@ -247,11 +377,23 @@ public final class Ast {
      * default); a suffix-declared parameter like {@code a$} does not — a bare
      * {@code a} in the body is a different variable (DEFxxx-typed).
      */
-    public record Param(String name, boolean asTyped) {
+    public record Param(String name, boolean asTyped, String asType, boolean array) {
+        public Param(final String name, final boolean asTyped) {
+            this(name, asTyped, null, false);
+        }
+
+        public Param(final String name, final boolean asTyped, final String asType) {
+            this(name, asTyped, asType, false);
+        }
     }
 
     /** {@code SUB name (param, ...) ... END SUB} — definition, not executed inline */
-    public record SubStmt(String name, List<Param> params, List<Stmt> body, int line) implements Stmt {
+    public record SubStmt(String name, List<Param> params, List<Stmt> body, int line,
+                          boolean isStatic) implements Stmt {
+        public SubStmt(final String name, final List<Param> params, final List<Stmt> body,
+                       final int line) {
+            this(name, params, body, line, false);
+        }
     }
 
     /**
@@ -259,7 +401,12 @@ public final class Ast {
      * executed inline. The return value is whatever was last assigned to
      * the function's own name inside the body (0 / "" when never assigned).
      */
-    public record FunctionStmt(String name, List<Param> params, List<Stmt> body, int line) implements Stmt {
+    public record FunctionStmt(String name, List<Param> params, List<Stmt> body, int line,
+                               boolean isStatic) implements Stmt {
+        public FunctionStmt(final String name, final List<Param> params, final List<Stmt> body,
+                            final int line) {
+            this(name, params, body, line, false);
+        }
     }
 
     /**
@@ -392,6 +539,13 @@ public final class Ast {
     public record Var(String name, int line) implements Expr {
     }
 
+    /**
+     * TYPE member read: {@code var.field} or {@code array(i).field} —
+     * indices is empty for a scalar record variable.
+     */
+    public record Member(String name, List<Expr> indices, String field, int line) implements Expr {
+    }
+
     /** Function call: POINT(x, y), POS(0), CSRLIN, CHR$(n), ... */
     public record Call(String name, List<Expr> args, int line) implements Expr {
     }
index 430c64a..b5b9488 100644 (file)
@@ -254,6 +254,17 @@ public final class SwingFrontend {
             }
             return;
         }
+        if (e.isAltDown()) {
+            // ALT+letter: the classic CHR$(0) + BIOS scancode sequence.
+            final int scan = "QWERTYUIOPASDFGHJKLZXCVBNM".indexOf(
+                    Character.toUpperCase(e.getKeyChar()));
+            final int[] bios = {16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
+                    30, 31, 32, 33, 34, 35, 36, 37, 38, 44, 45, 46, 47, 48, 49, 50};
+            if (scan >= 0) {
+                keys.push(KeyboardQueue.VK_ALT_BASE + bios[scan], KeyboardQueue.CHAR_NONE);
+                return;
+            }
+        }
         keys.push(e.getKeyCode(),
                 e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar());
     }
index 95f642e..1e0233b 100644 (file)
@@ -28,6 +28,13 @@ public final class KeyboardQueue {
     /** Sentinel for "no character associated" (arrow keys etc.). */
     public static final char CHAR_NONE = '\0';
 
+    /**
+     * VK code base for ALT+letter combinations: pushes as
+     * {@code VK_ALT_BASE + biosScanCode} (ALT+q = scan 16). The interpreter
+     * maps these to the classic CHR$(0) + scancode sequence.
+     */
+    public static final int VK_ALT_BASE = -2000;
+
     /** Pushes a key press. Called by frontends/drivers. */
     public void push(final int vkCode, final char character) {
         queue.add(new Key(vkCode, character));
index a0f9ebf..34193ec 100644 (file)
@@ -133,6 +133,19 @@ public final class Interpreter {
      * that same block — a GOSUB whose label lives in an outer block unwinds
      * the originating block, making its continuation unreachable.
      */
+    /**
+     * Signals an EXIT FOR/DO/WHILE/SUB/FUNCTION: unwinds to the innermost
+     * matching construct, which catches it; everything else rethrows.
+     */
+    private static final class ExitJump extends RuntimeException {
+        private final String what;
+
+        ExitJump(final String construct) {
+            super(null, null, false, false);
+            what = construct;
+        }
+    }
+
     private record GosubFrame(List<Ast.Stmt> block, int pc) {
     }
 
@@ -242,6 +255,8 @@ public final class Interpreter {
      * one-line lookahead so EOF(n) can answer without consuming input.
      */
     private final Map<Integer, java.io.BufferedReader> fileChannels = new HashMap<>();
+    /** File path per text channel (lazy byte-handle creation for INPUT$). */
+    private final Map<Integer, java.nio.file.Path> fileChannelPaths = new HashMap<>();
     /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */
     private final Map<Integer, java.io.BufferedWriter> fileOutputs = new HashMap<>();
     private final Map<Integer, Integer> fileOutputColumns = new HashMap<>();
@@ -289,6 +304,163 @@ public final class Interpreter {
     /** Last value returned by RND; RND(0) repeats it. */
     private double rndLast;
 
+    // ---- QB45 EXAMPLE-driven extensions ---------------------------------
+
+    /** User-defined TYPEs (TYPE...END TYPE), keyed by uppercase name. */
+    private final Map<String, TypeDef> typeDefs = new HashMap<>();
+    /** Record type of DIMmed variables, keyed by lowercase base name. */
+    private final Map<String, TypeDef> recordTypes = new HashMap<>();
+    /** ERR: the classic error code of the last runtime error. */
+    private int lastErrorCode;
+    /** Persistent cells for STATIC procedure locals, per procedure name. */
+    private final Map<String, Map<String, Cell>> staticStorage = new HashMap<>();
+    /** Static cells of the procedure currently executing (null at module level). */
+    private Map<String, Cell> currentStaticCells;
+    /** Names of the STATIC variables of the procedure currently executing. */
+    private java.util.Set<String> currentStaticNames = java.util.Set.of();
+    /** Whether the current procedure has the STATIC header attribute. */
+    private boolean currentProcAllStatic;
+    /** Call stack of procedure names (SUB/FUNCTION), for static storage keys. */
+    private final Deque<String> procStack = new ArrayDeque<>();
+    /** BINARY/RANDOM channels: byte-exact content plus a file pointer. */
+    private final Map<Integer, FileHandle> binaryChannels = new HashMap<>();
+    /** Channels opened on "CON" (console output). */
+    private final java.util.Set<Integer> consoleChannels = new java.util.HashSet<>();
+    /** Null-modem COM channels: input always empty, output discarded. */
+    private final java.util.Set<Integer> comChannels = new java.util.HashSet<>();
+
+    /**
+     * A user TYPE layout: field order, per-field kind and byte offsets.
+     * Byte sizes follow QB45: INTEGER 2, LONG 4, SINGLE 4, DOUBLE 8,
+     * STRING*n exactly n, a nested TYPE its own size.
+     */
+    private static final class TypeDef {
+        private final String name;
+        private final List<Ast.TypeField> fields;
+        /** Byte size of the whole record (LEN of a variable of this type). */
+        private final int size;
+
+        TypeDef(final String name, final List<Ast.TypeField> fields,
+                final Map<String, TypeDef> known) {
+            this.name = name;
+            this.fields = fields;
+            int total = 0;
+            for (final Ast.TypeField field : fields) {
+                total += fieldSize(field, known);
+            }
+            size = total;
+        }
+
+        static int fieldSize(final Ast.TypeField field, final Map<String, TypeDef> known) {
+            return switch (field.type().toUpperCase()) {
+                case "INTEGER" -> 2;
+                case "LONG" -> 4;
+                case "SINGLE" -> 4;
+                case "DOUBLE" -> 8;
+                case "STRING" -> field.stringLength();
+                default -> {
+                    final TypeDef nested = known.get(field.type().toUpperCase());
+                    yield nested == null ? 0 : nested.size;
+                }
+            };
+        }
+
+        Ast.TypeField field(final String fieldName) {
+            for (final Ast.TypeField f : fields) {
+                if (f.name().equalsIgnoreCase(fieldName)) {
+                    return f;
+                }
+            }
+            return null;
+        }
+    }
+
+    /** A live record: a TypeDef plus one value per field (Double or String). */
+    private static final class BasicRecord {
+        private final TypeDef def;
+        private final Object[] values;
+
+        BasicRecord(final TypeDef def) {
+            this.def = def;
+            values = new Object[def.fields.size()];
+            for (int i = 0; i < values.length; i++) {
+                values[i] = defaultFieldValue(def.fields.get(i));
+            }
+        }
+
+        private static Object defaultFieldValue(final Ast.TypeField field) {
+            if (field.type().equalsIgnoreCase("STRING")) {
+                return " ".repeat(field.stringLength());
+            }
+            return 0.0;
+        }
+    }
+
+    /**
+     * A BINARY/RANDOM file channel: whole-file bytes (growable on write)
+     * plus a 0-based file pointer. RANDOM channels address records of
+     * recordLen bytes; BINARY channels address bytes.
+     */
+    private static final class FileHandle {
+        private byte[] data;
+        private int length;
+        private long pos;
+        private final int recordLen;
+        private final boolean random;   // RANDOM: positions are record numbers
+        private final boolean writable;
+        private final java.nio.file.Path path; // null for pipes like CON
+
+        FileHandle(final byte[] content, final int recordLen, final boolean random,
+                   final boolean writable, final java.nio.file.Path path) {
+            data = content;
+            length = content.length;
+            this.recordLen = recordLen <= 0 ? 128 : recordLen;
+            this.random = random;
+            this.writable = writable;
+            this.path = path;
+        }
+
+        /** Positions the pointer: record number for RANDOM, 1-based byte for BINARY. */
+        void seek(final long position) {
+            pos = random ? (position - 1) * recordLen : position - 1;
+        }
+
+        /** Current 1-based position as SEEK(n) reports it. */
+        long position() {
+            return random ? pos / recordLen + 1 : pos + 1;
+        }
+
+        byte[] read(final int count) {
+            final int available = (int) Math.max(0, Math.min(count, length - pos));
+            final byte[] out = new byte[available];
+            System.arraycopy(data, (int) pos, out, 0, available);
+            pos += available;
+            return out;
+        }
+
+        void write(final byte[] bytes) {
+            final long end = pos + bytes.length;
+            if (end > data.length) {
+                data = Arrays.copyOf(data, (int) Math.max(end, data.length * 2 + 128));
+            }
+            System.arraycopy(bytes, 0, data, (int) pos, bytes.length);
+            pos = end;
+            length = (int) Math.max(length, end);
+        }
+
+        /** Persists the content (after writes), DOS CRLF untouched. */
+        void flush() {
+            if (!writable || path == null) {
+                return;
+            }
+            try {
+                java.nio.file.Files.write(path, Arrays.copyOf(data, length));
+            } catch (final java.io.IOException e) {
+                throw new InterpreterException("File write error: " + e.getMessage());
+            }
+        }
+    }
+
     public Interpreter(final VgaDevice vgaDevice, final KeyboardQueue keyboard,
                        final SoundQueue soundQueue) {
         vga = vgaDevice;
@@ -362,6 +534,9 @@ public final class Interpreter {
                 final String name = declare.what().substring(declare.what().indexOf(' ') + 1);
                 declaredParams.put(baseName(name), declare.params());
                 declaredNames.put(baseName(name), name);
+            } else if (statement instanceof Ast.TypeDefStmt typeDef) {
+                typeDefs.put(typeDef.name().toUpperCase(),
+                        new TypeDef(typeDef.name(), typeDef.fields(), typeDefs));
             }
         }
         for (final Ast.Stmt statement : program) {
@@ -793,42 +968,34 @@ public final class Interpreter {
             }
             case Ast.PcopyStmt s -> vga.pcopy(toInt(eval(s.sourcePage()), s.line()),
                     toInt(eval(s.targetPage()), s.line()));
-            case Ast.LocateStmt s -> vga.locate(toInt(eval(s.row()), s.line()),
-                    s.col() == null ? vga.textColumn() : toInt(eval(s.col()), s.line()));
-            case Ast.ClsStmt s -> vga.cls();
+            case Ast.LocateStmt s -> {
+                vga.locate(s.row() == null ? vga.cursorRow() : toInt(eval(s.row()), s.line()),
+                        s.col() == null ? vga.textColumn() : toInt(eval(s.col()), s.line()));
+                if (s.cursor() != null) {
+                    // LOCATE r, c, vis[, start, stop]: cursor shape is cosmetic here.
+                    vga.setCursor(toInt(eval(s.cursor()), s.line()) != 0,
+                            s.cursorStart() == null ? 6 : toInt(eval(s.cursorStart()), s.line()),
+                            s.cursorStop() == null ? 7 : toInt(eval(s.cursorStop()), s.line()));
+                }
+            }
+            case Ast.ClsStmt s -> {
+                if (s.what() == null) {
+                    vga.cls();
+                } else {
+                    switch (toInt(eval(s.what()), s.line())) {
+                        case 1 -> vga.clsGraphics();
+                        case 2 -> vga.clsText();
+                        default -> vga.cls();
+                    }
+                }
+            }
             case Ast.RandomizeStmt s -> {
                 // RANDOMIZE n reseeds like RND(-n); no seed = RANDOMIZE TIMER.
                 final double seed = s.seed() == null ? timerSeconds()
                         : ((Number) eval(s.seed())).doubleValue();
                 rndReseed(-seed);
             }
-            case Ast.OpenStmt s -> {
-                final int channel = toInt(eval(s.channel()), s.line());
-                final String path = str(eval(s.path()), s.line());
-                if ("OUTPUT".equals(s.mode()) || "APPEND".equals(s.mode())) {
-                    try {
-                        // OUTPUT truncates/creates; APPEND creates and writes
-                        // at end of file.
-                        fileOutputs.put(channel, java.nio.file.Files.newBufferedWriter(
-                                resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437"),
-                                "APPEND".equals(s.mode())
-                                        ? new java.nio.file.OpenOption[]{
-                                                java.nio.file.StandardOpenOption.CREATE,
-                                                java.nio.file.StandardOpenOption.APPEND}
-                                        : new java.nio.file.OpenOption[0]));
-                        fileOutputColumns.put(channel, 1);
-                    } catch (final java.io.IOException e) {
-                        throw error("Cannot create file: " + path, s.line());
-                    }
-                } else {
-                    try {
-                        fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
-                                resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437")));
-                    } catch (final java.io.IOException e) {
-                        throw error("File not found: " + path, s.line());
-                    }
-                }
-            }
+            case Ast.OpenStmt s -> execOpen(s);
             case Ast.ChdirStmt s -> {
                 final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line()));
                 if (!java.nio.file.Files.isDirectory(dir)) {
@@ -847,17 +1014,30 @@ public final class Interpreter {
                 }
             }
             case Ast.CloseStmt s -> {
-                if (s.channel() == null) {
+                if (s.channels() == null) {
                     for (final int channel : List.copyOf(fileChannels.keySet())) {
                         closeChannel(channel, s.line());
                     }
                     for (final int channel : List.copyOf(fileOutputs.keySet())) {
                         closeOutputChannel(channel, s.line());
                     }
+                    for (final int channel : List.copyOf(binaryChannels.keySet())) {
+                        binaryChannels.remove(channel).flush();
+                    }
+                    comChannels.clear();
+                    consoleChannels.clear();
                 } else {
-                    final int channel = toInt(eval(s.channel()), s.line());
-                    closeChannel(channel, s.line());
-                    closeOutputChannel(channel, s.line());
+                    for (final Ast.Expr channelExpr : s.channels()) {
+                        final int channel = toInt(eval(channelExpr), s.line());
+                        closeChannel(channel, s.line());
+                        closeOutputChannel(channel, s.line());
+                        final FileHandle handle = binaryChannels.remove(channel);
+                        if (handle != null) {
+                            handle.flush();
+                        }
+                        comChannels.remove(channel);
+                        consoleChannels.remove(channel);
+                    }
                 }
             }
             case Ast.LineInputStmt s -> {
@@ -892,7 +1072,13 @@ public final class Interpreter {
                 final int fg = toInt(eval(s.foreground()), s.line());
                 final int bg = s.background() == null ? currentBackground()
                         : toInt(eval(s.background()), s.line());
-                vga.setTextColor(fg, bg);
+                if (vga.mode() == ScreenMode.SCREEN_1 && s.background() != null) {
+                    // SCREEN 1 COLOR: background color + CGA palette select
+                    // (COLORS.BAS: COLOR background, palette).
+                    vga.screenOneColor(fg, bg);
+                } else {
+                    vga.setTextColor(fg, bg);
+                }
             }
             case Ast.WidthStmt s -> {
                 final int columns = s.columns() == null ? vga.columns()
@@ -906,17 +1092,71 @@ public final class Interpreter {
                 }
             }
             case Ast.SwapStmt s -> execSwap(s);
+            case Ast.ExitStmt s -> throw new ExitJump(s.what());
+            case Ast.StaticStmt s -> {
+                // STATIC name, ...: these locals persist across calls of
+                // the current procedure.
+                if (currentStaticCells != null) {
+                    final java.util.Set<String> names = new java.util.HashSet<>(currentStaticNames);
+                    for (final Ast.ReadTarget target : s.names()) {
+                        names.add(canonical(target.name()));
+                    }
+                    currentStaticNames = names;
+                }
+            }
+            case Ast.SharedStmt s -> execShared(s);
+            case Ast.TypeDefStmt s -> {
+                // TYPE layouts are registered by run(); no runtime effect.
+            }
+            case Ast.NameStmt s -> execName(s);
+            case Ast.KillStmt s -> execKill(s);
+            case Ast.ShellStmt s -> execShell(s);
+            case Ast.SeekStmt s -> {
+                final FileHandle handle = binaryChannel(toInt(eval(s.channel()), s.line()), s.line());
+                handle.seek((long) num(eval(s.position()), s.line()));
+            }
+            case Ast.GetFileStmt s -> execGetFile(s);
+            case Ast.PutFileStmt s -> execPutFile(s);
+            case Ast.ViewportStmt s -> {
+                if (s.x1() == null) {
+                    vga.resetViewport();
+                } else {
+                    vga.viewport(
+                            toInt(eval(s.x1()), s.line()), toInt(eval(s.y1()), s.line()),
+                            toInt(eval(s.x2()), s.line()), toInt(eval(s.y2()), s.line()),
+                            s.fill() == null ? null : toInt(eval(s.fill()), s.line()),
+                            s.border() == null ? null : toInt(eval(s.border()), s.line()));
+                }
+            }
+            case Ast.WindowStmt s -> {
+                if (s.x1() == null) {
+                    vga.resetWindow();
+                } else {
+                    vga.window(num(eval(s.x1()), s.line()), num(eval(s.y1()), s.line()),
+                            num(eval(s.x2()), s.line()), num(eval(s.y2()), s.line()),
+                            s.screenCoords());
+                }
+            }
+            case Ast.PaletteStmt s -> execPalette(s);
+            case Ast.DrawStmt s -> vga.draw(str(eval(s.macro()), s.line()));
+            case Ast.LineInputConsoleStmt s -> {
+                if (s.prompt() != null) {
+                    vga.print(str(eval(s.prompt()), s.line()));
+                }
+                setTarget(s.target(), readConsoleLine(), s.line());
+                vga.newLine();
+            }
             case Ast.PrintStmt s -> execPrint(s);
-            case Ast.PsetStmt s -> vga.pset(toInt(eval(s.x()), s.line()),
-                    toInt(eval(s.y()), s.line()), colorOrDefault(s.color(), s.line()));
+            case Ast.PsetStmt s -> vga.pset(num(eval(s.x()), s.line()),
+                    num(eval(s.y()), s.line()), colorOrDefault(s.color(), s.line()));
             case Ast.LineStmt s -> execLine(s);
             case Ast.CircleStmt s -> {
                 if (s.start() != null || s.end() != null) {
                     throw error("CIRCLE arcs (start/end angles) are not implemented", s.line());
                 }
-                final int cx = toInt(eval(s.x()), s.line());
-                final int cy = toInt(eval(s.y()), s.line());
-                final int radius = toInt(eval(s.radius()), s.line());
+                final double cx = num(eval(s.x()), s.line());
+                final double cy = num(eval(s.y()), s.line());
+                final double radius = num(eval(s.radius()), s.line());
                 final int color = colorOrDefault(s.color(), s.line());
                 if (s.aspect() == null) {
                     vga.circle(cx, cy, radius, color);
@@ -942,18 +1182,37 @@ public final class Interpreter {
                     toInt(eval(s.address()), s.line()),
                     toInt(eval(s.value()), s.line()));
             case Ast.PaintStmt s -> {
-                final int fill = s.fill() == null ? vga.textForeground()
-                        : toInt(eval(s.fill()), s.line());
-                final int border = s.border() == null ? fill : toInt(eval(s.border()), s.line());
-                vga.paint(toInt(eval(s.x()), s.line()), toInt(eval(s.y()), s.line()), fill, border);
+                final Object fillValue = s.fill() == null ? null : eval(s.fill());
+                if (fillValue instanceof String pattern) {
+                    // PAINT (x, y), pattern$ — tiled pattern fill; the
+                    // default border is the current foreground color.
+                    final int border = s.border() == null ? vga.textForeground()
+                            : toInt(eval(s.border()), s.line());
+                    vga.paintPattern(num(eval(s.x()), s.line()), num(eval(s.y()), s.line()),
+                            cp437Bytes(pattern), vga.textForeground(), border);
+                } else {
+                    final int fill = fillValue == null ? vga.textForeground()
+                            : toInt(fillValue, s.line());
+                    final int border = s.border() == null ? fill
+                            : toInt(eval(s.border()), s.line());
+                    vga.paint(num(eval(s.x()), s.line()), num(eval(s.y()), s.line()),
+                            fill, border);
+                }
             }
             case Ast.DimStmt s -> {
                 for (final Ast.DimEntry entry : s.entries()) {
+                    final TypeDef recordType = entry.type() == null ? null
+                            : typeDefs.get(entry.type().toUpperCase());
                     if (entry.type() != null) {
                         // DIM name AS type: suffix-less references to this
-                        // name resolve to the DIM'd type.
+                        // name resolve to the DIM'd type. User TYPEs get no
+                        // suffix — the record type registry drives them.
                         final String base = baseName(entry.name()).toLowerCase();
-                        dimSuffixes.put(base, typeSuffix(entry.type()));
+                        if (recordType != null) {
+                            recordTypes.put(base, recordType);
+                        } else {
+                            dimSuffixes.put(base, typeSuffix(entry.type()));
+                        }
                     }
                     final String key = canonical(entry.name());
                     final Map<String, Cell> target = s.shared() ? scopes.getLast() : scopes.peek();
@@ -961,7 +1220,10 @@ public final class Interpreter {
                         sharedNames.add(key);
                     }
                     if (entry.bounds().isEmpty()) {
-                        target.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+                        final Cell cell = target.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+                        if (recordType != null && !(cell.value instanceof BasicRecord)) {
+                            cell.set(new BasicRecord(recordType));
+                        }
                     } else {
                         final int[] lows = new int[entry.bounds().size()];
                         final int[] sizes = new int[entry.bounds().size()];
@@ -981,15 +1243,23 @@ public final class Interpreter {
                             // slot (classic dynamic-array behavior). A scalar
                             // of the same name lives in cell.value and is
                             // unaffected.
-                            cell.array = new BasicArray(lows, sizes, stringArray, elementKind);
+                            final BasicArray created = new BasicArray(lows, sizes, stringArray,
+                                    elementKind);
+                            if (recordType != null) {
+                                Arrays.fill(created.data, null);
+                                for (int el = 0; el < created.data.length; el++) {
+                                    created.data[el] = new BasicRecord(recordType);
+                                }
+                            }
+                            cell.array = created;
                         }
                     }
                 }
             }
             case Ast.GetStmt s -> {
                 final CapturedRegion region = vga.getRegion(
-                        toInt(eval(s.x1()), s.line()), toInt(eval(s.y1()), s.line()),
-                        toInt(eval(s.x2()), s.line()), toInt(eval(s.y2()), s.line()));
+                        num(eval(s.x1()), s.line()), num(eval(s.y1()), s.line()),
+                        num(eval(s.x2()), s.line()), num(eval(s.y2()), s.line()));
                 if (s.offsetIndices().isEmpty()) {
                     cellFor(s.arrayName()).array = region;
                 } else {
@@ -1020,7 +1290,7 @@ public final class Interpreter {
                 if (!(captured instanceof CapturedRegion region)) {
                     throw error("PUT needs an array filled by GET", s.line());
                 }
-                vga.putRegion(toInt(eval(s.x()), s.line()), toInt(eval(s.y()), s.line()),
+                vga.putRegion(num(eval(s.x()), s.line()), num(eval(s.y()), s.line()),
                         region, s.action());
             }
             case Ast.MidStmt s -> execMid(s);
@@ -1053,17 +1323,38 @@ public final class Interpreter {
                 for (final Ast.ConstEntry entry : s.entries()) {
                     final String key = canonical(entry.name());
                     final Map<String, Cell> global = scopes.getLast();
+                    final Object value = eval(entry.value());
                     if (global.containsKey(key)) {
-                        throw error("Duplicate definition", s.line());
+                        // Re-executing the same CONST (a CONST inside a
+                        // procedure called twice, e.g. BAR.BAS) is harmless
+                        // when the value is unchanged — QB45 evaluates CONST
+                        // at compile time, so the "statement" never re-runs.
+                        final Object previous = global.get(key).value;
+                        if (previous == null || !previous.equals(value)) {
+                            throw error("Duplicate definition", s.line());
+                        }
+                        continue;
                     }
                     constants.add(key);
-                    final Cell cell = new Cell(kindOf(key), null);
-                    cell.set(eval(entry.value()));
+                    // An out-of-INTEGER-range whole value under DEFINT widens
+                    // to LONG: the constant's type comes from its value
+                    // (MANDEL.BAS: CONST MAXSIZE = 1000000 under DEFINT A-Z).
+                    int kind = kindOf(key);
+                    if (kind == 1 && value instanceof Double d
+                            && (d != Math.rint(d) || d < -32768 || d > 32767)) {
+                        kind = d == Math.rint(d) ? 2 : 0;
+                    }
+                    final Cell cell = new Cell(kind, null);
+                    cell.set(value);
                     global.put(key, cell);
                 }
             }
             case Ast.AssignStmt s -> {
-                if (s.indices().isEmpty()) {
+                if (s.field() != null) {
+                    // TYPE member assignment: var.field = x
+                    final BasicRecord record = recordOf(s.variable(), s.indices(), s.line());
+                    setRecordField(record, s.field(), eval(s.value()), s.line());
+                } else if (s.indices().isEmpty()) {
                     if (constants.contains(canonical(s.variable()))) {
                         throw error("Duplicate definition", s.line());
                     }
@@ -1104,31 +1395,43 @@ public final class Interpreter {
             case Ast.ForStmt s -> execFor(s);
             case Ast.IfStmt s -> execBlock(truthy(eval(s.condition())) ? s.thenBody() : s.elseBody());
             case Ast.WhileStmt s -> {
-                while (truthy(eval(s.condition()))) {
-                    // An empty body never passes through exec(), so the
-                    // statement budget must be enforced on the spin itself
-                    // (WHILE INKEY$ = "": WEND in headless runs).
-                    if (s.body().isEmpty() && ++executed > maxStatements) {
-                        throw new StepsLimitException(executed);
+                try {
+                    while (truthy(eval(s.condition()))) {
+                        // An empty body never passes through exec(), so the
+                        // statement budget must be enforced on the spin itself
+                        // (WHILE INKEY$ = "": WEND in headless runs).
+                        if (s.body().isEmpty() && ++executed > maxStatements) {
+                            throw new StepsLimitException(executed);
+                        }
+                        execBlock(s.body());
+                    }
+                } catch (final ExitJump exit) {
+                    if (!exit.what.equals("WHILE")) {
+                        throw exit;
                     }
-                    execBlock(s.body());
                 }
             }
             case Ast.DoStmt s -> {
-                while (true) {
-                    if (s.preCondition() != null
-                            && truthy(eval(s.preCondition())) == s.preIsUntil()) {
-                        break;
+                try {
+                    while (true) {
+                        if (s.preCondition() != null
+                                && truthy(eval(s.preCondition())) == s.preIsUntil()) {
+                            break;
+                        }
+                        // Same empty-body budget guard as WHILE above
+                        // (DO UNTIL INKEY$ <> "": LOOP wait loops).
+                        if (s.body().isEmpty() && ++executed > maxStatements) {
+                            throw new StepsLimitException(executed);
+                        }
+                        execBlock(s.body());
+                        if (s.postCondition() != null
+                                && truthy(eval(s.postCondition())) == s.postIsUntil()) {
+                            break;
+                        }
                     }
-                    // Same empty-body budget guard as WHILE above
-                    // (DO UNTIL INKEY$ <> "": LOOP wait loops).
-                    if (s.body().isEmpty() && ++executed > maxStatements) {
-                        throw new StepsLimitException(executed);
-                    }
-                    execBlock(s.body());
-                    if (s.postCondition() != null
-                            && truthy(eval(s.postCondition())) == s.postIsUntil()) {
-                        break;
+                } catch (final ExitJump exit) {
+                    if (!exit.what.equals("DO")) {
+                        throw exit;
                     }
                 }
             }
@@ -1144,6 +1447,13 @@ public final class Interpreter {
             execPrintFile(s);
             return;
         }
+        if (s.usingFormat() != null) {
+            vga.print(usingFormatted(s));
+            if (s.newline()) {
+                vga.newLine();
+            }
+            return;
+        }
         for (final Ast.PrintStmt.Item item : s.items()) {
             final Object value = eval(item.expr());
             vga.print(value instanceof String text ? text
@@ -1157,6 +1467,119 @@ public final class Interpreter {
         }
     }
 
+    /**
+     * PRINT USING: walks the format left to right; literal characters print
+     * as-is ('_' escapes the next char), each format field consumes one
+     * item in order. String fields: '!' first char, '&' whole string,
+     * '\\...\\' fixed width. Numeric fields: runs of '#', '.', '$', '*',
+     * '+', '-', ',' — right-justified, decimals rounded half-even, '$$'
+     * floats the dollar sign, '*' fills with asterisks, overflow prints
+     * '%' plus the number. When items outlast the format, QB restarts the
+     * format after a newline; the current examples never do.
+     */
+    private String usingFormatted(final Ast.PrintStmt s) {
+        final String format = str(eval(s.usingFormat()), s.line());
+        final StringBuilder out = new StringBuilder();
+        int itemIndex = 0;
+        int pos = 0;
+        // Literals print as walked; a FIELD consumes the next item. The
+        // walk stops at the first field with no item left — trailing
+        // literals after the last consumed item still print.
+        while (pos < format.length()) {
+            final char c = format.charAt(pos);
+            if (c == '_' && pos + 1 < format.length()) {
+                out.append(format.charAt(pos + 1));
+                pos += 2;
+                continue;
+            }
+            final boolean isStringField = c == '!' || c == '&' || c == '\\';
+            final boolean isNumericField = "#.*$+-,".indexOf(c) >= 0;
+            if ((isStringField || isNumericField) && itemIndex >= s.items().size()) {
+                break;
+            }
+            if (c == '!' || c == '&') {
+                final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
+                out.append(c == '!' ? (text.isEmpty() ? " " : text.charAt(0)) : text);
+                itemIndex++;
+                pos++;
+                continue;
+            }
+            if (c == '\\') {
+                final int close = format.indexOf('\\', pos + 1);
+                final int width = close < 0 ? 2 : close - pos + 1;
+                final String text = str(eval(s.items().get(itemIndex).expr()), s.line());
+                out.append(text.length() >= width ? text.substring(0, width)
+                        : text + " ".repeat(width - text.length()));
+                itemIndex++;
+                pos = close < 0 ? format.length() : close + 1;
+                continue;
+            }
+            if ("#.*$+-,".indexOf(c) >= 0) {
+                final int start = pos;
+                while (pos < format.length() && "#.,$*^".indexOf(format.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                if (pos < format.length() && "+-".indexOf(format.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                final Object value = eval(s.items().get(itemIndex).expr());
+                out.append(formatNumericField(format.substring(start, pos),
+                        num(value, s.line())));
+                itemIndex++;
+                continue;
+            }
+            out.append(c);
+            pos++;
+        }
+        return out.toString();
+    }
+
+    /** Formats one number into one PRINT USING numeric field. */
+    private static String formatNumericField(final String spec, final double value) {
+        final boolean starFill = spec.indexOf('*') >= 0;
+        final boolean dollar = spec.indexOf('$') >= 0;
+        final boolean dollarFloat = spec.startsWith("$$") || spec.contains("**$");
+        final boolean trailingMinus = spec.endsWith("-");
+        final int dot = spec.indexOf('.');
+        int slots = 0;
+        int decimals = 0;
+        for (int i = 0; i < spec.length(); i++) {
+            final char c = spec.charAt(i);
+            if (c == '#') {
+                if (dot >= 0 && i > dot) {
+                    decimals++;
+                } else {
+                    slots++;
+                }
+            }
+        }
+        if (dollarFloat) {
+            slots++; // the floating '$' claims one digit slot
+        }
+        final java.math.BigDecimal rounded = new java.math.BigDecimal(Math.abs(value))
+                .setScale(decimals, java.math.RoundingMode.HALF_EVEN);
+        final String plain = rounded.stripTrailingZeros().toPlainString();
+        final String intDigits = plain.contains(".")
+                ? plain.substring(0, plain.indexOf('.')) : plain;
+        String fracDigits = decimals == 0 ? ""
+                : String.format(java.util.Locale.ROOT, "%." + decimals + "f", rounded)
+                .split("\\.")[1];
+        if (intDigits.length() > slots) {
+            // Overflow: '%' plus the unfielded number.
+            return "%" + formatNumber(value);
+        }
+        // The field's total width is the format spec's character count;
+        // a negative sign lives inside the field (no extra slot).
+        final String sign = value < 0 ? "-" : "";
+        final String digits = (dot >= 0 ? intDigits + "." + fracDigits : intDigits);
+        final String core = sign + (dollar ? "$" : "") + digits;
+        final int pad = Math.max(0, spec.length() - core.length());
+        final String padded = (starFill ? "*" : " ").repeat(pad) + core;
+        return trailingMinus && value < 0
+                ? (starFill ? "*" : " ").repeat(pad) + digits + "-"
+                : padded;
+    }
+
     /**
      * Statically determines whether a PRINT expression has type DOUBLE:
      * numbers are formatted by the expression's type (16 significant digits
@@ -1186,19 +1609,26 @@ public final class Interpreter {
 
     private void execLine(final Ast.LineStmt s) {
         // Omitted start point: draw from the graphics cursor (QB45's
-        // LINE -(x, y) form).
-        final int x1 = s.x1() == null ? vga.graphicsCursorX() : toInt(eval(s.x1()), s.line());
-        final int y1 = s.y1() == null ? vga.graphicsCursorY() : toInt(eval(s.y1()), s.line());
-        final int x2 = toInt(eval(s.x2()), s.line());
-        final int y2 = toInt(eval(s.y2()), s.line());
+        // LINE -(x, y) form). With -STEP the endpoint is relative to the start.
+        final double x1 = s.x1() == null ? vga.graphicsCursorX() : num(eval(s.x1()), s.line());
+        final double y1 = s.y1() == null ? vga.graphicsCursorY() : num(eval(s.y1()), s.line());
+        double x2 = num(eval(s.x2()), s.line());
+        double y2 = num(eval(s.y2()), s.line());
+        if (s.step()) {
+            x2 += x1;
+            y2 += y1;
+        }
         final int color = colorOrDefault(s.color(), s.line());
-        logLine(x1, y1, x2, y2, color);
+        // 5th LINE slot: a 16-bit dash pattern (bit 15 first, repeating).
+        final int style = s.style() == null ? 0xFFFF : toInt(eval(s.style()), s.line()) & 0xFFFF;
+        logLine((int) Math.rint(x1), (int) Math.rint(y1),
+                (int) Math.rint(x2), (int) Math.rint(y2), color);
         if (s.fill()) {
             vga.lineBoxFilled(x1, y1, x2, y2, color);
         } else if (s.box()) {
-            vga.lineBox(x1, y1, x2, y2, color);
+            vga.lineBox(x1, y1, x2, y2, color, style);
         } else {
-            vga.line(x1, y1, x2, y2, color);
+            vga.line(x1, y1, x2, y2, color, style);
         }
     }
 
@@ -1211,10 +1641,16 @@ public final class Interpreter {
             throw error("STEP cannot be 0", s.line());
         }
         cell.set(from);
-        while (step > 0 ? num(cell.value, s.line()) <= to
-                : num(cell.value, s.line()) >= to) {
-            execBlock(s.body());
-            cell.set(num(cell.value, s.line()) + step);
+        try {
+            while (step > 0 ? num(cell.value, s.line()) <= to
+                    : num(cell.value, s.line()) >= to) {
+                execBlock(s.body());
+                cell.set(num(cell.value, s.line()) + step);
+            }
+        } catch (final ExitJump exit) {
+            if (!exit.what.equals("FOR")) {
+                throw exit;
+            }
         }
     }
 
@@ -1229,13 +1665,40 @@ public final class Interpreter {
             throw error("Unknown SUB '" + call.name() + "'", call.line());
         }
         final Map<String, Cell> scope = bindScope(sub.name(), sub.params(), call.args(), call.line());
-        scopes.push(scope);
+        pushProcScope(sub.name(), sub.isStatic(), scope);
         try {
             execBlock(sub.body());
+        } catch (final ExitJump exit) {
+            if (!exit.what.equals("SUB")) {
+                throw exit;
+            }
         } catch (final GotoJump jump) {
             throw error("Label not found: " + jump.label, jump.line);
         } finally {
-            scopes.pop();
+            popProcScope();
+        }
+    }
+
+    /** Pushes a procedure scope with its STATIC-local storage context. */
+    private void pushProcScope(final String procName, final boolean allStatic,
+                             final Map<String, Cell> scope) {
+        scopes.push(scope);
+        procStack.addLast(procName.toUpperCase());
+        currentStaticCells = staticStorage.computeIfAbsent(
+                procName.toUpperCase(), k -> new HashMap<>());
+        currentStaticNames = java.util.Set.of();
+        currentProcAllStatic = allStatic;
+    }
+
+    private void popProcScope() {
+        scopes.pop();
+        procStack.pollLast();
+        if (procStack.isEmpty()) {
+            currentStaticCells = null;
+            currentStaticNames = java.util.Set.of();
+            currentProcAllStatic = false;
+        } else {
+            currentStaticCells = staticStorage.get(procStack.peekLast());
         }
     }
 
@@ -1261,9 +1724,27 @@ public final class Interpreter {
             final Ast.Param declare = i < declared.size() ? declared.get(i) : header;
             final String headerKey = canonical(header.name());
             final String declaredKey = canonical(declare.name());
-            final Cell cell = arg instanceof Ast.Var v
-                    ? cellFor(v.name()) // by reference
-                    : new Cell(kindOf(declaredKey), eval(arg)); // by value
+            final Cell cell;
+            if (arg instanceof Ast.Var v) {
+                cell = cellFor(v.name()); // by reference
+            } else if (arg instanceof Ast.Call arrayRef && arrayRef.args().isEmpty()
+                    && (header.array() || declare.array())) {
+                // Whole-array argument: Label$() — shares the caller's cell,
+                // array slot included (assignments AND elements by reference).
+                cell = cellFor(arrayRef.name());
+            } else {
+                cell = new Cell(kindOf(declaredKey), eval(arg)); // by value
+            }
+            // An AS-usertype parameter passes a record: make sure the cell
+            // actually holds one (fresh records for never-assigned vars).
+            final String recordTypeName = header.asType() != null ? header.asType()
+                    : declare.asType();
+            if (recordTypeName != null && !(cell.value instanceof BasicRecord)) {
+                final TypeDef def = typeDefs.get(recordTypeName.toUpperCase());
+                if (def != null) {
+                    cell.set(new BasicRecord(def));
+                }
+            }
             scope.put(headerKey, cell);
             scope.put(declaredKey, cell);
             // An "AS type" parameter claims the bare name: body references
@@ -1299,13 +1780,17 @@ public final class Interpreter {
         // canonical forms, so the body can assign with or without suffix.
         scope.put(resultKey, result);
         scope.put(canonical(function.name()), result);
-        scopes.push(scope);
+        pushProcScope(function.name(), function.isStatic(), scope);
         try {
             execBlock(function.body());
+        } catch (final ExitJump exit) {
+            if (!exit.what.equals("FUNCTION")) {
+                throw exit;
+            }
         } catch (final GotoJump jump) {
             throw error("Label not found: " + jump.label, jump.line);
         } finally {
-            scopes.pop();
+            popProcScope();
         }
         return result.value != null ? result.value : (resultKey.endsWith("$") ? "" : 0.0);
     }
@@ -1341,8 +1826,20 @@ public final class Interpreter {
         final String key = canonical(name);
         final Map<String, Cell> current = scopes.peek();
         if (!current.containsKey(key) && scopes.size() > 1 && sharedNames.contains(key)) {
+            // SHARED wins over STATIC: a SHARED name in a STATIC procedure
+            // still refers to the module variable (PALETTE.BAS).
             return scopes.getLast().computeIfAbsent(key, k -> new Cell(kindOf(k), null));
         }
+        if (currentStaticCells != null && !current.containsKey(key) && constants.contains(key)) {
+            // CONSTs are module-global and win over STATIC locals too
+            // (WHEREIS.BAS: ROOT read inside STATIC FUNCTION MakeFileName$).
+            return scopes.getLast().get(key);
+        }
+        if (currentStaticCells != null && !current.containsKey(key)
+                && (currentProcAllStatic || currentStaticNames.contains(key))) {
+            // STATIC local: the same cell across calls of this procedure.
+            return currentStaticCells.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+        }
         return current.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
     }
 
@@ -1422,6 +1919,11 @@ public final class Interpreter {
 
     /** Assigns a value to a READ / INPUT # / LINE INPUT # target (scalar or array element). */
     private void setTarget(final Ast.ReadTarget target, final Object value, final int line) {
+        if (target.field() != null) {
+            setRecordField(recordOf(target.name(), target.indices(), line),
+                    target.field(), value, line);
+            return;
+        }
         if (target.indices().isEmpty()) {
             cellFor(target.name()).set(value);
             return;
@@ -1436,6 +1938,10 @@ public final class Interpreter {
 
     /** Reads the current value of a scalar or array-element target. */
     private Object getTarget(final Ast.ReadTarget target, final int line) {
+        if (target.field() != null) {
+            return getRecordField(recordOf(target.name(), target.indices(), line),
+                    target.field(), line);
+        }
         if (target.indices().isEmpty()) {
             final Object value = cellFor(target.name()).value;
             if (value != null) {
@@ -1466,6 +1972,538 @@ public final class Interpreter {
         setTarget(s.second(), first, s.line());
     }
 
+    // ---------------------------------------------------- QB45 extensions
+
+    /** SHARED name[()][ AS type], ... inside a procedure: bind module variables. */
+    private void execShared(final Ast.SharedStmt s) {
+        for (final Ast.SharedEntry entry : s.entries()) {
+            final TypeDef recordType = entry.type() == null ? null
+                    : typeDefs.get(entry.type().toUpperCase());
+            if (recordType != null) {
+                recordTypes.put(baseName(entry.name()).toLowerCase(), recordType);
+            }
+            final String key = canonical(entry.name());
+            sharedNames.add(key);
+            final Map<String, Cell> global = scopes.getLast();
+            final Cell cell = global.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+            if (recordType != null && !entry.array() && !(cell.value instanceof BasicRecord)) {
+                cell.set(new BasicRecord(recordType));
+            }
+        }
+    }
+
+    /**
+     * Resolves the record behind a (possibly indexed) variable name for a
+     * member access.
+     */
+    private BasicRecord recordOf(final String name, final List<Ast.Expr> indices, final int line) {
+        final Object holder;
+        if (indices.isEmpty()) {
+            holder = cellFor(name).value;
+        } else {
+            final BasicArray array = arrayFor(name, indices.size(), line);
+            final int offset = array.offset(subscripts(indices, line));
+            if (offset < 0) {
+                throw error("Subscript out of range", line);
+            }
+            holder = array.data[offset];
+        }
+        if (!(holder instanceof BasicRecord record)) {
+            throw error("Type mismatch (not a record: " + name + ")", line);
+        }
+        return record;
+    }
+
+    /** Coerces a value into a record field (fixed strings pad/truncate, numerics narrow). */
+    private void setRecordField(final BasicRecord record, final String fieldName,
+                                final Object value, final int line) {
+        for (int i = 0; i < record.def.fields.size(); i++) {
+            final Ast.TypeField field = record.def.fields.get(i);
+            if (!field.name().equalsIgnoreCase(fieldName)) {
+                continue;
+            }
+            if (field.type().equalsIgnoreCase("STRING")) {
+                String text = value instanceof String s ? s : formatNumber(num(value, line));
+                if (text.length() > field.stringLength()) {
+                    text = text.substring(0, field.stringLength());
+                } else if (text.length() < field.stringLength()) {
+                    text = text + " ".repeat(field.stringLength() - text.length());
+                }
+                record.values[i] = text;
+                return;
+            }
+            final double number = num(value, line);
+            record.values[i] = switch (field.type().toUpperCase()) {
+                case "INTEGER" -> integer(number);
+                case "LONG" -> longInteger(number);
+                case "DOUBLE" -> number;
+                default -> single(number);
+            };
+            return;
+        }
+        throw error("No such field '" + fieldName + "' in TYPE " + record.def.name, line);
+    }
+
+    /** Reads a record field's value. */
+    private Object getRecordField(final BasicRecord record, final String fieldName,
+                                  final int line) {
+        for (int i = 0; i < record.def.fields.size(); i++) {
+            if (record.def.fields.get(i).name().equalsIgnoreCase(fieldName)) {
+                return record.values[i];
+            }
+        }
+        throw error("No such field '" + fieldName + "' in TYPE " + record.def.name, line);
+    }
+
+    /** OPEN: text modes plus BINARY/RANDOM byte channels, CON and null-modem COM ports. */
+    private void execOpen(final Ast.OpenStmt s) {
+        final int channel = toInt(eval(s.channel()), s.line());
+        final String path = str(eval(s.path()), s.line());
+        final int recordLen = s.recordLength() == null ? 128
+                : toInt(eval(s.recordLength()), s.line());
+        // Serial ports: "COM1:1200,N,8,1" — emulated as a silent null modem:
+        // nothing ever arrives (EOF stays true, LOC is 0), output is discarded.
+        if (path.toUpperCase().startsWith("COM")) {
+            comChannels.add(channel);
+            return;
+        }
+        if ("CON".equalsIgnoreCase(path)) {
+            consoleChannels.add(channel);
+            return;
+        }
+        final java.nio.file.Path resolved = resolveOpenPath(path);
+        switch (s.mode()) {
+            case "OUTPUT", "APPEND" -> {
+                try {
+                    fileOutputs.put(channel, java.nio.file.Files.newBufferedWriter(
+                            resolved, java.nio.charset.Charset.forName("IBM437"),
+                            "APPEND".equals(s.mode())
+                                    ? new java.nio.file.OpenOption[]{
+                                            java.nio.file.StandardOpenOption.CREATE,
+                                            java.nio.file.StandardOpenOption.APPEND}
+                                    : new java.nio.file.OpenOption[0]));
+                    fileOutputColumns.put(channel, 1);
+                } catch (final java.io.IOException e) {
+                    throw error("Cannot create file: " + path, s.line());
+                }
+            }
+            case "INPUT" -> {
+                try {
+                    fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
+                            resolved, java.nio.charset.Charset.forName("IBM437")));
+                    fileChannelPaths.put(channel, resolved);
+                } catch (final java.io.IOException e) {
+                    throw error("File not found: " + path, s.line());
+                }
+            }
+            case "BINARY" -> {
+                try {
+                    binaryChannels.put(channel, new FileHandle(
+                            java.nio.file.Files.readAllBytes(resolved), recordLen, false, true,
+                            resolved));
+                } catch (final java.io.IOException e) {
+                    throw error("File not found: " + path, s.line());
+                }
+            }
+            case "RANDOM" -> {
+                // RANDOM files read AND write; a missing file starts empty.
+                final byte[] content;
+                try {
+                    content = java.nio.file.Files.exists(resolved)
+                            ? java.nio.file.Files.readAllBytes(resolved) : new byte[0];
+                } catch (final java.io.IOException e) {
+                    throw error("File not found: " + path, s.line());
+                }
+                binaryChannels.put(channel,
+                        new FileHandle(content, recordLen, true, true, resolved));
+            }
+            default -> throw error("OPEN mode not supported: " + s.mode(), s.line());
+        }
+    }
+
+    /** The byte channel for INPUT$/GET/SEEK/LOF, or an error. */
+    private FileHandle binaryChannel(final int channel, final int line) {
+        FileHandle handle = binaryChannels.get(channel);
+        if (handle == null && fileChannels.containsKey(channel)) {
+            // INPUT$ on a text (FOR INPUT) channel: switch to byte semantics,
+            // lazily creating the byte handle over the whole file. Programs
+            // that mix byte reads with INPUT #/LINE INPUT # on one channel
+            // lose the text position — none of the current programs do.
+            final java.nio.file.Path path = fileChannelPaths.get(channel);
+            try {
+                handle = new FileHandle(java.nio.file.Files.readAllBytes(path),
+                        0, false, false, path);
+            } catch (final java.io.IOException e) {
+                throw error("File read error: " + e.getMessage(), line);
+            }
+            binaryChannels.put(channel, handle);
+            closeChannel(channel, line);
+        }
+        if (handle == null) {
+            throw error("Bad file number: " + channel, line);
+        }
+        return handle;
+    }
+
+    /** NAME old AS new: rename; classic errors 53 (missing) and 58 (target exists). */
+    private void execName(final Ast.NameStmt s) {
+        final String oldPath = str(eval(s.oldPath()), s.line());
+        final String newPath = str(eval(s.newPath()), s.line());
+        final java.nio.file.Path source = resolveOpenPath(oldPath);
+        final java.nio.file.Path target = resolveOpenPath(newPath);
+        if (!java.nio.file.Files.exists(source)) {
+            lastErrorCode = 53;
+            throw error("File not found", s.line());
+        }
+        if (java.nio.file.Files.exists(target)) {
+            lastErrorCode = 58;
+            throw error("File already exists", s.line());
+        }
+        try {
+            java.nio.file.Files.move(source, target);
+        } catch (final java.io.IOException e) {
+            lastErrorCode = 76;
+            throw error("Path not found", s.line());
+        }
+    }
+
+    /** KILL filespec: delete; DOS wildcards * and ? in the last component. */
+    private void execKill(final Ast.KillStmt s) {
+        final String spec = str(eval(s.pathSpec()), s.line());
+        final String normalized = spec.replace('\\', '/');
+        final int slash = normalized.lastIndexOf('/');
+        final String dirPart = slash < 0 ? "" : normalized.substring(0, slash);
+        final String pattern = slash < 0 ? normalized : normalized.substring(slash + 1);
+        final java.nio.file.Path dir = resolveOpenPath(dirPart.isEmpty() ? "." : dirPart);
+        if (!java.nio.file.Files.isDirectory(dir)) {
+            lastErrorCode = 76;
+            throw error("Path not found", s.line());
+        }
+        final String regex = pattern.toUpperCase()
+                .replace(".", "\\.").replace("*", ".*").replace("?", ".");
+        int killed = 0;
+        try (var stream = java.nio.file.Files.list(dir)) {
+            for (final java.nio.file.Path entry
+                    : stream.filter(p -> p.getFileName().toString().toUpperCase().matches(regex)
+                    && java.nio.file.Files.isRegularFile(p)).toList()) {
+                java.nio.file.Files.delete(entry);
+                killed++;
+            }
+        } catch (final java.io.IOException e) {
+            throw error("Path/file access error", s.line());
+        }
+        if (killed == 0) {
+            lastErrorCode = 53;
+            throw error("File not found", s.line());
+        }
+    }
+
+    /**
+     * SHELL command: a small built-in DOS subset. Currently {@code DIR path
+     * [> outfile]} producing the classic 8.3 listing format WHEREIS.BAS
+     * parses (name cols 1-8, extension cols 10-12, "DIR" at cols 15-17;
+     * non-entry lines start with a space or '.'). Unsupported commands are
+     * silently ignored, like a DOS that succeeds doing nothing.
+     */
+    private void execShell(final Ast.ShellStmt s) {
+        final String command = s.command() == null ? "" : str(eval(s.command()), s.line()).trim();
+        final String upper = command.toUpperCase();
+        if (!upper.startsWith("DIR")) {
+            return; // Only DIR is modeled.
+        }
+        String rest = command.substring(3).trim();
+        String outFile = null;
+        final int redirect = upper.indexOf('>');
+        if (redirect >= 0) {
+            outFile = command.substring(redirect + 1).trim();
+            rest = command.substring(3, redirect).trim();
+        }
+        if (rest.endsWith("/W") || rest.endsWith("/w")) {
+            rest = rest.substring(0, rest.length() - 2).trim();
+        }
+        // The directory to list: "\" (drive root) and "" both map to the
+        // current directory of the emulated machine.
+        final java.nio.file.Path dir = resolveOpenPath(
+                rest.isEmpty() || rest.equals("\\") || rest.equals("/") ? "." : rest);
+        if (!java.nio.file.Files.isDirectory(dir)) {
+            lastErrorCode = 76;
+            throw error("Path not found", s.line());
+        }
+        final StringBuilder listing = new StringBuilder();
+        listing.append(" Volume in drive C is DOS\r\n");
+        listing.append(" Directory of ").append(rest.isEmpty() ? "\\" : rest.toUpperCase())
+                .append("\r\n\r\n");
+        listing.append(".\r\n..\r\n");
+        int files = 0;
+        try (var stream = java.nio.file.Files.list(dir)) {
+            for (final java.nio.file.Path entry : stream.sorted().toList()) {
+                final String name = entry.getFileName().toString().toUpperCase();
+                final boolean isDir = java.nio.file.Files.isDirectory(entry);
+                // DOS 8.3 names: split and truncate.
+                final int dot = name.lastIndexOf('.');
+                String base = dot < 0 ? name : name.substring(0, dot);
+                String ext = dot < 0 ? "" : name.substring(dot + 1);
+                if (base.length() > 8) {
+                    base = base.substring(0, 8);
+                }
+                if (ext.length() > 3) {
+                    ext = ext.substring(0, 3);
+                }
+                if (isDir) {
+                    listing.append(String.format("%-8s %-3s  DIR\r\n", base, ext));
+                } else {
+                    long size = 0;
+                    try {
+                        size = java.nio.file.Files.size(entry);
+                    } catch (final java.io.IOException ignored) {
+                        // Size is cosmetic.
+                    }
+                    listing.append(String.format(java.util.Locale.ROOT, "%-8s %-3s %9d\r\n",
+                            base, ext, size));
+                    files++;
+                }
+            }
+        } catch (final java.io.IOException e) {
+            throw error("Path/file access error", s.line());
+        }
+        listing.append(String.format(java.util.Locale.ROOT, "        %d File(s)\r\n", files));
+        if (outFile == null) {
+            vga.print(listing.toString().replace("\r\n", "\n"));
+            return;
+        }
+        try {
+            java.nio.file.Files.write(resolveOpenPath(outFile),
+                    listing.toString().getBytes(java.nio.charset.Charset.forName("IBM437")));
+        } catch (final java.io.IOException e) {
+            throw error("Cannot create file: " + outFile, s.line());
+        }
+    }
+
+    /** GET #ch, [position], var: reads bytes into a record or scalar variable. */
+    private void execGetFile(final Ast.GetFileStmt s) {
+        final int channel = toInt(eval(s.channel()), s.line());
+        final FileHandle handle = binaryChannel(channel, s.line());
+        if (s.position() != null) {
+            handle.seek((long) num(eval(s.position()), s.line()));
+        }
+        final int count = handle.random ? handle.recordLen : byteSizeOf(s.target(), s.line());
+        final byte[] bytes = handle.read(count);
+        decodeInto(s.target(), bytes, s.line());
+    }
+
+    /** PUT #ch, [position], var: writes a record or scalar variable, then syncs. */
+    private void execPutFile(final Ast.PutFileStmt s) {
+        final int channel = toInt(eval(s.channel()), s.line());
+        final FileHandle handle = binaryChannel(channel, s.line());
+        if (s.position() != null) {
+            handle.seek((long) num(eval(s.position()), s.line()));
+        }
+        handle.write(encodeFrom(s.target(), s.line()));
+        handle.flush();
+    }
+
+    /** Byte count of a GET/PUT target variable (record LEN or scalar type size). */
+    private int byteSizeOf(final Ast.ReadTarget target, final int line) {
+        if (target.indices().isEmpty()) {
+            final Object value = cellFor(target.name()).value;
+            if (value instanceof BasicRecord record) {
+                return record.def.size;
+            }
+        } else {
+            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
+            final int offset = array.offset(subscripts(target.indices(), line));
+            if (offset >= 0 && array.data[offset] instanceof BasicRecord record) {
+                return record.def.size;
+            }
+        }
+        final String key = canonical(target.name());
+        if (key.endsWith("$")) {
+            // GET into a string variable reads its current length's bytes.
+            return str(getTarget(target, line), line).length();
+        }
+        return key.endsWith("%") ? 2 : key.endsWith("&") ? 4 : key.endsWith("#") ? 8 : 4;
+    }
+
+    /** Decodes bytes (little-endian QB45 layout) into the target variable. */
+    private void decodeInto(final Ast.ReadTarget target, final byte[] bytes, final int line) {
+        if (target.field() != null) {
+            throw error("GET/PUT into a record field is not supported", line);
+        }
+        if (target.indices().isEmpty() && cellFor(target.name()).value instanceof BasicRecord record) {
+            decodeRecord(record, bytes);
+            return;
+        }
+        if (!target.indices().isEmpty()) {
+            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
+            final int offset = array.offset(subscripts(target.indices(), line));
+            if (offset >= 0 && array.data[offset] instanceof BasicRecord record) {
+                decodeRecord(record, bytes);
+                return;
+            }
+        }
+        // Scalar target: decode by the variable's type.
+        final String key = canonical(target.name());
+        final Object value;
+        if (key.endsWith("$")) {
+            value = cp437String(bytes);
+        } else {
+            value = switch (key.charAt(key.length() - 1)) {
+                case '%' -> (double) (short) (bytes.length >= 2
+                        ? (bytes[0] & 0xFF) | (bytes[1] << 8) : 0);
+                case '&' -> (double) (bytes.length >= 4
+                        ? (bytes[0] & 0xFF) | ((bytes[1] & 0xFF) << 8)
+                        | ((bytes[2] & 0xFF) << 16) | (bytes[3] << 24) : 0);
+                case '#' -> bytes.length >= 8 ? java.nio.ByteBuffer.wrap(bytes)
+                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getDouble() : 0.0;
+                default -> bytes.length >= 4 ? (double) java.nio.ByteBuffer.wrap(bytes)
+                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getFloat() : 0.0;
+            };
+        }
+        setTarget(target, value, line);
+    }
+
+    /** Decodes a record from its byte image (QB45 little-endian field layout). */
+    private void decodeRecord(final BasicRecord record, final byte[] bytes) {
+        int offset = 0;
+        for (int i = 0; i < record.def.fields.size(); i++) {
+            final Ast.TypeField field = record.def.fields.get(i);
+            final int size = TypeDef.fieldSize(field, typeDefs);
+            final int available = Math.max(0, Math.min(size, bytes.length - offset));
+            switch (field.type().toUpperCase()) {
+                case "STRING" -> {
+                    final byte[] slice = Arrays.copyOfRange(bytes, offset, offset + available);
+                    String text = cp437String(slice);
+                    if (text.length() < field.stringLength()) {
+                        text += " ".repeat(field.stringLength() - text.length());
+                    }
+                    record.values[i] = text;
+                }
+                case "INTEGER" -> record.values[i] = available >= 2
+                        ? (double) (short) ((bytes[offset] & 0xFF) | (bytes[offset + 1] << 8)) : 0.0;
+                case "LONG" -> record.values[i] = available >= 4
+                        ? (double) ((bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8)
+                        | ((bytes[offset + 2] & 0xFF) << 16) | (bytes[offset + 3] << 24)) : 0.0;
+                case "SINGLE" -> record.values[i] = available >= 4
+                        ? (double) java.nio.ByteBuffer.wrap(bytes, offset, 4)
+                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getFloat() : 0.0;
+                case "DOUBLE" -> record.values[i] = available >= 8
+                        ? java.nio.ByteBuffer.wrap(bytes, offset, 8)
+                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getDouble() : 0.0;
+                default -> {
+                    // Nested TYPEs: not needed by the current program set.
+                }
+            }
+            offset += size;
+        }
+    }
+
+    /** Encodes the target variable to its QB45 byte image. */
+    private byte[] encodeFrom(final Ast.ReadTarget target, final int line) {
+        final BasicRecord record;
+        if (target.indices().isEmpty() && cellFor(target.name()).value instanceof BasicRecord r) {
+            record = r;
+        } else if (!target.indices().isEmpty()) {
+            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
+            final int offset = array.offset(subscripts(target.indices(), line));
+            record = offset >= 0 && array.data[offset] instanceof BasicRecord r ? r : null;
+        } else {
+            record = null;
+        }
+        if (record == null) {
+            // Scalar: encode by type.
+            final String key = canonical(target.name());
+            final Object value = getTarget(target, line);
+            if (key.endsWith("$")) {
+                return cp437Bytes(str(value, line));
+            }
+            final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
+                    .allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+            switch (key.charAt(key.length() - 1)) {
+                case '%' -> {
+                    buffer.putShort((short) (int) integer(num(value, line)));
+                    return Arrays.copyOf(buffer.array(), 2);
+                }
+                case '&' -> {
+                    buffer.putInt((int) (long) longInteger(num(value, line)));
+                    return Arrays.copyOf(buffer.array(), 4);
+                }
+                case '#' -> {
+                    buffer.putDouble(num(value, line));
+                    return Arrays.copyOf(buffer.array(), 8);
+                }
+                default -> {
+                    buffer.putFloat((float) num(value, line));
+                    return Arrays.copyOf(buffer.array(), 4);
+                }
+            }
+        }
+        final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
+                .allocate(record.def.size).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+        for (int i = 0; i < record.def.fields.size(); i++) {
+            final Ast.TypeField field = record.def.fields.get(i);
+            final Object value = record.values[i];
+            switch (field.type().toUpperCase()) {
+                case "STRING" -> {
+                    final byte[] text = cp437Bytes(str(value, line));
+                    final byte[] padded = new byte[field.stringLength()];
+                    Arrays.fill(padded, (byte) ' ');
+                    System.arraycopy(text, 0, padded, 0,
+                            Math.min(text.length, field.stringLength()));
+                    buffer.put(padded);
+                }
+                case "INTEGER" -> buffer.putShort((short) (int) integer(num(value, line)));
+                case "LONG" -> buffer.putInt((int) (long) longInteger(num(value, line)));
+                case "SINGLE" -> buffer.putFloat((float) num(value, line));
+                case "DOUBLE" -> buffer.putDouble(num(value, line));
+                default -> buffer.put(new byte[TypeDef.fieldSize(field, typeDefs)]);
+            }
+        }
+        return buffer.array();
+    }
+
+    /** CP437 bytes → interpreter string. */
+    private static String cp437String(final byte[] bytes) {
+        final StringBuilder sb = new StringBuilder(bytes.length);
+        for (final byte b : bytes) {
+            sb.append(Cp437.charOf(b & 0xFF));
+        }
+        return sb.toString();
+    }
+
+    /** Interpreter string → CP437 bytes. */
+    private static byte[] cp437Bytes(final String text) {
+        final byte[] out = new byte[text.length()];
+        for (int i = 0; i < text.length(); i++) {
+            out[i] = (byte) Cp437.byteOf(text.charAt(i));
+        }
+        return out;
+    }
+
+    /** PALETTE: attribute form, USING array form, or the bare reset. */
+    private void execPalette(final Ast.PaletteStmt s) {
+        if (s.usingArray() != null) {
+            final BasicArray array = arrayFor(s.usingArray(),
+                    Math.max(1, s.usingIndices().size()), s.line());
+            final int[] colors = new int[vga.mode().colors()];
+            Arrays.fill(colors, -1);
+            final int base = s.usingIndices().isEmpty() ? 0
+                    : array.offset(subscripts(s.usingIndices(), s.line()));
+            for (int k = 0; k < colors.length; k++) {
+                if (base + k < array.data.length
+                        && array.data[base + k] instanceof Double d) {
+                    colors[k] = d.intValue();
+                }
+            }
+            vga.paletteUsing(colors);
+            return;
+        }
+        if (s.color() == null) {
+            vga.resetPalette();
+            return;
+        }
+        vga.paletteAttribute(toInt(eval(s.color()), s.line()), toInt(eval(s.newColor()), s.line()));
+    }
+
     // ------------------------------------------------------------ expressions
 
     private Object eval(final Ast.Expr expr) {
@@ -1473,6 +2511,8 @@ public final class Interpreter {
             case Ast.Num n -> n.doublePrecision() ? n.value() : single(n.value());
             case Ast.Str s -> s.value();
             case Ast.Var v -> evalVar(v);
+            case Ast.Member m -> getRecordField(recordOf(m.name(), m.indices(), m.line()),
+                    m.field(), m.line());
             case Ast.Call c -> evalCall(c);
             case Ast.Unary u -> {
                 if (u.op().equals("NOT")) {
@@ -1510,11 +2550,28 @@ public final class Interpreter {
             // Bare COMMAND$: the text DOS passed after the program name.
             return commandLine;
         }
+        if (v.name().equalsIgnoreCase("ERR")) {
+            // Bare ERR: the classic error code of the last runtime error.
+            return (double) lastErrorCode;
+        }
+        if (v.name().equalsIgnoreCase("FREEFILE")) {
+            // Bare FREEFILE (no parens): lowest free channel, 1-255.
+            return evalCall(new Ast.Call("FREEFILE", List.of(), v.line()));
+        }
         final String key = canonical(v.name());
         Cell cell = scopes.peek().get(key);
         if (cell == null && scopes.size() > 1 && sharedNames.contains(key)) {
             cell = scopes.getLast().get(key);
         }
+        if (cell == null && constants.contains(key)) {
+            // CONSTs are module-global: visible inside every procedure.
+            cell = scopes.getLast().get(key);
+        }
+        if (cell == null && currentStaticCells != null
+                && (currentProcAllStatic || currentStaticNames.contains(key))) {
+            // STATIC local of the current procedure.
+            cell = currentStaticCells.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+        }
         if (cell == null) {
             // A bare reference to a parameterless user FUNCTION calls it
             // (the dialect allows dropping the empty parentheses).
@@ -1552,10 +2609,16 @@ public final class Interpreter {
             return result ? -1.0 : 0.0;
         }
         // Logic (bitwise on integers).
-        if (op.equals("AND") || op.equals("OR")) {
+        if (List.of("AND", "OR", "XOR", "EQV", "IMP").contains(op)) {
             final long left = (long) num(eval(b.left()), b.line());
             final long right = (long) num(eval(b.right()), b.line());
-            return (double) (op.equals("AND") ? left & right : left | right);
+            return (double) switch (op) {
+                case "AND" -> left & right;
+                case "OR" -> left | right;
+                case "XOR" -> left ^ right;
+                case "EQV" -> ~(left ^ right);
+                default -> ~left | right; // IMP
+            };
         }
         // String concatenation.
         final Object left = eval(b.left());
@@ -1673,8 +2736,98 @@ public final class Interpreter {
                 return v >= 0 ? " " + s : s;
             }
             case "LEN" -> {
+                // LEN of a TYPEd variable is the record's byte size.
+                if (c.args().size() == 1 && c.args().get(0) instanceof Ast.Var lenVar) {
+                    final TypeDef def = recordTypes.get(
+                            baseName(lenVar.name()).toLowerCase());
+                    if (def != null) {
+                        return (double) def.size;
+                    }
+                    final Cell cell = scopes.peek().get(canonical(lenVar.name()));
+                    if (cell != null && cell.value instanceof BasicRecord record) {
+                        return (double) record.def.size;
+                    }
+                }
                 return (double) str(singleArgValue(c), c.line()).length();
             }
+            case "INSTR" -> {
+                // INSTR([start,] haystack$, needle$): 1-based position, 0 = absent.
+                if (args.size() == 2) {
+                    final String haystack = str(eval(args.get(0)), c.line());
+                    final String needle = str(eval(args.get(1)), c.line());
+                    return (double) (haystack.indexOf(needle) + 1);
+                }
+                requireArgs(name, args, 3, c.line());
+                final int start = Math.max(1, toInt(eval(args.get(0)), c.line()));
+                final String haystack = str(eval(args.get(1)), c.line());
+                final String needle = str(eval(args.get(2)), c.line());
+                final int found = haystack.indexOf(needle, start - 1);
+                return (double) (found < 0 ? 0 : found + 1);
+            }
+            case "TAB" -> {
+                // PRINT TAB(n): move the cursor to column n (wraps forward).
+                final int target = toInt(singleArgValue(c), c.line());
+                if (target > vga.cursorColumn()) {
+                    vga.print(" ".repeat(target - vga.cursorColumn()));
+                } else {
+                    vga.newLine();
+                    vga.print(" ".repeat(target - 1));
+                }
+                return "";
+            }
+            case "PMAP" -> {
+                requireArgs(name, args, 2, c.line());
+                final double value = num(eval(args.get(0)), c.line());
+                final int which = toInt(eval(args.get(1)), c.line());
+                return single(vga.pmap(value, which));
+            }
+            case "MKS$" -> {
+                // MKS$(x): the 4-byte IEEE-754 little-endian image of a SINGLE.
+                final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
+                        .allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN);
+                buffer.putFloat((float) num(singleArgValue(c), c.line()));
+                return cp437String(buffer.array());
+            }
+            case "CVL" -> {
+                // CVL(s): a 4-byte little-endian string as a signed LONG.
+                final byte[] bytes = cp437Bytes(str(singleArgValue(c), c.line()));
+                int result = 0;
+                for (int i = Math.min(3, bytes.length - 1); i >= 0; i--) {
+                    result = (result << 8) | (bytes[i] & 0xFF);
+                }
+                return (double) result;
+            }
+            case "FREEFILE" -> {
+                // Lowest channel number (1-255) not currently open.
+                for (int channel = 1; channel <= 255; channel++) {
+                    if (!fileChannels.containsKey(channel) && !fileOutputs.containsKey(channel)
+                            && !binaryChannels.containsKey(channel)
+                            && !comChannels.contains(channel)
+                            && !consoleChannels.contains(channel)) {
+                        return (double) channel;
+                    }
+                }
+                return 0.0;
+            }
+            case "LOF" -> {
+                // LOF(n): the file's length in bytes.
+                return (double) binaryChannel(toInt(singleArgValue(c), c.line()), c.line()).length;
+            }
+            case "SEEK" -> {
+                // SEEK(n): the next position (record number for RANDOM,
+                // 1-based byte position otherwise).
+                return (double) binaryChannel(toInt(singleArgValue(c), c.line()), c.line())
+                        .position();
+            }
+            case "LOC" -> {
+                // LOC(n): bytes waiting (COM ports) — files report remaining.
+                final int channel = toInt(singleArgValue(c), c.line());
+                if (comChannels.contains(channel)) {
+                    return 0.0;
+                }
+                final FileHandle handle = binaryChannel(channel, c.line());
+                return (double) Math.max(0, handle.length - handle.pos);
+            }
             case "EOF" -> {
                 // -1 when the channel has no more input, 0 otherwise.
                 return fileEof(toInt(singleArgValue(c), c.line()), c.line()) ? -1.0 : 0.0;
@@ -1746,6 +2899,19 @@ public final class Interpreter {
                 }
                 return " ".repeat(n);
             }
+            case "STRING$" -> {
+                // STRING$(n, code) or STRING$(n, "char"): n repeats.
+                requireArgs(name, args, 2, c.line());
+                final int n = toInt(eval(args.get(0)), c.line());
+                if (n < 0) {
+                    throw error("Illegal function call", c.line());
+                }
+                final Object fill = eval(args.get(1));
+                final String ch = fill instanceof String fs
+                        ? (fs.isEmpty() ? " " : fs.substring(0, 1))
+                        : Cp437.charOf((int) num(fill, c.line()));
+                return ch.repeat(n);
+            }
             case "INKEY$" -> {
                 // Non-blocking: "" when no key is pending.
                 return inkey();
@@ -1754,7 +2920,17 @@ public final class Interpreter {
                 // INPUT$(n): read n input BYTES — an extended key yields
                 // CHR$(0) and its scancode in two successive reads.
                 // Interactive mode waits for keys; headless mode drains
-                // what is queued and returns.
+                // what is queued and returns. INPUT$(n, #ch) reads n bytes
+                // from a file channel (fewer at end of file; a COM port
+                // never has any).
+                if (args.size() == 2) {
+                    final int n = toInt(eval(args.get(0)), c.line());
+                    final int channel = toInt(eval(args.get(1)), c.line());
+                    if (comChannels.contains(channel)) {
+                        return "";
+                    }
+                    return cp437String(binaryChannel(channel, c.line()).read(n));
+                }
                 final int n = toInt(singleArgValue(c), c.line());
                 final StringBuilder sb = new StringBuilder();
                 while (sb.length() < n) {
@@ -2069,6 +3245,10 @@ public final class Interpreter {
      * programs test for; unmapped keys yield "".
      */
     private static String keyToQb(final KeyboardQueue.Key key) {
+        if (key.vkCode() < 0 && key.vkCode() >= KeyboardQueue.VK_ALT_BASE) {
+            // ALT+letter: extended 2-byte sequence with the BIOS scancode.
+            return "\0" + (char) (key.vkCode() - KeyboardQueue.VK_ALT_BASE);
+        }
         if (key.hasCharacter()) {
             return String.valueOf(key.character() == '\n' ? '\r' : key.character());
         }
@@ -2165,7 +3345,7 @@ public final class Interpreter {
         for (final Ast.ReadTarget target : s.targets()) {
             final String item = slot < items.length ? items[slot].trim() : "";
             slot++;
-            if (canonical(target.name()).endsWith("$")) {
+            if (isStringTarget(target, s.line())) {
                 setTarget(target, item, s.line());
             } else {
                 double value;
@@ -2179,6 +3359,16 @@ public final class Interpreter {
         }
     }
 
+    /** Whether an INPUT target takes text: a $ variable or a STRING record field. */
+    private boolean isStringTarget(final Ast.ReadTarget target, final int line) {
+        if (target.field() == null) {
+            return canonical(target.name()).endsWith("$");
+        }
+        final BasicRecord record = recordOf(target.name(), target.indices(), line);
+        final Ast.TypeField field = record.def.field(target.field());
+        return field != null && field.type().equalsIgnoreCase("STRING");
+    }
+
     /**
      * Reads one console line for INPUT. Printable keys echo and append,
      * Backspace erases, Enter finishes; extended keys are ignored.
@@ -2361,13 +3551,15 @@ public final class Interpreter {
         }
         final java.nio.file.Path base = currentDirectory != null ? currentDirectory : baseDirectory;
         final boolean absolute = normalized.startsWith("/");
+        // DOS-absolute paths ("\SUB\X") start at the drive root — in this
+        // sandbox the drive root is the .bas directory, not the host root.
         final java.nio.file.Path direct = absolute
-                ? java.nio.file.Path.of(normalized)
+                ? baseDirectory.resolve(normalized.substring(1))
                 : base.resolve(normalized);
         if (java.nio.file.Files.exists(direct)) {
             return direct;
         }
-        java.nio.file.Path current = absolute ? java.nio.file.Path.of("/") : base;
+        java.nio.file.Path current = absolute ? baseDirectory : base;
         for (final String component : normalized.split("/")) {
             if (component.isEmpty() || component.equals(".")) {
                 continue;
@@ -2394,6 +3586,15 @@ public final class Interpreter {
 
     /** EOF(n): fills the lookahead so EOF never consumes input. */
     private boolean fileEof(final int channel, final int line) {
+        // Byte-oriented channels (BINARY/RANDOM, or INPUT$ used on a text
+        // file) answer from the file pointer; COM ports are always empty.
+        if (comChannels.contains(channel)) {
+            return true;
+        }
+        final FileHandle handle = binaryChannels.get(channel);
+        if (handle != null) {
+            return handle.pos >= handle.length;
+        }
         final java.io.BufferedReader reader = fileChannels.get(channel);
         if (reader == null) {
             throw error("Bad file number: " + channel, line);
@@ -2415,6 +3616,7 @@ public final class Interpreter {
 
     private void closeChannel(final int channel, final int line) {
         final java.io.BufferedReader reader = fileChannels.remove(channel);
+        fileChannelPaths.remove(channel);
         fileLookahead.remove(channel);
         fileRemainder.remove(channel);
         if (reader == null) {
@@ -2448,10 +3650,39 @@ public final class Interpreter {
      */
     private void execPrintFile(final Ast.PrintStmt s) {
         final int channel = toInt(eval(s.channel()), s.line());
+        if (comChannels.contains(channel)) {
+            // Null modem: bytes go nowhere.
+            return;
+        }
+        if (consoleChannels.contains(channel)) {
+            // OPEN "CON" FOR OUTPUT: PRINT # writes to the screen.
+            for (final Ast.PrintStmt.Item item : s.items()) {
+                final Object value = eval(item.expr());
+                vga.print(value instanceof String text ? text
+                        : basicNumber(num(value, s.line()), exprIsDouble(item.expr())));
+            }
+            if (s.newline()) {
+                vga.newLine();
+            }
+            return;
+        }
         final java.io.BufferedWriter writer = fileOutputs.get(channel);
         if (writer == null) {
             throw error("Bad file number", s.line());
         }
+        if (s.usingFormat() != null) {
+            // PRINT #n, USING — same formatting as the console.
+            try {
+                writer.write(usingFormatted(s));
+                if (s.newline()) {
+                    writer.write("\r\n");
+                }
+                writer.flush();
+            } catch (final java.io.IOException e) {
+                throw error("File write error: " + e.getMessage(), s.line());
+            }
+            return;
+        }
         int column = fileOutputColumns.get(channel);
         try {
             for (final Ast.PrintStmt.Item item : s.items()) {
@@ -2570,7 +3801,43 @@ public final class Interpreter {
         if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed) && Math.abs(narrowed) < 1e15) {
             return Long.toString((long) narrowed);
         }
-        return Double.toString(narrowed);
+        return formatSingle7(narrowed);
+    }
+
+    /**
+     * Formats a SINGLE in the classic style: 7 significant digits of the
+     * value's exact float32 decimal expansion (rounded half-even at the 8th
+     * digit), trailing zeros trimmed, no leading zero on fractions, E±xx
+     * scientific notation outside the fixed range. PRINT VAL("12345.67")
+     * therefore shows 12345.67, not the raw 12345.669921875 float.
+     */
+    private static String formatSingle7(final double value) {
+        if (value == 0) {
+            return "0";
+        }
+        final String sign = value < 0 ? "-" : "";
+        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+        final java.math.BigDecimal rounded = bd.round(
+                new java.math.MathContext(7, java.math.RoundingMode.HALF_EVEN));
+        final int exponent = rounded.precision() - rounded.scale() - 1;
+        final String digits = rounded.unscaledValue().toString();
+        if (exponent > 6 || exponent < -3) {
+            final String fraction = digits.substring(1).replaceFirst("0+$", "");
+            final String mantissa = digits.charAt(0)
+                    + (fraction.isEmpty() ? "" : "." + fraction);
+            return sign + mantissa + "E" + (exponent >= 0 ? "+" : "-")
+                    + String.format("%02d", Math.abs(exponent));
+        }
+        if (exponent >= 0) {
+            final String intPart = exponent + 1 >= digits.length()
+                    ? digits + "0".repeat(exponent + 1 - digits.length())
+                    : digits.substring(0, exponent + 1);
+            final String fraction = exponent + 1 >= digits.length()
+                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+        }
+        return sign + "." + "0".repeat(-exponent - 1)
+                + digits.replaceFirst("0+$", "");
     }
 
     private double throwDivisionByZero(final int line) {
@@ -2578,6 +3845,22 @@ public final class Interpreter {
     }
 
     private InterpreterException error(final String message, final int line) {
+        lastErrorCode = switch (message) {
+            case String m when m.startsWith("File not found") -> 53;
+            case String m when m.startsWith("Path not found") -> 76;
+            case String m when m.startsWith("Path/file access error") -> 75;
+            case String m when m.startsWith("File already exists") -> 58;
+            case String m when m.startsWith("Bad file name") -> 64;
+            case String m when m.startsWith("Bad file number") -> 52;
+            case String m when m.startsWith("Input past end of file") -> 62;
+            case String m when m.startsWith("Out of DATA") -> 4;
+            case String m when m.startsWith("Illegal function call") -> 5;
+            case String m when m.startsWith("Overflow") -> 6;
+            case String m when m.startsWith("Subscript out of range") -> 9;
+            case String m when m.startsWith("Division by zero") -> 11;
+            case String m when m.startsWith("Type mismatch") -> 13;
+            default -> lastErrorCode;
+        };
         final StringBuilder sb = new StringBuilder(message).append(" at line ").append(line);
         if (sourceLines != null && line >= 1 && line <= sourceLines.length) {
             final String text = sourceLines[line - 1].trim();
index 28afced..692a5eb 100644 (file)
@@ -113,6 +113,10 @@ public final class Lexer {
                         || (source.charAt(pos) >= 'A' && source.charAt(pos) <= 'F'))) {
                     pos++;
                 }
+                // Optional LONG suffix: &HFF& — forces the 32-bit reading.
+                if (pos < length && source.charAt(pos) == '&') {
+                    pos++;
+                }
                 tokens.add(new Token(Token.Type.NUMBER, source.substring(start, pos), line));
                 continue;
             }
@@ -167,8 +171,9 @@ public final class Lexer {
 
             // Single-character operators. '#' appears standalone as the
             // file-channel prefix (OPEN ... AS #1); directly after an
-            // identifier it was already consumed as a type suffix.
-            if ("+-*/\\^=<>(),#".indexOf(c) >= 0 || c == ';') {
+            // identifier it was already consumed as a type suffix. '.'
+            // separates a TYPE member (var.field).
+            if ("+-*/\\^=<>(),#".indexOf(c) >= 0 || c == ';' || c == '.') {
                 tokens.add(new Token(Token.Type.OP, String.valueOf(c), line));
                 pos++;
                 continue;
@@ -183,7 +188,7 @@ public final class Lexer {
     }
 
     private static boolean isIdentChar(final char c) {
-        return Character.isLetterOrDigit(c) || c == '.' || c == '_';
+        return Character.isLetterOrDigit(c) || c == '_';
     }
 
     /** Thrown on un-tokenizable input. */
index 52276fa..6684212 100644 (file)
@@ -20,6 +20,8 @@ public final class Parser {
     private final List<Token> tokens;
     private final String[] sourceLines;
     private int pos;
+    /** User TYPE names seen so far (TYPE must precede use, per QB45). */
+    private final java.util.Set<String> knownTypes = new java.util.HashSet<>();
 
     private Parser(final List<Token> tokens, final String[] sourceLines) {
         this.tokens = tokens;
@@ -68,10 +70,7 @@ public final class Parser {
                 case "SCREEN" -> screenStatement();
                 case "LOCATE" -> locateStatement();
                 case "PRINT", "?" -> printStatement();
-                case "CLS" -> {
-                    next();
-                    yield new Ast.ClsStmt(token.line());
-                }
+                case "CLS" -> clsStatement(token);
                 case "COLOR" -> colorStatement();
                 case "WIDTH" -> widthStatement();
                 case "SWAP" -> swapStatement();
@@ -100,11 +99,44 @@ public final class Parser {
                 case "DATA" -> dataStatement();
                 case "READ" -> readStatement();
                 case "VIEW" -> viewStatement();
+                case "WINDOW" -> windowStatement();
+                case "PALETTE" -> paletteStatement();
+                case "DRAW" -> {
+                    next();
+                    yield new Ast.DrawStmt(expression(), token.line());
+                }
                 case "PAINT" -> paintStatement();
                 case "PCOPY" -> pcopyStatement();
                 case "GET" -> getStatement();
                 case "PUT" -> putStatement();
                 case "MID$" -> midStatement();
+                case "EXIT" -> exitStatement(token);
+                case "STATIC" -> staticStatement(token);
+                case "SHARED" -> sharedStatement(token);
+                case "TYPE" -> typeStatement(token);
+                case "NAME" -> {
+                    next();
+                    final Ast.Expr oldPath = expression();
+                    expectText("AS");
+                    yield new Ast.NameStmt(oldPath, expression(), token.line());
+                }
+                case "KILL" -> {
+                    next();
+                    yield new Ast.KillStmt(expression(), token.line());
+                }
+                case "SHELL" -> {
+                    next();
+                    yield new Ast.ShellStmt(
+                            peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)
+                                    ? null : expression(), token.line());
+                }
+                case "SEEK" -> {
+                    next();
+                    expectOp("#");
+                    final Ast.Expr channel = expression();
+                    expectOp(",");
+                    yield new Ast.SeekStmt(channel, expression(), token.line());
+                }
                 case "LET" -> {
                     // LET x = expr — the explicit-assignment keyword.
                     next();
@@ -184,7 +216,7 @@ public final class Parser {
         return label.text();
     }
 
-    /** {@code SUB name [(param [AS type], ...)] ... END SUB} */
+    /** {@code SUB name [(param [AS type], ...)] [STATIC] ... END SUB} */
     private Ast.Stmt subStatement() {
         final Token keyword = next(); // SUB
         final Token name = expect(Token.Type.IDENT);
@@ -200,13 +232,20 @@ public final class Parser {
             }
             expectOp(")");
         }
+        // Optional STATIC attribute: locals would persist across calls;
+        // parsed and recorded (semantics in the interpreter).
+        boolean isStatic = false;
+        if (isAny("STATIC")) {
+            next();
+            isStatic = true;
+        }
         final List<Ast.Stmt> body = procedureBody("SUB");
         expectText("END");
         expectText("SUB");
-        return new Ast.SubStmt(name.text(), params, body, keyword.line());
+        return new Ast.SubStmt(name.text(), params, body, keyword.line(), isStatic);
     }
 
-    /** {@code FUNCTION name [(param, ...)] ... END FUNCTION} */
+    /** {@code FUNCTION name [(param, ...)] [STATIC] ... END FUNCTION} */
     private Ast.Stmt functionStatement() {
         final Token keyword = next(); // FUNCTION
         final Token name = expect(Token.Type.IDENT);
@@ -222,10 +261,15 @@ public final class Parser {
             }
             expectOp(")");
         }
+        boolean isStatic = false;
+        if (isAny("STATIC")) {
+            next();
+            isStatic = true;
+        }
         final List<Ast.Stmt> body = procedureBody("FUNCTION");
         expectText("END");
         expectText("FUNCTION");
-        return new Ast.FunctionStmt(name.text(), params, body, keyword.line());
+        return new Ast.FunctionStmt(name.text(), params, body, keyword.line(), isStatic);
     }
 
     /**
@@ -322,22 +366,38 @@ public final class Parser {
      */
     private Ast.Param param() {
         final Token name = expect(Token.Type.IDENT);
+        Ast.Param result;
         if (isAny("AS")) {
             next();
             final Token type = expect(Token.Type.IDENT); // SINGLE, INTEGER, ...
-            final char last = name.text().charAt(name.text().length() - 1);
-            if ("%&!#$".indexOf(last) >= 0) {
-                return new Ast.Param(name.text(), true); // Already suffixed; keep it.
+            // A user TYPE name: the parameter passes a record by reference
+            // and keeps its bare name (no suffix folding).
+            if (knownTypes.contains(type.text().toUpperCase())) {
+                result = new Ast.Param(name.text(), true, type.text());
+            } else {
+                final char last = name.text().charAt(name.text().length() - 1);
+                if ("%&!#$".indexOf(last) >= 0) {
+                    result = new Ast.Param(name.text(), true); // Already suffixed; keep it.
+                } else {
+                    result = new Ast.Param(name.text() + switch (type.text().toUpperCase()) {
+                        case "INTEGER" -> '%';
+                        case "LONG" -> '&';
+                        case "DOUBLE" -> '#';
+                        case "STRING" -> '$';
+                        default -> '!'; // SINGLE and anything else
+                    }, true);
+                }
             }
-            return new Ast.Param(name.text() + switch (type.text().toUpperCase()) {
-                case "INTEGER" -> '%';
-                case "LONG" -> '&';
-                case "DOUBLE" -> '#';
-                case "STRING" -> '$';
-                default -> '!'; // SINGLE and anything else
-            }, true);
+        } else {
+            result = new Ast.Param(name.text(), false);
+        }
+        // Array parameter: Label$(), Value() — the whole array is shared.
+        if (peek().isText("(")) {
+            next();
+            expectOp(")");
+            result = new Ast.Param(result.name(), result.asTyped(), result.asType(), true);
         }
-        return new Ast.Param(name.text(), false);
+        return result;
     }
 
     /** {@code CALL name [(arg, ...)]} */
@@ -470,32 +530,50 @@ public final class Parser {
         return new Ast.SleepStmt(seconds, keyword.line());
     }
 
-    /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */
+    /** {@code OPEN path FOR INPUT|OUTPUT|APPEND|BINARY|RANDOM AS [#]channel [LEN = n]}. */
     private Ast.Stmt openStatement() {
         final Token keyword = next(); // OPEN
         final Ast.Expr path = expression();
         expectText("FOR");
         final Token mode = expect(Token.Type.IDENT);
-        if (!"INPUT".equalsIgnoreCase(mode.text()) && !"OUTPUT".equalsIgnoreCase(mode.text())
-                && !"APPEND".equalsIgnoreCase(mode.text())) {
-            throw error("OPEN: only FOR INPUT, FOR OUTPUT and FOR APPEND are supported", mode);
+        if (!List.of("INPUT", "OUTPUT", "APPEND", "BINARY", "RANDOM")
+                .contains(mode.text().toUpperCase())) {
+            throw error("OPEN: expected INPUT, OUTPUT, APPEND, BINARY or RANDOM after FOR", mode);
         }
         expectText("AS");
-        expectOp("#");
+        // The '#' before the channel number is optional (OPEN ... AS FileNum).
+        if (peek().isText("#")) {
+            next();
+        }
         final Ast.Expr channel = expression();
-        return new Ast.OpenStmt(path, mode.text().toUpperCase(), channel, keyword.line());
+        Ast.Expr recordLength = null;
+        if (isAny("LEN")) {
+            next();
+            expectOp("=");
+            recordLength = expression();
+        }
+        return new Ast.OpenStmt(path, mode.text().toUpperCase(), channel, recordLength,
+                keyword.line());
     }
 
-    /** {@code CLOSE [#channel]}. */
+    /** {@code CLOSE [#channel], [[#]channel, ...]}. */
     private Ast.Stmt closeStatement() {
         final Token keyword = next(); // CLOSE
         if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
-            return new Ast.CloseStmt(null, keyword.line());
+            return new Ast.CloseStmt((List<Ast.Expr>) null, keyword.line());
         }
-        if (peek().isText("#")) {
+        final List<Ast.Expr> channels = new ArrayList<>();
+        while (true) {
+            if (peek().isText("#")) {
+                next();
+            }
+            channels.add(expression());
+            if (!peek().isText(",")) {
+                break;
+            }
             next();
         }
-        return new Ast.CloseStmt(expression(), keyword.line());
+        return new Ast.CloseStmt(channels, keyword.line());
     }
 
     /** {@code INPUT [#channel, | [;]["prompt"{;|,}]] var, ...}. */
@@ -590,12 +668,39 @@ public final class Parser {
         throw error("Expected NEXT or label after RESUME", target);
     }
 
-    /** {@code DATA value, ...} — comma-separated constants. */
+    /**
+     * {@code DATA value, ...} — comma-separated constants. Items are
+     * LITERALS, not expressions: unquoted text is a string constant
+     * (DATA January reads "January"), numbers parse as numeric literals
+     * (optional leading sign).
+     */
     private Ast.Stmt dataStatement() {
         final Token keyword = next(); // DATA
         final List<Ast.Expr> values = new ArrayList<>();
         while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
-            values.add(expression());
+            boolean negative = false;
+            if (peek().is(Token.Type.OP) && peek().isText("-")) {
+                next();
+                negative = true;
+            } else if (peek().is(Token.Type.OP) && peek().isText("+")) {
+                next();
+            }
+            final Token item = peek();
+            if (item.is(Token.Type.STRING)) {
+                next();
+                values.add(new Ast.Str(item.text(), item.line()));
+            } else if (item.is(Token.Type.NUMBER)) {
+                next();
+                final Ast.Num num = parseNum(item);
+                values.add(negative
+                        ? new Ast.Num(-num.value(), num.doublePrecision(), item.line()) : num);
+            } else if (item.is(Token.Type.IDENT)) {
+                // Unquoted text: a string constant up to the comma/EOL.
+                next();
+                values.add(new Ast.Str(item.text(), item.line()));
+            } else {
+                throw error("Expected a DATA constant", item);
+            }
             if (peek().isText(",")) {
                 next();
             } else {
@@ -620,7 +725,8 @@ public final class Parser {
         return new Ast.ReadStmt(targets, keyword.line());
     }
 
-    /** One assignment target: a plain variable or an array element {@code name(i, j)}. */
+    /** One assignment target: a plain variable, an array element {@code name(i, j)},
+     *  or a TYPE member {@code var.field} / {@code array(i).field}. */
     private Ast.ReadTarget readTarget() {
         final Token name = expect(Token.Type.IDENT);
         final List<Ast.Expr> indices = new ArrayList<>();
@@ -633,7 +739,12 @@ public final class Parser {
             }
             expectOp(")");
         }
-        return new Ast.ReadTarget(name.text(), indices);
+        String field = null;
+        if (peek().isText(".")) {
+            next(); // .
+            field = expect(Token.Type.IDENT).text();
+        }
+        return new Ast.ReadTarget(name.text(), indices, field);
     }
 
     /** {@code WIDTH [columns][, rows]} — text grid size (SCREEN 0). */
@@ -660,18 +771,243 @@ public final class Parser {
         return new Ast.SwapStmt(first, second, keyword.line());
     }
 
-    /** {@code VIEW PRINT [top TO bottom]} (graphics VIEW is unsupported). */
+    /** {@code CLS [n]} — an optional argument selects what to clear. */
+    private Ast.Stmt clsStatement(final Token keyword) {
+        next(); // CLS
+        final Ast.Expr what = peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)
+                ? null : expression();
+        return new Ast.ClsStmt(what, keyword.line());
+    }
+
+    /** {@code EXIT FOR|DO|SUB|FUNCTION}. */
+    private Ast.Stmt exitStatement(final Token keyword) {
+        next(); // EXIT
+        final Token kind = expect(Token.Type.IDENT);
+        final String what = kind.text().toUpperCase();
+        if (!List.of("FOR", "DO", "SUB", "FUNCTION", "WHILE").contains(what)) {
+            throw error("Expected FOR, DO, SUB or FUNCTION after EXIT", kind);
+        }
+        return new Ast.ExitStmt(what, keyword.line());
+    }
+
+    /** {@code STATIC name, name, ...} — procedure-local persistent variables. */
+    private Ast.Stmt staticStatement(final Token keyword) {
+        next(); // STATIC
+        final List<Ast.ReadTarget> names = new ArrayList<>();
+        names.add(readTarget());
+        while (peek().isText(",")) {
+            next();
+            names.add(readTarget());
+        }
+        return new Ast.StaticStmt(names, keyword.line());
+    }
+
+    /** {@code SHARED name[()][ AS type], ...} — procedure sees module variables. */
+    private Ast.Stmt sharedStatement(final Token keyword) {
+        next(); // SHARED
+        final List<Ast.SharedEntry> entries = new ArrayList<>();
+        while (true) {
+            final Token name = expect(Token.Type.IDENT);
+            boolean array = false;
+            if (peek().isText("(")) {
+                next();
+                expectOp(")");
+                array = true;
+            }
+            String type = null;
+            if (isAny("AS")) {
+                next();
+                type = expect(Token.Type.IDENT).text();
+                if ("STRING".equalsIgnoreCase(type) && peek().isText("*")) {
+                    next(); // *
+                    expression(); // fixed length — dynamic strings here
+                }
+            }
+            entries.add(new Ast.SharedEntry(name.text(), array, type));
+            if (!peek().isText(",")) {
+                break;
+            }
+            next();
+        }
+        return new Ast.SharedStmt(entries, keyword.line());
+    }
+
+    /** {@code TYPE name / field AS type ... END TYPE}. */
+    private Ast.Stmt typeStatement(final Token keyword) {
+        next(); // TYPE
+        final Token name = expect(Token.Type.IDENT);
+        final List<Ast.TypeField> fields = new ArrayList<>();
+        skipSeparators();
+        while (!isAny("END")) {
+            final Token field = expect(Token.Type.IDENT);
+            expectText("AS");
+            final Token type = expect(Token.Type.IDENT);
+            int stringLength = 0;
+            if ("STRING".equalsIgnoreCase(type.text()) && peek().isText("*")) {
+                next(); // *
+                stringLength = (int) numberLiteral();
+            }
+            fields.add(new Ast.TypeField(field.text(), type.text(), stringLength));
+            skipSeparators();
+        }
+        expectText("END");
+        expectText("TYPE");
+        knownTypes.add(name.text().toUpperCase());
+        return new Ast.TypeDefStmt(name.text(), fields, keyword.line());
+    }
+
+    /** Reads a NUMBER token as an int (TYPE field lengths). */
+    private double numberLiteral() {
+        final Token token = expect(Token.Type.NUMBER);
+        return parseNum(token).value();
+    }
+
+    /** {@code VIEW PRINT ...}, graphics {@code VIEW (…)…}, or a bare VIEW (reset). */
     private Ast.Stmt viewStatement() {
         final Token keyword = next(); // VIEW
-        expectText("PRINT");
-        Ast.Expr top = null;
-        Ast.Expr bottom = null;
-        if (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
-            top = expression();
-            expectText("TO");
-            bottom = expression();
+        if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+            // Bare VIEW: back to the full screen.
+            return new Ast.ViewportStmt(null, null, null, null, null, null, keyword.line());
+        }
+        if (isAny("PRINT")) {
+            next();
+            Ast.Expr top = null;
+            Ast.Expr bottom = null;
+            if (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+                top = expression();
+                expectText("TO");
+                bottom = expression();
+            }
+            return new Ast.ViewPrintStmt(top, bottom, keyword.line());
+        }
+        expectOp("(");
+        final Ast.Expr x1 = expression();
+        expectOp(",");
+        final Ast.Expr y1 = expression();
+        expectOp(")");
+        expectOp("-");
+        expectOp("(");
+        final Ast.Expr x2 = expression();
+        expectOp(",");
+        final Ast.Expr y2 = expression();
+        expectOp(")");
+        // Optional ", fill, border" — either slot may be empty (VIEW ..., , 1).
+        Ast.Expr fill = null;
+        Ast.Expr border = null;
+        if (peek().isText(",")) {
+            next();
+            if (!peek().isText(",") && !peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+                fill = expression();
+            }
+            if (peek().isText(",")) {
+                next();
+                border = expression();
+            }
+        }
+        return new Ast.ViewportStmt(x1, y1, x2, y2, fill, border, keyword.line());
+    }
+
+    /** {@code WINDOW [(x1, y1)-(x2, y2)]} — bare form resets the mapping. */
+    private Ast.Stmt windowStatement() {
+        final Token keyword = next(); // WINDOW
+        if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+            return new Ast.WindowStmt(null, null, null, null, keyword.line());
+        }
+        // WINDOW SCREEN keeps screen coordinate order (y1 on top).
+        boolean screenCoords = false;
+        if (isAny("SCREEN")) {
+            next();
+            screenCoords = true;
+        }
+        expectOp("(");
+        final Ast.Expr x1 = expression();
+        expectOp(",");
+        final Ast.Expr y1 = expression();
+        expectOp(")");
+        expectOp("-");
+        expectOp("(");
+        final Ast.Expr x2 = expression();
+        expectOp(",");
+        final Ast.Expr y2 = expression();
+        expectOp(")");
+        return new Ast.WindowStmt(x1, y1, x2, y2, screenCoords, keyword.line());
+    }
+
+    /** {@code PALETTE [color, newColor | USING array[(i)]]}. */
+    private Ast.Stmt paletteStatement() {
+        final Token keyword = next(); // PALETTE
+        if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+            return new Ast.PaletteStmt(null, null, null, List.of(), keyword.line());
         }
-        return new Ast.ViewPrintStmt(top, bottom, keyword.line());
+        if (isAny("USING")) {
+            next();
+            final Token array = expect(Token.Type.IDENT);
+            return new Ast.PaletteStmt(null, null, array.text(), arrayOffsetIndices(), keyword.line());
+        }
+        final Ast.Expr color = expression();
+        expectOp(",");
+        return new Ast.PaletteStmt(color, expression(), null, List.of(), keyword.line());
+    }
+
+    /** {@code GET/PUT} — graphics form when '(' follows, file form on '#'. */
+    private Ast.Stmt getStatement() {
+        final Token keyword = next(); // GET
+        if (peek().isText("#")) {
+            next(); // #
+            final Ast.Expr channel = expression();
+            expectOp(",");
+            Ast.Expr position = null;
+            if (!peek().isText(",")) {
+                position = expression();
+            }
+            expectOp(",");
+            return new Ast.GetFileStmt(channel, position, readTarget(), keyword.line());
+        }
+        expectOp("(");
+        final Ast.Expr x1 = expression();
+        expectOp(",");
+        final Ast.Expr y1 = expression();
+        expectOp(")");
+        expectOp("-");
+        expectOp("(");
+        final Ast.Expr x2 = expression();
+        expectOp(",");
+        final Ast.Expr y2 = expression();
+        expectOp(")");
+        expectOp(",");
+        final Token array = expect(Token.Type.IDENT);
+        return new Ast.GetStmt(x1, y1, x2, y2, array.text(),
+                arrayOffsetIndices(), keyword.line());
+    }
+
+    /** {@code PUT} — graphics form when '(' follows, file form on '#'. */
+    private Ast.Stmt putStatement() {
+        final Token keyword = next(); // PUT
+        if (peek().isText("#")) {
+            next(); // #
+            final Ast.Expr channel = expression();
+            expectOp(",");
+            Ast.Expr position = null;
+            if (!peek().isText(",")) {
+                position = expression();
+            }
+            expectOp(",");
+            return new Ast.PutFileStmt(channel, position, readTarget(), keyword.line());
+        }
+        expectOp("(");
+        final Ast.Expr x = expression();
+        expectOp(",");
+        final Ast.Expr y = expression();
+        expectOp(")");
+        expectOp(",");
+        final Token array = expect(Token.Type.IDENT);
+        final List<Ast.Expr> indices = arrayOffsetIndices();
+        String action = "XOR";
+        if (peek().isText(",")) {
+            next();
+            action = expect(Token.Type.IDENT).text().toUpperCase();
+        }
+        return new Ast.PutStmt(x, y, array.text(), indices, action, keyword.line());
     }
 
     /** {@code PAINT (x, y)[, fillColor[, borderColor]]} */
@@ -747,26 +1083,6 @@ public final class Parser {
         return new Ast.Bound(null, first);
     }
 
-    /** {@code GET (x1, y1)-(x2, y2), arrayName[(i, j)]} */
-    private Ast.Stmt getStatement() {
-        final Token keyword = next(); // GET
-        expectOp("(");
-        final Ast.Expr x1 = expression();
-        expectOp(",");
-        final Ast.Expr y1 = expression();
-        expectOp(")");
-        expectOp("-");
-        expectOp("(");
-        final Ast.Expr x2 = expression();
-        expectOp(",");
-        final Ast.Expr y2 = expression();
-        expectOp(")");
-        expectOp(",");
-        final Token array = expect(Token.Type.IDENT);
-        return new Ast.GetStmt(x1, y1, x2, y2, array.text(),
-                arrayOffsetIndices(), keyword.line());
-    }
-
     /** Optional {@code (i, j)} array-element anchor after a GET/PUT array name. */
     private List<Ast.Expr> arrayOffsetIndices() {
         final List<Ast.Expr> indices = new ArrayList<>();
@@ -783,25 +1099,6 @@ public final class Parser {
         return indices;
     }
 
-    /** {@code PUT (x, y), arrayName[(i, j)][, PSET|PRESET|XOR|OR|AND]} — default action is XOR. */
-    private Ast.Stmt putStatement() {
-        final Token keyword = next(); // PUT
-        expectOp("(");
-        final Ast.Expr x = expression();
-        expectOp(",");
-        final Ast.Expr y = expression();
-        expectOp(")");
-        expectOp(",");
-        final Token array = expect(Token.Type.IDENT);
-        final List<Ast.Expr> indices = arrayOffsetIndices();
-        String action = "XOR";
-        if (peek().isText(",")) {
-            next();
-            action = expect(Token.Type.IDENT).text().toUpperCase();
-        }
-        return new Ast.PutStmt(x, y, array.text(), indices, action, keyword.line());
-    }
-
     /** {@code MID$(target, start[, length]) = replacement} — in-place string overwrite. */
     private Ast.Stmt midStatement() {
         final Token keyword = next(); // MID$
@@ -859,13 +1156,27 @@ public final class Parser {
 
     private Ast.Stmt locateStatement() {
         final Token keyword = next(); // LOCATE
-        final Ast.Expr row = expression();
-        Ast.Expr col = null;
-        if (peek().isText(",")) {
+        // LOCATE [row][, [col][, [cursor][, [start][, stop]]]] — any slot
+        // may be left empty (LOCATE , , 1, 0, 13).
+        final List<Ast.Expr> slots = new ArrayList<>();
+        if (!peek().isText(",") && !peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            slots.add(expression());
+        } else {
+            slots.add(null);
+        }
+        while (peek().isText(",") && slots.size() < 5) {
             next(); // ,
-            col = expression();
+            if (peek().isText(",") || peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+                slots.add(null);
+            } else {
+                slots.add(expression());
+            }
         }
-        return new Ast.LocateStmt(row, col, keyword.line());
+        return new Ast.LocateStmt(slots.get(0),
+                slots.size() > 1 ? slots.get(1) : null,
+                slots.size() > 2 ? slots.get(2) : null,
+                slots.size() > 3 ? slots.get(3) : null,
+                slots.size() > 4 ? slots.get(4) : null, keyword.line());
     }
 
     private Ast.Stmt printStatement() {
@@ -877,6 +1188,17 @@ public final class Parser {
             channel = expression();
             expectOp(",");
         }
+        // PRINT USING format$; items — formatted output.
+        Ast.Expr usingFormat = null;
+        if (isAny("USING")) {
+            next();
+            usingFormat = expression();
+            if (peek().is(Token.Type.OP) && (peek().isText(";") || peek().isText(","))) {
+                next();
+            } else {
+                throw error("PRINT USING: expected ';' or ',' after the format", peek());
+            }
+        }
         final List<Ast.PrintStmt.Item> items = new ArrayList<>();
         boolean newline = true;
         while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)
@@ -900,7 +1222,7 @@ public final class Parser {
                 }
             }
         }
-        return new Ast.PrintStmt(channel, items, newline, keyword.line());
+        return new Ast.PrintStmt(channel, items, newline, keyword.line(), usingFormat);
     }
 
     private Ast.Stmt colorStatement() {
@@ -932,15 +1254,29 @@ public final class Parser {
     private Ast.Stmt lineStatement() {
         final Token keyword = next(); // LINE
         if (isAny("INPUT")) {
-            // LINE INPUT #channel, var$ — file input, not graphics.
             next();
-            expectOp("#");
-            final Ast.Expr channel = expression();
-            expectOp(",");
-            return new Ast.LineInputStmt(channel, readTarget(), keyword.line());
+            if (peek().isText("#")) {
+                // LINE INPUT #channel, var$ — file input, not graphics.
+                next();
+                final Ast.Expr channel = expression();
+                expectOp(",");
+                return new Ast.LineInputStmt(channel, readTarget(), keyword.line());
+            }
+            // Console LINE INPUT ["prompt"{;|,}] var$ — whole line, no "? ".
+            Ast.Expr prompt = null;
+            if (peek().is(Token.Type.STRING)) {
+                prompt = expression();
+                if (peek().is(Token.Type.OP) && (peek().isText(";") || peek().isText(","))) {
+                    next();
+                } else {
+                    throw error("LINE INPUT: expected ';' or ',' after the prompt", peek());
+                }
+            }
+            return new Ast.LineInputConsoleStmt(prompt, readTarget(), keyword.line());
         }
         Ast.Expr x1 = null;
         Ast.Expr y1 = null;
+        boolean step = false;
         if (peek().isText("-")) {
             // LINE -(x2, y2): start from the graphics cursor (last point
             // referenced) — x1/y1 stay null (Interferogram.bas).
@@ -953,6 +1289,11 @@ public final class Parser {
             expectOp(")");
             expectOp("-");
         }
+        // Relative endpoint: LINE (x1, y1)-STEP(dx, dy).
+        if (isAny("STEP")) {
+            next();
+            step = true;
+        }
         expectOp("(");
         final Ast.Expr x2 = expression();
         expectOp(",");
@@ -961,9 +1302,14 @@ public final class Parser {
         Ast.Expr color = null;
         boolean box = false;
         boolean fill = false;
+        Ast.Expr style = null;
         if (peek().isText(",")) {
             next();
-            color = expression();
+            // The color slot may be empty: LINE (x, y)-(x2, y2), , B
+            if (!peek().isText(",") && !peek().isText("B") && !peek().isText("BF")
+                    && !peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+                color = expression();
+            }
             if (peek().isText(",")) {
                 next();
                 if (peek().isText("BF")) {
@@ -973,12 +1319,15 @@ public final class Parser {
                 } else if (peek().isText("B")) {
                     next();
                     box = true;
-                } else {
-                    throw error("Expected B or BF", peek());
+                }
+                // 5th slot: a 16-bit line-style bitmask (dashed lines).
+                if (peek().isText(",")) {
+                    next();
+                    style = expression();
                 }
             }
         }
-        return new Ast.LineStmt(x1, y1, x2, y2, color, box, fill, keyword.line());
+        return new Ast.LineStmt(x1, y1, x2, y2, color, box, fill, step, style, keyword.line());
     }
 
     private Ast.Stmt circleStatement() {
@@ -1045,6 +1394,14 @@ public final class Parser {
             next(); // =
             return new Ast.AssignStmt(name.text(), List.of(), expression(), name.line());
         }
+        if (peek().isText(".")) {
+            // TYPE member assignment: var.field = expr
+            next(); // .
+            final Token field = expect(Token.Type.IDENT);
+            expectOp("=");
+            return new Ast.AssignStmt(name.text(), List.of(), expression(), name.line(),
+                    field.text());
+        }
         if (peek().isText("(")) {
             // array(i, j) = value — or a parenthesized SUB call
             next(); // (
@@ -1055,6 +1412,14 @@ public final class Parser {
                 indices.add(expression());
             }
             expectOp(")");
+            if (peek().isText(".")) {
+                // TYPE member of an array element: array(i).field = expr
+                next(); // .
+                final Token field = expect(Token.Type.IDENT);
+                expectOp("=");
+                return new Ast.AssignStmt(name.text(), indices, expression(), name.line(),
+                        field.text());
+            }
             if (peek().isText("=")) {
                 next(); // =
                 return new Ast.AssignStmt(name.text(), indices, expression(), name.line());
@@ -1238,9 +1603,10 @@ public final class Parser {
 
     private Ast.Expr orExpression() {
         Ast.Expr left = andExpression();
-        while (peek().is(Token.Type.IDENT) && peek().isText("OR")) {
+        while (peek().is(Token.Type.IDENT)
+                && (peek().isText("OR") || peek().isText("XOR"))) {
             final Token op = next();
-            left = new Ast.Binary("OR", left, andExpression(), op.line());
+            left = new Ast.Binary(op.text().toUpperCase(), left, andExpression(), op.line());
         }
         return left;
     }
@@ -1333,12 +1699,29 @@ public final class Parser {
                     args.add(expression());
                     while (peek().is(Token.Type.OP) && peek().isText(",")) {
                         next();
+                        // A '#' prefix marks a file channel in call
+                        // arguments (INPUT$(n, #1)): cosmetic, dropped.
+                        if (peek().is(Token.Type.OP) && peek().isText("#")) {
+                            next();
+                        }
                         args.add(expression());
                     }
                 }
                 expectOp(")");
+                // TYPE member read: name(args).field
+                if (peek().isText(".")) {
+                    next(); // .
+                    final Token field = expect(Token.Type.IDENT);
+                    return new Ast.Member(token.text(), args, field.text(), token.line());
+                }
                 return new Ast.Call(token.text(), args, token.line());
             }
+            if (peek().isText(".")) {
+                // TYPE member read: var.field
+                next(); // .
+                final Token field = expect(Token.Type.IDENT);
+                return new Ast.Member(token.text(), List.of(), field.text(), token.line());
+            }
             return new Ast.Var(token.text(), token.line());
         }
         if (token.is(Token.Type.OP) && token.isText("(")) {
@@ -1354,24 +1737,48 @@ public final class Parser {
      * Builds a Num literal from a NUMBER token. A trailing '#' suffix (or a
      * D exponent) makes it a DOUBLE constant: it keeps full double
      * precision instead of narrowing to SINGLE like other literals.
+     * INTEGRAL literals (hex/octal, or decimal with no fraction/exponent)
+     * are exact integers in this dialect — never SINGLE-narrowed either
+     * (PRINT &H7FFFFFFF must give 2147483647, and X& AND &H7FFFFFFF must
+     * not see 2^31).
      */
     private static Ast.Num parseNum(final Token token) {
         String text = token.text();
         final char last = text.charAt(text.length() - 1);
-        final boolean doublePrecision = last == '#' || text.indexOf('D') >= 0 || text.indexOf('d') >= 0;
+        final boolean hexOctal = text.startsWith("&H") || text.startsWith("&h")
+                || text.startsWith("&O") || text.startsWith("&o");
+        boolean doublePrecision = last == '#' || text.indexOf('D') >= 0 || text.indexOf('d') >= 0;
         if ("%&!#".indexOf(last) >= 0) {
             text = text.substring(0, text.length() - 1);
         }
+        // Decimal integer (no fraction, no exponent) or any hex/octal literal.
+        if (hexOctal || (!text.contains(".") && !text.contains("E") && !text.contains("e"))) {
+            doublePrecision = true; // exact integer: skip SINGLE narrowing
+        }
         return new Ast.Num(parseNumber(text, token.line()), doublePrecision, token.line());
     }
 
     private static double parseNumber(final String text, final int line) {
         try {
-            if (text.startsWith("&H") || text.startsWith("&h")) {
-                return Integer.parseInt(text.substring(2), 16);
-            }
-            if (text.startsWith("&O") || text.startsWith("&o")) {
-                return Integer.parseInt(text.substring(2), 8);
+            if (text.startsWith("&H") || text.startsWith("&h")
+                    || text.startsWith("&O") || text.startsWith("&o")) {
+                final boolean hex = text.charAt(1) == 'H' || text.charAt(1) == 'h';
+                String digits = text.substring(2);
+                final boolean longSuffix = digits.endsWith("&");
+                if (longSuffix) {
+                    digits = digits.substring(0, digits.length() - 1);
+                }
+                final long raw = Long.parseLong(digits, hex ? 16 : 8);
+                // QB45 signed-literal semantics: without a suffix and at
+                // most 4 hex (3 octal-16-bit) digits the value is INTEGER —
+                // it wraps to signed 16 bits (&HFFFF = -1). Longer or
+                // &-suffixed literals are LONG and wrap to signed 32 bits
+                // (&H80000000 = -2147483648, &HFF& = 255, &HFFFF& = 65535).
+                if (longSuffix || digits.length() > (hex ? 4 : 6)) {
+                    return (int) (raw & 0xFFFFFFFFL);
+                }
+                final int v = (int) (raw & 0xFFFF);
+                return v >= 0x8000 ? v - 0x10000 : v;
             }
             return Double.parseDouble(text.replace('D', 'E').replace('d', 'e'));
         } catch (final NumberFormatException e) {
index 0db9f6c..af2514c 100644 (file)
@@ -66,6 +66,11 @@ final class DefaultPalettes {
         // Utility class.
     }
 
+    /** @return the default 0xRRGGBB color of EGA palette entry 0-15 */
+    static int ega16(final int index) {
+        return EGA_16[index & 15];
+    }
+
     /** Fills the palette with the mode's default colors. */
     static void apply(final int[] palette, final ScreenMode mode) {
         if (mode == ScreenMode.SCREEN_13) {
index 057d40a..d053db9 100644 (file)
@@ -175,6 +175,11 @@ public final class Framebuffer {
         return palette[index & 0xFF];
     }
 
+    /** Direct palette array access (PALETTE's default-restore writes here). */
+    int[] palette() {
+        return palette;
+    }
+
     /**
      * Converts a page to a displayable image, resolving palette indices to
      * actual colors at this point (and only at this point).
index ca02d42..b0bd6e5 100644 (file)
@@ -148,6 +148,13 @@ public final class TextConsole {
         pendingWrap = false;
     }
 
+    /** Homes the text cursor without clearing (part of bordered-VIEW CLS). */
+    public void homeCursor() {
+        cursorRow = scrollTop;
+        cursorCol = 1;
+        pendingWrap = false;
+    }
+
     /** @return {@code CSRLIN}: 1-based cursor row */
     public int cursorRow() {
         return cursorRow;
index c088e53..af75796 100644 (file)
@@ -19,8 +19,22 @@ public final class VgaDevice {
     private TextConsole[] consoles;
     private int activePage;
     private int visualPage;
-    private int graphicsCursorX;
-    private int graphicsCursorY;
+    private double graphicsCursorX;
+    private double graphicsCursorY;
+
+    // ---- VIEW viewport + WINDOW logical coordinates ---------------------
+    /** Graphics viewport in physical pixels; default = whole screen. */
+    private int viewX1, viewY1, viewX2, viewY2;
+    private boolean viewportActive;
+    /** WINDOW logical corners; when set, all graphics input coords are logical. */
+    private boolean windowActive;
+    /** WINDOW SCREEN: y1 maps to the viewport top (default: Cartesian y-up). */
+    private boolean windowScreen;
+    private double winX1, winY1, winX2, winY2;
+    /** The current VIEW drew a border ring: CLS clears only its interior. */
+    private boolean viewportBorder;
+    /** Text cursor visibility/shape (LOCATE row, col, vis, start, stop) — cosmetic. */
+    private boolean cursorVisible;
 
     /** Creates the device in the default mode: SCREEN 0, 80x25 text. */
     public VgaDevice() {
@@ -32,11 +46,39 @@ public final class VgaDevice {
      * resetting the palette to mode defaults and clearing the screen.
      */
     public void setMode(final ScreenMode newMode) {
+        final boolean sameMode = newMode == mode;
         mode = newMode;
+        if (sameMode) {
+            // SCREEN with the mode unchanged (page flips: SCREEN 7, , a, v)
+            // keeps video memory intact — CUBE.BAS redraws one page while
+            // showing the other. VIEW/WINDOW still reset, like real QB.
+            viewportActive = false;
+            viewportBorder = false;
+            windowActive = false;
+            viewX1 = 0;
+            viewY1 = 0;
+            viewX2 = newMode.pixelWidth() - 1;
+            viewY2 = newMode.pixelHeight() - 1;
+            graphicsCursorX = newMode.pixelWidth() / 2.0;
+            graphicsCursorY = newMode.pixelHeight() / 2.0;
+            return;
+        }
         framebuffer.resize(newMode.pixelWidth(), newMode.pixelHeight(),
                 newMode.pages(), newMode);
         activePage = 0;
         visualPage = 0;
+        // SCREEN resets the viewport and the logical window.
+        viewportActive = false;
+        viewportBorder = false;
+        windowActive = false;
+        // QB's graphics cursor starts at the screen CENTER after a mode set
+        // (PLOTTER.BAS draws its first stroke from mid-screen).
+        graphicsCursorX = newMode.pixelWidth() / 2.0;
+        graphicsCursorY = newMode.pixelHeight() / 2.0;
+        viewX1 = 0;
+        viewY1 = 0;
+        viewX2 = newMode.pixelWidth() - 1;
+        viewY2 = newMode.pixelHeight() - 1;
         consoles = new TextConsole[newMode.pages()];
         for (int page = 0; page < consoles.length; page++) {
             consoles[page] = new TextConsole(framebuffer, newMode, page);
@@ -129,6 +171,15 @@ public final class VgaDevice {
 
     /** {@code CLS}. */
     public void cls() {
+        if (viewportActive && viewportBorder) {
+            // QB keeps the VIEW border ring across a plain CLS: only the
+            // interior is cleared (SINEWAVE.BAS's box survives every loop).
+            framebuffer.fillRect(activePage, viewX1 + 1, viewY1 + 1,
+                    viewX2 - viewX1 - 1, viewY2 - viewY1 - 1,
+                    consoles[activePage].background());
+            consoles[activePage].homeCursor();
+            return;
+        }
         consoles[activePage].cls();
     }
 
@@ -178,44 +229,281 @@ public final class VgaDevice {
 
     // -------------------------------------------------------------- pixels
 
+    /**
+     * Maps an input x coordinate to physical pixels. With WINDOW active the
+     * input is logical and maps linearly onto the viewport corners; without
+     * it the input is physical, relative to the viewport's left edge.
+     */
+    private double mapX(final double x) {
+        if (windowActive) {
+            return viewX1 + (x - winX1) * (viewX2 - viewX1) / (winX2 - winX1);
+        }
+        return x + (viewportActive ? viewX1 : 0);
+    }
+
+    /** Same for y. A plain WINDOW is Cartesian: y1 sits at the viewport
+     *  BOTTOM (y grows upward). WINDOW SCREEN keeps screen order (y1 on top). */
+    private double mapY(final double y) {
+        if (windowActive) {
+            double t = (y - winY1) / (winY2 - winY1);
+            if (!windowScreen) {
+                t = 1 - t;
+            }
+            return viewY1 + t * (viewY2 - viewY1);
+        }
+        return y + (viewportActive ? viewY1 : 0);
+    }
+
+    /** Inverse of {@link #mapX} (PMAP modes 2/3). */
+    private double unmapX(final double x) {
+        if (windowActive) {
+            return winX1 + (x - viewX1) * (winX2 - winX1) / (viewX2 - viewX1);
+        }
+        return x - (viewportActive ? viewX1 : 0);
+    }
+
+    private double unmapY(final double y) {
+        if (windowActive) {
+            double t = (y - viewY1) / (viewY2 - viewY1);
+            if (!windowScreen) {
+                t = 1 - t;
+            }
+            return winY1 + t * (winY2 - winY1);
+        }
+        return y - (viewportActive ? viewY1 : 0);
+    }
+
+    /**
+     * {@code PMAP(x, n)}: n = 0 logical x → physical x, 1 logical y →
+     * physical y, 2 physical x → logical x, 3 physical y → logical y.
+     */
+    public double pmap(final double value, final int which) {
+        return switch (which) {
+            case 0 -> mapX(value);
+            case 1 -> mapY(value);
+            case 2 -> unmapX(value);
+            case 3 -> unmapY(value);
+            default -> throw new IllegalArgumentException("PMAP mode must be 0-3: " + which);
+        };
+    }
+
+    /**
+     * {@code VIEW (x1, y1)-(x2, y2), fill, border}: confines all graphics to
+     * the rectangle (physical pixels, clipped to the screen), optionally
+     * fills it and draws the border.
+     */
+    public void viewport(final int x1, final int y1, final int x2, final int y2,
+                         final Integer fill, final Integer border) {
+        viewX1 = Math.min(x1, x2);
+        viewY1 = Math.min(y1, y2);
+        viewX2 = Math.max(x1, x2);
+        viewY2 = Math.max(y1, y2);
+        viewportActive = true;
+        if (fill != null) {
+            framebuffer.fillRect(activePage, viewX1, viewY1,
+                    viewX2 - viewX1 + 1, viewY2 - viewY1 + 1, fill);
+        }
+        viewportBorder = border != null;
+        if (border != null) {
+            linePhysical(viewX1, viewY1, viewX2, viewY2, border, true, 0xFFFF);
+        }
+    }
+
+    /** Bare {@code VIEW}: the viewport is the whole screen again. */
+    public void resetViewport() {
+        viewportActive = false;
+        viewportBorder = false;
+        windowActive = false;
+        viewX1 = 0;
+        viewY1 = 0;
+        viewX2 = framebuffer.width() - 1;
+        viewY2 = framebuffer.height() - 1;
+    }
+
+    /** {@code WINDOW (x1, y1)-(x2, y2)}: logical coordinate mapping onto the viewport. */
+    public void window(final double x1, final double y1, final double x2, final double y2,
+                       final boolean screenCoords) {
+        winX1 = x1;
+        winY1 = y1;
+        winX2 = x2;
+        winY2 = y2;
+        windowScreen = screenCoords;
+        windowActive = true;
+    }
+
+    /** Bare {@code WINDOW}: back to physical coordinates. */
+    public void resetWindow() {
+        windowActive = false;
+    }
+
+    /** Sets one pixel (input coords), clipped to the viewport. */
+    private void pixel(final double x, final double y, final int colorIndex) {
+        final int px = (int) Math.rint(mapX(x));
+        final int py = (int) Math.rint(mapY(y));
+        pixelPhysical(px, py, colorIndex);
+    }
+
+    /** Sets one physical pixel, honoring the viewport clip. */
+    private void pixelPhysical(final int px, final int py, final int colorIndex) {
+        if (px < viewX1 || py < viewY1 || px > viewX2 || py > viewY2) {
+            return;
+        }
+        framebuffer.setPixel(activePage, px, py, colorIndex);
+    }
+
     /** {@code PSET (x, y), colorIndex}: sets one pixel on the active page. */
-    public void pset(final int x, final int y, final int colorIndex) {
-        framebuffer.setPixel(activePage, x, y, colorIndex);
+    public void pset(final double x, final double y, final int colorIndex) {
+        pixel(x, y, colorIndex);
         graphicsCursorX = x;
         graphicsCursorY = y;
     }
 
     /** {@code POINT(x, y)}: reads the palette index of one pixel. */
-    public int point(final int x, final int y) {
-        return framebuffer.getPixel(activePage, x, y);
+    public int point(final double x, final double y) {
+        return framebuffer.getPixel(activePage,
+                (int) Math.rint(mapX(x)), (int) Math.rint(mapY(y)));
     }
 
     /** {@code GET (x1, y1)-(x2, y2), array}: captures a rectangle of the active page. */
-    public CapturedRegion getRegion(final int x1, final int y1, final int x2, final int y2) {
-        final CapturedRegion region = framebuffer.captureRect(activePage, x1, y1, x2, y2);
+    public CapturedRegion getRegion(final double x1, final double y1,
+                                    final double x2, final double y2) {
+        final CapturedRegion region = framebuffer.captureRect(activePage,
+                (int) Math.rint(mapX(x1)), (int) Math.rint(mapY(y1)),
+                (int) Math.rint(mapX(x2)), (int) Math.rint(mapY(y2)));
         graphicsCursorX = x2;
         graphicsCursorY = y2;
         return region;
     }
 
     /** {@code PUT (x, y), array, action}: blits a captured rectangle onto the active page. */
-    public void putRegion(final int x, final int y, final CapturedRegion region, final String action) {
-        framebuffer.blitRect(activePage, x, y, region, action);
+    public void putRegion(final double x, final double y, final CapturedRegion region,
+                          final String action) {
+        int px = (int) Math.rint(mapX(x));
+        int py = (int) Math.rint(mapY(y));
+        if (windowActive) {
+            // With WINDOW the anchor is the region's LOGICAL top-left
+            // corner; in Cartesian (y-up) windows the image extends UPWARD
+            // on screen (BALLPSET.BAS's ball rests on the viewport's bottom
+            // edge, oracle-verified with QB.EXE).
+            final double wLogical = region.width()
+                    / Math.abs((viewX2 - viewX1) / (winX2 - winX1));
+            final double hLogical = region.height()
+                    / Math.abs((viewY2 - viewY1) / (winY2 - winY1));
+            final int px2 = (int) Math.rint(mapX(x + wLogical));
+            final int py2 = (int) Math.rint(mapY(y + hLogical));
+            px = Math.min(px, px2);
+            py = Math.min(py, py2);
+        }
+        blitClipped(px, py, region, action);
         graphicsCursorX = x;
         graphicsCursorY = y;
     }
 
+    /** PUT with viewport clipping (PUT clips to the viewport, like every primitive). */
+    private void blitClipped(final int x, final int y, final CapturedRegion region,
+                             final String action) {
+        final byte[] source = region.paletteIndices();
+        for (int row = 0; row < region.height(); row++) {
+            final int dy = y + row;
+            if (dy < viewY1 || dy > viewY2) {
+                continue;
+            }
+            for (int col = 0; col < region.width(); col++) {
+                final int dx = x + col;
+                if (dx < viewX1 || dx > viewX2) {
+                    continue;
+                }
+                final int src = source[row * region.width() + col] & 0xFF;
+                final int dst = framebuffer.getPixel(activePage, dx, dy);
+                final int value = switch (action) {
+                    case "PSET" -> src;
+                    case "XOR" -> dst ^ src;
+                    case "OR" -> dst | src;
+                    case "AND" -> dst & src;
+                    case "PRESET" -> 255 ^ src;
+                    default -> throw new IllegalArgumentException(
+                            "PUT action not supported: " + action);
+                };
+                framebuffer.setPixel(activePage, dx, dy, value);
+            }
+        }
+    }
+
     /**
-     * {@code LINE (x1, y1)-(x2, y2), color}: replicates the classic DDA line
+     * {@code LINE (x1, y1)-(x2, y2), color} in input coordinates (logical
+     * when WINDOW is active, viewport-relative otherwise).
+     */
+    public void line(final double x1, final double y1, final double x2, final double y2,
+                     final int colorIndex) {
+        line(x1, y1, x2, y2, colorIndex, 0xFFFF);
+    }
+
+    /**
+     * LINE with a 16-bit dash pattern: along the major axis, pixel k is
+     * drawn when bit (15 - k mod 16) of the style is set.
+     */
+    public void line(final double x1, final double y1, final double x2, final double y2,
+                     final int colorIndex, final int style) {
+        linePhysical((int) Math.rint(mapX(x1)), (int) Math.rint(mapY(y1)),
+                (int) Math.rint(mapX(x2)), (int) Math.rint(mapY(y2)), colorIndex, false, style);
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /** {@code LINE (x1, y1)-(x2, y2), color, B}: rectangle outline. */
+    public void lineBox(final double x1, final double y1, final double x2, final double y2,
+                        final int colorIndex) {
+        lineBox(x1, y1, x2, y2, colorIndex, 0xFFFF);
+    }
+
+    /** Rectangle outline with a dash pattern. */
+    public void lineBox(final double x1, final double y1, final double x2, final double y2,
+                        final int colorIndex, final int style) {
+        linePhysical((int) Math.rint(mapX(x1)), (int) Math.rint(mapY(y1)),
+                (int) Math.rint(mapX(x2)), (int) Math.rint(mapY(y2)), colorIndex, true, style);
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /** {@code LINE (x1, y1)-(x2, y2), color, BF}: filled rectangle, clipped to the viewport. */
+    public void lineBoxFilled(final double x1, final double y1, final double x2, final double y2,
+                              final int colorIndex) {
+        final int px1 = (int) Math.rint(mapX(x1));
+        final int py1 = (int) Math.rint(mapY(y1));
+        final int px2 = (int) Math.rint(mapX(x2));
+        final int py2 = (int) Math.rint(mapY(y2));
+        final int left = Math.max(Math.min(px1, px2), viewX1);
+        final int top = Math.max(Math.min(py1, py2), viewY1);
+        final int right = Math.min(Math.max(px1, px2), viewX2);
+        final int bottom = Math.min(Math.max(py1, py2), viewY2);
+        if (left <= right && top <= bottom) {
+            framebuffer.fillRect(activePage, left, top, right - left + 1, bottom - top + 1,
+                    colorIndex);
+        }
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /**
+     * Physical-pixel line: replicates the classic DDA line
      * rasterizer bit-exactly. The line is walked along its major axis from
      * (x1, y1); the minor coordinate of step k advances when the error
      * accumulator crosses the major delta, starting from an initial bias
      * of floor(3M/4) when stepping right and M-1-floor(3M/4) when
      * stepping left (this bias makes reversed endpoint order plot the
      * identical pixel set). Both
-     * endpoints are drawn. Updates the graphics cursor to (x2, y2).
+     * endpoints are drawn. Clipped to the viewport; updates the physical
+     * cursor (the caller updates the logical cursor).
      */
-    public void line(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
+    private void linePhysical(final int x1, final int y1, final int x2, final int y2,
+                              final int colorIndex, final boolean box, final int style) {
+        if (box) {
+            linePhysical(x1, y1, x2, y1, colorIndex, false, style);
+            linePhysical(x2, y1, x2, y2, colorIndex, false, style);
+            linePhysical(x2, y2, x1, y2, colorIndex, false, style);
+            linePhysical(x1, y2, x1, y1, colorIndex, false, style);
+            return;
+        }
         // The segment is clipped to the screen rectangle BEFORE rasterizing
         // (Liang–Barsky). The intersection's off-axis coordinate is computed
         // as base + ROUND(offset) on the integer offset: nearest, with
@@ -225,8 +513,10 @@ public final class VgaDevice {
         // clipped start at 12.5 lands
         // on 13 not 12). This shifts the visible part of lines that leave
         // the screen vs. per-pixel clipping.
-        final int maxX = framebuffer.width() - 1;
-        final int maxY = framebuffer.height() - 1;
+        final int maxX = Math.min(framebuffer.width() - 1, viewX2);
+        final int maxY = Math.min(framebuffer.height() - 1, viewY2);
+        final int minX = Math.max(0, viewX1);
+        final int minY = Math.max(0, viewY1);
         double t0 = 0;
         double t1 = 1;
         int enterEdge = -1; // 0=left, 1=right, 2=top, 3=bottom
@@ -234,15 +524,13 @@ public final class VgaDevice {
         final double dx = x2 - x1;
         final double dy = y2 - y1;
         final double[][] pq = {
-                {-dx, x1}, {dx, maxX - x1}, {-dy, y1}, {dy, maxY - y1}};
+                {-dx, x1 - minX}, {dx, maxX - x1}, {-dy, y1 - minY}, {dy, maxY - y1}};
         for (int edge = 0; edge < pq.length; edge++) {
             final double p = pq[edge][0];
             final double q = pq[edge][1];
             if (p == 0) {
                 if (q < 0) {
                     // Parallel to and outside this edge: fully clipped.
-                    graphicsCursorX = x2;
-                    graphicsCursorY = y2;
                     return;
                 }
             } else {
@@ -261,14 +549,12 @@ public final class VgaDevice {
             }
         }
         if (t0 > t1) {
-            graphicsCursorX = x2;
-            graphicsCursorY = y2;
             return;
         }
-        final int cx1 = clipCoord(x1, x2, y1, y2, enterEdge, maxX, maxY, true, x1, true);
-        final int cy1 = clipCoord(x1, x2, y1, y2, enterEdge, maxX, maxY, false, y1, true);
-        final int cx2 = clipCoord(x1, x2, y1, y2, leaveEdge, maxX, maxY, true, x2, false);
-        final int cy2 = clipCoord(x1, x2, y1, y2, leaveEdge, maxX, maxY, false, y2, false);
+        final int cx1 = clipCoord(x1, x2, y1, y2, enterEdge, minX, maxX, minY, maxY, true, x1, true);
+        final int cy1 = clipCoord(x1, x2, y1, y2, enterEdge, minX, maxX, minY, maxY, false, y1, true);
+        final int cx2 = clipCoord(x1, x2, y1, y2, leaveEdge, minX, maxX, minY, maxY, true, x2, false);
+        final int cy2 = clipCoord(x1, x2, y1, y2, leaveEdge, minX, maxX, minY, maxY, false, y2, false);
 
         final int adx = Math.abs(cx2 - cx1);
         final int ady = Math.abs(cy2 - cy1);
@@ -281,7 +567,9 @@ public final class VgaDevice {
         int error = bias;
         if (adx >= ady) {
             for (int k = 0; k <= adx; k++) {
-                framebuffer.setPixel(activePage, x, y, colorIndex);
+                if ((style >> (15 - (k & 15)) & 1) != 0) {
+                    framebuffer.setPixel(activePage, x, y, colorIndex);
+                }
                 x += stepX;
                 error += ady;
                 if (error >= adx) {
@@ -291,7 +579,9 @@ public final class VgaDevice {
             }
         } else {
             for (int k = 0; k <= ady; k++) {
-                framebuffer.setPixel(activePage, x, y, colorIndex);
+                if ((style >> (15 - (k & 15)) & 1) != 0) {
+                    framebuffer.setPixel(activePage, x, y, colorIndex);
+                }
                 y += stepY;
                 error += adx;
                 if (error >= ady) {
@@ -300,8 +590,6 @@ public final class VgaDevice {
                 }
             }
         }
-        graphicsCursorX = x2;
-        graphicsCursorY = y2;
     }
 
     /**
@@ -318,7 +606,8 @@ public final class VgaDevice {
      * @param startClip true when clipping the line's start point
      */
     private static int clipCoord(final int x1, final int x2, final int y1, final int y2,
-                                 final int edge, final int maxX, final int maxY,
+                                 final int edge, final int minX, final int maxX,
+                                 final int minY, final int maxY,
                                  final boolean xCoord, final int inside,
                                  final boolean startClip) {
         if (edge < 0) {
@@ -326,9 +615,9 @@ public final class VgaDevice {
         }
         final boolean verticalEdge = edge < 2;
         final int boundary = switch (edge) {
-            case 0 -> 0;
+            case 0 -> minX;
             case 1 -> maxX;
-            case 2 -> 0;
+            case 2 -> minY;
             default -> maxY;
         };
         if (verticalEdge == xCoord) {
@@ -364,31 +653,29 @@ public final class VgaDevice {
         return offset >= 0 ? (int) floor : (int) floor + 1;
     }
 
-    /** {@code LINE (x1, y1)-(x2, y2), color, B}: rectangle outline. */
-    public void lineBox(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
-        line(x1, y1, x2, y1, colorIndex);
-        line(x2, y1, x2, y2, colorIndex);
-        line(x2, y2, x1, y2, colorIndex);
-        line(x1, y2, x1, y1, colorIndex);
-        graphicsCursorX = x2;
-        graphicsCursorY = y2;
-    }
-
-    /** {@code LINE (x1, y1)-(x2, y2), color, BF}: filled rectangle. */
-    public void lineBoxFilled(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
-        framebuffer.fillRect(activePage,
-                Math.min(x1, x2), Math.min(y1, y2),
-                Math.abs(x2 - x1) + 1, Math.abs(y2 - y1) + 1, colorIndex);
-        graphicsCursorX = x2;
-        graphicsCursorY = y2;
-    }
-
     /**
      * {@code CIRCLE (x, y), radius, color}: axis-aligned ellipse using the
-     * mode's default aspect ratio. Updates the graphics cursor to the center.
+     * mode's default aspect ratio — or the WINDOW's scales when logical
+     * coordinates are active (the radius is in logical x units; the y
+     * radius follows the y scale, making the circle geometrically correct
+     * in logical space). Updates the graphics cursor to the center.
      */
-    public void circle(final int centerX, final int centerY, final int radius, final int colorIndex) {
-        circle(centerX, centerY, radius, colorIndex, mode.defaultAspect());
+    public void circle(final double centerX, final double centerY, final double radius,
+                       final int colorIndex) {
+        if (windowActive) {
+            // With WINDOW the radius is in logical X units; the Y radius
+            // keeps the mode's screen aspect (a circle stays visually round
+            // regardless of the window's y scale — oracle-measured with
+            // QB.EXE on BALLPSET.BAS: 33x13 logical px in SCREEN 2).
+            final double rx = Math.abs(radius * (viewX2 - viewX1) / (winX2 - winX1));
+            circlePhysical((int) Math.rint(mapX(centerX)), (int) Math.rint(mapY(centerY)),
+                    rx, mode.defaultAspect(), colorIndex);
+        } else {
+            circlePhysical((int) Math.rint(mapX(centerX)), (int) Math.rint(mapY(centerY)),
+                    radius, mode.defaultAspect(), colorIndex);
+        }
+        graphicsCursorX = centerX;
+        graphicsCursorY = centerY;
     }
 
     /**
@@ -406,19 +693,27 @@ public final class VgaDevice {
      * aspect is SINGLE precision (a float, like the dialect's default
      * reals); both details are load-bearing for pixel-exact output.</p>
      */
-    public void circle(final int centerX, final int centerY, final int radius,
+    public void circle(final double centerX, final double centerY, final double radius,
                        final int colorIndex, final double aspect) {
+        circlePhysical((int) Math.rint(mapX(centerX)), (int) Math.rint(mapY(centerY)),
+                radius, aspect, colorIndex);
+        graphicsCursorX = centerX;
+        graphicsCursorY = centerY;
+    }
+
+    /** Physical-pixel ellipse: circle space of radius rx mapped through the aspect. */
+    private void circlePhysical(final int centerX, final int centerY, final double radius,
+                                final double aspect, final int colorIndex) {
         // Aspect VALUE is SINGLE precision, but products/quotients are
         // computed in double and rounded half-up — that combination is what
         // reproduces the classic pixel output exactly.
         final double singleAspect = (float) aspect;
-        for (int t = 0; t <= radius; t++) {
-            final int u = (int) (Math.sqrt((double) radius * radius - (double) t * t) + 0.5);
+        final int r = Math.max(0, (int) Math.rint(radius));
+        for (int t = 0; t <= r; t++) {
+            final int u = (int) (Math.sqrt((double) r * r - (double) t * t) + 0.5);
             plotCircleSpace(centerX, centerY, t, u, singleAspect, colorIndex);
             plotCircleSpace(centerX, centerY, u, t, singleAspect, colorIndex);
         }
-        graphicsCursorX = centerX;
-        graphicsCursorY = centerY;
     }
 
     /** Maps a circle-space point through the aspect ratio and plots it mirrored 4 ways. */
@@ -433,29 +728,32 @@ public final class VgaDevice {
             dx = (int) (spaceX / aspect + 0.5);
             dy = spaceY;
         }
-        framebuffer.setPixel(activePage, centerX + dx, centerY + dy, colorIndex);
-        framebuffer.setPixel(activePage, centerX - dx, centerY + dy, colorIndex);
-        framebuffer.setPixel(activePage, centerX + dx, centerY - dy, colorIndex);
-        framebuffer.setPixel(activePage, centerX - dx, centerY - dy, colorIndex);
+        pixelPhysical(centerX + dx, centerY + dy, colorIndex);
+        pixelPhysical(centerX - dx, centerY + dy, colorIndex);
+        pixelPhysical(centerX + dx, centerY - dy, colorIndex);
+        pixelPhysical(centerX - dx, centerY - dy, colorIndex);
     }
 
     /**
      * {@code PAINT (x, y), fillColor[, borderColor]}: flood fill starting at
-     * (x, y). Classic semantics: paints over every reachable pixel that is
+     * (x, y) — input coordinates. Classic semantics: paints over every
+     * reachable pixel that is
      * not borderColor — the seed pixel's color does not confine the fill, so
      * a shape drawn over other colors still comes out solid. Painting a
-     * pixel that is already the border color does nothing.
+     * pixel that is already the border color does nothing. The fill is
+     * confined to the current VIEW viewport.
      */
-    public void paint(final int x, final int y, final int fillColor, final int borderColor) {
+    public void paint(final double sx, final double sy, final int fillColor,
+                      final int borderColor) {
         final int fill = fillColor & 0xFF;
         final int border = borderColor & 0xFF;
-        graphicsCursorX = x;
-        graphicsCursorY = y;
-        final int width = framebuffer.width();
-        final int height = framebuffer.height();
-        if (x < 0 || y < 0 || x >= width || y >= height
+        graphicsCursorX = sx;
+        graphicsCursorY = sy;
+        final int x = (int) Math.rint(mapX(sx));
+        final int y = (int) Math.rint(mapY(sy));
+        if (x < viewX1 || y < viewY1 || x > viewX2 || y > viewY2
                 || framebuffer.getPixel(activePage, x, y) == border) {
-            // Off-screen seed clips like any other drawing primitive
+            // Off-viewport seed clips like any other drawing primitive
             // (programs legitimately PAINT shapes that wander off-screen);
             // a seed on the border color paints nothing.
             return;
@@ -463,7 +761,8 @@ public final class VgaDevice {
         // Visited tracking, not color matching: interior pixels that already
         // hold the fill color must not block the spread (classic PAINT
         // paints "through" them — they stay fill-colored either way).
-        final byte[] visited = new byte[width * height];
+        final int width = framebuffer.width();
+        final byte[] visited = new byte[width * framebuffer.height()];
         final java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
         visited[y * width + x] = 1;
         framebuffer.setPixel(activePage, x, y, fill);
@@ -473,7 +772,7 @@ public final class VgaDevice {
             final int[][] neighbors = {
                 {p[0] + 1, p[1]}, {p[0] - 1, p[1]}, {p[0], p[1] + 1}, {p[0], p[1] - 1}};
             for (final int[] n : neighbors) {
-                if (n[0] < 0 || n[1] < 0 || n[0] >= width || n[1] >= height
+                if (n[0] < viewX1 || n[1] < viewY1 || n[0] > viewX2 || n[1] > viewY2
                         || visited[n[1] * width + n[0]] != 0) {
                     continue;
                 }
@@ -487,13 +786,67 @@ public final class VgaDevice {
         }
     }
 
-    /** @return x of the last point referenced by a drawing primitive */
-    public int graphicsCursorX() {
+    /**
+     * Pattern PAINT: floods the region reachable from the seed (border color
+     * stops the spread, as with solid PAINT), then sets only the pixels whose
+     * pattern bit is 1 — bit-0 pixels keep their color. Byte y mod N of the
+     * pattern covers screen row y; within a row the byte repeats every 8
+     * pixels, bit 7 leftmost, anchored at screen x = 0.
+     */
+    public void paintPattern(final double sx, final double sy, final byte[] pattern,
+                             final int fillColor, final int borderColor) {
+        final int fill = fillColor & 0xFF;
+        final int border = borderColor & 0xFF;
+        graphicsCursorX = sx;
+        graphicsCursorY = sy;
+        final int x = (int) Math.rint(mapX(sx));
+        final int y = (int) Math.rint(mapY(sy));
+        if (x < viewX1 || y < viewY1 || x > viewX2 || y > viewY2
+                || framebuffer.getPixel(activePage, x, y) == border
+                || pattern.length == 0) {
+            return;
+        }
+        final int width = framebuffer.width();
+        final byte[] visited = new byte[width * framebuffer.height()];
+        final java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
+        visited[y * width + x] = 1;
+        paintPatternPixel(x, y, pattern, fill);
+        queue.add(new int[]{x, y});
+        while (!queue.isEmpty()) {
+            final int[] p = queue.poll();
+            final int[][] neighbors = {
+                {p[0] + 1, p[1]}, {p[0] - 1, p[1]}, {p[0], p[1] + 1}, {p[0], p[1] - 1}};
+            for (final int[] n : neighbors) {
+                if (n[0] < viewX1 || n[1] < viewY1 || n[0] > viewX2 || n[1] > viewY2
+                        || visited[n[1] * width + n[0]] != 0) {
+                    continue;
+                }
+                visited[n[1] * width + n[0]] = 1;
+                if (framebuffer.getPixel(activePage, n[0], n[1]) == border) {
+                    continue;
+                }
+                paintPatternPixel(n[0], n[1], pattern, fill);
+                queue.add(n);
+            }
+        }
+    }
+
+    /** One pixel of a pattern fill: set it only where the tile bit is 1. */
+    private void paintPatternPixel(final int x, final int y, final byte[] pattern,
+                                   final int fill) {
+        final int patternByte = pattern[y % pattern.length] & 0xFF;
+        if ((patternByte >> (7 - (x & 7)) & 1) != 0) {
+            framebuffer.setPixel(activePage, x, y, fill);
+        }
+    }
+
+    /** @return x of the last point referenced by a drawing primitive (input coords) */
+    public double graphicsCursorX() {
         return graphicsCursorX;
     }
 
     /** @return y of the last point referenced by a drawing primitive */
-    public int graphicsCursorY() {
+    public double graphicsCursorY() {
         return graphicsCursorY;
     }
 
@@ -504,11 +857,291 @@ public final class VgaDevice {
         framebuffer.setPaletteColor(index, rgb);
     }
 
+    /**
+     * {@code PALETTE attribute, color}: in EGA modes the attribute displays
+     * as the default color of palette entry {@code color} (0-15).
+     */
+    public void paletteAttribute(final int attribute, final int color) {
+        framebuffer.setPaletteColor(attribute & 0xFF, DefaultPalettes.ega16(color & 0xF));
+    }
+
+    /**
+     * {@code PALETTE USING array}: element k (starting at the given array
+     * values) becomes the display color of attribute k; -1 leaves the
+     * attribute unchanged.
+     */
+    public void paletteUsing(final int[] colors) {
+        for (int k = 0; k < colors.length && k < mode.colors(); k++) {
+            if (colors[k] >= 0) {
+                paletteAttribute(k, colors[k]);
+            }
+        }
+    }
+
+    /** Bare {@code PALETTE}: restores the mode's default palette. */
+    public void resetPalette() {
+        DefaultPalettes.apply(framebufferPalette(), mode);
+    }
+
+    /** Palette array access for {@link #resetPalette()}. */
+    private int[] framebufferPalette() {
+        return framebuffer.palette();
+    }
+
+    /**
+     * SCREEN 1 {@code COLOR background, palette}: sets the border/backdrop
+     * color (index 0) and selects the CGA palette — odd selects
+     * cyan/magenta/white, even selects green/red/yellow (high intensity).
+     */
+    public void screenOneColor(final int background, final int paletteNumber) {
+        framebuffer.setPaletteColor(0, DefaultPalettes.ega16(background & 15));
+        if ((paletteNumber & 1) == 1) {
+            framebuffer.setPaletteColor(1, 0x55FFFF);
+            framebuffer.setPaletteColor(2, 0xFF55FF);
+            framebuffer.setPaletteColor(3, 0xFFFFFF);
+        } else {
+            framebuffer.setPaletteColor(1, 0x55FF55);
+            framebuffer.setPaletteColor(2, 0xFF5555);
+            framebuffer.setPaletteColor(3, 0xFFFF55);
+        }
+    }
+
     /** @return 0xRRGGBB color of a palette entry */
     public int paletteColor(final int index) {
         return framebuffer.paletteColor(index);
     }
 
+    // -------------------------------------------------------- CLS variants
+
+    /** {@code CLS 1}: clears the graphics viewport of the active page. */
+    public void clsGraphics() {
+        framebuffer.fillRect(activePage, viewX1, viewY1,
+                viewX2 - viewX1 + 1, viewY2 - viewY1 + 1, consoles[activePage].background());
+    }
+
+    /** {@code CLS 2}: clears only the text scroll area. */
+    public void clsText() {
+        cls();
+    }
+
+    /** LOCATE's cursor visibility/shape slots — stored, not rendered. */
+    public void setCursor(final boolean visible, final int start, final int stop) {
+        cursorVisible = visible;
+    }
+
+    public boolean cursorVisible() {
+        return cursorVisible;
+    }
+
+    // --------------------------------------------------------------- DRAW
+
+    /**
+     * {@code DRAW macro$}: the classic graphics macro language in the
+     * current input coordinate space. Supported commands: U/D/L/R/E/F/G/H
+     * (with optional length), M x,y (absolute, or +/- relative), B/N
+     * prefixes, C color, P fill,border (PAINT at the cursor), S scale,
+     * A n (90° steps) and TA angle (degrees, counter-clockwise).
+     * Numbers are digit runs; the {@code =varname;} form is not supported
+     * (programs concatenate variables instead).
+     */
+    public void draw(final String macro) {
+        double angle = 0;      // TA rotation, degrees counter-clockwise
+        int quarterTurns = 0;  // A rotation
+        double scale = 1;      // S scale (unit = 4/S? classic: length = n * S/4)
+        int color = consoles[activePage].foreground();
+        double cx = graphicsCursorX;
+        double cy = graphicsCursorY;
+        int i = 0;
+        final int len = macro.length();
+        boolean blank = false;   // B prefix: move without drawing
+        boolean noUpdate = false; // N prefix: draw but don't move the cursor
+        while (i < len) {
+            final char raw = macro.charAt(i);
+            if (raw == ' ') {
+                i++;
+                continue;
+            }
+            final char c = Character.toUpperCase(raw);
+            i++;
+            if (c == 'B') {
+                blank = true;
+                continue;
+            }
+            if (c == 'N') {
+                noUpdate = true;
+                continue;
+            }
+            switch (c) {
+                case 'C' -> {
+                    final int[] num = drawNumber(macro, i);
+                    color = num[0];
+                    i = num[1];
+                }
+                case 'S' -> {
+                    final int[] num = drawNumber(macro, i);
+                    scale = num[0] / 4.0;
+                    i = num[1];
+                }
+                case 'P' -> {
+                    final int[] fill = drawNumber(macro, i);
+                    i = fill[1];
+                    int border = fill[0];
+                    if (i < len && macro.charAt(i) == ',') {
+                        final int[] b = drawNumber(macro, i + 1);
+                        border = b[0];
+                        i = b[1];
+                    }
+                    paint(cx, cy, fill[0], border);
+                }
+                case 'T' -> {
+                    // TA angle — turn angle in degrees.
+                    if (i < len && (macro.charAt(i) == 'A' || macro.charAt(i) == 'a')) {
+                        i++;
+                        final int[] num = drawNumber(macro, i);
+                        angle = num[0];
+                        i = num[1];
+                    }
+                }
+                case 'A' -> {
+                    final int[] num = drawNumber(macro, i);
+                    quarterTurns = num[0] & 3;
+                    i = num[1];
+                }
+                case 'M' -> {
+                    // M [+|-]x,y — absolute or relative move (draws a line).
+                    i = skipSpaces(macro, i);
+                    boolean relX = false;
+                    if (i < len && (macro.charAt(i) == '+' || macro.charAt(i) == '-')) {
+                        relX = true;
+                    }
+                    final double[] xy = drawCoord(macro, i);
+                    double nx = xy[0];
+                    double ny = xy[1];
+                    i = (int) xy[2];
+                    if (relX) {
+                        nx += cx;
+                        ny += cy;
+                    }
+                    if (!blank) {
+                        linePhysical((int) Math.rint(mapX(cx)), (int) Math.rint(mapY(cy)),
+                                (int) Math.rint(mapX(nx)), (int) Math.rint(mapY(ny)),
+                                color, false, 0xFFFF);
+                    }
+                    if (!noUpdate) {
+                        cx = nx;
+                        cy = ny;
+                    }
+                    blank = false;
+                    noUpdate = false;
+                }
+                case 'U', 'D', 'L', 'R', 'E', 'F', 'G', 'H' -> {
+                    final int[] num = drawNumber(macro, i);
+                    double dist = (num[2] == 1 ? num[0] : 1) * scale;
+                    i = num[1];
+                    // Base direction vectors in input space (y down-positive):
+                    // E = up-right, F = down-right, G = down-left, H = up-left.
+                    double dx = switch (c) {
+                        case 'L' -> -1;
+                        case 'R' -> 1;
+                        case 'E', 'F' -> 1;
+                        case 'G', 'H' -> -1;
+                        default -> 0;
+                    };
+                    double dy = switch (c) {
+                        case 'U' -> -1;
+                        case 'D' -> 1;
+                        case 'F', 'G' -> 1;
+                        case 'E', 'H' -> -1;
+                        default -> 0;
+                    };
+                    if (c == 'E' || c == 'F' || c == 'G' || c == 'H') {
+                        // Diagonals: the classic pixel length is 1:1 per axis.
+                    }
+                    // A: quarter turns clockwise (screen coordinates).
+                    for (int q = 0; q < quarterTurns; q++) {
+                        final double t = dx;
+                        dx = -dy;
+                        dy = t;
+                    }
+                    // TA: rotate counter-clockwise in screen space (angle>0
+                    // rotates up/right = counter-clockwise on screen).
+                    if (angle != 0) {
+                        final double rad = Math.toRadians(angle);
+                        final double cos = Math.cos(rad);
+                        final double sin = Math.sin(rad);
+                        final double t = dx * cos + dy * sin;
+                        dy = dy * cos - dx * sin;
+                        dx = t;
+                    }
+                    final double nx = cx + dx * dist;
+                    final double ny = cy + dy * dist;
+                    if (!blank) {
+                        linePhysical((int) Math.rint(mapX(cx)), (int) Math.rint(mapY(cy)),
+                                (int) Math.rint(mapX(nx)), (int) Math.rint(mapY(ny)),
+                                color, false, 0xFFFF);
+                    }
+                    if (!noUpdate) {
+                        cx = nx;
+                        cy = ny;
+                    }
+                    blank = false;
+                    noUpdate = false;
+                }
+                default -> {
+                    // Unknown DRAW command: ignore the letter.
+                }
+            }
+        }
+        graphicsCursorX = cx;
+        graphicsCursorY = cy;
+    }
+
+    private static int skipSpaces(final String s, final int i) {
+        int p = i;
+        while (p < s.length() && s.charAt(p) == ' ') {
+            p++;
+        }
+        return p;
+    }
+
+    /**
+     * Reads a signed integer run at position i.
+     *
+     * @return {value, nextPosition, hadDigits}
+     */
+    private static int[] drawNumber(final String s, final int i) {
+        int p = skipSpaces(s, i);
+        int sign = 1;
+        if (p < s.length() && (s.charAt(p) == '+' || s.charAt(p) == '-')) {
+            sign = s.charAt(p) == '-' ? -1 : 1;
+            p++;
+        }
+        int value = 0;
+        int digits = 0;
+        while (p < s.length() && Character.isDigit(s.charAt(p))) {
+            value = value * 10 + s.charAt(p) - '0';
+            p++;
+            digits++;
+        }
+        return new int[]{sign * value, p, digits > 0 ? 1 : 0};
+    }
+
+    /**
+     * Reads an "x,y" pair for DRAW's M command (each may carry a sign).
+     *
+     * @return {x, y, nextPosition}
+     */
+    private static double[] drawCoord(final String s, final int i) {
+        final int[] x = drawNumber(s, i);
+        int p = skipSpaces(s, x[1]);
+        if (p < s.length() && s.charAt(p) == ',') {
+            p++;
+        }
+        final int[] y = drawNumber(s, p);
+        return new double[]{x[0], y[0], y[1]};
+    }
+
+
     // --------------------------------------------------------------- ports
 
     private int dacWriteIndex;
index da0d572..0cac09e 100644 (file)
@@ -174,6 +174,9 @@ public final class GameDriver {
             case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE);
             case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE);
             case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE);
+            case '@' ->
+                // ALT+q (BIOS scan 16): TERMINAL.BAS's quit key.
+                    queue.push(KeyboardQueue.VK_ALT_BASE + 16, KeyboardQueue.CHAR_NONE);
             case '~' -> {
                 // Pause: let the simulation advance without input.
             }