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. }