From: Svjatoslav Agejenko Date: Thu, 20 Aug 2026 20:52:18 +0000 (+0300) Subject: initial commit X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=e1e0b463a24da0bcd7537a088b77a2c11020d947;p=crtbasic.git initial commit --- e1e0b463a24da0bcd7537a088b77a2c11020d947 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c302191 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# IntelliJ settings +/.idea/ + +# Build directory +/target/ + +*.html diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/COPYING @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/Documentation/agent-integration/index.org b/Documentation/agent-integration/index.org new file mode 100644 index 0000000..f8ae407 --- /dev/null +++ b/Documentation/agent-integration/index.org @@ -0,0 +1,146 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Agent Integration - CRT Basic +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[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 program = Parser.parse(source); + +VgaDevice vga = new VgaDevice(); +KeyboardQueue keys = new KeyboardQueue(); +Interpreter interpreter = new Interpreter(vga, keys, new SoundQueue()); +interpreter.setMaxStatements(100_000); // statement budget +interpreter.setBlockOnInput(false); // headless: input never blocks +interpreter.setBaseDirectory(path.getParent()); // OPEN resolves here + +keys.pushChar(' '); // inject input any time +interpreter.run(program); + +vga.dumpPng(Path.of("out.png")); // screenshot +byte[] pixels = vga.framebuffer().pageData(0); // raw indexed pixels +Object score = interpreter.variable("score%"); // program state +long steps = interpreter.statementsExecuted(); // work performed +#+END_SRC + +This is exactly what =Main= and =GameDriver= do; nothing is hidden +behind the CLI. + +* Visual verification showcase +:PROPERTIES: +:CUSTOM_ID: showcase +:END: + +=VideoDemoMain= (test sources) renders a set of showcase frames — +SCREEN 13 color stripes with text being erased by spaces, a +=VIEW PRINT= scroll region, the SCREEN 12 color table, SCREEN 0 +CP437 box drawing — as upscaled PNGs: + +#+BEGIN_SRC sh +mvn package +java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain +# frames written to /tmp/crt-basic-demo/ +#+END_SRC + +These exist so that an agent with vision can verify the video +subsystem by *looking* at its output, not just by reading assertions. diff --git a/Documentation/architecture/index.org b/Documentation/architecture/index.org new file mode 100644 index 0000000..d5d52fb --- /dev/null +++ b/Documentation/architecture/index.org @@ -0,0 +1,237 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Architecture - CRT Basic +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.org][Back to main documentation]] + +* Architecture +:PROPERTIES: +:CUSTOM_ID: architecture +:END: + +*CRT Basic* is a classic language pipeline feeding a set of virtual +devices. A =.bas= file is tokenized, parsed into an AST, and executed +against a virtual VGA card, a keyboard queue and a sound timer. + +The guiding idea is that *devices are shared between frontends*. The +interactive Swing window and the headless agent drivers talk to the +exact same =VgaDevice= and =KeyboardQueue= instances; only the outer +shell differs. A program therefore behaves identically whether a +human watches it in a window or an agent samples it offscreen. + +* Package map +:PROPERTIES: +:CUSTOM_ID: package-map +:END: + +| Package | Key classes | Responsibility | +|-------------+-----------------------------------------------+----------------| +| (root) | =Main= | CLI entry point: interactive vs headless modes | +| =lexer= | =Lexer=, =Token= | Source text → token stream | +| =parser= | =Parser= | Tokens → AST, recursive descent | +| =ast= | =Ast= (sealed =Stmt= / =Expr= records) | Typed syntax tree, every node carries its source line | +| =interp= | =Interpreter= | Executes the AST (interim tree-walker) | +| =video= | =VgaDevice=, =Framebuffer=, =TextConsole=, =ScreenMode=, =VgaFont=, =DefaultPalettes=, =CapturedRegion= | The virtual VGA card | +| =input= | =KeyboardQueue= | Single input path for all frontends | +| =audio= | =SoundQueue= | SOUND timing model (no audio yet) | +| =frontend= | =SwingFrontend= | Interactive window: live screen + key capture | + +Test sources additionally carry the JUnit suites, the +=VideoDemoMain= showcase renderer and the =GameDriver= play-testing +driver (see [[file:../agent-integration/index.org][Agent integration]]). + +* The pipeline, stage by stage +:PROPERTIES: +:CUSTOM_ID: pipeline-stages +:END: + +** Lexer (=lexer=) + +Converts source text into tokens. The lexer is deliberately tolerant +of what real DOS-era =.bas= files look like: + +- Files are read as *CP437* (IBM437) with CRLF line endings — the + charset is applied at file-read time in =Main=. +- Both newlines and =:= are statement separators (=SEP= tokens). +- ='= comments and word-boundary-matched =REM= comments are dropped. +- Numbers: decimal with optional fraction and =E=/=D= exponents + (=1.5e3=, =2D+10=), hex =&H…=, octal =&O…=, and trailing type + suffixes (=1024&=, =1.5#=, =2!=). +- Identifiers may carry the BASIC type suffixes =% & ! # $= and may + contain =.= and =_=; CP437 high characters pass through in strings + and identifiers. +- A =_= as the last non-blank character of a line joins physical + lines into one logical statement (line continuation). + +Keywords are *not* special at this stage: they are emitted as plain +identifiers and the parser matches them by text, case-insensitively. +This keeps the lexer small and makes the grammar easy to extend. + +** Parser (=parser=, =ast=) + +A hand-written recursive-descent parser builds the AST. Every node is +an immutable record (sealed =Ast.Stmt= / =Ast.Expr= interfaces) that +carries the 1-based source line, so runtime errors can quote the +failing source line. + +Statements that are not (yet) supported throw a =ParseException= +naming the statement and the line. This is a feature: when a new +vintage program fails to load, the error message *is* the work item. + +Expression parsing follows the usual BASIC precedence (weakest to +strongest): =OR=, =AND=, =NOT=, comparison (= = <> < <= > >= =), +addition, multiplication (= * / \ MOD =), unary minus, power (=^=), +primaries. + +** Interpreter (=interp=) + +Executes the AST directly (tree-walking). This is the *interim* +execution engine: the target architecture compiles the AST to a +compact stack-based bytecode and runs it on a small VM. The +tree-walker exists so that whole programs run today and the +verification workflow applies at program level; lexer, parser and AST +carry over to the bytecode world unchanged. + +The interpreter is where BASIC semantics live: float32 narrowing on +store, type suffixes, true = -1, classic number formatting in +=PRINT=, by-reference SUB parameters, module-wide =DATA=, the 24-bit +=RND= generator. All of that is documented on the +[[file:../interpreter/index.org][Interpreter semantics]] page. + +** Video (=video=) + +A virtual VGA card: indexed-color framebuffer with multiple video +pages, a text console that blits CP437 glyphs into the pixel buffer, +mode table for SCREEN 0–13, default palettes, drawing primitives +(=PSET=, =LINE=, =CIRCLE=, =PAINT=, =GET=/=PUT=) and the DAC palette +ports reachable through =OUT=. Everything the program can see is +recoverable: PNG screenshots of the visual page, or the raw indexed +pixels of every page. Full details: [[file:../video/index.org][Video subsystem]]. + +** Input (=input=) + +=KeyboardQueue= is a thread-safe blocking queue of key presses: a +KeyEvent VK code plus the typed character where one exists. *Any* +driver pushes keys in — the Swing window, a JUnit test, an agent +script — and the program consumes them through =INKEY$= / =INPUT$=. +The mapping from modern key codes to the BASIC byte sequences +(printable character, or =CHR$(0)= + scancode for extended keys) +happens at consumption time inside the interpreter. + +** Audio (=audio=) + +=SoundQueue= models the *timing* of =SOUND= without producing audio: +durations are in PC timer ticks (18.2 per second), the note queue is +one note deep, and the next =SOUND= blocks until the previous note +finishes. This preserves two classic idioms — =SOUND 0, n= as a delay +and a single short =SOUND= per loop iteration as a frame-rate +limiter — which is what game programs actually depend on. + +** Frontend (=frontend=) + +=SwingFrontend= is deliberately dumb: it paints the visual page of +the =VgaDevice= into a window (nearest-neighbor scaling, ~30 fps +repaint timer) and pushes key presses into the =KeyboardQueue=. It +owns no emulation state. Headless operation simply skips this class. + +* Entry point: two ways to run +:PROPERTIES: +:CUSTOM_ID: entry-point +:END: + +=Main= wires the pieces together and selects one of two modes: + +#+BEGIN_SRC text +crtbasic program.bas interactive +crtbasic program.bas --headless [--screenshot=out.png] + [--raw=out.raw] [--steps=N] agent path +#+END_SRC + +- *Interactive* (default): the Swing window is shown and the program + runs on a daemon thread; key presses feed the input queue and + =INPUT$= blocks waiting for keys. +- *Headless*: no window. =--steps=N= caps executed statements so + endless game loops can be sampled; afterwards the framebuffer is + dumped as PNG (=--screenshot=) and/or raw indexed pixels + (=--raw=, all video pages concatenated). Input never blocks — + =INKEY$= / =INPUT$= just drain whatever a driver queued. + +Relative paths in =OPEN= resolve against the directory of the =.bas= +file, so programs find their data files regardless of the caller's +working directory. + +Startup errors (unreadable file, parse error) go to stderr in +headless mode and to an error dialog in interactive mode, where +nobody would see stderr. + +* Design decisions +:PROPERTIES: +:CUSTOM_ID: design-decisions +:END: + +** Bytecode VM is the target; the tree-walker is scaffolding + +SCREEN 13 programs sit in tight pixel loops. A compact stack-based +bytecode keeps interpretation overhead low and gives a natural +"execute N instructions" granularity for deterministic stepping. The +tree-walking interpreter is a deliberate interim: it made whole +programs runnable (and verifiable) from day one. When the compiler +and VM land, only =interp= is replaced. + +** Introspection is a feature, not an afterthought + +The interpreter exposes the executed-statement counter, variable +values from the global scope, and the raw framebuffer. An agent can +therefore *assert on program state* — "after 1000 statements, +=score%= must be 3 and pixel (10,10) must be color 4" — instead of +guessing from prose output. + +** Fail loudly on the unknown + +Unsupported statements raise errors that name the statement and the +source line. Silent misinterpretation would poison debugging and +verification: a wrong behavior must mean a wrong implementation, +never a skipped feature. + +** One device set, many frontends + +Because frontends share =VgaDevice= and =KeyboardQueue=, there is no +"headless build" and no behavioral drift between interactive and +automated runs. The =--headless= flag only skips window creation and +input blocking. + +* Repository layout +:PROPERTIES: +:CUSTOM_ID: repository-layout +:END: + +#+BEGIN_SRC text +crtbasic/ +├── README.org project overview and status +├── TODO.org work items (games to support, features) +├── COPYING license +├── pom.xml Maven build (Java 21, jar with Main-Class) +├── install.sh builds and installs to ~/.local +├── Documentation/ this documentation +└── src/ + ├── main/java/eu/svjatoslav/crtbasic/ + │ ├── Main.java + │ ├── lexer/ parser/ ast/ interp/ + │ ├── video/ input/ audio/ frontend/ + │ └── resources/fonts/ CP437 bitmap fonts (8x8, 8x14, 8x16) + └── test/java/eu/svjatoslav/crtbasic/ + ├── video/ unit tests + VideoDemoMain showcase + └── drivers/ GameDriver (scripted play-testing) +#+END_SRC + +The CP437 font binaries under =resources/fonts= were extracted from +the public-domain VGA fonts shipped with SeaBIOS +(=vgasrc/vgafonts.c=): 256 glyphs each, one byte per glyph scanline. diff --git a/Documentation/crtbasic.png b/Documentation/crtbasic.png new file mode 100644 index 0000000..710568a Binary files /dev/null and b/Documentation/crtbasic.png differ diff --git a/Documentation/index.org b/Documentation/index.org new file mode 100644 index 0000000..02050bc --- /dev/null +++ b/Documentation/index.org @@ -0,0 +1,215 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: CRT Basic - a BASIC interpreter +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +* Introduction +:PROPERTIES: +:CUSTOM_ID: introduction +:END: + +[[file:crtbasic.png]] + +*CRT Basic* is an interpreter for the BASIC programming language, +written in pure Java. It runs classic =.bas= programs on a virtual +VGA display: text mode, pixel graphics, palettes, multiple video +pages, keyboard input and sound timing — the full environment that +BASIC programs of the DOS era expect. + +The motivation is simple: BASIC programs from that era are a perfect +playground for AI agents. They are small, self-contained, visual and +interactive. *CRT Basic* is built so that an AI agent can *write, run, +observe, interact with and test* BASIC programs with no human in the +loop. Every program can run headless: the agent caps execution at N +statements, dumps the screen to a PNG or as raw video memory bytes, +injects scripted keystrokes, and +reads program variables back out when the run finishes. + +The language implemented is the structured BASIC dialect that became +widespread in the 1990s: no line numbers required, named +SUB/FUNCTION procedures, =DO...LOOP=, =SELECT CASE=, typed variables, +and the classic =SCREEN= graphics modes with =PSET=, =LINE=, =CIRCLE=, +=PAINT=, =GET= and =PUT=. Compatibility with existing vintage programs +comes as a practical side effect of implementing this dialect +faithfully: many classic =.bas= games and demos run unchanged. + +* Quick start +:PROPERTIES: +:CUSTOM_ID: quick-start +:END: + +#+BEGIN_SRC sh +cd /path/to/crtbasic +./install.sh # installs to ~/.local (CRT_BASIC_PREFIX to override) +crtbasic program.bas # interactive window +crtbasic program.bas --headless --screenshot=out.png --raw=out.raw --steps=100000 +#+END_SRC + +- With no options the program runs *interactively*: a Swing window + shows the live screen (~30 fps) and key presses reach the program + through =INKEY$= and =INPUT$=. +- =--headless= (implied by =--screenshot= / =--raw= / =--steps=) runs + with no window and dumps the screen afterwards — the agent-driving + path. =--raw= writes the raw indexed pixels of every video page, + suitable for byte-level comparison (see [[file:agent-integration/index.org][Agent integration]]). +- =--steps=N= caps executed statements, so programs with endless game + loops can be sampled deterministically. + +The interpreter requires Java 21 or newer. Building from source is a +plain =mvn package=; see [[#building][Building and testing]]. + +* Architecture at a glance +:PROPERTIES: +:CUSTOM_ID: architecture +:END: + +A =.bas= file flows through a classic interpreter pipeline. The +*lexer* is tolerant of DOS file realities (CP437 charset, CRLF line +endings). The *parser* produces an AST of immutable records. An +*interpreter* currently tree-walks the AST directly — an interim +solution until the planned bytecode compiler and VM land. Program +output goes to a *virtual VGA device*: indexed-color framebuffer, +bitmap text console, palettes and video pages. Input arrives through +a shared *keyboard queue* fed by the Swing window or by headless +drivers. + +| Package | Responsibility | +|-------------+-------------------------------------------------------| +| =lexer= | Source text → tokens | +| =parser= | Tokens → AST (recursive descent) | +| =ast= | Statement/expression record types | +| =interp= | Executes the AST (interim tree-walker; bytecode VM planned) | +| =video= | Virtual VGA: modes, framebuffer, text, drawing, palette | +| =input= | Keyboard queue shared by all frontends | +| =audio= | SOUND timing model (no audio output yet) | +| =frontend= | Swing window: live screen view + key capture | + +For the full walkthrough see [[file:architecture/index.org][Architecture]]. + +* Language coverage +:PROPERTIES: +:CUSTOM_ID: language +:END: + +The interpreter supports a practical, growing subset of structured +BASIC: + +- *Graphics*: =SCREEN= (modes 0, 1, 2, 7–13), =PSET=, =POINT=, =LINE= + (plain, =B=, =BF=), =CIRCLE= (with aspect), =PAINT=, =GET=, =PUT= + (PSET/XOR/OR/AND), =PALETTE= via =OUT= to the DAC ports, =PCOPY=. +- *Text*: =PRINT= (with =;= and =,= zones), =LOCATE=, =COLOR=, =CLS=, + =VIEW PRINT=, =CSRLIN=, =POS=. +- *Control flow*: =FOR/NEXT= (with =STEP=), single-line and block + =IF/THEN/ELSE/END IF=, =WHILE/WEND=, =DO...LOOP= (WHILE/UNTIL on + either end), =GOTO= and labels, =SELECT CASE=, =END=, =SYSTEM=. +- *Procedures*: =SUB= and =FUNCTION= with by-reference parameters, + =CALL=, =DECLARE=, =DIM SHARED=, =DEFINT= and siblings. +- *Data*: =DIM= (multi-dimensional arrays, =AS type=), =ERASE=, + =DATA=/=READ=. +- *Error handling*: =ON ERROR GOTO=, =RESUME= / =RESUME NEXT=. +- *Files*: =OPEN ... FOR INPUT=, =CLOSE=, =INPUT #=, =LINE INPUT #=, + =EOF()=. +- *Input and time*: =INKEY$=, =INPUT$=, =TIMER=, =TIME$=, =RANDOMIZE=, + =RND= (the classic 24-bit generator, bit-exact). +- *Sound*: =SOUND= is modeled as a timing device (18.2 ticks/s, + one-note queue); no audible output yet. + +Anything not yet supported fails loudly with the offending line +number — that is deliberate: the error message is the to-do list. +Known gaps include console =INPUT=, =CIRCLE= arcs, =GOSUB=, =DRAW= and +audio output. + +The full reference is on the [[file:language/index.org][Language]] page. + +* The virtual VGA +:PROPERTIES: +:CUSTOM_ID: video +:END: + +Program output lands on a virtual VGA card modeled after real +hardware: + +- *Indexed pixels*: one byte per pixel = palette index, exactly like + mode 13h video memory. =PALETTE= tricks and color cycling work + because remapping a palette entry recolors every pixel using it. +- *Text is pixels*: there is no separate text plane. =PRINT= blits + CP437 glyph bitmaps into the framebuffer, painting the *whole glyph + cell* in every mode — which is why printing spaces erases text, + exactly as vintage programs rely on. +- *Video pages*: SCREEN 7–10 have multiple pages; drawing targets the + active page while the monitor shows the visual page. +- *Pixel-exact drawing*: =LINE= reproduces the classic DDA + rasterizer including its clipping and endpoint bias, and =CIRCLE= + follows the classic rounding rules (half-up rounding, + SINGLE-precision aspect value). + +Details and diagrams: [[file:video/index.org][Video subsystem]]. + +* Driving programs without a human +:PROPERTIES: +:CUSTOM_ID: agent-integration +:END: + +The headless path is the heart of the project: + +#+BEGIN_SRC sh +# Run offscreen, cap at 100k statements, dump the screen +crtbasic game.bas --headless --screenshot=out.png --steps=100000 + +# Raw indexed pixels for byte-level comparison +crtbasic game.bas --headless --raw=out.raw + +# Scripted play-testing: feed keystrokes, then inspect variables +java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.drivers.GameDriver \ + game.bas --keys="RR~~~LL~" --screenshot=end.png --vars=score%,lives% +#+END_SRC + +The =GameDriver= feeds a scripted key sequence to a running game and +prints chosen variables afterwards. Combined with statement budgets +and framebuffer dumps, an agent can play-test keyboard-driven games +entirely on its own. + +See [[file:agent-integration/index.org][Agent integration]]. + +* Building and testing +:PROPERTIES: +:CUSTOM_ID: building +:END: + +#+BEGIN_SRC sh +cd /path/to/crtbasic +mvn package +java -jar target/crt-basic-*-SNAPSHOT.jar program.bas +mvn test # JUnit: video semantics, drawing primitives, end-to-end runs +#+END_SRC + +The =install.sh= script builds the jar and installs it to +=~/.local/share/crtbasic= together with a =crtbasic= launcher in +=~/.local/bin= and a desktop entry that makes =.bas= files open with +the interpreter (set =CRT_BASIC_PREFIX= to install elsewhere). + +A demo renderer (test sources) writes showcase frames for visual +verification: + +#+BEGIN_SRC sh +java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain +# frames appear in /tmp/crt-basic-demo/ +#+END_SRC + +* Further reading +:PROPERTIES: +:CUSTOM_ID: further-reading +:END: + +- [[file:architecture/index.org][Architecture]] — packages, pipeline, and why each piece exists. +- [[file:language/index.org][Language reference]] — the supported BASIC dialect in detail. +- [[file:video/index.org][Video subsystem]] — the virtual VGA: modes, text, drawing, palettes. +- [[file:interpreter/index.org][Interpreter semantics]] — numerics, variables, scoping, RND, errors. +- [[file:agent-integration/index.org][Agent integration]] — headless runs, drivers, scripted play-testing. diff --git a/Documentation/interpreter/index.org b/Documentation/interpreter/index.org new file mode 100644 index 0000000..32d5627 --- /dev/null +++ b/Documentation/interpreter/index.org @@ -0,0 +1,199 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Interpreter Semantics - CRT Basic +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.org][Back to main documentation]] + +* Execution engine +:PROPERTIES: +:CUSTOM_ID: execution-engine +:END: + +The =Interpreter= class executes the AST directly, tree-walking. It +is the *interim* engine: the target architecture compiles the AST to +a compact stack-based bytecode executed by a small VM, which is +cheaper in tight pixel loops and gives a natural "execute N +instructions" granularity. The tree-walker exists so that whole +programs run — and can be verified — today. The lexer, parser and +AST carry over unchanged; only this package gets replaced. + +Because BASIC semantics are subtle and real programs depend on the +subtleties, they are pinned down here explicitly. + +* The numeric model +:PROPERTIES: +:CUSTOM_ID: numeric-model +:END: + +#+INCLUDE: "number-flow.svg" export html + +- *Expression evaluation always runs in double precision.* Precision + is only lost when a value is *stored*: into a variable, an array + element, or a numeric literal narrower than DOUBLE. +- *Storage narrows to the slot's type.* SINGLE slots (the default) + narrow to float32. INTEGER (= %=) and LONG (= &=) slots round + *half-to-even* and raise *Overflow* when out of range. DOUBLE + (= #=) slots keep everything. +- Numeric literals narrow to float32 unless written with a =#= + suffix or a =D= exponent, which mark them DOUBLE. +- Graphics coordinates and colors convert fractional values by + rounding half-to-even — not truncation. + +In practice: a value computed as =0.1 + 0.2= keeps full double +precision through the addition and narrows exactly once, on +assignment. + +* Variable identity +:PROPERTIES: +:CUSTOM_ID: variable-identity +:END: + +Variable names are case-insensitive but *suffix-sensitive*: + +- =x= and =x!= are the same variable; =x%= is a different one. +- A suffix-less name resolves its suffix from =DEFINT= and siblings + (module-wide, by first letter), or from a =DIM name AS type= + declaration, defaulting to SINGLE. +- Unassigned numeric variables read as =0=, strings as =""=. +- True is =-1=, false is =0=; =AND= / =OR= / =NOT= are bitwise. + +* Procedures and scoping +:PROPERTIES: +:CUSTOM_ID: procedures-and-scoping +:END: + +- *SUBs and FUNCTIONs register at load time* and never execute + inline; main code is everything else. +- *Plain-variable arguments pass by reference* — the parameter shares + the caller's storage cell, so writes through the parameter are + visible to the caller. Expression arguments pass by value. +- SUBs do not see the caller's other variables. =DIM SHARED= names + resolve against the global scope from inside any SUB. +- When a SUB/FUNCTION header uses bare parameter names, the matching + =DECLARE= statement's suffixes decide the parameter types. +- A FUNCTION returns whatever was last assigned to its own name + inside the body (=0= / =""= when never assigned). +- Arrays not yet =DIM=med auto-dimension to =0 TO 10= per dimension + on first indexed use; =ERASE= returns a slot to its never-DIMmed + state. + +* Labels, GOTO and error handling +:PROPERTIES: +:CUSTOM_ID: control-flow-internals +:END: + +Each statement block executes under a program counter. A =GOTO= +throws a control-flow signal carrying the label; the enclosing block +catches it when it contains the label, and rethrows otherwise — which +gives labels *procedure-wide scope*, exactly as the language defines, +without any pre-pass. + +=ON ERROR GOTO= registers a module-global handler label. A trapped +runtime error transfers control to the handler, remembering the +program counter of the failing statement. =RESUME= retries that +statement, =RESUME NEXT= continues after it, =RESUME label= jumps. +=ON ERROR GOTO 0= disables trapping. (For an error raised inside a +SUB, the current approximation retries the CALL statement.) + +=DATA= constants are module-wide: they are collected in program order +before execution begins, and =READ= walks that list. Running out +raises *Out of DATA*. + +* RND is bit-exact +:PROPERTIES: +:CUSTOM_ID: rnd +:END: + +The random generator reproduces the reference algorithm exactly: a +24-bit linear congruential generator + +#+BEGIN_SRC text +seed = (seed * &HFD43FD + &HC39EC3) mod 2^24 +RND = seed / 2^24 (as SINGLE) +#+END_SRC + +- Without =RANDOMIZE=, the generator starts from the same fixed seed + as the reference, so programs get the identical deterministic + sequence. +- =RND(0)= repeats the last value; =RND(-n)= reseeds first; + =RANDOMIZE n= reseeds like =RND(-n)=; bare =RANDOMIZE= seeds from + the clock (=TIMER=). +- Reseeding folds the SINGLE bit pattern of =|n|= into the 24-bit + seed as =(bits & &HFFFFFF) + (bits >>> 24) + &H80=. + +Programs that shuffle, spawn or wander "randomly" therefore produce +the same playthrough every time — which is what makes scripted +play-testing reproducible. + +* PRINT number formatting +:PROPERTIES: +:CUSTOM_ID: print-formatting +:END: + +Numbers print at the *expression's static type*: SINGLE formatting +(~7 significant digits, float32-narrowed) by default, 16 significant +digits for DOUBLE expressions — with =D±xx= scientific notation +outside the fixed range. Non-negative values get a leading space; +every number gets a trailing space. + +Known gap: some classic implementations of this dialect misprint a +few DOUBLE values by 1–2 ulp in a bit-pattern-dependent way +(=STR$(592#)= prints ="591.9999999999999"= there, while =321#= +prints exactly). Every constant-error hypothesis failed on +same-decade value pairs, so this interpreter prints the +mathematically correct 16 digits instead. + +* Statement budget and introspection +:PROPERTIES: +:CUSTOM_ID: introspection +:END: + +- =setMaxStatements(n)= caps execution; the + =StepsLimitException= fires when the budget is spent. This is how + =--steps=N= samples endless game loops deterministically. +- =statementsExecuted()= reports how far a run got. +- =variable(name)= reads a variable from the global scope — agents + assert on program state directly. +- =video()= exposes the whole virtual VGA: raw page bytes, PNG dumps, + cursor positions, palette entries. +- Setting the system property =crtbasic.linelog= to a file path makes + every =LINE= call append =x1,y1,x2,y2,color= after coordinate + conversion — a debug aid for tracing draw calls. + +* File I/O model +:PROPERTIES: +:CUSTOM_ID: file-io +:END: + +=OPEN path FOR INPUT AS #n= opens a text file (read as CP437; relative +paths resolve against the =.bas= file's directory). Each channel keeps +a one-line lookahead so =EOF(n)= can answer without consuming input, +plus a buffer of comma-split items so several =INPUT #= statements can +share one line and one =INPUT #= can span lines. Numeric targets parse +like =VAL= (empty or unparseable is 0); string targets keep raw text +with surrounding quotes stripped. =LINE INPUT #= reads whole lines; +=CLOSE= with no channel closes everything; closing an unopened channel +is silently ignored. + +Output modes (=OPEN FOR OUTPUT=, =PRINT #=) are not yet implemented. + +* Input consumption +:PROPERTIES: +:CUSTOM_ID: input-consumption +:END: + +The interpreter maps queued keys to classic byte sequences at +consumption time: printable keys yield their character (Enter as +=CHR$(13)=); extended keys yield =CHR$(0)= + scancode. =INKEY$= +returns a whole sequence in one call; =INPUT$(n)= doles bytes out +across reads, holding the tail of a half-consumed extended key. +Interactive mode blocks waiting for keys in =INPUT$=; headless mode +never blocks. diff --git a/Documentation/interpreter/number-flow.svg b/Documentation/interpreter/number-flow.svg new file mode 100644 index 0000000..34d6928 --- /dev/null +++ b/Documentation/interpreter/number-flow.svg @@ -0,0 +1,32 @@ + + + + + + + + + + literal / variable + 0.1, x%, total# + + + expression evaluation + always double precision + + + store into variable / array + narrow to the slot's type + + + + + float32 unless # / D-exp + no precision loss + mid-expression + % int16, & int32 (half-even) + ! float32, # double + + y = 0.1 + 0.2 → full double math, then ONE narrowing when assigned to y + diff --git a/Documentation/language/index.org b/Documentation/language/index.org new file mode 100644 index 0000000..3b0c60d --- /dev/null +++ b/Documentation/language/index.org @@ -0,0 +1,304 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Language Reference - CRT Basic +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.org][Back to main documentation]] + +* The BASIC dialect +:PROPERTIES: +:CUSTOM_ID: dialect +:END: + +*CRT Basic* implements the structured BASIC dialect common to early-90s +DOS programs: optional line numbers, named procedures, typed variables +by suffix, and the classic =SCREEN= graphics environment. + +This page documents what the interpreter *currently* supports. The +project is under active development: unsupported statements fail with +an error naming the statement and the source line, and support grows +program by program. + +* Source files +:PROPERTIES: +:CUSTOM_ID: source-files +:END: + +- Files are read as *CP437* with CRLF tolerated — the natural format + of DOS-era =.bas= files. +- *Comments*: ='= anywhere, or =REM= as a statement. Both run to end + of line. +- *Statement separators*: newline or =:=. Multiple statements per line + work (=A = 1 : B = 2=). +- *Line continuation*: a =_= as the last non-blank character of a line + joins it with the next. +- *Case*: keywords and variable names are case-insensitive. +- *Numbers*: decimal (=3.25=), exponents (=1.5e3=, =2D+10=), hex + =&HFF=, octal =&O17=, and type-suffixed literals (=1024&=, =1.5#=). +- *Labels*: numeric (=1 IF x THEN GOTO 2=) or alphanumeric + (=MainLoop:=). Targets for =GOTO= and =ON ERROR GOTO=. + +* Variables and types +:PROPERTIES: +:CUSTOM_ID: variables-and-types +:END: + +A variable's type comes from its *name suffix*: + +| Suffix | Type | Storage | +|--------+---------+------------------------------------------| +| =%= | INTEGER | 16-bit integer, rounded half-to-even on store; out of range → *Overflow* | +| =&= | LONG | 32-bit integer, same rounding/overflow rules | +| =!= | SINGLE | 32-bit float (narrowed on store) | +| =#= | DOUBLE | 64-bit float | +| =$= | STRING | dynamically sized string | + +A name without a suffix gets a *default type*: SINGLE, unless changed +by =DEFINT= / =DEFLNG= / =DEFSNG= / =DEFDBL= / =DEFSTR= letter ranges +(=DEFINT A-Z= makes every plain name an INTEGER). The default applies +by first letter and is module-wide, independent of where the =DEFxxx= +statement appears. + +Important consequences: + +- =x= and =x!= are the *same* variable (under the default rules); + =x%= is a *different* one. +- Numbers are computed in double precision inside expressions and + *narrowed to the variable's type on store*. =y = 0.1 + 0.2= keeps + full precision during the addition, then narrows to SINGLE when + assigned. +- =DIM name AS INTEGER= fixes a suffix-less name's type the same way + a suffix would. +- Unassigned numeric variables read as =0=, strings as =""=. + +*Truth values*: comparisons return =-1= for true and =0= for false. +Any nonzero number is true in =IF= / =WHILE= conditions. =AND=, =OR= +and =NOT= are *bitwise* operations on integers, exactly as classic +BASIC defines them. + +* Arrays +:PROPERTIES: +:CUSTOM_ID: arrays +:END: + +#+BEGIN_SRC basic +DIM scores(0 TO 50) ' explicit bounds +DIM grid(10, 20) ' 0-based, two dimensions +DIM SHARED high%(9) ' visible inside SUBs +DIM names$(20) AS STRING ' AS type form +#+END_SRC + +- An array used *without* =DIM= is auto-dimensioned =0 TO 10= per + dimension on first indexed use — the classic default. +- Indexing out of range raises *Subscript out of range*. +- =ERASE name= frees an array; it can be re-=DIM=med afterwards. +- Element types follow the same suffix rules as scalars; INTEGER + arrays narrow and range-check each stored element. + +* Statements +:PROPERTIES: +:CUSTOM_ID: statements +:END: + +** Screen and graphics + +| Statement | Notes | +|-----------+-------| +| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count | +| =PSET (x, y)[, color]= | One pixel; default color = current text foreground | +| =POINT(x, y)= | Function: palette index of a pixel | +| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original | +| =LINE …, , B= / =BF= | Rectangle outline / filled rectangle (inclusive corners) | +| =CIRCLE (x,y), r[, color][,,,aspect]= | Ellipse via aspect; arcs (start/end angles) *not yet* | +| =PAINT (x,y)[, fill[, border]]= | Flood fill | +| =GET (x1,y1)-(x2,y2), array= | Captures a screen rectangle into a variable | +| =PUT (x,y), array[, action]= | Blits a captured rectangle back; action is PSET, XOR, OR or AND | +| =PCOPY source, target= | Copies one video page onto another | +| =PALETTE= via =OUT &H3C8/&H3C9= | DAC palette ports are modeled; other ports ignored | + +** Text + +| Statement | Notes | +|-----------+-------| +| =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline | +| =LOCATE row, col= | 1-based cursor position | +| =COLOR fg[, bg]= | Text colors (palette indices) | +| =CLS= | Clears to background, homes cursor | +| =VIEW PRINT top TO bottom= | Scroll region (parsed; framebuffer effect is currently an approximation) | +| =CSRLIN=, =POS(0)= | Functions: cursor row / column | + +** Control flow + +| Statement | Notes | +|-----------+-------| +| =FOR v = a TO b [STEP s] … NEXT= | STEP 0 raises an error | +| =IF c THEN … [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms | +| =WHILE c … WEND= | | +| =DO [WHILE/UNTIL c] … LOOP [WHILE/UNTIL c]= | Condition on either end | +| =SELECT CASE x … CASE a, b … CASE ELSE … END SELECT= | Equality cases (=CASE IS=, =CASE a TO b= not yet) | +| =GOTO label= | Procedure-wide label scope | +| =END=, =SYSTEM= | Both end the program | +| =GOSUB= / =RETURN= | *Not yet implemented* | + +** Procedures + +#+BEGIN_SRC basic +DECLARE SUB DrawStar (cx%, cy%, r!) + +CALL DrawStar(100, 100, 20) +DrawStar 50, 50, 10 ' bare-call form, no parentheses + +SUB DrawStar (cx%, cy%, r!) + ... +END SUB + +FUNCTION Area# (r!) + Area# = 3.141592653589793# * r! * r! +END FUNCTION +#+END_SRC + +- Arguments that are *plain variables* are passed *by reference*: + assignments to the parameter write through to the caller's + variable. Expressions are passed by value. +- A FUNCTION's return value is whatever was last assigned to the + function's own name inside the body. +- =DECLARE= statements carry parameter type suffixes: when a + SUB/FUNCTION header uses bare names, the declared suffixes type the + parameters. +- SUBs do *not* see the caller's variables (classic scoping), except + names declared =DIM SHARED=. + +** Data, files, errors, misc + +| Statement | Notes | +|-----------+-------| +| =DATA v, …= / =READ var, …= | DATA is module-wide, collected in program order; running out raises *Out of DATA* | +| =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= | +| =OPEN path FOR INPUT AS #n= | Text files; paths resolve relative to the =.bas= file | +| =INPUT #n, var, …= | Comma-delimited items; numbers parse like =VAL= | +| =LINE INPUT #n, var$= | Whole line | +| =EOF(n)= | True at end of channel | +| =CLOSE [#n]= | Bare form closes all | +| =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping | +| =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump | +| =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet | +| =OUT port, value= | VGA DAC ports only; others ignored | + +* Expressions and operators +:PROPERTIES: +:CUSTOM_ID: expressions +:END: + +Precedence, weakest first: + +| Level | Operators | +|-------+-----------| +| 1 | =OR= | +| 2 | =AND= | +| 3 | =NOT= (unary) | +| 4 | = = <> < <= > >= = (comparison) | +| 5 | =+ - = | +| 6 | =* / \ MOD= | +| 7 | unary =- = | +| 8 | =^= | + +Semantics worth knowing: + +- =/= is floating-point division; =\= and =MOD= round both operands + to integers (half-to-even) first, then divide / take the remainder. +- Division by zero raises *Division by zero*. +- =+= on two strings concatenates. +- Comparison of strings is by exact/lexicographic string order; + comparing a string to a number raises *Type mismatch*. + +* Built-in functions +:PROPERTIES: +:CUSTOM_ID: functions +:END: + +| Function | Result | +|----------+--------| +| =ABS=, =SGN=, =INT=, =FIX= | magnitude/sign/floor/truncation | +| =CINT=, =CLNG= | round half-to-even into int16 / int32 range (Overflow if out of range) | +| =SQR=, =EXP=, =LOG= | square root, e^x, natural log | +| =SIN=, =COS=, =TAN=, =ATN= | radians, double precision | +| =RND[(n)]= | =n>0= or omitted: next number; =n=0=: repeat last; =n<0=: reseed first | +| =TIMER= | seconds since local midnight (single precision) | +| =TIME$= | current time, ="HH:MM:SS"= | +| =POINT(x, y)= | palette index of a pixel | +| =POS(0)=, =CSRLIN= | cursor column / row (1-based) | +| =CHR$(n)=, =ASC(s$)= | character ↔ code | +| =STR$(n)=, =VAL(s$)= | number ↔ string (=VAL= of junk is 0) | +| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) | +| =UCASE$=, =LCASE$= | case conversion | +| =LTRIM$=, =RTRIM$= | strip spaces | +| =INKEY$= | one key, non-blocking; =""= when idle | +| =INPUT$(n)= | read up to n input bytes (extended keys arrive as =CHR$(0)= + scancode) | +| =EOF(n)= | -1 at end of file channel | + +* PRINT formatting +:PROPERTIES: +:CUSTOM_ID: print-formatting +:END: + +Numbers print the way classic BASIC formats them: + +- A *leading space* for non-negative values and a *trailing space* — + =PRINT 1= emits =" 1 "=. +- SINGLE values print with float32's ~7 significant digits; DOUBLE + expressions (a =#=-suffixed literal or variable anywhere inside) + print with 16 significant digits, using =D±xx= scientific notation + outside the fixed range. +- =,= advances to the next 14-column print zone; =;= prints compactly. + A trailing separator suppresses the newline. + +Whole glyph cells are painted when text lands in a graphics mode — +see [[file:../video/index.org::#text-is-pixels][Text is pixels]] for +why that matters. + +* Keyboard input model +:PROPERTIES: +:CUSTOM_ID: keyboard-input +:END: + +Programs read keys through =INKEY$= (single key, non-blocking) and +=INPUT$(n)= (up to n bytes). Printable keys arrive as their character +(Enter as =CHR$(13)=). Cursor keys, function keys and editing keys +arrive as the classic two-byte sequence =CHR$(0)= followed by the +scancode — =INKEY$= returns both bytes in one call, =INPUT$= hands +them out across successive reads. + +| Key | Sequence | +|-----+----------| +| Up / Down / Left / Right | =CHR$(0)= + 72 / 80 / 75 / 77 | +| Home / End / PgUp / PgDn | =CHR$(0)= + 71 / 79 / 73 / 81 | +| Insert / Delete | =CHR$(0)= + 82 / 83 | +| F1–F10 | =CHR$(0)= + 59–68 | +| Esc, Enter, Backspace, Tab | single byte: 27, 13, 8, 9 | + +In headless mode input never blocks: both functions simply drain +whatever a driver pushed into the keyboard queue and return =""= when +it is empty. + +* Error behavior +:PROPERTIES: +:CUSTOM_ID: errors +:END: + +Runtime errors are fatal by default and always name the source line +(plus the offending source text when available). Trap them with +=ON ERROR GOTO label=; =RESUME= retries the trapped statement, +=RESUME NEXT= continues after it, =RESUME label= jumps. =ON ERROR +GOTO 0= disables trapping. + +Common runtime errors: *Overflow* (integer store out of range), +*Division by zero*, *Subscript out of range*, *Type mismatch*, +*Out of DATA*, *Input past end of file*, *File not found*, +*Bad file number*, *Label not found*. diff --git a/Documentation/style.css b/Documentation/style.css new file mode 100644 index 0000000..3403e0e --- /dev/null +++ b/Documentation/style.css @@ -0,0 +1,35 @@ +.flex-center { + display: flex; + justify-content: center; +} + +.flex-center video { + width: min(90%, 1000px); + height: auto; +} + +.responsive-img { + width: min(100%, 1000px); + height: auto; +} + +/* === SVG diagram theme === */ +svg > rect:first-child { + fill: #061018; +} + +svg text[fill="#666"], +svg text[fill="#999"] { + fill: #aaa !important; +} + +svg line[stroke="#ccc"] { + stroke: #445566 !important; +} + +svg { + background-color: #061018; + border-radius: 8px; + display: block; + margin: 0 auto; +} \ No newline at end of file diff --git a/Documentation/video/index.org b/Documentation/video/index.org new file mode 100644 index 0000000..5d8083c --- /dev/null +++ b/Documentation/video/index.org @@ -0,0 +1,237 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Video Subsystem - CRT Basic +#+LANGUAGE: en +#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry} +#+LATEX_HEADER: \usepackage{parskip} +#+LATEX_HEADER: \usepackage[none]{hyphenat} + +#+OPTIONS: H:20 num:20 +#+OPTIONS: author:nil + +#+HTML_HEAD: + +[[file:../index.org][Back to main documentation]] + +* The virtual VGA +:PROPERTIES: +:CUSTOM_ID: virtual-vga +:END: + +Everything a BASIC program draws lands on a virtual VGA card +(package =eu.svjatoslav.crtbasic.video=). The card is modeled after +real hardware closely enough that classic programs behave the way +they were written to behave: palette tricks work, text erases the +pixels under it, page flipping is flicker-free, and the full video +memory can be dumped byte-for-byte for external inspection. + +| Class | Role | +|-------+------| +| =VgaDevice= | Facade the interpreter calls: modes, text, drawing, palette, pages | +| =ScreenMode= | Table of modes 0–13: resolution, colors, pages, glyph height | +| =Framebuffer= | Indexed pixels (one byte = one palette index), pages, PNG/raw dumps | +| =TextConsole= | =PRINT= / =LOCATE= / =CLS= / =VIEW PRINT= on top of the framebuffer | +| =VgaFont= | CP437 bitmap fonts (8x8, 8x14, 8x16), 256 glyphs each | +| =DefaultPalettes= | The 16 EGA colors and the mode-13h BIOS-style palette | +| =CapturedRegion= | What =GET= stores and =PUT= blits | + +* Screen modes +:PROPERTIES: +:CUSTOM_ID: screen-modes +:END: + +=SCREEN n= switches the card between modes. Every graphics mode +doubles as a text mode — characters are blitted as 8-pixel-wide +glyph cells into the same framebuffer that pixel primitives draw +into. + +| SCREEN | Pixels | Colors | Pages | Glyph cell | Text grid | +|--------+---------+--------+-------+------------+-----------| +| 0 | 640x400 | 16 | 1 | 8x16 | 80x25 | +| 1 | 320x200 | 4 | 1 | 8x8 | 40x25 | +| 2 | 640x200 | 2 | 1 | 8x8 | 80x25 | +| 7 | 320x200 | 16 | 8 | 8x8 | 40x25 | +| 8 | 640x200 | 16 | 4 | 8x8 | 80x25 | +| 9 | 640x350 | 16 | 2 | 8x14 | 80x25 | +| 10 | 640x350 | 4 | 2 | 8x14 | 80x25 | +| 11 | 640x480 | 2 | 1 | 8x16 | 80x30 | +| 12 | 640x480 | 16 | 1 | 8x16 | 80x30 | +| 13 | 320x200 | 256 | 1 | 8x8 | 40x25 | + +Switching modes reallocates video memory, resets the palette to the +mode defaults and clears the screen. After =SCREEN=, the default text +foreground is 7 (light gray) in SCREEN 0 and 15 (bright white) in +graphics modes — a small detail real programs visibly depend on. + +Modes 3–6 do not exist in this dialect's target hardware and raise +an error. + +* Indexed pixels and palettes +:PROPERTIES: +:CUSTOM_ID: indexed-pixels +:END: + +#+INCLUDE: "indexed-pixels.svg" export html + +The framebuffer stores *palette indices*, not colors: one byte per +pixel, exactly like mode 13h video memory. Indices resolve to RGB +only when a frame is displayed or dumped to PNG. This single decision +makes the classic effects work for free: + +- =PALETTE= (via the DAC ports, see below) remaps an entry and every + pixel using it changes color on the next frame. +- *Color cycling* — rotating a range of palette entries for cheap + animation — needs no pixel writes at all. + +The default palette holds the 16 standard EGA colors in entries 0–15. +SCREEN 13 additionally gets a BIOS-style 256-color extension: a +grayscale ramp followed by hue wheels at decreasing saturation. +SCREEN 1 is the exception: being a CGA mode, it starts on CGA palette +1 high intensity (cyan / magenta / bright white). + +** The DAC ports + +Programs of the era often skip =PALETTE= and poke the VGA DAC +directly: + +#+BEGIN_SRC basic +OUT &H3C8, index ' select palette entry +OUT &H3C9, red ' 0-63 per component +OUT &H3C9, green +OUT &H3C9, blue +#+END_SRC + +=OUT= to port =&H3C8= selects the color index; three writes to +=&H3C9= supply red, green, blue (6 bits each, scaled to 0–255). The +index auto-increments after each complete color, exactly like +hardware. All other ports are out of scope and ignored. + +* Text is pixels +:PROPERTIES: +:CUSTOM_ID: text-is-pixels +:END: + +#+INCLUDE: "text-cell.svg" export html + +There is no separate text plane. =TextConsole= blits CP437 glyph +bitmaps straight into the pixel framebuffer — the same thing the BIOS +does on real hardware. Consequences that fall out naturally: + +- =PRINT= works identically in text and graphics modes. +- The *whole glyph cell* is painted: stroke pixels in the foreground + color, all other pixels in the background color, in every mode. + Printing spaces therefore erases text even over a drawn scene — + the standard technique for removing a score display before + reprinting it. +- Scrolling is a pixel-copy of the scroll region shifted up by one + cell height; the freed bottom line is filled with the background + color. + +Cursor positions are 1-based (=LOCATE row, col=, =CSRLIN=, =POS(0)=). +The console reproduces the classic *bottom-right corner* behavior: +printing into the last cell of the screen does not scroll by itself — +the cursor parks on the final column and wraps only when the next +character arrives. + +=VIEW PRINT top TO bottom= confines printing and scrolling to a band +of text rows and homes the cursor into the band. + +The fonts are CP437 bitmap fonts (256 glyphs each, one byte per glyph +scanline) in the three hardware heights 8, 14 and 16, extracted from +the public-domain VGA fonts shipped with SeaBIOS and embedded as jar +resources. + +* Video pages +:PROPERTIES: +:CUSTOM_ID: video-pages +:END: + +#+INCLUDE: "video-pages.svg" export html + +SCREEN 7, 8, 9 and 10 have multiple video pages. Two page selectors +are independent: + +- The *active page* is what drawing and printing target + (=SCREEN ,,apage= or the active-page argument). +- The *visual page* is what the monitor (and screenshots) show. + +The classic flicker-free animation loop draws the next frame on a +hidden page and flips the visual page when done. =PCOPY source, +target= copies a whole page instantly — the standard way to stamp a +pre-drawn background before each frame. + +* Drawing primitives +:PROPERTIES: +:CUSTOM_ID: drawing-primitives +:END: + +All drawing targets the active page and updates the *graphics cursor* +(the "last point referenced"), which relative coordinates build on. + +| Primitive | Statement form | +|-----------+----------------| +| =pset= / =point= | =PSET (x,y), c= / =POINT(x,y)= | +| =line= | =LINE (x1,y1)-(x2,y2), c= | +| =lineBox= / =lineBoxFilled= | =…, c, B= / =…, c, BF= | +| =circle= | =CIRCLE (x,y), r, c, , , aspect= | +| =paint= | =PAINT (x,y), fill, border= | +| =getRegion= / =putRegion= | =GET= / =PUT= with PSET, XOR, OR, AND | + +** LINE is pixel-exact + +=LINE= reproduces the original rasterizer bit-for-bit: + +- The segment is *clipped to the screen before rasterizing* + (Liang–Barsky). The clipped endpoint's off-axis coordinate rounds + to nearest, with exact .5 ties going *toward the surviving part of + the line*. This differs from per-pixel clipping for lines that + leave the screen. +- The walk is a DDA along the major axis with an initial error bias + of =floor(3M/4)= (or its complement when stepping left), which + makes a reversed endpoint order plot the identical pixel set. +- Both endpoints are drawn. =B= outlines, =BF= fills inclusive + corners — =LINE (x,y)-(x+1,y+1),c,BF= is the era's "magnify pixel" + idiom and covers exactly 2x2 pixels. + +** CIRCLE is pixel-exact + +=CIRCLE= draws axis-aligned ellipses. With no aspect given, the +mode's default aspect (=4/3= corrected for the pixel resolution) +makes circles look round on the period-correct display. An explicit +aspect below 1 makes =radius= the x radius; 1 or more makes it the y +radius. + +Rasterization reproduces the classic pixel output exactly: the +ellipse is computed in "circle space" and mapped through the aspect +as the union of two scan passes, mirrored to four quadrants. All +rounding is half-up and the aspect value is SINGLE precision — both +details are load-bearing. Arcs (start/end angles) are parsed but not +yet implemented and raise an explicit error. + +** PAINT, GET and PUT + +=PAINT= flood-fills the contiguous region of the start pixel's color, +stopping at the border color; painting a pixel that already is the +border color does nothing. + +=GET= captures a screen rectangle into a variable; =PUT= blits it +back with an action: =PSET= overwrites, =XOR= / =OR= / =AND= combine +palette indices bitwise. (The original packs the image into a numeric +array in a binary format; since arrays never leave this interpreter, +the capture rides in the variable slot as a dedicated object — +GET/PUT round-trips are pixel-identical.) + +* Reading the screen back +:PROPERTIES: +:CUSTOM_ID: reading-screen +:END: + +Because the whole point is agent-observable output: + +- =dumpPng(path)= writes the visual page as a PNG — the headless + equivalent of looking at the monitor (the =--screenshot= CLI + option). +- =pageData(page)= returns the raw indexed pixels of a page, + row-major — the exact equivalent of a =BSAVE= of mode-13h video + memory, for byte-level comparison (the =--raw= CLI option). +- =toImage(page)= converts a page to an AWT image for the Swing + frontend. diff --git a/Documentation/video/indexed-pixels.svg b/Documentation/video/indexed-pixels.svg new file mode 100644 index 0000000..a1f6616 --- /dev/null +++ b/Documentation/video/indexed-pixels.svg @@ -0,0 +1,79 @@ + + + + + + + + + + Framebuffer (indices) + + + 15 + 15 + 1 + 9 + 0 + + 15 + 9 + 9 + 1 + 0 + + 0 + 0 + 1 + 15 + 15 + + one byte per pixel = + palette index + + + + + + Palette (index → RGB) + + 0 + 1 + 9 + 15 + #000000 + #0000AA + #00AAAA + #FFFFFF + + PALETTE / OUT &H3C9 remap entries + + + + + + Screen (RGB) + + + + + + + + + + + + + + + + + + + + + indices resolve to colors + only at display time + diff --git a/Documentation/video/text-cell.svg b/Documentation/video/text-cell.svg new file mode 100644 index 0000000..2908525 --- /dev/null +++ b/Documentation/video/text-cell.svg @@ -0,0 +1,58 @@ + + + + + PRINT "A" paints the whole 8x8 cell + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + glyph stroke → foreground color + + everything else → background color + There is no transparent text: every + cell pixel is written, in every mode. + Printing spaces therefore erases + whatever was on screen before. + diff --git a/Documentation/video/video-pages.svg b/Documentation/video/video-pages.svg new file mode 100644 index 0000000..7b3af80 --- /dev/null +++ b/Documentation/video/video-pages.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + BASIC program + + + + Page 0 + + Page 1 (active) + + Page 2 + + + + Monitor + visual page (0) + + + + drawing + + displayed + + + + PCOPY 1, 0: instant page copy + + draw on a hidden page, flip visual page — flicker-free animation (SCREEN 7–10) + diff --git a/README.org b/README.org new file mode 100644 index 0000000..0ac292c --- /dev/null +++ b/README.org @@ -0,0 +1,132 @@ +* CRT Basic + +A BASIC interpreter written in Java, built so an AI agent can +write, run, observe, interact with, and test classic =.bas= programs +with no human in the loop. + +* Why not an existing implementation? + +Surveyed existing Java BASIC implementations (2026-08): + +| Project | Dialect | Showstopper | +|----------------------------------------------------+-------------------+------------------------------------------------------| +| PuffinBASIC | GW-BASIC | Line numbers required; no SUB/FUNCTION, DEFINT, SCREEN 13 | +| lwiest/BASICCompiler | GW-BASIC | Same dialect mismatch; compiles to JVM bytecode | +| jvmBASIC | classic BASIC | Line-numbered dialect, no graphics/IO | +| Student interpreters (various repos) | BASIC-ish subsets | Text-only, abandoned, no graphics | + +None of them run the classic DOS-era programs this interpreter targets +unchanged. Hence this project. +* Architecture + +#+begin_example +.bas source + │ + ▼ +lexer → tokens (CP437/CRLF tolerant; DONE — lexer package) +parser → AST (DONE for a practical subset — parser/ast packages) +compiler → stack-based bytecode (planned; enables deterministic stepping) +vm → executes bytecode (planned) +interp → INTERIM: tree-walks the AST so whole programs run today +video → virtual VGA: text mode 80×25 and SCREEN 1–13 pixel modes (DONE) +input → keyboard queue (DONE — window frontend and headless drivers feed it) +frontend → Swing window: live screen view + keyboard capture (DONE) +audio → SOUND/PLAY stubbed (parsed, no-op), real output later +#+end_example + +Design decisions: + +- *Bytecode VM, not tree-walking.* SCREEN 13 programs sit in tight + PSET loops; a compact stack bytecode keeps interpretation overhead low + and gives a natural "execute N instructions" granularity for + deterministic, agent-driven stepping. +- *Introspection.* The VM exposes current instruction pointer, variable + values and screen state so an agent can verify program behavior without + guessing. +* Install and usage + +#+begin_src sh +cd "/path/to/crtbasic" +./install.sh # installs to ~/.local (CRT_BASIC_PREFIX to override) +crtbasic program.bas # interactive window +crtbasic app.bas --headless --screenshot=out.png --raw=out.raw --steps=100000 +#+end_src + +- Default is interactive: a Swing window shows the live screen (~30 fps) + and key presses feed the program's input queue. +- =--headless= (implied by =--screenshot= / =--raw=) runs with no window + and dumps the screen afterwards — the bot-driving path. =--raw= gives + the raw indexed pixels of every video page, suitable for byte-level + comparison between runs. +- =--steps=N= caps executed statements so looping games can be sampled + deterministically. +- =--command=text= sets what COMMAND$ returns (e.g. People.bas's + slideshow auto-advances slides on a timer when run with + =--command=t=). + +* Build + +#+begin_src sh +cd "/path/to/crtbasic" +mvn package +java -jar target/crt-basic-*-SNAPSHOT.jar program.bas +#+end_src + +* Status + +Implemented so far: + +- *video* package (=eu.svjatoslav.crtbasic.video=) — the virtual VGA: + - =ScreenMode= — table of screen modes 0, 1, 2, 7-13: pixel resolution, + glyph cell height (8/14/16), colors, video pages. + - =Framebuffer= — indexed pixels (one byte per pixel = palette index) so + PALETTE tricks and color cycling work; multiple video pages; PNG dumps. + - =VgaFont= — CP437 8x8/8x14/8x16 bitmap fonts (256 glyphs each, + public-domain VGA fonts from SeaBIOS) as embedded resources. + - =TextConsole= — PRINT/LOCATE/COLOR/CLS/VIEW PRINT semantics: glyph + blitting into the pixel framebuffer (opaque cells in every mode), + cursor wrap with the classic bottom-right-corner behavior, + band-confined scrolling. + - =VgaDevice= — facade the executor calls: mode switching, text, + PSET/POINT, LINE (plain/B/BF), CIRCLE (mode default aspect), palette, + active/visual page selection, graphics cursor, screenshots. +- *lexer* / *parser* / *ast* — tokenizer (CP437/CRLF tolerant, `_` line + continuations) and recursive-descent parser for a practical subset: + SCREEN, LOCATE, PRINT (incl. ; and , zones), CLS, COLOR, PSET, LINE + (B/BF), CIRCLE, SOUND, OUT, PAINT, GET/PUT (incl. array-element + offsets like =playerWalkingFrames(202, 1)=), DIM (SHARED, `TO` bounds, + AS type, fixed-length STRING * n), MID$ statement, OPEN FOR INPUT / + INPUT # / LINE INPUT # / CLOSE (DOS paths resolve case-insensitively + against the .bas directory), assignment, FOR/NEXT (STEP), single-line + and block IF/THEN/ELSE, WHILE/WEND, DO...LOOP, GOTO labels, + SUB/FUNCTION/CALL, DECLARE, DEFINT & siblings, SELECT CASE (equality + and inclusive =a TO b= ranges, CASE ELSE), SLEEP, END, and expressions + with the usual functions (POINT, POS, CSRLIN, ABS, INT, SQR, + SIN/COS/TAN/ATN/EXP/LOG, RND, CHR$, STR$, LEN, ASC, VAL, + LEFT$/RIGHT$/MID$, UCASE$/LCASE$, INKEY$, TIME$, TIMER, COMMAND$). + Unsupported statements fail loudly with the line number — that tells + us what to implement next. +- *interp* — INTERIM tree-walking executor (to be replaced by the + bytecode VM). Classic dialect semantics: doubles by default, + suffix-typed variables, true = -1, classic number formatting in PRINT. +- *input* — KeyboardQueue shared by all frontends. +- *frontend* — Swing window: live scaled screen view, keys into the queue. +- =Main= CLI — interactive by default, headless dumps for bots. +- =install.sh= — installs jar + =crtbasic= launcher to =~/.local=. +- JUnit tests cover video semantics, drawing primitives and the + interpreter end to end. +- =VideoDemoMain= (test sources) renders showcase frames to + =/tmp/crt-basic-demo/= for visual verification: + #+begin_src sh + java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain + #+end_src + +Notable semantics worth knowing: + +- Graphics-mode text paints the *whole glyph cell* (opaque background) — + that is why PRINTing spaces erases text. +- Default foreground after SCREEN is 15 in graphics modes, 7 in SCREEN 0. + +Not yet: bytecode compiler/VM (interim tree-walker runs programs now), +GOSUB/RETURN, DRAW, CASE IS, CIRCLE arcs, console INPUT statement, real +audio (SOUND is timing-modeled but silent). diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA new file mode 100755 index 0000000..304bf94 --- /dev/null +++ b/Tools/Open with IntelliJ IDEA @@ -0,0 +1,54 @@ +#!/bin/bash + +# This script launches IntelliJ IDEA with the current project +# directory. The script is designed to be run by double-clicking it in +# the GNOME Nautilus file manager. + +# First, we change the current working directory to the directory of +# the script. + +# "${0%/*}" gives us the path of the script itself, without the +# script's filename. + +# This command basically tells the system "change the current +# directory to the directory containing this script". + +cd "${0%/*}" + +# Then, we move up one directory level. +# The ".." tells the system to go to the parent directory of the current directory. +# This is done because we assume that the project directory is one level up from the script. +cd .. + +# Now, we use the 'setsid' command to start a new session and run +# IntelliJ IDEA in the background. 'setsid' is a UNIX command that +# runs a program in a new session. + +# The command 'idea .' opens IntelliJ IDEA with the current directory +# as the project directory. The '&' at the end is a UNIX command that +# runs the process in the background. The '> /dev/null' part tells +# the system to redirect all output (both stdout and stderr, denoted +# by '&') that would normally go to the terminal to go to /dev/null +# instead, which is a special file that discards all data written to +# it. + +setsid idea . &>/dev/null & + +# The 'disown' command is a shell built-in that removes a shell job +# from the shell's active list. Therefore, the shell will not send a +# SIGHUP to this particular job when the shell session is terminated. + +# '-h' option specifies that if the shell receives a SIGHUP, it also +# doesn't send a SIGHUP to the job. + +# '$!' is a shell special parameter that expands to the process ID of +# the most recent background job. +disown -h $! + + +sleep 2 + +# Finally, we use the 'exit' command to terminate the shell script. +# This command tells the system to close the terminal window after +# IntelliJ IDEA has been opened. +exit diff --git a/Tools/Update web site b/Tools/Update web site new file mode 100755 index 0000000..d5b8f82 --- /dev/null +++ b/Tools/Update web site @@ -0,0 +1,80 @@ +#!/bin/bash +cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi; + +cd .. + +# Function to export org to html using emacs in batch mode +export_org_to_html() { + local org_file=$1 + local dir=$(dirname "$org_file") + local base=$(basename "$org_file" .org) + ( + cd "$dir" || return 1 + local html_file="${base}.html" + if [ -f "$html_file" ]; then + rm -f "$html_file" + fi + echo "Exporting: $org_file → $dir/$html_file" + emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill + if [ $? -eq 0 ]; then + echo "✓ Successfully exported $org_file" + else + echo "✗ Failed to export $org_file" + return 1 + fi + ) +} + +export_org_files_to_html() { + echo "🔍 Searching for .org files in Documentation/ ..." + echo "=======================================" + + mapfile -t ORG_FILES < <(find Documentation -type f -name "*.org" | sort) + + if [ ${#ORG_FILES[@]} -eq 0 ]; then + echo "❌ No .org files found!" + return 1 + fi + + echo "Found ${#ORG_FILES[@]} .org file(s):" + printf '%s\n' "${ORG_FILES[@]}" + echo "=======================================" + + SUCCESS_COUNT=0 + FAILED_COUNT=0 + + for org_file in "${ORG_FILES[@]}"; do + export_org_to_html "$org_file" + if [ $? -eq 0 ]; then + ((SUCCESS_COUNT++)) + else + ((FAILED_COUNT++)) + fi + done + + echo "=======================================" + echo "📊 SUMMARY:" + echo " ✓ Successful: $SUCCESS_COUNT" + echo " ✗ Failed: $FAILED_COUNT" + echo " Total: $((SUCCESS_COUNT + FAILED_COUNT))" + echo "" +} + +# Publish Emacs org-mode files into HTML format +export_org_files_to_html + +# Upload assembled documentation to server +SERVER_DIR="n0@www3.svjatoslav.eu:/mnt/big/projects/crtbasic/" + +echo "📤 Uploading to $SERVER_DIR ..." +rsync -avz --delete -e 'ssh -p 10006' Documentation/ "$SERVER_DIR" + +if [ $? -eq 0 ]; then + echo "✓ Upload completed successfully!" +else + echo "✗ Upload failed!" +fi + +echo "" +echo "Press ENTER to close this window." +read diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6a61c95 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# CRT Basic installer: builds the jar and installs a `crtbasic` command. +# +# Default install location is per-user (~/.local); override with: +# CRT_BASIC_PREFIX=/usr/local ./install.sh (needs write permission) +# +# After install, ensure $CRT_BASIC_PREFIX/bin is on your PATH. + +set -e + +PREFIX="${CRT_BASIC_PREFIX:-$HOME/.local}" +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Building CRT Basic..." +cd "$PROJECT_DIR" +mvn -q package + +JAR="$(ls target/crt-basic-*-SNAPSHOT.jar | head -n 1)" +if [ ! -f "$JAR" ]; then + echo "error: jar not found after build" >&2 + exit 1 +fi + +mkdir -p "$PREFIX/share/crtbasic" "$PREFIX/bin" +cp "$JAR" "$PREFIX/share/crtbasic/crt-basic.jar" + +cat > "$PREFIX/bin/crtbasic" < "$APPS_DIR/crtbasic.desktop" </dev/null || true + +echo "Installed:" +echo " jar: $PREFIX/share/crtbasic/crt-basic.jar" +echo " launcher: $PREFIX/bin/crtbasic" +echo " desktop: $APPS_DIR/crtbasic.desktop (Open With for .bas)" + +case ":$PATH:" in + *":$PREFIX/bin:"*) ;; + *) echo "note: $PREFIX/bin is not on your PATH — add it to use 'crtbasic' directly." ;; +esac + +echo "Try: crtbasic path/to/program.bas" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d3f9900 --- /dev/null +++ b/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + eu.svjatoslav + crt-basic + 1-SNAPSHOT + jar + + CRT Basic + + 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. + + + + 21 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + eu.svjatoslav.crtbasic.Main + + + + + + + diff --git a/src/main/java/eu/svjatoslav/crtbasic/Cp437.java b/src/main/java/eu/svjatoslav/crtbasic/Cp437.java new file mode 100644 index 0000000..454922b --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/Cp437.java @@ -0,0 +1,48 @@ +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 CHAR_TO_BYTE = new HashMap<>(); + + static { + for (int b = 128; b < 256; b++) { + final String s = new String(new byte[]{(byte) b}, CHARSET); + if (s.length() == 1) { + CHAR_TO_BYTE.putIfAbsent(s.charAt(0), b); + } + } + } + + private Cp437() { + // Utility class. + } + + /** CP437 byte value of a character (identity for ASCII; '?' when unmappable). */ + public static int byteOf(final char c) { + if (c < 128) { + return c; + } + final Integer b = CHAR_TO_BYTE.get(c); + return b != null ? b : (c <= 255 ? c : '?'); + } + + /** The CP437 character for a byte value (0-255). */ + public static String charOf(final int byteValue) { + final int b = byteValue & 0xFF; + return b < 128 ? String.valueOf((char) b) + : new String(new byte[]{(byte) b}, CHARSET); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/Main.java b/src/main/java/eu/svjatoslav/crtbasic/Main.java new file mode 100644 index 0000000..939c268 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/Main.java @@ -0,0 +1,190 @@ +package eu.svjatoslav.crtbasic; + +import eu.svjatoslav.crtbasic.ast.Ast; +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.frontend.SwingFrontend; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.interp.Interpreter; +import eu.svjatoslav.crtbasic.parser.Parser; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; + +/** + * Command line entry point of CRT Basic. + * + *

