feat: add GOSUB/RETURN, CONST, SWAP, WIDTH, ELSEIF, and screen capture
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 22 Aug 2026 10:28:41 +0000 (13:28 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 22 Aug 2026 10:28:41 +0000 (13:28 +0300)
- Language: GOSUB/RETURN (nested calls, same-block labels), CONST
  module-level constants, SWAP for scalars and array elements, ELSEIF
  chains in block IF, and bare parameterless FUNCTION calls.
- WIDTH statement for SCREEN 0: 80x25, 40x25 (double-wide glyphs) and
  80x50 (8x8 font) text grids; clears the screen like the original.
- PAINT gets classic border semantics: fills every reachable non-border
  pixel instead of confining to the seed color; off-screen seeds clip.
- INPUT # follows classic delimiters: numbers split on comma, space or
  newline; strings on comma/newline with quotes stripped; empty slots
  read as 0 / ""; statements may share and span lines.
- VIEW PRINT now confines printing and scrolling to the band; bare
  VIEW PRINT restores the full screen.
- New capture package: ScreenRecorder samples the visual page at 30 Hz
  into a lossless XOR-delta+deflate .crtrec; CrtrecEncoder pipes raw
  frames to ffmpeg (H.264, CRF 18 with a VBV cap). Swing frontend:
  F12 screenshot, Shift+F12 recording, background transcode queue that
  drains before exit; --encode CLI re-encodes a recording later.
- Docs: README.org replaced by AGENTS.org (agent operating guide);
  language reference updated for the new statements and semantics.

17 files changed:
AGENTS.org [new file with mode: 0644]
Documentation/language/index.org
README.org [deleted file]
src/main/java/eu/svjatoslav/crtbasic/Main.java
src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java
src/main/resources/fonts/README.txt [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/interp/HackerBasTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java

diff --git a/AGENTS.org b/AGENTS.org
new file mode 100644 (file)
index 0000000..2dbcc99
--- /dev/null
@@ -0,0 +1,172 @@
+Operating guide for AI coding agents working in this repository.
+
+* Purpose
+:PROPERTIES:
+:ID:       4e41de30-543c-422b-b439-029a225b8002
+:END:
+
+CRT Basic is a BASIC interpreter written in pure Java.
+
+It implements the video modes of the classic =SCREEN= statement —
+modes 0, 1, 2 and 7–13, i.e. the CGA, EGA and VGA BIOS modes with
+their original resolutions and color depths.
+
+CRT Basic is built to be also easily usable by an AI agent so that it
+can write, run, observe, interact with, and test classic DOS-era
+=.bas= programs with no human in the loop. Every part of the runtime —
+screen, keyboard, program state — is observable and scriptable.
+
+| Key        | Value                                       |
+|------------+---------------------------------------------|
+| Repository | https://www3.svjatoslav.eu/git/crtbasic.git |
+| Language   | Java 21, Maven, JUnit 5                     |
+| Main class | ~eu.svjatoslav.crtbasic.Main~               |
+| License    | CC0                                         |
+
+* Documentation is the authority
+:PROPERTIES:
+:ID:       e9619a92-3e87-4ad7-be0a-5eb076309492
+:END:
+
+The org-mode site under =Documentation/= is the authoritative
+description of the language and runtime. Consult it before assuming
+what is implemented:
+
+- [[file:Documentation/language/index.org][Documentation/language]] — the BASIC dialect: statements, types,
+  functions, PRINT formatting, error behavior. This is the feature
+  list; treat it as ground truth over any summary (including this
+  file).
+- [[file:Documentation/video/index.org][Documentation/video]] — the virtual VGA: indexed pixels, text
+  rendering, video pages.
+- [[file:Documentation/interpreter/index.org][Documentation/interpreter]] — the execution engine and number flow.
+- [[file:Documentation/architecture/index.org][Documentation/architecture]] — pipeline and module boundaries.
+- [[file:Documentation/agent-integration/index.org][Documentation/agent-integration]] — the agent drive loop in full
+  detail (CLI flags, GameDriver, embedding API).
+
+When a change alters observable behavior, update the matching
+=Documentation/= page in the same pass. The site is regenerated with
+~Tools/Update web site~ (exports the =Documentation/= org files to
+HTML via Emacs batch). This AGENTS.org is not part of the site and
+needs no export.
+
+* Layout
+:PROPERTIES:
+:ID:       21f8dccb-f227-44b7-bb8a-c75072947524
+:END:
+
+#+begin_example
+src/main/java/eu/svjatoslav/crtbasic/
+├── lexer/     tokenizer (CP437/CRLF tolerant, _ line continuations)
+├── parser/    recursive-descent parser
+├── ast/       AST node definitions
+├── interp/    tree-walking executor (interim; bytecode VM is planned)
+├── video/     virtual VGA: ScreenMode, Framebuffer, VgaFont,
+│              TextConsole, VgaDevice, DefaultPalettes
+├── input/     KeyboardQueue shared by all frontends
+├── audio/     SoundQueue (timing modeled, silent)
+├── frontend/  Swing window: live screen + keyboard capture
+├── Cp437.java CP437 ↔ Unicode mapping
+└── Main.java  CLI entry point
+
+src/test/java/eu/svjatoslav/crtbasic/
+├── drivers/GameDriver.java        scripted keystroke play-testing
+├── video/VideoDemoMain.java       renders showcase frames to
+│                                  /tmp/crt-basic-demo/
+└── ...                            JUnit tests for video, graphics
+                                   primitives, interpreter end to end
+#+end_example
+
+- ~install.sh~ — builds and installs the jar plus a ~crtbasic~
+  launcher into =~/.local= (override with =CRT_BASIC_PREFIX=), and
+  registers a desktop file handler for =.bas= files.
+- =Tools/= — desktop helper scripts (~Open with IntelliJ IDEA~,
+  ~Update web site~).
+
+* Build and run
+:PROPERTIES:
+:ID:       d92189db-d9f4-4e95-b47d-18b5d7adf5fc
+:END:
+
+#+begin_src sh
+cd "/path/to/crtbasic"
+mvn package
+java -jar target/crt-basic-*-SNAPSHOT.jar program.bas
+#+end_src
+
+Tests: ~mvn test~. Run them before declaring any interpreter change
+done.
+
+* Agent drive loop
+:PROPERTIES:
+:ID:       71c4714b-b0e1-4a95-886f-2addb720eeeb
+:END:
+
+1) Run the program headless with a statement budget and dumps:
+
+   : crtbasic app.bas --headless --screenshot=out.png --raw=out.raw --steps=100000
+
+2) =--raw= gives the raw indexed pixels of every video page, suitable
+   for byte-level comparison between runs. The run also reports its
+   statement count to stderr, so "did the loop spin?" is answerable
+   without looking at pixels.
+
+3) For keyboard-driven programs use the GameDriver (test sources),
+   which injects one key event per =--interval= milliseconds:
+
+   : java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.drivers.GameDriver worm.bas --keys="RR~~~UU" --steps=8000000 --screenshot=end.png --vars=score%
+
+4) =--vars=a%,score= prints named global variables after the run —
+   assert on program state directly instead of inferring it from
+   pixels.
+
+5) For video-subsystem changes, run =VideoDemoMain= and *look* at the
+   frames in =/tmp/crt-basic-demo/= with vision; assertions alone do
+   not catch rendering regressions.
+
+Setting =-Dcrtbasic.linelog=/tmp/lines.log= makes every =LINE=
+statement append its post-conversion coordinates to that file — frame
+by frame drawing traces.
+
+* Semantics worth knowing
+:PROPERTIES:
+:ID:       edacb822-d82b-4d2d-a54f-4aa2dc4f5eb0
+:END:
+
+These are verified behaviors that are easy to get wrong when editing
+the interpreter. Each is covered by the documentation; the pointers
+below exist so they are not "rediscovered" as bugs.
+
+- *=Name:= is a label only at the start of a logical line.* After
+  =THEN= or a =:= separator it parses as a parameterless bare SUB call
+  followed by the next statement (=Parser.java=). Do not "fix" one
+  reading without checking the other.
+- *Strings round-trip CP437 at the language boundary.* =CHR$(n)= is
+  the CP437 character for byte =n=; =ASC("ü")= is 129, not the Unicode
+  code point 252 (=Cp437.java=, used by =interp=). Source files are
+  read as CP437.
+- *=PUT= default action is XOR*, and =GET=/=PUT= accept array-element
+  anchors like =playerFrames(202, 1)= as the target (=parser=,
+  =ast=).
+- *The SCREEN 13 default palette is an exact 256-entry BIOS dump*
+  (=DefaultPalettes.java=) — do not regenerate it from a formula.
+- *Graphics-mode text paints the whole glyph cell* with an opaque
+  background in every mode: PRINTing spaces erases text and graphics
+  underneath. Default text foreground after =SCREEN= is 15 in
+  graphics modes, 7 in SCREEN 0.
+- *Headless input never blocks*: =INKEY$= / =INPUT$(n)= drain the
+  keyboard queue and return =""= when empty, so bot runs cannot hang.
+  Interactive mode blocks.
+- Unsupported statements fail loudly, naming the statement and the
+  source line — that error stream is the implementation roadmap, not a
+  crash to paper over.
+
+* Conventions
+:PROPERTIES:
+:ID:       5b1e9c72-6d3f-4a08-b7e2-2c9d4f1a6e53
+:END:
+
+- *Portable paths only.* Committed files must not contain
+  machine-local absolute paths; use =/path/to/crtbasic= in examples
+  and =~/...= for per-user locations.
+- *Filenames with spaces.* Test programs and helper scripts carry
+  spaces in their names — quote every path in scripts and examples.
index 3b0c60d..1a84013 100644 (file)
@@ -43,7 +43,7 @@ program by program.
 - *Numbers*: decimal (=3.25=), exponents (=1.5e3=, =2D+10=), hex
   =&HFF=, octal =&O17=, and type-suffixed literals (=1024&=, =1.5#=).
 - *Labels*: numeric (=1 IF x THEN GOTO 2=) or alphanumeric
-  (=MainLoop:=). Targets for =GOTO= and =ON ERROR GOTO=.
+  (=MainLoop:=). Targets for =GOTO=, =GOSUB= and =ON ERROR GOTO=.
 
 * Variables and types
 :PROPERTIES:
@@ -76,6 +76,9 @@ Important consequences:
   assigned.
 - =DIM name AS INTEGER= fixes a suffix-less name's type the same way
   a suffix would.
+- =CONST name = expr [, …]= defines module-level named constants
+  (may reference earlier constants); reassigning one raises
+  *Duplicate definition*.
 - Unassigned numeric variables read as =0=, strings as =""=.
 
 *Truth values*: comparisons return =-1= for true and =0= for false.
@@ -117,7 +120,7 @@ DIM names$(20) AS STRING     ' AS type form
 | =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original |
 | =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 |
+| =PAINT (x,y)[, fill[, border]]= | Flood fill: paints over any color up to the border (border defaults to fill) |
 | =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 |
 | =PCOPY source, target= | Copies one video page onto another |
@@ -130,8 +133,9 @@ DIM names$(20) AS STRING     ' AS type form
 | =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline |
 | =LOCATE row, col= | 1-based cursor position |
 | =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 (parsed; framebuffer effect is currently an approximation) |
+| =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 |
 
 ** Control flow
@@ -139,13 +143,13 @@ DIM names$(20) AS STRING     ' AS type form
 | Statement | Notes |
 |-----------+-------|
 | =FOR v = a TO b [STEP s] … NEXT= | STEP 0 raises an error |
-| =IF c THEN … [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms |
+| =IF c THEN … [ELSEIF c THEN …] [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms; =ELSEIF= in block form only |
 | =WHILE c … WEND= | |
 | =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 |
+| =GOSUB label= / =RETURN= | Nested calls OK; GOSUB and its label must live in the same block |
 | =END=, =SYSTEM= | Both end the program |
-| =GOSUB= / =RETURN= | *Not yet implemented* |
 
 ** Procedures
 
@@ -169,6 +173,8 @@ END FUNCTION
   variable. Expressions are passed by value.
 - A FUNCTION's return value is whatever was last assigned to the
   function's own name inside the body.
+- A parameterless FUNCTION may be called without parentheses (=x =
+  getChar= calls =getChar()=, unless a variable by that name exists).
 - =DECLARE= statements carry parameter type suffixes: when a
   SUB/FUNCTION header uses bare names, the declared suffixes type the
   parameters.
@@ -182,13 +188,14 @@ END FUNCTION
 | =DATA v, …= / =READ var, …= | DATA is module-wide, collected in program order; running out raises *Out of DATA* |
 | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= |
 | =OPEN path FOR INPUT AS #n= | Text files; paths resolve relative to the =.bas= file |
-| =INPUT #n, var, …= | Comma-delimited items; numbers parse like =VAL= |
+| =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= |
 | =LINE INPUT #n, var$= | Whole line |
 | =EOF(n)= | True at end of channel |
 | =CLOSE [#n]= | Bare form closes all |
 | =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping |
 | =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump |
 | =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet |
+| =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* |
 | =OUT port, value= | VGA DAC ports only; others ignored |
 
 * Expressions and operators
diff --git a/README.org b/README.org
deleted file mode 100644 (file)
index 0ac292c..0000000
+++ /dev/null
@@ -1,132 +0,0 @@
-* CRT Basic
-
-A BASIC interpreter written in Java, built so an AI agent can
-write, run, observe, interact with, and test classic =.bas= programs
-with no human in the loop.
-
-* Why not an existing implementation?
-
-Surveyed existing Java BASIC implementations (2026-08):
-
-| Project                                            | Dialect           | Showstopper                                          |
-|----------------------------------------------------+-------------------+------------------------------------------------------|
-| PuffinBASIC                                        | GW-BASIC          | Line numbers required; no SUB/FUNCTION, DEFINT, SCREEN 13 |
-| lwiest/BASICCompiler                               | GW-BASIC          | Same dialect mismatch; compiles to JVM bytecode      |
-| jvmBASIC                                           | classic BASIC     | Line-numbered dialect, no graphics/IO                |
-| Student interpreters (various repos)               | BASIC-ish subsets | Text-only, abandoned, no graphics                    |
-
-None of them run the classic DOS-era programs this interpreter targets
-unchanged. Hence this project.
-* Architecture
-
-#+begin_example
-.bas source
-   │
-   ▼
-lexer      → tokens (CP437/CRLF tolerant; DONE — lexer package)
-parser     → AST   (DONE for a practical subset — parser/ast packages)
-compiler   → stack-based bytecode (planned; enables deterministic stepping)
-vm         → executes bytecode (planned)
-interp     → INTERIM: tree-walks the AST so whole programs run today
-video      → virtual VGA: text mode 80×25 and SCREEN 1–13 pixel modes (DONE)
-input      → keyboard queue (DONE — window frontend and headless drivers feed it)
-frontend   → Swing window: live screen view + keyboard capture (DONE)
-audio      → SOUND/PLAY stubbed (parsed, no-op), real output later
-#+end_example
-
-Design decisions:
-
-- *Bytecode VM, not tree-walking.* SCREEN 13 programs sit in tight
-  PSET loops; a compact stack bytecode keeps interpretation overhead low
-  and gives a natural "execute N instructions" granularity for
-  deterministic, agent-driven stepping.
-- *Introspection.* The VM exposes current instruction pointer, variable
-  values and screen state so an agent can verify program behavior without
-  guessing.
-* Install and usage
-
-#+begin_src sh
-cd "/path/to/crtbasic"
-./install.sh                     # installs to ~/.local (CRT_BASIC_PREFIX to override)
-crtbasic program.bas                           # interactive window
-crtbasic app.bas --headless --screenshot=out.png --raw=out.raw --steps=100000
-#+end_src
-
-- Default is interactive: a Swing window shows the live screen (~30 fps)
-  and key presses feed the program's input queue.
-- =--headless= (implied by =--screenshot= / =--raw=) runs with no window
-  and dumps the screen afterwards — the bot-driving path. =--raw= gives
-  the raw indexed pixels of every video page, suitable for byte-level
-  comparison between runs.
-- =--steps=N= caps executed statements so looping games can be sampled
-  deterministically.
-- =--command=text= sets what COMMAND$ returns (e.g. People.bas's
-  slideshow auto-advances slides on a timer when run with
-  =--command=t=).
-
-* Build
-
-#+begin_src sh
-cd "/path/to/crtbasic"
-mvn package
-java -jar target/crt-basic-*-SNAPSHOT.jar program.bas
-#+end_src
-
-* Status
-
-Implemented so far:
-
-- *video* package (=eu.svjatoslav.crtbasic.video=) — the virtual VGA:
-  - =ScreenMode= — table of screen modes 0, 1, 2, 7-13: pixel resolution,
-    glyph cell height (8/14/16), colors, video pages.
-  - =Framebuffer= — indexed pixels (one byte per pixel = palette index) so
-    PALETTE tricks and color cycling work; multiple video pages; PNG dumps.
-  - =VgaFont= — CP437 8x8/8x14/8x16 bitmap fonts (256 glyphs each,
-    public-domain VGA fonts from SeaBIOS) as embedded resources.
-  - =TextConsole= — PRINT/LOCATE/COLOR/CLS/VIEW PRINT semantics: glyph
-    blitting into the pixel framebuffer (opaque cells in every mode),
-    cursor wrap with the classic bottom-right-corner behavior,
-    band-confined scrolling.
-  - =VgaDevice= — facade the executor calls: mode switching, text,
-    PSET/POINT, LINE (plain/B/BF), CIRCLE (mode default aspect), palette,
-    active/visual page selection, graphics cursor, screenshots.
-- *lexer* / *parser* / *ast* — tokenizer (CP437/CRLF tolerant, `_` line
-  continuations) and recursive-descent parser for a practical subset:
-  SCREEN, LOCATE, PRINT (incl. ; and , zones), CLS, COLOR, PSET, LINE
-  (B/BF), CIRCLE, SOUND, OUT, PAINT, GET/PUT (incl. array-element
-  offsets like =playerWalkingFrames(202, 1)=), DIM (SHARED, `TO` bounds,
-  AS type, fixed-length STRING * n), MID$ statement, OPEN FOR INPUT /
-  INPUT # / LINE INPUT # / CLOSE (DOS paths resolve case-insensitively
-  against the .bas directory), assignment, FOR/NEXT (STEP), single-line
-  and block IF/THEN/ELSE, WHILE/WEND, DO...LOOP, GOTO labels,
-  SUB/FUNCTION/CALL, DECLARE, DEFINT & siblings, SELECT CASE (equality
-  and inclusive =a TO b= ranges, CASE ELSE), SLEEP, END, and expressions
-  with the usual functions (POINT, POS, CSRLIN, ABS, INT, SQR,
-  SIN/COS/TAN/ATN/EXP/LOG, RND, CHR$, STR$, LEN, ASC, VAL,
-  LEFT$/RIGHT$/MID$, UCASE$/LCASE$, INKEY$, TIME$, TIMER, COMMAND$).
-  Unsupported statements fail loudly with the line number — that tells
-  us what to implement next.
-- *interp* — INTERIM tree-walking executor (to be replaced by the
-  bytecode VM). Classic dialect semantics: doubles by default,
-  suffix-typed variables, true = -1, classic number formatting in PRINT.
-- *input* — KeyboardQueue shared by all frontends.
-- *frontend* — Swing window: live scaled screen view, keys into the queue.
-- =Main= CLI — interactive by default, headless dumps for bots.
-- =install.sh= — installs jar + =crtbasic= launcher to =~/.local=.
-- JUnit tests cover video semantics, drawing primitives and the
-  interpreter end to end.
-- =VideoDemoMain= (test sources) renders showcase frames to
-  =/tmp/crt-basic-demo/= for visual verification:
-  #+begin_src sh
-  java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain
-  #+end_src
-
-Notable semantics worth knowing:
-
-- Graphics-mode text paints the *whole glyph cell* (opaque background) —
-  that is why PRINTing spaces erases text.
-- Default foreground after SCREEN is 15 in graphics modes, 7 in SCREEN 0.
-
-Not yet: bytecode compiler/VM (interim tree-walker runs programs now),
-GOSUB/RETURN, DRAW, CASE IS, CIRCLE arcs, console INPUT statement, real
-audio (SOUND is timing-modeled but silent).
index 939c268..e711966 100644 (file)
@@ -2,6 +2,7 @@ package eu.svjatoslav.crtbasic;
 
 import eu.svjatoslav.crtbasic.ast.Ast;
 import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.capture.CrtrecEncoder;
 import eu.svjatoslav.crtbasic.frontend.SwingFrontend;
 import eu.svjatoslav.crtbasic.input.KeyboardQueue;
 import eu.svjatoslav.crtbasic.interp.Interpreter;
@@ -43,6 +44,10 @@ public final class Main {
         if (args.length == 0) {
             usage();
         }
+        if (args[0].startsWith("--encode=")) {
+            runEncoder(args);
+            return;
+        }
         final Path programPath = Path.of(args[0]);
         boolean headless = false;
         Path screenshot = null;
@@ -136,7 +141,7 @@ public final class Main {
                                        final VgaDevice vga, final KeyboardQueue keys,
                                        final Path programPath) {
         SwingUtilities.invokeLater(() ->
-                new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName()).show());
+                new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName(), programPath).show());
         final Thread programThread = new Thread(() -> {
             try {
                 interpreter.run(program);
@@ -178,13 +183,28 @@ public final class Main {
                 JOptionPane.ERROR_MESSAGE);
     }
 
+    private static void runEncoder(final String[] args) {
+        final Path recording = Path.of(args[0].substring("--encode=".length()));
+        final Path output = args.length > 1
+                ? Path.of(args[1]) : CrtrecEncoder.defaultMp4For(recording);
+        try {
+            CrtrecEncoder.encodeToMp4(recording, output);
+        } catch (final IOException e) {
+            System.err.println("Encode failed: " + e.getMessage());
+            System.exit(1);
+        }
+        System.err.println("MP4: " + output);
+    }
+
     private static void usage() {
         System.err.println("""
                 Usage: crtbasic <program.bas> [--headless] [--screenshot=out.png] [--raw=out.raw] [--steps=N] [--command=text]
-                  (no options)   interactive window
+                       crtbasic --encode=recording.crtrec [out.mp4]
+                  (no options)   interactive window (F12 screenshot, Shift+F12 record)
                   --headless     run offscreen; dump framebuffer afterwards
                   --steps=N      stop after N statements (default: unlimited)
-                  --command=text what COMMAND$ returns (default: empty)""");
+                  --command=text what COMMAND$ returns (default: empty)
+                  --encode       re-encode a .crtrec recording to MP4 via ffmpeg""");
         System.exit(1);
     }
 }
index 80c748b..570a2c4 100644 (file)
@@ -108,6 +108,14 @@ public final class Ast {
     public record ColorStmt(Expr foreground, Expr background, int line) implements Stmt {
     }
 
+    /** {@code WIDTH [columns][, rows]} — text grid size; null slot = omitted. */
+    public record WidthStmt(Expr columns, Expr rows, int line) implements Stmt {
+    }
+
+    /** {@code SWAP a, b} — exchanges two variables or array elements in place. */
+    public record SwapStmt(ReadTarget first, ReadTarget second, int line) implements Stmt {
+    }
+
     /** {@code PSET (x, y)[, color]} */
     public record PsetStmt(Expr x, Expr y, Expr color, int line) implements Stmt {
     }
@@ -178,6 +186,22 @@ public final class Ast {
     public record GotoStmt(String label, int line) implements Stmt {
     }
 
+    /** {@code GOSUB label} — pushes a return address, then jumps like GOTO */
+    public record GosubStmt(String label, int line) implements Stmt {
+    }
+
+    /** {@code RETURN} — resumes after the matching GOSUB */
+    public record ReturnStmt(int line) implements Stmt {
+    }
+
+    /** One {@code name = value} pair of a CONST statement. */
+    public record ConstEntry(String name, Expr value) {
+    }
+
+    /** {@code CONST name = expr [, name = expr …]} — module-level named constants */
+    public record ConstStmt(List<ConstEntry> entries, int line) implements Stmt {
+    }
+
     /** {@code SUB name (param, ...) ... END SUB} — definition, not executed inline */
     public record SubStmt(String name, List<String> params, List<Stmt> body, int line) implements Stmt {
     }
diff --git a/src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java b/src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java
new file mode 100644 (file)
index 0000000..fc95b24
--- /dev/null
@@ -0,0 +1,227 @@
+package eu.svjatoslav.crtbasic.capture;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+/**
+ * Re-encodes a {@code .crtrec} recording (see {@link ScreenRecorder}) into
+ * an MP4 by piping raw RGB frames into ffmpeg. This is where compression
+ * quality is paid for: H.264 sees the full frame sequence and exploits
+ * motion between frames, which per-frame JPEG never could.
+ *
+ * <p>ffmpeg must be on PATH; {@link #ffmpegAvailable()} checks that.</p>
+ */
+public final class CrtrecEncoder {
+
+    private static final byte TYPE_FRAME = 1;
+    private static final byte TYPE_PALETTE = 2;
+    private static final byte TYPE_REPEAT = 3;
+
+    private CrtrecEncoder() {
+        // Utility class, not meant to be instantiated.
+    }
+
+    /** @return true when an ffmpeg executable is reachable on PATH */
+    public static boolean ffmpegAvailable() {
+        try {
+            final Process probe = new ProcessBuilder("ffmpeg", "-version")
+                    .redirectErrorStream(true)
+                    .start();
+            probe.getInputStream().readAllBytes();
+            return probe.waitFor() == 0;
+        } catch (final IOException | InterruptedException e) {
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Encodes {@code recording} to {@code output} (H.264, CRF 18 with a
+     * VBV bitrate cap, yuv420p for player compatibility). The cap
+     * ({@link #maxBitRateBps}) keeps high-motion content from ballooning:
+     * calm scenes keep CRF's low bitrate, motion bursts get clamped.
+     * Blocks until ffmpeg finishes.
+     *
+     * @throws IOException on I/O or recording-format errors, or when ffmpeg
+     *                     exits with a non-zero status
+     */
+    public static void encodeToMp4(final Path recording, final Path output) throws IOException {
+        encodeToMp4(recording, output, null);
+    }
+
+    /**
+     * Like {@link #encodeToMp4(Path, Path)}, but appends ffmpeg's stderr to
+     * {@code logFile} (instead of inheriting this process's stderr) when
+     * non-null — for desktop launches where nobody can see stderr.
+     */
+    public static void encodeToMp4(final Path recording, final Path output, final Path logFile)
+            throws IOException {
+        final DataInputStream in = new DataInputStream(new BufferedInputStream(
+                Files.newInputStream(recording), 1 << 16));
+        final int width;
+        final int height;
+        final int fps;
+        try (in) {
+            final byte[] magic = new byte[8];
+            in.readFully(magic);
+            if (!"CRTREC01".equals(new String(magic, java.nio.charset.StandardCharsets.US_ASCII))) {
+                throw new IOException(recording + ": not a CRTREC01 recording");
+            }
+            width = in.readInt();
+            height = in.readInt();
+            fps = in.readInt();
+
+            // VBV cap: CRF 18 alone lets x264 spend unbounded bits on
+            // high-motion content (a full-screen Matrix crawl hits
+            // ~10 Mbit/s). maxrate/bufsize clamp the peaks; calm scenes
+            // are unaffected since CRF stays below the cap there.
+            final long maxRate = maxBitRateBps(width, height, fps);
+            final ProcessBuilder builder = new ProcessBuilder("ffmpeg", "-y",
+                        "-f", "rawvideo",
+                        "-pix_fmt", "rgb24",
+                        "-s", width + "x" + height,
+                        "-r", Integer.toString(fps),
+                        "-i", "pipe:0",
+                        "-c:v", "libx264",
+                        "-preset", "medium",
+                        "-crf", "18",
+                        "-maxrate", maxRate / 1000 + "k",
+                        "-bufsize", maxRate / 500 + "k",
+                        "-pix_fmt", "yuv420p",
+                        output.toString());
+            if (logFile != null) {
+                builder.redirectError(ProcessBuilder.Redirect.appendTo(logFile.toFile()));
+            } else {
+                builder.redirectError(ProcessBuilder.Redirect.INHERIT);
+            }
+            final Process ffmpeg = builder.start();
+            final Thread pump = new Thread(() -> pumpFrames(in, ffmpeg.getOutputStream(),
+                    width, height), "crtrec-encode-pump");
+            pump.start();
+            final int exit = await(ffmpeg);
+            try {
+                pump.join();
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            if (exit != 0) {
+                throw new IOException("ffmpeg exited with status " + exit);
+            }
+        }
+    }
+
+    /** @return default output path for a recording: same name with .mp4 */
+    public static Path defaultMp4For(final Path recording) {
+        final String name = recording.getFileName().toString();
+        final String base = name.endsWith(".crtrec")
+                ? name.substring(0, name.length() - ".crtrec".length()) : name;
+        return recording.resolveSibling(base + ".mp4");
+    }
+
+    // ------------------------------------------------------------ internals
+
+    /**
+     * VBV peak bitrate in bit/s: 0.35 bits per pixel per frame, floored at
+     * 200 kbit/s so small SCREEN modes still get a useful budget.
+     */
+    static long maxBitRateBps(final int width, final int height, final int fps) {
+        final long bps = (long) (width * (double) height * fps * 0.35);
+        return Math.max(bps, 200_000);
+    }
+
+    private static void pumpFrames(final DataInputStream in, final OutputStream ffmpegIn,
+                                   final int width, final int height) {
+        final int pixelCount = width * height;
+        final byte[] frame = new byte[pixelCount];
+        final int[] palette = new int[256];
+        final byte[] rgb = new byte[pixelCount * 3];
+        try (in; ffmpegIn) {
+            while (true) {
+                final byte type;
+                try {
+                    type = in.readByte();
+                } catch (final EOFException e) {
+                    return;
+                }
+                switch (type) {
+                    case TYPE_FRAME -> {
+                        final int length = in.readInt();
+                        final byte[] compressed = new byte[length];
+                        in.readFully(compressed);
+                        final byte[] delta = inflate(compressed, pixelCount);
+                        for (int i = 0; i < pixelCount; i++) {
+                            frame[i] ^= delta[i];
+                        }
+                        writeRgb(frame, palette, rgb, ffmpegIn);
+                    }
+                    case TYPE_PALETTE -> {
+                        for (int i = 0; i < palette.length; i++) {
+                            final int r = in.readByte() & 0xFF;
+                            final int g = in.readByte() & 0xFF;
+                            final int b = in.readByte() & 0xFF;
+                            palette[i] = (r << 16) | (g << 8) | b;
+                        }
+                    }
+                    case TYPE_REPEAT -> {
+                        final int count = in.readInt();
+                        for (int i = 0; i < count; i++) {
+                            writeRgb(frame, palette, rgb, ffmpegIn);
+                        }
+                    }
+                    default -> throw new IOException("Unknown record type " + type);
+                }
+            }
+        } catch (final IOException e) {
+            // ffmpeg closing the pipe early (e.g. on its own error) surfaces
+            // as an exception here; ffmpeg's exit code is checked by the caller.
+        }
+    }
+
+    private static void writeRgb(final byte[] frame, final int[] palette,
+                                 final byte[] rgb, final OutputStream out) throws IOException {
+        for (int i = 0; i < frame.length; i++) {
+            final int color = palette[frame[i] & 0xFF];
+            rgb[i * 3] = (byte) (color >> 16);
+            rgb[i * 3 + 1] = (byte) (color >> 8);
+            rgb[i * 3 + 2] = (byte) color;
+        }
+        out.write(rgb);
+    }
+
+    private static byte[] inflate(final byte[] compressed, final int expectedLength)
+            throws IOException {
+        final Inflater inflater = new Inflater();
+        try {
+            inflater.setInput(compressed);
+            final byte[] result = new byte[expectedLength];
+            final int produced = inflater.inflate(result);
+            if (produced != expectedLength) {
+                throw new IOException("Corrupt FRAME record: expected " + expectedLength
+                        + " bytes, got " + produced);
+            }
+            return result;
+        } catch (final DataFormatException e) {
+            throw new IOException("Corrupt FRAME record: " + e.getMessage(), e);
+        } finally {
+            inflater.end();
+        }
+    }
+
+    private static int await(final Process process) throws IOException {
+        try {
+            return process.waitFor();
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IOException("Interrupted while waiting for ffmpeg", e);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java b/src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java
new file mode 100644 (file)
index 0000000..7ee1f52
--- /dev/null
@@ -0,0 +1,259 @@
+package eu.svjatoslav.crtbasic.capture;
+
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.io.BufferedOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.zip.Deflater;
+
+/**
+ * Lossless screen recorder: samples the visual page of a {@link VgaDevice}
+ * at a fixed 30 Hz and writes a compact {@code .crtrec} file that can be
+ * re-encoded to video later (see {@link CrtrecEncoder}).
+ *
+ * <p>Frames are stored as palette indices (the framebuffer's native format),
+ * XOR-deltaed against the previous stored frame and deflated at level 1 —
+ * pixel graphics delta-compress extremely well, so the cost per frame is a
+ * memcpy, an XOR pass and a fast deflate of at most a few hundred KB.
+ * Unchanged frames collapse to a repeat count (zero bytes per frame), and
+ * palette changes are stored as palette snapshot records so the encoder can
+ * reconstruct exact RGB later. No per-frame JPEG: no generation loss, and
+ * the final compressor gets to exploit motion between frames.</p>
+ *
+ * <p>A SCREEN mode change during recording changes the frame geometry, which
+ * a video stream cannot express — the recorder stops itself in that case
+ * (see {@link #setOnAutoStop}).</p>
+ *
+ * <h2>.crtrec file format (all integers big-endian)</h2>
+ * <pre>
+ *   header:  8 bytes magic "CRTREC01", int width, int height, int fps
+ *   record:  byte type, payload
+ *     type 1 FRAME:   int compressedLength, deflate(XOR delta vs previous frame)
+ *     type 2 PALETTE: 768 bytes (256 RGB triplets)
+ *     type 3 REPEAT:  int count (previous frame repeated count more times)
+ *   The stream starts with one PALETTE record, then a FRAME record whose
+ *   delta is against an all-zero page.
+ * </pre>
+ */
+public final class ScreenRecorder {
+
+    /** Sampling rate; also the fps stamped into the .crtrec header. */
+    public static final int FPS = 30;
+    private static final int SAMPLE_MS = 1000 / FPS;
+
+    private static final byte TYPE_FRAME = 1;
+    private static final byte TYPE_PALETTE = 2;
+    private static final byte TYPE_REPEAT = 3;
+
+    private final VgaDevice vga;
+    private final Path output;
+    private final long startedNanos = System.nanoTime();
+
+    private DataOutputStream stream;
+    private Thread sampler;
+    private volatile boolean running;
+    private Runnable onAutoStop;
+
+    private byte[] previousFrame;
+    private byte[] currentFrame;
+    private int[] previousPalette;
+    private int pendingRepeats;
+    private long framesWritten;
+
+    private ScreenRecorder(final VgaDevice vgaDevice, final Path outputPath) {
+        vga = vgaDevice;
+        output = outputPath;
+    }
+
+    /**
+     * Starts recording the device's visual page to {@code output}. The file
+     * is created (and truncated) immediately.
+     */
+    public static ScreenRecorder start(final VgaDevice vgaDevice, final Path outputPath)
+            throws IOException {
+        final ScreenRecorder recorder = new ScreenRecorder(vgaDevice, outputPath);
+        recorder.open();
+        return recorder;
+    }
+
+    /** Invoked on the sampler thread if recording stops by itself (mode change, I/O error). */
+    public void setOnAutoStop(final Runnable callback) {
+        onAutoStop = callback;
+    }
+
+    /** @return the file being written */
+    public Path output() {
+        return output;
+    }
+
+    /** @return elapsed recording time in whole seconds, for UI display */
+    public long elapsedSeconds() {
+        return (System.nanoTime() - startedNanos) / 1_000_000_000L;
+    }
+
+    /** @return number of distinct frames stored so far (repeats excluded) */
+    public long framesWritten() {
+        return framesWritten;
+    }
+
+    /**
+     * Stops sampling, flushes pending repeats and closes the file. Safe to
+     * call from any thread; a no-op if already stopped.
+     */
+    public Path stop() {
+        running = false;
+        if (sampler != null) {
+            try {
+                sampler.join(2000);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+        if (stream != null) {
+            try {
+                flushRepeats();
+                stream.close();
+            } catch (final IOException e) {
+                // Best effort: the file keeps whatever was flushed.
+            }
+            stream = null;
+        }
+        return output;
+    }
+
+    // ------------------------------------------------------------ internals
+
+    private void open() throws IOException {
+        final int width = vga.framebuffer().width();
+        final int height = vga.framebuffer().height();
+        stream = new DataOutputStream(new BufferedOutputStream(
+                Files.newOutputStream(output), 1 << 16));
+        stream.writeBytes("CRTREC01");
+        stream.writeInt(width);
+        stream.writeInt(height);
+        stream.writeInt(FPS);
+        previousFrame = new byte[width * height];
+        currentFrame = new byte[width * height];
+        previousPalette = currentPalette();
+        writePalette(previousPalette);
+        running = true;
+        sampler = new Thread(this::sampleLoop, "screen-recorder");
+        sampler.setDaemon(true);
+        sampler.start();
+    }
+
+    private void sampleLoop() {
+        while (running) {
+            final long tickStarted = System.nanoTime();
+            try {
+                sample();
+            } catch (final GeometryChangedException | IOException e) {
+                running = false;
+                notifyAutoStop();
+                return;
+            }
+            final long elapsedMs = (System.nanoTime() - tickStarted) / 1_000_000L;
+            final long sleepMs = SAMPLE_MS - elapsedMs;
+            if (sleepMs > 0) {
+                try {
+                    Thread.sleep(sleepMs);
+                } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    return;
+                }
+            }
+        }
+    }
+
+    private void sample() throws IOException {
+        final byte[] live = vga.framebuffer().pageData(vga.visualPage());
+        if (live.length != previousFrame.length) {
+            // SCREEN mode change: frame geometry changed mid-recording.
+            throw new GeometryChangedException();
+        }
+        // Snapshot BEFORE computing anything: the program draws on this
+        // array concurrently. Deltaing the live array against previousFrame
+        // tears pixels mid-scan, and since previousFrame is then updated
+        // from the torn read, encoder and decoder disagree about the
+        // reference frame forever — errors accumulate instead of healing.
+        System.arraycopy(live, 0, currentFrame, 0, live.length);
+        final int[] palette = currentPalette();
+        if (!Arrays.equals(palette, previousPalette)) {
+            flushRepeats();
+            writePalette(palette);
+            previousPalette = palette;
+        }
+        final byte[] delta = new byte[currentFrame.length];
+        boolean changed = false;
+        for (int i = 0; i < currentFrame.length; i++) {
+            delta[i] = (byte) (currentFrame[i] ^ previousFrame[i]);
+            if (delta[i] != 0) {
+                changed = true;
+            }
+        }
+        if (!changed) {
+            pendingRepeats++;
+            return;
+        }
+        flushRepeats();
+        System.arraycopy(currentFrame, 0, previousFrame, 0, currentFrame.length);
+        writeFrame(delta);
+        framesWritten++;
+    }
+
+    private int[] currentPalette() {
+        final int[] palette = new int[256];
+        for (int i = 0; i < palette.length; i++) {
+            palette[i] = vga.framebuffer().paletteColor(i);
+        }
+        return palette;
+    }
+
+    private void writePalette(final int[] palette) throws IOException {
+        stream.writeByte(TYPE_PALETTE);
+        for (final int rgb : palette) {
+            stream.writeByte((rgb >> 16) & 0xFF);
+            stream.writeByte((rgb >> 8) & 0xFF);
+            stream.writeByte(rgb & 0xFF);
+        }
+    }
+
+    private void writeFrame(final byte[] delta) throws IOException {
+        final Deflater deflater = new Deflater(Deflater.BEST_SPEED);
+        try {
+            deflater.setInput(delta);
+            deflater.finish();
+            final byte[] buffer = new byte[delta.length + 64];
+            final int length = deflater.deflate(buffer);
+            stream.writeByte(TYPE_FRAME);
+            stream.writeInt(length);
+            stream.write(buffer, 0, length);
+        } finally {
+            deflater.end();
+        }
+    }
+
+    private void flushRepeats() throws IOException {
+        if (pendingRepeats == 0) {
+            return;
+        }
+        stream.writeByte(TYPE_REPEAT);
+        stream.writeInt(pendingRepeats);
+        pendingRepeats = 0;
+    }
+
+    private void notifyAutoStop() {
+        final Runnable callback = onAutoStop;
+        if (callback != null) {
+            callback.run();
+        }
+    }
+
+    /** Marker for "frame geometry changed mid-recording" (SCREEN switch). */
+    private static final class GeometryChangedException extends RuntimeException {
+    }
+}
index 9dea4f8..116f2e3 100644 (file)
@@ -1,5 +1,7 @@
 package eu.svjatoslav.crtbasic.frontend;
 
+import eu.svjatoslav.crtbasic.capture.CrtrecEncoder;
+import eu.svjatoslav.crtbasic.capture.ScreenRecorder;
 import eu.svjatoslav.crtbasic.input.KeyboardQueue;
 import eu.svjatoslav.crtbasic.video.VgaDevice;
 import java.awt.Graphics;
@@ -7,8 +9,18 @@ import java.awt.Graphics2D;
 import java.awt.RenderingHints;
 import java.awt.event.KeyAdapter;
 import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.BorderFactory;
 import javax.swing.JFrame;
+import javax.swing.JLabel;
 import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
 import javax.swing.Timer;
 import javax.swing.WindowConstants;
 
@@ -18,9 +30,25 @@ import javax.swing.WindowConstants;
  * on a timer. Key presses are pushed into the shared {@link KeyboardQueue}
  * so a running program sees them through {@code INKEY$}/{@code INPUT}.
  *
- * <p>The window is deliberately dumb: it owns no emulation state, it just
- * renders and forwards keys. Headless drivers use the same VgaDevice and
- * KeyboardQueue without this class.</p>
+ * <p>Two hotkeys are intercepted here and never reach the program (F11/F12
+ * produce no key code a classic program can observe):</p>
+ * <ul>
+ *   <li>{@code F12} — screenshot: the visual page as
+ *       {@code <program> - Screenshot <n>.png} next to the .bas file.</li>
+ *   <li>{@code Shift+F12} — toggles video recording: encoded to
+ *       {@code <program> - Screencast <n>.mp4} next to the .bas file when
+ *       the recording stops (the intermediate .crtrec and encode log are
+ *       deleted on success, kept on failure). Ongoing recording is shown
+ *       in the window title.</li>
+ * </ul>
+ *
+ * <p>Closing the window never abandons work: a recording in progress is
+ * finalized, all pending MP4 transcodes run to completion behind a small
+ * progress window, and only then does the JVM exit.</p>
+ *
+ * <p>The window is otherwise deliberately dumb: it owns no emulation state,
+ * it just renders and forwards keys. Headless drivers use the same
+ * VgaDevice and KeyboardQueue without this class.</p>
  */
 public final class SwingFrontend {
 
@@ -29,10 +57,35 @@ public final class SwingFrontend {
 
     private final VgaDevice vga;
     private final JFrame frame;
+    private final String baseTitle;
+    private final Path programDirectory;
+    private final String programBaseName;
+
+    private ScreenRecorder recorder;
+    private Timer titleTimer;
+    private Thread shutdownHook;
+
+    /** Pending MP4 transcodes, drained by a single background worker. */
+    private final List<Path> encodeQueue = new ArrayList<>();
+    private int encodeTotal;
+    private int encodeDone;
+    private Thread encodeWorker;
+    /** Window close requested: exit as soon as the encode queue drains. */
+    private boolean closing;
+    private JFrame progressFrame;
+    private JLabel progressLabel;
 
     @SuppressWarnings("serial")
-    public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, final String title) {
+    public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys,
+                         final String title, final Path programPath) {
         vga = vgaDevice;
+        baseTitle = title;
+        final Path absolute = programPath.toAbsolutePath();
+        programDirectory = absolute.getParent() == null
+                ? Path.of("").toAbsolutePath() : absolute.getParent();
+        final String fileName = programPath.getFileName().toString();
+        programBaseName = fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".bas")
+                ? fileName.substring(0, fileName.length() - 4) : fileName;
         final int scale = vga.mode().pixelWidth() <= 320 ? 3 : 2;
 
         final JPanel canvas = new JPanel() {
@@ -51,13 +104,28 @@ public final class SwingFrontend {
         canvas.addKeyListener(new KeyAdapter() {
             @Override
             public void keyPressed(final KeyEvent e) {
+                if (e.getKeyCode() == KeyEvent.VK_F12) {
+                    // Hotkey: swallowed here, never pushed into the queue.
+                    if (e.isShiftDown()) {
+                        toggleRecording();
+                    } else {
+                        screenshot();
+                    }
+                    return;
+                }
                 keys.push(e.getKeyCode(),
                         e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar());
             }
         });
 
         frame = new JFrame(title);
-        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+        frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+        frame.addWindowListener(new WindowAdapter() {
+            @Override
+            public void windowClosing(final WindowEvent e) {
+                closeRequested();
+            }
+        });
         frame.setContentPane(canvas);
         frame.pack();
         frame.setLocationRelativeTo(null);
@@ -74,4 +142,250 @@ public final class SwingFrontend {
     public void modeChanged() {
         frame.pack();
     }
+
+    // ---------------------------------------------------------- screenshot
+
+    private void screenshot() {
+        final Path output = nextScreenshotName();
+        try {
+            vga.dumpPng(output);
+            System.err.println("Screenshot: " + output);
+        } catch (final IOException e) {
+            System.err.println("Screenshot failed: " + e.getMessage());
+        }
+    }
+
+    // ---------------------------------------------------------- recording
+
+    private void toggleRecording() {
+        if (recorder == null) {
+            startRecording();
+        } else {
+            stopRecording("Recording stopped");
+        }
+    }
+
+    private void startRecording() {
+        final Path output = nextScreencastName(".crtrec");
+        try {
+            recorder = ScreenRecorder.start(vga, output);
+        } catch (final IOException e) {
+            System.err.println("Recording failed to start: " + e.getMessage());
+            recorder = null;
+            return;
+        }
+        // Last-resort safety net for JVM termination paths that bypass the
+        // window close handler (kill signal, System.exit from elsewhere).
+        shutdownHook = new Thread(() -> {
+            final Path crtrec = recorder.stop();
+            System.err.println("Recording stopped (JVM exiting): " + crtrec);
+            encodeOne(crtrec);
+        }, "crtrec-shutdown");
+        Runtime.getRuntime().addShutdownHook(shutdownHook);
+        recorder.setOnAutoStop(() -> SwingUtilities.invokeLater(
+                () -> stopRecording("Recording ended (video mode changed)")));
+        System.err.println("Recording: " + output);
+        titleTimer = new Timer(500, e -> {
+            if (recorder != null) {
+                frame.setTitle(String.format("%s — REC %d:%02d", baseTitle,
+                        recorder.elapsedSeconds() / 60, recorder.elapsedSeconds() % 60));
+            }
+        });
+        titleTimer.start();
+    }
+
+    private void stopRecording(final String reason) {
+        if (recorder == null) {
+            return;
+        }
+        if (titleTimer != null) {
+            titleTimer.stop();
+            titleTimer = null;
+        }
+        if (shutdownHook != null) {
+            Runtime.getRuntime().removeShutdownHook(shutdownHook);
+            shutdownHook = null;
+        }
+        final Path crtrec = recorder.stop();
+        recorder = null;
+        System.err.println(reason + ": " + crtrec);
+        if (!closing) {
+            frame.setTitle(baseTitle);
+        }
+        enqueueEncode(crtrec);
+    }
+
+    // ------------------------------------------------------------- closing
+
+    /**
+     * Window close: finalize any recording in progress, then let pending
+     * transcodes finish behind a progress window before exiting.
+     */
+    private void closeRequested() {
+        closing = true;
+        if (recorder != null) {
+            stopRecording("Recording stopped (window closed)");
+        }
+        frame.setVisible(false);
+        synchronized (encodeQueue) {
+            if (encodeQueue.isEmpty()
+                    && (encodeWorker == null || !encodeWorker.isAlive())) {
+                System.exit(0);
+            }
+        }
+        showProgressWindow();
+        updateProgressUi();
+    }
+
+    private void showProgressWindow() {
+        progressLabel = new JLabel();
+        progressLabel.setBorder(BorderFactory.createEmptyBorder(16, 24, 16, 24));
+        progressFrame = new JFrame("CRT Basic — encoding screencasts");
+        progressFrame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+        progressFrame.setContentPane(progressLabel);
+        progressFrame.pack();
+        progressFrame.setLocationRelativeTo(null);
+        progressFrame.setVisible(true);
+    }
+
+    // ------------------------------------------------------------ encoding
+
+    private void enqueueEncode(final Path crtrec) {
+        if (!CrtrecEncoder.ffmpegAvailable()) {
+            System.err.println("ffmpeg not found; re-encode later with: crtbasic --encode=" + crtrec);
+            log(encodeLogFor(crtrec), "ffmpeg not available at encode time"
+                    + " (PATH=" + System.getenv("PATH") + ")");
+            synchronized (encodeQueue) {
+                encodeQueue.clear(); // nothing can run; if closing, exit now
+            }
+            if (closing) {
+                System.exit(0);
+            }
+            return;
+        }
+        synchronized (encodeQueue) {
+            encodeQueue.add(crtrec);
+            encodeTotal++;
+            if (encodeWorker == null || !encodeWorker.isAlive()) {
+                encodeWorker = new Thread(this::encodeLoop, "crtrec-encoder");
+                encodeWorker.start();
+            }
+        }
+        updateProgressUi();
+    }
+
+    /** Drains the encode queue one file at a time; exits the JVM at the end when closing. */
+    private void encodeLoop() {
+        while (true) {
+            final Path crtrec;
+            synchronized (encodeQueue) {
+                if (encodeQueue.isEmpty()) {
+                    break;
+                }
+                crtrec = encodeQueue.remove(0);
+            }
+            updateProgressUi();
+            encodeOne(crtrec);
+            synchronized (encodeQueue) {
+                encodeDone++;
+            }
+            updateProgressUi();
+        }
+        SwingUtilities.invokeLater(() -> {
+            if (!closing) {
+                frame.setTitle(baseTitle);
+            }
+        });
+        if (closing) {
+            System.exit(0);
+        }
+    }
+
+    private void encodeOne(final Path crtrec) {
+        final Path mp4 = CrtrecEncoder.defaultMp4For(crtrec);
+        final Path log = encodeLogFor(crtrec);
+        log(log, "encode started: " + crtrec.getFileName() + " -> " + mp4.getFileName()
+                + " (PATH=" + System.getenv("PATH") + ")");
+        try {
+            CrtrecEncoder.encodeToMp4(crtrec, mp4, log);
+            System.err.println("MP4: " + mp4);
+            // Intermediates are only worth keeping when something failed.
+            Files.deleteIfExists(crtrec);
+            Files.deleteIfExists(log);
+        } catch (final IOException e) {
+            System.err.println("MP4 encode failed: " + e.getMessage());
+            log(log, "encode FAILED: " + e);
+        }
+    }
+
+    /** Diagnostics next to the recording: {@code <name>.encode.log}. */
+    private static Path encodeLogFor(final Path crtrec) {
+        final String name = crtrec.getFileName().toString();
+        final String base = name.endsWith(".crtrec")
+                ? name.substring(0, name.length() - ".crtrec".length()) : name;
+        return crtrec.resolveSibling(base + ".encode.log");
+    }
+
+    private static void log(final Path logFile, final String line) {
+        try {
+            Files.writeString(logFile, java.time.LocalDateTime.now() + " " + line + "\n",
+                    java.nio.charset.StandardCharsets.UTF_8,
+                    java.nio.file.StandardOpenOption.CREATE,
+                    java.nio.file.StandardOpenOption.APPEND);
+        } catch (final IOException e) {
+            // Diagnostics must never break encoding.
+        }
+    }
+
+    /** Window title during normal operation, progress label during close. */
+    private void updateProgressUi() {
+        final String text;
+        synchronized (encodeQueue) {
+            text = String.format("Encoding screencast %d of %d (%s) — please wait",
+                    Math.min(encodeDone + 1, encodeTotal), encodeTotal,
+                    encodeQueue.isEmpty() ? "finishing" : encodeQueue.get(0).getFileName());
+        }
+        SwingUtilities.invokeLater(() -> {
+            if (progressFrame != null) {
+                progressLabel.setText(text);
+                progressFrame.pack();
+            } else if (recorder == null) {
+                frame.setTitle(baseTitle + " — encoding…");
+            }
+        });
+    }
+
+    // ------------------------------------------------------------- helpers
+
+    /**
+     * {@code <program> - Screenshot <n>.png} next to the .bas file, starting
+     * from 0; picks the first number that does not exist yet.
+     */
+    private Path nextScreenshotName() {
+        for (int n = 0; ; n++) {
+            final Path candidate = programDirectory.resolve(
+                    programBaseName + " - Screenshot " + n + ".png");
+            if (!Files.exists(candidate)) {
+                return candidate;
+            }
+        }
+    }
+
+    /**
+     * {@code <program> - Screencast <n>.crtrec} next to the .bas file,
+     * starting from 0; picks the first number for which neither the .crtrec
+     * nor the .mp4 exists yet.
+     */
+    private Path nextScreencastName(final String extension) {
+        for (int n = 0; ; n++) {
+            final Path candidate = programDirectory.resolve(
+                    programBaseName + " - Screencast " + n + extension);
+            final Path sibling = programDirectory.resolve(
+                    programBaseName + " - Screencast " + n
+                            + (extension.equals(".crtrec") ? ".mp4" : ".crtrec"));
+            if (!Files.exists(candidate) && !Files.exists(sibling)) {
+                return candidate;
+            }
+        }
+    }
 }
index a5ef4d8..46271da 100644 (file)
@@ -118,6 +118,15 @@ public final class Interpreter {
         }
     }
 
+    /**
+     * A GOSUB return address: the statement list the GOSUB sits in and the
+     * pc of the statement after it. RETURN only resolves when it executes in
+     * that same block — a GOSUB whose label lives in an outer block unwinds
+     * the originating block, making its continuation unreachable.
+     */
+    private record GosubFrame(List<Ast.Stmt> block, int pc) {
+    }
+
     /**
      * Signals a GOTO jump. Each enclosing block that does not contain the
      * target label rethrows it, which gives procedure-wide label scope.
@@ -138,6 +147,8 @@ public final class Interpreter {
     private final SoundQueue sound;
     /** Scope stack: bottom is the global scope, top is the current SUB. */
     private final Deque<Map<String, Cell>> scopes = new ArrayDeque<>();
+    /** GOSUB return addresses: the block and pc to resume on RETURN. */
+    private final Deque<GosubFrame> gosubStack = new ArrayDeque<>();
     private final Map<String, Ast.SubStmt> subs = new HashMap<>();
     /** Functions, keyed by base name (uppercase, no type suffix). */
     private final Map<String, Ast.FunctionStmt> functions = new HashMap<>();
@@ -154,6 +165,8 @@ public final class Interpreter {
     private final Map<String, String> declaredNames = new HashMap<>();
     /** Canonical names declared DIM SHARED: visible from inside SUBs. */
     private final java.util.Set<String> sharedNames = new java.util.HashSet<>();
+    /** Canonical names fixed by CONST: reassignment is a Duplicate definition. */
+    private final java.util.Set<String> constants = new java.util.HashSet<>();
     /**
      * Type suffix per lowercase base name fixed by {@code DIM name AS type}:
      * suffix-less references to that variable resolve to the DIM'd type.
@@ -172,8 +185,8 @@ public final class Interpreter {
      */
     private final Map<Integer, java.io.BufferedReader> fileChannels = new HashMap<>();
     private final Map<Integer, String> fileLookahead = new HashMap<>();
-    /** Pending data items for INPUT #channel: comma-split but unconsumed. */
-    private final Map<Integer, ArrayDeque<String>> fileItems = new HashMap<>();
+    /** Unconsumed tail of the current INPUT # line, per channel. */
+    private final Map<Integer, String> fileRemainder = new HashMap<>();
     /** Directory that relative OPEN paths resolve against (the .bas directory). */
     private java.nio.file.Path baseDirectory = java.nio.file.Path.of("");
     /**
@@ -333,6 +346,26 @@ public final class Interpreter {
                 if (stmt instanceof Ast.GotoStmt g) {
                     throw new GotoJump(g.label(), g.line());
                 }
+                if (stmt instanceof Ast.GosubStmt g) {
+                    // Return address: the statement after this GOSUB, in
+                    // this block. The label jump itself is a GOTO.
+                    gosubStack.addLast(new GosubFrame(stmts, pc));
+                    throw new GotoJump(g.label(), g.line());
+                }
+                if (stmt instanceof Ast.ReturnStmt r) {
+                    if (++executed > maxStatements) {
+                        throw new StepsLimitException(executed);
+                    }
+                    final GosubFrame frame = gosubStack.pollLast();
+                    if (frame == null) {
+                        throw error("RETURN without GOSUB", r.line());
+                    }
+                    if (frame.block() != stmts) {
+                        throw error("Cross-block GOSUB/RETURN is not supported", r.line());
+                    }
+                    pc = frame.pc();
+                    continue;
+                }
                 exec(stmt);
             } catch (final GotoJump jump) {
                 if (++executed > maxStatements) {
@@ -480,6 +513,18 @@ public final class Interpreter {
                         : toInt(eval(s.background()), s.line());
                 vga.setTextColor(fg, bg);
             }
+            case Ast.WidthStmt s -> {
+                final int columns = s.columns() == null ? vga.columns()
+                        : toInt(eval(s.columns()), s.line());
+                final int rows = s.rows() == null ? 25
+                        : toInt(eval(s.rows()), s.line());
+                try {
+                    vga.setTextGrid(columns, rows);
+                } catch (final IllegalArgumentException e) {
+                    throw error("Illegal function call: " + e.getMessage(), s.line());
+                }
+            }
+            case Ast.SwapStmt s -> execSwap(s);
             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()));
@@ -604,12 +649,32 @@ public final class Interpreter {
                 // Parsed but intentionally not modeled.
             }
             case Ast.GotoStmt s -> throw new GotoJump(s.label(), s.line());
+            case Ast.GosubStmt s ->
+                // Reached only through paths that bypass execBlock's pc loop.
+                    throw error("GOSUB is only supported as a standalone statement", s.line());
+            case Ast.ReturnStmt s -> throw error("RETURN without GOSUB", s.line());
             case Ast.SubStmt s -> {
                 // SUB definitions are registered by run(), never executed inline.
             }
             case Ast.CallStmt s -> execCall(s);
+            case Ast.ConstStmt s -> {
+                for (final Ast.ConstEntry entry : s.entries()) {
+                    final String key = canonical(entry.name());
+                    final Map<String, Cell> global = scopes.getLast();
+                    if (global.containsKey(key)) {
+                        throw error("Duplicate definition", s.line());
+                    }
+                    constants.add(key);
+                    final Cell cell = new Cell(kindOf(key), null);
+                    cell.set(eval(entry.value()));
+                    global.put(key, cell);
+                }
+            }
             case Ast.AssignStmt s -> {
                 if (s.indices().isEmpty()) {
+                    if (constants.contains(canonical(s.variable()))) {
+                        throw error("Duplicate definition", s.line());
+                    }
                     cellFor(s.variable()).set(eval(s.value()));
                 } else {
                     final BasicArray array = arrayFor(s.variable(), s.indices().size(), s.line());
@@ -625,9 +690,16 @@ public final class Interpreter {
                 // DATA values were collected by run(); executing is a no-op.
             }
             case Ast.ViewPrintStmt s -> {
-                // Text scroll viewport. Approximation: no framebuffer effect —
-                // CLS clears the whole screen and PRINT never scrolls past the
-                // bottom row, so the region has no observable effect yet.
+                // Text scroll viewport: printing and scrolling are confined
+                // to the band; the cursor homes to the band's top-left.
+                final int top = s.top() == null ? 1 : toInt(eval(s.top()), s.line());
+                final int bottom = s.bottom() == null ? vga.rows()
+                        : toInt(eval(s.bottom()), s.line());
+                try {
+                    vga.viewPrint(top, bottom);
+                } catch (final IllegalArgumentException e) {
+                    throw error("Illegal function call: " + e.getMessage(), s.line());
+                }
             }
             case Ast.ReadStmt s -> {
                 for (final Ast.ReadTarget target : s.targets()) {
@@ -696,7 +768,7 @@ public final class Interpreter {
     /** ',' in PRINT: advance to the next 14-column print zone. */
     private void printZoneAdvance() {
         final int col = vga.cursorColumn();
-        final int columns = vga.mode().columns();
+        final int columns = vga.columns();
         final int target = ((col - 1) / 14 + 1) * 14 + 1;
         if (target > columns) {
             vga.newLine();
@@ -938,6 +1010,38 @@ public final class Interpreter {
         array.data[offset] = value instanceof Double d ? array.narrow(d) : value;
     }
 
+    /** Reads the current value of a scalar or array-element target. */
+    private Object getTarget(final Ast.ReadTarget target, final int line) {
+        if (target.indices().isEmpty()) {
+            final Object value = cellFor(target.name()).value;
+            if (value != null) {
+                return value;
+            }
+            return target.name().endsWith("$") ? "" : 0.0;
+        }
+        final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
+        final int offset = array.offset(subscripts(target.indices(), line));
+        if (offset < 0) {
+            throw error("Subscript out of range", line);
+        }
+        return array.data[offset];
+    }
+
+    /**
+     * {@code SWAP a, b}: exchanges two variables or array elements in
+     * place. Mixing a string with a number raises Type mismatch; each value
+     * is narrowed to its new home's type on store, like an assignment.
+     */
+    private void execSwap(final Ast.SwapStmt s) {
+        final Object first = getTarget(s.first(), s.line());
+        final Object second = getTarget(s.second(), s.line());
+        if (first instanceof String != second instanceof String) {
+            throw error("Type mismatch", s.line());
+        }
+        setTarget(s.first(), second, s.line());
+        setTarget(s.second(), first, s.line());
+    }
+
     // ------------------------------------------------------------ expressions
 
     private Object eval(final Ast.Expr expr) {
@@ -987,6 +1091,14 @@ public final class Interpreter {
         if (cell == null && scopes.size() > 1 && sharedNames.contains(key)) {
             cell = scopes.getLast().get(key);
         }
+        if (cell == null) {
+            // A bare reference to a parameterless user FUNCTION calls it
+            // (the dialect allows dropping the empty parentheses).
+            final Ast.FunctionStmt function = functions.get(baseName(v.name()));
+            if (function != null && function.params().isEmpty()) {
+                return invokeFunction(function, List.of(), v.line());
+            }
+        }
         if (cell != null && cell.value != null) {
             return cell.value;
         }
@@ -1530,26 +1642,73 @@ public final class Interpreter {
     }
 
     /**
-     * Reads the next comma-delimited data item for {@code INPUT #channel}
-     * and converts it for the target variable: numbers parse like VAL
-     * (empty/unparseable = 0), strings keep their raw text (surrounding
-     * quotes stripped). Items are buffered per channel so several INPUT #
-     * statements can share one line and one INPUT # can span lines.
+     * Reads the next data item for {@code INPUT #channel} and converts it
+     * for the target variable. Classic delimiters: numbers end at a comma,
+     * whitespace or end of line; strings end at a comma or end of line
+     * (inner spaces kept, surrounding quotes stripped). An empty slot
+     * between commas reads as 0 / "". A statement may span lines and
+     * several statements may share one line.
      */
     private Object readFileItem(final int channel, final String variable, final int line) {
-        final ArrayDeque<String> items = fileItems.computeIfAbsent(
-                channel, k -> new ArrayDeque<>());
-        if (items.isEmpty()) {
-            final String lineText = readFileLine(channel, line);
-            if (lineText == null) {
-                throw error("Input past end of file", line);
+        final boolean stringTarget = canonical(variable).endsWith("$");
+        String rest = fileRemainder.get(channel);
+        while (true) {
+            if (rest == null) {
+                rest = readFileLine(channel, line);
+                if (rest == null) {
+                    throw error("Input past end of file", line);
+                }
+                continue;
+            }
+            rest = rest.stripLeading();
+            if (rest.isEmpty()) {
+                // Line exhausted: the item comes from the next line.
+                rest = null;
+                fileRemainder.remove(channel);
+                continue;
+            }
+            if (rest.charAt(0) == ',') {
+                // Empty item between two commas.
+                fileRemainder.put(channel, rest.substring(1));
+                return stringTarget ? "" : 0.0;
+            }
+            break;
+        }
+        if (stringTarget && rest.startsWith("\"")) {
+            final int close = rest.indexOf('"', 1);
+            if (close < 0) {
+                // Unterminated quote: take the rest of the line.
+                fileRemainder.remove(channel);
+                return rest.substring(1);
+            }
+            String tail = rest.substring(close + 1).stripLeading();
+            if (tail.startsWith(",")) {
+                tail = tail.substring(1);
+            }
+            fileRemainder.put(channel, tail);
+            return rest.substring(1, close);
+        }
+        final int end;
+        if (stringTarget) {
+            final int comma = rest.indexOf(',');
+            end = comma < 0 ? rest.length() : comma;
+        } else {
+            int i = 0;
+            while (i < rest.length() && rest.charAt(i) != ','
+                    && !Character.isWhitespace(rest.charAt(i))) {
+                i++;
             }
-            items.addAll(List.of(lineText.split(",", -1)));
+            end = i;
+        }
+        final String item = stringTarget
+                ? rest.substring(0, end).stripTrailing() : rest.substring(0, end);
+        String tail = rest.substring(end).stripLeading();
+        if (tail.startsWith(",")) {
+            tail = tail.substring(1);
         }
-        final String item = items.poll().trim();
-        if (canonical(variable).endsWith("$")) {
-            return item.length() >= 2 && item.startsWith("\"") && item.endsWith("\"")
-                    ? item.substring(1, item.length() - 1) : item;
+        fileRemainder.put(channel, tail);
+        if (stringTarget) {
+            return item;
         }
         try {
             return Double.parseDouble(item);
@@ -1619,7 +1778,7 @@ public final class Interpreter {
     private void closeChannel(final int channel, final int line) {
         final java.io.BufferedReader reader = fileChannels.remove(channel);
         fileLookahead.remove(channel);
-        fileItems.remove(channel);
+        fileRemainder.remove(channel);
         if (reader == null) {
             return; // CLOSE on an unopened channel is ignored
         }
index 0714a18..e510d37 100644 (file)
@@ -73,6 +73,8 @@ public final class Parser {
                     yield new Ast.ClsStmt(token.line());
                 }
                 case "COLOR" -> colorStatement();
+                case "WIDTH" -> widthStatement();
+                case "SWAP" -> swapStatement();
                 case "PSET" -> psetStatement();
                 case "LINE" -> lineStatement();
                 case "CIRCLE" -> circleStatement();
@@ -98,6 +100,9 @@ public final class Parser {
                 case "WHILE" -> whileStatement();
                 case "DO" -> doStatement();
                 case "GOTO" -> gotoStatement();
+                case "GOSUB" -> gosubStatement();
+                case "RETURN" -> returnStatement();
+                case "CONST" -> constStatement();
                 case "SUB" -> subStatement();
                 case "FUNCTION" -> functionStatement();
                 case "SELECT" -> selectStatement();
@@ -114,7 +119,7 @@ public final class Parser {
                     next();
                     yield new Ast.EndStmt(token.line());
                 }
-                case "NEXT", "WEND", "ELSE", "THEN", "TO", "STEP", "ENDIF", "LOOP" ->
+                case "NEXT", "WEND", "ELSE", "ELSEIF", "THEN", "TO", "STEP", "ENDIF", "LOOP" ->
                         throw error("Unexpected " + keyword, token);
                 default -> assignmentOrCallStatement();
             };
@@ -124,12 +129,43 @@ public final class Parser {
 
     private Ast.Stmt gotoStatement() {
         final Token keyword = next(); // GOTO
+        return new Ast.GotoStmt(jumpLabel(keyword), keyword.line());
+    }
+
+    private Ast.Stmt gosubStatement() {
+        final Token keyword = next(); // GOSUB
+        return new Ast.GosubStmt(jumpLabel(keyword), keyword.line());
+    }
+
+    private Ast.Stmt returnStatement() {
+        final Token keyword = next(); // RETURN
+        // Only the plain form: RETURN <label> (ON n GOSUB) is unsupported.
+        return new Ast.ReturnStmt(keyword.line());
+    }
+
+    /** {@code CONST name = expr [, name = expr …]} */
+    private Ast.Stmt constStatement() {
+        final Token keyword = next(); // CONST
+        final List<Ast.ConstEntry> entries = new ArrayList<>();
+        while (true) {
+            final Token name = expect(Token.Type.IDENT);
+            expectOp("=");
+            entries.add(new Ast.ConstEntry(name.text(), expression()));
+            if (!peek().isText(",")) {
+                break;
+            }
+            next(); // ,
+        }
+        return new Ast.ConstStmt(entries, keyword.line());
+    }
+
+    private String jumpLabel(final Token keyword) {
         final Token label = peek();
         if (!label.is(Token.Type.NUMBER) && !label.is(Token.Type.IDENT)) {
-            throw error("Expected label after GOTO", label);
+            throw error("Expected label after " + keyword.text().toUpperCase(), label);
         }
         next();
-        return new Ast.GotoStmt(label.text(), keyword.line());
+        return label.text();
     }
 
     /** {@code SUB name [(param [AS type], ...)] ... END SUB} */
@@ -510,6 +546,30 @@ public final class Parser {
         return new Ast.ReadTarget(name.text(), indices);
     }
 
+    /** {@code WIDTH [columns][, rows]} — text grid size (SCREEN 0). */
+    private Ast.Stmt widthStatement() {
+        final Token keyword = next(); // WIDTH
+        Ast.Expr columns = null;
+        Ast.Expr rows = null;
+        if (!peek().isText(",")) {
+            columns = expression();
+        }
+        if (peek().isText(",")) {
+            next();
+            rows = expression();
+        }
+        return new Ast.WidthStmt(columns, rows, keyword.line());
+    }
+
+    /** {@code SWAP a, b} — each side may be a scalar variable or an array element. */
+    private Ast.Stmt swapStatement() {
+        final Token keyword = next(); // SWAP
+        final Ast.ReadTarget first = readTarget();
+        expectOp(",");
+        final Ast.ReadTarget second = readTarget();
+        return new Ast.SwapStmt(first, second, keyword.line());
+    }
+
     /** {@code VIEW PRINT [top TO bottom]} (graphics VIEW is unsupported). */
     private Ast.Stmt viewStatement() {
         final Token keyword = next(); // VIEW
@@ -922,10 +982,14 @@ public final class Parser {
         final Ast.Expr condition = expression();
         expectText("THEN");
         if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
-            // Block form: IF cond THEN <newline> stmts [ELSE stmts] END IF
+            // Block form: IF cond THEN <newline> stmts [ELSEIF ...] [ELSE stmts] END IF
             final List<Ast.Stmt> thenBody = ifBranchBody();
             List<Ast.Stmt> elseBody = List.of();
-            if (isAny("ELSE")) {
+            if (isAny("ELSEIF")) {
+                // ELSEIF chains parse as nested block IFs; the outermost
+                // END IF closes the whole chain.
+                elseBody = List.of(elseIfStatement());
+            } else if (isAny("ELSE")) {
                 next();
                 elseBody = ifBranchBody();
             }
@@ -952,7 +1016,27 @@ public final class Parser {
     }
 
     /**
-     * Parses a block-IF branch body: stops at ELSE, ENDIF, or END followed
+     * {@code ELSEIF cond THEN} inside a block IF: parsed as a nested block
+     * IF in the outer statement's else branch. Does not consume the closing
+     * END IF — the outermost IF's endIfTail does.
+     */
+    private Ast.Stmt elseIfStatement() {
+        final Token keyword = next(); // ELSEIF
+        final Ast.Expr condition = expression();
+        expectText("THEN");
+        final List<Ast.Stmt> thenBody = ifBranchBody();
+        List<Ast.Stmt> elseBody = List.of();
+        if (isAny("ELSEIF")) {
+            elseBody = List.of(elseIfStatement());
+        } else if (isAny("ELSE")) {
+            next();
+            elseBody = ifBranchBody();
+        }
+        return new Ast.IfStmt(condition, thenBody, elseBody, keyword.line());
+    }
+
+    /**
+     * Parses a block-IF branch body: stops at ELSE, ELSEIF, ENDIF, or END followed
      * by IF — a bare END inside a branch is the program-end statement, not
      * the end of the IF block (like CASE branch bodies vs END SELECT).
      */
@@ -960,7 +1044,7 @@ public final class Parser {
         final List<Ast.Stmt> body = new ArrayList<>();
         skipSeparators();
         while (!peek().is(Token.Type.EOF)) {
-            if (isAny("ELSE") || isAny("ENDIF")) {
+            if (isAny("ELSE") || isAny("ELSEIF") || isAny("ENDIF")) {
                 break;
             }
             if (isAny("END") && pos + 1 < tokens.size()
index ad415e5..d0ee6c0 100644 (file)
@@ -25,7 +25,6 @@ public final class TextConsole {
 
     private final Framebuffer framebuffer;
     private final ScreenMode mode;
-    private final VgaFont font;
     private final int page;
 
     private int cursorRow = 1;
@@ -34,6 +33,16 @@ public final class TextConsole {
     private int background = 0;
     private int scrollTop = 1;
     private int scrollBottom;
+    /** Text grid width in columns (WIDTH-adjusted; default from the mode). */
+    private int columns;
+    /** Text grid height in rows (WIDTH-adjusted; default from the mode). */
+    private int rows;
+    /** Glyph cell width in pixels: 8 normally, 16 in WIDTH 40 (double-wide). */
+    private int cellWidth = ScreenMode.CELL_WIDTH;
+    /** Glyph cell height in pixels: from the mode, 8 in WIDTH 80,50. */
+    private int cellHeight;
+    /** Glyph bitmaps for the current cell height (WIDTH 80,50 swaps fonts). */
+    private VgaFont font;
 
     /**
      * True after a character was printed in the rightmost column: the cursor
@@ -46,10 +55,13 @@ public final class TextConsole {
     TextConsole(final Framebuffer framebuffer, final ScreenMode mode, final int page) {
         this.framebuffer = framebuffer;
         this.mode = mode;
-        this.font = VgaFont.forCellHeight(mode.cellHeight());
         this.page = page;
         this.foreground = mode.defaultForeground();
-        scrollBottom = mode.rows();
+        rows = mode.rows();
+        cellHeight = mode.cellHeight();
+        font = VgaFont.forCellHeight(cellHeight);
+        scrollBottom = rows;
+        columns = mode.columns();
     }
 
     /**
@@ -73,7 +85,7 @@ public final class TextConsole {
                 advanceLine();
             }
             drawGlyph(eu.svjatoslav.crtbasic.Cp437.byteOf(c));
-            if (cursorCol == mode.columns()) {
+            if (cursorCol == columns) {
                 pendingWrap = true;
             } else {
                 cursorCol++;
@@ -92,10 +104,10 @@ public final class TextConsole {
      * {@code LOCATE row, col}: moves the text cursor. Coordinates are 1-based.
      */
     public void locate(final int row, final int col) {
-        if (row < 1 || row > mode.rows() || col < 1 || col > mode.columns()) {
+        if (row < 1 || row > rows || col < 1 || col > columns) {
             throw new IllegalArgumentException(
                     "LOCATE out of range: " + row + "," + col
-                            + " (screen is " + mode.rows() + "x" + mode.columns() + ")");
+                            + " (screen is " + rows + "x" + columns + ")");
         }
         cursorRow = row;
         cursorCol = col;
@@ -114,7 +126,7 @@ public final class TextConsole {
      * of the band.
      */
     public void viewPrint(final int topRow, final int bottomRow) {
-        if (topRow < 1 || bottomRow > mode.rows() || topRow > bottomRow) {
+        if (topRow < 1 || bottomRow > rows || topRow > bottomRow) {
             throw new IllegalArgumentException(
                     "VIEW PRINT out of range: " + topRow + " TO " + bottomRow);
         }
@@ -143,6 +155,40 @@ public final class TextConsole {
         return cursorCol;
     }
 
+    /** @return current text grid width in columns (WIDTH-adjusted) */
+    public int columns() {
+        return columns;
+    }
+
+    /** @return text grid height in rows (WIDTH-adjusted) */
+    public int rows() {
+        return rows;
+    }
+
+    /**
+     * {@code WIDTH columns, rows}: redefines the text grid. Narrower grids
+     * widen the glyph cells to keep covering the full framebuffer (WIDTH 40
+     * renders double-wide characters, like the 40-column text mode of real
+     * hardware); 50 rows swap to the 8x8 font (the 80x50 mode of real
+     * hardware). The caller is expected to clear the screen afterwards.
+     */
+    void setGrid(final int newColumns, final int newRows) {
+        columns = newColumns;
+        cellWidth = framebuffer.width() / newColumns;
+        rows = newRows;
+        cellHeight = framebuffer.height() / newRows;
+        font = VgaFont.forCellHeight(cellHeight);
+        if (cursorCol > columns) {
+            cursorCol = columns;
+        }
+        if (cursorRow > rows) {
+            cursorRow = rows;
+        }
+        scrollTop = 1;
+        scrollBottom = rows;
+        pendingWrap = false;
+    }
+
     public int foreground() {
         return foreground;
     }
@@ -160,7 +206,6 @@ public final class TextConsole {
     }
 
     private void scrollUp() {
-        final int cellHeight = mode.cellHeight();
         final int topY = (scrollTop - 1) * cellHeight;
         final int bandHeight = (scrollBottom - scrollTop + 1) * cellHeight;
         framebuffer.copyBandUp(page, topY, topY + cellHeight, bandHeight - cellHeight);
@@ -169,16 +214,21 @@ public final class TextConsole {
     }
 
     private void drawGlyph(final int glyph) {
-        final int originX = (cursorCol - 1) * ScreenMode.CELL_WIDTH;
-        final int originY = (cursorRow - 1) * mode.cellHeight();
+        final int originX = (cursorCol - 1) * cellWidth;
+        final int originY = (cursorRow - 1) * cellHeight;
+        // Horizontal stretch factor: 1 normally, 2 in WIDTH 40 (each font
+        // pixel covers two framebuffer pixels, giving double-wide glyphs).
+        final int stretch = cellWidth / ScreenMode.CELL_WIDTH;
         // The whole glyph cell is painted with the background color
         // in every mode, graphics modes included. That is why programs erase
         // text by LOCATE-ing and PRINTing spaces over it (and why reprinting
         // at the same spot never smears).
-        for (int row = 0; row < mode.cellHeight(); row++) {
+        for (int row = 0; row < cellHeight; row++) {
             for (int col = 0; col < ScreenMode.CELL_WIDTH; col++) {
-                framebuffer.setPixel(page, originX + col, originY + row,
-                        font.pixelSet(glyph, col, row) ? foreground : background);
+                final int color = font.pixelSet(glyph, col, row) ? foreground : background;
+                for (int dup = 0; dup < stretch; dup++) {
+                    framebuffer.setPixel(page, originX + col * stretch + dup, originY + row, color);
+                }
             }
         }
     }
index de79daa..d3d87ed 100644 (file)
@@ -116,6 +116,40 @@ public final class VgaDevice {
         consoles[activePage].cls();
     }
 
+    /** @return current text grid width in columns (WIDTH-adjusted) */
+    public int columns() {
+        return consoles[activePage].columns();
+    }
+
+    /** @return current text grid height in rows (WIDTH-adjusted) */
+    public int rows() {
+        return consoles[activePage].rows();
+    }
+
+    /**
+     * {@code WIDTH columns, rows}: redefines the SCREEN 0 text grid.
+     * Supported grids are 80x25 (the default), 40x25 (double-wide
+     * characters, like the 40-column text mode of real hardware) and 80x50
+     * (8x8 font, like the 50-line mode of real hardware). Like the original
+     * statement, WIDTH clears the screen.
+     *
+     * @throws IllegalArgumentException for other modes or grid sizes
+     */
+    public void setTextGrid(final int columns, final int rows) {
+        if (mode != ScreenMode.SCREEN_0) {
+            throw new IllegalArgumentException("WIDTH is only supported in SCREEN 0");
+        }
+        if ((columns != 40 && columns != 80) || (rows != 25 && rows != 50)
+                || (columns == 40 && rows == 50)) {
+            throw new IllegalArgumentException(
+                    "WIDTH: supported text grids are 80x25, 40x25 and 80x50");
+        }
+        for (final TextConsole console : consoles) {
+            console.setGrid(columns, rows);
+            console.cls();
+        }
+    }
+
     /** @return {@code CSRLIN}: 1-based text cursor row */
     public int cursorRow() {
         return consoles[activePage].cursorRow();
@@ -391,22 +425,31 @@ public final class VgaDevice {
 
     /**
      * {@code PAINT (x, y), fillColor[, borderColor]}: flood fill starting at
-     * (x, y). Replaces the contiguous region of the start pixel's color with
-     * fillColor, stopping at pixels of borderColor. Painting a pixel that is
-     * already the border color does nothing.
+     * (x, y). 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.
      */
     public void paint(final int x, final int y, final int fillColor, final int borderColor) {
         final int fill = fillColor & 0xFF;
         final int border = borderColor & 0xFF;
-        final int target = framebuffer.getPixel(activePage, x, y);
-        if (target == border || target == fill) {
-            graphicsCursorX = x;
-            graphicsCursorY = y;
-            return;
-        }
+        graphicsCursorX = x;
+        graphicsCursorY = y;
         final int width = framebuffer.width();
         final int height = framebuffer.height();
+        if (x < 0 || y < 0 || x >= width || y >= height
+                || framebuffer.getPixel(activePage, x, y) == border) {
+            // Off-screen seed clips like any other drawing primitive
+            // (programs legitimately PAINT shapes that wander off-screen);
+            // a seed on the border color paints nothing.
+            return;
+        }
+        // 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 java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
+        visited[y * width + x] = 1;
         framebuffer.setPixel(activePage, x, y, fill);
         queue.add(new int[]{x, y});
         while (!queue.isEmpty()) {
@@ -414,17 +457,18 @@ 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] < 0 || n[1] < 0 || n[0] >= width || n[1] >= height
+                        || visited[n[1] * width + n[0]] != 0) {
                     continue;
                 }
-                if (framebuffer.getPixel(activePage, n[0], n[1]) == target) {
-                    framebuffer.setPixel(activePage, n[0], n[1], fill);
-                    queue.add(n);
+                visited[n[1] * width + n[0]] = 1;
+                if (framebuffer.getPixel(activePage, n[0], n[1]) == border) {
+                    continue;
                 }
+                framebuffer.setPixel(activePage, n[0], n[1], fill);
+                queue.add(n);
             }
         }
-        graphicsCursorX = x;
-        graphicsCursorY = y;
     }
 
     /** @return x of the last point referenced by a drawing primitive */
index be58023..c3054e8 100644 (file)
@@ -8,7 +8,8 @@ import java.io.UncheckedIOException;
  * CP437 VGA bitmap font: 256 glyphs, 8 pixels wide, 8/14 or 16 pixels tall.
  *
  * <p>Glyph data is loaded from binary resources that were extracted from the
- * public-domain VGA fonts shipped with SeaBIOS ({@code vgasrc/vgafonts.c}).
+ * public-domain VGA fonts shipped with SeaBIOS ({@code vgasrc/vgafonts.c});
+ * provenance and license note in {@code resources/fonts/README.txt}.
  * One byte per glyph scanline; bit 7 is the leftmost pixel.</p>
  */
 public final class VgaFont {
diff --git a/src/main/resources/fonts/README.txt b/src/main/resources/fonts/README.txt
new file mode 100644 (file)
index 0000000..906ef8c
--- /dev/null
@@ -0,0 +1,23 @@
+CP437 VGA bitmap fonts
+======================
+
+Files: cp437-8x8.bin, cp437-8x14.bin, cp437-8x16.bin
+Format: 256 glyphs each, 8 pixels wide, 8/14/16 bytes per glyph,
+one bit per pixel, MSB = leftmost pixel.
+
+Source and license
+------------------
+
+These bitmaps come from SeaBIOS (src/vgasrc/vgafonts.c), whose header
+comment states:
+
+    These fonts come from ftp://ftp.simtel.net/pub/simtelnet/msdos/screen/fntcol16.zip
+    The package is (c) by Joseph Gil
+    The individual fonts are public domain
+
+The public-domain dedication can be read at the top of:
+
+    https://github.com/coreboot/seabios/blob/master/vgasrc/vgafonts.c
+
+The .bin files here are byte-identical to the vgafont8 / vgafont14 /
+vgafont16 arrays in that file (verified 2026-08-21).
diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/HackerBasTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/HackerBasTest.java
new file mode 100644 (file)
index 0000000..11f8713
--- /dev/null
@@ -0,0 +1,157 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.ScreenMode;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Interpreter semantics added for 2D GFX/Animations/hacker.bas: WIDTH text
+ * grids, SWAP, and bare parameterless FUNCTION calls.
+ */
+class HackerBasTest {
+
+    private static Interpreter run(final String source) {
+        final Interpreter interpreter = new Interpreter(
+                new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+        interpreter.setBlockOnInput(false);
+        interpreter.run(Parser.parse(source));
+        return interpreter;
+    }
+
+    @Test
+    void swapExchangesScalarVariables() {
+        final Interpreter interpreter = run("a = 1 : b = 2 : SWAP a, b");
+        assertEquals(2.0, interpreter.variable("a"));
+        assertEquals(1.0, interpreter.variable("b"));
+    }
+
+    @Test
+    void swapExchangesArrayElements() {
+        final Interpreter interpreter = run("""
+                DIM a(2)
+                a(1) = 10
+                a(2) = 20
+                SWAP a(1), a(2)
+                x = a(1)
+                y = a(2)
+                """);
+        assertEquals(20.0, interpreter.variable("x"));
+        assertEquals(10.0, interpreter.variable("y"));
+    }
+
+    @Test
+    void swapRejectsMixedTypes() {
+        assertThrows(Interpreter.InterpreterException.class,
+                () -> run("a$ = \"x\" : b = 1 : SWAP a$, b"));
+    }
+
+    @Test
+    void swapNarrowsToTheDestinationType() {
+        // DEFINT makes both variables INTEGER: the swapped SINGLE value
+        // rounds half-to-even on store, like an assignment would.
+        final Interpreter interpreter = run("""
+                DEFINT A-Z
+                a = 3
+                b! = 2.5
+                SWAP a, b!
+                """);
+        assertEquals(2.0, interpreter.variable("a"));
+        assertEquals(3.0, interpreter.variable("b!"));
+    }
+
+    @Test
+    void width40SwitchesToFortyColumns() {
+        final Interpreter interpreter = run("WIDTH 40, 25");
+        assertEquals(40, interpreter.video().columns());
+        assertEquals(80, run("WIDTH 40, 25\nWIDTH 80, 25").video().columns());
+    }
+
+    @Test
+    void width40RendersDoubleWideGlyphs() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setTextGrid(40, 25);
+        vga.print("A");
+        // Every font pixel covers two horizontal framebuffer pixels.
+        for (int row = 0; row < 16; row++) {
+            for (int col = 0; col < 8; col++) {
+                assertEquals(vga.point(col * 2, row), vga.point(col * 2 + 1, row),
+                        "pixel pair at glyph col " + col + ", row " + row);
+            }
+        }
+    }
+
+    @Test
+    void widthClearsTheScreen() {
+        final VgaDevice vga = new VgaDevice();
+        vga.pset(100, 100, 15);
+        vga.setTextGrid(40, 25);
+        assertEquals(0, vga.point(100, 100));
+    }
+
+    @Test
+    void widthRejectsUnsupportedGrids() {
+        final VgaDevice vga = new VgaDevice();
+        assertThrows(IllegalArgumentException.class, () -> vga.setTextGrid(40, 50));
+        assertThrows(IllegalArgumentException.class, () -> vga.setTextGrid(20, 25));
+        vga.setMode(ScreenMode.SCREEN_13);
+        assertThrows(IllegalArgumentException.class, () -> vga.setTextGrid(40, 25));
+    }
+
+    @Test
+    void width80x50SwapsToTheShortFont() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setTextGrid(80, 50);
+        assertEquals(50, vga.rows());
+        assertEquals(80, vga.columns());
+        vga.locate(50, 1); // valid on the 50-row grid
+        assertThrows(IllegalArgumentException.class, () -> vga.locate(51, 1));
+        vga.locate(1, 1);
+        vga.print("A");
+        // 8x8 glyphs: rows 0..7 may hold strokes, row 8 (the next cell)
+        // must stay black.
+        for (int x = 0; x < 8; x++) {
+            assertEquals(0, vga.point(x, 8), "row 8 must belong to the next cell");
+        }
+    }
+
+    @Test
+    void locateBeyondFortyColumnsFailsAfterWidth40() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setTextGrid(40, 25);
+        assertThrows(IllegalArgumentException.class, () -> vga.locate(1, 41));
+        vga.locate(1, 40); // still valid
+    }
+
+    @Test
+    void bareParameterlessFunctionCallInvokesTheFunction() {
+        final Interpreter interpreter = run("""
+                DECLARE FUNCTION getChar% ()
+                x = getChar
+                END
+                FUNCTION getChar
+                getChar = 42
+                END FUNCTION
+                """);
+        assertEquals(42.0, interpreter.variable("x"));
+    }
+
+    @Test
+    void existingVariableWinsOverSameNamedFunction() {
+        final Interpreter interpreter = run("""
+                DECLARE FUNCTION f% ()
+                f = 7
+                x = f
+                END
+                FUNCTION f
+                f = 42
+                END FUNCTION
+                """);
+        assertEquals(7.0, interpreter.variable("x"));
+    }
+}
index 9f3b2c3..728fb56 100644 (file)
@@ -1,6 +1,7 @@
 package eu.svjatoslav.crtbasic.interp;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -12,8 +13,8 @@ import java.time.Duration;
 import org.junit.jupiter.api.Test;
 
 /**
- * Interpreter semantics added for 2D GFX/People.bas: SELECT CASE ranges,
- * COMMAND$ and SLEEP.
+ * Feature-level interpreter semantics tests: SELECT CASE ranges, COMMAND$,
+ * SLEEP (2D GFX/People.bas), ELSEIF chains (2D GFX/Animations/matrix.bas).
  */
 class InterpreterFeaturesTest {
 
@@ -98,4 +99,240 @@ class InterpreterFeaturesTest {
         // The interrupting key stays queued for INKEY$.
         assertTrue(!keys.isEmpty());
     }
+
+    @Test
+    void elseIfPicksTheFirstMatchingBranch() {
+        final Interpreter interpreter = run("""
+                FOR i = 1 TO 4
+                IF i = 1 THEN
+                r = r + 10
+                ELSEIF i = 2 THEN
+                r = r + 100
+                ELSEIF i = 3 THEN
+                r = r + 1000
+                ELSE
+                r = r + 10000
+                END IF
+                NEXT i
+                """);
+        assertEquals(10.0 + 100 + 1000 + 10000, interpreter.variable("r"));
+    }
+
+    @Test
+    void elseIfFallsThroughToElse() {
+        final Interpreter interpreter = run("""
+                x = 5
+                IF x = 1 THEN
+                hit = 1
+                ELSEIF x = 2 THEN
+                hit = 2
+                ELSE
+                hit = 3
+                END IF
+                """);
+        assertEquals(3.0, interpreter.variable("hit"));
+    }
+
+    @Test
+    void elseIfWithoutElseBranch() {
+        final Interpreter interpreter = run("""
+                x = 2
+                IF x = 1 THEN
+                hit = 1
+                ELSEIF x = 2 THEN
+                hit = 2
+                END IF
+                """);
+        assertEquals(2.0, interpreter.variable("hit"));
+    }
+
+    @Test
+    void viewPrintConfinesScrollingToTheBand() {
+        final Interpreter interpreter = run("""
+                PRINT "TOP";
+                VIEW PRINT 2 TO 25
+                FOR i = 1 TO 30
+                LOCATE 25, 1
+                PRINT "SCROLL"
+                NEXT i
+                """);
+        // Row 1 still shows "TOP": some pixel in the first text row is lit.
+        assertTrue(topRowLit(interpreter),
+                "row 1 must survive scrolling inside VIEW PRINT 2 TO 25");
+    }
+
+    @Test
+    void bareViewPrintResetsToFullScreen() {
+        final Interpreter interpreter = run("""
+                PRINT "TOP";
+                VIEW PRINT 2 TO 25
+                LOCATE 25, 1
+                PRINT "X"
+                VIEW PRINT
+                FOR i = 1 TO 30
+                LOCATE 25, 1
+                PRINT ""
+                NEXT i
+                """);
+        // After the reset the band covers row 1 again: 30 full-screen
+        // scrolls of blank lines push "TOP" off the top.
+        assertTrue(!topRowLit(interpreter),
+                "bare VIEW PRINT must restore full-screen scrolling");
+    }
+
+    /** @return true when any pixel of the first text row is non-background */
+    private static boolean topRowLit(final Interpreter interpreter) {
+        for (int y = 0; y < 16; y++) {
+            for (int x = 0; x < 24; x++) {
+                if (interpreter.video().point(x, y) != 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    @Test
+    void gosubReturnsToTheStatementAfterTheCall() {
+        final Interpreter interpreter = run("""
+                x = 1
+                GOSUB Bump
+                x = x * 10
+                GOTO Done
+                Bump:
+                x = x + 1
+                RETURN
+                Done:
+                """);
+        assertEquals(20.0, interpreter.variable("x"));
+    }
+
+    @Test
+    void gosubNestsAndTargetsNumericLabels() {
+        final Interpreter interpreter = run("""
+                x = 1
+                GOSUB 100
+                GOTO Done
+                100 x = x * 2
+                GOSUB 200
+                RETURN
+                200 x = x + 3
+                RETURN
+                Done:
+                """);
+        assertEquals(5.0, interpreter.variable("x"));
+    }
+
+    @Test
+    void gosubWorksInsideASubBody() {
+        final Interpreter interpreter = run("""
+                DECLARE SUB Twice ()
+                DIM SHARED x
+                Twice
+                END
+                SUB Twice
+                x = 1
+                GOSUB Bump
+                GOSUB Bump
+                GOTO Quit
+                Bump:
+                x = x * 10
+                RETURN
+                Quit:
+                END SUB
+                """);
+        assertEquals(100.0, interpreter.variable("x"));
+    }
+
+    @Test
+    void returnWithoutGosubIsAnError() {
+        final Interpreter.InterpreterException e = assertThrows(
+                Interpreter.InterpreterException.class, () -> run("RETURN"));
+        assertTrue(e.getMessage().contains("RETURN without GOSUB"));
+    }
+
+    @Test
+    void constDefinesModuleLevelConstants() {
+        final Interpreter interpreter = run("""
+                CONST InitialSize = 100, Double = InitialSize * 2
+                result = Double + InitialSize
+                """);
+        assertEquals(300.0, interpreter.variable("result"));
+    }
+
+    @Test
+    void constRejectsReassignment() {
+        final Interpreter.InterpreterException e = assertThrows(
+                Interpreter.InterpreterException.class,
+                () -> run("CONST A = 1\nA = 2"));
+        assertTrue(e.getMessage().contains("Duplicate definition"));
+    }
+
+    @Test
+    void constRejectsRedefinition() {
+        final Interpreter.InterpreterException e = assertThrows(
+                Interpreter.InterpreterException.class,
+                () -> run("CONST A = 1\nCONST A = 2"));
+        assertTrue(e.getMessage().contains("Duplicate definition"));
+    }
+
+    private static Interpreter runWithFile(final java.nio.file.Path directory, final String source) {
+        final Interpreter interpreter = new Interpreter(
+                new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+        interpreter.setBlockOnInput(false);
+        interpreter.setBaseDirectory(directory);
+        interpreter.run(Parser.parse(source));
+        return interpreter;
+    }
+
+    @Test
+    void inputFileSplitsNumbersOnWhitespace(
+            @org.junit.jupiter.api.io.TempDir final java.nio.file.Path dir) throws java.io.IOException {
+        // COPTER.DAT (3D GFX/Helicopter) separates numbers with spaces.
+        java.nio.file.Files.writeString(dir.resolve("data.dat"),
+                "0 -10 -5\r\n-20 -10 5\r\n");
+        final Interpreter interpreter = runWithFile(dir, """
+                OPEN "data.dat" FOR INPUT AS #1
+                INPUT #1, a, b, c
+                INPUT #1, d, e, f
+                CLOSE #1
+                """);
+        assertEquals(-10.0, interpreter.variable("b"));
+        assertEquals(-20.0, interpreter.variable("d"));
+        assertEquals(5.0, interpreter.variable("f"));
+    }
+
+    @Test
+    void inputFileSharesLinesAndSpansLines(
+            @org.junit.jupiter.api.io.TempDir final java.nio.file.Path dir) throws java.io.IOException {
+        java.nio.file.Files.writeString(dir.resolve("data.dat"), "1, 2\n3, 4\n");
+        final Interpreter interpreter = runWithFile(dir, """
+                OPEN "data.dat" FOR INPUT AS #1
+                INPUT #1, a
+                INPUT #1, b, c
+                INPUT #1, d
+                CLOSE #1
+                """);
+        assertEquals(1.0, interpreter.variable("a"));
+        assertEquals(2.0, interpreter.variable("b"));
+        assertEquals(3.0, interpreter.variable("c"));
+        assertEquals(4.0, interpreter.variable("d"));
+    }
+
+    @Test
+    void inputFileStringsKeepSpacesAndQuotes(
+            @org.junit.jupiter.api.io.TempDir final java.nio.file.Path dir) throws java.io.IOException {
+        java.nio.file.Files.writeString(dir.resolve("data.dat"),
+                "hello world,\"quoted, with comma\",1,, 5\n");
+        final Interpreter interpreter = runWithFile(dir, """
+                OPEN "data.dat" FOR INPUT AS #1
+                INPUT #1, a$, b$, n, e, m
+                CLOSE #1
+                """);
+        assertEquals("hello world", interpreter.variable("a$"));
+        assertEquals("quoted, with comma", interpreter.variable("b$"));
+        assertEquals(1.0, interpreter.variable("n"));
+        assertEquals(0.0, interpreter.variable("e"));
+        assertEquals(5.0, interpreter.variable("m"));
+    }
 }
index 1656564..9f24c45 100644 (file)
@@ -192,4 +192,52 @@ class VgaDeviceTest {
         }
         assertTrue(anyPixel, "8x16 glyph must be drawn");
     }
+
+    @Test
+    void paintFillsUpToBorderAcrossOtherColors() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        // A red bar crossing the area the circle will occupy, like the sun
+        // bleeding into the earth's outline in sun&eart.bas.
+        for (int y = 60; y < 140; y++) {
+            for (int x = 90; x < 110; x++) {
+                vga.pset(x, y, 12);
+            }
+        }
+        vga.circle(100, 100, 30, 1);
+        vga.paint(100, 100, 1, 1);
+        // Interior must be solid: every pixel strictly inside the outline,
+        // including the ones that were red, is now the fill color.
+        for (int y = 80; y <= 120; y++) {
+            for (int x = 80; x <= 120; x++) {
+                final int dx = x - 100;
+                final int dy = y - 100;
+                if (dx * dx + dy * dy < 24 * 24) {
+                    assertEquals(1, vga.point(x, y),
+                            "interior pixel (" + x + "," + y + ") must be filled");
+                }
+            }
+        }
+    }
+
+    @Test
+    void paintOnBorderColorDoesNothing() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.pset(50, 50, 4);
+        vga.paint(50, 50, 2, 4);
+        assertEquals(4, vga.point(50, 50), "seed on the border color: no-op");
+        assertEquals(0, vga.point(51, 50), "neighbors must stay untouched");
+    }
+
+    @Test
+    void paintOffScreenSeedIsClipped() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_12);
+        // Branches wandering off-screen (Tree.bas) must not crash.
+        vga.paint(700, 481, 2, 2);
+        vga.paint(-5, -5, 2, 2);
+        assertEquals(-5, vga.graphicsCursorX(), "graphics cursor still tracks the seed");
+        assertEquals(0, vga.point(0, 0), "nothing painted");
+    }
 }