initial commit
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 20 Aug 2026 20:52:18 +0000 (23:52 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 20 Aug 2026 20:52:18 +0000 (23:52 +0300)
44 files changed:
.gitignore [new file with mode: 0644]
COPYING [new file with mode: 0644]
Documentation/agent-integration/index.org [new file with mode: 0644]
Documentation/architecture/index.org [new file with mode: 0644]
Documentation/crtbasic.png [new file with mode: 0644]
Documentation/index.org [new file with mode: 0644]
Documentation/interpreter/index.org [new file with mode: 0644]
Documentation/interpreter/number-flow.svg [new file with mode: 0644]
Documentation/language/index.org [new file with mode: 0644]
Documentation/style.css [new file with mode: 0644]
Documentation/video/index.org [new file with mode: 0644]
Documentation/video/indexed-pixels.svg [new file with mode: 0644]
Documentation/video/text-cell.svg [new file with mode: 0644]
Documentation/video/video-pages.svg [new file with mode: 0644]
README.org [new file with mode: 0644]
Tools/Open with IntelliJ IDEA [new file with mode: 0755]
Tools/Update web site [new file with mode: 0755]
install.sh [new file with mode: 0755]
pom.xml [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/Cp437.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/Main.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java [new file with mode: 0644]
src/main/resources/fonts/cp437-8x14.bin [new file with mode: 0644]
src/main/resources/fonts/cp437-8x16.bin [new file with mode: 0644]
src/main/resources/fonts/cp437-8x8.bin [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..c302191
--- /dev/null
@@ -0,0 +1,7 @@
+# IntelliJ settings
+/.idea/
+
+# Build directory
+/target/
+
+*.html
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..0e259d4
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/Documentation/agent-integration/index.org b/Documentation/agent-integration/index.org
new file mode 100644 (file)
index 0000000..f8ae407
--- /dev/null
@@ -0,0 +1,146 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Agent Integration - CRT Basic
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.org][Back to main documentation]]
+
+* Built to be driven by an AI agent
+:PROPERTIES:
+:CUSTOM_ID: agent-driven
+:END:
+
+*CRT Basic* exists so that an AI agent can work with BASIC programs
+end to end — write one, run it, *see* what it drew, press its keys,
+and assert on its internal state — with no human in the loop. Every
+piece of the runtime is therefore observable and scriptable:
+
+| Capability | Mechanism |
+|------------+-----------|
+| Run without a display | =--headless= |
+| Bound endless loops | =--steps=N= statement budget |
+| See the screen | =--screenshot=out.png= (visual page as PNG) |
+| Compare pixels exactly | =--raw=out.raw= (raw indexed video memory) |
+| Press keys programmatically | =GameDriver= scripted keystrokes |
+| Read program state | =--vars=a%,score= prints variables after the run |
+| Count work done | "Statements executed" report |
+
+The interactive window and the headless path share the same
+=VgaDevice= and =KeyboardQueue=, so a program cannot tell which one
+it runs under.
+
+* Command line
+:PROPERTIES:
+:CUSTOM_ID: command-line
+:END:
+
+#+BEGIN_SRC text
+crtbasic program.bas [--headless] [--screenshot=out.png] [--raw=out.raw] [--steps=N]
+#+END_SRC
+
+| Option | Effect |
+|--------+--------|
+| (none) | Interactive: Swing window, live screen ~30 fps, keyboard input |
+| =--headless= | No window; input never blocks; dumps afterwards |
+| =--screenshot=FILE= | PNG of the visual page (implies headless) |
+| =--raw=FILE= | Raw indexed pixels of all video pages (implies headless) |
+| =--steps=N= | Stop after N executed statements (implies headless) |
+
+A run reports its statement count to stderr, so "did the loop spin?"
+is answerable without looking at pixels.
+
+* The GameDriver: scripted play-testing
+:PROPERTIES:
+:CUSTOM_ID: game-driver
+:END:
+
+=--headless= cannot inject keys mid-run. For keyboard-driven games
+there is a dedicated driver (in test sources):
+
+#+BEGIN_SRC sh
+java -cp target/classes:target/test-classes \
+    eu.svjatoslav.crtbasic.drivers.GameDriver \
+    worm.bas --keys="RR~~~UU~~~LL" --interval=12 \
+    --steps=8000000 --screenshot=end.png --vars=score%,lives%
+#+END_SRC
+
+The key script delivers one input event per =--interval= milliseconds
+(default 12) while the program runs:
+
+| Script character | Meaning |
+|------------------+---------|
+| =R= =L= =U= =D=  | Arrow keys (right/left/up/down) |
+| =~=              | Pause: no key, just let time pass |
+| anything else    | The typed character itself |
+
+After the script is exhausted the program keeps running until it ends
+or the statement budget (default 8M) is spent. The named =--vars= are
+then printed from the global scope — so an agent can verify "after
+this play sequence, =score%= must be 3" directly, and look at
+=end.png= to verify what the screen showed.
+
+* Debugging aid: the line log
+:PROPERTIES:
+:CUSTOM_ID: line-log
+:END:
+
+Setting the system property =crtbasic.linelog= to a file path makes
+every =LINE= statement append its post-conversion coordinates
+(=x1,y1,x2,y2,color=) to that file — handy for tracing exactly which
+coordinates a program drew, frame by frame.
+
+* Programmatic embedding
+:PROPERTIES:
+:CUSTOM_ID: embedding
+:END:
+
+The runtime is a plain library — no window system required:
+
+#+BEGIN_SRC java
+String source = Files.readString(path, Charset.forName("IBM437"));
+List<Ast.Stmt> program = Parser.parse(source);
+
+VgaDevice vga = new VgaDevice();
+KeyboardQueue keys = new KeyboardQueue();
+Interpreter interpreter = new Interpreter(vga, keys, new SoundQueue());
+interpreter.setMaxStatements(100_000);       // statement budget
+interpreter.setBlockOnInput(false);          // headless: input never blocks
+interpreter.setBaseDirectory(path.getParent());  // OPEN resolves here
+
+keys.pushChar(' ');                          // inject input any time
+interpreter.run(program);
+
+vga.dumpPng(Path.of("out.png"));             // screenshot
+byte[] pixels = vga.framebuffer().pageData(0);   // raw indexed pixels
+Object score = interpreter.variable("score%");   // program state
+long steps = interpreter.statementsExecuted();   // work performed
+#+END_SRC
+
+This is exactly what =Main= and =GameDriver= do; nothing is hidden
+behind the CLI.
+
+* Visual verification showcase
+:PROPERTIES:
+:CUSTOM_ID: showcase
+:END:
+
+=VideoDemoMain= (test sources) renders a set of showcase frames —
+SCREEN 13 color stripes with text being erased by spaces, a
+=VIEW PRINT= scroll region, the SCREEN 12 color table, SCREEN 0
+CP437 box drawing — as upscaled PNGs:
+
+#+BEGIN_SRC sh
+mvn package
+java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain
+# frames written to /tmp/crt-basic-demo/
+#+END_SRC
+
+These exist so that an agent with vision can verify the video
+subsystem by *looking* at its output, not just by reading assertions.
diff --git a/Documentation/architecture/index.org b/Documentation/architecture/index.org
new file mode 100644 (file)
index 0000000..d5d52fb
--- /dev/null
@@ -0,0 +1,237 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Architecture - CRT Basic
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.org][Back to main documentation]]
+
+* Architecture
+:PROPERTIES:
+:CUSTOM_ID: architecture
+:END:
+
+*CRT Basic* is a classic language pipeline feeding a set of virtual
+devices. A =.bas= file is tokenized, parsed into an AST, and executed
+against a virtual VGA card, a keyboard queue and a sound timer.
+
+The guiding idea is that *devices are shared between frontends*. The
+interactive Swing window and the headless agent drivers talk to the
+exact same =VgaDevice= and =KeyboardQueue= instances; only the outer
+shell differs. A program therefore behaves identically whether a
+human watches it in a window or an agent samples it offscreen.
+
+* Package map
+:PROPERTIES:
+:CUSTOM_ID: package-map
+:END:
+
+| Package     | Key classes                                   | Responsibility |
+|-------------+-----------------------------------------------+----------------|
+| (root)      | =Main=                                        | CLI entry point: interactive vs headless modes |
+| =lexer=     | =Lexer=, =Token=                              | Source text → token stream |
+| =parser=    | =Parser=                                      | Tokens → AST, recursive descent |
+| =ast=       | =Ast= (sealed =Stmt= / =Expr= records)        | Typed syntax tree, every node carries its source line |
+| =interp=    | =Interpreter=                                 | Executes the AST (interim tree-walker) |
+| =video=     | =VgaDevice=, =Framebuffer=, =TextConsole=, =ScreenMode=, =VgaFont=, =DefaultPalettes=, =CapturedRegion= | The virtual VGA card |
+| =input=     | =KeyboardQueue=                               | Single input path for all frontends |
+| =audio=     | =SoundQueue=                                  | SOUND timing model (no audio yet) |
+| =frontend=  | =SwingFrontend=                               | Interactive window: live screen + key capture |
+
+Test sources additionally carry the JUnit suites, the
+=VideoDemoMain= showcase renderer and the =GameDriver= play-testing
+driver (see [[file:../agent-integration/index.org][Agent integration]]).
+
+* The pipeline, stage by stage
+:PROPERTIES:
+:CUSTOM_ID: pipeline-stages
+:END:
+
+** Lexer (=lexer=)
+
+Converts source text into tokens. The lexer is deliberately tolerant
+of what real DOS-era =.bas= files look like:
+
+- Files are read as *CP437* (IBM437) with CRLF line endings — the
+  charset is applied at file-read time in =Main=.
+- Both newlines and =:= are statement separators (=SEP= tokens).
+- ='= comments and word-boundary-matched =REM= comments are dropped.
+- Numbers: decimal with optional fraction and =E=/=D= exponents
+  (=1.5e3=, =2D+10=), hex =&H…=, octal =&O…=, and trailing type
+  suffixes (=1024&=, =1.5#=, =2!=).
+- Identifiers may carry the BASIC type suffixes =% & ! # $= and may
+  contain =.= and =_=; CP437 high characters pass through in strings
+  and identifiers.
+- A =_= as the last non-blank character of a line joins physical
+  lines into one logical statement (line continuation).
+
+Keywords are *not* special at this stage: they are emitted as plain
+identifiers and the parser matches them by text, case-insensitively.
+This keeps the lexer small and makes the grammar easy to extend.
+
+** Parser (=parser=, =ast=)
+
+A hand-written recursive-descent parser builds the AST. Every node is
+an immutable record (sealed =Ast.Stmt= / =Ast.Expr= interfaces) that
+carries the 1-based source line, so runtime errors can quote the
+failing source line.
+
+Statements that are not (yet) supported throw a =ParseException=
+naming the statement and the line. This is a feature: when a new
+vintage program fails to load, the error message *is* the work item.
+
+Expression parsing follows the usual BASIC precedence (weakest to
+strongest): =OR=, =AND=, =NOT=, comparison (= = <> < <= > >= =),
+addition, multiplication (= * / \ MOD =), unary minus, power (=^=),
+primaries.
+
+** Interpreter (=interp=)
+
+Executes the AST directly (tree-walking). This is the *interim*
+execution engine: the target architecture compiles the AST to a
+compact stack-based bytecode and runs it on a small VM. The
+tree-walker exists so that whole programs run today and the
+verification workflow applies at program level; lexer, parser and AST
+carry over to the bytecode world unchanged.
+
+The interpreter is where BASIC semantics live: float32 narrowing on
+store, type suffixes, true = -1, classic number formatting in
+=PRINT=, by-reference SUB parameters, module-wide =DATA=, the 24-bit
+=RND= generator. All of that is documented on the
+[[file:../interpreter/index.org][Interpreter semantics]] page.
+
+** Video (=video=)
+
+A virtual VGA card: indexed-color framebuffer with multiple video
+pages, a text console that blits CP437 glyphs into the pixel buffer,
+mode table for SCREEN 0–13, default palettes, drawing primitives
+(=PSET=, =LINE=, =CIRCLE=, =PAINT=, =GET=/=PUT=) and the DAC palette
+ports reachable through =OUT=. Everything the program can see is
+recoverable: PNG screenshots of the visual page, or the raw indexed
+pixels of every page. Full details: [[file:../video/index.org][Video subsystem]].
+
+** Input (=input=)
+
+=KeyboardQueue= is a thread-safe blocking queue of key presses: a
+KeyEvent VK code plus the typed character where one exists. *Any*
+driver pushes keys in — the Swing window, a JUnit test, an agent
+script — and the program consumes them through =INKEY$= / =INPUT$=.
+The mapping from modern key codes to the BASIC byte sequences
+(printable character, or =CHR$(0)= + scancode for extended keys)
+happens at consumption time inside the interpreter.
+
+** Audio (=audio=)
+
+=SoundQueue= models the *timing* of =SOUND= without producing audio:
+durations are in PC timer ticks (18.2 per second), the note queue is
+one note deep, and the next =SOUND= blocks until the previous note
+finishes. This preserves two classic idioms — =SOUND 0, n= as a delay
+and a single short =SOUND= per loop iteration as a frame-rate
+limiter — which is what game programs actually depend on.
+
+** Frontend (=frontend=)
+
+=SwingFrontend= is deliberately dumb: it paints the visual page of
+the =VgaDevice= into a window (nearest-neighbor scaling, ~30 fps
+repaint timer) and pushes key presses into the =KeyboardQueue=. It
+owns no emulation state. Headless operation simply skips this class.
+
+* Entry point: two ways to run
+:PROPERTIES:
+:CUSTOM_ID: entry-point
+:END:
+
+=Main= wires the pieces together and selects one of two modes:
+
+#+BEGIN_SRC text
+crtbasic program.bas                                             interactive
+crtbasic program.bas --headless [--screenshot=out.png]
+                                [--raw=out.raw] [--steps=N]      agent path
+#+END_SRC
+
+- *Interactive* (default): the Swing window is shown and the program
+  runs on a daemon thread; key presses feed the input queue and
+  =INPUT$= blocks waiting for keys.
+- *Headless*: no window. =--steps=N= caps executed statements so
+  endless game loops can be sampled; afterwards the framebuffer is
+  dumped as PNG (=--screenshot=) and/or raw indexed pixels
+  (=--raw=, all video pages concatenated). Input never blocks —
+  =INKEY$= / =INPUT$= just drain whatever a driver queued.
+
+Relative paths in =OPEN= resolve against the directory of the =.bas=
+file, so programs find their data files regardless of the caller's
+working directory.
+
+Startup errors (unreadable file, parse error) go to stderr in
+headless mode and to an error dialog in interactive mode, where
+nobody would see stderr.
+
+* Design decisions
+:PROPERTIES:
+:CUSTOM_ID: design-decisions
+:END:
+
+** Bytecode VM is the target; the tree-walker is scaffolding
+
+SCREEN 13 programs sit in tight pixel loops. A compact stack-based
+bytecode keeps interpretation overhead low and gives a natural
+"execute N instructions" granularity for deterministic stepping. The
+tree-walking interpreter is a deliberate interim: it made whole
+programs runnable (and verifiable) from day one. When the compiler
+and VM land, only =interp= is replaced.
+
+** Introspection is a feature, not an afterthought
+
+The interpreter exposes the executed-statement counter, variable
+values from the global scope, and the raw framebuffer. An agent can
+therefore *assert on program state* — "after 1000 statements,
+=score%= must be 3 and pixel (10,10) must be color 4" — instead of
+guessing from prose output.
+
+** Fail loudly on the unknown
+
+Unsupported statements raise errors that name the statement and the
+source line. Silent misinterpretation would poison debugging and
+verification: a wrong behavior must mean a wrong implementation,
+never a skipped feature.
+
+** One device set, many frontends
+
+Because frontends share =VgaDevice= and =KeyboardQueue=, there is no
+"headless build" and no behavioral drift between interactive and
+automated runs. The =--headless= flag only skips window creation and
+input blocking.
+
+* Repository layout
+:PROPERTIES:
+:CUSTOM_ID: repository-layout
+:END:
+
+#+BEGIN_SRC text
+crtbasic/
+├── README.org                 project overview and status
+├── TODO.org                   work items (games to support, features)
+├── COPYING                    license
+├── pom.xml                    Maven build (Java 21, jar with Main-Class)
+├── install.sh                 builds and installs to ~/.local
+├── Documentation/             this documentation
+└── src/
+    ├── main/java/eu/svjatoslav/crtbasic/
+    │   ├── Main.java
+    │   ├── lexer/  parser/  ast/  interp/
+    │   ├── video/  input/  audio/  frontend/
+    │   └── resources/fonts/   CP437 bitmap fonts (8x8, 8x14, 8x16)
+    └── test/java/eu/svjatoslav/crtbasic/
+        ├── video/             unit tests + VideoDemoMain showcase
+        └── drivers/           GameDriver (scripted play-testing)
+#+END_SRC
+
+The CP437 font binaries under =resources/fonts= were extracted from
+the public-domain VGA fonts shipped with SeaBIOS
+(=vgasrc/vgafonts.c=): 256 glyphs each, one byte per glyph scanline.
diff --git a/Documentation/crtbasic.png b/Documentation/crtbasic.png
new file mode 100644 (file)
index 0000000..710568a
Binary files /dev/null and b/Documentation/crtbasic.png differ
diff --git a/Documentation/index.org b/Documentation/index.org
new file mode 100644 (file)
index 0000000..02050bc
--- /dev/null
@@ -0,0 +1,215 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: CRT Basic - a BASIC interpreter
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="style.css"/>
+
+* Introduction
+:PROPERTIES:
+:CUSTOM_ID: introduction
+:END:
+
+[[file:crtbasic.png]]
+
+*CRT Basic* is an interpreter for the BASIC programming language,
+written in pure Java. It runs classic =.bas= programs on a virtual
+VGA display: text mode, pixel graphics, palettes, multiple video
+pages, keyboard input and sound timing — the full environment that
+BASIC programs of the DOS era expect.
+
+The motivation is simple: BASIC programs from that era are a perfect
+playground for AI agents. They are small, self-contained, visual and
+interactive. *CRT Basic* is built so that an AI agent can *write, run,
+observe, interact with and test* BASIC programs with no human in the
+loop. Every program can run headless: the agent caps execution at N
+statements, dumps the screen to a PNG or as raw video memory bytes,
+injects scripted keystrokes, and
+reads program variables back out when the run finishes.
+
+The language implemented is the structured BASIC dialect that became
+widespread in the 1990s: no line numbers required, named
+SUB/FUNCTION procedures, =DO...LOOP=, =SELECT CASE=, typed variables,
+and the classic =SCREEN= graphics modes with =PSET=, =LINE=, =CIRCLE=,
+=PAINT=, =GET= and =PUT=. Compatibility with existing vintage programs
+comes as a practical side effect of implementing this dialect
+faithfully: many classic =.bas= games and demos run unchanged.
+
+* Quick start
+:PROPERTIES:
+:CUSTOM_ID: quick-start
+:END:
+
+#+BEGIN_SRC sh
+cd /path/to/crtbasic
+./install.sh                          # installs to ~/.local (CRT_BASIC_PREFIX to override)
+crtbasic program.bas                  # interactive window
+crtbasic program.bas --headless --screenshot=out.png --raw=out.raw --steps=100000
+#+END_SRC
+
+- With no options the program runs *interactively*: a Swing window
+  shows the live screen (~30 fps) and key presses reach the program
+  through =INKEY$= and =INPUT$=.
+- =--headless= (implied by =--screenshot= / =--raw= / =--steps=) runs
+  with no window and dumps the screen afterwards — the agent-driving
+  path. =--raw= writes the raw indexed pixels of every video page,
+  suitable for byte-level comparison (see [[file:agent-integration/index.org][Agent integration]]).
+- =--steps=N= caps executed statements, so programs with endless game
+  loops can be sampled deterministically.
+
+The interpreter requires Java 21 or newer. Building from source is a
+plain =mvn package=; see [[#building][Building and testing]].
+
+* Architecture at a glance
+:PROPERTIES:
+:CUSTOM_ID: architecture
+:END:
+
+A =.bas= file flows through a classic interpreter pipeline. The
+*lexer* is tolerant of DOS file realities (CP437 charset, CRLF line
+endings). The *parser* produces an AST of immutable records. An
+*interpreter* currently tree-walks the AST directly — an interim
+solution until the planned bytecode compiler and VM land. Program
+output goes to a *virtual VGA device*: indexed-color framebuffer,
+bitmap text console, palettes and video pages. Input arrives through
+a shared *keyboard queue* fed by the Swing window or by headless
+drivers.
+
+| Package     | Responsibility                                        |
+|-------------+-------------------------------------------------------|
+| =lexer=     | Source text → tokens                                  |
+| =parser=    | Tokens → AST (recursive descent)                      |
+| =ast=       | Statement/expression record types                     |
+| =interp=    | Executes the AST (interim tree-walker; bytecode VM planned) |
+| =video=     | Virtual VGA: modes, framebuffer, text, drawing, palette |
+| =input=     | Keyboard queue shared by all frontends                |
+| =audio=     | SOUND timing model (no audio output yet)              |
+| =frontend=  | Swing window: live screen view + key capture          |
+
+For the full walkthrough see [[file:architecture/index.org][Architecture]].
+
+* Language coverage
+:PROPERTIES:
+:CUSTOM_ID: language
+:END:
+
+The interpreter supports a practical, growing subset of structured
+BASIC:
+
+- *Graphics*: =SCREEN= (modes 0, 1, 2, 7–13), =PSET=, =POINT=, =LINE=
+  (plain, =B=, =BF=), =CIRCLE= (with aspect), =PAINT=, =GET=, =PUT=
+  (PSET/XOR/OR/AND), =PALETTE= via =OUT= to the DAC ports, =PCOPY=.
+- *Text*: =PRINT= (with =;= and =,= zones), =LOCATE=, =COLOR=, =CLS=,
+  =VIEW PRINT=, =CSRLIN=, =POS=.
+- *Control flow*: =FOR/NEXT= (with =STEP=), single-line and block
+  =IF/THEN/ELSE/END IF=, =WHILE/WEND=, =DO...LOOP= (WHILE/UNTIL on
+  either end), =GOTO= and labels, =SELECT CASE=, =END=, =SYSTEM=.
+- *Procedures*: =SUB= and =FUNCTION= with by-reference parameters,
+  =CALL=, =DECLARE=, =DIM SHARED=, =DEFINT= and siblings.
+- *Data*: =DIM= (multi-dimensional arrays, =AS type=), =ERASE=,
+  =DATA=/=READ=.
+- *Error handling*: =ON ERROR GOTO=, =RESUME= / =RESUME NEXT=.
+- *Files*: =OPEN ... FOR INPUT=, =CLOSE=, =INPUT #=, =LINE INPUT #=,
+  =EOF()=.
+- *Input and time*: =INKEY$=, =INPUT$=, =TIMER=, =TIME$=, =RANDOMIZE=,
+  =RND= (the classic 24-bit generator, bit-exact).
+- *Sound*: =SOUND= is modeled as a timing device (18.2 ticks/s,
+  one-note queue); no audible output yet.
+
+Anything not yet supported fails loudly with the offending line
+number — that is deliberate: the error message is the to-do list.
+Known gaps include console =INPUT=, =CIRCLE= arcs, =GOSUB=, =DRAW= and
+audio output.
+
+The full reference is on the [[file:language/index.org][Language]] page.
+
+* The virtual VGA
+:PROPERTIES:
+:CUSTOM_ID: video
+:END:
+
+Program output lands on a virtual VGA card modeled after real
+hardware:
+
+- *Indexed pixels*: one byte per pixel = palette index, exactly like
+  mode 13h video memory. =PALETTE= tricks and color cycling work
+  because remapping a palette entry recolors every pixel using it.
+- *Text is pixels*: there is no separate text plane. =PRINT= blits
+  CP437 glyph bitmaps into the framebuffer, painting the *whole glyph
+  cell* in every mode — which is why printing spaces erases text,
+  exactly as vintage programs rely on.
+- *Video pages*: SCREEN 7–10 have multiple pages; drawing targets the
+  active page while the monitor shows the visual page.
+- *Pixel-exact drawing*: =LINE= reproduces the classic DDA
+  rasterizer including its clipping and endpoint bias, and =CIRCLE=
+  follows the classic rounding rules (half-up rounding,
+  SINGLE-precision aspect value).
+
+Details and diagrams: [[file:video/index.org][Video subsystem]].
+
+* Driving programs without a human
+:PROPERTIES:
+:CUSTOM_ID: agent-integration
+:END:
+
+The headless path is the heart of the project:
+
+#+BEGIN_SRC sh
+# Run offscreen, cap at 100k statements, dump the screen
+crtbasic game.bas --headless --screenshot=out.png --steps=100000
+
+# Raw indexed pixels for byte-level comparison
+crtbasic game.bas --headless --raw=out.raw
+
+# Scripted play-testing: feed keystrokes, then inspect variables
+java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.drivers.GameDriver \
+    game.bas --keys="RR~~~LL~" --screenshot=end.png --vars=score%,lives%
+#+END_SRC
+
+The =GameDriver= feeds a scripted key sequence to a running game and
+prints chosen variables afterwards. Combined with statement budgets
+and framebuffer dumps, an agent can play-test keyboard-driven games
+entirely on its own.
+
+See [[file:agent-integration/index.org][Agent integration]].
+
+* Building and testing
+:PROPERTIES:
+:CUSTOM_ID: building
+:END:
+
+#+BEGIN_SRC sh
+cd /path/to/crtbasic
+mvn package
+java -jar target/crt-basic-*-SNAPSHOT.jar program.bas
+mvn test          # JUnit: video semantics, drawing primitives, end-to-end runs
+#+END_SRC
+
+The =install.sh= script builds the jar and installs it to
+=~/.local/share/crtbasic= together with a =crtbasic= launcher in
+=~/.local/bin= and a desktop entry that makes =.bas= files open with
+the interpreter (set =CRT_BASIC_PREFIX= to install elsewhere).
+
+A demo renderer (test sources) writes showcase frames for visual
+verification:
+
+#+BEGIN_SRC sh
+java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain
+# frames appear in /tmp/crt-basic-demo/
+#+END_SRC
+
+* Further reading
+:PROPERTIES:
+:CUSTOM_ID: further-reading
+:END:
+
+- [[file:architecture/index.org][Architecture]] — packages, pipeline, and why each piece exists.
+- [[file:language/index.org][Language reference]] — the supported BASIC dialect in detail.
+- [[file:video/index.org][Video subsystem]] — the virtual VGA: modes, text, drawing, palettes.
+- [[file:interpreter/index.org][Interpreter semantics]] — numerics, variables, scoping, RND, errors.
+- [[file:agent-integration/index.org][Agent integration]] — headless runs, drivers, scripted play-testing.
diff --git a/Documentation/interpreter/index.org b/Documentation/interpreter/index.org
new file mode 100644 (file)
index 0000000..32d5627
--- /dev/null
@@ -0,0 +1,199 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Interpreter Semantics - CRT Basic
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.org][Back to main documentation]]
+
+* Execution engine
+:PROPERTIES:
+:CUSTOM_ID: execution-engine
+:END:
+
+The =Interpreter= class executes the AST directly, tree-walking. It
+is the *interim* engine: the target architecture compiles the AST to
+a compact stack-based bytecode executed by a small VM, which is
+cheaper in tight pixel loops and gives a natural "execute N
+instructions" granularity. The tree-walker exists so that whole
+programs run — and can be verified — today. The lexer, parser and
+AST carry over unchanged; only this package gets replaced.
+
+Because BASIC semantics are subtle and real programs depend on the
+subtleties, they are pinned down here explicitly.
+
+* The numeric model
+:PROPERTIES:
+:CUSTOM_ID: numeric-model
+:END:
+
+#+INCLUDE: "number-flow.svg" export html
+
+- *Expression evaluation always runs in double precision.* Precision
+  is only lost when a value is *stored*: into a variable, an array
+  element, or a numeric literal narrower than DOUBLE.
+- *Storage narrows to the slot's type.* SINGLE slots (the default)
+  narrow to float32. INTEGER (= %=) and LONG (= &=) slots round
+  *half-to-even* and raise *Overflow* when out of range. DOUBLE
+  (= #=) slots keep everything.
+- Numeric literals narrow to float32 unless written with a =#=
+  suffix or a =D= exponent, which mark them DOUBLE.
+- Graphics coordinates and colors convert fractional values by
+  rounding half-to-even — not truncation.
+
+In practice: a value computed as =0.1 + 0.2= keeps full double
+precision through the addition and narrows exactly once, on
+assignment.
+
+* Variable identity
+:PROPERTIES:
+:CUSTOM_ID: variable-identity
+:END:
+
+Variable names are case-insensitive but *suffix-sensitive*:
+
+- =x= and =x!= are the same variable; =x%= is a different one.
+- A suffix-less name resolves its suffix from =DEFINT= and siblings
+  (module-wide, by first letter), or from a =DIM name AS type=
+  declaration, defaulting to SINGLE.
+- Unassigned numeric variables read as =0=, strings as =""=.
+- True is =-1=, false is =0=; =AND= / =OR= / =NOT= are bitwise.
+
+* Procedures and scoping
+:PROPERTIES:
+:CUSTOM_ID: procedures-and-scoping
+:END:
+
+- *SUBs and FUNCTIONs register at load time* and never execute
+  inline; main code is everything else.
+- *Plain-variable arguments pass by reference* — the parameter shares
+  the caller's storage cell, so writes through the parameter are
+  visible to the caller. Expression arguments pass by value.
+- SUBs do not see the caller's other variables. =DIM SHARED= names
+  resolve against the global scope from inside any SUB.
+- When a SUB/FUNCTION header uses bare parameter names, the matching
+  =DECLARE= statement's suffixes decide the parameter types.
+- A FUNCTION returns whatever was last assigned to its own name
+  inside the body (=0= / =""= when never assigned).
+- Arrays not yet =DIM=med auto-dimension to =0 TO 10= per dimension
+  on first indexed use; =ERASE= returns a slot to its never-DIMmed
+  state.
+
+* Labels, GOTO and error handling
+:PROPERTIES:
+:CUSTOM_ID: control-flow-internals
+:END:
+
+Each statement block executes under a program counter. A =GOTO=
+throws a control-flow signal carrying the label; the enclosing block
+catches it when it contains the label, and rethrows otherwise — which
+gives labels *procedure-wide scope*, exactly as the language defines,
+without any pre-pass.
+
+=ON ERROR GOTO= registers a module-global handler label. A trapped
+runtime error transfers control to the handler, remembering the
+program counter of the failing statement. =RESUME= retries that
+statement, =RESUME NEXT= continues after it, =RESUME label= jumps.
+=ON ERROR GOTO 0= disables trapping. (For an error raised inside a
+SUB, the current approximation retries the CALL statement.)
+
+=DATA= constants are module-wide: they are collected in program order
+before execution begins, and =READ= walks that list. Running out
+raises *Out of DATA*.
+
+* RND is bit-exact
+:PROPERTIES:
+:CUSTOM_ID: rnd
+:END:
+
+The random generator reproduces the reference algorithm exactly: a
+24-bit linear congruential generator
+
+#+BEGIN_SRC text
+seed = (seed * &HFD43FD + &HC39EC3) mod 2^24
+RND  = seed / 2^24   (as SINGLE)
+#+END_SRC
+
+- Without =RANDOMIZE=, the generator starts from the same fixed seed
+  as the reference, so programs get the identical deterministic
+  sequence.
+- =RND(0)= repeats the last value; =RND(-n)= reseeds first;
+  =RANDOMIZE n= reseeds like =RND(-n)=; bare =RANDOMIZE= seeds from
+  the clock (=TIMER=).
+- Reseeding folds the SINGLE bit pattern of =|n|= into the 24-bit
+  seed as =(bits & &HFFFFFF) + (bits >>> 24) + &H80=.
+
+Programs that shuffle, spawn or wander "randomly" therefore produce
+the same playthrough every time — which is what makes scripted
+play-testing reproducible.
+
+* PRINT number formatting
+:PROPERTIES:
+:CUSTOM_ID: print-formatting
+:END:
+
+Numbers print at the *expression's static type*: SINGLE formatting
+(~7 significant digits, float32-narrowed) by default, 16 significant
+digits for DOUBLE expressions — with =D±xx= scientific notation
+outside the fixed range. Non-negative values get a leading space;
+every number gets a trailing space.
+
+Known gap: some classic implementations of this dialect misprint a
+few DOUBLE values by 1–2 ulp in a bit-pattern-dependent way
+(=STR$(592#)= prints ="591.9999999999999"= there, while =321#=
+prints exactly). Every constant-error hypothesis failed on
+same-decade value pairs, so this interpreter prints the
+mathematically correct 16 digits instead.
+
+* Statement budget and introspection
+:PROPERTIES:
+:CUSTOM_ID: introspection
+:END:
+
+- =setMaxStatements(n)= caps execution; the
+  =StepsLimitException= fires when the budget is spent. This is how
+  =--steps=N= samples endless game loops deterministically.
+- =statementsExecuted()= reports how far a run got.
+- =variable(name)= reads a variable from the global scope — agents
+  assert on program state directly.
+- =video()= exposes the whole virtual VGA: raw page bytes, PNG dumps,
+  cursor positions, palette entries.
+- Setting the system property =crtbasic.linelog= to a file path makes
+  every =LINE= call append =x1,y1,x2,y2,color= after coordinate
+  conversion — a debug aid for tracing draw calls.
+
+* File I/O model
+:PROPERTIES:
+:CUSTOM_ID: file-io
+:END:
+
+=OPEN path FOR INPUT AS #n= opens a text file (read as CP437; relative
+paths resolve against the =.bas= file's directory). Each channel keeps
+a one-line lookahead so =EOF(n)= can answer without consuming input,
+plus a buffer of comma-split items so several =INPUT #= statements can
+share one line and one =INPUT #= can span lines. Numeric targets parse
+like =VAL= (empty or unparseable is 0); string targets keep raw text
+with surrounding quotes stripped. =LINE INPUT #= reads whole lines;
+=CLOSE= with no channel closes everything; closing an unopened channel
+is silently ignored.
+
+Output modes (=OPEN FOR OUTPUT=, =PRINT #=) are not yet implemented.
+
+* Input consumption
+:PROPERTIES:
+:CUSTOM_ID: input-consumption
+:END:
+
+The interpreter maps queued keys to classic byte sequences at
+consumption time: printable keys yield their character (Enter as
+=CHR$(13)=); extended keys yield =CHR$(0)= + scancode. =INKEY$=
+returns a whole sequence in one call; =INPUT$(n)= doles bytes out
+across reads, holding the tail of a half-consumed extended key.
+Interactive mode blocks waiting for keys in =INPUT$=; headless mode
+never blocks.
diff --git a/Documentation/interpreter/number-flow.svg b/Documentation/interpreter/number-flow.svg
new file mode 100644 (file)
index 0000000..34d6928
--- /dev/null
@@ -0,0 +1,32 @@
+<svg viewBox="0 0 700 150" width="700" height="150" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+  </defs>
+  <rect width="700" height="150" fill="#061018"/>
+
+  <rect x="15" y="30" width="150" height="40" rx="3" fill="rgba(100,100,100,0.2)" stroke="#aaa" stroke-width="1.5"/>
+  <text x="90" y="47" fill="#aaa" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">literal / variable</text>
+  <text x="90" y="61" fill="#aaa" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">0.1, x%, total#</text>
+
+  <rect x="215" y="30" width="180" height="40" rx="3" fill="rgba(32,112,192,0.15)" stroke="#2070c0" stroke-width="1.5"/>
+  <text x="305" y="47" fill="#2070c0" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">expression evaluation</text>
+  <text x="305" y="61" fill="#2070c0" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">always double precision</text>
+
+  <rect x="445" y="30" width="220" height="40" rx="3" fill="rgba(255,102,0,0.15)" stroke="#FF6600" stroke-width="1.5"/>
+  <text x="555" y="47" fill="#FF6600" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">store into variable / array</text>
+  <text x="555" y="61" fill="#FF6600" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">narrow to the slot's type</text>
+
+  <line x1="165" y1="50" x2="210" y2="50" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="395" y1="50" x2="440" y2="50" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+  <text x="90" y="95" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">float32 unless # / D-exp</text>
+  <text x="305" y="95" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">no precision loss</text>
+  <text x="305" y="107" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">mid-expression</text>
+  <text x="555" y="95" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">% int16, &amp; int32 (half-even)</text>
+  <text x="555" y="107" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">! float32, # double</text>
+
+  <text x="350" y="130" fill="#666" font-size="10" font-family="monospace" text-anchor="middle">y = 0.1 + 0.2  →  full double math, then ONE narrowing when assigned to y</text>
+</svg>
diff --git a/Documentation/language/index.org b/Documentation/language/index.org
new file mode 100644 (file)
index 0000000..3b0c60d
--- /dev/null
@@ -0,0 +1,304 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Language Reference - CRT Basic
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.org][Back to main documentation]]
+
+* The BASIC dialect
+:PROPERTIES:
+:CUSTOM_ID: dialect
+:END:
+
+*CRT Basic* implements the structured BASIC dialect common to early-90s
+DOS programs: optional line numbers, named procedures, typed variables
+by suffix, and the classic =SCREEN= graphics environment.
+
+This page documents what the interpreter *currently* supports. The
+project is under active development: unsupported statements fail with
+an error naming the statement and the source line, and support grows
+program by program.
+
+* Source files
+:PROPERTIES:
+:CUSTOM_ID: source-files
+:END:
+
+- Files are read as *CP437* with CRLF tolerated — the natural format
+  of DOS-era =.bas= files.
+- *Comments*: ='= anywhere, or =REM= as a statement. Both run to end
+  of line.
+- *Statement separators*: newline or =:=. Multiple statements per line
+  work (=A = 1 : B = 2=).
+- *Line continuation*: a =_= as the last non-blank character of a line
+  joins it with the next.
+- *Case*: keywords and variable names are case-insensitive.
+- *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=.
+
+* Variables and types
+:PROPERTIES:
+:CUSTOM_ID: variables-and-types
+:END:
+
+A variable's type comes from its *name suffix*:
+
+| Suffix | Type    | Storage                                  |
+|--------+---------+------------------------------------------|
+| =%=    | INTEGER | 16-bit integer, rounded half-to-even on store; out of range → *Overflow* |
+| =&=    | LONG    | 32-bit integer, same rounding/overflow rules |
+| =!=    | SINGLE  | 32-bit float (narrowed on store)         |
+| =#=    | DOUBLE  | 64-bit float                             |
+| =$=    | STRING  | dynamically sized string                 |
+
+A name without a suffix gets a *default type*: SINGLE, unless changed
+by =DEFINT= / =DEFLNG= / =DEFSNG= / =DEFDBL= / =DEFSTR= letter ranges
+(=DEFINT A-Z= makes every plain name an INTEGER). The default applies
+by first letter and is module-wide, independent of where the =DEFxxx=
+statement appears.
+
+Important consequences:
+
+- =x= and =x!= are the *same* variable (under the default rules);
+  =x%= is a *different* one.
+- Numbers are computed in double precision inside expressions and
+  *narrowed to the variable's type on store*. =y = 0.1 + 0.2= keeps
+  full precision during the addition, then narrows to SINGLE when
+  assigned.
+- =DIM name AS INTEGER= fixes a suffix-less name's type the same way
+  a suffix would.
+- Unassigned numeric variables read as =0=, strings as =""=.
+
+*Truth values*: comparisons return =-1= for true and =0= for false.
+Any nonzero number is true in =IF= / =WHILE= conditions. =AND=, =OR=
+and =NOT= are *bitwise* operations on integers, exactly as classic
+BASIC defines them.
+
+* Arrays
+:PROPERTIES:
+:CUSTOM_ID: arrays
+:END:
+
+#+BEGIN_SRC basic
+DIM scores(0 TO 50)          ' explicit bounds
+DIM grid(10, 20)             ' 0-based, two dimensions
+DIM SHARED high%(9)          ' visible inside SUBs
+DIM names$(20) AS STRING     ' AS type form
+#+END_SRC
+
+- An array used *without* =DIM= is auto-dimensioned =0 TO 10= per
+  dimension on first indexed use — the classic default.
+- Indexing out of range raises *Subscript out of range*.
+- =ERASE name= frees an array; it can be re-=DIM=med afterwards.
+- Element types follow the same suffix rules as scalars; INTEGER
+  arrays narrow and range-check each stored element.
+
+* Statements
+:PROPERTIES:
+:CUSTOM_ID: statements
+:END:
+
+** Screen and graphics
+
+| Statement | Notes |
+|-----------+-------|
+| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count |
+| =PSET (x, y)[, color]= | One pixel; default color = current text foreground |
+| =POINT(x, y)= | Function: palette index of a pixel |
+| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original |
+| =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 |
+| =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 |
+| =PALETTE= via =OUT &H3C8/&H3C9= | DAC palette ports are modeled; other ports ignored |
+
+** Text
+
+| Statement | Notes |
+|-----------+-------|
+| =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) |
+| =CLS= | Clears to background, homes cursor |
+| =VIEW PRINT top TO bottom= | Scroll region (parsed; framebuffer effect is currently an approximation) |
+| =CSRLIN=, =POS(0)= | Functions: cursor row / column |
+
+** Control flow
+
+| 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 |
+| =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 |
+| =END=, =SYSTEM= | Both end the program |
+| =GOSUB= / =RETURN= | *Not yet implemented* |
+
+** Procedures
+
+#+BEGIN_SRC basic
+DECLARE SUB DrawStar (cx%, cy%, r!)
+
+CALL DrawStar(100, 100, 20)
+DrawStar 50, 50, 10        ' bare-call form, no parentheses
+
+SUB DrawStar (cx%, cy%, r!)
+    ...
+END SUB
+
+FUNCTION Area# (r!)
+    Area# = 3.141592653589793# * r! * r!
+END FUNCTION
+#+END_SRC
+
+- Arguments that are *plain variables* are passed *by reference*:
+  assignments to the parameter write through to the caller's
+  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.
+- =DECLARE= statements carry parameter type suffixes: when a
+  SUB/FUNCTION header uses bare names, the declared suffixes type the
+  parameters.
+- SUBs do *not* see the caller's variables (classic scoping), except
+  names declared =DIM SHARED=.
+
+** Data, files, errors, misc
+
+| Statement | Notes |
+|-----------+-------|
+| =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= |
+| =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 |
+| =OUT port, value= | VGA DAC ports only; others ignored |
+
+* Expressions and operators
+:PROPERTIES:
+:CUSTOM_ID: expressions
+:END:
+
+Precedence, weakest first:
+
+| Level | Operators |
+|-------+-----------|
+| 1 | =OR= |
+| 2 | =AND= |
+| 3 | =NOT= (unary) |
+| 4 | = = <> < <= > >= = (comparison) |
+| 5 | =+ - = |
+| 6 | =* / \ MOD= |
+| 7 | unary =- = |
+| 8 | =^= |
+
+Semantics worth knowing:
+
+- =/= is floating-point division; =\= and =MOD= round both operands
+  to integers (half-to-even) first, then divide / take the remainder.
+- Division by zero raises *Division by zero*.
+- =+= on two strings concatenates.
+- Comparison of strings is by exact/lexicographic string order;
+  comparing a string to a number raises *Type mismatch*.
+
+* Built-in functions
+:PROPERTIES:
+:CUSTOM_ID: functions
+:END:
+
+| Function | Result |
+|----------+--------|
+| =ABS=, =SGN=, =INT=, =FIX= | magnitude/sign/floor/truncation |
+| =CINT=, =CLNG= | round half-to-even into int16 / int32 range (Overflow if out of range) |
+| =SQR=, =EXP=, =LOG= | square root, e^x, natural log |
+| =SIN=, =COS=, =TAN=, =ATN= | radians, double precision |
+| =RND[(n)]= | =n>0= or omitted: next number; =n=0=: repeat last; =n<0=: reseed first |
+| =TIMER= | seconds since local midnight (single precision) |
+| =TIME$= | current time, ="HH:MM:SS"= |
+| =POINT(x, y)= | palette index of a pixel |
+| =POS(0)=, =CSRLIN= | cursor column / row (1-based) |
+| =CHR$(n)=, =ASC(s$)= | character ↔ code |
+| =STR$(n)=, =VAL(s$)= | number ↔ string (=VAL= of junk is 0) |
+| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) |
+| =UCASE$=, =LCASE$= | case conversion |
+| =LTRIM$=, =RTRIM$= | strip spaces |
+| =INKEY$= | one key, non-blocking; =""= when idle |
+| =INPUT$(n)= | read up to n input bytes (extended keys arrive as =CHR$(0)= + scancode) |
+| =EOF(n)= | -1 at end of file channel |
+
+* PRINT formatting
+:PROPERTIES:
+:CUSTOM_ID: print-formatting
+:END:
+
+Numbers print the way classic BASIC formats them:
+
+- A *leading space* for non-negative values and a *trailing space* —
+  =PRINT 1= emits =" 1 "=.
+- SINGLE values print with float32's ~7 significant digits; DOUBLE
+  expressions (a =#=-suffixed literal or variable anywhere inside)
+  print with 16 significant digits, using =D±xx= scientific notation
+  outside the fixed range.
+- =,= advances to the next 14-column print zone; =;= prints compactly.
+  A trailing separator suppresses the newline.
+
+Whole glyph cells are painted when text lands in a graphics mode —
+see [[file:../video/index.org::#text-is-pixels][Text is pixels]] for
+why that matters.
+
+* Keyboard input model
+:PROPERTIES:
+:CUSTOM_ID: keyboard-input
+:END:
+
+Programs read keys through =INKEY$= (single key, non-blocking) and
+=INPUT$(n)= (up to n bytes). Printable keys arrive as their character
+(Enter as =CHR$(13)=). Cursor keys, function keys and editing keys
+arrive as the classic two-byte sequence =CHR$(0)= followed by the
+scancode — =INKEY$= returns both bytes in one call, =INPUT$= hands
+them out across successive reads.
+
+| Key | Sequence |
+|-----+----------|
+| Up / Down / Left / Right | =CHR$(0)= + 72 / 80 / 75 / 77 |
+| Home / End / PgUp / PgDn | =CHR$(0)= + 71 / 79 / 73 / 81 |
+| Insert / Delete | =CHR$(0)= + 82 / 83 |
+| F1–F10 | =CHR$(0)= + 59–68 |
+| Esc, Enter, Backspace, Tab | single byte: 27, 13, 8, 9 |
+
+In headless mode input never blocks: both functions simply drain
+whatever a driver pushed into the keyboard queue and return =""= when
+it is empty.
+
+* Error behavior
+:PROPERTIES:
+:CUSTOM_ID: errors
+:END:
+
+Runtime errors are fatal by default and always name the source line
+(plus the offending source text when available). Trap them with
+=ON ERROR GOTO label=; =RESUME= retries the trapped statement,
+=RESUME NEXT= continues after it, =RESUME label= jumps. =ON ERROR
+GOTO 0= disables trapping.
+
+Common runtime errors: *Overflow* (integer store out of range),
+*Division by zero*, *Subscript out of range*, *Type mismatch*,
+*Out of DATA*, *Input past end of file*, *File not found*,
+*Bad file number*, *Label not found*.
diff --git a/Documentation/style.css b/Documentation/style.css
new file mode 100644 (file)
index 0000000..3403e0e
--- /dev/null
@@ -0,0 +1,35 @@
+.flex-center {
+  display: flex;
+  justify-content: center;
+}
+
+.flex-center video {
+  width: min(90%, 1000px);
+  height: auto;
+}
+
+.responsive-img {
+  width: min(100%, 1000px);
+  height: auto;
+}
+
+/* === SVG diagram theme === */
+svg > rect:first-child {
+  fill: #061018;
+}
+
+svg text[fill="#666"],
+svg text[fill="#999"] {
+  fill: #aaa !important;
+}
+
+svg line[stroke="#ccc"] {
+  stroke: #445566 !important;
+}
+
+svg {
+  background-color: #061018;
+  border-radius: 8px;
+  display: block;
+  margin: 0 auto;
+}
\ No newline at end of file
diff --git a/Documentation/video/index.org b/Documentation/video/index.org
new file mode 100644 (file)
index 0000000..5d8083c
--- /dev/null
@@ -0,0 +1,237 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Video Subsystem - CRT Basic
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.org][Back to main documentation]]
+
+* The virtual VGA
+:PROPERTIES:
+:CUSTOM_ID: virtual-vga
+:END:
+
+Everything a BASIC program draws lands on a virtual VGA card
+(package =eu.svjatoslav.crtbasic.video=). The card is modeled after
+real hardware closely enough that classic programs behave the way
+they were written to behave: palette tricks work, text erases the
+pixels under it, page flipping is flicker-free, and the full video
+memory can be dumped byte-for-byte for external inspection.
+
+| Class | Role |
+|-------+------|
+| =VgaDevice= | Facade the interpreter calls: modes, text, drawing, palette, pages |
+| =ScreenMode= | Table of modes 0–13: resolution, colors, pages, glyph height |
+| =Framebuffer= | Indexed pixels (one byte = one palette index), pages, PNG/raw dumps |
+| =TextConsole= | =PRINT= / =LOCATE= / =CLS= / =VIEW PRINT= on top of the framebuffer |
+| =VgaFont= | CP437 bitmap fonts (8x8, 8x14, 8x16), 256 glyphs each |
+| =DefaultPalettes= | The 16 EGA colors and the mode-13h BIOS-style palette |
+| =CapturedRegion= | What =GET= stores and =PUT= blits |
+
+* Screen modes
+:PROPERTIES:
+:CUSTOM_ID: screen-modes
+:END:
+
+=SCREEN n= switches the card between modes. Every graphics mode
+doubles as a text mode — characters are blitted as 8-pixel-wide
+glyph cells into the same framebuffer that pixel primitives draw
+into.
+
+| SCREEN | Pixels  | Colors | Pages | Glyph cell | Text grid |
+|--------+---------+--------+-------+------------+-----------|
+| 0      | 640x400 | 16     | 1     | 8x16       | 80x25     |
+| 1      | 320x200 | 4      | 1     | 8x8        | 40x25     |
+| 2      | 640x200 | 2      | 1     | 8x8        | 80x25     |
+| 7      | 320x200 | 16     | 8     | 8x8        | 40x25     |
+| 8      | 640x200 | 16     | 4     | 8x8        | 80x25     |
+| 9      | 640x350 | 16     | 2     | 8x14       | 80x25     |
+| 10     | 640x350 | 4      | 2     | 8x14       | 80x25     |
+| 11     | 640x480 | 2      | 1     | 8x16       | 80x30     |
+| 12     | 640x480 | 16     | 1     | 8x16       | 80x30     |
+| 13     | 320x200 | 256    | 1     | 8x8        | 40x25     |
+
+Switching modes reallocates video memory, resets the palette to the
+mode defaults and clears the screen. After =SCREEN=, the default text
+foreground is 7 (light gray) in SCREEN 0 and 15 (bright white) in
+graphics modes — a small detail real programs visibly depend on.
+
+Modes 3–6 do not exist in this dialect's target hardware and raise
+an error.
+
+* Indexed pixels and palettes
+:PROPERTIES:
+:CUSTOM_ID: indexed-pixels
+:END:
+
+#+INCLUDE: "indexed-pixels.svg" export html
+
+The framebuffer stores *palette indices*, not colors: one byte per
+pixel, exactly like mode 13h video memory. Indices resolve to RGB
+only when a frame is displayed or dumped to PNG. This single decision
+makes the classic effects work for free:
+
+- =PALETTE= (via the DAC ports, see below) remaps an entry and every
+  pixel using it changes color on the next frame.
+- *Color cycling* — rotating a range of palette entries for cheap
+  animation — needs no pixel writes at all.
+
+The default palette holds the 16 standard EGA colors in entries 0–15.
+SCREEN 13 additionally gets a BIOS-style 256-color extension: a
+grayscale ramp followed by hue wheels at decreasing saturation.
+SCREEN 1 is the exception: being a CGA mode, it starts on CGA palette
+1 high intensity (cyan / magenta / bright white).
+
+** The DAC ports
+
+Programs of the era often skip =PALETTE= and poke the VGA DAC
+directly:
+
+#+BEGIN_SRC basic
+OUT &H3C8, index        ' select palette entry
+OUT &H3C9, red          ' 0-63 per component
+OUT &H3C9, green
+OUT &H3C9, blue
+#+END_SRC
+
+=OUT= to port =&H3C8= selects the color index; three writes to
+=&H3C9= supply red, green, blue (6 bits each, scaled to 0–255). The
+index auto-increments after each complete color, exactly like
+hardware. All other ports are out of scope and ignored.
+
+* Text is pixels
+:PROPERTIES:
+:CUSTOM_ID: text-is-pixels
+:END:
+
+#+INCLUDE: "text-cell.svg" export html
+
+There is no separate text plane. =TextConsole= blits CP437 glyph
+bitmaps straight into the pixel framebuffer — the same thing the BIOS
+does on real hardware. Consequences that fall out naturally:
+
+- =PRINT= works identically in text and graphics modes.
+- The *whole glyph cell* is painted: stroke pixels in the foreground
+  color, all other pixels in the background color, in every mode.
+  Printing spaces therefore erases text even over a drawn scene —
+  the standard technique for removing a score display before
+  reprinting it.
+- Scrolling is a pixel-copy of the scroll region shifted up by one
+  cell height; the freed bottom line is filled with the background
+  color.
+
+Cursor positions are 1-based (=LOCATE row, col=, =CSRLIN=, =POS(0)=).
+The console reproduces the classic *bottom-right corner* behavior:
+printing into the last cell of the screen does not scroll by itself —
+the cursor parks on the final column and wraps only when the next
+character arrives.
+
+=VIEW PRINT top TO bottom= confines printing and scrolling to a band
+of text rows and homes the cursor into the band.
+
+The fonts are CP437 bitmap fonts (256 glyphs each, one byte per glyph
+scanline) in the three hardware heights 8, 14 and 16, extracted from
+the public-domain VGA fonts shipped with SeaBIOS and embedded as jar
+resources.
+
+* Video pages
+:PROPERTIES:
+:CUSTOM_ID: video-pages
+:END:
+
+#+INCLUDE: "video-pages.svg" export html
+
+SCREEN 7, 8, 9 and 10 have multiple video pages. Two page selectors
+are independent:
+
+- The *active page* is what drawing and printing target
+  (=SCREEN ,,apage= or the active-page argument).
+- The *visual page* is what the monitor (and screenshots) show.
+
+The classic flicker-free animation loop draws the next frame on a
+hidden page and flips the visual page when done. =PCOPY source,
+target= copies a whole page instantly — the standard way to stamp a
+pre-drawn background before each frame.
+
+* Drawing primitives
+:PROPERTIES:
+:CUSTOM_ID: drawing-primitives
+:END:
+
+All drawing targets the active page and updates the *graphics cursor*
+(the "last point referenced"), which relative coordinates build on.
+
+| Primitive | Statement form |
+|-----------+----------------|
+| =pset= / =point= | =PSET (x,y), c= / =POINT(x,y)= |
+| =line= | =LINE (x1,y1)-(x2,y2), c= |
+| =lineBox= / =lineBoxFilled= | =…, c, B= / =…, c, BF= |
+| =circle= | =CIRCLE (x,y), r, c, , , aspect= |
+| =paint= | =PAINT (x,y), fill, border= |
+| =getRegion= / =putRegion= | =GET= / =PUT= with PSET, XOR, OR, AND |
+
+** LINE is pixel-exact
+
+=LINE= reproduces the original rasterizer bit-for-bit:
+
+- The segment is *clipped to the screen before rasterizing*
+  (Liang–Barsky). The clipped endpoint's off-axis coordinate rounds
+  to nearest, with exact .5 ties going *toward the surviving part of
+  the line*. This differs from per-pixel clipping for lines that
+  leave the screen.
+- The walk is a DDA along the major axis with an initial error bias
+  of =floor(3M/4)= (or its complement when stepping left), which
+  makes a reversed endpoint order plot the identical pixel set.
+- Both endpoints are drawn. =B= outlines, =BF= fills inclusive
+  corners — =LINE (x,y)-(x+1,y+1),c,BF= is the era's "magnify pixel"
+  idiom and covers exactly 2x2 pixels.
+
+** CIRCLE is pixel-exact
+
+=CIRCLE= draws axis-aligned ellipses. With no aspect given, the
+mode's default aspect (=4/3= corrected for the pixel resolution)
+makes circles look round on the period-correct display. An explicit
+aspect below 1 makes =radius= the x radius; 1 or more makes it the y
+radius.
+
+Rasterization reproduces the classic pixel output exactly: the
+ellipse is computed in "circle space" and mapped through the aspect
+as the union of two scan passes, mirrored to four quadrants. All
+rounding is half-up and the aspect value is SINGLE precision — both
+details are load-bearing. Arcs (start/end angles) are parsed but not
+yet implemented and raise an explicit error.
+
+** PAINT, GET and PUT
+
+=PAINT= flood-fills the contiguous region of the start pixel's color,
+stopping at the border color; painting a pixel that already is the
+border color does nothing.
+
+=GET= captures a screen rectangle into a variable; =PUT= blits it
+back with an action: =PSET= overwrites, =XOR= / =OR= / =AND= combine
+palette indices bitwise. (The original packs the image into a numeric
+array in a binary format; since arrays never leave this interpreter,
+the capture rides in the variable slot as a dedicated object —
+GET/PUT round-trips are pixel-identical.)
+
+* Reading the screen back
+:PROPERTIES:
+:CUSTOM_ID: reading-screen
+:END:
+
+Because the whole point is agent-observable output:
+
+- =dumpPng(path)= writes the visual page as a PNG — the headless
+  equivalent of looking at the monitor (the =--screenshot= CLI
+  option).
+- =pageData(page)= returns the raw indexed pixels of a page,
+  row-major — the exact equivalent of a =BSAVE= of mode-13h video
+  memory, for byte-level comparison (the =--raw= CLI option).
+- =toImage(page)= converts a page to an AWT image for the Swing
+  frontend.
diff --git a/Documentation/video/indexed-pixels.svg b/Documentation/video/indexed-pixels.svg
new file mode 100644 (file)
index 0000000..a1f6616
--- /dev/null
@@ -0,0 +1,79 @@
+<svg viewBox="0 0 700 200" width="700" height="200" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+  </defs>
+  <rect width="700" height="200" fill="#061018"/>
+
+  <!-- Framebuffer: pixel grid of palette indices -->
+  <text x="95" y="22" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Framebuffer (indices)</text>
+  <g font-family="monospace" font-size="10" fill="#ccc" text-anchor="middle">
+    <!-- row 0 -->
+    <rect x="20" y="35" width="28" height="28" fill="none" stroke="#445566"/><text x="34" y="53">15</text>
+    <rect x="48" y="35" width="28" height="28" fill="none" stroke="#445566"/><text x="62" y="53">15</text>
+    <rect x="76" y="35" width="28" height="28" fill="none" stroke="#445566"/><text x="90" y="53">1</text>
+    <rect x="104" y="35" width="28" height="28" fill="none" stroke="#445566"/><text x="118" y="53">9</text>
+    <rect x="132" y="35" width="28" height="28" fill="none" stroke="#445566"/><text x="146" y="53">0</text>
+    <!-- row 1 -->
+    <rect x="20" y="63" width="28" height="28" fill="none" stroke="#445566"/><text x="34" y="81">15</text>
+    <rect x="48" y="63" width="28" height="28" fill="none" stroke="#445566"/><text x="62" y="81">9</text>
+    <rect x="76" y="63" width="28" height="28" fill="none" stroke="#445566"/><text x="90" y="81">9</text>
+    <rect x="104" y="63" width="28" height="28" fill="none" stroke="#445566"/><text x="118" y="81">1</text>
+    <rect x="132" y="63" width="28" height="28" fill="none" stroke="#445566"/><text x="146" y="81">0</text>
+    <!-- row 2 -->
+    <rect x="20" y="91" width="28" height="28" fill="none" stroke="#445566"/><text x="34" y="109">0</text>
+    <rect x="48" y="91" width="28" height="28" fill="none" stroke="#445566"/><text x="62" y="109">0</text>
+    <rect x="76" y="91" width="28" height="28" fill="none" stroke="#445566"/><text x="90" y="109">1</text>
+    <rect x="104" y="91" width="28" height="28" fill="none" stroke="#445566"/><text x="118" y="109">15</text>
+    <rect x="132" y="91" width="28" height="28" fill="none" stroke="#445566"/><text x="146" y="109">15</text>
+  </g>
+  <text x="95" y="140" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">one byte per pixel =</text>
+  <text x="95" y="152" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">palette index</text>
+
+  <!-- Arrow -->
+  <line x1="175" y1="75" x2="205" y2="75" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+  <!-- Palette -->
+  <text x="310" y="22" fill="#2070c0" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Palette (index → RGB)</text>
+  <g font-family="monospace" font-size="10" fill="#ccc">
+    <rect x="220" y="35" width="24" height="24" fill="#000000" stroke="#445566"/><text x="252" y="51">0</text>
+    <rect x="220" y="65" width="24" height="24" fill="#0000AA" stroke="#445566"/><text x="252" y="81">1</text>
+    <rect x="220" y="95" width="24" height="24" fill="#00AAAA" stroke="#445566"/><text x="252" y="111">9</text>
+    <rect x="220" y="125" width="24" height="24" fill="#FFFFFF" stroke="#445566"/><text x="252" y="141">15</text>
+    <text x="290" y="51" fill="#666">#000000</text>
+    <text x="290" y="81" fill="#666">#0000AA</text>
+    <text x="290" y="111" fill="#666">#00AAAA</text>
+    <text x="290" y="141" fill="#666">#FFFFFF</text>
+  </g>
+  <text x="330" y="170" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">PALETTE / OUT &amp;H3C9 remap entries</text>
+
+  <!-- Arrow -->
+  <line x1="410" y1="75" x2="440" y2="75" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+  <!-- Screen -->
+  <text x="560" y="22" fill="#39FF14" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Screen (RGB)</text>
+  <g stroke="#061018" stroke-width="1">
+    <!-- row 0 -->
+    <rect x="470" y="35" width="28" height="28" fill="#FFFFFF"/>
+    <rect x="498" y="35" width="28" height="28" fill="#FFFFFF"/>
+    <rect x="526" y="35" width="28" height="28" fill="#0000AA"/>
+    <rect x="554" y="35" width="28" height="28" fill="#00AAAA"/>
+    <rect x="582" y="35" width="28" height="28" fill="#000000"/>
+    <!-- row 1 -->
+    <rect x="470" y="63" width="28" height="28" fill="#FFFFFF"/>
+    <rect x="498" y="63" width="28" height="28" fill="#00AAAA"/>
+    <rect x="526" y="63" width="28" height="28" fill="#00AAAA"/>
+    <rect x="554" y="63" width="28" height="28" fill="#0000AA"/>
+    <rect x="582" y="63" width="28" height="28" fill="#000000"/>
+    <!-- row 2 -->
+    <rect x="470" y="91" width="28" height="28" fill="#000000"/>
+    <rect x="498" y="91" width="28" height="28" fill="#000000"/>
+    <rect x="526" y="91" width="28" height="28" fill="#0000AA"/>
+    <rect x="554" y="91" width="28" height="28" fill="#FFFFFF"/>
+    <rect x="582" y="91" width="28" height="28" fill="#FFFFFF"/>
+  </g>
+  <text x="560" y="140" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">indices resolve to colors</text>
+  <text x="560" y="152" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">only at display time</text>
+</svg>
diff --git a/Documentation/video/text-cell.svg b/Documentation/video/text-cell.svg
new file mode 100644 (file)
index 0000000..2908525
--- /dev/null
@@ -0,0 +1,58 @@
+<svg viewBox="0 0 700 230" width="700" height="230" xmlns="http://www.w3.org/2000/svg">
+  <rect width="700" height="230" fill="#061018"/>
+
+  <!-- 8x8 glyph cell: letter A, whole cell painted -->
+  <text x="120" y="22" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">PRINT "A" paints the whole 8x8 cell</text>
+  <g stroke="#061018" stroke-width="1">
+    <!-- row 0: 00111100 -->
+    <rect x="40" y="35" width="16" height="16" fill="#000044"/><rect x="56" y="35" width="16" height="16" fill="#000044"/>
+    <rect x="72" y="35" width="16" height="16" fill="#55FFFF"/><rect x="88" y="35" width="16" height="16" fill="#55FFFF"/>
+    <rect x="104" y="35" width="16" height="16" fill="#55FFFF"/><rect x="120" y="35" width="16" height="16" fill="#55FFFF"/>
+    <rect x="136" y="35" width="16" height="16" fill="#000044"/><rect x="152" y="35" width="16" height="16" fill="#000044"/>
+    <!-- row 1: 01100110 -->
+    <rect x="40" y="51" width="16" height="16" fill="#000044"/><rect x="56" y="51" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="51" width="16" height="16" fill="#55FFFF"/><rect x="88" y="51" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="51" width="16" height="16" fill="#000044"/><rect x="120" y="51" width="16" height="16" fill="#55FFFF"/>
+    <rect x="136" y="51" width="16" height="16" fill="#55FFFF"/><rect x="152" y="51" width="16" height="16" fill="#000044"/>
+    <!-- row 2: 11000011 -->
+    <rect x="40" y="67" width="16" height="16" fill="#55FFFF"/><rect x="56" y="67" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="67" width="16" height="16" fill="#000044"/><rect x="88" y="67" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="67" width="16" height="16" fill="#000044"/><rect x="120" y="67" width="16" height="16" fill="#000044"/>
+    <rect x="136" y="67" width="16" height="16" fill="#55FFFF"/><rect x="152" y="67" width="16" height="16" fill="#55FFFF"/>
+    <!-- row 3: 11000011 -->
+    <rect x="40" y="83" width="16" height="16" fill="#55FFFF"/><rect x="56" y="83" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="83" width="16" height="16" fill="#000044"/><rect x="88" y="83" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="83" width="16" height="16" fill="#000044"/><rect x="120" y="83" width="16" height="16" fill="#000044"/>
+    <rect x="136" y="83" width="16" height="16" fill="#55FFFF"/><rect x="152" y="83" width="16" height="16" fill="#55FFFF"/>
+    <!-- row 4: 11111111 -->
+    <rect x="40" y="99" width="16" height="16" fill="#55FFFF"/><rect x="56" y="99" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="99" width="16" height="16" fill="#55FFFF"/><rect x="88" y="99" width="16" height="16" fill="#55FFFF"/>
+    <rect x="104" y="99" width="16" height="16" fill="#55FFFF"/><rect x="120" y="99" width="16" height="16" fill="#55FFFF"/>
+    <rect x="136" y="99" width="16" height="16" fill="#55FFFF"/><rect x="152" y="99" width="16" height="16" fill="#55FFFF"/>
+    <!-- row 5: 11000011 -->
+    <rect x="40" y="115" width="16" height="16" fill="#55FFFF"/><rect x="56" y="115" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="115" width="16" height="16" fill="#000044"/><rect x="88" y="115" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="115" width="16" height="16" fill="#000044"/><rect x="120" y="115" width="16" height="16" fill="#000044"/>
+    <rect x="136" y="115" width="16" height="16" fill="#55FFFF"/><rect x="152" y="115" width="16" height="16" fill="#55FFFF"/>
+    <!-- row 6: 11000011 -->
+    <rect x="40" y="131" width="16" height="16" fill="#55FFFF"/><rect x="56" y="131" width="16" height="16" fill="#55FFFF"/>
+    <rect x="72" y="131" width="16" height="16" fill="#000044"/><rect x="88" y="131" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="131" width="16" height="16" fill="#000044"/><rect x="120" y="131" width="16" height="16" fill="#000044"/>
+    <rect x="136" y="131" width="16" height="16" fill="#55FFFF"/><rect x="152" y="131" width="16" height="16" fill="#55FFFF"/>
+    <!-- row 7: 00000000 -->
+    <rect x="40" y="147" width="16" height="16" fill="#000044"/><rect x="56" y="147" width="16" height="16" fill="#000044"/>
+    <rect x="72" y="147" width="16" height="16" fill="#000044"/><rect x="88" y="147" width="16" height="16" fill="#000044"/>
+    <rect x="104" y="147" width="16" height="16" fill="#000044"/><rect x="120" y="147" width="16" height="16" fill="#000044"/>
+    <rect x="136" y="147" width="16" height="16" fill="#000044"/><rect x="152" y="147" width="16" height="16" fill="#000044"/>
+  </g>
+
+  <!-- Legend -->
+  <rect x="230" y="45" width="16" height="16" fill="#55FFFF"/>
+  <text x="254" y="58" fill="#ccc" font-size="11" font-family="monospace">glyph stroke → foreground color</text>
+  <rect x="230" y="75" width="16" height="16" fill="#000044" stroke="#445566"/>
+  <text x="254" y="88" fill="#ccc" font-size="11" font-family="monospace">everything else → background color</text>
+  <text x="230" y="125" fill="#666" font-size="10" font-family="monospace">There is no transparent text: every</text>
+  <text x="230" y="139" fill="#666" font-size="10" font-family="monospace">cell pixel is written, in every mode.</text>
+  <text x="230" y="163" fill="#666" font-size="10" font-family="monospace">Printing spaces therefore erases</text>
+  <text x="230" y="177" fill="#666" font-size="10" font-family="monospace">whatever was on screen before.</text>
+</svg>
diff --git a/Documentation/video/video-pages.svg b/Documentation/video/video-pages.svg
new file mode 100644 (file)
index 0000000..7b3af80
--- /dev/null
@@ -0,0 +1,42 @@
+<svg viewBox="0 0 700 190" width="700" height="190" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+    <marker id="arrowhead2" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#39FF14"/>
+    </marker>
+  </defs>
+  <rect width="700" height="190" fill="#061018"/>
+
+  <!-- Program -->
+  <rect x="20" y="70" width="110" height="36" rx="3" fill="rgba(200,80,140,0.15)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="75" y="92" fill="#c05088" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">BASIC program</text>
+
+  <!-- Pages -->
+  <rect x="200" y="25" width="140" height="34" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="270" y="46" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Page 0</text>
+  <rect x="200" y="72" width="140" height="34" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="270" y="93" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Page 1 (active)</text>
+  <rect x="200" y="119" width="140" height="34" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="270" y="140" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Page 2</text>
+
+  <!-- Monitor -->
+  <rect x="450" y="25" width="140" height="60" rx="3" fill="rgba(57,255,20,0.15)" stroke="#39FF14" stroke-width="1.5"/>
+  <text x="520" y="50" fill="#39FF14" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Monitor</text>
+  <text x="520" y="66" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">visual page (0)</text>
+
+  <!-- Arrows -->
+  <line x1="130" y1="88" x2="195" y2="88" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <text x="162" y="78" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">drawing</text>
+  <line x1="340" y1="42" x2="445" y2="50" stroke="#39FF14" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <text x="392" y="36" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">displayed</text>
+
+  <!-- PCOPY -->
+  <line x1="360" y1="106" x2="360" y2="42" stroke="#c8a028" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arrowhead)"/>
+  <text x="440" y="106" fill="#c8a028" font-size="10" font-family="monospace">PCOPY 1, 0: instant page copy</text>
+
+  <text x="270" y="180" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">draw on a hidden page, flip visual page — flicker-free animation (SCREEN 7–10)</text>
+</svg>
diff --git a/README.org b/README.org
new file mode 100644 (file)
index 0000000..0ac292c
--- /dev/null
@@ -0,0 +1,132 @@
+* 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).
diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA
new file mode 100755 (executable)
index 0000000..304bf94
--- /dev/null
@@ -0,0 +1,54 @@
+#!/bin/bash
+
+# This script launches IntelliJ IDEA with the current project
+# directory. The script is designed to be run by double-clicking it in
+# the GNOME Nautilus file manager.
+
+# First, we change the current working directory to the directory of
+# the script.
+
+# "${0%/*}" gives us the path of the script itself, without the
+# script's filename.
+
+# This command basically tells the system "change the current
+# directory to the directory containing this script".
+
+cd "${0%/*}"
+
+# Then, we move up one directory level.
+# The ".." tells the system to go to the parent directory of the current directory.
+# This is done because we assume that the project directory is one level up from the script.
+cd ..
+
+# Now, we use the 'setsid' command to start a new session and run
+# IntelliJ IDEA in the background. 'setsid' is a UNIX command that
+# runs a program in a new session.
+
+# The command 'idea .' opens IntelliJ IDEA with the current directory
+# as the project directory.  The '&' at the end is a UNIX command that
+# runs the process in the background.  The '> /dev/null' part tells
+# the system to redirect all output (both stdout and stderr, denoted
+# by '&') that would normally go to the terminal to go to /dev/null
+# instead, which is a special file that discards all data written to
+# it.
+
+setsid idea . &>/dev/null &
+
+# The 'disown' command is a shell built-in that removes a shell job
+# from the shell's active list. Therefore, the shell will not send a
+# SIGHUP to this particular job when the shell session is terminated.
+
+# '-h' option specifies that if the shell receives a SIGHUP, it also
+# doesn't send a SIGHUP to the job.
+
+# '$!' is a shell special parameter that expands to the process ID of
+# the most recent background job.
+disown -h $!
+
+
+sleep 2
+
+# Finally, we use the 'exit' command to terminate the shell script.
+# This command tells the system to close the terminal window after
+# IntelliJ IDEA has been opened.
+exit
diff --git a/Tools/Update web site b/Tools/Update web site
new file mode 100755 (executable)
index 0000000..d5b8f82
--- /dev/null
@@ -0,0 +1,80 @@
+#!/bin/bash
+cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi;
+
+cd ..
+
+# Function to export org to html using emacs in batch mode
+export_org_to_html() {
+    local org_file=$1
+    local dir=$(dirname "$org_file")
+    local base=$(basename "$org_file" .org)
+    (
+        cd "$dir" || return 1
+        local html_file="${base}.html"
+        if [ -f "$html_file" ]; then
+            rm -f "$html_file"
+        fi
+        echo "Exporting: $org_file → $dir/$html_file"
+        emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill
+        if [ $? -eq 0 ]; then
+            echo "✓ Successfully exported $org_file"
+        else
+            echo "✗ Failed to export $org_file"
+            return 1
+        fi
+    )
+}
+
+export_org_files_to_html() {
+    echo "🔍 Searching for .org files in Documentation/ ..."
+    echo "======================================="
+
+    mapfile -t ORG_FILES < <(find Documentation -type f -name "*.org" | sort)
+
+    if [ ${#ORG_FILES[@]} -eq 0 ]; then
+        echo "❌ No .org files found!"
+        return 1
+    fi
+
+    echo "Found ${#ORG_FILES[@]} .org file(s):"
+    printf '%s\n' "${ORG_FILES[@]}"
+    echo "======================================="
+
+    SUCCESS_COUNT=0
+    FAILED_COUNT=0
+
+    for org_file in "${ORG_FILES[@]}"; do
+        export_org_to_html "$org_file"
+        if [ $? -eq 0 ]; then
+            ((SUCCESS_COUNT++))
+        else
+            ((FAILED_COUNT++))
+        fi
+    done
+
+    echo "======================================="
+    echo "📊 SUMMARY:"
+    echo "   ✓ Successful: $SUCCESS_COUNT"
+    echo "   ✗ Failed:     $FAILED_COUNT"
+    echo "   Total:        $((SUCCESS_COUNT + FAILED_COUNT))"
+    echo ""
+}
+
+# Publish Emacs org-mode files into HTML format
+export_org_files_to_html
+
+# Upload assembled documentation to server
+SERVER_DIR="n0@www3.svjatoslav.eu:/mnt/big/projects/crtbasic/"
+
+echo "📤 Uploading to $SERVER_DIR ..."
+rsync -avz --delete -e 'ssh -p 10006' Documentation/ "$SERVER_DIR"
+
+if [ $? -eq 0 ]; then
+    echo "✓ Upload completed successfully!"
+else
+    echo "✗ Upload failed!"
+fi
+
+echo ""
+echo "Press ENTER to close this window."
+read
diff --git a/install.sh b/install.sh
new file mode 100755 (executable)
index 0000000..6a61c95
--- /dev/null
@@ -0,0 +1,64 @@
+#!/bin/sh
+# CRT Basic installer: builds the jar and installs a `crtbasic` command.
+#
+# Default install location is per-user (~/.local); override with:
+#   CRT_BASIC_PREFIX=/usr/local ./install.sh        (needs write permission)
+#
+# After install, ensure $CRT_BASIC_PREFIX/bin is on your PATH.
+
+set -e
+
+PREFIX="${CRT_BASIC_PREFIX:-$HOME/.local}"
+PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "Building CRT Basic..."
+cd "$PROJECT_DIR"
+mvn -q package
+
+JAR="$(ls target/crt-basic-*-SNAPSHOT.jar | head -n 1)"
+if [ ! -f "$JAR" ]; then
+    echo "error: jar not found after build" >&2
+    exit 1
+fi
+
+mkdir -p "$PREFIX/share/crtbasic" "$PREFIX/bin"
+cp "$JAR" "$PREFIX/share/crtbasic/crt-basic.jar"
+
+cat > "$PREFIX/bin/crtbasic" <<EOF
+#!/bin/sh
+# CRT Basic launcher — generated by install.sh
+exec java -jar "$PREFIX/share/crtbasic/crt-basic.jar" "\$@"
+EOF
+chmod +x "$PREFIX/bin/crtbasic"
+
+# Desktop integration: appear in "Open With" for .bas files (text/x-basic).
+APPS_DIR="$HOME/.local/share/applications"
+mkdir -p "$APPS_DIR"
+cat > "$APPS_DIR/crtbasic.desktop" <<EOF
+[Desktop Entry]
+Version=1.0
+Type=Application
+Name=CRT Basic
+GenericName=BASIC Interpreter
+Comment=Run BASIC programs on a virtual VGA
+Exec=$PREFIX/bin/crtbasic %f
+Terminal=false
+Categories=Development;IDE;
+MimeType=text/x-basic;
+Keywords=basic;dos;interpreter;
+StartupNotify=true
+EOF
+chmod 644 "$APPS_DIR/crtbasic.desktop"
+update-desktop-database "$APPS_DIR" 2>/dev/null || true
+
+echo "Installed:"
+echo "  jar:      $PREFIX/share/crtbasic/crt-basic.jar"
+echo "  launcher: $PREFIX/bin/crtbasic"
+echo "  desktop:  $APPS_DIR/crtbasic.desktop (Open With for .bas)"
+
+case ":$PATH:" in
+    *":$PREFIX/bin:"*) ;;
+    *) echo "note: $PREFIX/bin is not on your PATH — add it to use 'crtbasic' directly." ;;
+esac
+
+echo "Try: crtbasic path/to/program.bas"
diff --git a/pom.xml b/pom.xml
new file mode 100644 (file)
index 0000000..d3f9900
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>eu.svjatoslav</groupId>
+    <artifactId>crt-basic</artifactId>
+    <version>1-SNAPSHOT</version>
+    <packaging>jar</packaging>
+
+    <name>CRT Basic</name>
+    <description>
+        BASIC interpreter: compiles .bas source to an internal
+        bytecode and executes it on a small VM. Can be driven
+        programmatically (headless video, keyboard injection, frame
+        capture) so an AI agent can write, run, observe and test
+        BASIC programs end to end.
+    </description>
+
+    <properties>
+        <maven.compiler.release>21</maven.compiler.release>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <version>5.11.4</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>3.5.2</version>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <version>3.4.2</version>
+                <configuration>
+                    <archive>
+                        <manifest>
+                            <mainClass>eu.svjatoslav.crtbasic.Main</mainClass>
+                        </manifest>
+                    </archive>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/src/main/java/eu/svjatoslav/crtbasic/Cp437.java b/src/main/java/eu/svjatoslav/crtbasic/Cp437.java
new file mode 100644 (file)
index 0000000..454922b
--- /dev/null
@@ -0,0 +1,48 @@
+package eu.svjatoslav.crtbasic;
+
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CP437 &harr; Unicode mapping for the language boundary. Strings in this
+ * dialect are byte strings: ASC returns the CP437 byte value of a character and
+ * CHR$ turns a byte into its CP437 character. Sources and data files are
+ * decoded from CP437 on load, so high characters (e.g. 'ü' = byte 129)
+ * must map back to their original byte values here.
+ */
+public final class Cp437 {
+
+    private static final Charset CHARSET = Charset.forName("IBM437");
+    /** Unicode char → CP437 byte value. */
+    private static final Map<Character, Integer> CHAR_TO_BYTE = new HashMap<>();
+
+    static {
+        for (int b = 128; b < 256; b++) {
+            final String s = new String(new byte[]{(byte) b}, CHARSET);
+            if (s.length() == 1) {
+                CHAR_TO_BYTE.putIfAbsent(s.charAt(0), b);
+            }
+        }
+    }
+
+    private Cp437() {
+        // Utility class.
+    }
+
+    /** CP437 byte value of a character (identity for ASCII; '?' when unmappable). */
+    public static int byteOf(final char c) {
+        if (c < 128) {
+            return c;
+        }
+        final Integer b = CHAR_TO_BYTE.get(c);
+        return b != null ? b : (c <= 255 ? c : '?');
+    }
+
+    /** The CP437 character for a byte value (0-255). */
+    public static String charOf(final int byteValue) {
+        final int b = byteValue & 0xFF;
+        return b < 128 ? String.valueOf((char) b)
+                : new String(new byte[]{(byte) b}, CHARSET);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/Main.java b/src/main/java/eu/svjatoslav/crtbasic/Main.java
new file mode 100644 (file)
index 0000000..939c268
--- /dev/null
@@ -0,0 +1,190 @@
+package eu.svjatoslav.crtbasic;
+
+import eu.svjatoslav.crtbasic.ast.Ast;
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.frontend.SwingFrontend;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.interp.Interpreter;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import javax.swing.JOptionPane;
+import javax.swing.SwingUtilities;
+
+/**
+ * Command line entry point of CRT Basic.
+ *
+ * <p>Pipeline: .bas source -&gt; lexer -&gt; parser (AST) -&gt; tree-walk
+ * interpreter (interim; bytecode VM later) against a virtual VGA device.</p>
+ *
+ * <p>Two modes:</p>
+ * <ul>
+ *   <li>{@code crtbasic program.bas} — interactive Swing window, keys go to
+ *       the program via {@code INKEY$}/{@code INPUT}.</li>
+ *   <li>{@code crtbasic program.bas --headless [--screenshot=out.png]
+ *       [--raw=out.raw] [--steps=N]} — agent drive path: run offscreen
+ *       (optionally capped at N statements), then dump the framebuffer.</li>
+ * </ul>
+ */
+public final class Main {
+
+    /** .bas files are DOS artifacts: CP437 charset, CRLF line endings. */
+    private static final Charset SOURCE_CHARSET = Charset.forName("IBM437");
+
+    private Main() {
+        // Utility class, not meant to be instantiated.
+    }
+
+    public static void main(final String[] args) {
+        if (args.length == 0) {
+            usage();
+        }
+        final Path programPath = Path.of(args[0]);
+        boolean headless = false;
+        Path screenshot = null;
+        Path raw = null;
+        long steps = Long.MAX_VALUE;
+        String commandLine = "";
+        for (int i = 1; i < args.length; i++) {
+            final String arg = args[i];
+            if (arg.equals("--headless")) {
+                headless = true;
+            } else if (arg.startsWith("--screenshot=")) {
+                screenshot = Path.of(arg.substring("--screenshot=".length()));
+            } else if (arg.startsWith("--raw=")) {
+                raw = Path.of(arg.substring("--raw=".length()));
+            } else if (arg.startsWith("--steps=")) {
+                steps = Long.parseLong(arg.substring("--steps=".length()));
+            } else if (arg.startsWith("--command=")) {
+                commandLine = arg.substring("--command=".length());
+            } else {
+                System.err.println("Unknown option: " + arg);
+                usage();
+            }
+        }
+        if (!headless && (screenshot != null || raw != null || steps != Long.MAX_VALUE)) {
+            System.err.println("--screenshot/--raw/--steps require --headless");
+            usage();
+        }
+
+        final String source;
+        final List<Ast.Stmt> program;
+        try {
+            source = Files.readString(programPath, SOURCE_CHARSET);
+            program = Parser.parse(source);
+        } catch (final IOException e) {
+            fatal(headless, programPath, "Cannot read " + programPath + ": " + e.getMessage());
+            return;
+        } catch (final RuntimeException e) {
+            fatal(headless, programPath, e.getMessage());
+            return;
+        }
+
+        final VgaDevice vga = new VgaDevice();
+        final KeyboardQueue keys = new KeyboardQueue();
+        final Interpreter interpreter = new Interpreter(vga, keys, new SoundQueue());
+        interpreter.setMaxStatements(steps);
+        interpreter.setSourceLines(source.split("\n", -1));
+        interpreter.setBlockOnInput(!headless);
+        interpreter.setCommandLine(commandLine);
+        // OPEN resolves relative paths against the .bas file's directory.
+        final Path parent = programPath.toAbsolutePath().getParent();
+        interpreter.setBaseDirectory(parent == null ? Path.of("").toAbsolutePath() : parent);
+
+        if (headless) {
+            runHeadless(interpreter, program, screenshot, raw);
+        } else {
+            runInteractive(interpreter, program, vga, keys, programPath);
+        }
+    }
+
+    private static void runHeadless(final Interpreter interpreter, final List<Ast.Stmt> program,
+                                    final Path screenshot, final Path raw) {
+        try {
+            interpreter.run(program);
+        } catch (final RuntimeException e) {
+            System.err.println(e.getMessage());
+        }
+        System.err.println("Statements executed: " + interpreter.statementsExecuted());
+        try {
+            if (screenshot != null) {
+                interpreter.video().dumpPng(screenshot);
+                System.err.println("Screenshot: " + screenshot);
+            }
+            if (raw != null) {
+                // All video pages concatenated, chunky palette indices —
+                // suitable for byte-level comparison between runs.
+                final java.io.ByteArrayOutputStream pages = new java.io.ByteArrayOutputStream();
+                for (int page = 0; page < interpreter.video().framebuffer().pageCount(); page++) {
+                    pages.write(interpreter.video().framebuffer().pageData(page), 0,
+                            interpreter.video().framebuffer().pageData(page).length);
+                }
+                Files.write(raw, pages.toByteArray());
+                System.err.println("Raw framebuffer: " + raw);
+            }
+        } catch (final IOException e) {
+            System.err.println("Dump failed: " + e.getMessage());
+            System.exit(1);
+        }
+    }
+
+    private static void runInteractive(final Interpreter interpreter, final List<Ast.Stmt> program,
+                                       final VgaDevice vga, final KeyboardQueue keys,
+                                       final Path programPath) {
+        SwingUtilities.invokeLater(() ->
+                new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName()).show());
+        final Thread programThread = new Thread(() -> {
+            try {
+                interpreter.run(program);
+            } catch (final RuntimeException e) {
+                System.err.println(e.getMessage());
+                showError(programPath, e.getMessage());
+            }
+        }, "basic-program");
+        programThread.setDaemon(true);
+        programThread.start();
+    }
+
+    /**
+     * Reports a fatal startup error: stderr in headless mode, an error dialog
+     * when launched from the desktop (where nobody sees stderr).
+     */
+    private static void fatal(final boolean headless, final Path programPath, final String message) {
+        System.err.println(message);
+        if (!headless) {
+            // Block until the dialog is dismissed — System.exit must not
+            // outrun the EDT.
+            try {
+                SwingUtilities.invokeAndWait(() -> errorDialog(programPath, message));
+            } catch (final Exception e) {
+                // Dialog failed; stderr already has the message.
+            }
+        }
+        System.exit(1);
+    }
+
+    private static void showError(final Path programPath, final String message) {
+        SwingUtilities.invokeLater(() -> errorDialog(programPath, message));
+    }
+
+    private static void errorDialog(final Path programPath, final String message) {
+        JOptionPane.showMessageDialog(null,
+                message == null ? "Unknown error" : message,
+                "CRT Basic - " + programPath.getFileName(),
+                JOptionPane.ERROR_MESSAGE);
+    }
+
+    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
+                  --headless     run offscreen; dump framebuffer afterwards
+                  --steps=N      stop after N statements (default: unlimited)
+                  --command=text what COMMAND$ returns (default: empty)""");
+        System.exit(1);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
new file mode 100644 (file)
index 0000000..80c748b
--- /dev/null
@@ -0,0 +1,334 @@
+package eu.svjatoslav.crtbasic.ast;
+
+import java.util.List;
+
+/**
+ * AST node types for the supported BASIC subset. Statements and expressions
+ * are records; every node carries the 1-based source line for error
+ * reporting.
+ */
+public final class Ast {
+
+    private Ast() {
+        // Namespace class.
+    }
+
+    public sealed interface Stmt {
+        int line();
+    }
+
+    /** {@code SCREEN mode[, [colorburst][, [activePage][, visualPage]]]} — null slot = omitted. */
+    public record ScreenStmt(Expr mode, Expr activePage, Expr visualPage, int line) implements Stmt {
+    }
+
+    /** {@code PCOPY sourcePage, targetPage} */
+    public record PcopyStmt(Expr sourcePage, Expr targetPage, int line) implements Stmt {
+    }
+
+    /** {@code LOCATE row, col} */
+    public record LocateStmt(Expr row, Expr col, int line) implements Stmt {
+    }
+
+    /**
+     * {@code PRINT item;item,item} — items are expressions plus the
+     * separator that follows them (';' compact, ',' print zone).
+     * {@code newline} is false when the statement ends with ; or ,.
+     */
+    public record PrintStmt(List<Item> items, boolean newline, int line) implements Stmt {
+        public record Item(Expr expr, char separatorAfter) {
+        }
+    }
+
+    /** {@code CLS} */
+    public record ClsStmt(int line) implements Stmt {
+    }
+
+    /**
+     * {@code RANDOMIZE [seed]} — reseeds the RND generator. A null seed
+     * means RANDOMIZE TIMER (seeded from the clock).
+     */
+    public record RandomizeStmt(Expr seed, int line) implements Stmt {
+    }
+
+    /** {@code OPEN path FOR INPUT AS #channel} (only INPUT mode is modeled). */
+    public record OpenStmt(Expr path, Expr channel, int line) implements Stmt {
+    }
+
+    /** {@code CLOSE [#channel]} — a null channel closes all files. */
+    public record CloseStmt(Expr channel, int line) implements Stmt {
+    }
+
+    /** {@code LINE INPUT #channel, target} — reads one line from an open file. */
+    public record LineInputStmt(Expr channel, ReadTarget target, int line) implements Stmt {
+    }
+
+    /**
+     * {@code INPUT #channel, var, array(i), ...} — reads comma-delimited data
+     * items from an open file (console INPUT is not modeled).
+     */
+    public record InputFileStmt(Expr channel, List<ReadTarget> targets, int line) implements Stmt {
+    }
+
+    /**
+     * {@code ERASE name, ...} — frees the array(s) held by the named
+     * variables, returning the slots to their never-DIMmed state.
+     */
+    public record EraseStmt(List<String> names, int line) implements Stmt {
+    }
+
+    /** {@code ON ERROR GOTO label} — registers a runtime-error handler;
+     * label "0" disables trapping. */
+    public record OnErrorStmt(String label, int line) implements Stmt {
+    }
+
+    /** {@code RESUME [NEXT | label]} — a null label with next=false retries
+     * the erroring statement; next=true continues after it. */
+    public record ResumeStmt(String label, boolean next, int line) implements Stmt {
+    }
+
+    /** {@code DATA value, ...} — constants collected module-wide in program
+     * order; executing the statement itself is a no-op. */
+    public record DataStmt(List<Expr> values, int line) implements Stmt {
+    }
+
+    /** One READ target: a scalar variable or an array element. */
+    public record ReadTarget(String name, List<Expr> indices) {
+    }
+
+    /** {@code READ var, array(i), ...} — consumes DATA values in order. */
+    public record ReadStmt(List<ReadTarget> targets, int line) implements Stmt {
+    }
+
+    /** {@code VIEW PRINT [top TO bottom]} — sets the text scroll viewport.
+     * Rows are null for the bare form (whole screen). */
+    public record ViewPrintStmt(Expr top, Expr bottom, int line) implements Stmt {
+    }
+
+    /** {@code COLOR foreground[, background]} */
+    public record ColorStmt(Expr foreground, Expr background, int line) implements Stmt {
+    }
+
+    /** {@code PSET (x, y)[, color]} */
+    public record PsetStmt(Expr x, Expr y, Expr color, int line) implements Stmt {
+    }
+
+    /** {@code LINE (x1, y1)-(x2, y2)[, color[, B|BF]]} */
+    public record LineStmt(Expr x1, Expr y1, Expr x2, Expr y2, Expr color,
+                           boolean box, boolean fill, int line) implements Stmt {
+    }
+
+    /** {@code CIRCLE (x, y), radius[, color[, start[, end[, aspect]]]]} — null slot = omitted */
+    public record CircleStmt(Expr x, Expr y, Expr radius, Expr color,
+                             Expr start, Expr end, Expr aspect, int line) implements Stmt {
+    }
+
+    /** {@code SOUND freq, duration} — parsed, executed as no-op for now */
+    public record SoundStmt(Expr frequency, Expr duration, int line) implements Stmt {
+    }
+
+    /**
+     * {@code SLEEP [seconds]} — waits the given whole seconds; a null
+     * seconds waits for a keypress. Any keypress ends the wait early.
+     */
+    public record SleepStmt(Expr seconds, int line) implements Stmt {
+    }
+
+    /**
+     * {@code variable = expression} or {@code array(i, j) = expression} —
+     * {@code indices} is empty for scalar assignment.
+     */
+    public record AssignStmt(String variable, List<Expr> indices, Expr value, int line) implements Stmt {
+    }
+
+    /** {@code FOR v = a TO b [STEP s] ... NEXT [v]} — body is nested */
+    public record ForStmt(String variable, Expr from, Expr to, Expr step,
+                          List<Stmt> body, int line) implements Stmt {
+    }
+
+    /** Single-line {@code IF cond THEN stmts [ELSE stmts]} */
+    public record IfStmt(Expr condition, List<Stmt> thenBody,
+                         List<Stmt> elseBody, int line) implements Stmt {
+    }
+
+    /** {@code WHILE cond ... WEND} */
+    public record WhileStmt(Expr condition, List<Stmt> body, int line) implements Stmt {
+    }
+
+    /**
+     * {@code DO [WHILE|UNTIL cond] ... LOOP [WHILE|UNTIL cond]} — a null
+     * condition means that end of the loop is unconditional.
+     */
+    public record DoStmt(Expr preCondition, boolean preIsUntil, Expr postCondition,
+                         boolean postIsUntil, List<Stmt> body, int line) implements Stmt {
+    }
+
+    /** {@code END} */
+    public record EndStmt(int line) implements Stmt {
+    }
+
+    /**
+     * Numeric or alphanumeric label marking a jump target for
+     * {@code GOTO}/{@code GOSUB} (e.g. the {@code 1} in {@code 1 IF ...}).
+     * Executes as a no-op.
+     */
+    public record LabelStmt(String label, int line) implements Stmt {
+    }
+
+    /** {@code GOTO label} — target resolved within the enclosing procedure */
+    public record GotoStmt(String label, 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 {
+    }
+
+    /**
+     * {@code FUNCTION name (param, ...) ... END FUNCTION} — definition, not
+     * executed inline. The return value is whatever was last assigned to
+     * the function's own name inside the body (0 / "" when never assigned).
+     */
+    public record FunctionStmt(String name, List<String> params, List<Stmt> body, int line) implements Stmt {
+    }
+
+    /**
+     * {@code SELECT CASE subject ... CASE v1, v2 ... CASE ELSE ... END SELECT}.
+     * A branch matches when any of its values equals the subject.
+     */
+    public record SelectStmt(Expr subject, List<CaseBranch> cases,
+                             List<Stmt> elseBody, int line) implements Stmt {
+    }
+
+    /** One {@code CASE v1, v2 TO v3, ...} branch of a SELECT statement. */
+    public record CaseBranch(List<CaseTest> tests, List<Stmt> body) {
+    }
+
+    /**
+     * One test inside a CASE branch: either a single value (equality) or a
+     * {@code lo TO hi} inclusive range (hi != null).
+     */
+    public record CaseTest(Expr lo, Expr hi) {
+    }
+
+    /**
+     * SUB invocation as a statement: {@code Name arg, arg, ...} or
+     * {@code CALL Name(arg, ...)}. Arguments that are plain variables are
+     * passed by reference (the dialect default), expressions by value.
+     */
+    public record CallStmt(String name, List<Expr> args, int line) implements Stmt {
+    }
+
+    /** {@code OUT port, value} — hardware port write (VGA DAC implemented) */
+    public record OutStmt(Expr port, Expr value, int line) implements Stmt {
+    }
+
+    /** {@code PAINT (x, y)[, fillColor[, borderColor]]} — flood fill */
+    public record PaintStmt(Expr x, Expr y, Expr fill, Expr border, int line) implements Stmt {
+    }
+
+    /**
+     * {@code DIM [SHARED] name[(bounds)] [AS type], ...} — entries carry
+     * optional subscript bounds; an empty bounds list is a scalar. The AS
+     * type fixes the variable's type (an {@code AS INTEGER} name behaves
+     * as if %-suffixed). SHARED names are visible inside SUBs (resolved
+     * against the global scope).
+     */
+    public record DimStmt(List<DimEntry> entries, boolean shared, int line) implements Stmt {
+    }
+
+    /**
+     * One DIM entry: a name, per-dimension bounds for arrays, and the
+     * optional {@code AS type} name (INTEGER, LONG, SINGLE, DOUBLE,
+     * STRING); null when absent.
+     */
+    public record DimEntry(String name, List<Bound> bounds, String type) {
+    }
+
+    /** Array dimension bounds: {@code low TO high}; null low means 0. */
+    public record Bound(Expr low, Expr high) {
+    }
+
+    /**
+     * {@code GET (x1, y1)-(x2, y2), array[(i, j)]} — captures a screen
+     * rectangle. With offsetIndices the capture is anchored at that array
+     * element (GET/PUT may start mid-array, e.g. one frame slot of
+     * a sprite-sheet array).
+     */
+    public record GetStmt(Expr x1, Expr y1, Expr x2, Expr y2,
+                          String arrayName, List<Expr> offsetIndices, int line) implements Stmt {
+    }
+
+    /**
+     * {@code PUT (x, y), array[(i, j)][, action]} — blits a captured
+     * rectangle back. The default action (none written) is XOR.
+     */
+    public record PutStmt(Expr x, Expr y, String arrayName, List<Expr> offsetIndices,
+                          String action, int line) implements Stmt {
+    }
+
+    /**
+     * {@code MID$(target, start[, length]) = replacement} — overwrites part
+     * of a string variable or array element in place; the target's length
+     * never changes.
+     */
+    public record MidStmt(ReadTarget target, Expr start, Expr length,
+                          Expr replacement, int line) implements Stmt {
+    }
+
+    /**
+     * Recognized but intentionally unsupported statement (e.g. hardware
+     * pokes): parsed and skipped.
+     */
+    public record IgnoredStmt(String what, int line) implements Stmt {
+    }
+
+    /**
+     * {@code DECLARE SUB/FUNCTION name (params)}: no runtime effect, but
+     * carries the declared parameter names (with type suffixes) so calls
+     * can type parameters whose SUB/FUNCTION header used bare names.
+     *
+     * @param what e.g. {@code "SUB DrawLine"} (name as written)
+     */
+    public record DeclareStmt(String what, List<String> params, int line) implements Stmt {
+    }
+
+    /**
+     * {@code DEFINT A-Z} and siblings (DEFSNG/DEFDBL/DEFLNG/DEFSTR): sets
+     * the default type suffix for suffix-less variable names whose first
+     * letter falls in one of the ranges (inclusive, 0-based a..z codes).
+     * Module-wide: applies to main code and all SUBs regardless
+     * of where the statement appears.
+     */
+    public record DefTypeStmt(char suffix, List<int[]> ranges, int line) implements Stmt {
+    }
+
+    // ----------------------------------------------------------- expressions
+
+    public sealed interface Expr {
+        int line();
+    }
+
+    /** Numeric literal; doublePrecision for {@code #}-suffixed or
+     * D-exponent literals, which stay DOUBLE (no SINGLE narrowing). */
+    public record Num(double value, boolean doublePrecision, int line) implements Expr {
+    }
+
+    /** String literal */
+    public record Str(String value, int line) implements Expr {
+    }
+
+    /** Variable reference (name includes the type suffix if written) */
+    public record Var(String name, int line) implements Expr {
+    }
+
+    /** Function call: POINT(x, y), POS(0), CSRLIN, CHR$(n), ... */
+    public record Call(String name, List<Expr> args, int line) implements Expr {
+    }
+
+    /** Binary operation: + - * / \ ^ MOD AND OR = <> < <= > >= */
+    public record Binary(String op, Expr left, Expr right, int line) implements Expr {
+    }
+
+    /** Unary operation: -expr, NOT expr */
+    public record Unary(String op, Expr operand, int line) implements Expr {
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java b/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java
new file mode 100644 (file)
index 0000000..89e5a19
--- /dev/null
@@ -0,0 +1,50 @@
+package eu.svjatoslav.crtbasic.audio;
+
+/**
+ * SOUND timing model.
+ *
+ * <p>SOUND note plays in the background while the program
+ * continues — but the note queue is only ONE note deep: the next SOUND
+ * blocks until the previous note has finished. This makes
+ * {@code SOUND 0, n} the classic BASIC delay idiom, and a single
+ * {@code SOUND 0, n} per animation loop a frame-rate limiter at
+ * 18.2/n FPS.</p>
+ *
+ * <p>Durations are in PC timer ticks — 18.2 ticks per second, NOT
+ * seconds — and fractional ticks are honored ({@code SOUND 0, .4} delays
+ * for 0.4 ticks, about 22 ms). Frequency does not affect timing; 0 Hz is
+ * an inaudible rest that takes its full duration like any other note.</p>
+ *
+ * <p>No actual audio is produced: this class models timing only.</p>
+ */
+public final class SoundQueue {
+
+    /** SOUND durations are measured in these ticks per second. */
+    private static final double TICKS_PER_SECOND = 18.2;
+    private static final double NANOS_PER_TICK = 1_000_000_000.0 / TICKS_PER_SECOND;
+
+    /** When the currently playing note finishes (System.nanoTime clock). */
+    private long noteFinishNanos;
+
+    /**
+     * {@code SOUND frequency, duration}: starts a note. Blocks while the
+     * previous note is still playing; returns as soon as the new note
+     * starts (it then keeps "playing" in the background).
+     */
+    public synchronized void sound(final int frequency, final double durationTicks) {
+        long now = System.nanoTime();
+        // Compare, don't subtract: with the initial 0 sentinel a naive
+        // (finish - now) overflows to a huge positive value.
+        if (noteFinishNanos > now) {
+            final long waitNanos = noteFinishNanos - now;
+            try {
+                wait(waitNanos / 1_000_000, (int) (waitNanos % 1_000_000));
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+            now = System.nanoTime();
+        }
+        noteFinishNanos = now + Math.round(durationTicks * NANOS_PER_TICK);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
new file mode 100644 (file)
index 0000000..9dea4f8
--- /dev/null
@@ -0,0 +1,77 @@
+package eu.svjatoslav.crtbasic.frontend;
+
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.Timer;
+import javax.swing.WindowConstants;
+
+/**
+ * Interactive frontend: a Swing window showing the live visual page of a
+ * {@link VgaDevice}, scaled with nearest-neighbor (crisp pixels), repainted
+ * 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>
+ */
+public final class SwingFrontend {
+
+    /** Repaint interval in milliseconds (~30 fps). */
+    private static final int REPAINT_MS = 33;
+
+    private final VgaDevice vga;
+    private final JFrame frame;
+
+    @SuppressWarnings("serial")
+    public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, final String title) {
+        vga = vgaDevice;
+        final int scale = vga.mode().pixelWidth() <= 320 ? 3 : 2;
+
+        final JPanel canvas = new JPanel() {
+            @Override
+            protected void paintComponent(final Graphics g) {
+                final Graphics2D g2 = (Graphics2D) g;
+                g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
+                        RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+                g2.drawImage(vga.framebuffer().toImage(vga.visualPage()),
+                        0, 0, getWidth(), getHeight(), null);
+            }
+        };
+        canvas.setPreferredSize(new java.awt.Dimension(
+                vga.mode().pixelWidth() * scale, vga.mode().pixelHeight() * scale));
+        canvas.setFocusable(true);
+        canvas.addKeyListener(new KeyAdapter() {
+            @Override
+            public void keyPressed(final KeyEvent e) {
+                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.setContentPane(canvas);
+        frame.pack();
+        frame.setLocationRelativeTo(null);
+
+        new Timer(REPAINT_MS, e -> canvas.repaint()).start();
+    }
+
+    /** Shows the window. Must be called on or before the program starts. */
+    public void show() {
+        frame.setVisible(true);
+    }
+
+    /** Re-packs the window after a video mode change (new resolution). */
+    public void modeChanged() {
+        frame.pack();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java b/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java
new file mode 100644 (file)
index 0000000..95f642e
--- /dev/null
@@ -0,0 +1,58 @@
+package eu.svjatoslav.crtbasic.input;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Keyboard input queue: the single input path for all frontends.
+ *
+ * <p>Any driver — a Swing window, a headless test, a bot injecting
+ * keystrokes — pushes key codes in; {@code INKEY$}/{@code INPUT}
+ * consume them. Keys are stored as {@link java.awt.event.KeyEvent}
+ * VK codes for the window frontend plus ASCII where applicable; the
+ * mapping to BASIC scancode/ASCII pairs happens at the consumer side
+ * when the VM lands.</p>
+ */
+public final class KeyboardQueue {
+
+    private final BlockingQueue<Key> queue = new LinkedBlockingQueue<>();
+
+    /** One key press: a VK code (as in KeyEvent) and the typed char, if any. */
+    public record Key(int vkCode, char character) {
+        public boolean hasCharacter() {
+            return character != CHAR_NONE;
+        }
+    }
+
+    /** Sentinel for "no character associated" (arrow keys etc.). */
+    public static final char CHAR_NONE = '\0';
+
+    /** Pushes a key press. Called by frontends/drivers. */
+    public void push(final int vkCode, final char character) {
+        queue.add(new Key(vkCode, character));
+    }
+
+    /** Convenience for headless drivers: pushes a typed character. */
+    public void pushChar(final char character) {
+        queue.add(new Key(0, character));
+    }
+
+    /** @return the next key, or null if the queue is empty (INKEY$ style) */
+    public Key poll() {
+        return queue.poll();
+    }
+
+    /** @return the next key, waiting up to the given timeout (INPUT style) */
+    public Key poll(final long timeoutMillis) throws InterruptedException {
+        return queue.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+    }
+
+    public boolean isEmpty() {
+        return queue.isEmpty();
+    }
+
+    public int size() {
+        return queue.size();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
new file mode 100644 (file)
index 0000000..a5ef4d8
--- /dev/null
@@ -0,0 +1,1779 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import eu.svjatoslav.crtbasic.Cp437;
+import eu.svjatoslav.crtbasic.ast.Ast;
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.video.CapturedRegion;
+import eu.svjatoslav.crtbasic.video.ScreenMode;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.awt.event.KeyEvent;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Executes the AST directly (tree-walking).
+ *
+ * <p>INTERIM: the agreed target architecture compiles to stack-based
+ * bytecode for tight pixel loops. This tree-walker exists so that whole
+ * .bas programs run today and semantics can be verified at program
+ * level. The lexer/parser/AST are reused by the bytecode compiler
+ * unchanged; this class is the piece that gets replaced.</p>
+ *
+ * <p>Semantics notes: numeric values are doubles held at SINGLE precision
+ * (the dialect's default type) when stored: variables, array elements, and
+ * numeric literals are narrowed to float32, while expression evaluation
+ * itself runs in double precision and narrows only on assignment.
+ * Variable identity is case-insensitive and
+ * suffix-sensitive ({@code x%} and {@code x} are different variables,
+ * {@code x} and {@code x!} the same); true is -1; PRINT formats numbers
+ * in the classic style (leading space for non-negative, trailing space).</p>
+ */
+public final class Interpreter {
+
+    /**
+     * A variable storage slot. SUB parameters passed as plain variables are
+     * bound to the caller's cell (the dialect passes by reference), so writes
+     * through the parameter are visible to the caller.
+     *
+     * <p>INTEGER variables ({@code %} suffix or DEFINT default) narrow on
+     * store to a 16-bit integer, rounding half-to-even; out-of-range values
+     * raise the Overflow error. Other numerics narrow to SINGLE.</p>
+     */
+    private static final class Cell {
+        /** 0 = SINGLE, 1 = INTEGER (int16), 2 = LONG (int32), 3 = DOUBLE. */
+        private final int kind;
+        private Object value;
+
+        Cell(final int kind, final Object initial) {
+            this.kind = kind;
+            set(initial);
+        }
+
+        /** Stores a value, narrowing numbers to the variable's type. */
+        void set(final Object newValue) {
+            value = newValue instanceof Double d
+                    ? (kind == 1 ? integer(d) : kind == 2 ? longInteger(d)
+                    : kind == 3 ? d : single(d))
+                    : newValue;
+        }
+    }
+
+    /**
+     * A dimensioned array: flat row-major storage with per-dimension
+     * lower bounds ({@code DIM a(0 TO 50, 0 TO 100)}). Numeric arrays start
+     * at 0.0, string arrays at "". INTEGER arrays narrow elements on store.
+     */
+    private static final class BasicArray {
+        private final int[] lows;
+        private final int[] sizes;
+        /** Element kind: 0 = SINGLE, 1 = INTEGER, 2 = LONG, 3 = DOUBLE. */
+        private final int kind;
+        private final Object[] data;
+        /**
+         * GET captures anchored at array elements (flat offsets), for the
+         * {@code GET ..., array(i, j)} sprite-sheet idiom. Captures live
+         * outside {@code data} because numeric elements stay numeric.
+         */
+        private final Map<Integer, CapturedRegion> captures = new HashMap<>();
+
+        BasicArray(final int[] lows, final int[] sizes, final boolean stringArray,
+                final int kind) {
+            this.lows = lows;
+            this.sizes = sizes;
+            this.kind = kind;
+            int total = 1;
+            for (final int size : sizes) {
+                total *= size;
+            }
+            data = new Object[total];
+            Arrays.fill(data, stringArray ? "" : 0.0);
+        }
+
+        /** Narrows a numeric value to the array's element type. */
+        Object narrow(final double element) {
+            return kind == 1 ? integer(element) : kind == 2 ? longInteger(element)
+                    : kind == 3 ? element : single(element);
+        }
+
+        /** @return flat offset for the subscripts, or -1 when out of range */
+        int offset(final int[] subscripts) {
+            if (subscripts.length != sizes.length) {
+                return -1;
+            }
+            int offset = 0;
+            for (int dim = 0; dim < sizes.length; dim++) {
+                final int index = subscripts[dim] - lows[dim];
+                if (index < 0 || index >= sizes[dim]) {
+                    return -1;
+                }
+                offset = offset * sizes[dim] + index;
+            }
+            return offset;
+        }
+    }
+
+    /**
+     * Signals a GOTO jump. Each enclosing block that does not contain the
+     * target label rethrows it, which gives procedure-wide label scope.
+     */
+    private static final class GotoJump extends RuntimeException {
+        private final String label;
+        private final int line;
+
+        GotoJump(final String targetLabel, final int sourceLine) {
+            super(null, null, false, false);
+            label = targetLabel;
+            line = sourceLine;
+        }
+    }
+
+    private final VgaDevice vga;
+    private final KeyboardQueue keys;
+    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<>();
+    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<>();
+    /**
+     * Parameter names from DECLARE statements, keyed by procedure base
+     * name (uppercase, no suffix). When a SUB/FUNCTION header uses bare
+     * parameter names, the DECLARE'd suffixes decide the parameter types.
+     */
+    private final Map<String, List<String>> declaredParams = new HashMap<>();
+    /**
+     * Declared procedure names as written (with type suffix), keyed by
+     * base name. A DECLARE'd FUNCTION suffix types its return value.
+     */
+    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<>();
+    /**
+     * 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.
+     */
+    private final Map<String, Character> dimSuffixes = new HashMap<>();
+    private String[] sourceLines;
+    /** Undelivered tail of an extended (2-byte) keycode being read via INPUT$. */
+    private String pendingKeys = "";
+    /** Interactive mode waits for keys in INPUT$; headless returns immediately. */
+    private boolean blockOnInput = true;
+    /** Value returned by COMMAND$ (the text after the program name). */
+    private String commandLine = "";
+    /**
+     * Open file channels (OPEN ... FOR INPUT AS #n). Each reader carries a
+     * one-line lookahead so EOF(n) can answer without consuming input.
+     */
+    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<>();
+    /** Directory that relative OPEN paths resolve against (the .bas directory). */
+    private java.nio.file.Path baseDirectory = java.nio.file.Path.of("");
+    /**
+     * ON ERROR GOTO target (lowercased label), null when trapping is off.
+     * Trapping is module-global.
+     */
+    private String errorHandler = null;
+    /**
+     * Program counter of the trapped statement within the block that
+     * resolved the handler; RESUME / RESUME NEXT restart from there.
+     * For errors raised inside a SUB this is the CALL statement (classic
+     * interpreters resume inside the SUB — our approximation retries the call).
+     */
+    private int resumePc = -1;
+    /**
+     * Module-wide DATA constants in program order, collected by run();
+     * READ consumes them sequentially via dataIndex.
+     */
+    private final List<Object> dataValues = new ArrayList<>();
+    private int dataIndex;
+    /**
+     * Default type suffix per first letter for suffix-less variable names,
+     * as set by DEFxxx statements (module-wide). Defaults to
+     * SINGLE ('!').
+     */
+    private final char[] defSuffix = new char[26];
+    private long maxStatements = Long.MAX_VALUE;
+    private long executed;
+    /**
+     * Classic RND generator state: a 24-bit linear congruential generator
+     * ({@code seed = seed * &HFD43FD + &HC39EC3 mod 2^24}, result =
+     * {@code seed / 2^24} as SINGLE). Without RANDOMIZE the generator
+     * starts from a fixed seed, so programs get an identical deterministic
+     * sequence on every run.
+     */
+    private int rndSeed = 0x50000;
+    /** Last value returned by RND; RND(0) repeats it. */
+    private double rndLast;
+
+    public Interpreter(final VgaDevice vgaDevice, final KeyboardQueue keyboard,
+                       final SoundQueue soundQueue) {
+        vga = vgaDevice;
+        keys = keyboard;
+        sound = soundQueue;
+        Arrays.fill(defSuffix, '!');
+        scopes.push(new HashMap<>());
+    }
+
+    /** Sets the statement budget after which {@link StepsLimitException} fires. */
+    public void setMaxStatements(final long limit) {
+        maxStatements = limit;
+    }
+
+    /** Supplies the source text so runtime errors can quote the failing line. */
+    public void setSourceLines(final String[] lines) {
+        sourceLines = lines;
+    }
+
+    /** Headless mode: INPUT$/INKEY$ never block, they just drain the queue. */
+    public void setBlockOnInput(final boolean block) {
+        blockOnInput = block;
+    }
+
+    /** Sets the string COMMAND$ returns (what DOS passed after the program name). */
+    public void setCommandLine(final String command) {
+        commandLine = command == null ? "" : command;
+    }
+
+    /** Sets the directory that relative OPEN paths resolve against. */
+    public void setBaseDirectory(final java.nio.file.Path directory) {
+        baseDirectory = directory;
+    }
+
+    public long statementsExecuted() {
+        return executed;
+    }
+
+    /** Variable value access for agent introspection (global scope). */
+    public Object variable(final String name) {
+        final Cell cell = scopes.getLast().get(canonical(name));
+        return cell == null ? null : cell.value;
+    }
+
+    public VgaDevice video() {
+        return vga;
+    }
+
+    public void run(final List<Ast.Stmt> program) {
+        // SUB definitions register and do not execute inline.
+        // DEFxxx statements are collected up front: their effect is
+        // module-wide, independent of execution position.
+        for (final Ast.Stmt statement : program) {
+            if (statement instanceof Ast.DefTypeStmt def) {
+                for (final int[] range : def.ranges()) {
+                    for (int letter = range[0]; letter <= range[1]; letter++) {
+                        defSuffix[letter] = def.suffix();
+                    }
+                }
+            }
+        }
+        final List<Ast.Stmt> main = new ArrayList<>();
+        for (final Ast.Stmt statement : program) {
+            if (statement instanceof Ast.DeclareStmt declare) {
+                // what() is "SUB name" / "FUNCTION name".
+                final String name = declare.what().substring(declare.what().indexOf(' ') + 1);
+                declaredParams.put(baseName(name), declare.params());
+                declaredNames.put(baseName(name), name);
+            }
+        }
+        for (final Ast.Stmt statement : program) {
+            if (statement instanceof Ast.SubStmt sub) {
+                subs.put(sub.name().toUpperCase(), sub);
+            } else if (statement instanceof Ast.FunctionStmt function) {
+                functions.put(baseName(function.name()), function);
+            } else if (statement instanceof Ast.DefTypeStmt) {
+                // Already applied above.
+            } else if (statement instanceof Ast.DeclareStmt) {
+                // Recorded above.
+            } else {
+                main.add(statement);
+            }
+        }
+        try {
+            // DATA constants are module-wide: collect them in
+            // program order before execution begins (READ walks this list).
+            dataValues.clear();
+            dataIndex = 0;
+            for (final Ast.Stmt statement : program) {
+                if (statement instanceof Ast.DataStmt data) {
+                    for (final Ast.Expr value : data.values()) {
+                        dataValues.add(eval(value));
+                    }
+                }
+            }
+            execBlock(main);
+        } catch (final ProgramEnd e) {
+            // END statement: normal termination.
+        } catch (final GotoJump jump) {
+            throw error("Label not found: " + jump.label, jump.line);
+        }
+    }
+
+    // ------------------------------------------------------------ statements
+
+    /**
+     * Executes a statement list with a program counter so that
+     * {@code GOTO} can jump to any {@link Ast.LabelStmt} in the list. A jump
+     * whose target is not in this list propagates to the enclosing block,
+     * giving labels procedure-wide scope.
+     */
+    private void execBlock(final List<Ast.Stmt> stmts) {
+        Map<String, Integer> labels = null;
+        int pc = 0;
+        while (pc < stmts.size()) {
+            final Ast.Stmt stmt = stmts.get(pc++);
+            try {
+                if (stmt instanceof Ast.GotoStmt g) {
+                    throw new GotoJump(g.label(), g.line());
+                }
+                exec(stmt);
+            } catch (final GotoJump jump) {
+                if (++executed > maxStatements) {
+                    throw new StepsLimitException(executed);
+                }
+                if (labels == null) {
+                    labels = new HashMap<>();
+                    for (int i = 0; i < stmts.size(); i++) {
+                        if (stmts.get(i) instanceof Ast.LabelStmt l) {
+                            labels.putIfAbsent(l.label().toLowerCase(), i);
+                        }
+                    }
+                }
+                final Integer target = labels.get(jump.label.toLowerCase());
+                if (target == null) {
+                    throw jump;
+                }
+                pc = target;
+            } catch (final InterpreterException e) {
+                // ON ERROR GOTO: transfer control to the handler label. The
+                // handler lives in module-level code; blocks that do not
+                // contain it propagate the error to the enclosing block.
+                if (errorHandler == null) {
+                    throw e;
+                }
+                if (++executed > maxStatements) {
+                    throw new StepsLimitException(executed);
+                }
+                if (labels == null) {
+                    labels = new HashMap<>();
+                    for (int i = 0; i < stmts.size(); i++) {
+                        if (stmts.get(i) instanceof Ast.LabelStmt l) {
+                            labels.putIfAbsent(l.label().toLowerCase(), i);
+                        }
+                    }
+                }
+                final Integer target = labels.get(errorHandler);
+                if (target == null) {
+                    throw e;
+                }
+                resumePc = pc - 1;
+                pc = target;
+            } catch (final ResumeJump resume) {
+                if (++executed > maxStatements) {
+                    throw new StepsLimitException(executed);
+                }
+                if (resume.label != null) {
+                    if (labels == null) {
+                        labels = new HashMap<>();
+                        for (int i = 0; i < stmts.size(); i++) {
+                            if (stmts.get(i) instanceof Ast.LabelStmt l) {
+                                labels.putIfAbsent(l.label().toLowerCase(), i);
+                            }
+                        }
+                    }
+                    final Integer target = labels.get(resume.label.toLowerCase());
+                    if (target == null) {
+                        throw new GotoJump(resume.label, 0);
+                    }
+                    pc = target;
+                } else if (resumePc < 0) {
+                    throw error("RESUME without error", stmt.line());
+                } else {
+                    pc = resume.next ? resumePc + 1 : resumePc;
+                }
+                resumePc = -1;
+            }
+        }
+    }
+
+    private void exec(final Ast.Stmt stmt) {
+        if (++executed > maxStatements) {
+            throw new StepsLimitException(executed);
+        }
+        switch (stmt) {
+            case Ast.ScreenStmt s -> {
+                vga.setMode(ScreenMode.byNumber(toInt(eval(s.mode()), s.line())));
+                if (s.activePage() != null) {
+                    vga.setActivePage(toInt(eval(s.activePage()), s.line()));
+                }
+                if (s.visualPage() != null) {
+                    vga.setVisualPage(toInt(eval(s.visualPage()), s.line()));
+                }
+            }
+            case Ast.PcopyStmt s -> vga.pcopy(toInt(eval(s.sourcePage()), s.line()),
+                    toInt(eval(s.targetPage()), s.line()));
+            case Ast.LocateStmt s -> vga.locate(toInt(eval(s.row()), s.line()),
+                    toInt(eval(s.col()), s.line()));
+            case Ast.ClsStmt s -> vga.cls();
+            case Ast.RandomizeStmt s -> {
+                // RANDOMIZE n reseeds like RND(-n); no seed = RANDOMIZE TIMER.
+                final double seed = s.seed() == null ? timerSeconds()
+                        : ((Number) eval(s.seed())).doubleValue();
+                rndReseed(-seed);
+            }
+            case Ast.OpenStmt s -> {
+                final int channel = toInt(eval(s.channel()), s.line());
+                final String path = str(eval(s.path()), s.line());
+                try {
+                    fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
+                            resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437")));
+                } catch (final java.io.IOException e) {
+                    throw error("File not found: " + path, s.line());
+                }
+            }
+            case Ast.CloseStmt s -> {
+                if (s.channel() == null) {
+                    for (final int channel : List.copyOf(fileChannels.keySet())) {
+                        closeChannel(channel, s.line());
+                    }
+                } else {
+                    closeChannel(toInt(eval(s.channel()), s.line()), s.line());
+                }
+            }
+            case Ast.LineInputStmt s -> {
+                final int channel = toInt(eval(s.channel()), s.line());
+                final String lineText = readFileLine(channel, s.line());
+                if (lineText == null) {
+                    throw error("Input past end of file", s.line());
+                }
+                setTarget(s.target(), lineText, s.line());
+            }
+            case Ast.InputFileStmt s -> {
+                final int channel = toInt(eval(s.channel()), s.line());
+                for (final Ast.ReadTarget target : s.targets()) {
+                    setTarget(target, readFileItem(channel, target.name(), s.line()), s.line());
+                }
+            }
+            case Ast.EraseStmt s -> {
+                // The classic dialect frees dynamic arrays; our arrays all behave like
+                // dynamic ones, so ERASE returns the slot to its
+                // never-DIMmed state (re-DIMming afterwards works).
+                for (final String name : s.names()) {
+                    cellFor(name).value = null;
+                }
+            }
+            case Ast.OnErrorStmt s -> {
+                // ON ERROR GOTO 0 disables trapping.
+                errorHandler = "0".equals(s.label()) ? null : s.label().toLowerCase();
+            }
+            case Ast.ResumeStmt s -> throw new ResumeJump(s.label(), s.next());
+            case Ast.ColorStmt s -> {
+                final int fg = toInt(eval(s.foreground()), s.line());
+                final int bg = s.background() == null ? currentBackground()
+                        : toInt(eval(s.background()), s.line());
+                vga.setTextColor(fg, bg);
+            }
+            case Ast.PrintStmt s -> execPrint(s);
+            case Ast.PsetStmt s -> vga.pset(toInt(eval(s.x()), s.line()),
+                    toInt(eval(s.y()), s.line()), colorOrDefault(s.color(), s.line()));
+            case Ast.LineStmt s -> execLine(s);
+            case Ast.CircleStmt s -> {
+                if (s.start() != null || s.end() != null) {
+                    throw error("CIRCLE arcs (start/end angles) are not implemented", s.line());
+                }
+                final int cx = toInt(eval(s.x()), s.line());
+                final int cy = toInt(eval(s.y()), s.line());
+                final int radius = toInt(eval(s.radius()), s.line());
+                final int color = colorOrDefault(s.color(), s.line());
+                if (s.aspect() == null) {
+                    vga.circle(cx, cy, radius, color);
+                } else {
+                    vga.circle(cx, cy, radius, color, num(eval(s.aspect()), s.line()));
+                }
+            }
+            case Ast.SoundStmt s -> {
+                // Timing modeled (1-note queue, 18.2 ticks/s); no actual audio.
+                sound.sound(toInt(eval(s.frequency()), s.line()),
+                        num(eval(s.duration()), s.line()));
+            }
+            case Ast.SleepStmt s -> execSleep(s);
+            case Ast.OutStmt s -> vga.out(toInt(eval(s.port()), s.line()),
+                    toInt(eval(s.value()), s.line()));
+            case Ast.PaintStmt s -> {
+                final int fill = s.fill() == null ? vga.textForeground()
+                        : toInt(eval(s.fill()), s.line());
+                final int border = s.border() == null ? fill : toInt(eval(s.border()), s.line());
+                vga.paint(toInt(eval(s.x()), s.line()), toInt(eval(s.y()), s.line()), fill, border);
+            }
+            case Ast.DimStmt s -> {
+                for (final Ast.DimEntry entry : s.entries()) {
+                    if (entry.type() != null) {
+                        // DIM name AS type: suffix-less references to this
+                        // name resolve to the DIM'd type.
+                        final String base = baseName(entry.name()).toLowerCase();
+                        dimSuffixes.put(base, typeSuffix(entry.type()));
+                    }
+                    final String key = canonical(entry.name());
+                    final Map<String, Cell> target = s.shared() ? scopes.getLast() : scopes.peek();
+                    if (s.shared()) {
+                        sharedNames.add(key);
+                    }
+                    if (entry.bounds().isEmpty()) {
+                        target.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+                    } else {
+                        final int[] lows = new int[entry.bounds().size()];
+                        final int[] sizes = new int[entry.bounds().size()];
+                        for (int dim = 0; dim < lows.length; dim++) {
+                            final Ast.Bound bound = entry.bounds().get(dim);
+                            lows[dim] = bound.low() == null ? 0 : toInt(eval(bound.low()), s.line());
+                            sizes[dim] = toInt(eval(bound.high()), s.line()) - lows[dim] + 1;
+                            if (sizes[dim] <= 0) {
+                                throw error("Subscript out of range", s.line());
+                            }
+                        }
+                        final boolean stringArray = key.endsWith("$");
+                        final int elementKind = stringArray ? 0 : kindOf(key);
+                        final Cell cell = target.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+                        if (cell.value == null) {
+                            // Fresh DIM — or a re-DIM after ERASE freed the
+                            // slot (classic dynamic-array behavior).
+                            cell.value = new BasicArray(lows, sizes, stringArray, elementKind);
+                        }
+                    }
+                }
+            }
+            case Ast.GetStmt s -> {
+                final CapturedRegion region = vga.getRegion(
+                        toInt(eval(s.x1()), s.line()), toInt(eval(s.y1()), s.line()),
+                        toInt(eval(s.x2()), s.line()), toInt(eval(s.y2()), s.line()));
+                if (s.offsetIndices().isEmpty()) {
+                    cellFor(s.arrayName()).value = region;
+                } else {
+                    // GET ..., array(i, j): anchor the capture at that
+                    // element — one slot of a sprite-sheet array.
+                    final BasicArray array = arrayFor(s.arrayName(),
+                            s.offsetIndices().size(), s.line());
+                    final int offset = array.offset(subscripts(s.offsetIndices(), s.line()));
+                    if (offset < 0) {
+                        throw error("Subscript out of range", s.line());
+                    }
+                    array.captures.put(offset, region);
+                }
+            }
+            case Ast.PutStmt s -> {
+                final Object captured;
+                if (s.offsetIndices().isEmpty()) {
+                    captured = cellFor(s.arrayName()).value;
+                } else {
+                    final BasicArray array = arrayFor(s.arrayName(),
+                            s.offsetIndices().size(), s.line());
+                    final int offset = array.offset(subscripts(s.offsetIndices(), s.line()));
+                    if (offset < 0) {
+                        throw error("Subscript out of range", s.line());
+                    }
+                    captured = array.captures.get(offset);
+                }
+                if (!(captured instanceof CapturedRegion region)) {
+                    throw error("PUT needs an array filled by GET", s.line());
+                }
+                vga.putRegion(toInt(eval(s.x()), s.line()), toInt(eval(s.y()), s.line()),
+                        region, s.action());
+            }
+            case Ast.MidStmt s -> execMid(s);
+            case Ast.LabelStmt s -> {
+                // Jump target marker: no-op when reached by fall-through.
+            }
+            case Ast.DefTypeStmt s -> {
+                // Collected module-wide by run() before execution starts.
+            }
+            case Ast.FunctionStmt s -> {
+                // FUNCTION definitions are registered by run(), never executed inline.
+            }
+            case Ast.DeclareStmt s -> {
+                // Parameter signatures recorded by run(); no runtime effect.
+            }
+            case Ast.SelectStmt s -> execSelect(s);
+            case Ast.IgnoredStmt s -> {
+                // Parsed but intentionally not modeled.
+            }
+            case Ast.GotoStmt s -> throw new GotoJump(s.label(), s.line());
+            case Ast.SubStmt s -> {
+                // SUB definitions are registered by run(), never executed inline.
+            }
+            case Ast.CallStmt s -> execCall(s);
+            case Ast.AssignStmt s -> {
+                if (s.indices().isEmpty()) {
+                    cellFor(s.variable()).set(eval(s.value()));
+                } else {
+                    final BasicArray array = arrayFor(s.variable(), s.indices().size(), s.line());
+                    final int offset = array.offset(subscripts(s.indices(), s.line()));
+                    if (offset < 0) {
+                        throw error("Subscript out of range", s.line());
+                    }
+                    final Object element = eval(s.value());
+                    array.data[offset] = element instanceof Double d ? array.narrow(d) : element;
+                }
+            }
+            case Ast.DataStmt s -> {
+                // 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.
+            }
+            case Ast.ReadStmt s -> {
+                for (final Ast.ReadTarget target : s.targets()) {
+                    if (dataIndex >= dataValues.size()) {
+                        throw error("Out of DATA", s.line());
+                    }
+                    setTarget(target, dataValues.get(dataIndex++), s.line());
+                }
+            }
+            case Ast.ForStmt s -> execFor(s);
+            case Ast.IfStmt s -> execBlock(truthy(eval(s.condition())) ? s.thenBody() : s.elseBody());
+            case Ast.WhileStmt s -> {
+                while (truthy(eval(s.condition()))) {
+                    execBlock(s.body());
+                }
+            }
+            case Ast.DoStmt s -> {
+                while (true) {
+                    if (s.preCondition() != null
+                            && truthy(eval(s.preCondition())) == s.preIsUntil()) {
+                        break;
+                    }
+                    execBlock(s.body());
+                    if (s.postCondition() != null
+                            && truthy(eval(s.postCondition())) == s.postIsUntil()) {
+                        break;
+                    }
+                }
+            }
+            case Ast.EndStmt s -> {
+                // Program end: run() simply returns after this.
+                throw new ProgramEnd();
+            }
+        }
+    }
+
+    private void execPrint(final Ast.PrintStmt s) {
+        for (final Ast.PrintStmt.Item item : s.items()) {
+            final Object value = eval(item.expr());
+            vga.print(value instanceof String text ? text
+                    : basicNumber(num(value, s.line()), exprIsDouble(item.expr())));
+            if (item.separatorAfter() == ',') {
+                printZoneAdvance();
+            }
+        }
+        if (s.newline()) {
+            vga.newLine();
+        }
+    }
+
+    /**
+     * Statically determines whether a PRINT expression has type DOUBLE:
+     * numbers are formatted by the expression's type (16 significant digits
+     * for DOUBLE, ~7 for SINGLE).
+     */
+    private boolean exprIsDouble(final Ast.Expr e) {
+        return switch (e) {
+            case Ast.Num n -> n.doublePrecision();
+            case Ast.Var v -> kindOf(canonical(v.name())) == 3;
+            case Ast.Binary b -> exprIsDouble(b.left()) || exprIsDouble(b.right());
+            case Ast.Unary u -> exprIsDouble(u.operand());
+            default -> false;
+        };
+    }
+
+    /** ',' 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 target = ((col - 1) / 14 + 1) * 14 + 1;
+        if (target > columns) {
+            vga.newLine();
+        } else {
+            vga.print(" ".repeat(target - col));
+        }
+    }
+
+    private void execLine(final Ast.LineStmt s) {
+        final int x1 = toInt(eval(s.x1()), s.line());
+        final int y1 = toInt(eval(s.y1()), s.line());
+        final int x2 = toInt(eval(s.x2()), s.line());
+        final int y2 = toInt(eval(s.y2()), s.line());
+        final int color = colorOrDefault(s.color(), s.line());
+        logLine(x1, y1, x2, y2, color);
+        if (s.fill()) {
+            vga.lineBoxFilled(x1, y1, x2, y2, color);
+        } else if (s.box()) {
+            vga.lineBox(x1, y1, x2, y2, color);
+        } else {
+            vga.line(x1, y1, x2, y2, color);
+        }
+    }
+
+    private void execFor(final Ast.ForStmt s) {
+        final Cell cell = cellFor(s.variable());
+        final double from = num(eval(s.from()), s.line());
+        final double to = num(eval(s.to()), s.line());
+        final double step = s.step() == null ? 1 : num(eval(s.step()), s.line());
+        if (step == 0) {
+            throw error("STEP cannot be 0", s.line());
+        }
+        cell.set(from);
+        while (step > 0 ? num(cell.value, s.line()) <= to
+                : num(cell.value, s.line()) >= to) {
+            execBlock(s.body());
+            cell.set(num(cell.value, s.line()) + step);
+        }
+    }
+
+    /**
+     * Invokes a SUB. Plain-variable arguments share the caller's cell (the
+     * dialect passes by reference); expression arguments are copied by value.
+     * SUBs do not see the caller's other variables (classic scoping).
+     */
+    private void execCall(final Ast.CallStmt call) {
+        final Ast.SubStmt sub = subs.get(call.name().toUpperCase());
+        if (sub == null) {
+            throw error("Unknown SUB '" + call.name() + "'", call.line());
+        }
+        final Map<String, Cell> scope = bindScope(sub.name(), sub.params(), call.args(), call.line());
+        scopes.push(scope);
+        try {
+            execBlock(sub.body());
+        } catch (final GotoJump jump) {
+            throw error("Label not found: " + jump.label, jump.line);
+        } finally {
+            scopes.pop();
+        }
+    }
+
+    /**
+     * Builds the local scope for a SUB/FUNCTION call. Plain-variable
+     * arguments share the caller's cell (the dialect passes by reference);
+     * expression arguments are copied by value into a cell typed after the
+     * DECLARE'd parameter name when available, else the header name. The
+     * cell is registered under both names' canonical forms, so the body
+     * can refer to the parameter with or without the type suffix.
+     */
+    private Map<String, Cell> bindScope(final String procName, final List<String> headerParams,
+                                        final List<Ast.Expr> args, final int line) {
+        if (args.size() != headerParams.size()) {
+            throw error("'" + procName + "' expects " + headerParams.size()
+                    + " argument(s), got " + args.size(), line);
+        }
+        final List<String> declared = declaredParams.getOrDefault(baseName(procName), List.of());
+        final Map<String, Cell> scope = new HashMap<>();
+        for (int i = 0; i < args.size(); i++) {
+            final Ast.Expr arg = args.get(i);
+            final String headerKey = canonical(headerParams.get(i));
+            final String declaredKey = i < declared.size()
+                    ? canonical(declared.get(i)) : headerKey;
+            final Cell cell = arg instanceof Ast.Var v
+                    ? cellFor(v.name()) // by reference
+                    : new Cell(kindOf(declaredKey), eval(arg)); // by value
+            scope.put(headerKey, cell);
+            scope.put(declaredKey, cell);
+        }
+        return scope;
+    }
+
+    /**
+     * Invokes a FUNCTION and returns its result: the value last assigned
+     * to the function's own name inside the body (0 / "" when unassigned).
+     */
+    private Object invokeFunction(final Ast.FunctionStmt function, final List<Ast.Expr> args,
+                                  final int line) {
+        final Map<String, Cell> scope = bindScope(function.name(), function.params(), args, line);
+        // The DECLARE'd name (with suffix) types the return value when present.
+        final String resultKey = canonical(declaredNames.getOrDefault(
+                baseName(function.name()), function.name()));
+        final Cell result = new Cell(kindOf(resultKey), null);
+        // Visible under both the header name's and the DECLARE'd name's
+        // canonical forms, so the body can assign with or without suffix.
+        scope.put(resultKey, result);
+        scope.put(canonical(function.name()), result);
+        scopes.push(scope);
+        try {
+            execBlock(function.body());
+        } catch (final GotoJump jump) {
+            throw error("Label not found: " + jump.label, jump.line);
+        } finally {
+            scopes.pop();
+        }
+        return result.value != null ? result.value : (resultKey.endsWith("$") ? "" : 0.0);
+    }
+
+    /** SELECT CASE: first branch with any equal value runs; else CASE ELSE. */
+    private void execSelect(final Ast.SelectStmt s) {
+        final Object subject = eval(s.subject());
+        for (final Ast.CaseBranch branch : s.cases()) {
+            for (final Ast.CaseTest test : branch.tests()) {
+                final boolean match = test.hi() != null
+                        ? num(subject, s.line()) >= num(eval(test.lo()), s.line())
+                          && num(subject, s.line()) <= num(eval(test.hi()), s.line())
+                        : basicEquals(subject, eval(test.lo()), s.line());
+                if (match) {
+                    execBlock(branch.body());
+                    return;
+                }
+            }
+        }
+        execBlock(s.elseBody());
+    }
+
+    /** Classic {@code =} semantics: exact string match or numeric equality. */
+    private boolean basicEquals(final Object left, final Object right, final int line) {
+        if (left instanceof String ls && right instanceof String rs) {
+            return ls.equals(rs);
+        }
+        return num(left, line) == num(right, line);
+    }
+
+    /** Returns the named variable's cell, creating it if absent. DIM SHARED names resolve to the global scope. */
+    private Cell cellFor(final String name) {
+        final String key = canonical(name);
+        final Map<String, Cell> current = scopes.peek();
+        if (!current.containsKey(key) && scopes.size() > 1 && sharedNames.contains(key)) {
+            return scopes.getLast().computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+        }
+        return current.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
+    }
+
+    /**
+     * Resolves the named array, auto-dimensioning it {@code 0 TO 10} per
+     * dimension on first indexed use if never DIMmed (the classic default).
+     */
+    private BasicArray arrayFor(final String name, final int dimensions, final int line) {
+        final Cell cell = cellFor(name);
+        if (cell.value == null) {
+            final int[] lows = new int[dimensions];
+            final int[] sizes = new int[dimensions];
+            Arrays.fill(sizes, 11);
+            cell.value = new BasicArray(lows, sizes, name.endsWith("$"),
+                    name.endsWith("$") ? 0 : kindOf(canonical(name)));
+        }
+        if (!(cell.value instanceof BasicArray array) || array.sizes.length != dimensions) {
+            throw error("Subscript out of range", line);
+        }
+        return array;
+    }
+
+    private int[] subscripts(final List<Ast.Expr> indices, final int line) {
+        final int[] subscripts = new int[indices.size()];
+        for (int i = 0; i < subscripts.length; i++) {
+            subscripts[i] = toInt(eval(indices.get(i)), line);
+        }
+        return subscripts;
+    }
+
+    /**
+     * MID$(target, start[, length]) = replacement: overwrites characters of
+     * the target string in place. At most min(LEN(replacement), length,
+     * remaining target) characters are replaced; the target never grows or
+     * shrinks. start < 1 (or length < 0) raises Illegal function call.
+     */
+    private void execMid(final Ast.MidStmt s) {
+        final String replacement = str(eval(s.replacement()), s.line());
+        final int start = toInt(eval(s.start()), s.line());
+        final int limit = s.length() == null ? Integer.MAX_VALUE
+                : toInt(eval(s.length()), s.line());
+        if (start < 1 || limit < 0) {
+            throw error("Illegal function call", s.line());
+        }
+        final String original;
+        final Cell scalarCell;
+        final BasicArray array;
+        final int offset;
+        if (s.target().indices().isEmpty()) {
+            scalarCell = cellFor(s.target().name());
+            array = null;
+            offset = -1;
+            original = scalarCell.value instanceof String text ? text : "";
+        } else {
+            scalarCell = null;
+            array = arrayFor(s.target().name(), s.target().indices().size(), s.line());
+            offset = array.offset(subscripts(s.target().indices(), s.line()));
+            if (offset < 0) {
+                throw error("Subscript out of range", s.line());
+            }
+            original = array.data[offset] instanceof String text ? text : "";
+        }
+        final int count = Math.min(Math.min(replacement.length(), limit),
+                original.length() - (start - 1));
+        if (count <= 0) {
+            return; // start beyond the string end: no-op
+        }
+        final String updated = original.substring(0, start - 1)
+                + replacement.substring(0, count)
+                + original.substring(start - 1 + count);
+        if (scalarCell != null) {
+            scalarCell.set(updated);
+        } else {
+            array.data[offset] = updated;
+        }
+    }
+
+    /** Assigns a value to a READ / INPUT # / LINE INPUT # target (scalar or array element). */
+    private void setTarget(final Ast.ReadTarget target, final Object value, final int line) {
+        if (target.indices().isEmpty()) {
+            cellFor(target.name()).set(value);
+            return;
+        }
+        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);
+        }
+        array.data[offset] = value instanceof Double d ? array.narrow(d) : value;
+    }
+
+    // ------------------------------------------------------------ expressions
+
+    private Object eval(final Ast.Expr expr) {
+        return switch (expr) {
+            case Ast.Num n -> n.doublePrecision() ? n.value() : single(n.value());
+            case Ast.Str s -> s.value();
+            case Ast.Var v -> evalVar(v);
+            case Ast.Call c -> evalCall(c);
+            case Ast.Unary u -> {
+                if (u.op().equals("NOT")) {
+                    yield (double) ~(long) num(eval(u.operand()), u.line());
+                }
+                yield -num(eval(u.operand()), u.line());
+            }
+            case Ast.Binary b -> evalBinary(b);
+        };
+    }
+
+    private Object evalVar(final Ast.Var v) {
+        // Parameterless system functions in variable position.
+        if (v.name().equalsIgnoreCase("CSRLIN")) {
+            return (double) vga.cursorRow();
+        }
+        if (v.name().equalsIgnoreCase("RND")) {
+            // Bare RND (no parentheses): next random number.
+            return rndNext();
+        }
+        if (v.name().equalsIgnoreCase("INKEY$")) {
+            // Bare INKEY$ (no parentheses): non-blocking key read.
+            return inkey();
+        }
+        if (v.name().equalsIgnoreCase("TIMER")) {
+            // Bare TIMER: seconds since local midnight.
+            return timerSeconds();
+        }
+        if (v.name().equalsIgnoreCase("TIME$")) {
+            // Bare TIME$: current time as "HH:MM:SS" (24-hour).
+            return java.time.LocalTime.now()
+                    .format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
+        }
+        if (v.name().equalsIgnoreCase("COMMAND$")) {
+            // Bare COMMAND$: the text DOS passed after the program name.
+            return commandLine;
+        }
+        final String key = canonical(v.name());
+        Cell cell = scopes.peek().get(key);
+        if (cell == null && scopes.size() > 1 && sharedNames.contains(key)) {
+            cell = scopes.getLast().get(key);
+        }
+        if (cell != null && cell.value != null) {
+            return cell.value;
+        }
+        return v.name().endsWith("$") ? "" : 0.0;
+    }
+
+    private Object evalBinary(final Ast.Binary b) {
+        final String op = b.op();
+        // Comparison (numeric or string).
+        if (List.of("=", "<>", "<", "<=", ">", ">=").contains(op)) {
+            final Object left = eval(b.left());
+            final Object right = eval(b.right());
+            final int cmp;
+            if (left instanceof String ls && right instanceof String rs) {
+                cmp = ls.compareTo(rs);
+            } else {
+                cmp = Double.compare(num(left, b.line()), num(right, b.line()));
+            }
+            final boolean result = switch (op) {
+                case "=" -> cmp == 0;
+                case "<>" -> cmp != 0;
+                case "<" -> cmp < 0;
+                case "<=" -> cmp <= 0;
+                case ">" -> cmp > 0;
+                default -> cmp >= 0;
+            };
+            return result ? -1.0 : 0.0;
+        }
+        // Logic (bitwise on integers).
+        if (op.equals("AND") || op.equals("OR")) {
+            final long left = (long) num(eval(b.left()), b.line());
+            final long right = (long) num(eval(b.right()), b.line());
+            return (double) (op.equals("AND") ? left & right : left | right);
+        }
+        // String concatenation.
+        final Object left = eval(b.left());
+        final Object right = eval(b.right());
+        if (op.equals("+") && left instanceof String ls && right instanceof String rs) {
+            return ls + rs;
+        }
+        final double l = num(left, b.line());
+        final double r = num(right, b.line());
+        return switch (op) {
+            // Arithmetic runs in double: the expression engine keeps
+            // full precision within an expression and narrows to SINGLE
+            // only when a value is stored.
+            case "+" -> l + r;
+            case "-" -> l - r;
+            case "*" -> l * r;
+            case "/" -> r == 0 ? throwDivisionByZero(b.line()) : l / r;
+            case "\\" -> {
+                // Both operands are rounded to integers (half-to-even) first.
+                final long divisor = (long) Math.rint(r);
+                if (divisor == 0) {
+                    throwDivisionByZero(b.line());
+                }
+                yield (double) ((long) Math.rint(l) / divisor);
+            }
+            case "MOD" -> {
+                final long divisor = (long) Math.rint(r);
+                if (divisor == 0) {
+                    throwDivisionByZero(b.line());
+                }
+                yield (double) ((long) Math.rint(l) % divisor);
+            }
+            case "^" -> Math.pow(l, r);
+            default -> throw error("Unsupported operator " + op, b.line());
+        };
+    }
+
+    private Object evalCall(final Ast.Call c) {
+        final String name = c.name().toUpperCase();
+        final List<Ast.Expr> args = c.args();
+        switch (name) {
+            case "POINT" -> {
+                requireArgs(name, args, 2, c.line());
+                return (double) vga.point(toInt(eval(args.get(0)), c.line()),
+                        toInt(eval(args.get(1)), c.line()));
+            }
+            case "POS" -> {
+                return (double) vga.cursorColumn();
+            }
+            case "CSRLIN" -> {
+                return (double) vga.cursorRow();
+            }
+            case "RND" -> {
+                // RND[(n)]: n > 0 (or omitted) = next number; n = 0 repeats
+                // the last one; n < 0 reseeds the generator first.
+                if (args.isEmpty()) {
+                    return rndNext();
+                }
+                requireArgs(name, args, 1, c.line());
+                final double argument = num(eval(args.get(0)), c.line());
+                if (argument < 0) {
+                    rndReseed(argument);
+                    return rndNext();
+                }
+                return argument == 0 ? rndLast : rndNext();
+            }
+            case "ABS" -> {
+                return Math.abs(num(singleArgValue(c), c.line()));
+            }
+            case "INT" -> {
+                return Math.floor(num(singleArgValue(c), c.line()));
+            }
+            case "CLNG" -> {
+                return longInteger(num(singleArgValue(c), c.line()));
+            }
+            case "CINT" -> {
+                return integer(num(singleArgValue(c), c.line()));
+            }
+            case "FIX" -> {
+                final double v = num(singleArgValue(c), c.line());
+                return (double) (long) v;
+            }
+            case "SGN" -> {
+                return Math.signum(num(singleArgValue(c), c.line()));
+            }
+            case "SQR" -> {
+                return Math.sqrt(num(singleArgValue(c), c.line()));
+            }
+            case "SIN" -> {
+                return Math.sin(num(singleArgValue(c), c.line()));
+            }
+            case "COS" -> {
+                return Math.cos(num(singleArgValue(c), c.line()));
+            }
+            case "TAN" -> {
+                return Math.tan(num(singleArgValue(c), c.line()));
+            }
+            case "ATN" -> {
+                return Math.atan(num(singleArgValue(c), c.line()));
+            }
+            case "EXP" -> {
+                return Math.exp(num(singleArgValue(c), c.line()));
+            }
+            case "LOG" -> {
+                return Math.log(num(singleArgValue(c), c.line()));
+            }
+            case "CHR$" -> {
+                // CHR$(n): the CP437 character for byte n, not Unicode code
+                // point n — they differ at 128-255 (CHR$(135) = 'ç').
+                return Cp437.charOf(toInt(singleArgValue(c), c.line()));
+            }
+            case "STR$" -> {
+                final double v = num(singleArgValue(c), c.line());
+                final String s = formatNumber(v);
+                return v >= 0 ? " " + s : s;
+            }
+            case "LEN" -> {
+                return (double) str(singleArgValue(c), c.line()).length();
+            }
+            case "EOF" -> {
+                // -1 when the channel has no more input, 0 otherwise.
+                return fileEof(toInt(singleArgValue(c), c.line()), c.line()) ? -1.0 : 0.0;
+            }
+            case "ASC" -> {
+                // ASC(s): the CP437 byte value of the first character —
+                // high chars map back to their original byte ('ü' = 129).
+                final String s = str(singleArgValue(c), c.line());
+                return s.isEmpty() ? 0.0 : (double) Cp437.byteOf(s.charAt(0));
+            }
+            case "VAL" -> {
+                try {
+                    return single(Double.parseDouble(str(singleArgValue(c), c.line()).trim()));
+                } catch (final NumberFormatException e) {
+                    return 0.0;
+                }
+            }
+            case "LEFT$" -> {
+                requireArgs(name, args, 2, c.line());
+                final String s = str(eval(args.get(0)), c.line());
+                return s.substring(0, Math.min(toInt(eval(args.get(1)), c.line()), s.length()));
+            }
+            case "RIGHT$" -> {
+                requireArgs(name, args, 2, c.line());
+                final String s = str(eval(args.get(0)), c.line());
+                final int n = toInt(eval(args.get(1)), c.line());
+                return s.substring(Math.max(0, s.length() - n));
+            }
+            case "MID$" -> {
+                if (args.size() < 2 || args.size() > 3) {
+                    throw error("MID$ needs 2-3 arguments", c.line());
+                }
+                final String s = str(eval(args.get(0)), c.line());
+                final int start = toInt(eval(args.get(1)), c.line());
+                final int len = args.size() == 3 ? toInt(eval(args.get(2)), c.line()) : s.length();
+                final int from = Math.max(0, Math.min(start - 1, s.length()));
+                return s.substring(from, Math.min(from + len, s.length()));
+            }
+            case "UCASE$" -> {
+                return str(singleArgValue(c), c.line()).toUpperCase();
+            }
+            case "LCASE$" -> {
+                return str(singleArgValue(c), c.line()).toLowerCase();
+            }
+            case "LTRIM$" -> {
+                return str(singleArgValue(c), c.line()).replaceAll("^ +", "");
+            }
+            case "RTRIM$" -> {
+                return str(singleArgValue(c), c.line()).replaceAll(" +$", "");
+            }
+            case "INKEY$" -> {
+                // Non-blocking: "" when no key is pending.
+                return inkey();
+            }
+            case "INPUT$" -> {
+                // INPUT$(n): read n input BYTES — an extended key yields
+                // CHR$(0) and its scancode in two successive reads.
+                // Interactive mode waits for keys; headless mode drains
+                // what is queued and returns.
+                final int n = toInt(singleArgValue(c), c.line());
+                final StringBuilder sb = new StringBuilder();
+                while (sb.length() < n) {
+                    final int b = readInputByte(c.line());
+                    if (b < 0) {
+                        break;
+                    }
+                    sb.append((char) b);
+                }
+                return sb.toString();
+            }
+            default -> {
+                // Not a built-in: a user FUNCTION, else an array element
+                // reference (auto-dimensioned 0 TO 10 per dimension if
+                // never DIMmed).
+                final Ast.FunctionStmt function = functions.get(baseName(c.name()));
+                if (function != null) {
+                    return invokeFunction(function, c.args(), c.line());
+                }
+                final BasicArray array = arrayFor(c.name(), args.size(), c.line());
+                final int offset = array.offset(subscripts(args, c.line()));
+                if (offset < 0) {
+                    throw error("Subscript out of range", c.line());
+                }
+                return array.data[offset];
+            }
+        }
+    }
+
+    private Ast.Expr singleArg(final Ast.Call c) {
+        requireArgs(c.name(), c.args(), 1, c.line());
+        return c.args().get(0);
+    }
+
+    /** Reads one key; blocks in interactive mode, returns null when headless and idle. */
+    private KeyboardQueue.Key readKey(final int line) {
+        if (!blockOnInput) {
+            return keys.poll();
+        }
+        try {
+            KeyboardQueue.Key key;
+            while ((key = keys.poll(100)) == null) {
+                // Wait for input.
+            }
+            return key;
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw error("Interrupted while waiting for input", line);
+        }
+    }
+
+    /**
+     * Reads one input byte for INPUT$: extended keys are handed out as
+     * CHR$(0) followed by their scancode in the next read. Keys with no
+     * classic byte sequence (modifiers) are skipped. Returns -1 when the
+     * queue is drained in headless mode.
+     */
+    private int readInputByte(final int line) {
+        while (true) {
+            if (!pendingKeys.isEmpty()) {
+                final char c = pendingKeys.charAt(0);
+                pendingKeys = pendingKeys.substring(1);
+                return c;
+            }
+            final KeyboardQueue.Key key = readKey(line);
+            if (key == null) {
+                return -1;
+            }
+            pendingKeys = keyToQb(key);
+        }
+    }
+
+    private Object singleArgValue(final Ast.Call c) {
+        return eval(singleArg(c));
+    }
+
+    private static void requireArgs(final String name, final List<Ast.Expr> args,
+                                    final int count, final int line) {
+        if (args.size() != count) {
+            throw new InterpreterException(
+                    name + " needs " + count + " argument(s) at line " + line);
+        }
+    }
+
+    // ---------------------------------------------------------------- values
+
+    /**
+     * Variable identity: case-insensitive. Suffix-less names get the
+     * default type suffix for their first letter ('!' unless a DEFxxx
+     * statement changed it), so {@code x} and {@code x%} are the same
+     * variable under DEFINT X.
+     */
+    private String canonical(final String name) {
+        final String lower = name.toLowerCase();
+        final char last = lower.charAt(lower.length() - 1);
+        if ("%&!#$".indexOf(last) >= 0) {
+            return lower;
+        }
+        final Character dimSuffix = dimSuffixes.get(lower);
+        if (dimSuffix != null) {
+            return lower + dimSuffix;
+        }
+        final char first = lower.charAt(0);
+        return lower + (first >= 'a' && first <= 'z' ? defSuffix[first - 'a'] : '!');
+    }
+
+    /** Maps a DIM AS type name to its type suffix character. */
+    private static char typeSuffix(final String typeName) {
+        return switch (typeName.toUpperCase()) {
+            case "INTEGER" -> '%';
+            case "LONG" -> '&';
+            case "DOUBLE" -> '#';
+            case "STRING" -> '$';
+            default -> '!';
+        };
+    }
+
+    /**
+     * @return variable kind for a canonical name: 0 = SINGLE, 1 = INTEGER
+     * ({@code %} suffix, int16), 2 = LONG ({@code &} suffix, int32),
+     * 3 = DOUBLE ({@code #} suffix).
+     */
+    private static int kindOf(final String canonicalName) {
+        return canonicalName.endsWith("%") ? 1 : canonicalName.endsWith("&") ? 2
+                : canonicalName.endsWith("#") ? 3 : 0;
+    }
+
+    /** Uppercase procedure name without type suffix (map key for DECLARE signatures). */
+    private static String baseName(final String name) {
+        final String upper = name.toUpperCase();
+        final char last = upper.charAt(upper.length() - 1);
+        return "%&!#$".indexOf(last) >= 0 ? upper.substring(0, upper.length() - 1) : upper;
+    }
+
+    /**
+     * Narrows a value to INTEGER (int16): rounds half-to-even and
+     * raises the Overflow error when out of range.
+     */
+    private static double integer(final double value) {
+        final double rounded = Math.rint(value);
+        if (rounded < -32768 || rounded > 32767) {
+            throw new InterpreterException("Overflow");
+        }
+        return rounded;
+    }
+
+    /**
+     * Narrows a value to LONG (int32): rounds half-to-even and
+     * raises the Overflow error when out of range.
+     */
+    private static double longInteger(final double value) {
+        final double rounded = Math.rint(value);
+        if (rounded < -2147483648.0 || rounded > 2147483647.0) {
+            throw new InterpreterException("Overflow");
+        }
+        return rounded;
+    }
+
+    private double num(final Object value, final int line) {
+        if (value instanceof Double d) {
+            return d;
+        }
+        throw error("Type mismatch (expected number, got string)", line);
+    }
+
+    private String str(final Object value, final int line) {
+        if (value instanceof String s) {
+            return s;
+        }
+        throw error("Type mismatch (expected string, got number)", line);
+    }
+
+    /**
+     * Fractional values convert to integers by rounding half-to-even
+     * (banker's rounding) — this applies to graphics coordinates, colors,
+     * and MOD/\ operands.
+     */
+    private int toInt(final Object value, final int line) {
+        return (int) Math.rint(num(value, line));
+    }
+
+    /** Narrows a value to SINGLE precision (float32). */
+    private static double single(final double value) {
+        return (float) value;
+    }
+
+    /**
+     * Debug aid: when the {@code crtbasic.linelog}
+     * system property names a file, every LINE call is appended as
+     * {@code x1,y1,x2,y2,color} after coordinate conversion.
+     */
+    private void logLine(final int x1, final int y1, final int x2, final int y2,
+                         final int color) {
+        final String path = System.getProperty("crtbasic.linelog");
+        if (path == null) {
+            return;
+        }
+        try (var writer = new java.io.PrintWriter(
+                new java.io.FileWriter(path, true))) {
+            writer.println(x1 + "," + y1 + "," + x2 + "," + y2 + "," + color);
+        } catch (final java.io.IOException e) {
+            throw new InterpreterException("Cannot write line log: " + e.getMessage());
+        }
+    }
+
+    /** Advances the RND generator and returns the new value in [0, 1). */
+    private double rndNext() {
+        rndSeed = (rndSeed * 0xFD43FD + 0xC39EC3) & 0xFFFFFF;
+        // The result has SINGLE precision: round so PRINTed digits match.
+        rndLast = (float) (rndSeed / 16777216.0);
+        return rndLast;
+    }
+
+    /**
+     * INKEY$ (bare or called): non-blocking; "" when no key is pending.
+     * Extended keys return their full 2-byte sequence in one call.
+     */
+    private String inkey() {
+        if (!pendingKeys.isEmpty()) {
+            // Tail of an extended keycode partially consumed by INPUT$.
+            final char c = pendingKeys.charAt(0);
+            pendingKeys = pendingKeys.substring(1);
+            return String.valueOf(c);
+        }
+        final KeyboardQueue.Key key = keys.poll();
+        return key == null ? "" : keyToQb(key);
+    }
+
+    /**
+     * Maps a pressed key to its classic byte sequence. Printable keys yield
+     * their character (Enter as CHR$(13)); cursor/editing/function keys
+     * yield the two-byte {@code CHR$(0) + scancode} sequence classic
+     * programs test for; unmapped keys yield "".
+     */
+    private static String keyToQb(final KeyboardQueue.Key key) {
+        if (key.hasCharacter()) {
+            return String.valueOf(key.character() == '\n' ? '\r' : key.character());
+        }
+        final int scan = switch (key.vkCode()) {
+            case KeyEvent.VK_UP -> 0x48; // H
+            case KeyEvent.VK_DOWN -> 0x50; // P
+            case KeyEvent.VK_LEFT -> 0x4B; // K
+            case KeyEvent.VK_RIGHT -> 0x4D; // M
+            case KeyEvent.VK_HOME -> 0x47; // G
+            case KeyEvent.VK_END -> 0x4F; // O
+            case KeyEvent.VK_PAGE_UP -> 0x49; // I
+            case KeyEvent.VK_PAGE_DOWN -> 0x51; // Q
+            case KeyEvent.VK_INSERT -> 0x52; // R
+            case KeyEvent.VK_DELETE -> 0x53; // S
+            case KeyEvent.VK_ESCAPE -> 0x1B;
+            case KeyEvent.VK_ENTER -> '\r';
+            case KeyEvent.VK_BACK_SPACE -> '\b';
+            case KeyEvent.VK_TAB -> '\t';
+            default -> key.vkCode() >= KeyEvent.VK_F1 && key.vkCode() <= KeyEvent.VK_F10
+                    ? 0x3B + key.vkCode() - KeyEvent.VK_F1 : -1;
+        };
+        if (scan < 0) {
+            return "";
+        }
+        // Control keys with an ASCII byte (ESC, Enter, Backspace, Tab)
+        // arrive as one byte; the rest are extended 2-byte sequences.
+        if (scan >= 0x20 || key.vkCode() >= KeyEvent.VK_F1 && key.vkCode() <= KeyEvent.VK_F10) {
+            return "\0" + (char) scan;
+        }
+        return String.valueOf((char) scan);
+    }
+
+    /**
+     * SLEEP [seconds]: waits the given whole seconds; no argument (or 0)
+     * waits for a keypress. Any pending key ends the wait early; the key
+     * stays in the queue for INKEY$/INPUT$. Headless mode
+     * never blocks on a key wait.
+     */
+    private void execSleep(final Ast.SleepStmt s) {
+        final double seconds = s.seconds() == null ? 0 : num(eval(s.seconds()), s.line());
+        if (seconds <= 0) {
+            // Wait for a keypress (headless: return immediately).
+            if (blockOnInput && keys.isEmpty()) {
+                try {
+                    final KeyboardQueue.Key key = keys.poll(Long.MAX_VALUE);
+                    if (key != null) {
+                        // SLEEP must not consume the key: put it back.
+                        keys.push(key.vkCode(), key.character());
+                    }
+                } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+            return;
+        }
+        final long deadline = System.currentTimeMillis() + Math.round(seconds * 1000.0);
+        while (System.currentTimeMillis() < deadline) {
+            if (!keys.isEmpty()) {
+                return; // Keypress ends the wait early; key stays queued.
+            }
+            try {
+                Thread.sleep(Math.min(50, deadline - System.currentTimeMillis()));
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+    }
+
+    /** TIMER: seconds since local midnight. */
+    private static double timerSeconds() {
+        final java.time.LocalTime now = java.time.LocalTime.now();
+        return single(now.toSecondOfDay() + now.getNano() / 1e9);
+    }
+
+    /**
+     * Reads the next line from an open channel, consuming the one-line
+     * lookahead first when EOF(n) filled it. Returns null at end of file
+     * (does not throw).
+     */
+    private String readFileLine(final int channel, final int line) {
+        final java.io.BufferedReader reader = fileChannels.get(channel);
+        if (reader == null) {
+            throw error("Bad file number: " + channel, line);
+        }
+        final String buffered = fileLookahead.remove(channel);
+        if (buffered != null) {
+            return buffered;
+        }
+        try {
+            return reader.readLine();
+        } catch (final java.io.IOException e) {
+            throw error("File read error: " + e.getMessage(), line);
+        }
+    }
+
+    /**
+     * 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.
+     */
+    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);
+            }
+            items.addAll(List.of(lineText.split(",", -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;
+        }
+        try {
+            return Double.parseDouble(item);
+        } catch (final NumberFormatException e) {
+            return 0.0;
+        }
+    }
+
+    /**
+     * Resolves an OPEN path against the .bas directory. DOS paths use
+     * backslashes and are case-insensitive; on a case-sensitive filesystem
+     * each missing component is matched case-insensitively against the
+     * directory listing (e.g. {@code IMG/pilv.i01} finds {@code img/}).
+     */
+    private java.nio.file.Path resolveOpenPath(final String path) {
+        final String normalized = path.replace('\\', '/');
+        final java.nio.file.Path direct = baseDirectory.resolve(normalized);
+        if (java.nio.file.Files.exists(direct)) {
+            return direct;
+        }
+        java.nio.file.Path current = baseDirectory;
+        for (final String component : normalized.split("/")) {
+            if (component.isEmpty() || component.equals(".")) {
+                continue;
+            }
+            final java.nio.file.Path candidate = current.resolve(component);
+            if (java.nio.file.Files.exists(candidate)) {
+                current = candidate;
+                continue;
+            }
+            try (var entries = java.nio.file.Files.list(current)) {
+                final java.nio.file.Path match = entries
+                        .filter(p -> p.getFileName().toString().equalsIgnoreCase(component))
+                        .findFirst().orElse(null);
+                if (match == null) {
+                    return direct; // let the open fail with the original path
+                }
+                current = match;
+            } catch (final java.io.IOException e) {
+                return direct;
+            }
+        }
+        return current;
+    }
+
+    /** EOF(n): fills the lookahead so EOF never consumes input. */
+    private boolean fileEof(final int channel, final int line) {
+        final java.io.BufferedReader reader = fileChannels.get(channel);
+        if (reader == null) {
+            throw error("Bad file number: " + channel, line);
+        }
+        if (fileLookahead.containsKey(channel)) {
+            return false;
+        }
+        try {
+            final String ahead = reader.readLine();
+            if (ahead == null) {
+                return true;
+            }
+            fileLookahead.put(channel, ahead);
+            return false;
+        } catch (final java.io.IOException e) {
+            throw error("File read error: " + e.getMessage(), line);
+        }
+    }
+
+    private void closeChannel(final int channel, final int line) {
+        final java.io.BufferedReader reader = fileChannels.remove(channel);
+        fileLookahead.remove(channel);
+        fileItems.remove(channel);
+        if (reader == null) {
+            return; // CLOSE on an unopened channel is ignored
+        }
+        try {
+            reader.close();
+        } catch (final java.io.IOException e) {
+            throw error("File close error: " + e.getMessage(), line);
+        }
+    }
+
+    /**
+     * RND(-n): reseeds the generator from n. The SINGLE bit pattern of
+     * |n| folds into the 24-bit seed as
+     * {@code (bits & 0xFFFFFF) + (bits >>> 24) + 0x80}; this reproduces
+     * the classic deterministic sequences for negative arguments.
+     */
+    private void rndReseed(final double negativeArgument) {
+        final int bits = Float.floatToRawIntBits((float) Math.abs(negativeArgument));
+        rndSeed = ((bits & 0xFFFFFF) + (bits >>> 24) + 0x80) & 0xFFFFFF;
+    }
+
+    private static boolean truthy(final Object value) {
+        return value instanceof Double d && d != 0;
+    }
+
+    private int colorOrDefault(final Ast.Expr color, final int line) {
+        return color == null ? vga.textForeground() : toInt(eval(color), line);
+    }
+
+    private int currentBackground() {
+        return vga.textBackground();
+    }
+
+    /** PRINT formatting: leading space if non-negative, trailing space. */
+    static String basicNumber(final double value) {
+        return basicNumber(value, false);
+    }
+
+    /** PRINT formatting for a numeric expression of known static type. */
+    static String basicNumber(final double value, final boolean isDouble) {
+        final String s = isDouble ? formatDouble16(value) : formatNumber(value);
+        return (value >= 0 ? " " + s : s) + " ";
+    }
+
+    /**
+     * Formats a DOUBLE expression in the classic style: 16 significant
+     * digits of the value's exact decimal expansion, rounded half-even at
+     * the 17th digit, trailing zeros trimmed; scientific notation (D±xx)
+     * outside the fixed range.
+     *
+     * <p>Known gap: some classic implementations of this dialect misprint
+     * certain values by 1-2 ulp in a bit-pattern-dependent way (STR$(592#)
+     * gives "591.9999999999999", STR$(500#) "499.9999999999999", but 321#
+     * and 12714# print exactly there). The exact cause resists modeling —
+     * every constant-error hypothesis fails on same-decade pairs
+     * (300 vs 321, 321 vs 592). This formatter prints the mathematically
+     * correct 16 digits instead.</p>
+     */
+    private static String formatDouble16(final double value) {
+        if (value == 0) {
+            return "0";
+        }
+        final String sign = value < 0 ? "-" : "";
+        final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value));
+        final java.math.BigDecimal rounded = bd.round(
+                new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN));
+        final int exponent = rounded.precision() - rounded.scale() - 1;
+        final String digits = rounded.unscaledValue().toString();
+        if (exponent > 15 || exponent < -3) {
+            // Scientific: d.dddD±ee
+            final String fraction = digits.substring(1).replaceFirst("0+$", "");
+            final String mantissa = digits.charAt(0)
+                    + (fraction.isEmpty() ? "" : "." + fraction);
+            return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-")
+                    + String.format("%02d", Math.abs(exponent));
+        }
+        if (exponent >= 0) {
+            final String intPart = exponent + 1 >= digits.length()
+                    ? digits + "0".repeat(exponent + 1 - digits.length())
+                    : digits.substring(0, exponent + 1);
+            final String fraction = exponent + 1 >= digits.length()
+                    ? "" : digits.substring(exponent + 1).replaceFirst("0+$", "");
+            return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction);
+        }
+        return sign + "." + "0".repeat(-exponent - 1)
+                + digits.replaceFirst("0+$", "");
+    }
+
+    private static String formatNumber(final double value) {
+        // Numbers are formatted at the expression's type; the default
+        // SINGLE type means the value is narrowed to float32 for display.
+        final double narrowed = single(value);
+        if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed) && Math.abs(narrowed) < 1e15) {
+            return Long.toString((long) narrowed);
+        }
+        return Double.toString(narrowed);
+    }
+
+    private double throwDivisionByZero(final int line) {
+        throw error("Division by zero", line);
+    }
+
+    private InterpreterException error(final String message, final int line) {
+        final StringBuilder sb = new StringBuilder(message).append(" at line ").append(line);
+        if (sourceLines != null && line >= 1 && line <= sourceLines.length) {
+            final String text = sourceLines[line - 1].trim();
+            if (!text.isEmpty()) {
+                sb.append(": ").append(text);
+            }
+        }
+        return new InterpreterException(sb.toString());
+    }
+
+    /** Runtime failure with source line context. */
+    public static final class InterpreterException extends RuntimeException {
+        public InterpreterException(final String message) {
+            super(message);
+        }
+    }
+
+    /** Thrown when the statement budget (--steps) is exhausted. */
+    public static final class StepsLimitException extends RuntimeException {
+        private final long count;
+
+        StepsLimitException(final long count) {
+            super("statement limit reached: " + count);
+            this.count = count;
+        }
+
+        public long count() {
+            return count;
+        }
+    }
+
+    /**
+     * Signals a RESUME jump out of an error handler: retry the trapped
+     * statement (label null, next false), continue after it (next true),
+     * or jump to a label.
+     */
+    private static final class ResumeJump extends RuntimeException {
+        private final String label;
+        private final boolean next;
+
+        ResumeJump(final String targetLabel, final boolean resumeNext) {
+            super(null, null, false, false);
+            label = targetLabel;
+            next = resumeNext;
+        }
+    }
+
+    /** Control-flow signal for END. */
+    private static final class ProgramEnd extends RuntimeException {
+        ProgramEnd() {
+            super(null, null, false, false);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java b/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java
new file mode 100644 (file)
index 0000000..28afced
--- /dev/null
@@ -0,0 +1,195 @@
+package eu.svjatoslav.crtbasic.lexer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tokenizes .bas source. Tolerates the realities of DOS files: CRLF
+ * line endings, CP437 high characters (passed through in strings and
+ * identifiers), {@code '} and {@code REM} comments, {@code :} statement
+ * separators.
+ *
+ * <p>Keywords are emitted as plain identifiers; the parser matches them by
+ * text. Both newlines and {@code :} produce {@link Token.Type#SEP}
+ * (statement separator) tokens.</p>
+ */
+public final class Lexer {
+
+    private Lexer() {
+        // Utility class.
+    }
+
+    public static List<Token> tokenize(final String source) {
+        final List<Token> tokens = new ArrayList<>();
+        int pos = 0;
+        int line = 1;
+        final int length = source.length();
+
+        while (pos < length) {
+            final char c = source.charAt(pos);
+
+            // Whitespace (not newline).
+            if (c == ' ' || c == '\t' || c == '\r') {
+                pos++;
+                continue;
+            }
+
+            // Newline: statement separator.
+            if (c == '\n') {
+                tokens.add(new Token(Token.Type.SEP, "\n", line));
+                line++;
+                pos++;
+                continue;
+            }
+
+            // Colon: statement separator.
+            if (c == ':') {
+                tokens.add(new Token(Token.Type.SEP, ":", line));
+                pos++;
+                continue;
+            }
+
+            // Apostrophe comment: skip to end of line.
+            if (c == '\'') {
+                while (pos < length && source.charAt(pos) != '\n') {
+                    pos++;
+                }
+                continue;
+            }
+
+            // String literal.
+            if (c == '"') {
+                final int start = ++pos;
+                while (pos < length && source.charAt(pos) != '"' && source.charAt(pos) != '\n') {
+                    pos++;
+                }
+                tokens.add(new Token(Token.Type.STRING, source.substring(start, pos), line));
+                if (pos < length && source.charAt(pos) == '"') {
+                    pos++;
+                }
+                continue;
+            }
+
+            // Number: decimal (with optional fraction/exponent), hex (&H),
+            // octal (&O).
+            if (Character.isDigit(c) || (c == '.' && pos + 1 < length
+                    && Character.isDigit(source.charAt(pos + 1)))) {
+                final int start = pos;
+                while (pos < length
+                        && (Character.isDigit(source.charAt(pos)) || source.charAt(pos) == '.')) {
+                    pos++;
+                }
+                // Optional exponent: 1.5e3, 2D+10 (D = double exponent).
+                if (pos < length && "eEdD".indexOf(source.charAt(pos)) >= 0) {
+                    final int save = pos;
+                    pos++;
+                    if (pos < length && (source.charAt(pos) == '+' || source.charAt(pos) == '-')) {
+                        pos++;
+                    }
+                    if (pos < length && Character.isDigit(source.charAt(pos))) {
+                        while (pos < length && Character.isDigit(source.charAt(pos))) {
+                            pos++;
+                        }
+                    } else {
+                        pos = save; // was not an exponent after all
+                    }
+                }
+                // Optional type suffix: 1024&, 1.5#, 2! (hex &H… is
+                // lexed by its own branch below and never reaches here).
+                if (pos < length && "%&!#".indexOf(source.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                tokens.add(new Token(Token.Type.NUMBER, source.substring(start, pos), line));
+                continue;
+            }
+            if (c == '&' && pos + 1 < length
+                    && (source.charAt(pos + 1) == 'H' || source.charAt(pos + 1) == 'h'
+                    || source.charAt(pos + 1) == 'O' || source.charAt(pos + 1) == 'o')) {
+                final int start = pos;
+                pos += 2;
+                while (pos < length
+                        && (Character.isDigit(source.charAt(pos))
+                        || (source.charAt(pos) >= 'a' && source.charAt(pos) <= 'f')
+                        || (source.charAt(pos) >= 'A' && source.charAt(pos) <= 'F'))) {
+                    pos++;
+                }
+                tokens.add(new Token(Token.Type.NUMBER, source.substring(start, pos), line));
+                continue;
+            }
+
+            // Identifier / keyword, with optional type suffix.
+            if (Character.isLetter(c)) {
+                final int start = pos;
+                pos++;
+                while (pos < length && isIdentChar(source.charAt(pos))) {
+                    pos++;
+                }
+                final String word = source.substring(start, pos);
+                // REM comment (word-boundary matched): skip to end of line.
+                if (word.equalsIgnoreCase("REM")
+                        && (pos >= length || !isIdentChar(source.charAt(pos)))) {
+                    while (pos < length && source.charAt(pos) != '\n') {
+                        pos++;
+                    }
+                    continue;
+                }
+                if (pos < length && "%&!#$".indexOf(source.charAt(pos)) >= 0) {
+                    pos++;
+                }
+                tokens.add(new Token(Token.Type.IDENT, source.substring(start, pos), line));
+                continue;
+            }
+
+            // Line continuation: a '_' as the last non-blank character of
+            // a line joins the physical lines into one logical statement.
+            if (c == '_') {
+                int probe = pos + 1;
+                while (probe < length && (source.charAt(probe) == ' '
+                        || source.charAt(probe) == '\t' || source.charAt(probe) == '\r')) {
+                    probe++;
+                }
+                if (probe < length && source.charAt(probe) == '\n') {
+                    pos = probe + 1;
+                    line++;
+                    continue;
+                }
+            }
+
+            // Two-character operators.
+            if (pos + 1 < length) {
+                final String two = source.substring(pos, pos + 2);
+                if (two.equals("<=") || two.equals(">=") || two.equals("<>")) {
+                    tokens.add(new Token(Token.Type.OP, two, line));
+                    pos += 2;
+                    continue;
+                }
+            }
+
+            // Single-character operators. '#' appears standalone as the
+            // file-channel prefix (OPEN ... AS #1); directly after an
+            // identifier it was already consumed as a type suffix.
+            if ("+-*/\\^=<>(),#".indexOf(c) >= 0 || c == ';') {
+                tokens.add(new Token(Token.Type.OP, String.valueOf(c), line));
+                pos++;
+                continue;
+            }
+
+            throw new LexException("Unexpected character '" + c + "' (0x"
+                    + Integer.toHexString(c) + ") at line " + line);
+        }
+
+        tokens.add(new Token(Token.Type.EOF, "", line));
+        return tokens;
+    }
+
+    private static boolean isIdentChar(final char c) {
+        return Character.isLetterOrDigit(c) || c == '.' || c == '_';
+    }
+
+    /** Thrown on un-tokenizable input. */
+    public static final class LexException extends RuntimeException {
+        public LexException(final String message) {
+            super(message);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java b/src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java
new file mode 100644 (file)
index 0000000..6eef311
--- /dev/null
@@ -0,0 +1,30 @@
+package eu.svjatoslav.crtbasic.lexer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * One token from BASIC source.
+ *
+ * @param type broad category — keywords are not distinguished from
+ *             identifiers here (the parser matches statement keywords by
+ *             text, case-insensitively)
+ * @param text the token text (identifiers/keywords as written, strings
+ *             without quotes, numbers as written)
+ * @param line 1-based source line for error reporting
+ */
+public record Token(Type type, String text, int line) {
+
+    public enum Type {
+        IDENT, NUMBER, STRING, OP, SEP, EOF
+    }
+
+    public boolean is(final Type expected) {
+        return type == expected;
+    }
+
+    /** Case-insensitive text match, for keywords and operators. */
+    public boolean isText(final String expected) {
+        return text.equalsIgnoreCase(expected);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
new file mode 100644 (file)
index 0000000..0714a18
--- /dev/null
@@ -0,0 +1,1254 @@
+package eu.svjatoslav.crtbasic.parser;
+
+import eu.svjatoslav.crtbasic.ast.Ast;
+import eu.svjatoslav.crtbasic.lexer.Lexer;
+import eu.svjatoslav.crtbasic.lexer.Token;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Recursive-descent parser for the supported BASIC subset.
+ *
+ * <p>Supported: SCREEN, LOCATE, PRINT, CLS, COLOR, PSET, LINE (B/BF),
+ * CIRCLE, SOUND, assignment, FOR/NEXT, single-line IF/THEN/ELSE,
+ * WHILE/WEND, END, comments. Anything else throws
+ * {@link ParseException} naming the statement and line — that precision is
+ * what tells us which statement to implement next.</p>
+ */
+public final class Parser {
+
+    private final List<Token> tokens;
+    private final String[] sourceLines;
+    private int pos;
+
+    private Parser(final List<Token> tokens, final String[] sourceLines) {
+        this.tokens = tokens;
+        this.sourceLines = sourceLines;
+    }
+
+    public static List<Ast.Stmt> parse(final String source) {
+        return new Parser(Lexer.tokenize(source), source.split("\n", -1)).program();
+    }
+
+    // -------------------------------------------------------------- program
+
+    private List<Ast.Stmt> program() {
+        final List<Ast.Stmt> statements = new ArrayList<>();
+        skipSeparators();
+        while (!peek().is(Token.Type.EOF)) {
+            statements.add(statement());
+            skipSeparators();
+        }
+        return statements;
+    }
+
+    /** Parses statements until one of the given keywords (not consumed). */
+    private List<Ast.Stmt> blockUntil(final String... keywords) {
+        final List<Ast.Stmt> body = new ArrayList<>();
+        skipSeparators();
+        while (!peek().is(Token.Type.EOF) && !isAny(keywords)) {
+            body.add(statement());
+            skipSeparators();
+        }
+        return body;
+    }
+
+    // ------------------------------------------------------------ statements
+
+    private Ast.Stmt statement() {
+        final Token token = peek();
+        // Numeric line label (e.g. the 1 in "1 IF c > 30 THEN").
+        if (token.is(Token.Type.NUMBER)) {
+            next();
+            return new Ast.LabelStmt(token.text(), token.line());
+        }
+        if (token.is(Token.Type.IDENT)) {
+            final String keyword = token.text().toUpperCase();
+            return switch (keyword) {
+                case "SCREEN" -> screenStatement();
+                case "LOCATE" -> locateStatement();
+                case "PRINT", "?" -> printStatement();
+                case "CLS" -> {
+                    next();
+                    yield new Ast.ClsStmt(token.line());
+                }
+                case "COLOR" -> colorStatement();
+                case "PSET" -> psetStatement();
+                case "LINE" -> lineStatement();
+                case "CIRCLE" -> circleStatement();
+                case "SOUND" -> soundStatement();
+                case "SLEEP" -> sleepStatement();
+                case "OUT" -> outStatement();
+                case "RANDOMIZE" -> randomizeStatement();
+                case "OPEN" -> openStatement();
+                case "CLOSE" -> closeStatement();
+                case "ON" -> onErrorStatement();
+                case "RESUME" -> resumeStatement();
+                case "DATA" -> dataStatement();
+                case "READ" -> readStatement();
+                case "VIEW" -> viewStatement();
+                case "PAINT" -> paintStatement();
+                case "PCOPY" -> pcopyStatement();
+                case "GET" -> getStatement();
+                case "PUT" -> putStatement();
+                case "MID$" -> midStatement();
+                case "DIM" -> dimStatement();
+                case "FOR" -> forStatement();
+                case "IF" -> ifStatement();
+                case "WHILE" -> whileStatement();
+                case "DO" -> doStatement();
+                case "GOTO" -> gotoStatement();
+                case "SUB" -> subStatement();
+                case "FUNCTION" -> functionStatement();
+                case "SELECT" -> selectStatement();
+                case "CALL" -> callStatement();
+                case "DECLARE" -> declareStatement();
+                case "DEFINT", "DEFSNG", "DEFDBL", "DEFLNG", "DEFSTR" -> defTypeStatement(keyword);
+                case "INPUT" -> inputStatement();
+                case "ERASE" -> eraseStatement();
+                case "SYSTEM" -> {
+                    next();
+                    yield new Ast.EndStmt(token.line());
+                }
+                case "END" -> {
+                    next();
+                    yield new Ast.EndStmt(token.line());
+                }
+                case "NEXT", "WEND", "ELSE", "THEN", "TO", "STEP", "ENDIF", "LOOP" ->
+                        throw error("Unexpected " + keyword, token);
+                default -> assignmentOrCallStatement();
+            };
+        }
+        throw error("Unsupported statement", token);
+    }
+
+    private Ast.Stmt gotoStatement() {
+        final Token keyword = next(); // GOTO
+        final Token label = peek();
+        if (!label.is(Token.Type.NUMBER) && !label.is(Token.Type.IDENT)) {
+            throw error("Expected label after GOTO", label);
+        }
+        next();
+        return new Ast.GotoStmt(label.text(), keyword.line());
+    }
+
+    /** {@code SUB name [(param [AS type], ...)] ... END SUB} */
+    private Ast.Stmt subStatement() {
+        final Token keyword = next(); // SUB
+        final Token name = expect(Token.Type.IDENT);
+        final List<String> params = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            if (!peek().isText(")")) {
+                params.add(paramName());
+                while (peek().isText(",")) {
+                    next();
+                    params.add(paramName());
+                }
+            }
+            expectOp(")");
+        }
+        final List<Ast.Stmt> body = procedureBody("SUB");
+        expectText("END");
+        expectText("SUB");
+        return new Ast.SubStmt(name.text(), params, body, keyword.line());
+    }
+
+    /** {@code FUNCTION name [(param, ...)] ... END FUNCTION} */
+    private Ast.Stmt functionStatement() {
+        final Token keyword = next(); // FUNCTION
+        final Token name = expect(Token.Type.IDENT);
+        final List<String> params = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            if (!peek().isText(")")) {
+                params.add(paramName());
+                while (peek().isText(",")) {
+                    next();
+                    params.add(paramName());
+                }
+            }
+            expectOp(")");
+        }
+        final List<Ast.Stmt> body = procedureBody("FUNCTION");
+        expectText("END");
+        expectText("FUNCTION");
+        return new Ast.FunctionStmt(name.text(), params, body, keyword.line());
+    }
+
+    /**
+     * Parses a SUB/FUNCTION body: stops at END followed by the given
+     * keyword — a bare END inside the body is the program-end statement,
+     * not the end of the procedure.
+     */
+    private List<Ast.Stmt> procedureBody(final String endKeyword) {
+        final List<Ast.Stmt> body = new ArrayList<>();
+        skipSeparators();
+        while (!peek().is(Token.Type.EOF)) {
+            if (isAny("END") && pos + 1 < tokens.size()
+                    && tokens.get(pos + 1).isText(endKeyword)) {
+                break;
+            }
+            body.add(statement());
+            skipSeparators();
+        }
+        return body;
+    }
+
+    /**
+     * {@code SELECT CASE subject / CASE v1, v2 ... / CASE ELSE ... / END SELECT}.
+     * Equality cases and inclusive {@code a TO b} ranges are supported
+     * (no CASE IS).
+     */
+    private Ast.Stmt selectStatement() {
+        final Token keyword = next(); // SELECT
+        expectText("CASE");
+        final Ast.Expr subject = expression();
+        final List<Ast.CaseBranch> cases = new ArrayList<>();
+        List<Ast.Stmt> elseBody = List.of();
+        // Statements between the subject line and the first CASE are dead
+        // in classic BASIC but must still parse.
+        caseBody();
+        while (isAny("CASE")) {
+            next(); // CASE
+            if (isAny("ELSE")) {
+                next();
+                elseBody = caseBody();
+                break;
+            }
+            final List<Ast.CaseTest> tests = new ArrayList<>();
+            tests.add(caseTest());
+            while (peek().isText(",")) {
+                next();
+                tests.add(caseTest());
+            }
+            cases.add(new Ast.CaseBranch(tests, caseBody()));
+        }
+        expectText("END");
+        expectText("SELECT");
+        return new Ast.SelectStmt(subject, cases, elseBody, keyword.line());
+    }
+
+    /** One CASE test: a plain value, or a {@code lo TO hi} inclusive range. */
+    private Ast.CaseTest caseTest() {
+        final Ast.Expr lo = expression();
+        if (peek().isText("TO")) {
+            next(); // TO
+            return new Ast.CaseTest(lo, expression());
+        }
+        return new Ast.CaseTest(lo, null);
+    }
+
+    /**
+     * Parses a CASE branch body: stops at the next CASE, or at END only
+     * when it is followed by SELECT — a bare END inside a branch is the
+     * program-end statement, not the end of the SELECT block.
+     */
+    private List<Ast.Stmt> caseBody() {
+        final List<Ast.Stmt> body = new ArrayList<>();
+        skipSeparators();
+        while (!peek().is(Token.Type.EOF)) {
+            if (isAny("CASE")) {
+                break;
+            }
+            if (isAny("END") && pos + 1 < tokens.size()
+                    && tokens.get(pos + 1).isText("SELECT")) {
+                break;
+            }
+            body.add(statement());
+            skipSeparators();
+        }
+        return body;
+    }
+
+    /** Parameter name with optional {@code AS type} suffix (type not modeled). */
+    private String paramName() {
+        final Token name = expect(Token.Type.IDENT);
+        if (isAny("AS")) {
+            next();
+            expect(Token.Type.IDENT); // type name: SINGLE, INTEGER, ...
+        }
+        return name.text();
+    }
+
+    /** {@code CALL name [(arg, ...)]} */
+    private Ast.Stmt callStatement() {
+        final Token keyword = next(); // CALL
+        final Token name = expect(Token.Type.IDENT);
+        final List<Ast.Expr> args = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            if (!peek().isText(")")) {
+                args.add(expression());
+                while (peek().isText(",")) {
+                    next();
+                    args.add(expression());
+                }
+            }
+            expectOp(")");
+        }
+        return new Ast.CallStmt(name.text(), args, keyword.line());
+    }
+
+    /**
+     * {@code DECLARE SUB/FUNCTION name (params)} — the statement itself is
+     * skipped, but the parameter names (with type suffixes) are kept: when
+     * the actual SUB/FUNCTION header lists bare names, the DECLARE'd
+     * suffixes decide the parameter types.
+     */
+    private Ast.Stmt declareStatement() {
+        final Token keyword = next(); // DECLARE
+        final String kind = expect(Token.Type.IDENT).text().toUpperCase();
+        final Token name = expect(Token.Type.IDENT);
+        final List<String> params = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            if (!peek().isText(")")) {
+                params.add(paramName());
+                while (peek().isText(",")) {
+                    next();
+                    params.add(paramName());
+                }
+            }
+            expectOp(")");
+        }
+        return new Ast.DeclareStmt(kind + " " + name.text(), params, keyword.line());
+    }
+
+    /**
+     * {@code DEFINT A-Z, Q} and siblings: parses the letter ranges so the
+     * interpreter can apply the default type suffix to suffix-less names.
+     */
+    private Ast.Stmt defTypeStatement(final String keyword) {
+        final Token token = next(); // DEFxxx
+        final char suffix = switch (keyword) {
+            case "DEFINT" -> '%';
+            case "DEFLNG" -> '&';
+            case "DEFDBL" -> '#';
+            case "DEFSTR" -> '$';
+            default -> '!';
+        };
+        final List<int[]> ranges = new ArrayList<>();
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            final Token from = next();
+            if (from.isText(",")) {
+                continue;
+            }
+            final int lo = letterCode(from);
+            int hi = lo;
+            if (peek().isText("-")) {
+                next();
+                hi = letterCode(next());
+            }
+            if (lo >= 0 && hi >= lo) {
+                ranges.add(new int[]{lo, hi});
+            }
+        }
+        return new Ast.DefTypeStmt(suffix, ranges, token.line());
+    }
+
+    /** @return 0-based a..z code for a single-letter token, or -1 */
+    private static int letterCode(final Token token) {
+        if (!token.is(Token.Type.IDENT) || token.text().length() != 1) {
+            return -1;
+        }
+        final char c = Character.toLowerCase(token.text().charAt(0));
+        return c >= 'a' && c <= 'z' ? c - 'a' : -1;
+    }
+
+    private Ast.Stmt outStatement() {
+        final Token keyword = next(); // OUT
+        final Ast.Expr port = expression();
+        expectOp(",");
+        final Ast.Expr value = expression();
+        return new Ast.OutStmt(port, value, keyword.line());
+    }
+
+    /** {@code RANDOMIZE [seed]} — no seed means RANDOMIZE TIMER. */
+    private Ast.Stmt randomizeStatement() {
+        final Token keyword = next(); // RANDOMIZE
+        final Ast.Expr seed = peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)
+                ? null : expression();
+        return new Ast.RandomizeStmt(seed, keyword.line());
+    }
+
+    /** {@code SLEEP [seconds]} — no argument means "wait for a keypress". */
+    private Ast.Stmt sleepStatement() {
+        final Token keyword = next(); // SLEEP
+        final Ast.Expr seconds = peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)
+                ? null : expression();
+        return new Ast.SleepStmt(seconds, keyword.line());
+    }
+
+    /** {@code OPEN path FOR INPUT AS #channel} — only INPUT mode is supported. */
+    private Ast.Stmt openStatement() {
+        final Token keyword = next(); // OPEN
+        final Ast.Expr path = expression();
+        expectText("FOR");
+        final Token mode = expect(Token.Type.IDENT);
+        if (!"INPUT".equalsIgnoreCase(mode.text())) {
+            throw error("OPEN: only FOR INPUT is supported", mode);
+        }
+        expectText("AS");
+        expectOp("#");
+        final Ast.Expr channel = expression();
+        return new Ast.OpenStmt(path, channel, keyword.line());
+    }
+
+    /** {@code CLOSE [#channel]}. */
+    private Ast.Stmt closeStatement() {
+        final Token keyword = next(); // CLOSE
+        if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+            return new Ast.CloseStmt(null, keyword.line());
+        }
+        if (peek().isText("#")) {
+            next();
+        }
+        return new Ast.CloseStmt(expression(), keyword.line());
+    }
+
+    /** {@code INPUT #channel, var, ...} — file form only; console INPUT unsupported. */
+    private Ast.Stmt inputStatement() {
+        final Token keyword = next(); // INPUT
+        if (!peek().isText("#")) {
+            throw error("Console INPUT is not implemented yet (only INPUT #file)", keyword);
+        }
+        next(); // #
+        final Ast.Expr channel = expression();
+        final List<Ast.ReadTarget> targets = new ArrayList<>();
+        do {
+            expectOp(",");
+            targets.add(readTarget());
+        } while (peek().isText(","));
+        return new Ast.InputFileStmt(channel, targets, keyword.line());
+    }
+
+    /** {@code ERASE name, name, ...}. */
+    private Ast.Stmt eraseStatement() {
+        final Token keyword = next(); // ERASE
+        final List<String> names = new ArrayList<>();
+        names.add(expect(Token.Type.IDENT).text());
+        while (peek().isText(",")) {
+            next();
+            names.add(expect(Token.Type.IDENT).text());
+        }
+        return new Ast.EraseStmt(names, keyword.line());
+    }
+
+    /** {@code ON ERROR GOTO label} — only this form (no ON n GOTO). */
+    private Ast.Stmt onErrorStatement() {
+        final Token keyword = next(); // ON
+        expectText("ERROR");
+        expectText("GOTO");
+        final Token label = peek();
+        if (!label.is(Token.Type.NUMBER) && !label.is(Token.Type.IDENT)) {
+            throw error("Expected label after ON ERROR GOTO", label);
+        }
+        next();
+        return new Ast.OnErrorStmt(label.text(), keyword.line());
+    }
+
+    /** {@code RESUME [NEXT | label]}. */
+    private Ast.Stmt resumeStatement() {
+        final Token keyword = next(); // RESUME
+        if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+            return new Ast.ResumeStmt(null, false, keyword.line());
+        }
+        final Token target = next();
+        if (target.is(Token.Type.IDENT) && "NEXT".equalsIgnoreCase(target.text())) {
+            return new Ast.ResumeStmt(null, true, keyword.line());
+        }
+        if (target.is(Token.Type.NUMBER) || target.is(Token.Type.IDENT)) {
+            return new Ast.ResumeStmt(target.text(), false, keyword.line());
+        }
+        throw error("Expected NEXT or label after RESUME", target);
+    }
+
+    /** {@code DATA value, ...} — comma-separated constants. */
+    private Ast.Stmt dataStatement() {
+        final Token keyword = next(); // DATA
+        final List<Ast.Expr> values = new ArrayList<>();
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            values.add(expression());
+            if (peek().isText(",")) {
+                next();
+            } else {
+                break;
+            }
+        }
+        return new Ast.DataStmt(values, keyword.line());
+    }
+
+    /** {@code READ var, array(i), ...}. */
+    private Ast.Stmt readStatement() {
+        final Token keyword = next(); // READ
+        final List<Ast.ReadTarget> targets = new ArrayList<>();
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            targets.add(readTarget());
+            if (peek().isText(",")) {
+                next();
+            } else {
+                break;
+            }
+        }
+        return new Ast.ReadStmt(targets, keyword.line());
+    }
+
+    /** One assignment target: a plain variable or an array element {@code name(i, j)}. */
+    private Ast.ReadTarget readTarget() {
+        final Token name = expect(Token.Type.IDENT);
+        final List<Ast.Expr> indices = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            indices.add(expression());
+            while (peek().isText(",")) {
+                next();
+                indices.add(expression());
+            }
+            expectOp(")");
+        }
+        return new Ast.ReadTarget(name.text(), indices);
+    }
+
+    /** {@code VIEW PRINT [top TO bottom]} (graphics VIEW is unsupported). */
+    private Ast.Stmt viewStatement() {
+        final Token keyword = next(); // VIEW
+        expectText("PRINT");
+        Ast.Expr top = null;
+        Ast.Expr bottom = null;
+        if (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            top = expression();
+            expectText("TO");
+            bottom = expression();
+        }
+        return new Ast.ViewPrintStmt(top, bottom, keyword.line());
+    }
+
+    /** {@code PAINT (x, y)[, fillColor[, borderColor]]} */
+    private Ast.Stmt paintStatement() {
+        final Token keyword = next(); // PAINT
+        expectOp("(");
+        final Ast.Expr x = expression();
+        expectOp(",");
+        final Ast.Expr y = expression();
+        expectOp(")");
+        Ast.Expr fill = null;
+        Ast.Expr border = null;
+        if (peek().isText(",")) {
+            next();
+            fill = expression();
+            if (peek().isText(",")) {
+                next();
+                border = expression();
+            }
+        }
+        return new Ast.PaintStmt(x, y, fill, border, keyword.line());
+    }
+
+    /** {@code DIM [SHARED] name[(bounds)] [AS type], ...} */
+    private Ast.Stmt dimStatement() {
+        final Token keyword = next(); // DIM
+        boolean shared = false;
+        if (isAny("SHARED")) {
+            next();
+            shared = true;
+        }
+        final List<Ast.DimEntry> entries = new ArrayList<>();
+        while (true) {
+            final Token name = expect(Token.Type.IDENT);
+            final List<Ast.Bound> bounds = new ArrayList<>();
+            if (peek().isText("(")) {
+                next();
+                bounds.add(arrayBound());
+                while (peek().isText(",")) {
+                    next();
+                    bounds.add(arrayBound());
+                }
+                expectOp(")");
+            }
+            entries.add(new Ast.DimEntry(name.text(), bounds, null));
+            if (isAny("AS")) {
+                next();
+                final String typeName = expect(Token.Type.IDENT).text();
+                // Fixed-length string: STRING * n. The length is parsed
+                // but ignored; strings are dynamically sized here.
+                if ("STRING".equalsIgnoreCase(typeName) && peek().isText("*")) {
+                    next();
+                    expression();
+                }
+                entries.set(entries.size() - 1,
+                        new Ast.DimEntry(name.text(), bounds, typeName));
+            }
+            if (!peek().isText(",")) {
+                break;
+            }
+            next();
+        }
+        return new Ast.DimStmt(entries, shared, keyword.line());
+    }
+
+    /** Array dimension: {@code high} or {@code low TO high}. */
+    private Ast.Bound arrayBound() {
+        final Ast.Expr first = expression();
+        if (isAny("TO")) {
+            next();
+            return new Ast.Bound(first, expression());
+        }
+        return new Ast.Bound(null, first);
+    }
+
+    /** {@code GET (x1, y1)-(x2, y2), arrayName[(i, j)]} */
+    private Ast.Stmt getStatement() {
+        final Token keyword = next(); // GET
+        expectOp("(");
+        final Ast.Expr x1 = expression();
+        expectOp(",");
+        final Ast.Expr y1 = expression();
+        expectOp(")");
+        expectOp("-");
+        expectOp("(");
+        final Ast.Expr x2 = expression();
+        expectOp(",");
+        final Ast.Expr y2 = expression();
+        expectOp(")");
+        expectOp(",");
+        final Token array = expect(Token.Type.IDENT);
+        return new Ast.GetStmt(x1, y1, x2, y2, array.text(),
+                arrayOffsetIndices(), keyword.line());
+    }
+
+    /** Optional {@code (i, j)} array-element anchor after a GET/PUT array name. */
+    private List<Ast.Expr> arrayOffsetIndices() {
+        final List<Ast.Expr> indices = new ArrayList<>();
+        if (!peek().isText("(")) {
+            return indices;
+        }
+        next(); // (
+        indices.add(expression());
+        while (peek().isText(",")) {
+            next();
+            indices.add(expression());
+        }
+        expectOp(")");
+        return indices;
+    }
+
+    /** {@code PUT (x, y), arrayName[(i, j)][, PSET|PRESET|XOR|OR|AND]} — default action is XOR. */
+    private Ast.Stmt putStatement() {
+        final Token keyword = next(); // PUT
+        expectOp("(");
+        final Ast.Expr x = expression();
+        expectOp(",");
+        final Ast.Expr y = expression();
+        expectOp(")");
+        expectOp(",");
+        final Token array = expect(Token.Type.IDENT);
+        final List<Ast.Expr> indices = arrayOffsetIndices();
+        String action = "XOR";
+        if (peek().isText(",")) {
+            next();
+            action = expect(Token.Type.IDENT).text().toUpperCase();
+        }
+        return new Ast.PutStmt(x, y, array.text(), indices, action, keyword.line());
+    }
+
+    /** {@code MID$(target, start[, length]) = replacement} — in-place string overwrite. */
+    private Ast.Stmt midStatement() {
+        final Token keyword = next(); // MID$
+        expectOp("(");
+        final Token name = expect(Token.Type.IDENT);
+        final List<Ast.Expr> indices = new ArrayList<>();
+        if (peek().isText("(")) {
+            next();
+            indices.add(expression());
+            while (peek().isText(",")) {
+                next();
+                indices.add(expression());
+            }
+            expectOp(")");
+        }
+        expectOp(",");
+        final Ast.Expr start = expression();
+        Ast.Expr length = null;
+        if (peek().isText(",")) {
+            next();
+            length = expression();
+        }
+        expectOp(")");
+        expectOp("=");
+        return new Ast.MidStmt(new Ast.ReadTarget(name.text(), indices),
+                start, length, expression(), keyword.line());
+    }
+
+    private Ast.Stmt screenStatement() {
+        final Token keyword = next(); // SCREEN
+        final Ast.Expr mode = expression();
+        // Optional ", colorburst, apage, vpage" — any slot may be left
+        // empty (SCREEN 7, , , 1). colorburst is parsed but not modeled.
+        final List<Ast.Expr> optional = new ArrayList<>();
+        while (peek().isText(",") && optional.size() < 3) {
+            next();
+            if (peek().isText(",") || peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+                optional.add(null);
+            } else {
+                optional.add(expression());
+            }
+        }
+        final Ast.Expr activePage = optional.size() > 1 ? optional.get(1) : null;
+        final Ast.Expr visualPage = optional.size() > 2 ? optional.get(2) : null;
+        return new Ast.ScreenStmt(mode, activePage, visualPage, keyword.line());
+    }
+
+    private Ast.Stmt pcopyStatement() {
+        final Token keyword = next(); // PCOPY
+        final Ast.Expr source = expression();
+        expectOp(",");
+        final Ast.Expr target = expression();
+        return new Ast.PcopyStmt(source, target, keyword.line());
+    }
+
+    private Ast.Stmt locateStatement() {
+        final Token keyword = next(); // LOCATE
+        final Ast.Expr row = expression();
+        expectOp(",");
+        final Ast.Expr col = expression();
+        return new Ast.LocateStmt(row, col, keyword.line());
+    }
+
+    private Ast.Stmt printStatement() {
+        final Token keyword = next(); // PRINT or ?
+        final List<Ast.PrintStmt.Item> items = new ArrayList<>();
+        boolean newline = true;
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)
+                && !isAny("ELSE")) {
+            final Ast.Expr expr = expression();
+            char separator = ' ';
+            if (peek().isText(";")) {
+                next();
+                separator = ';';
+            } else if (peek().isText(",")) {
+                next();
+                separator = ',';
+            }
+            items.add(new Ast.PrintStmt.Item(expr, separator));
+            newline = separator == ' ';
+            if (separator != ' ') {
+                newline = false;
+                // trailing separator at end of line ends the statement
+                if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+                    break;
+                }
+            }
+        }
+        return new Ast.PrintStmt(items, newline, keyword.line());
+    }
+
+    private Ast.Stmt colorStatement() {
+        final Token keyword = next(); // COLOR
+        final Ast.Expr foreground = expression();
+        Ast.Expr background = null;
+        if (peek().isText(",")) {
+            next();
+            background = expression();
+        }
+        return new Ast.ColorStmt(foreground, background, keyword.line());
+    }
+
+    private Ast.Stmt psetStatement() {
+        final Token keyword = next(); // PSET
+        expectOp("(");
+        final Ast.Expr x = expression();
+        expectOp(",");
+        final Ast.Expr y = expression();
+        expectOp(")");
+        Ast.Expr color = null;
+        if (peek().isText(",")) {
+            next();
+            color = expression();
+        }
+        return new Ast.PsetStmt(x, y, color, keyword.line());
+    }
+
+    private Ast.Stmt lineStatement() {
+        final Token keyword = next(); // LINE
+        if (isAny("INPUT")) {
+            // LINE INPUT #channel, var$ — file input, not graphics.
+            next();
+            expectOp("#");
+            final Ast.Expr channel = expression();
+            expectOp(",");
+            return new Ast.LineInputStmt(channel, readTarget(), keyword.line());
+        }
+        expectOp("(");
+        final Ast.Expr x1 = expression();
+        expectOp(",");
+        final Ast.Expr y1 = expression();
+        expectOp(")");
+        expectOp("-");
+        expectOp("(");
+        final Ast.Expr x2 = expression();
+        expectOp(",");
+        final Ast.Expr y2 = expression();
+        expectOp(")");
+        Ast.Expr color = null;
+        boolean box = false;
+        boolean fill = false;
+        if (peek().isText(",")) {
+            next();
+            color = expression();
+            if (peek().isText(",")) {
+                next();
+                if (peek().isText("BF")) {
+                    next();
+                    box = true;
+                    fill = true;
+                } else if (peek().isText("B")) {
+                    next();
+                    box = true;
+                } else {
+                    throw error("Expected B or BF", peek());
+                }
+            }
+        }
+        return new Ast.LineStmt(x1, y1, x2, y2, color, box, fill, keyword.line());
+    }
+
+    private Ast.Stmt circleStatement() {
+        final Token keyword = next(); // CIRCLE
+        expectOp("(");
+        final Ast.Expr x = expression();
+        expectOp(",");
+        final Ast.Expr y = expression();
+        expectOp(")");
+        expectOp(",");
+        final Ast.Expr radius = expression();
+        // Optional ", color, start, end, aspect" — any slot may be left
+        // empty (CIRCLE (x, y), r, c, , , aspect).
+        final List<Ast.Expr> optional = new ArrayList<>();
+        while (peek().isText(",") && optional.size() < 4) {
+            next();
+            if (peek().isText(",") || peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
+                optional.add(null);
+            } else {
+                optional.add(expression());
+            }
+        }
+        final Ast.Expr color = optional.isEmpty() ? null : optional.get(0);
+        final Ast.Expr start = optional.size() > 1 ? optional.get(1) : null;
+        final Ast.Expr end = optional.size() > 2 ? optional.get(2) : null;
+        final Ast.Expr aspect = optional.size() > 3 ? optional.get(3) : null;
+        return new Ast.CircleStmt(x, y, radius, color, start, end, aspect, keyword.line());
+    }
+
+    private Ast.Stmt soundStatement() {
+        final Token keyword = next(); // SOUND
+        final Ast.Expr frequency = expression();
+        expectOp(",");
+        final Ast.Expr duration = expression();
+        return new Ast.SoundStmt(frequency, duration, keyword.line());
+    }
+
+    private Ast.Stmt assignmentOrCallStatement() {
+        final Token name = next(); // variable, label or SUB name
+        if (peek().isText(":")) {
+            // `Name:` is an alphanumeric label definition only at the start
+            // of a logical line. After THEN or a ':' separator it is a
+            // parameterless bare SUB call followed by the next statement —
+            // Classic interpreters resolve the ambiguity through the DECLARE/SUB symbol
+            // table; line-start position is the practical equivalent
+            // (Pomppu Paavo 2: `IF ... THEN a = 1: LoadCurrentLevel: b = 3`).
+            if (isAtLineStart()) {
+                next(); // :
+                return new Ast.LabelStmt(name.text(), name.line());
+            }
+            return new Ast.CallStmt(name.text(), List.of(), name.line());
+        }
+        if (peek().isText("=")) {
+            next(); // =
+            return new Ast.AssignStmt(name.text(), List.of(), expression(), name.line());
+        }
+        if (peek().isText("(")) {
+            // array(i, j) = value — or a parenthesized SUB call
+            next(); // (
+            final List<Ast.Expr> indices = new ArrayList<>();
+            indices.add(expression());
+            while (peek().isText(",")) {
+                next();
+                indices.add(expression());
+            }
+            expectOp(")");
+            if (peek().isText("=")) {
+                next(); // =
+                return new Ast.AssignStmt(name.text(), indices, expression(), name.line());
+            }
+            return new Ast.CallStmt(name.text(), indices, name.line());
+        }
+        // Bare SUB call: Name arg, arg, ...
+        final List<Ast.Expr> args = new ArrayList<>();
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+            args.add(expression());
+            if (peek().isText(",")) {
+                next();
+            } else {
+                break;
+            }
+        }
+        return new Ast.CallStmt(name.text(), args, name.line());
+    }
+
+    private Ast.Stmt forStatement() {
+        final Token keyword = next(); // FOR
+        final Token variable = expect(Token.Type.IDENT);
+        expectOp("=");
+        final Ast.Expr from = expression();
+        expectText("TO");
+        final Ast.Expr to = expression();
+        Ast.Expr step = null;
+        if (peek().isText("STEP")) {
+            next();
+            step = expression();
+        }
+        final List<Ast.Stmt> body = blockUntil("NEXT");
+        expectText("NEXT");
+        // Optional variable name after NEXT.
+        if (peek().is(Token.Type.IDENT)) {
+            next();
+        }
+        return new Ast.ForStmt(variable.text(), from, to, step, body, keyword.line());
+    }
+
+    private Ast.Stmt ifStatement() {
+        final Token keyword = next(); // IF
+        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
+            final List<Ast.Stmt> thenBody = ifBranchBody();
+            List<Ast.Stmt> elseBody = List.of();
+            if (isAny("ELSE")) {
+                next();
+                elseBody = ifBranchBody();
+            }
+            endIfTail();
+            return new Ast.IfStmt(condition, thenBody, elseBody, keyword.line());
+        }
+        // Single-line form: IF cond THEN stmts [ELSE stmts]
+        final List<Ast.Stmt> thenBody = new ArrayList<>();
+        while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF) && !isAny("ELSE")) {
+            thenBody.add(statement());
+            skipInlineSeparators();
+        }
+        List<Ast.Stmt> elseBody = List.of();
+        if (isAny("ELSE")) {
+            next();
+            final List<Ast.Stmt> collected = new ArrayList<>();
+            while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)) {
+                collected.add(statement());
+                skipInlineSeparators();
+            }
+            elseBody = collected;
+        }
+        return new Ast.IfStmt(condition, thenBody, elseBody, keyword.line());
+    }
+
+    /**
+     * Parses a block-IF branch body: stops at ELSE, 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).
+     */
+    private List<Ast.Stmt> ifBranchBody() {
+        final List<Ast.Stmt> body = new ArrayList<>();
+        skipSeparators();
+        while (!peek().is(Token.Type.EOF)) {
+            if (isAny("ELSE") || isAny("ENDIF")) {
+                break;
+            }
+            if (isAny("END") && pos + 1 < tokens.size()
+                    && tokens.get(pos + 1).isText("IF")) {
+                break;
+            }
+            body.add(statement());
+            skipSeparators();
+        }
+        return body;
+    }
+
+    /** Consumes the {@code END IF} (or {@code ENDIF}) closing a block IF. */
+    private void endIfTail() {
+        if (isAny("ENDIF")) {
+            next();
+            return;
+        }
+        expectText("END");
+        expectText("IF");
+    }
+
+    private Ast.Stmt whileStatement() {
+        final Token keyword = next(); // WHILE
+        final Ast.Expr condition = expression();
+        final List<Ast.Stmt> body = blockUntil("WEND");
+        expectText("WEND");
+        return new Ast.WhileStmt(condition, body, keyword.line());
+    }
+
+    /** {@code DO [WHILE|UNTIL cond] ... LOOP [WHILE|UNTIL cond]} — nests via blockUntil. */
+    private Ast.Stmt doStatement() {
+        final Token keyword = next(); // DO
+        Ast.Expr preCondition = null;
+        boolean preIsUntil = false;
+        if (isAny("WHILE") || isAny("UNTIL")) {
+            preIsUntil = next().text().equalsIgnoreCase("UNTIL");
+            preCondition = expression();
+        }
+        final List<Ast.Stmt> body = blockUntil("LOOP");
+        expectText("LOOP");
+        Ast.Expr postCondition = null;
+        boolean postIsUntil = false;
+        if (isAny("WHILE") || isAny("UNTIL")) {
+            postIsUntil = next().text().equalsIgnoreCase("UNTIL");
+            postCondition = expression();
+        }
+        return new Ast.DoStmt(preCondition, preIsUntil, postCondition, postIsUntil,
+                body, keyword.line());
+    }
+
+    // ------------------------------------------------------------ expressions
+
+    private Ast.Expr expression() {
+        return orExpression();
+    }
+
+    private Ast.Expr orExpression() {
+        Ast.Expr left = andExpression();
+        while (peek().is(Token.Type.IDENT) && peek().isText("OR")) {
+            final Token op = next();
+            left = new Ast.Binary("OR", left, andExpression(), op.line());
+        }
+        return left;
+    }
+
+    private Ast.Expr andExpression() {
+        Ast.Expr left = notExpression();
+        while (peek().is(Token.Type.IDENT) && peek().isText("AND")) {
+            final Token op = next();
+            left = new Ast.Binary("AND", left, notExpression(), op.line());
+        }
+        return left;
+    }
+
+    private Ast.Expr notExpression() {
+        if (peek().is(Token.Type.IDENT) && peek().isText("NOT")) {
+            final Token op = next();
+            return new Ast.Unary("NOT", notExpression(), op.line());
+        }
+        return comparison();
+    }
+
+    private Ast.Expr comparison() {
+        Ast.Expr left = additive();
+        while (peek().is(Token.Type.OP)
+                && List.of("=", "<>", "<", "<=", ">", ">=").contains(peek().text())) {
+            final Token op = next();
+            left = new Ast.Binary(op.text(), left, additive(), op.line());
+        }
+        return left;
+    }
+
+    private Ast.Expr additive() {
+        Ast.Expr left = multiplicative();
+        // Operator check must be type-aware: a string literal like "-"
+        // is not a minus sign.
+        while (peek().is(Token.Type.OP) && (peek().isText("+") || peek().isText("-"))) {
+            final Token op = next();
+            left = new Ast.Binary(op.text(), left, multiplicative(), op.line());
+        }
+        return left;
+    }
+
+    private Ast.Expr multiplicative() {
+        Ast.Expr left = unary();
+        while ((peek().is(Token.Type.OP) && (peek().isText("*") || peek().isText("/")
+                || peek().isText("\\")))
+                || (peek().is(Token.Type.IDENT) && peek().isText("MOD"))) {
+            final Token op = next();
+            left = new Ast.Binary(op.text().toUpperCase(), left, unary(), op.line());
+        }
+        return left;
+    }
+
+    private Ast.Expr unary() {
+        if (peek().is(Token.Type.OP) && peek().isText("-")) {
+            final Token op = next();
+            return new Ast.Unary("-", unary(), op.line());
+        }
+        return power();
+    }
+
+    private Ast.Expr power() {
+        Ast.Expr base = primary();
+        if (peek().is(Token.Type.OP) && peek().isText("^")) {
+            final Token op = next();
+            return new Ast.Binary("^", base, unary(), op.line());
+        }
+        return base;
+    }
+
+    private Ast.Expr primary() {
+        final Token token = peek();
+        if (token.is(Token.Type.NUMBER)) {
+            next();
+            return parseNum(token);
+        }
+        if (token.is(Token.Type.STRING)) {
+            next();
+            return new Ast.Str(token.text(), token.line());
+        }
+        if (token.is(Token.Type.IDENT)) {
+            next();
+            if (peek().isText("(")) {
+                next();
+                final List<Ast.Expr> args = new ArrayList<>();
+                if (!peek().isText(")")) {
+                    args.add(expression());
+                    while (peek().isText(",")) {
+                        next();
+                        args.add(expression());
+                    }
+                }
+                expectOp(")");
+                return new Ast.Call(token.text(), args, token.line());
+            }
+            return new Ast.Var(token.text(), token.line());
+        }
+        if (token.isText("(")) {
+            next();
+            final Ast.Expr inner = expression();
+            expectOp(")");
+            return inner;
+        }
+        throw error("Expected expression", token);
+    }
+
+    /**
+     * Builds a Num literal from a NUMBER token. A trailing '#' suffix (or a
+     * D exponent) makes it a DOUBLE constant: it keeps full double
+     * precision instead of narrowing to SINGLE like other literals.
+     */
+    private static Ast.Num parseNum(final Token token) {
+        String text = token.text();
+        final char last = text.charAt(text.length() - 1);
+        final boolean doublePrecision = last == '#' || text.indexOf('D') >= 0 || text.indexOf('d') >= 0;
+        if ("%&!#".indexOf(last) >= 0) {
+            text = text.substring(0, text.length() - 1);
+        }
+        return new Ast.Num(parseNumber(text, token.line()), doublePrecision, token.line());
+    }
+
+    private static double parseNumber(final String text, final int line) {
+        try {
+            if (text.startsWith("&H") || text.startsWith("&h")) {
+                return Integer.parseInt(text.substring(2), 16);
+            }
+            if (text.startsWith("&O") || text.startsWith("&o")) {
+                return Integer.parseInt(text.substring(2), 8);
+            }
+            return Double.parseDouble(text.replace('D', 'E').replace('d', 'e'));
+        } catch (final NumberFormatException e) {
+            throw new ParseException("Bad number '" + text + "' at line " + line);
+        }
+    }
+
+    // ---------------------------------------------------------------- tokens
+
+    /**
+     * True when the just-consumed token began a logical line: it is the
+     * first token of the program or follows a newline separator (not a
+     * ':' inline separator).
+     */
+    private boolean isAtLineStart() {
+        if (pos < 2) {
+            return true;
+        }
+        final Token before = tokens.get(pos - 2);
+        return before.is(Token.Type.SEP) && before.isText("\n");
+    }
+
+    private Token peek() {
+        return tokens.get(pos);
+    }
+
+    private Token next() {
+        return tokens.get(pos++);
+    }
+
+    private boolean isAny(final String... keywords) {
+        final Token token = peek();
+        if (!token.is(Token.Type.IDENT)) {
+            return false;
+        }
+        for (final String keyword : keywords) {
+            if (token.isText(keyword)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void skipSeparators() {
+        while (peek().is(Token.Type.SEP)) {
+            pos++;
+        }
+    }
+
+    private void skipInlineSeparators() {
+        while (peek().isText(":")) {
+            pos++;
+        }
+    }
+
+    private Token expect(final Token.Type type) {
+        final Token token = peek();
+        if (!token.is(type)) {
+            throw error("Expected " + type + ", got '" + token.text() + "'", token);
+        }
+        return next();
+    }
+
+    private void expectOp(final String op) {
+        final Token token = peek();
+        if (!token.isText(op)) {
+            throw error("Expected '" + op + "', got '" + token.text() + "'", token);
+        }
+        next();
+    }
+
+    private void expectText(final String keyword) {
+        final Token token = peek();
+        if (!token.isText(keyword)) {
+            throw error("Expected " + keyword + ", got '" + token.text() + "'", token);
+        }
+        next();
+    }
+
+    private ParseException error(final String message, final Token token) {
+        final StringBuilder sb = new StringBuilder(message)
+                .append(" at line ").append(token.line());
+        if (token.line() >= 1 && token.line() <= sourceLines.length) {
+            final String text = sourceLines[token.line() - 1].trim();
+            if (!text.isEmpty()) {
+                sb.append(": ").append(text);
+            }
+        }
+        if (!token.is(Token.Type.EOF)) {
+            sb.append("  <-- near '").append(token.text()).append("'");
+        }
+        return new ParseException(sb.toString());
+    }
+
+    /** Thrown on unsupported or malformed syntax. */
+    public static final class ParseException extends RuntimeException {
+        public ParseException(final String message) {
+            super(message);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java b/src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java
new file mode 100644 (file)
index 0000000..c6fe42f
--- /dev/null
@@ -0,0 +1,13 @@
+package eu.svjatoslav.crtbasic.video;
+
+/**
+ * A screen rectangle captured by {@code GET (x1, y1)-(x2, y2), array} and
+ * blitted back by {@code PUT (x, y), array, action}.
+ *
+ * <p>In the classic dialect the image lives inside a numeric array in a packed binary
+ * format; since CRT Basic arrays never leave the interpreter, the capture is
+ * kept as a dedicated object in the array's variable slot instead — GET/PUT
+ * round-trips behave identically at the pixel level.</p>
+ */
+public record CapturedRegion(int width, int height, byte[] paletteIndices) {
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
new file mode 100644 (file)
index 0000000..5d560f4
--- /dev/null
@@ -0,0 +1,82 @@
+package eu.svjatoslav.crtbasic.video;
+
+/**
+ * Default VGA palettes.
+ *
+ * <p>The 16 standard EGA/VGA colors (indices 0-15) are exact. SCREEN 13 gets
+ * the full 256-entry BIOS default palette, dumped entry-by-entry from a
+ * real VGA DAC (read through the DAC ports OUT &H3C7 / INP &H3C9,
+ * 6-bit DAC components scaled to 8-bit as (v&lt;&lt;2)|(v&gt;&gt;4)) — programs
+ * like Pomppu Paavo 2 draw sprites into the default palette and depend on
+ * its exact colors. SCREEN 1 is the exception among the 16-color modes: it
+ * is a CGA mode and starts on CGA palette 1 (high intensity): 1 = light
+ * cyan, 2 = light magenta, 3 = bright white.</p>
+ */
+final class DefaultPalettes {
+
+    /** The standard 16 EGA colors, 0xRRGGBB. */
+    private static final int[] EGA_16 = {
+            0x000000, 0x0000AA, 0x00AA00, 0x00AAAA,
+            0xAA0000, 0xAA00AA, 0xAA5500, 0xAAAAAA,
+            0x555555, 0x5555FF, 0x55FF55, 0x55FFFF,
+            0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
+    };
+
+    /**
+     * The BIOS default palette installed by SCREEN 13, 0xRRGGBB per index.
+     * Ground truth dumped from a real VGA DAC after a SCREEN 13 mode set
+     * (2026-08-19).
+     */
+    private static final int[] MODE_13 = {
+            0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xAA5500, 0xAAAAAA,
+            0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
+            0x000000, 0x141414, 0x202020, 0x2C2C2C, 0x383838, 0x454545, 0x515151, 0x616161,
+            0x717171, 0x828282, 0x929292, 0xA2A2A2, 0xB6B6B6, 0xCBCBCB, 0xE3E3E3, 0xFFFFFF,
+            0x0000FF, 0x4100FF, 0x7D00FF, 0xBE00FF, 0xFF00FF, 0xFF00BE, 0xFF007D, 0xFF0041,
+            0xFF0000, 0xFF4100, 0xFF7D00, 0xFFBE00, 0xFFFF00, 0xBEFF00, 0x7DFF00, 0x41FF00,
+            0x00FF00, 0x00FF41, 0x00FF7D, 0x00FFBE, 0x00FFFF, 0x00BEFF, 0x007DFF, 0x0041FF,
+            0x7D7DFF, 0x9E7DFF, 0xBE7DFF, 0xDF7DFF, 0xFF7DFF, 0xFF7DDF, 0xFF7DBE, 0xFF7D9E,
+            0xFF7D7D, 0xFF9E7D, 0xFFBE7D, 0xFFDF7D, 0xFFFF7D, 0xDFFF7D, 0xBEFF7D, 0x9EFF7D,
+            0x7DFF7D, 0x7DFF9E, 0x7DFFBE, 0x7DFFDF, 0x7DFFFF, 0x7DDFFF, 0x7DBEFF, 0x7D9EFF,
+            0xB6B6FF, 0xC7B6FF, 0xDBB6FF, 0xEBB6FF, 0xFFB6FF, 0xFFB6EB, 0xFFB6DB, 0xFFB6C7,
+            0xFFB6B6, 0xFFC7B6, 0xFFDBB6, 0xFFEBB6, 0xFFFFB6, 0xEBFFB6, 0xDBFFB6, 0xC7FFB6,
+            0xB6FFB6, 0xB6FFC7, 0xB6FFDB, 0xB6FFEB, 0xB6FFFF, 0xB6EBFF, 0xB6DBFF, 0xB6C7FF,
+            0x000071, 0x1C0071, 0x380071, 0x550071, 0x710071, 0x710055, 0x710038, 0x71001C,
+            0x710000, 0x711C00, 0x713800, 0x715500, 0x717100, 0x557100, 0x387100, 0x1C7100,
+            0x007100, 0x00711C, 0x007138, 0x007155, 0x007171, 0x005571, 0x003871, 0x001C71,
+            0x383871, 0x453871, 0x553871, 0x613871, 0x713871, 0x713861, 0x713855, 0x713845,
+            0x713838, 0x714538, 0x715538, 0x716138, 0x717138, 0x617138, 0x557138, 0x457138,
+            0x387138, 0x387145, 0x387155, 0x387161, 0x387171, 0x386171, 0x385571, 0x384571,
+            0x515171, 0x595171, 0x615171, 0x695171, 0x715171, 0x715169, 0x715161, 0x715159,
+            0x715151, 0x715951, 0x716151, 0x716951, 0x717151, 0x697151, 0x617151, 0x597151,
+            0x517151, 0x517159, 0x517161, 0x517169, 0x517171, 0x516971, 0x516171, 0x515971,
+            0x000041, 0x100041, 0x200041, 0x300041, 0x410041, 0x410030, 0x410020, 0x410010,
+            0x410000, 0x411000, 0x412000, 0x413000, 0x414100, 0x304100, 0x204100, 0x104100,
+            0x004100, 0x004110, 0x004120, 0x004130, 0x004141, 0x003041, 0x002041, 0x001041,
+            0x202041, 0x282041, 0x302041, 0x382041, 0x412041, 0x412038, 0x412030, 0x412028,
+            0x412020, 0x412820, 0x413020, 0x413820, 0x414120, 0x384120, 0x304120, 0x284120,
+            0x204120, 0x204128, 0x204130, 0x204138, 0x204141, 0x203841, 0x203041, 0x202841,
+            0x2C2C41, 0x302C41, 0x342C41, 0x3C2C41, 0x412C41, 0x412C3C, 0x412C34, 0x412C30,
+            0x412C2C, 0x41302C, 0x41342C, 0x413C2C, 0x41412C, 0x3C412C, 0x34412C, 0x30412C,
+            0x2C412C, 0x2C4130, 0x2C4134, 0x2C413C, 0x2C4141, 0x2C3C41, 0x2C3441, 0x2C3041,
+            0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000,
+    };
+
+    private DefaultPalettes() {
+        // Utility class.
+    }
+
+    /** Fills the palette with the mode's default colors. */
+    static void apply(final int[] palette, final ScreenMode mode) {
+        if (mode == ScreenMode.SCREEN_13) {
+            System.arraycopy(MODE_13, 0, palette, 0, MODE_13.length);
+            return;
+        }
+        System.arraycopy(EGA_16, 0, palette, 0, EGA_16.length);
+        if (mode == ScreenMode.SCREEN_1) {
+            palette[1] = 0x55FFFF;
+            palette[2] = 0xFF55FF;
+            palette[3] = 0xFFFFFF;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java b/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java
new file mode 100644 (file)
index 0000000..057d40a
--- /dev/null
@@ -0,0 +1,206 @@
+package eu.svjatoslav.crtbasic.video;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Arrays;
+import javax.imageio.ImageIO;
+
+/**
+ * Indexed-color video memory: one byte per pixel, holding a palette index.
+ *
+ * <p>Keeping pixels as palette indices (instead of direct RGB) is what makes
+ * {@code PALETTE} tricks and color cycling work: a program can remap a
+ * palette entry and every pixel using it changes color on the next frame,
+ * exactly like on real VGA hardware.</p>
+ *
+ * <p>Modes with multiple video pages (SCREEN 7-10) allocate one byte buffer
+ * per page.</p>
+ */
+public final class Framebuffer {
+
+    /** Number of palette entries. Modes use fewer, but the VGA DAC has 256. */
+    public static final int PALETTE_SIZE = 256;
+
+    private int width;
+    private int height;
+    private byte[][] pages;
+
+    /** Palette as 0xRRGGBB values. */
+    private final int[] palette = new int[PALETTE_SIZE];
+
+    /**
+     * (Re)allocates video memory for the given geometry. All pages are zeroed
+     * and the palette is reset to the mode defaults.
+     */
+    void resize(final int newWidth, final int newHeight, final int pageCount,
+                final ScreenMode mode) {
+        width = newWidth;
+        height = newHeight;
+        pages = new byte[pageCount][newWidth * newHeight];
+        DefaultPalettes.apply(palette, mode);
+    }
+
+    public int width() {
+        return width;
+    }
+
+    public int height() {
+        return height;
+    }
+
+    public int pageCount() {
+        return pages.length;
+    }
+
+    /**
+     * Sets one pixel to a palette index. Out-of-bounds writes are silently
+     * ignored, matching how clipped drawing behaves on hardware.
+     */
+    public void setPixel(final int page, final int x, final int y, final int colorIndex) {
+        if (x < 0 || y < 0 || x >= width || y >= height) {
+            return;
+        }
+        pages[page][y * width + x] = (byte) colorIndex;
+    }
+
+    /** @return palette index of the pixel, or 0 for out-of-bounds reads */
+    public int getPixel(final int page, final int x, final int y) {
+        if (x < 0 || y < 0 || x >= width || y >= height) {
+            return 0;
+        }
+        return pages[page][y * width + x] & 0xFF;
+    }
+
+    /** Fills a rectangle with a palette index, clipped to the screen. */
+    public void fillRect(final int page, final int x, final int y, final int w, final int h, final int colorIndex) {
+        final int x0 = Math.max(0, x);
+        final int y0 = Math.max(0, y);
+        final int x1 = Math.min(width, x + w);
+        final int y1 = Math.min(height, y + h);
+        if (x0 >= x1 || y0 >= y1) {
+            // Fully off-screen.
+            return;
+        }
+        final byte[] target = pages[page];
+        final byte value = (byte) colorIndex;
+        for (int row = y0; row < y1; row++) {
+            Arrays.fill(target, row * width + x0, row * width + x1, value);
+        }
+    }
+
+    /**
+     * Copies a horizontal band of pixels upwards, used for text scrolling.
+     *
+     * @param dstTopY     first destination scanline
+     * @param srcTopY     first source scanline
+     * @param pixelHeight number of scanlines to copy
+     */
+    public void copyBandUp(final int page, final int dstTopY, final int srcTopY, final int pixelHeight) {
+        System.arraycopy(pages[page], srcTopY * width, pages[page], dstTopY * width, pixelHeight * width);
+    }
+
+    /** {@code PCOPY}: copies one page's pixels onto another. */
+    public void copyPage(final int source, final int target) {
+        System.arraycopy(pages[source], 0, pages[target], 0, pages[target].length);
+    }
+
+    /**
+     * {@code GET}: captures a rectangle of the page (both endpoints
+     * inclusive, corners normalized). Out-of-bounds pixels read as 0.
+     */
+    public CapturedRegion captureRect(final int page, final int x1, final int y1,
+                                      final int x2, final int y2) {
+        final int left = Math.min(x1, x2);
+        final int top = Math.min(y1, y2);
+        final int w = Math.abs(x2 - x1) + 1;
+        final int h = Math.abs(y2 - y1) + 1;
+        final byte[] pixels = new byte[w * h];
+        final byte[] source = pages[page];
+        for (int row = 0; row < h; row++) {
+            final int sy = top + row;
+            if (sy < 0 || sy >= height) {
+                continue;
+            }
+            final int fromX = Math.max(0, left);
+            final int toX = Math.min(width, left + w);
+            if (fromX < toX) {
+                System.arraycopy(source, sy * width + fromX,
+                        pixels, row * w + (fromX - left), toX - fromX);
+            }
+        }
+        return new CapturedRegion(w, h, pixels);
+    }
+
+    /**
+     * {@code PUT}: blits a captured rectangle back onto the page, clipped to
+     * the screen. Action combines source and destination pixels the classic way:
+     * PSET overwrites, XOR/OR/AND combine palette indices bitwise.
+     */
+    public void blitRect(final int page, final int x, final int y,
+                         final CapturedRegion region, final String action) {
+        final byte[] target = pages[page];
+        final byte[] source = region.paletteIndices();
+        for (int row = 0; row < region.height(); row++) {
+            final int dy = y + row;
+            if (dy < 0 || dy >= height) {
+                continue;
+            }
+            for (int col = 0; col < region.width(); col++) {
+                final int dx = x + col;
+                if (dx < 0 || dx >= width) {
+                    continue;
+                }
+                final int src = source[row * region.width() + col] & 0xFF;
+                final int offset = dy * width + dx;
+                target[offset] = (byte) switch (action) {
+                    case "PSET" -> src;
+                    case "XOR" -> (target[offset] & 0xFF) ^ src;
+                    case "OR" -> (target[offset] & 0xFF) | src;
+                    case "AND" -> (target[offset] & 0xFF) & src;
+                    default -> throw new IllegalArgumentException(
+                            "PUT action not supported: " + action);
+                };
+            }
+        }
+    }
+
+    /** Sets a palette entry to a 0xRRGGBB color. */
+    public void setPaletteColor(final int index, final int rgb) {
+        palette[index & 0xFF] = rgb & 0xFFFFFF;
+    }
+
+    /** @return 0xRRGGBB color of a palette entry */
+    public int paletteColor(final int index) {
+        return palette[index & 0xFF];
+    }
+
+    /**
+     * Converts a page to a displayable image, resolving palette indices to
+     * actual colors at this point (and only at this point).
+     */
+    public BufferedImage toImage(final int page) {
+        final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        final byte[] source = pages[page];
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                image.setRGB(x, y, palette[source[y * width + x] & 0xFF]);
+            }
+        }
+        return image;
+    }
+
+    /** Writes a page as a PNG screenshot. */
+    public void dumpPng(final int page, final Path output) throws IOException {
+        ImageIO.write(toImage(page), "png", output.toFile());
+    }
+
+    /**
+     * Raw indexed pixels of a page, row-major — the exact equivalent of a
+     * {@code BSAVE} of VGA memory in mode 13h, suitable for byte-level
+     * comparison between runs.
+     */
+    public byte[] pageData(final int page) {
+        return pages[page];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java
new file mode 100644 (file)
index 0000000..906611f
--- /dev/null
@@ -0,0 +1,138 @@
+package eu.svjatoslav.crtbasic.video;
+
+/**
+ * Description of one {@code SCREEN} mode.
+ *
+ * <p>Every graphics mode doubles as a text mode: characters are rendered as
+ * 8-pixel-wide glyph cells blitted into the same framebuffer that pixel
+ * primitives draw into. The only things that differ between modes are the
+ * pixel resolution, the glyph cell height (8/14/16), the number of colors and
+ * the number of video pages.</p>
+ */
+public enum ScreenMode {
+
+    /** Text mode: 80x25 characters, 16 colors. */
+    SCREEN_0(0, 640, 400, 16, 16, 1),
+
+    /** CGA 320x200, 4 colors. */
+    SCREEN_1(1, 320, 200, 8, 4, 1),
+
+    /** CGA 640x200, 2 colors. */
+    SCREEN_2(2, 640, 200, 8, 2, 1),
+
+    /** EGA 320x200, 16 colors, 8 pages. */
+    SCREEN_7(7, 320, 200, 8, 16, 8),
+
+    /** EGA 640x200, 16 colors, 4 pages. */
+    SCREEN_8(8, 640, 200, 8, 16, 4),
+
+    /** EGA 640x350, 16 colors, 2 pages, 8x14 font. */
+    SCREEN_9(9, 640, 350, 14, 16, 2),
+
+    /** EGA 640x350 monochrome, 2 pages, 8x14 font. */
+    SCREEN_10(10, 640, 350, 14, 4, 2),
+
+    /** VGA 640x480, 2 colors, 8x16 font. */
+    SCREEN_11(11, 640, 480, 16, 2, 1),
+
+    /** VGA 640x480, 16 colors, 8x16 font. */
+    SCREEN_12(12, 640, 480, 16, 16, 1),
+
+    /** VGA 320x200, 256 colors ("mode 13h"). */
+    SCREEN_13(13, 320, 200, 8, 256, 1);
+
+    /** Glyph cell width in pixels. Always 8 on real VGA. */
+    public static final int CELL_WIDTH = 8;
+
+    private final int number;
+    private final int pixelWidth;
+    private final int pixelHeight;
+    private final int cellHeight;
+    private final int colors;
+    private final int pages;
+
+    ScreenMode(final int number,
+               final int pixelWidth,
+               final int pixelHeight,
+               final int cellHeight,
+               final int colors,
+               final int pages) {
+        this.number = number;
+        this.pixelWidth = pixelWidth;
+        this.pixelHeight = pixelHeight;
+        this.cellHeight = cellHeight;
+        this.colors = colors;
+        this.pages = pages;
+    }
+
+    /** @return the SCREEN statement number (0, 1, 2, 7-13) */
+    public int number() {
+        return number;
+    }
+
+    public int pixelWidth() {
+        return pixelWidth;
+    }
+
+    public int pixelHeight() {
+        return pixelHeight;
+    }
+
+    /** @return glyph cell height in pixels: 8, 14 or 16 depending on mode */
+    public int cellHeight() {
+        return cellHeight;
+    }
+
+    /** @return number of simultaneous colors in this mode */
+    public int colors() {
+        return colors;
+    }
+
+    /** @return number of video pages (SCREEN 7/8/9/10 have several) */
+    public int pages() {
+        return pages;
+    }
+
+    /** @return text columns (pixelWidth / 8) */
+    public int columns() {
+        return pixelWidth / CELL_WIDTH;
+    }
+
+    /** @return text rows (pixelHeight / cellHeight) */
+    public int rows() {
+        return pixelHeight / cellHeight;
+    }
+
+    /**
+     * @return the foreground color active right after
+     * {@code SCREEN}: 7 (light gray) in text mode, 15 (bright white) in
+     * graphics modes.
+     */
+    public int defaultForeground() {
+        return this == SCREEN_0 ? 7 : 15;
+    }
+
+    /**
+     * @return the default {@code CIRCLE} aspect ratio (y radius per unit of x
+     * radius) that makes circles look round on this mode's display:
+     * 4/3 corrected for the pixel resolution.
+     */
+    public double defaultAspect() {
+        return 4.0 * pixelHeight / (3.0 * pixelWidth);
+    }
+
+    /**
+     * @param number SCREEN mode number
+     * @return the matching mode
+     * @throws IllegalArgumentException for unsupported mode numbers (3-6
+     *                                  have no classic VGA mode)
+     */
+    public static ScreenMode byNumber(final int number) {
+        for (final ScreenMode mode : values()) {
+            if (mode.number == number) {
+                return mode;
+            }
+        }
+        throw new IllegalArgumentException("Unsupported SCREEN mode: " + number);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
new file mode 100644 (file)
index 0000000..ad415e5
--- /dev/null
@@ -0,0 +1,185 @@
+package eu.svjatoslav.crtbasic.video;
+
+/**
+ * Text layer on top of the framebuffer, implementing the classic
+ * {@code PRINT}/{@code LOCATE}/{@code CLS}/{@code VIEW PRINT} semantics.
+ *
+ * <p>There is no separate text plane: printing blits glyph bitmaps straight
+ * into the pixel framebuffer, which is exactly what the BIOS does on real
+ * hardware. Consequences that fall out naturally:</p>
+ *
+ * <ul>
+ *   <li>PRINT works identically in text and graphics modes.</li>
+ *   <li>The whole glyph cell is painted: stroke pixels in the foreground
+ *       color, all other pixels in the background color — in every mode.
+ *       Printing spaces therefore erases text even over a drawn scene.</li>
+ *   <li>Scrolling is a framebuffer pixel-copy of the scroll region, shifted
+ *       up by one cell height; the freed bottom line is filled with the
+ *       background color.</li>
+ * </ul>
+ *
+ * <p>Cursor positions are 1-based, like {@code LOCATE row, col} /
+ * {@code CSRLIN} / {@code POS(0)}.</p>
+ */
+public final class TextConsole {
+
+    private final Framebuffer framebuffer;
+    private final ScreenMode mode;
+    private final VgaFont font;
+    private final int page;
+
+    private int cursorRow = 1;
+    private int cursorCol = 1;
+    private int foreground;
+    private int background = 0;
+    private int scrollTop = 1;
+    private int scrollBottom;
+
+    /**
+     * True after a character was printed in the rightmost column: the cursor
+     * stays on the last column until the next character triggers the wrap.
+     * This reproduces the classic bottom-right-corner behavior where printing
+     * the last cell of the screen does not scroll by itself.
+     */
+    private boolean pendingWrap;
+
+    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();
+    }
+
+    /**
+     * Prints a string at the cursor, advancing it with wrap and scroll.
+     * Characters map back to their CP437 byte values for the glyph lookup
+     * (high chars like 'ü' were decoded from CP437 on load and must find
+     * their original glyph); {@code '\n'} performs a newline, other control
+     * characters print as their glyphs (the classic
+     * {@code PRINT CHR$(n)} behavior).
+     */
+    public void print(final String text) {
+        for (int i = 0; i < text.length(); i++) {
+            final char c = text.charAt(i);
+            if (c == '\n') {
+                newLine();
+                continue;
+            }
+            if (pendingWrap) {
+                pendingWrap = false;
+                cursorCol = 1;
+                advanceLine();
+            }
+            drawGlyph(eu.svjatoslav.crtbasic.Cp437.byteOf(c));
+            if (cursorCol == mode.columns()) {
+                pendingWrap = true;
+            } else {
+                cursorCol++;
+            }
+        }
+    }
+
+    /** Moves the cursor to the start of the next line, scrolling if needed. */
+    public void newLine() {
+        pendingWrap = false;
+        cursorCol = 1;
+        advanceLine();
+    }
+
+    /**
+     * {@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()) {
+            throw new IllegalArgumentException(
+                    "LOCATE out of range: " + row + "," + col
+                            + " (screen is " + mode.rows() + "x" + mode.columns() + ")");
+        }
+        cursorRow = row;
+        cursorCol = col;
+        pendingWrap = false;
+    }
+
+    /** {@code COLOR foreground, background}. */
+    public void setColor(final int fg, final int bg) {
+        foreground = fg & 0xFF;
+        background = bg & 0xFF;
+    }
+
+    /**
+     * {@code VIEW PRINT top TO bottom}: confines printing and scrolling to a
+     * band of text rows (1-based, inclusive). The cursor is homed to the top
+     * of the band.
+     */
+    public void viewPrint(final int topRow, final int bottomRow) {
+        if (topRow < 1 || bottomRow > mode.rows() || topRow > bottomRow) {
+            throw new IllegalArgumentException(
+                    "VIEW PRINT out of range: " + topRow + " TO " + bottomRow);
+        }
+        scrollTop = topRow;
+        scrollBottom = bottomRow;
+        cursorRow = topRow;
+        cursorCol = 1;
+        pendingWrap = false;
+    }
+
+    /** {@code CLS}: clears the screen to the background color, homes the cursor. */
+    public void cls() {
+        framebuffer.fillRect(page, 0, 0, framebuffer.width(), framebuffer.height(), background);
+        cursorRow = scrollTop;
+        cursorCol = 1;
+        pendingWrap = false;
+    }
+
+    /** @return {@code CSRLIN}: 1-based cursor row */
+    public int cursorRow() {
+        return cursorRow;
+    }
+
+    /** @return {@code POS(0)}: 1-based cursor column */
+    public int cursorColumn() {
+        return cursorCol;
+    }
+
+    public int foreground() {
+        return foreground;
+    }
+
+    public int background() {
+        return background;
+    }
+
+    private void advanceLine() {
+        if (cursorRow == scrollBottom) {
+            scrollUp();
+        } else {
+            cursorRow++;
+        }
+    }
+
+    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);
+        framebuffer.fillRect(page, 0, topY + bandHeight - cellHeight,
+                framebuffer.width(), cellHeight, background);
+    }
+
+    private void drawGlyph(final int glyph) {
+        final int originX = (cursorCol - 1) * ScreenMode.CELL_WIDTH;
+        final int originY = (cursorRow - 1) * mode.cellHeight();
+        // 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 col = 0; col < ScreenMode.CELL_WIDTH; col++) {
+                framebuffer.setPixel(page, originX + col, originY + row,
+                        font.pixelSet(glyph, col, row) ? foreground : background);
+            }
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
new file mode 100644 (file)
index 0000000..de79daa
--- /dev/null
@@ -0,0 +1,509 @@
+package eu.svjatoslav.crtbasic.video;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Facade for the whole video subsystem: the surface the VM opcodes will call.
+ *
+ * <p>Owns the indexed framebuffer, the current {@link ScreenMode}, the text
+ * console and the page selection. All drawing — pixels and
+ * text alike — targets the active page; screenshots show the visual page.
+ * The graphics cursor (last point referenced, used by {@code STEP} relative
+ * coordinates) is tracked across all drawing primitives.</p>
+ */
+public final class VgaDevice {
+
+    private final Framebuffer framebuffer = new Framebuffer();
+    private ScreenMode mode;
+    private TextConsole[] consoles;
+    private int activePage;
+    private int visualPage;
+    private int graphicsCursorX;
+    private int graphicsCursorY;
+
+    /** Creates the device in the default mode: SCREEN 0, 80x25 text. */
+    public VgaDevice() {
+        setMode(ScreenMode.SCREEN_0);
+    }
+
+    /**
+     * {@code SCREEN number}: switches video mode, reallocating video memory,
+     * resetting the palette to mode defaults and clearing the screen.
+     */
+    public void setMode(final ScreenMode newMode) {
+        mode = newMode;
+        framebuffer.resize(newMode.pixelWidth(), newMode.pixelHeight(),
+                newMode.pages(), newMode);
+        activePage = 0;
+        visualPage = 0;
+        consoles = new TextConsole[newMode.pages()];
+        for (int page = 0; page < consoles.length; page++) {
+            consoles[page] = new TextConsole(framebuffer, newMode, page);
+        }
+    }
+
+    public ScreenMode mode() {
+        return mode;
+    }
+
+    /** Selects the page that drawing and printing target. */
+    public void setActivePage(final int page) {
+        checkPage(page);
+        activePage = page;
+    }
+
+    /** Selects the page shown on screen (and in screenshots). */
+    public void setVisualPage(final int page) {
+        checkPage(page);
+        visualPage = page;
+    }
+
+    /** {@code PCOPY sourcePage, targetPage}: copies one video page onto another. */
+    public void pcopy(final int sourcePage, final int targetPage) {
+        checkPage(sourcePage);
+        checkPage(targetPage);
+        framebuffer.copyPage(sourcePage, targetPage);
+    }
+
+    public int activePage() {
+        return activePage;
+    }
+
+    public int visualPage() {
+        return visualPage;
+    }
+
+    // ---------------------------------------------------------------- text
+
+    /** {@code PRINT}: prints text at the text cursor. See {@link TextConsole}. */
+    public void print(final String text) {
+        consoles[activePage].print(text);
+    }
+
+    /** Moves the text cursor to the start of the next line, scrolling if needed. */
+    public void newLine() {
+        consoles[activePage].newLine();
+    }
+
+    /** @return current text foreground color index of the active page */
+    public int textForeground() {
+        return consoles[activePage].foreground();
+    }
+
+    /** @return current text background color index of the active page */
+    public int textBackground() {
+        return consoles[activePage].background();
+    }
+
+    /** {@code LOCATE row, col}. */
+    public void locate(final int row, final int col) {
+        consoles[activePage].locate(row, col);
+    }
+
+    /** {@code COLOR foreground, background}. */
+    public void setTextColor(final int foreground, final int background) {
+        consoles[activePage].setColor(foreground, background);
+    }
+
+    /** {@code VIEW PRINT top TO bottom}. */
+    public void viewPrint(final int topRow, final int bottomRow) {
+        consoles[activePage].viewPrint(topRow, bottomRow);
+    }
+
+    /** {@code CLS}. */
+    public void cls() {
+        consoles[activePage].cls();
+    }
+
+    /** @return {@code CSRLIN}: 1-based text cursor row */
+    public int cursorRow() {
+        return consoles[activePage].cursorRow();
+    }
+
+    /** @return {@code POS(0)}: 1-based text cursor column */
+    public int cursorColumn() {
+        return consoles[activePage].cursorColumn();
+    }
+
+    // -------------------------------------------------------------- pixels
+
+    /** {@code PSET (x, y), colorIndex}: sets one pixel on the active page. */
+    public void pset(final int x, final int y, final int colorIndex) {
+        framebuffer.setPixel(activePage, x, y, colorIndex);
+        graphicsCursorX = x;
+        graphicsCursorY = y;
+    }
+
+    /** {@code POINT(x, y)}: reads the palette index of one pixel. */
+    public int point(final int x, final int y) {
+        return framebuffer.getPixel(activePage, x, y);
+    }
+
+    /** {@code GET (x1, y1)-(x2, y2), array}: captures a rectangle of the active page. */
+    public CapturedRegion getRegion(final int x1, final int y1, final int x2, final int y2) {
+        final CapturedRegion region = framebuffer.captureRect(activePage, x1, y1, x2, y2);
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+        return region;
+    }
+
+    /** {@code PUT (x, y), array, action}: blits a captured rectangle onto the active page. */
+    public void putRegion(final int x, final int y, final CapturedRegion region, final String action) {
+        framebuffer.blitRect(activePage, x, y, region, action);
+        graphicsCursorX = x;
+        graphicsCursorY = y;
+    }
+
+    /**
+     * {@code LINE (x1, y1)-(x2, y2), color}: replicates the classic DDA line
+     * rasterizer bit-exactly. The line is walked along its major axis from
+     * (x1, y1); the minor coordinate of step k advances when the error
+     * accumulator crosses the major delta, starting from an initial bias
+     * of floor(3M/4) when stepping right and M-1-floor(3M/4) when
+     * stepping left (this bias makes reversed endpoint order plot the
+     * identical pixel set). Both
+     * endpoints are drawn. Updates the graphics cursor to (x2, y2).
+     */
+    public void line(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
+        // The segment is clipped to the screen rectangle BEFORE rasterizing
+        // (Liang–Barsky). The intersection's off-axis coordinate is computed
+        // as base + ROUND(offset) on the integer offset: nearest, with
+        // exact .5 ties going toward the SURVIVING endpoint — toward zero
+        // offset for a clipped line end, away from zero for a clipped line
+        // start (a clipped end at y-offset 4.5 lands on 333 not 334, a
+        // clipped start at 12.5 lands
+        // on 13 not 12). This shifts the visible part of lines that leave
+        // the screen vs. per-pixel clipping.
+        final int maxX = framebuffer.width() - 1;
+        final int maxY = framebuffer.height() - 1;
+        double t0 = 0;
+        double t1 = 1;
+        int enterEdge = -1; // 0=left, 1=right, 2=top, 3=bottom
+        int leaveEdge = -1;
+        final double dx = x2 - x1;
+        final double dy = y2 - y1;
+        final double[][] pq = {
+                {-dx, x1}, {dx, maxX - x1}, {-dy, y1}, {dy, maxY - y1}};
+        for (int edge = 0; edge < pq.length; edge++) {
+            final double p = pq[edge][0];
+            final double q = pq[edge][1];
+            if (p == 0) {
+                if (q < 0) {
+                    // Parallel to and outside this edge: fully clipped.
+                    graphicsCursorX = x2;
+                    graphicsCursorY = y2;
+                    return;
+                }
+            } else {
+                final double t = q / p;
+                if (p < 0) {
+                    if (t > t0) {
+                        t0 = t;
+                        enterEdge = edge;
+                    }
+                } else {
+                    if (t < t1) {
+                        t1 = t;
+                        leaveEdge = edge;
+                    }
+                }
+            }
+        }
+        if (t0 > t1) {
+            graphicsCursorX = x2;
+            graphicsCursorY = y2;
+            return;
+        }
+        final int cx1 = clipCoord(x1, x2, y1, y2, enterEdge, maxX, maxY, true, x1, true);
+        final int cy1 = clipCoord(x1, x2, y1, y2, enterEdge, maxX, maxY, false, y1, true);
+        final int cx2 = clipCoord(x1, x2, y1, y2, leaveEdge, maxX, maxY, true, x2, false);
+        final int cy2 = clipCoord(x1, x2, y1, y2, leaveEdge, maxX, maxY, false, y2, false);
+
+        final int adx = Math.abs(cx2 - cx1);
+        final int ady = Math.abs(cy2 - cy1);
+        final int stepX = Integer.signum(cx2 - cx1);
+        final int stepY = Integer.signum(cy2 - cy1);
+        final int major = Math.max(adx, ady);
+        final int bias = stepX < 0 ? major - 1 - 3 * major / 4 : 3 * major / 4;
+        int x = cx1;
+        int y = cy1;
+        int error = bias;
+        if (adx >= ady) {
+            for (int k = 0; k <= adx; k++) {
+                framebuffer.setPixel(activePage, x, y, colorIndex);
+                x += stepX;
+                error += ady;
+                if (error >= adx) {
+                    y += stepY;
+                    error -= adx;
+                }
+            }
+        } else {
+            for (int k = 0; k <= ady; k++) {
+                framebuffer.setPixel(activePage, x, y, colorIndex);
+                y += stepY;
+                error += adx;
+                if (error >= ady) {
+                    x += stepX;
+                    error -= ady;
+                }
+            }
+        }
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /**
+     * Computes one coordinate of a clipped LINE endpoint the classic way: the on-boundary coordinate is the exact boundary; the off-axis
+     * coordinate is {@code base + ROUND(offset)} where the offset is
+     * rounded to nearest, ties going toward the surviving endpoint (away
+     * from zero for a clipped start, toward zero for a clipped end).
+     *
+     * @param edge  clipping edge (0=left, 1=right, 2=top, 3=bottom) or -1
+     *              when the endpoint is inside (then {@code inside} is
+     *              returned)
+     * @param xCoord true to compute x, false for y
+     * @param inside the endpoint's original coordinate when unclipped
+     * @param startClip true when clipping the line's start point
+     */
+    private static int clipCoord(final int x1, final int x2, final int y1, final int y2,
+                                 final int edge, final int maxX, final int maxY,
+                                 final boolean xCoord, final int inside,
+                                 final boolean startClip) {
+        if (edge < 0) {
+            return inside;
+        }
+        final boolean verticalEdge = edge < 2;
+        final int boundary = switch (edge) {
+            case 0 -> 0;
+            case 1 -> maxX;
+            case 2 -> 0;
+            default -> maxY;
+        };
+        if (verticalEdge == xCoord) {
+            return boundary;
+        }
+        if (verticalEdge) {
+            // x on boundary; y = y1 + ROUND(dy * (boundary - x1) / dx)
+            return y1 + roundOffset((double) (y2 - y1) * (boundary - x1) / (x2 - x1), startClip);
+        }
+        // y on boundary; x = x1 + ROUND(dx * (boundary - y1) / dy)
+        return x1 + roundOffset((double) (x2 - x1) * (boundary - y1) / (y2 - y1), startClip);
+    }
+
+    /**
+     * Rounds to nearest; exact .5 ties go away from zero when
+     * {@code tieAway} (clipped line start), toward zero otherwise
+     * (clipped line end) — so ties always move toward the part of the
+     * line that survives clipping.
+     */
+    private static int roundOffset(final double offset, final boolean tieAway) {
+        final double floor = Math.floor(offset);
+        final double frac = offset - floor;
+        if (frac < 0.5) {
+            return (int) floor;
+        }
+        if (frac > 0.5) {
+            return (int) floor + 1;
+        }
+        // Exact tie.
+        if (tieAway) {
+            return offset >= 0 ? (int) floor + 1 : (int) floor;
+        }
+        return offset >= 0 ? (int) floor : (int) floor + 1;
+    }
+
+    /** {@code LINE (x1, y1)-(x2, y2), color, B}: rectangle outline. */
+    public void lineBox(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
+        line(x1, y1, x2, y1, colorIndex);
+        line(x2, y1, x2, y2, colorIndex);
+        line(x2, y2, x1, y2, colorIndex);
+        line(x1, y2, x1, y1, colorIndex);
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /** {@code LINE (x1, y1)-(x2, y2), color, BF}: filled rectangle. */
+    public void lineBoxFilled(final int x1, final int y1, final int x2, final int y2, final int colorIndex) {
+        framebuffer.fillRect(activePage,
+                Math.min(x1, x2), Math.min(y1, y2),
+                Math.abs(x2 - x1) + 1, Math.abs(y2 - y1) + 1, colorIndex);
+        graphicsCursorX = x2;
+        graphicsCursorY = y2;
+    }
+
+    /**
+     * {@code CIRCLE (x, y), radius, color}: axis-aligned ellipse using the
+     * mode's default aspect ratio. Updates the graphics cursor to the center.
+     */
+    public void circle(final int centerX, final int centerY, final int radius, final int colorIndex) {
+        circle(centerX, centerY, radius, colorIndex, mode.defaultAspect());
+    }
+
+    /**
+     * {@code CIRCLE (x, y), radius, color, , , aspect}: ellipse with an
+     * explicit aspect ratio. An aspect below 1 makes
+     * {@code radius} the x radius; an aspect of 1 or more makes it the y
+     * radius.
+     *
+     * <p>Rasterization reproduces the classic pixel output exactly: the
+     * ellipse is computed
+     * in "circle space" of the given radius and mapped through the aspect
+     * ratio, taken as the union of two scan passes — iterating circle-space
+     * x (0..r, y = round(sqrt(r²-x²))) and circle-space y (same, swapped) —
+     * then mirrored to all four quadrants. All rounding is half-up and the
+     * aspect is SINGLE precision (a float, like the dialect's default
+     * reals); both details are load-bearing for pixel-exact output.</p>
+     */
+    public void circle(final int centerX, final int centerY, final int radius,
+                       final int colorIndex, final double aspect) {
+        // Aspect VALUE is SINGLE precision, but products/quotients are
+        // computed in double and rounded half-up — that combination is what
+        // reproduces the classic pixel output exactly.
+        final double singleAspect = (float) aspect;
+        for (int t = 0; t <= radius; t++) {
+            final int u = (int) (Math.sqrt((double) radius * radius - (double) t * t) + 0.5);
+            plotCircleSpace(centerX, centerY, t, u, singleAspect, colorIndex);
+            plotCircleSpace(centerX, centerY, u, t, singleAspect, colorIndex);
+        }
+        graphicsCursorX = centerX;
+        graphicsCursorY = centerY;
+    }
+
+    /** Maps a circle-space point through the aspect ratio and plots it mirrored 4 ways. */
+    private void plotCircleSpace(final int centerX, final int centerY, final int spaceX,
+                                 final int spaceY, final double aspect, final int colorIndex) {
+        final int dx;
+        final int dy;
+        if (aspect < 1) {
+            dx = spaceX;
+            dy = (int) (spaceY * aspect + 0.5);
+        } else {
+            dx = (int) (spaceX / aspect + 0.5);
+            dy = spaceY;
+        }
+        framebuffer.setPixel(activePage, centerX + dx, centerY + dy, colorIndex);
+        framebuffer.setPixel(activePage, centerX - dx, centerY + dy, colorIndex);
+        framebuffer.setPixel(activePage, centerX + dx, centerY - dy, colorIndex);
+        framebuffer.setPixel(activePage, centerX - dx, centerY - dy, colorIndex);
+    }
+
+    /**
+     * {@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.
+     */
+    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;
+        }
+        final int width = framebuffer.width();
+        final int height = framebuffer.height();
+        final java.util.ArrayDeque<int[]> queue = new java.util.ArrayDeque<>();
+        framebuffer.setPixel(activePage, x, y, fill);
+        queue.add(new int[]{x, y});
+        while (!queue.isEmpty()) {
+            final int[] p = queue.poll();
+            final int[][] neighbors = {
+                {p[0] + 1, p[1]}, {p[0] - 1, p[1]}, {p[0], p[1] + 1}, {p[0], p[1] - 1}};
+            for (final int[] n : neighbors) {
+                if (n[0] < 0 || n[1] < 0 || n[0] >= width || n[1] >= height) {
+                    continue;
+                }
+                if (framebuffer.getPixel(activePage, n[0], n[1]) == target) {
+                    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 */
+    public int graphicsCursorX() {
+        return graphicsCursorX;
+    }
+
+    /** @return y of the last point referenced by a drawing primitive */
+    public int graphicsCursorY() {
+        return graphicsCursorY;
+    }
+
+    // ------------------------------------------------------------- palette
+
+    /** {@code PALETTE index, rgb}: remaps one palette entry. */
+    public void setPalette(final int index, final int rgb) {
+        framebuffer.setPaletteColor(index, rgb);
+    }
+
+    /** @return 0xRRGGBB color of a palette entry */
+    public int paletteColor(final int index) {
+        return framebuffer.paletteColor(index);
+    }
+
+    // --------------------------------------------------------------- ports
+
+    private int dacWriteIndex;
+    private int dacComponent;
+    private final int[] dacColor = new int[3];
+
+    /**
+     * {@code OUT port, value}: hardware port write. Only the VGA DAC palette
+     * ports are modeled: 0x3C8 selects the color index, then three writes to
+     * 0x3C9 supply the red, green and blue components (hardware uses the low
+     * 6 bits of each). All other ports are ignored.
+     */
+    public void out(final int port, final int value) {
+        switch (port) {
+            case 0x3C8 -> {
+                dacWriteIndex = value & 0xFF;
+                dacComponent = 0;
+            }
+            case 0x3C9 -> {
+                final int component6 = value & 0x3F;
+                dacColor[dacComponent] = (component6 << 2) | (component6 >> 4);
+                dacComponent++;
+                if (dacComponent == 3) {
+                    framebuffer.setPaletteColor(dacWriteIndex,
+                            (dacColor[0] << 16) | (dacColor[1] << 8) | dacColor[2]);
+                    dacWriteIndex = (dacWriteIndex + 1) & 0xFF;
+                    dacComponent = 0;
+                }
+            }
+            default -> {
+                // Other hardware ports are out of scope: ignore.
+            }
+        }
+    }
+
+    // --------------------------------------------------------------- output
+
+    /**
+     * Writes the visual page as a PNG screenshot — the headless equivalent of
+     * looking at the monitor.
+     */
+    public void dumpPng(final Path output) throws IOException {
+        framebuffer.dumpPng(visualPage, output);
+    }
+
+    /** Raw framebuffer access for tests and VM introspection. */
+    public Framebuffer framebuffer() {
+        return framebuffer;
+    }
+
+    private void checkPage(final int page) {
+        if (page < 0 || page >= framebuffer.pageCount()) {
+            throw new IllegalArgumentException(
+                    "Page " + page + " out of range: SCREEN " + mode.number()
+                            + " has " + framebuffer.pageCount() + " page(s)");
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java
new file mode 100644 (file)
index 0000000..be58023
--- /dev/null
@@ -0,0 +1,76 @@
+package eu.svjatoslav.crtbasic.video;
+
+import java.io.IOException;
+import java.io.InputStream;
+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}).
+ * One byte per glyph scanline; bit 7 is the leftmost pixel.</p>
+ */
+public final class VgaFont {
+
+    /** Glyph width in pixels. Always 8 for VGA text. */
+    public static final int GLYPH_WIDTH = 8;
+
+    /** Number of glyphs in a full CP437 font. */
+    public static final int GLYPH_COUNT = 256;
+
+    private static final VgaFont FONT_8 = load(8);
+    private static final VgaFont FONT_14 = load(14);
+    private static final VgaFont FONT_16 = load(16);
+
+    private final int glyphHeight;
+    private final byte[] glyphs;
+
+    private VgaFont(final int glyphHeight, final byte[] glyphs) {
+        this.glyphHeight = glyphHeight;
+        this.glyphs = glyphs;
+    }
+
+    /** @return the font matching the given cell height (8, 14 or 16) */
+    public static VgaFont forCellHeight(final int cellHeight) {
+        return switch (cellHeight) {
+            case 8 -> FONT_8;
+            case 14 -> FONT_14;
+            case 16 -> FONT_16;
+            default -> throw new IllegalArgumentException("No VGA font with cell height " + cellHeight);
+        };
+    }
+
+    private static VgaFont load(final int glyphHeight) {
+        final String resource = "/fonts/cp437-8x" + glyphHeight + ".bin";
+        try (InputStream stream = VgaFont.class.getResourceAsStream(resource)) {
+            if (stream == null) {
+                throw new IllegalStateException("Missing font resource: " + resource);
+            }
+            final byte[] glyphs = stream.readAllBytes();
+            if (glyphs.length != GLYPH_COUNT * glyphHeight) {
+                throw new IllegalStateException(
+                        "Font " + resource + " has " + glyphs.length + " bytes, expected "
+                                + GLYPH_COUNT * glyphHeight);
+            }
+            return new VgaFont(glyphHeight, glyphs);
+        } catch (final IOException e) {
+            throw new UncheckedIOException("Failed to load font resource: " + resource, e);
+        }
+    }
+
+    public int glyphHeight() {
+        return glyphHeight;
+    }
+
+    /**
+     * @param glyph CP437 glyph index (0-255)
+     * @param col   pixel column within the glyph (0 = leftmost)
+     * @param row   pixel row within the glyph (0 = top)
+     * @return true if the pixel is part of the glyph stroke
+     */
+    public boolean pixelSet(final int glyph, final int col, final int row) {
+        final int scanline = glyphs[(glyph & 0xFF) * glyphHeight + row] & 0xFF;
+        return (scanline & (0x80 >> col)) != 0;
+    }
+}
diff --git a/src/main/resources/fonts/cp437-8x14.bin b/src/main/resources/fonts/cp437-8x14.bin
new file mode 100644 (file)
index 0000000..2c6d8bb
Binary files /dev/null and b/src/main/resources/fonts/cp437-8x14.bin differ
diff --git a/src/main/resources/fonts/cp437-8x16.bin b/src/main/resources/fonts/cp437-8x16.bin
new file mode 100644 (file)
index 0000000..672d0e1
Binary files /dev/null and b/src/main/resources/fonts/cp437-8x16.bin differ
diff --git a/src/main/resources/fonts/cp437-8x8.bin b/src/main/resources/fonts/cp437-8x8.bin
new file mode 100644 (file)
index 0000000..10e5b4f
Binary files /dev/null and b/src/main/resources/fonts/cp437-8x8.bin differ
diff --git a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
new file mode 100644 (file)
index 0000000..35de341
--- /dev/null
@@ -0,0 +1,126 @@
+package eu.svjatoslav.crtbasic.drivers;
+
+import eu.svjatoslav.crtbasic.ast.Ast;
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.interp.Interpreter;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.awt.event.KeyEvent;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Headless gameplay driver: runs a .bas program offscreen while feeding it
+ * a scripted sequence of keystrokes, then dumps the framebuffer. This is
+ * the agent play-testing path for keyboard-driven games (Main's --headless
+ * mode cannot inject keys).
+ *
+ * <p>Usage:</p>
+ * <pre>
+ *   GameDriver program.bas [--steps=N] [--screenshot=out.png]
+ *       [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
+ * </pre>
+ *
+ * <p>Key script: each character is one input event, delivered roughly every
+ * --interval milliseconds (default 12) while the program runs:</p>
+ * <ul>
+ *   <li>{@code R L U D} — arrow keys (right/left/up/down, VK codes)</li>
+ *   <li>{@code ~} — pause (no key, just waits one interval)</li>
+ *   <li>anything else — the typed character itself</li>
+ * </ul>
+ *
+ * <p>After the script is exhausted the program keeps running until it ends
+ * on its own or the statement budget (default 8M) is spent. The named
+ * variables are printed from the global scope afterwards for state
+ * verification.</p>
+ */
+public final class GameDriver {
+
+    private static final Charset SOURCE_CHARSET = Charset.forName("IBM437");
+
+    private GameDriver() {
+        // Utility class.
+    }
+
+    public static void main(final String[] args) throws Exception {
+        if (args.length == 0) {
+            System.err.println("Usage: GameDriver program.bas [--steps=N]"
+                    + " [--screenshot=out.png] [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%]");
+            System.exit(1);
+        }
+        final Path programPath = Path.of(args[0]);
+        long steps = 8_000_000;
+        Path screenshot = null;
+        String keys = "";
+        long interval = 12;
+        String[] vars = new String[0];
+        for (int i = 1; i < args.length; i++) {
+            final String arg = args[i];
+            if (arg.startsWith("--steps=")) {
+                steps = Long.parseLong(arg.substring("--steps=".length()));
+            } else if (arg.startsWith("--screenshot=")) {
+                screenshot = Path.of(arg.substring("--screenshot=".length()));
+            } else if (arg.startsWith("--keys=")) {
+                keys = arg.substring("--keys=".length());
+            } else if (arg.startsWith("--interval=")) {
+                interval = Long.parseLong(arg.substring("--interval=".length()));
+            } else if (arg.startsWith("--vars=")) {
+                vars = arg.substring("--vars=".length()).split(",");
+            } else {
+                System.err.println("Unknown option: " + arg);
+                System.exit(1);
+            }
+        }
+
+        final String source = Files.readString(programPath, SOURCE_CHARSET);
+        final List<Ast.Stmt> program = Parser.parse(source);
+        final VgaDevice vga = new VgaDevice();
+        final KeyboardQueue queue = new KeyboardQueue();
+        final Interpreter interpreter = new Interpreter(vga, queue, new SoundQueue());
+        interpreter.setMaxStatements(steps);
+        interpreter.setSourceLines(source.split("\n", -1));
+        interpreter.setBlockOnInput(false);
+        final Path parent = programPath.toAbsolutePath().getParent();
+        interpreter.setBaseDirectory(parent == null ? Path.of("").toAbsolutePath() : parent);
+
+        final Thread programThread = new Thread(() -> {
+            try {
+                interpreter.run(program);
+            } catch (final RuntimeException e) {
+                System.err.println(e.getMessage());
+            }
+        }, "basic-program");
+        programThread.setDaemon(true);
+        programThread.start();
+
+        for (final char c : keys.toCharArray()) {
+            if (!programThread.isAlive()) {
+                break;
+            }
+            switch (c) {
+                case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE);
+                case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE);
+                case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE);
+                case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE);
+                case '~' -> {
+                    // Pause: let the simulation advance without input.
+                }
+                default -> queue.pushChar(c);
+            }
+            Thread.sleep(interval);
+        }
+        programThread.join();
+
+        if (screenshot != null) {
+            interpreter.video().dumpPng(screenshot);
+            System.out.println("Screenshot: " + screenshot);
+        }
+        System.out.println("Statements executed: " + interpreter.statementsExecuted());
+        for (final String variable : vars) {
+            System.out.println(variable + " = " + interpreter.variable(variable));
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
new file mode 100644 (file)
index 0000000..9f3b2c3
--- /dev/null
@@ -0,0 +1,101 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Interpreter semantics added for 2D GFX/People.bas: SELECT CASE ranges,
+ * COMMAND$ and SLEEP.
+ */
+class InterpreterFeaturesTest {
+
+    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 selectCaseSupportsToRanges() {
+        final Interpreter interpreter = run("""
+                FOR i = 1 TO 4
+                SELECT CASE i
+                CASE 1
+                r = r + 10
+                CASE 2 TO 3
+                r = r + 100
+                CASE ELSE
+                r = r + 1000
+                END SELECT
+                NEXT i
+                """);
+        assertEquals(10.0 + 100 + 100 + 1000, interpreter.variable("r"));
+    }
+
+    @Test
+    void selectCaseRangeBoundariesAreInclusive() {
+        final Interpreter interpreter = run("""
+                SELECT CASE 50
+                CASE 50 TO 200
+                hit = 1
+                END SELECT
+                SELECT CASE 200
+                CASE 50 TO 200
+                hit2 = 1
+                END SELECT
+                SELECT CASE 201
+                CASE 50 TO 200
+                hit3 = 1
+                CASE ELSE
+                hit3 = 2
+                END SELECT
+                """);
+        assertEquals(1.0, interpreter.variable("hit"));
+        assertEquals(1.0, interpreter.variable("hit2"));
+        assertEquals(2.0, interpreter.variable("hit3"));
+    }
+
+    @Test
+    void commandDollarReturnsConfiguredCommandLine() {
+        final Interpreter interpreter = new Interpreter(
+                new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+        interpreter.setBlockOnInput(false);
+        interpreter.setCommandLine("t");
+        interpreter.run(Parser.parse("a$ = COMMAND$"));
+        assertEquals("t", interpreter.variable("a$"));
+    }
+
+    @Test
+    void commandDollarDefaultsToEmpty() {
+        final Interpreter interpreter = run("a$ = COMMAND$");
+        assertEquals("", interpreter.variable("a$"));
+    }
+
+    @Test
+    void sleepWithoutArgumentReturnsImmediatelyWhenHeadless() {
+        assertTimeoutPreemptively(Duration.ofSeconds(2),
+                () -> run("SLEEP\na = 1"));
+    }
+
+    @Test
+    void pendingKeyInterruptsSleep() {
+        final KeyboardQueue keys = new KeyboardQueue();
+        keys.pushChar('x');
+        final Interpreter interpreter = new Interpreter(
+                new VgaDevice(), keys, new SoundQueue());
+        assertTimeoutPreemptively(Duration.ofSeconds(2),
+                () -> interpreter.run(Parser.parse("SLEEP 60")));
+        // The interrupting key stays queued for INKEY$.
+        assertTrue(!keys.isEmpty());
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java
new file mode 100644 (file)
index 0000000..f0d7a0d
--- /dev/null
@@ -0,0 +1,79 @@
+package eu.svjatoslav.crtbasic.video;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** LINE / CIRCLE / graphics cursor semantics. */
+class GraphicsPrimitivesTest {
+
+    private VgaDevice vga;
+
+    @BeforeEach
+    void setUp() {
+        vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+    }
+
+    @Test
+    void lineIncludesBothEndpoints() {
+        vga.line(10, 20, 15, 20, 4);
+        assertEquals(4, vga.point(10, 20));
+        assertEquals(4, vga.point(15, 20));
+        assertEquals(0, vga.point(16, 20));
+    }
+
+    @Test
+    void lineDrawsDiagonal() {
+        vga.line(0, 0, 5, 5, 2);
+        for (int i = 0; i <= 5; i++) {
+            assertEquals(2, vga.point(i, i));
+        }
+    }
+
+    @Test
+    void lineBoxFilledCoversInclusiveArea() {
+        vga.lineBoxFilled(10, 10, 11, 11, 5); // the 2x2 "magnify pixel" idiom
+        assertEquals(5, vga.point(10, 10));
+        assertEquals(5, vga.point(11, 10));
+        assertEquals(5, vga.point(10, 11));
+        assertEquals(5, vga.point(11, 11));
+        assertEquals(0, vga.point(12, 10));
+        assertEquals(0, vga.point(10, 12));
+    }
+
+    @Test
+    void lineBoxOutlinesOnly() {
+        vga.lineBox(10, 10, 14, 14, 6);
+        assertEquals(6, vga.point(10, 10));
+        assertEquals(6, vga.point(14, 14));
+        assertEquals(6, vga.point(12, 10));
+        assertEquals(0, vga.point(12, 12), "interior must stay empty");
+    }
+
+    @Test
+    void circleUsesModeDefaultAspect() {
+        // SCREEN 13 aspect = 4*200/(3*320) = 0.8333 -> ry = round(10 * 0.8333) = 8
+        vga.circle(100, 100, 10, 9);
+        assertEquals(9, vga.point(110, 100), "rightmost point");
+        assertEquals(9, vga.point(90, 100), "leftmost point");
+        assertEquals(9, vga.point(100, 92), "topmost point (y radius 8)");
+        assertEquals(9, vga.point(100, 108), "bottommost point");
+        assertEquals(0, vga.point(100, 91), "no pixel above y radius");
+        assertEquals(0, vga.point(100, 100), "center stays empty");
+    }
+
+    @Test
+    void graphicsCursorTracksLastPointReferenced() {
+        vga.pset(3, 4, 1);
+        assertEquals(3, vga.graphicsCursorX());
+        assertEquals(4, vga.graphicsCursorY());
+        vga.line(0, 0, 20, 30, 1);
+        assertEquals(20, vga.graphicsCursorX());
+        assertEquals(30, vga.graphicsCursorY());
+        vga.circle(50, 60, 5, 1);
+        assertEquals(50, vga.graphicsCursorX());
+        assertEquals(60, vga.graphicsCursorY());
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java
new file mode 100644 (file)
index 0000000..1656564
--- /dev/null
@@ -0,0 +1,195 @@
+package eu.svjatoslav.crtbasic.video;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies the semantics of text-in-graphics-modes: glyph blitting,
+ * transparent vs. painted backgrounds, cursor wrap, scrolling and palettes.
+ */
+class VgaDeviceTest {
+
+    // 8x8 glyph 'A': row 0 is 0x30 (..##....), so pixel (col 2, row 0) is
+    // part of the stroke and (col 0, row 0) is not.
+    private static final int A_STROKE_X = 2;
+    private static final int A_STROKE_Y = 0;
+
+    @Test
+    void defaultsToScreen0TextMode() {
+        final VgaDevice vga = new VgaDevice();
+        assertEquals(ScreenMode.SCREEN_0, vga.mode());
+        assertEquals(80, vga.mode().columns());
+        assertEquals(25, vga.mode().rows());
+    }
+
+    @Test
+    void screen13HasFortyTextColumns() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        assertEquals(40, vga.mode().columns());
+        assertEquals(25, vga.mode().rows());
+        assertEquals(320, vga.framebuffer().width());
+        assertEquals(200, vga.framebuffer().height());
+    }
+
+    @Test
+    void printDrawsGlyphPixelsInGraphicsMode() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.print("A");
+        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = bright white in gfx modes
+        assertEquals(1, vga.cursorRow());
+        assertEquals(2, vga.cursorColumn());
+    }
+
+    @Test
+    void graphicsTextPaintsOpaqueBackground() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.pset(0, 0, 9); // a glyph "off" pixel of the cell we print into
+        vga.pset(A_STROKE_X, A_STROKE_Y, 9);
+        vga.print("A");
+        // The whole cell is painted; that is why PRINTing spaces
+        // erases text even in graphics modes.
+        assertEquals(0, vga.point(0, 0), "off-pixel must be painted with background color");
+        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color");
+    }
+
+    @Test
+    void printingSpacesErasesTextInGraphicsMode() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.print("A");
+        vga.locate(1, 1);
+        vga.print(" ");
+        assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "space must erase the glyph");
+    }
+
+    @Test
+    void textModePaintsWholeCell() {
+        final VgaDevice vga = new VgaDevice(); // SCREEN 0, 8x16 font
+        vga.setTextColor(7, 1);
+        vga.print("A");
+        assertEquals(1, vga.point(0, 0), "off-pixel must be painted with background");
+        // 8x16 glyph 'A': row 2 is 0x10 (...#....), so (col 3, row 2) is stroke.
+        assertEquals(7, vga.point(3, 2));
+    }
+
+    @Test
+    void cursorWrapsAtRightEdge() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13); // 40 columns
+        vga.print("X".repeat(41));
+        assertEquals(2, vga.cursorRow());
+        assertEquals(2, vga.cursorColumn());
+    }
+
+    @Test
+    void printingLastCellDoesNotScrollUntilNextCharacter() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.print("A"); // marker on row 1
+        vga.locate(25, 1);
+        vga.print("X".repeat(40)); // fills the last cell exactly
+        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact");
+
+        vga.print("X"); // one more character wraps and scrolls
+        assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "scroll: marker scrolled off the top");
+    }
+
+    @Test
+    void scrollFillsBottomLineWithBackground() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.setTextColor(7, 3);
+        for (int row = 1; row <= 25; row++) {
+            vga.locate(row, 1);
+            vga.print("line " + row);
+        }
+        vga.print("\n"); // leaves row 25 -> scroll
+        assertEquals(3, vga.point(0, 199), "bottom scanline cleared to background");
+        assertEquals(3, vga.point(319, 199));
+    }
+
+    @Test
+    void viewPrintConfinesScrollingToBand() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.locate(5, 1);
+        vga.print("A"); // marker above the band, at pixel row (5-1)*8 = 32
+
+        vga.viewPrint(10, 20);
+        for (int i = 0; i < 15; i++) {
+            vga.print("scrolling line " + i + "\n");
+        }
+        assertEquals(15, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move");
+        assertEquals(0, vga.point(A_STROKE_X, (20 - 1) * 8), "band bottom row was cleared");
+        assertEquals(20, vga.cursorRow());
+    }
+
+    @Test
+    void clsClearsToBackgroundAndHomesCursor() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.setTextColor(15, 4);
+        vga.print("something");
+        vga.cls();
+        assertEquals(4, vga.point(0, 0));
+        assertEquals(4, vga.point(319, 199));
+        assertEquals(1, vga.cursorRow());
+        assertEquals(1, vga.cursorColumn());
+    }
+
+    @Test
+    void paletteRemapChangesRenderedColorNotPixels() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        vga.pset(0, 0, 1);
+        assertEquals(0xFF0000AA, vga.framebuffer().toImage(0).getRGB(0, 0));
+        vga.setPalette(1, 0xFF0000);
+        assertEquals(1, vga.point(0, 0), "pixel value stays a palette index");
+        assertEquals(0xFFFF0000, vga.framebuffer().toImage(0).getRGB(0, 0));
+    }
+
+    @Test
+    void screen7PagesAreIndependent() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_7);
+        vga.setActivePage(1);
+        vga.print("A");
+        assertEquals(15, vga.framebuffer().getPixel(1, A_STROKE_X, A_STROKE_Y));
+        assertEquals(0, vga.framebuffer().getPixel(0, A_STROKE_X, A_STROKE_Y));
+        assertThrows(IllegalArgumentException.class, () -> vga.setActivePage(8));
+    }
+
+    @Test
+    void locateOutOfRangeThrows() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+        assertThrows(IllegalArgumentException.class, () -> vga.locate(26, 1));
+        assertThrows(IllegalArgumentException.class, () -> vga.locate(1, 41));
+    }
+
+    @Test
+    void mode12UsesTallFontThirtyRows() {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_12);
+        assertEquals(30, vga.mode().rows());
+        assertEquals(80, vga.mode().columns());
+        vga.print("A");
+        // 8x16 glyph 'A' has its top rows empty; just assert something was drawn
+        boolean anyPixel = false;
+        for (int y = 0; y < 16 && !anyPixel; y++) {
+            for (int x = 0; x < 8; x++) {
+                if (vga.point(x, y) == 15) {
+                    anyPixel = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(anyPixel, "8x16 glyph must be drawn");
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java b/src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java
new file mode 100644 (file)
index 0000000..29c6dc9
--- /dev/null
@@ -0,0 +1,124 @@
+package eu.svjatoslav.crtbasic.video;
+
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import javax.imageio.ImageIO;
+
+/**
+ * Renders a few showcase screens and dumps them as upscaled PNGs under
+ * {@code /tmp/crt-basic-demo/} for visual (agent vision) verification.
+ *
+ * <p>Run with:
+ * {@code java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain}</p>
+ */
+public final class VideoDemoMain {
+
+    private static final Path OUT_DIR = Path.of("/tmp/crt-basic-demo");
+
+    private VideoDemoMain() {
+        // Utility class.
+    }
+
+    public static void main(final String[] args) throws IOException {
+        Files.createDirectories(OUT_DIR);
+        screen13Demo();
+        screen12Demo();
+        screen0Demo();
+        System.out.println("Demo frames written to " + OUT_DIR);
+    }
+
+    /** Text printed over a pixel-drawn scene: whole glyph cells are painted. */
+    private static void screen13Demo() throws IOException {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_13);
+
+        // Pixel scene: diagonal color stripes.
+        for (int y = 0; y < 200; y++) {
+            for (int x = 0; x < 320; x++) {
+                vga.pset(x, y, 16 + ((x + y) / 8) % 32);
+            }
+        }
+
+        vga.setTextColor(15, 0);
+        vga.locate(3, 4);
+        vga.print("SCREEN 13: 320x200, 256 colors");
+        vga.locate(5, 4);
+        vga.print("Text paints whole cells");
+        vga.locate(7, 4);
+        vga.print("--- will now be erased ---");
+        vga.locate(7, 4);
+        vga.print("                          "); // spaces erase text
+
+        vga.viewPrint(12, 20);
+        vga.setTextColor(11, 0);
+        for (int i = 1; i <= 14; i++) {
+            vga.print("VIEW PRINT 12 TO 20, scrolled line " + i + "\n");
+        }
+
+        dump(vga, "screen13.png", 3);
+    }
+
+    /** Hi-res mode with the 8x16 font and 30 text rows. */
+    private static void screen12Demo() throws IOException {
+        final VgaDevice vga = new VgaDevice();
+        vga.setMode(ScreenMode.SCREEN_12);
+        vga.setTextColor(10, 1);
+        vga.cls();
+        vga.setTextColor(15, 1);
+        vga.locate(2, 3);
+        vga.print("SCREEN 12: 640x480, 16 colors, 80x30 text (8x16 font)");
+        for (int color = 0; color < 16; color++) {
+            vga.setTextColor(color, 1);
+            vga.locate(4 + color, 3);
+            vga.print("COLOR " + color + " -- the quick brown fox 0123456789");
+        }
+        dump(vga, "screen12.png", 2);
+    }
+
+    /** Classic 80x25 text mode with CP437 box drawing. */
+    private static void screen0Demo() throws IOException {
+        final VgaDevice vga = new VgaDevice();
+        vga.setTextColor(7, 0);
+        vga.cls();
+        vga.setTextColor(14, 0);
+        vga.locate(2, 25);
+        vga.print("SCREEN 0: 80x25 text mode");
+        vga.setTextColor(11, 0);
+        // Double-line box, CP437 glyphs.
+        final String top = "\u00C9" + "\u00CD".repeat(30) + "\u00BB";
+        final String mid = "\u00BA" + " ".repeat(30) + "\u00BA";
+        final String bot = "\u00C8" + "\u00CD".repeat(30) + "\u00BC";
+        vga.locate(5, 10);
+        vga.print(top);
+        vga.locate(6, 10);
+        vga.print(mid);
+        vga.locate(7, 10);
+        vga.print("\u00BA   Text mode paints whole cells   \u00BA");
+        vga.locate(8, 10);
+        vga.print(mid);
+        vga.locate(9, 10);
+        vga.print(bot);
+        vga.setTextColor(7, 4);
+        vga.locate(12, 10);
+        vga.print(" red background, light gray text ");
+        dump(vga, "screen0.png", 2);
+    }
+
+    private static void dump(final VgaDevice vga, final String name, final int scale) throws IOException {
+        final BufferedImage source = vga.framebuffer().toImage(vga.visualPage());
+        final BufferedImage scaled = new BufferedImage(
+                source.getWidth() * scale, source.getHeight() * scale, BufferedImage.TYPE_INT_RGB);
+        final Graphics2D g = scaled.createGraphics();
+        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
+                RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+        g.drawImage(source, 0, 0, scaled.getWidth(), scaled.getHeight(), null);
+        g.dispose();
+        final Path output = OUT_DIR.resolve(name);
+        ImageIO.write(scaled, "png", output.toFile());
+        System.out.println(output);
+    }
+}