From: Svjatoslav Agejenko
Date: Thu, 3 Sep 2026 20:13:51 +0000 (+0300)
Subject: feat: run the full QB45 EXAMPLES suite (23 programs) oracle-verified
X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=24c8c9e56917d0de28dec405735e032c6b54776e;p=crtbasic.git
feat: run the full QB45 EXAMPLES suite (23 programs) oracle-verified
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.
---
diff --git a/Documentation/language/index.org b/Documentation/language/index.org
index 14b31ee..a42e845 100644
--- a/Documentation/language/index.org
+++ b/Documentation/language/index.org
@@ -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
diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
index e3d008f..dc91ac9 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
@@ -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 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 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 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- items, boolean newline, int line) implements Stmt {
+ public record PrintStmt(Expr channel, List
- items, boolean newline, int line,
+ Expr usingFormat) implements Stmt {
public record Item(Expr expr, char separatorAfter) {
}
+
+ public PrintStmt(final Expr channel, final List
- 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 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 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 values, int line) implements Stmt {
}
- /** One READ target: a scalar variable or an array element. */
- public record ReadTarget(String name, List 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 indices, String field) {
+ public ReadTarget(final String name, final List 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 indices, Expr value, int line) implements Stmt {
+ public record AssignStmt(String variable, List indices, Expr value, int line,
+ String field) implements Stmt {
+ public AssignStmt(final String variable, final List 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 params, List body, int line) implements Stmt {
+ public record SubStmt(String name, List params, List body, int line,
+ boolean isStatic) implements Stmt {
+ public SubStmt(final String name, final List params, final List 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 params, List body, int line) implements Stmt {
+ public record FunctionStmt(String name, List params, List body, int line,
+ boolean isStatic) implements Stmt {
+ public FunctionStmt(final String name, final List params, final List 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 indices, String field, int line) implements Expr {
+ }
+
/** Function call: POINT(x, y), POS(0), CSRLIN, CHR$(n), ... */
public record Call(String name, List args, int line) implements Expr {
}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
index 430c64a..b5b9488 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
@@ -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());
}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java b/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java
index 95f642e..1e0233b 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java
@@ -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));
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
index a0f9ebf..34193ec 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
@@ -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 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 fileChannels = new HashMap<>();
+ /** File path per text channel (lazy byte-handle creation for INPUT$). */
+ private final Map fileChannelPaths = new HashMap<>();
/** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */
private final Map fileOutputs = new HashMap<>();
private final Map 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 typeDefs = new HashMap<>();
+ /** Record type of DIMmed variables, keyed by lowercase base name. */
+ private final Map 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> staticStorage = new HashMap<>();
+ /** Static cells of the procedure currently executing (null at module level). */
+ private Map currentStaticCells;
+ /** Names of the STATIC variables of the procedure currently executing. */
+ private java.util.Set 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 procStack = new ArrayDeque<>();
+ /** BINARY/RANDOM channels: byte-exact content plus a file pointer. */
+ private final Map binaryChannels = new HashMap<>();
+ /** Channels opened on "CON" (console output). */
+ private final java.util.Set consoleChannels = new java.util.HashSet<>();
+ /** Null-modem COM channels: input always empty, output discarded. */
+ private final java.util.Set 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 fields;
+ /** Byte size of the whole record (LEN of a variable of this type). */
+ private final int size;
+
+ TypeDef(final String name, final List fields,
+ final Map 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 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 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 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 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 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 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 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 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 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();
diff --git a/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java b/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java
index 28afced..692a5eb 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java
@@ -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. */
diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
index 52276fa..6684212 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
@@ -20,6 +20,8 @@ public final class Parser {
private final List 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 knownTypes = new java.util.HashSet<>();
private Parser(final List 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 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 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) null, keyword.line());
}
- if (peek().isText("#")) {
+ final List 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 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 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 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 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 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 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 arrayOffsetIndices() {
final List 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 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 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 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) {
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
index 0db9f6c..af2514c 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
@@ -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) {
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java b/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java
index 057d40a..d053db9 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java
@@ -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).
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
index ca02d42..b0bd6e5 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
@@ -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;
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
index c088e53..af75796 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
@@ -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.
*/
- 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 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 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;
diff --git a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
index da0d572..0cac09e 100644
--- a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
+++ b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
@@ -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.
}