Pipeline: .bas source -> lexer -> parser (AST) -> tree-walk + * interpreter (interim; bytecode VM later) against a virtual VGA device.

+ * + *

Two modes:

+ *
    + *
  • {@code crtbasic program.bas} — interactive Swing window, keys go to + * the program via {@code INKEY$}/{@code INPUT}.
  • + *
  • {@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.
  • + *
+ */ +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 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 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 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 [--headless] [--screenshot=out.png] [--raw=out.raw] [--steps=N] [--command=text] + (no options) interactive window + --headless run offscreen; dump framebuffer afterwards + --steps=N stop after N statements (default: unlimited) + --command=text what COMMAND$ returns (default: empty)"""); + System.exit(1); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java new file mode 100644 index 0000000..80c748b --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java @@ -0,0 +1,334 @@ +package eu.svjatoslav.crtbasic.ast; + +import java.util.List; + +/** + * AST node types for the supported BASIC subset. Statements and expressions + * are records; every node carries the 1-based source line for error + * reporting. + */ +public final class Ast { + + private Ast() { + // Namespace class. + } + + public sealed interface Stmt { + int line(); + } + + /** {@code SCREEN mode[, [colorburst][, [activePage][, visualPage]]]} — null slot = omitted. */ + public record ScreenStmt(Expr mode, Expr activePage, Expr visualPage, int line) implements Stmt { + } + + /** {@code PCOPY sourcePage, targetPage} */ + public record PcopyStmt(Expr sourcePage, Expr targetPage, int line) implements Stmt { + } + + /** {@code LOCATE row, col} */ + public record LocateStmt(Expr row, Expr col, int line) implements Stmt { + } + + /** + * {@code PRINT item;item,item} — items are expressions plus the + * separator that follows them (';' compact, ',' print zone). + * {@code newline} is false when the statement ends with ; or ,. + */ + public record PrintStmt(List 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 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 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 values, int line) implements Stmt { + } + + /** One READ target: a scalar variable or an array element. */ + public record ReadTarget(String name, List indices) { + } + + /** {@code READ var, array(i), ...} — consumes DATA values in order. */ + public record ReadStmt(List 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 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 body, int line) implements Stmt { + } + + /** Single-line {@code IF cond THEN stmts [ELSE stmts]} */ + public record IfStmt(Expr condition, List thenBody, + List elseBody, int line) implements Stmt { + } + + /** {@code WHILE cond ... WEND} */ + public record WhileStmt(Expr condition, List 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 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 params, List 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 params, List 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 cases, + List elseBody, int line) implements Stmt { + } + + /** One {@code CASE v1, v2 TO v3, ...} branch of a SELECT statement. */ + public record CaseBranch(List tests, List 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 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 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 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 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 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 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 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 args, int line) implements Expr { + } + + /** Binary operation: + - * / \ ^ MOD AND OR = <> < <= > >= */ + public record Binary(String op, Expr left, Expr right, int line) implements Expr { + } + + /** Unary operation: -expr, NOT expr */ + public record Unary(String op, Expr operand, int line) implements Expr { + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java b/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java new file mode 100644 index 0000000..89e5a19 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/audio/SoundQueue.java @@ -0,0 +1,50 @@ +package eu.svjatoslav.crtbasic.audio; + +/** + * SOUND timing model. + * + *

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.

+ * + *

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.

+ * + *

No actual audio is produced: this class models timing only.

+ */ +public final class SoundQueue { + + /** SOUND durations are measured in these ticks per second. */ + private static final double TICKS_PER_SECOND = 18.2; + private static final double NANOS_PER_TICK = 1_000_000_000.0 / TICKS_PER_SECOND; + + /** When the currently playing note finishes (System.nanoTime clock). */ + private long noteFinishNanos; + + /** + * {@code SOUND frequency, duration}: starts a note. Blocks while the + * previous note is still playing; returns as soon as the new note + * starts (it then keeps "playing" in the background). + */ + public synchronized void sound(final int frequency, final double durationTicks) { + long now = System.nanoTime(); + // Compare, don't subtract: with the initial 0 sentinel a naive + // (finish - now) overflows to a huge positive value. + if (noteFinishNanos > now) { + final long waitNanos = noteFinishNanos - now; + try { + wait(waitNanos / 1_000_000, (int) (waitNanos % 1_000_000)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + now = System.nanoTime(); + } + noteFinishNanos = now + Math.round(durationTicks * NANOS_PER_TICK); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java new file mode 100644 index 0000000..9dea4f8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java @@ -0,0 +1,77 @@ +package eu.svjatoslav.crtbasic.frontend; + +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.Timer; +import javax.swing.WindowConstants; + +/** + * Interactive frontend: a Swing window showing the live visual page of a + * {@link VgaDevice}, scaled with nearest-neighbor (crisp pixels), repainted + * on a timer. Key presses are pushed into the shared {@link KeyboardQueue} + * so a running program sees them through {@code INKEY$}/{@code INPUT}. + * + *

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.

+ */ +public final class SwingFrontend { + + /** Repaint interval in milliseconds (~30 fps). */ + private static final int REPAINT_MS = 33; + + private final VgaDevice vga; + private final JFrame frame; + + @SuppressWarnings("serial") + public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, final String title) { + vga = vgaDevice; + final int scale = vga.mode().pixelWidth() <= 320 ? 3 : 2; + + final JPanel canvas = new JPanel() { + @Override + protected void paintComponent(final Graphics g) { + final Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + g2.drawImage(vga.framebuffer().toImage(vga.visualPage()), + 0, 0, getWidth(), getHeight(), null); + } + }; + canvas.setPreferredSize(new java.awt.Dimension( + vga.mode().pixelWidth() * scale, vga.mode().pixelHeight() * scale)); + canvas.setFocusable(true); + canvas.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(final KeyEvent e) { + keys.push(e.getKeyCode(), + e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar()); + } + }); + + frame = new JFrame(title); + frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + frame.setContentPane(canvas); + frame.pack(); + frame.setLocationRelativeTo(null); + + new Timer(REPAINT_MS, e -> canvas.repaint()).start(); + } + + /** Shows the window. Must be called on or before the program starts. */ + public void show() { + frame.setVisible(true); + } + + /** Re-packs the window after a video mode change (new resolution). */ + public void modeChanged() { + frame.pack(); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java b/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java new file mode 100644 index 0000000..95f642e --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/input/KeyboardQueue.java @@ -0,0 +1,58 @@ +package eu.svjatoslav.crtbasic.input; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Keyboard input queue: the single input path for all frontends. + * + *

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.

+ */ +public final class KeyboardQueue { + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + /** One key press: a VK code (as in KeyEvent) and the typed char, if any. */ + public record Key(int vkCode, char character) { + public boolean hasCharacter() { + return character != CHAR_NONE; + } + } + + /** Sentinel for "no character associated" (arrow keys etc.). */ + public static final char CHAR_NONE = '\0'; + + /** Pushes a key press. Called by frontends/drivers. */ + public void push(final int vkCode, final char character) { + queue.add(new Key(vkCode, character)); + } + + /** Convenience for headless drivers: pushes a typed character. */ + public void pushChar(final char character) { + queue.add(new Key(0, character)); + } + + /** @return the next key, or null if the queue is empty (INKEY$ style) */ + public Key poll() { + return queue.poll(); + } + + /** @return the next key, waiting up to the given timeout (INPUT style) */ + public Key poll(final long timeoutMillis) throws InterruptedException { + return queue.poll(timeoutMillis, TimeUnit.MILLISECONDS); + } + + public boolean isEmpty() { + return queue.isEmpty(); + } + + public int size() { + return queue.size(); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java new file mode 100644 index 0000000..a5ef4d8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -0,0 +1,1779 @@ +package eu.svjatoslav.crtbasic.interp; + +import eu.svjatoslav.crtbasic.Cp437; +import eu.svjatoslav.crtbasic.ast.Ast; +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.video.CapturedRegion; +import eu.svjatoslav.crtbasic.video.ScreenMode; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.awt.event.KeyEvent; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Executes the AST directly (tree-walking). + * + *

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.

+ * + *

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

+ */ +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. + * + *

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.

+ */ + 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 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> scopes = new ArrayDeque<>(); + private final Map subs = new HashMap<>(); + /** Functions, keyed by base name (uppercase, no type suffix). */ + private final Map 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> 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 declaredNames = new HashMap<>(); + /** Canonical names declared DIM SHARED: visible from inside SUBs. */ + private final java.util.Set 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 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 fileChannels = new HashMap<>(); + private final Map fileLookahead = new HashMap<>(); + /** Pending data items for INPUT #channel: comma-split but unconsumed. */ + private final Map> 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 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 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 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 stmts) { + Map 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 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 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 bindScope(final String procName, final List headerParams, + final List args, final int line) { + if (args.size() != headerParams.size()) { + throw error("'" + procName + "' expects " + headerParams.size() + + " argument(s), got " + args.size(), line); + } + final List declared = declaredParams.getOrDefault(baseName(procName), List.of()); + final Map 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 args, + final int line) { + final Map 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 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 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 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 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 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. + * + *

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.

+ */ + private static String formatDouble16(final double value) { + if (value == 0) { + return "0"; + } + final String sign = value < 0 ? "-" : ""; + final java.math.BigDecimal bd = new java.math.BigDecimal(Math.abs(value)); + final java.math.BigDecimal rounded = bd.round( + new java.math.MathContext(16, java.math.RoundingMode.HALF_EVEN)); + final int exponent = rounded.precision() - rounded.scale() - 1; + final String digits = rounded.unscaledValue().toString(); + if (exponent > 15 || exponent < -3) { + // Scientific: d.dddD±ee + final String fraction = digits.substring(1).replaceFirst("0+$", ""); + final String mantissa = digits.charAt(0) + + (fraction.isEmpty() ? "" : "." + fraction); + return sign + mantissa + "D" + (exponent >= 0 ? "+" : "-") + + String.format("%02d", Math.abs(exponent)); + } + if (exponent >= 0) { + final String intPart = exponent + 1 >= digits.length() + ? digits + "0".repeat(exponent + 1 - digits.length()) + : digits.substring(0, exponent + 1); + final String fraction = exponent + 1 >= digits.length() + ? "" : digits.substring(exponent + 1).replaceFirst("0+$", ""); + return sign + intPart + (fraction.isEmpty() ? "" : "." + fraction); + } + return sign + "." + "0".repeat(-exponent - 1) + + digits.replaceFirst("0+$", ""); + } + + private static String formatNumber(final double value) { + // Numbers are formatted at the expression's type; the default + // SINGLE type means the value is narrowed to float32 for display. + final double narrowed = single(value); + if (narrowed == Math.floor(narrowed) && !Double.isInfinite(narrowed) && Math.abs(narrowed) < 1e15) { + return Long.toString((long) narrowed); + } + return Double.toString(narrowed); + } + + private double throwDivisionByZero(final int line) { + throw error("Division by zero", line); + } + + private InterpreterException error(final String message, final int line) { + final StringBuilder sb = new StringBuilder(message).append(" at line ").append(line); + if (sourceLines != null && line >= 1 && line <= sourceLines.length) { + final String text = sourceLines[line - 1].trim(); + if (!text.isEmpty()) { + sb.append(": ").append(text); + } + } + return new InterpreterException(sb.toString()); + } + + /** Runtime failure with source line context. */ + public static final class InterpreterException extends RuntimeException { + public InterpreterException(final String message) { + super(message); + } + } + + /** Thrown when the statement budget (--steps) is exhausted. */ + public static final class StepsLimitException extends RuntimeException { + private final long count; + + StepsLimitException(final long count) { + super("statement limit reached: " + count); + this.count = count; + } + + public long count() { + return count; + } + } + + /** + * Signals a RESUME jump out of an error handler: retry the trapped + * statement (label null, next false), continue after it (next true), + * or jump to a label. + */ + private static final class ResumeJump extends RuntimeException { + private final String label; + private final boolean next; + + ResumeJump(final String targetLabel, final boolean resumeNext) { + super(null, null, false, false); + label = targetLabel; + next = resumeNext; + } + } + + /** Control-flow signal for END. */ + private static final class ProgramEnd extends RuntimeException { + ProgramEnd() { + super(null, null, false, false); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java b/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java new file mode 100644 index 0000000..28afced --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/lexer/Lexer.java @@ -0,0 +1,195 @@ +package eu.svjatoslav.crtbasic.lexer; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tokenizes .bas source. Tolerates the realities of DOS files: CRLF + * line endings, CP437 high characters (passed through in strings and + * identifiers), {@code '} and {@code REM} comments, {@code :} statement + * separators. + * + *

Keywords are emitted as plain identifiers; the parser matches them by + * text. Both newlines and {@code :} produce {@link Token.Type#SEP} + * (statement separator) tokens.

+ */ +public final class Lexer { + + private Lexer() { + // Utility class. + } + + public static List tokenize(final String source) { + final List tokens = new ArrayList<>(); + int pos = 0; + int line = 1; + final int length = source.length(); + + while (pos < length) { + final char c = source.charAt(pos); + + // Whitespace (not newline). + if (c == ' ' || c == '\t' || c == '\r') { + pos++; + continue; + } + + // Newline: statement separator. + if (c == '\n') { + tokens.add(new Token(Token.Type.SEP, "\n", line)); + line++; + pos++; + continue; + } + + // Colon: statement separator. + if (c == ':') { + tokens.add(new Token(Token.Type.SEP, ":", line)); + pos++; + continue; + } + + // Apostrophe comment: skip to end of line. + if (c == '\'') { + while (pos < length && source.charAt(pos) != '\n') { + pos++; + } + continue; + } + + // String literal. + if (c == '"') { + final int start = ++pos; + while (pos < length && source.charAt(pos) != '"' && source.charAt(pos) != '\n') { + pos++; + } + tokens.add(new Token(Token.Type.STRING, source.substring(start, pos), line)); + if (pos < length && source.charAt(pos) == '"') { + pos++; + } + continue; + } + + // Number: decimal (with optional fraction/exponent), hex (&H), + // octal (&O). + if (Character.isDigit(c) || (c == '.' && pos + 1 < length + && Character.isDigit(source.charAt(pos + 1)))) { + final int start = pos; + while (pos < length + && (Character.isDigit(source.charAt(pos)) || source.charAt(pos) == '.')) { + pos++; + } + // Optional exponent: 1.5e3, 2D+10 (D = double exponent). + if (pos < length && "eEdD".indexOf(source.charAt(pos)) >= 0) { + final int save = pos; + pos++; + if (pos < length && (source.charAt(pos) == '+' || source.charAt(pos) == '-')) { + pos++; + } + if (pos < length && Character.isDigit(source.charAt(pos))) { + while (pos < length && Character.isDigit(source.charAt(pos))) { + pos++; + } + } else { + pos = save; // was not an exponent after all + } + } + // Optional type suffix: 1024&, 1.5#, 2! (hex &H… is + // lexed by its own branch below and never reaches here). + if (pos < length && "%&!#".indexOf(source.charAt(pos)) >= 0) { + pos++; + } + tokens.add(new Token(Token.Type.NUMBER, source.substring(start, pos), line)); + continue; + } + if (c == '&' && pos + 1 < length + && (source.charAt(pos + 1) == 'H' || source.charAt(pos + 1) == 'h' + || source.charAt(pos + 1) == 'O' || source.charAt(pos + 1) == 'o')) { + final int start = pos; + pos += 2; + while (pos < length + && (Character.isDigit(source.charAt(pos)) + || (source.charAt(pos) >= 'a' && source.charAt(pos) <= 'f') + || (source.charAt(pos) >= 'A' && source.charAt(pos) <= 'F'))) { + pos++; + } + tokens.add(new Token(Token.Type.NUMBER, source.substring(start, pos), line)); + continue; + } + + // Identifier / keyword, with optional type suffix. + if (Character.isLetter(c)) { + final int start = pos; + pos++; + while (pos < length && isIdentChar(source.charAt(pos))) { + pos++; + } + final String word = source.substring(start, pos); + // REM comment (word-boundary matched): skip to end of line. + if (word.equalsIgnoreCase("REM") + && (pos >= length || !isIdentChar(source.charAt(pos)))) { + while (pos < length && source.charAt(pos) != '\n') { + pos++; + } + continue; + } + if (pos < length && "%&!#$".indexOf(source.charAt(pos)) >= 0) { + pos++; + } + tokens.add(new Token(Token.Type.IDENT, source.substring(start, pos), line)); + continue; + } + + // Line continuation: a '_' as the last non-blank character of + // a line joins the physical lines into one logical statement. + if (c == '_') { + int probe = pos + 1; + while (probe < length && (source.charAt(probe) == ' ' + || source.charAt(probe) == '\t' || source.charAt(probe) == '\r')) { + probe++; + } + if (probe < length && source.charAt(probe) == '\n') { + pos = probe + 1; + line++; + continue; + } + } + + // Two-character operators. + if (pos + 1 < length) { + final String two = source.substring(pos, pos + 2); + if (two.equals("<=") || two.equals(">=") || two.equals("<>")) { + tokens.add(new Token(Token.Type.OP, two, line)); + pos += 2; + continue; + } + } + + // Single-character operators. '#' appears standalone as the + // file-channel prefix (OPEN ... AS #1); directly after an + // identifier it was already consumed as a type suffix. + if ("+-*/\\^=<>(),#".indexOf(c) >= 0 || c == ';') { + tokens.add(new Token(Token.Type.OP, String.valueOf(c), line)); + pos++; + continue; + } + + throw new LexException("Unexpected character '" + c + "' (0x" + + Integer.toHexString(c) + ") at line " + line); + } + + tokens.add(new Token(Token.Type.EOF, "", line)); + return tokens; + } + + private static boolean isIdentChar(final char c) { + return Character.isLetterOrDigit(c) || c == '.' || c == '_'; + } + + /** Thrown on un-tokenizable input. */ + public static final class LexException extends RuntimeException { + public LexException(final String message) { + super(message); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java b/src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java new file mode 100644 index 0000000..6eef311 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/lexer/Token.java @@ -0,0 +1,30 @@ +package eu.svjatoslav.crtbasic.lexer; + +import java.util.ArrayList; +import java.util.List; + +/** + * One token from BASIC source. + * + * @param type broad category — keywords are not distinguished from + * identifiers here (the parser matches statement keywords by + * text, case-insensitively) + * @param text the token text (identifiers/keywords as written, strings + * without quotes, numbers as written) + * @param line 1-based source line for error reporting + */ +public record Token(Type type, String text, int line) { + + public enum Type { + IDENT, NUMBER, STRING, OP, SEP, EOF + } + + public boolean is(final Type expected) { + return type == expected; + } + + /** Case-insensitive text match, for keywords and operators. */ + public boolean isText(final String expected) { + return text.equalsIgnoreCase(expected); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java new file mode 100644 index 0000000..0714a18 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java @@ -0,0 +1,1254 @@ +package eu.svjatoslav.crtbasic.parser; + +import eu.svjatoslav.crtbasic.ast.Ast; +import eu.svjatoslav.crtbasic.lexer.Lexer; +import eu.svjatoslav.crtbasic.lexer.Token; +import java.util.ArrayList; +import java.util.List; + +/** + * Recursive-descent parser for the supported BASIC subset. + * + *

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.

+ */ +public final class Parser { + + private final List tokens; + private final String[] sourceLines; + private int pos; + + private Parser(final List tokens, final String[] sourceLines) { + this.tokens = tokens; + this.sourceLines = sourceLines; + } + + public static List parse(final String source) { + return new Parser(Lexer.tokenize(source), source.split("\n", -1)).program(); + } + + // -------------------------------------------------------------- program + + private List program() { + final List 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 blockUntil(final String... keywords) { + final List 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 params = new ArrayList<>(); + if (peek().isText("(")) { + next(); + if (!peek().isText(")")) { + params.add(paramName()); + while (peek().isText(",")) { + next(); + params.add(paramName()); + } + } + expectOp(")"); + } + final List 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 params = new ArrayList<>(); + if (peek().isText("(")) { + next(); + if (!peek().isText(")")) { + params.add(paramName()); + while (peek().isText(",")) { + next(); + params.add(paramName()); + } + } + expectOp(")"); + } + final List 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 procedureBody(final String endKeyword) { + final List 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 cases = new ArrayList<>(); + List 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 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 caseBody() { + final List 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 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 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 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 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 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 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 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 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 entries = new ArrayList<>(); + while (true) { + final Token name = expect(Token.Type.IDENT); + final List 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 arrayOffsetIndices() { + final List 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 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 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 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 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 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 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 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 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 stmts [ELSE stmts] END IF + final List thenBody = ifBranchBody(); + List 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 thenBody = new ArrayList<>(); + while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF) && !isAny("ELSE")) { + thenBody.add(statement()); + skipInlineSeparators(); + } + List elseBody = List.of(); + if (isAny("ELSE")) { + next(); + final List 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 ifBranchBody() { + final List 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 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 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 args = new ArrayList<>(); + if (!peek().isText(")")) { + args.add(expression()); + while (peek().isText(",")) { + next(); + args.add(expression()); + } + } + expectOp(")"); + return new Ast.Call(token.text(), args, token.line()); + } + return new Ast.Var(token.text(), token.line()); + } + if (token.isText("(")) { + next(); + final Ast.Expr inner = expression(); + expectOp(")"); + return inner; + } + throw error("Expected expression", token); + } + + /** + * Builds a Num literal from a NUMBER token. A trailing '#' suffix (or a + * D exponent) makes it a DOUBLE constant: it keeps full double + * precision instead of narrowing to SINGLE like other literals. + */ + private static Ast.Num parseNum(final Token token) { + String text = token.text(); + final char last = text.charAt(text.length() - 1); + final boolean doublePrecision = last == '#' || text.indexOf('D') >= 0 || text.indexOf('d') >= 0; + if ("%&!#".indexOf(last) >= 0) { + text = text.substring(0, text.length() - 1); + } + return new Ast.Num(parseNumber(text, token.line()), doublePrecision, token.line()); + } + + private static double parseNumber(final String text, final int line) { + try { + if (text.startsWith("&H") || text.startsWith("&h")) { + return Integer.parseInt(text.substring(2), 16); + } + if (text.startsWith("&O") || text.startsWith("&o")) { + return Integer.parseInt(text.substring(2), 8); + } + return Double.parseDouble(text.replace('D', 'E').replace('d', 'e')); + } catch (final NumberFormatException e) { + throw new ParseException("Bad number '" + text + "' at line " + line); + } + } + + // ---------------------------------------------------------------- tokens + + /** + * True when the just-consumed token began a logical line: it is the + * first token of the program or follows a newline separator (not a + * ':' inline separator). + */ + private boolean isAtLineStart() { + if (pos < 2) { + return true; + } + final Token before = tokens.get(pos - 2); + return before.is(Token.Type.SEP) && before.isText("\n"); + } + + private Token peek() { + return tokens.get(pos); + } + + private Token next() { + return tokens.get(pos++); + } + + private boolean isAny(final String... keywords) { + final Token token = peek(); + if (!token.is(Token.Type.IDENT)) { + return false; + } + for (final String keyword : keywords) { + if (token.isText(keyword)) { + return true; + } + } + return false; + } + + private void skipSeparators() { + while (peek().is(Token.Type.SEP)) { + pos++; + } + } + + private void skipInlineSeparators() { + while (peek().isText(":")) { + pos++; + } + } + + private Token expect(final Token.Type type) { + final Token token = peek(); + if (!token.is(type)) { + throw error("Expected " + type + ", got '" + token.text() + "'", token); + } + return next(); + } + + private void expectOp(final String op) { + final Token token = peek(); + if (!token.isText(op)) { + throw error("Expected '" + op + "', got '" + token.text() + "'", token); + } + next(); + } + + private void expectText(final String keyword) { + final Token token = peek(); + if (!token.isText(keyword)) { + throw error("Expected " + keyword + ", got '" + token.text() + "'", token); + } + next(); + } + + private ParseException error(final String message, final Token token) { + final StringBuilder sb = new StringBuilder(message) + .append(" at line ").append(token.line()); + if (token.line() >= 1 && token.line() <= sourceLines.length) { + final String text = sourceLines[token.line() - 1].trim(); + if (!text.isEmpty()) { + sb.append(": ").append(text); + } + } + if (!token.is(Token.Type.EOF)) { + sb.append(" <-- near '").append(token.text()).append("'"); + } + return new ParseException(sb.toString()); + } + + /** Thrown on unsupported or malformed syntax. */ + public static final class ParseException extends RuntimeException { + public ParseException(final String message) { + super(message); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java b/src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java new file mode 100644 index 0000000..c6fe42f --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/CapturedRegion.java @@ -0,0 +1,13 @@ +package eu.svjatoslav.crtbasic.video; + +/** + * A screen rectangle captured by {@code GET (x1, y1)-(x2, y2), array} and + * blitted back by {@code PUT (x, y), array, action}. + * + *

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.

+ */ +public record CapturedRegion(int width, int height, byte[] paletteIndices) { +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java new file mode 100644 index 0000000..5d560f4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java @@ -0,0 +1,82 @@ +package eu.svjatoslav.crtbasic.video; + +/** + * Default VGA palettes. + * + *

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.

+ */ +final class DefaultPalettes { + + /** The standard 16 EGA colors, 0xRRGGBB. */ + private static final int[] EGA_16 = { + 0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, + 0xAA0000, 0xAA00AA, 0xAA5500, 0xAAAAAA, + 0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, + 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF, + }; + + /** + * The BIOS default palette installed by SCREEN 13, 0xRRGGBB per index. + * Ground truth dumped from a real VGA DAC after a SCREEN 13 mode set + * (2026-08-19). + */ + private static final int[] MODE_13 = { + 0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xAA5500, 0xAAAAAA, + 0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF, + 0x000000, 0x141414, 0x202020, 0x2C2C2C, 0x383838, 0x454545, 0x515151, 0x616161, + 0x717171, 0x828282, 0x929292, 0xA2A2A2, 0xB6B6B6, 0xCBCBCB, 0xE3E3E3, 0xFFFFFF, + 0x0000FF, 0x4100FF, 0x7D00FF, 0xBE00FF, 0xFF00FF, 0xFF00BE, 0xFF007D, 0xFF0041, + 0xFF0000, 0xFF4100, 0xFF7D00, 0xFFBE00, 0xFFFF00, 0xBEFF00, 0x7DFF00, 0x41FF00, + 0x00FF00, 0x00FF41, 0x00FF7D, 0x00FFBE, 0x00FFFF, 0x00BEFF, 0x007DFF, 0x0041FF, + 0x7D7DFF, 0x9E7DFF, 0xBE7DFF, 0xDF7DFF, 0xFF7DFF, 0xFF7DDF, 0xFF7DBE, 0xFF7D9E, + 0xFF7D7D, 0xFF9E7D, 0xFFBE7D, 0xFFDF7D, 0xFFFF7D, 0xDFFF7D, 0xBEFF7D, 0x9EFF7D, + 0x7DFF7D, 0x7DFF9E, 0x7DFFBE, 0x7DFFDF, 0x7DFFFF, 0x7DDFFF, 0x7DBEFF, 0x7D9EFF, + 0xB6B6FF, 0xC7B6FF, 0xDBB6FF, 0xEBB6FF, 0xFFB6FF, 0xFFB6EB, 0xFFB6DB, 0xFFB6C7, + 0xFFB6B6, 0xFFC7B6, 0xFFDBB6, 0xFFEBB6, 0xFFFFB6, 0xEBFFB6, 0xDBFFB6, 0xC7FFB6, + 0xB6FFB6, 0xB6FFC7, 0xB6FFDB, 0xB6FFEB, 0xB6FFFF, 0xB6EBFF, 0xB6DBFF, 0xB6C7FF, + 0x000071, 0x1C0071, 0x380071, 0x550071, 0x710071, 0x710055, 0x710038, 0x71001C, + 0x710000, 0x711C00, 0x713800, 0x715500, 0x717100, 0x557100, 0x387100, 0x1C7100, + 0x007100, 0x00711C, 0x007138, 0x007155, 0x007171, 0x005571, 0x003871, 0x001C71, + 0x383871, 0x453871, 0x553871, 0x613871, 0x713871, 0x713861, 0x713855, 0x713845, + 0x713838, 0x714538, 0x715538, 0x716138, 0x717138, 0x617138, 0x557138, 0x457138, + 0x387138, 0x387145, 0x387155, 0x387161, 0x387171, 0x386171, 0x385571, 0x384571, + 0x515171, 0x595171, 0x615171, 0x695171, 0x715171, 0x715169, 0x715161, 0x715159, + 0x715151, 0x715951, 0x716151, 0x716951, 0x717151, 0x697151, 0x617151, 0x597151, + 0x517151, 0x517159, 0x517161, 0x517169, 0x517171, 0x516971, 0x516171, 0x515971, + 0x000041, 0x100041, 0x200041, 0x300041, 0x410041, 0x410030, 0x410020, 0x410010, + 0x410000, 0x411000, 0x412000, 0x413000, 0x414100, 0x304100, 0x204100, 0x104100, + 0x004100, 0x004110, 0x004120, 0x004130, 0x004141, 0x003041, 0x002041, 0x001041, + 0x202041, 0x282041, 0x302041, 0x382041, 0x412041, 0x412038, 0x412030, 0x412028, + 0x412020, 0x412820, 0x413020, 0x413820, 0x414120, 0x384120, 0x304120, 0x284120, + 0x204120, 0x204128, 0x204130, 0x204138, 0x204141, 0x203841, 0x203041, 0x202841, + 0x2C2C41, 0x302C41, 0x342C41, 0x3C2C41, 0x412C41, 0x412C3C, 0x412C34, 0x412C30, + 0x412C2C, 0x41302C, 0x41342C, 0x413C2C, 0x41412C, 0x3C412C, 0x34412C, 0x30412C, + 0x2C412C, 0x2C4130, 0x2C4134, 0x2C413C, 0x2C4141, 0x2C3C41, 0x2C3441, 0x2C3041, + 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, + }; + + private DefaultPalettes() { + // Utility class. + } + + /** Fills the palette with the mode's default colors. */ + static void apply(final int[] palette, final ScreenMode mode) { + if (mode == ScreenMode.SCREEN_13) { + System.arraycopy(MODE_13, 0, palette, 0, MODE_13.length); + return; + } + System.arraycopy(EGA_16, 0, palette, 0, EGA_16.length); + if (mode == ScreenMode.SCREEN_1) { + palette[1] = 0x55FFFF; + palette[2] = 0xFF55FF; + palette[3] = 0xFFFFFF; + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java b/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java new file mode 100644 index 0000000..057d40a --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/Framebuffer.java @@ -0,0 +1,206 @@ +package eu.svjatoslav.crtbasic.video; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +/** + * Indexed-color video memory: one byte per pixel, holding a palette index. + * + *

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.

+ * + *

Modes with multiple video pages (SCREEN 7-10) allocate one byte buffer + * per page.

+ */ +public final class Framebuffer { + + /** Number of palette entries. Modes use fewer, but the VGA DAC has 256. */ + public static final int PALETTE_SIZE = 256; + + private int width; + private int height; + private byte[][] pages; + + /** Palette as 0xRRGGBB values. */ + private final int[] palette = new int[PALETTE_SIZE]; + + /** + * (Re)allocates video memory for the given geometry. All pages are zeroed + * and the palette is reset to the mode defaults. + */ + void resize(final int newWidth, final int newHeight, final int pageCount, + final ScreenMode mode) { + width = newWidth; + height = newHeight; + pages = new byte[pageCount][newWidth * newHeight]; + DefaultPalettes.apply(palette, mode); + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + public int pageCount() { + return pages.length; + } + + /** + * Sets one pixel to a palette index. Out-of-bounds writes are silently + * ignored, matching how clipped drawing behaves on hardware. + */ + public void setPixel(final int page, final int x, final int y, final int colorIndex) { + if (x < 0 || y < 0 || x >= width || y >= height) { + return; + } + pages[page][y * width + x] = (byte) colorIndex; + } + + /** @return palette index of the pixel, or 0 for out-of-bounds reads */ + public int getPixel(final int page, final int x, final int y) { + if (x < 0 || y < 0 || x >= width || y >= height) { + return 0; + } + return pages[page][y * width + x] & 0xFF; + } + + /** Fills a rectangle with a palette index, clipped to the screen. */ + public void fillRect(final int page, final int x, final int y, final int w, final int h, final int colorIndex) { + final int x0 = Math.max(0, x); + final int y0 = Math.max(0, y); + final int x1 = Math.min(width, x + w); + final int y1 = Math.min(height, y + h); + if (x0 >= x1 || y0 >= y1) { + // Fully off-screen. + return; + } + final byte[] target = pages[page]; + final byte value = (byte) colorIndex; + for (int row = y0; row < y1; row++) { + Arrays.fill(target, row * width + x0, row * width + x1, value); + } + } + + /** + * Copies a horizontal band of pixels upwards, used for text scrolling. + * + * @param dstTopY first destination scanline + * @param srcTopY first source scanline + * @param pixelHeight number of scanlines to copy + */ + public void copyBandUp(final int page, final int dstTopY, final int srcTopY, final int pixelHeight) { + System.arraycopy(pages[page], srcTopY * width, pages[page], dstTopY * width, pixelHeight * width); + } + + /** {@code PCOPY}: copies one page's pixels onto another. */ + public void copyPage(final int source, final int target) { + System.arraycopy(pages[source], 0, pages[target], 0, pages[target].length); + } + + /** + * {@code GET}: captures a rectangle of the page (both endpoints + * inclusive, corners normalized). Out-of-bounds pixels read as 0. + */ + public CapturedRegion captureRect(final int page, final int x1, final int y1, + final int x2, final int y2) { + final int left = Math.min(x1, x2); + final int top = Math.min(y1, y2); + final int w = Math.abs(x2 - x1) + 1; + final int h = Math.abs(y2 - y1) + 1; + final byte[] pixels = new byte[w * h]; + final byte[] source = pages[page]; + for (int row = 0; row < h; row++) { + final int sy = top + row; + if (sy < 0 || sy >= height) { + continue; + } + final int fromX = Math.max(0, left); + final int toX = Math.min(width, left + w); + if (fromX < toX) { + System.arraycopy(source, sy * width + fromX, + pixels, row * w + (fromX - left), toX - fromX); + } + } + return new CapturedRegion(w, h, pixels); + } + + /** + * {@code PUT}: blits a captured rectangle back onto the page, clipped to + * the screen. Action combines source and destination pixels the classic way: + * PSET overwrites, XOR/OR/AND combine palette indices bitwise. + */ + public void blitRect(final int page, final int x, final int y, + final CapturedRegion region, final String action) { + final byte[] target = pages[page]; + final byte[] source = region.paletteIndices(); + for (int row = 0; row < region.height(); row++) { + final int dy = y + row; + if (dy < 0 || dy >= height) { + continue; + } + for (int col = 0; col < region.width(); col++) { + final int dx = x + col; + if (dx < 0 || dx >= width) { + continue; + } + final int src = source[row * region.width() + col] & 0xFF; + final int offset = dy * width + dx; + target[offset] = (byte) switch (action) { + case "PSET" -> src; + case "XOR" -> (target[offset] & 0xFF) ^ src; + case "OR" -> (target[offset] & 0xFF) | src; + case "AND" -> (target[offset] & 0xFF) & src; + default -> throw new IllegalArgumentException( + "PUT action not supported: " + action); + }; + } + } + } + + /** Sets a palette entry to a 0xRRGGBB color. */ + public void setPaletteColor(final int index, final int rgb) { + palette[index & 0xFF] = rgb & 0xFFFFFF; + } + + /** @return 0xRRGGBB color of a palette entry */ + public int paletteColor(final int index) { + return palette[index & 0xFF]; + } + + /** + * Converts a page to a displayable image, resolving palette indices to + * actual colors at this point (and only at this point). + */ + public BufferedImage toImage(final int page) { + final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + final byte[] source = pages[page]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, y, palette[source[y * width + x] & 0xFF]); + } + } + return image; + } + + /** Writes a page as a PNG screenshot. */ + public void dumpPng(final int page, final Path output) throws IOException { + ImageIO.write(toImage(page), "png", output.toFile()); + } + + /** + * Raw indexed pixels of a page, row-major — the exact equivalent of a + * {@code BSAVE} of VGA memory in mode 13h, suitable for byte-level + * comparison between runs. + */ + public byte[] pageData(final int page) { + return pages[page]; + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java new file mode 100644 index 0000000..906611f --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java @@ -0,0 +1,138 @@ +package eu.svjatoslav.crtbasic.video; + +/** + * Description of one {@code SCREEN} mode. + * + *

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.

+ */ +public enum ScreenMode { + + /** Text mode: 80x25 characters, 16 colors. */ + SCREEN_0(0, 640, 400, 16, 16, 1), + + /** CGA 320x200, 4 colors. */ + SCREEN_1(1, 320, 200, 8, 4, 1), + + /** CGA 640x200, 2 colors. */ + SCREEN_2(2, 640, 200, 8, 2, 1), + + /** EGA 320x200, 16 colors, 8 pages. */ + SCREEN_7(7, 320, 200, 8, 16, 8), + + /** EGA 640x200, 16 colors, 4 pages. */ + SCREEN_8(8, 640, 200, 8, 16, 4), + + /** EGA 640x350, 16 colors, 2 pages, 8x14 font. */ + SCREEN_9(9, 640, 350, 14, 16, 2), + + /** EGA 640x350 monochrome, 2 pages, 8x14 font. */ + SCREEN_10(10, 640, 350, 14, 4, 2), + + /** VGA 640x480, 2 colors, 8x16 font. */ + SCREEN_11(11, 640, 480, 16, 2, 1), + + /** VGA 640x480, 16 colors, 8x16 font. */ + SCREEN_12(12, 640, 480, 16, 16, 1), + + /** VGA 320x200, 256 colors ("mode 13h"). */ + SCREEN_13(13, 320, 200, 8, 256, 1); + + /** Glyph cell width in pixels. Always 8 on real VGA. */ + public static final int CELL_WIDTH = 8; + + private final int number; + private final int pixelWidth; + private final int pixelHeight; + private final int cellHeight; + private final int colors; + private final int pages; + + ScreenMode(final int number, + final int pixelWidth, + final int pixelHeight, + final int cellHeight, + final int colors, + final int pages) { + this.number = number; + this.pixelWidth = pixelWidth; + this.pixelHeight = pixelHeight; + this.cellHeight = cellHeight; + this.colors = colors; + this.pages = pages; + } + + /** @return the SCREEN statement number (0, 1, 2, 7-13) */ + public int number() { + return number; + } + + public int pixelWidth() { + return pixelWidth; + } + + public int pixelHeight() { + return pixelHeight; + } + + /** @return glyph cell height in pixels: 8, 14 or 16 depending on mode */ + public int cellHeight() { + return cellHeight; + } + + /** @return number of simultaneous colors in this mode */ + public int colors() { + return colors; + } + + /** @return number of video pages (SCREEN 7/8/9/10 have several) */ + public int pages() { + return pages; + } + + /** @return text columns (pixelWidth / 8) */ + public int columns() { + return pixelWidth / CELL_WIDTH; + } + + /** @return text rows (pixelHeight / cellHeight) */ + public int rows() { + return pixelHeight / cellHeight; + } + + /** + * @return the foreground color active right after + * {@code SCREEN}: 7 (light gray) in text mode, 15 (bright white) in + * graphics modes. + */ + public int defaultForeground() { + return this == SCREEN_0 ? 7 : 15; + } + + /** + * @return the default {@code CIRCLE} aspect ratio (y radius per unit of x + * radius) that makes circles look round on this mode's display: + * 4/3 corrected for the pixel resolution. + */ + public double defaultAspect() { + return 4.0 * pixelHeight / (3.0 * pixelWidth); + } + + /** + * @param number SCREEN mode number + * @return the matching mode + * @throws IllegalArgumentException for unsupported mode numbers (3-6 + * have no classic VGA mode) + */ + public static ScreenMode byNumber(final int number) { + for (final ScreenMode mode : values()) { + if (mode.number == number) { + return mode; + } + } + throw new IllegalArgumentException("Unsupported SCREEN mode: " + number); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java new file mode 100644 index 0000000..ad415e5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java @@ -0,0 +1,185 @@ +package eu.svjatoslav.crtbasic.video; + +/** + * Text layer on top of the framebuffer, implementing the classic + * {@code PRINT}/{@code LOCATE}/{@code CLS}/{@code VIEW PRINT} semantics. + * + *

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:

+ * + *
    + *
  • 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.
  • + *
  • 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.
  • + *
+ * + *

Cursor positions are 1-based, like {@code LOCATE row, col} / + * {@code CSRLIN} / {@code POS(0)}.

+ */ +public final class TextConsole { + + private final Framebuffer framebuffer; + private final ScreenMode mode; + private final VgaFont font; + private final int page; + + private int cursorRow = 1; + private int cursorCol = 1; + private int foreground; + private int background = 0; + private int scrollTop = 1; + private int scrollBottom; + + /** + * True after a character was printed in the rightmost column: the cursor + * stays on the last column until the next character triggers the wrap. + * This reproduces the classic bottom-right-corner behavior where printing + * the last cell of the screen does not scroll by itself. + */ + private boolean pendingWrap; + + TextConsole(final Framebuffer framebuffer, final ScreenMode mode, final int page) { + this.framebuffer = framebuffer; + this.mode = mode; + this.font = VgaFont.forCellHeight(mode.cellHeight()); + this.page = page; + this.foreground = mode.defaultForeground(); + scrollBottom = mode.rows(); + } + + /** + * Prints a string at the cursor, advancing it with wrap and scroll. + * Characters map back to their CP437 byte values for the glyph lookup + * (high chars like 'ü' were decoded from CP437 on load and must find + * their original glyph); {@code '\n'} performs a newline, other control + * characters print as their glyphs (the classic + * {@code PRINT CHR$(n)} behavior). + */ + public void print(final String text) { + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (c == '\n') { + newLine(); + continue; + } + if (pendingWrap) { + pendingWrap = false; + cursorCol = 1; + advanceLine(); + } + drawGlyph(eu.svjatoslav.crtbasic.Cp437.byteOf(c)); + if (cursorCol == mode.columns()) { + pendingWrap = true; + } else { + cursorCol++; + } + } + } + + /** Moves the cursor to the start of the next line, scrolling if needed. */ + public void newLine() { + pendingWrap = false; + cursorCol = 1; + advanceLine(); + } + + /** + * {@code LOCATE row, col}: moves the text cursor. Coordinates are 1-based. + */ + public void locate(final int row, final int col) { + if (row < 1 || row > mode.rows() || col < 1 || col > mode.columns()) { + throw new IllegalArgumentException( + "LOCATE out of range: " + row + "," + col + + " (screen is " + mode.rows() + "x" + mode.columns() + ")"); + } + cursorRow = row; + cursorCol = col; + pendingWrap = false; + } + + /** {@code COLOR foreground, background}. */ + public void setColor(final int fg, final int bg) { + foreground = fg & 0xFF; + background = bg & 0xFF; + } + + /** + * {@code VIEW PRINT top TO bottom}: confines printing and scrolling to a + * band of text rows (1-based, inclusive). The cursor is homed to the top + * of the band. + */ + public void viewPrint(final int topRow, final int bottomRow) { + if (topRow < 1 || bottomRow > mode.rows() || topRow > bottomRow) { + throw new IllegalArgumentException( + "VIEW PRINT out of range: " + topRow + " TO " + bottomRow); + } + scrollTop = topRow; + scrollBottom = bottomRow; + cursorRow = topRow; + cursorCol = 1; + pendingWrap = false; + } + + /** {@code CLS}: clears the screen to the background color, homes the cursor. */ + public void cls() { + framebuffer.fillRect(page, 0, 0, framebuffer.width(), framebuffer.height(), background); + cursorRow = scrollTop; + cursorCol = 1; + pendingWrap = false; + } + + /** @return {@code CSRLIN}: 1-based cursor row */ + public int cursorRow() { + return cursorRow; + } + + /** @return {@code POS(0)}: 1-based cursor column */ + public int cursorColumn() { + return cursorCol; + } + + public int foreground() { + return foreground; + } + + public int background() { + return background; + } + + private void advanceLine() { + if (cursorRow == scrollBottom) { + scrollUp(); + } else { + cursorRow++; + } + } + + private void scrollUp() { + final int cellHeight = mode.cellHeight(); + final int topY = (scrollTop - 1) * cellHeight; + final int bandHeight = (scrollBottom - scrollTop + 1) * cellHeight; + framebuffer.copyBandUp(page, topY, topY + cellHeight, bandHeight - cellHeight); + framebuffer.fillRect(page, 0, topY + bandHeight - cellHeight, + framebuffer.width(), cellHeight, background); + } + + private void drawGlyph(final int glyph) { + final int originX = (cursorCol - 1) * ScreenMode.CELL_WIDTH; + final int originY = (cursorRow - 1) * mode.cellHeight(); + // The whole glyph cell is painted with the background color + // in every mode, graphics modes included. That is why programs erase + // text by LOCATE-ing and PRINTing spaces over it (and why reprinting + // at the same spot never smears). + for (int row = 0; row < mode.cellHeight(); row++) { + for (int col = 0; col < ScreenMode.CELL_WIDTH; col++) { + framebuffer.setPixel(page, originX + col, originY + row, + font.pixelSet(glyph, col, row) ? foreground : background); + } + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java new file mode 100644 index 0000000..de79daa --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java @@ -0,0 +1,509 @@ +package eu.svjatoslav.crtbasic.video; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Facade for the whole video subsystem: the surface the VM opcodes will call. + * + *

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.

+ */ +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. + * + *

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.

+ */ + 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 queue = new java.util.ArrayDeque<>(); + framebuffer.setPixel(activePage, x, y, fill); + queue.add(new int[]{x, y}); + while (!queue.isEmpty()) { + final int[] p = queue.poll(); + final int[][] neighbors = { + {p[0] + 1, p[1]}, {p[0] - 1, p[1]}, {p[0], p[1] + 1}, {p[0], p[1] - 1}}; + for (final int[] n : neighbors) { + if (n[0] < 0 || n[1] < 0 || n[0] >= width || n[1] >= height) { + continue; + } + if (framebuffer.getPixel(activePage, n[0], n[1]) == target) { + framebuffer.setPixel(activePage, n[0], n[1], fill); + queue.add(n); + } + } + } + graphicsCursorX = x; + graphicsCursorY = y; + } + + /** @return x of the last point referenced by a drawing primitive */ + public int graphicsCursorX() { + return graphicsCursorX; + } + + /** @return y of the last point referenced by a drawing primitive */ + public int graphicsCursorY() { + return graphicsCursorY; + } + + // ------------------------------------------------------------- palette + + /** {@code PALETTE index, rgb}: remaps one palette entry. */ + public void setPalette(final int index, final int rgb) { + framebuffer.setPaletteColor(index, rgb); + } + + /** @return 0xRRGGBB color of a palette entry */ + public int paletteColor(final int index) { + return framebuffer.paletteColor(index); + } + + // --------------------------------------------------------------- ports + + private int dacWriteIndex; + private int dacComponent; + private final int[] dacColor = new int[3]; + + /** + * {@code OUT port, value}: hardware port write. Only the VGA DAC palette + * ports are modeled: 0x3C8 selects the color index, then three writes to + * 0x3C9 supply the red, green and blue components (hardware uses the low + * 6 bits of each). All other ports are ignored. + */ + public void out(final int port, final int value) { + switch (port) { + case 0x3C8 -> { + dacWriteIndex = value & 0xFF; + dacComponent = 0; + } + case 0x3C9 -> { + final int component6 = value & 0x3F; + dacColor[dacComponent] = (component6 << 2) | (component6 >> 4); + dacComponent++; + if (dacComponent == 3) { + framebuffer.setPaletteColor(dacWriteIndex, + (dacColor[0] << 16) | (dacColor[1] << 8) | dacColor[2]); + dacWriteIndex = (dacWriteIndex + 1) & 0xFF; + dacComponent = 0; + } + } + default -> { + // Other hardware ports are out of scope: ignore. + } + } + } + + // --------------------------------------------------------------- output + + /** + * Writes the visual page as a PNG screenshot — the headless equivalent of + * looking at the monitor. + */ + public void dumpPng(final Path output) throws IOException { + framebuffer.dumpPng(visualPage, output); + } + + /** Raw framebuffer access for tests and VM introspection. */ + public Framebuffer framebuffer() { + return framebuffer; + } + + private void checkPage(final int page) { + if (page < 0 || page >= framebuffer.pageCount()) { + throw new IllegalArgumentException( + "Page " + page + " out of range: SCREEN " + mode.number() + + " has " + framebuffer.pageCount() + " page(s)"); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java new file mode 100644 index 0000000..be58023 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/video/VgaFont.java @@ -0,0 +1,76 @@ +package eu.svjatoslav.crtbasic.video; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; + +/** + * CP437 VGA bitmap font: 256 glyphs, 8 pixels wide, 8/14 or 16 pixels tall. + * + *

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.

+ */ +public final class VgaFont { + + /** Glyph width in pixels. Always 8 for VGA text. */ + public static final int GLYPH_WIDTH = 8; + + /** Number of glyphs in a full CP437 font. */ + public static final int GLYPH_COUNT = 256; + + private static final VgaFont FONT_8 = load(8); + private static final VgaFont FONT_14 = load(14); + private static final VgaFont FONT_16 = load(16); + + private final int glyphHeight; + private final byte[] glyphs; + + private VgaFont(final int glyphHeight, final byte[] glyphs) { + this.glyphHeight = glyphHeight; + this.glyphs = glyphs; + } + + /** @return the font matching the given cell height (8, 14 or 16) */ + public static VgaFont forCellHeight(final int cellHeight) { + return switch (cellHeight) { + case 8 -> FONT_8; + case 14 -> FONT_14; + case 16 -> FONT_16; + default -> throw new IllegalArgumentException("No VGA font with cell height " + cellHeight); + }; + } + + private static VgaFont load(final int glyphHeight) { + final String resource = "/fonts/cp437-8x" + glyphHeight + ".bin"; + try (InputStream stream = VgaFont.class.getResourceAsStream(resource)) { + if (stream == null) { + throw new IllegalStateException("Missing font resource: " + resource); + } + final byte[] glyphs = stream.readAllBytes(); + if (glyphs.length != GLYPH_COUNT * glyphHeight) { + throw new IllegalStateException( + "Font " + resource + " has " + glyphs.length + " bytes, expected " + + GLYPH_COUNT * glyphHeight); + } + return new VgaFont(glyphHeight, glyphs); + } catch (final IOException e) { + throw new UncheckedIOException("Failed to load font resource: " + resource, e); + } + } + + public int glyphHeight() { + return glyphHeight; + } + + /** + * @param glyph CP437 glyph index (0-255) + * @param col pixel column within the glyph (0 = leftmost) + * @param row pixel row within the glyph (0 = top) + * @return true if the pixel is part of the glyph stroke + */ + public boolean pixelSet(final int glyph, final int col, final int row) { + final int scanline = glyphs[(glyph & 0xFF) * glyphHeight + row] & 0xFF; + return (scanline & (0x80 >> col)) != 0; + } +} diff --git a/src/main/resources/fonts/cp437-8x14.bin b/src/main/resources/fonts/cp437-8x14.bin new file mode 100644 index 0000000..2c6d8bb Binary files /dev/null and b/src/main/resources/fonts/cp437-8x14.bin differ diff --git a/src/main/resources/fonts/cp437-8x16.bin b/src/main/resources/fonts/cp437-8x16.bin new file mode 100644 index 0000000..672d0e1 Binary files /dev/null and b/src/main/resources/fonts/cp437-8x16.bin differ diff --git a/src/main/resources/fonts/cp437-8x8.bin b/src/main/resources/fonts/cp437-8x8.bin new file mode 100644 index 0000000..10e5b4f Binary files /dev/null and b/src/main/resources/fonts/cp437-8x8.bin differ diff --git a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java new file mode 100644 index 0000000..35de341 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java @@ -0,0 +1,126 @@ +package eu.svjatoslav.crtbasic.drivers; + +import eu.svjatoslav.crtbasic.ast.Ast; +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.interp.Interpreter; +import eu.svjatoslav.crtbasic.parser.Parser; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.awt.event.KeyEvent; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Headless gameplay driver: runs a .bas program offscreen while feeding it + * a scripted sequence of keystrokes, then dumps the framebuffer. This is + * the agent play-testing path for keyboard-driven games (Main's --headless + * mode cannot inject keys). + * + *

Usage:

+ *
+ *   GameDriver program.bas [--steps=N] [--screenshot=out.png]
+ *       [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
+ * 
+ * + *

Key script: each character is one input event, delivered roughly every + * --interval milliseconds (default 12) while the program runs:

+ *
    + *
  • {@code R L U D} — arrow keys (right/left/up/down, VK codes)
  • + *
  • {@code ~} — pause (no key, just waits one interval)
  • + *
  • anything else — the typed character itself
  • + *
+ * + *

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.

+ */ +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 program = Parser.parse(source); + final VgaDevice vga = new VgaDevice(); + final KeyboardQueue queue = new KeyboardQueue(); + final Interpreter interpreter = new Interpreter(vga, queue, new SoundQueue()); + interpreter.setMaxStatements(steps); + interpreter.setSourceLines(source.split("\n", -1)); + interpreter.setBlockOnInput(false); + final Path parent = programPath.toAbsolutePath().getParent(); + interpreter.setBaseDirectory(parent == null ? Path.of("").toAbsolutePath() : parent); + + final Thread programThread = new Thread(() -> { + try { + interpreter.run(program); + } catch (final RuntimeException e) { + System.err.println(e.getMessage()); + } + }, "basic-program"); + programThread.setDaemon(true); + programThread.start(); + + for (final char c : keys.toCharArray()) { + if (!programThread.isAlive()) { + break; + } + switch (c) { + case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE); + case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE); + case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE); + case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE); + case '~' -> { + // Pause: let the simulation advance without input. + } + default -> queue.pushChar(c); + } + Thread.sleep(interval); + } + programThread.join(); + + if (screenshot != null) { + interpreter.video().dumpPng(screenshot); + System.out.println("Screenshot: " + screenshot); + } + System.out.println("Statements executed: " + interpreter.statementsExecuted()); + for (final String variable : vars) { + System.out.println(variable + " = " + interpreter.variable(variable)); + } + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java new file mode 100644 index 0000000..9f3b2c3 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java @@ -0,0 +1,101 @@ +package eu.svjatoslav.crtbasic.interp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.parser.Parser; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** + * Interpreter semantics added for 2D GFX/People.bas: SELECT CASE ranges, + * COMMAND$ and SLEEP. + */ +class InterpreterFeaturesTest { + + private static Interpreter run(final String source) { + final Interpreter interpreter = new Interpreter( + new VgaDevice(), new KeyboardQueue(), new SoundQueue()); + interpreter.setBlockOnInput(false); + interpreter.run(Parser.parse(source)); + return interpreter; + } + + @Test + void selectCaseSupportsToRanges() { + final Interpreter interpreter = run(""" + FOR i = 1 TO 4 + SELECT CASE i + CASE 1 + r = r + 10 + CASE 2 TO 3 + r = r + 100 + CASE ELSE + r = r + 1000 + END SELECT + NEXT i + """); + assertEquals(10.0 + 100 + 100 + 1000, interpreter.variable("r")); + } + + @Test + void selectCaseRangeBoundariesAreInclusive() { + final Interpreter interpreter = run(""" + SELECT CASE 50 + CASE 50 TO 200 + hit = 1 + END SELECT + SELECT CASE 200 + CASE 50 TO 200 + hit2 = 1 + END SELECT + SELECT CASE 201 + CASE 50 TO 200 + hit3 = 1 + CASE ELSE + hit3 = 2 + END SELECT + """); + assertEquals(1.0, interpreter.variable("hit")); + assertEquals(1.0, interpreter.variable("hit2")); + assertEquals(2.0, interpreter.variable("hit3")); + } + + @Test + void commandDollarReturnsConfiguredCommandLine() { + final Interpreter interpreter = new Interpreter( + new VgaDevice(), new KeyboardQueue(), new SoundQueue()); + interpreter.setBlockOnInput(false); + interpreter.setCommandLine("t"); + interpreter.run(Parser.parse("a$ = COMMAND$")); + assertEquals("t", interpreter.variable("a$")); + } + + @Test + void commandDollarDefaultsToEmpty() { + final Interpreter interpreter = run("a$ = COMMAND$"); + assertEquals("", interpreter.variable("a$")); + } + + @Test + void sleepWithoutArgumentReturnsImmediatelyWhenHeadless() { + assertTimeoutPreemptively(Duration.ofSeconds(2), + () -> run("SLEEP\na = 1")); + } + + @Test + void pendingKeyInterruptsSleep() { + final KeyboardQueue keys = new KeyboardQueue(); + keys.pushChar('x'); + final Interpreter interpreter = new Interpreter( + new VgaDevice(), keys, new SoundQueue()); + assertTimeoutPreemptively(Duration.ofSeconds(2), + () -> interpreter.run(Parser.parse("SLEEP 60"))); + // The interrupting key stays queued for INKEY$. + assertTrue(!keys.isEmpty()); + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java new file mode 100644 index 0000000..f0d7a0d --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/video/GraphicsPrimitivesTest.java @@ -0,0 +1,79 @@ +package eu.svjatoslav.crtbasic.video; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** LINE / CIRCLE / graphics cursor semantics. */ +class GraphicsPrimitivesTest { + + private VgaDevice vga; + + @BeforeEach + void setUp() { + vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + } + + @Test + void lineIncludesBothEndpoints() { + vga.line(10, 20, 15, 20, 4); + assertEquals(4, vga.point(10, 20)); + assertEquals(4, vga.point(15, 20)); + assertEquals(0, vga.point(16, 20)); + } + + @Test + void lineDrawsDiagonal() { + vga.line(0, 0, 5, 5, 2); + for (int i = 0; i <= 5; i++) { + assertEquals(2, vga.point(i, i)); + } + } + + @Test + void lineBoxFilledCoversInclusiveArea() { + vga.lineBoxFilled(10, 10, 11, 11, 5); // the 2x2 "magnify pixel" idiom + assertEquals(5, vga.point(10, 10)); + assertEquals(5, vga.point(11, 10)); + assertEquals(5, vga.point(10, 11)); + assertEquals(5, vga.point(11, 11)); + assertEquals(0, vga.point(12, 10)); + assertEquals(0, vga.point(10, 12)); + } + + @Test + void lineBoxOutlinesOnly() { + vga.lineBox(10, 10, 14, 14, 6); + assertEquals(6, vga.point(10, 10)); + assertEquals(6, vga.point(14, 14)); + assertEquals(6, vga.point(12, 10)); + assertEquals(0, vga.point(12, 12), "interior must stay empty"); + } + + @Test + void circleUsesModeDefaultAspect() { + // SCREEN 13 aspect = 4*200/(3*320) = 0.8333 -> ry = round(10 * 0.8333) = 8 + vga.circle(100, 100, 10, 9); + assertEquals(9, vga.point(110, 100), "rightmost point"); + assertEquals(9, vga.point(90, 100), "leftmost point"); + assertEquals(9, vga.point(100, 92), "topmost point (y radius 8)"); + assertEquals(9, vga.point(100, 108), "bottommost point"); + assertEquals(0, vga.point(100, 91), "no pixel above y radius"); + assertEquals(0, vga.point(100, 100), "center stays empty"); + } + + @Test + void graphicsCursorTracksLastPointReferenced() { + vga.pset(3, 4, 1); + assertEquals(3, vga.graphicsCursorX()); + assertEquals(4, vga.graphicsCursorY()); + vga.line(0, 0, 20, 30, 1); + assertEquals(20, vga.graphicsCursorX()); + assertEquals(30, vga.graphicsCursorY()); + vga.circle(50, 60, 5, 1); + assertEquals(50, vga.graphicsCursorX()); + assertEquals(60, vga.graphicsCursorY()); + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java new file mode 100644 index 0000000..1656564 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java @@ -0,0 +1,195 @@ +package eu.svjatoslav.crtbasic.video; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the semantics of text-in-graphics-modes: glyph blitting, + * transparent vs. painted backgrounds, cursor wrap, scrolling and palettes. + */ +class VgaDeviceTest { + + // 8x8 glyph 'A': row 0 is 0x30 (..##....), so pixel (col 2, row 0) is + // part of the stroke and (col 0, row 0) is not. + private static final int A_STROKE_X = 2; + private static final int A_STROKE_Y = 0; + + @Test + void defaultsToScreen0TextMode() { + final VgaDevice vga = new VgaDevice(); + assertEquals(ScreenMode.SCREEN_0, vga.mode()); + assertEquals(80, vga.mode().columns()); + assertEquals(25, vga.mode().rows()); + } + + @Test + void screen13HasFortyTextColumns() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + assertEquals(40, vga.mode().columns()); + assertEquals(25, vga.mode().rows()); + assertEquals(320, vga.framebuffer().width()); + assertEquals(200, vga.framebuffer().height()); + } + + @Test + void printDrawsGlyphPixelsInGraphicsMode() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.print("A"); + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = bright white in gfx modes + assertEquals(1, vga.cursorRow()); + assertEquals(2, vga.cursorColumn()); + } + + @Test + void graphicsTextPaintsOpaqueBackground() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.pset(0, 0, 9); // a glyph "off" pixel of the cell we print into + vga.pset(A_STROKE_X, A_STROKE_Y, 9); + vga.print("A"); + // The whole cell is painted; that is why PRINTing spaces + // erases text even in graphics modes. + assertEquals(0, vga.point(0, 0), "off-pixel must be painted with background color"); + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color"); + } + + @Test + void printingSpacesErasesTextInGraphicsMode() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.print("A"); + vga.locate(1, 1); + vga.print(" "); + assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "space must erase the glyph"); + } + + @Test + void textModePaintsWholeCell() { + final VgaDevice vga = new VgaDevice(); // SCREEN 0, 8x16 font + vga.setTextColor(7, 1); + vga.print("A"); + assertEquals(1, vga.point(0, 0), "off-pixel must be painted with background"); + // 8x16 glyph 'A': row 2 is 0x10 (...#....), so (col 3, row 2) is stroke. + assertEquals(7, vga.point(3, 2)); + } + + @Test + void cursorWrapsAtRightEdge() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); // 40 columns + vga.print("X".repeat(41)); + assertEquals(2, vga.cursorRow()); + assertEquals(2, vga.cursorColumn()); + } + + @Test + void printingLastCellDoesNotScrollUntilNextCharacter() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.print("A"); // marker on row 1 + vga.locate(25, 1); + vga.print("X".repeat(40)); // fills the last cell exactly + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact"); + + vga.print("X"); // one more character wraps and scrolls + assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "scroll: marker scrolled off the top"); + } + + @Test + void scrollFillsBottomLineWithBackground() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.setTextColor(7, 3); + for (int row = 1; row <= 25; row++) { + vga.locate(row, 1); + vga.print("line " + row); + } + vga.print("\n"); // leaves row 25 -> scroll + assertEquals(3, vga.point(0, 199), "bottom scanline cleared to background"); + assertEquals(3, vga.point(319, 199)); + } + + @Test + void viewPrintConfinesScrollingToBand() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.locate(5, 1); + vga.print("A"); // marker above the band, at pixel row (5-1)*8 = 32 + + vga.viewPrint(10, 20); + for (int i = 0; i < 15; i++) { + vga.print("scrolling line " + i + "\n"); + } + assertEquals(15, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move"); + assertEquals(0, vga.point(A_STROKE_X, (20 - 1) * 8), "band bottom row was cleared"); + assertEquals(20, vga.cursorRow()); + } + + @Test + void clsClearsToBackgroundAndHomesCursor() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.setTextColor(15, 4); + vga.print("something"); + vga.cls(); + assertEquals(4, vga.point(0, 0)); + assertEquals(4, vga.point(319, 199)); + assertEquals(1, vga.cursorRow()); + assertEquals(1, vga.cursorColumn()); + } + + @Test + void paletteRemapChangesRenderedColorNotPixels() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + vga.pset(0, 0, 1); + assertEquals(0xFF0000AA, vga.framebuffer().toImage(0).getRGB(0, 0)); + vga.setPalette(1, 0xFF0000); + assertEquals(1, vga.point(0, 0), "pixel value stays a palette index"); + assertEquals(0xFFFF0000, vga.framebuffer().toImage(0).getRGB(0, 0)); + } + + @Test + void screen7PagesAreIndependent() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_7); + vga.setActivePage(1); + vga.print("A"); + assertEquals(15, vga.framebuffer().getPixel(1, A_STROKE_X, A_STROKE_Y)); + assertEquals(0, vga.framebuffer().getPixel(0, A_STROKE_X, A_STROKE_Y)); + assertThrows(IllegalArgumentException.class, () -> vga.setActivePage(8)); + } + + @Test + void locateOutOfRangeThrows() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_13); + assertThrows(IllegalArgumentException.class, () -> vga.locate(26, 1)); + assertThrows(IllegalArgumentException.class, () -> vga.locate(1, 41)); + } + + @Test + void mode12UsesTallFontThirtyRows() { + final VgaDevice vga = new VgaDevice(); + vga.setMode(ScreenMode.SCREEN_12); + assertEquals(30, vga.mode().rows()); + assertEquals(80, vga.mode().columns()); + vga.print("A"); + // 8x16 glyph 'A' has its top rows empty; just assert something was drawn + boolean anyPixel = false; + for (int y = 0; y < 16 && !anyPixel; y++) { + for (int x = 0; x < 8; x++) { + if (vga.point(x, y) == 15) { + anyPixel = true; + break; + } + } + } + assertTrue(anyPixel, "8x16 glyph must be drawn"); + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java b/src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java new file mode 100644 index 0000000..29c6dc9 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/video/VideoDemoMain.java @@ -0,0 +1,124 @@ +package eu.svjatoslav.crtbasic.video; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.imageio.ImageIO; + +/** + * Renders a few showcase screens and dumps them as upscaled PNGs under + * {@code /tmp/crt-basic-demo/} for visual (agent vision) verification. + * + *

Run with: + * {@code java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain}

+ */ +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); + } +}