--- /dev/null
+# IntelliJ settings
+/.idea/
+
+# Build directory
+/target/
+
+*.html
--- /dev/null
+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.
--- /dev/null
+#+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.
--- /dev/null
+#+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.
--- /dev/null
+#+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.
--- /dev/null
+#+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.
--- /dev/null
+<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, & 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>
--- /dev/null
+#+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*.
--- /dev/null
+.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
--- /dev/null
+#+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.
--- /dev/null
+<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 &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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+* 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).
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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"
--- /dev/null
+<?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>
--- /dev/null
+package eu.svjatoslav.crtbasic;
+
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CP437 ↔ 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);
+ }
+}
--- /dev/null
+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 -> lexer -> parser (AST) -> 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);
+ }
+}
--- /dev/null
+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 {
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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);
+ }
+ }
+}
--- /dev/null
+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) {
+}
--- /dev/null
+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<<2)|(v>>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;
+ }
+ }
+}
--- /dev/null
+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];
+ }
+}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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);
+ }
+ }
+ }
+}
--- /dev/null
+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)");
+ }
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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));
+ }
+ }
+}
--- /dev/null
+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());
+ }
+}
--- /dev/null
+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());
+ }
+}
--- /dev/null
+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");
+ }
+}
--- /dev/null
+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);
+ }
+}