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