From: Svjatoslav Agejenko Date: Sat, 22 Aug 2026 10:28:41 +0000 (+0300) Subject: feat: add GOSUB/RETURN, CONST, SWAP, WIDTH, ELSEIF, and screen capture X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=9a2e354c3d4a19ce9069376e8f1c465aac7350d3;p=crtbasic.git feat: add GOSUB/RETURN, CONST, SWAP, WIDTH, ELSEIF, and screen capture - Language: GOSUB/RETURN (nested calls, same-block labels), CONST module-level constants, SWAP for scalars and array elements, ELSEIF chains in block IF, and bare parameterless FUNCTION calls. - WIDTH statement for SCREEN 0: 80x25, 40x25 (double-wide glyphs) and 80x50 (8x8 font) text grids; clears the screen like the original. - PAINT gets classic border semantics: fills every reachable non-border pixel instead of confining to the seed color; off-screen seeds clip. - INPUT # follows classic delimiters: numbers split on comma, space or newline; strings on comma/newline with quotes stripped; empty slots read as 0 / ""; statements may share and span lines. - VIEW PRINT now confines printing and scrolling to the band; bare VIEW PRINT restores the full screen. - New capture package: ScreenRecorder samples the visual page at 30 Hz into a lossless XOR-delta+deflate .crtrec; CrtrecEncoder pipes raw frames to ffmpeg (H.264, CRF 18 with a VBV cap). Swing frontend: F12 screenshot, Shift+F12 recording, background transcode queue that drains before exit; --encode CLI re-encodes a recording later. - Docs: README.org replaced by AGENTS.org (agent operating guide); language reference updated for the new statements and semantics. --- diff --git a/AGENTS.org b/AGENTS.org new file mode 100644 index 0000000..2dbcc99 --- /dev/null +++ b/AGENTS.org @@ -0,0 +1,172 @@ +Operating guide for AI coding agents working in this repository. + +* Purpose +:PROPERTIES: +:ID: 4e41de30-543c-422b-b439-029a225b8002 +:END: + +CRT Basic is a BASIC interpreter written in pure Java. + +It implements the video modes of the classic =SCREEN= statement — +modes 0, 1, 2 and 7–13, i.e. the CGA, EGA and VGA BIOS modes with +their original resolutions and color depths. + +CRT Basic is built to be also easily usable by an AI agent so that it +can write, run, observe, interact with, and test classic DOS-era +=.bas= programs with no human in the loop. Every part of the runtime — +screen, keyboard, program state — is observable and scriptable. + +| Key | Value | +|------------+---------------------------------------------| +| Repository | https://www3.svjatoslav.eu/git/crtbasic.git | +| Language | Java 21, Maven, JUnit 5 | +| Main class | ~eu.svjatoslav.crtbasic.Main~ | +| License | CC0 | + +* Documentation is the authority +:PROPERTIES: +:ID: e9619a92-3e87-4ad7-be0a-5eb076309492 +:END: + +The org-mode site under =Documentation/= is the authoritative +description of the language and runtime. Consult it before assuming +what is implemented: + +- [[file:Documentation/language/index.org][Documentation/language]] — the BASIC dialect: statements, types, + functions, PRINT formatting, error behavior. This is the feature + list; treat it as ground truth over any summary (including this + file). +- [[file:Documentation/video/index.org][Documentation/video]] — the virtual VGA: indexed pixels, text + rendering, video pages. +- [[file:Documentation/interpreter/index.org][Documentation/interpreter]] — the execution engine and number flow. +- [[file:Documentation/architecture/index.org][Documentation/architecture]] — pipeline and module boundaries. +- [[file:Documentation/agent-integration/index.org][Documentation/agent-integration]] — the agent drive loop in full + detail (CLI flags, GameDriver, embedding API). + +When a change alters observable behavior, update the matching +=Documentation/= page in the same pass. The site is regenerated with +~Tools/Update web site~ (exports the =Documentation/= org files to +HTML via Emacs batch). This AGENTS.org is not part of the site and +needs no export. + +* Layout +:PROPERTIES: +:ID: 21f8dccb-f227-44b7-bb8a-c75072947524 +:END: + +#+begin_example +src/main/java/eu/svjatoslav/crtbasic/ +├── lexer/ tokenizer (CP437/CRLF tolerant, _ line continuations) +├── parser/ recursive-descent parser +├── ast/ AST node definitions +├── interp/ tree-walking executor (interim; bytecode VM is planned) +├── video/ virtual VGA: ScreenMode, Framebuffer, VgaFont, +│ TextConsole, VgaDevice, DefaultPalettes +├── input/ KeyboardQueue shared by all frontends +├── audio/ SoundQueue (timing modeled, silent) +├── frontend/ Swing window: live screen + keyboard capture +├── Cp437.java CP437 ↔ Unicode mapping +└── Main.java CLI entry point + +src/test/java/eu/svjatoslav/crtbasic/ +├── drivers/GameDriver.java scripted keystroke play-testing +├── video/VideoDemoMain.java renders showcase frames to +│ /tmp/crt-basic-demo/ +└── ... JUnit tests for video, graphics + primitives, interpreter end to end +#+end_example + +- ~install.sh~ — builds and installs the jar plus a ~crtbasic~ + launcher into =~/.local= (override with =CRT_BASIC_PREFIX=), and + registers a desktop file handler for =.bas= files. +- =Tools/= — desktop helper scripts (~Open with IntelliJ IDEA~, + ~Update web site~). + +* Build and run +:PROPERTIES: +:ID: d92189db-d9f4-4e95-b47d-18b5d7adf5fc +:END: + +#+begin_src sh +cd "/path/to/crtbasic" +mvn package +java -jar target/crt-basic-*-SNAPSHOT.jar program.bas +#+end_src + +Tests: ~mvn test~. Run them before declaring any interpreter change +done. + +* Agent drive loop +:PROPERTIES: +:ID: 71c4714b-b0e1-4a95-886f-2addb720eeeb +:END: + +1) Run the program headless with a statement budget and dumps: + + : crtbasic app.bas --headless --screenshot=out.png --raw=out.raw --steps=100000 + +2) =--raw= gives the raw indexed pixels of every video page, suitable + for byte-level comparison between runs. The run also reports its + statement count to stderr, so "did the loop spin?" is answerable + without looking at pixels. + +3) For keyboard-driven programs use the GameDriver (test sources), + which injects one key event per =--interval= milliseconds: + + : java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.drivers.GameDriver worm.bas --keys="RR~~~UU" --steps=8000000 --screenshot=end.png --vars=score% + +4) =--vars=a%,score= prints named global variables after the run — + assert on program state directly instead of inferring it from + pixels. + +5) For video-subsystem changes, run =VideoDemoMain= and *look* at the + frames in =/tmp/crt-basic-demo/= with vision; assertions alone do + not catch rendering regressions. + +Setting =-Dcrtbasic.linelog=/tmp/lines.log= makes every =LINE= +statement append its post-conversion coordinates to that file — frame +by frame drawing traces. + +* Semantics worth knowing +:PROPERTIES: +:ID: edacb822-d82b-4d2d-a54f-4aa2dc4f5eb0 +:END: + +These are verified behaviors that are easy to get wrong when editing +the interpreter. Each is covered by the documentation; the pointers +below exist so they are not "rediscovered" as bugs. + +- *=Name:= is a label only at the start of a logical line.* After + =THEN= or a =:= separator it parses as a parameterless bare SUB call + followed by the next statement (=Parser.java=). Do not "fix" one + reading without checking the other. +- *Strings round-trip CP437 at the language boundary.* =CHR$(n)= is + the CP437 character for byte =n=; =ASC("ü")= is 129, not the Unicode + code point 252 (=Cp437.java=, used by =interp=). Source files are + read as CP437. +- *=PUT= default action is XOR*, and =GET=/=PUT= accept array-element + anchors like =playerFrames(202, 1)= as the target (=parser=, + =ast=). +- *The SCREEN 13 default palette is an exact 256-entry BIOS dump* + (=DefaultPalettes.java=) — do not regenerate it from a formula. +- *Graphics-mode text paints the whole glyph cell* with an opaque + background in every mode: PRINTing spaces erases text and graphics + underneath. Default text foreground after =SCREEN= is 15 in + graphics modes, 7 in SCREEN 0. +- *Headless input never blocks*: =INKEY$= / =INPUT$(n)= drain the + keyboard queue and return =""= when empty, so bot runs cannot hang. + Interactive mode blocks. +- Unsupported statements fail loudly, naming the statement and the + source line — that error stream is the implementation roadmap, not a + crash to paper over. + +* Conventions +:PROPERTIES: +:ID: 5b1e9c72-6d3f-4a08-b7e2-2c9d4f1a6e53 +:END: + +- *Portable paths only.* Committed files must not contain + machine-local absolute paths; use =/path/to/crtbasic= in examples + and =~/...= for per-user locations. +- *Filenames with spaces.* Test programs and helper scripts carry + spaces in their names — quote every path in scripts and examples. diff --git a/Documentation/language/index.org b/Documentation/language/index.org index 3b0c60d..1a84013 100644 --- a/Documentation/language/index.org +++ b/Documentation/language/index.org @@ -43,7 +43,7 @@ program by program. - *Numbers*: decimal (=3.25=), exponents (=1.5e3=, =2D+10=), hex =&HFF=, octal =&O17=, and type-suffixed literals (=1024&=, =1.5#=). - *Labels*: numeric (=1 IF x THEN GOTO 2=) or alphanumeric - (=MainLoop:=). Targets for =GOTO= and =ON ERROR GOTO=. + (=MainLoop:=). Targets for =GOTO=, =GOSUB= and =ON ERROR GOTO=. * Variables and types :PROPERTIES: @@ -76,6 +76,9 @@ Important consequences: assigned. - =DIM name AS INTEGER= fixes a suffix-less name's type the same way a suffix would. +- =CONST name = expr [, …]= defines module-level named constants + (may reference earlier constants); reassigning one raises + *Duplicate definition*. - Unassigned numeric variables read as =0=, strings as =""=. *Truth values*: comparisons return =-1= for true and =0= for false. @@ -117,7 +120,7 @@ DIM names$(20) AS STRING ' AS type form | =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original | | =LINE …, , B= / =BF= | Rectangle outline / filled rectangle (inclusive corners) | | =CIRCLE (x,y), r[, color][,,,aspect]= | Ellipse via aspect; arcs (start/end angles) *not yet* | -| =PAINT (x,y)[, fill[, border]]= | Flood fill | +| =PAINT (x,y)[, fill[, border]]= | Flood fill: paints over any color up to the border (border defaults to fill) | | =GET (x1,y1)-(x2,y2), array= | Captures a screen rectangle into a variable | | =PUT (x,y), array[, action]= | Blits a captured rectangle back; action is PSET, XOR, OR or AND | | =PCOPY source, target= | Copies one video page onto another | @@ -130,8 +133,9 @@ DIM names$(20) AS STRING ' AS type form | =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline | | =LOCATE row, col= | 1-based cursor position | | =COLOR fg[, bg]= | Text colors (palette indices) | +| =WIDTH cols[, rows]= | Text grid: 80x25 (default), 40x25 or 80x50 in =SCREEN 0=; 40 columns render double-wide glyphs, 50 rows swap to the 8x8 font; clears the screen | | =CLS= | Clears to background, homes cursor | -| =VIEW PRINT top TO bottom= | Scroll region (parsed; framebuffer effect is currently an approximation) | +| =VIEW PRINT top TO bottom= | Scroll region: printing and scrolling confined to the band; bare =VIEW PRINT= restores the full screen | | =CSRLIN=, =POS(0)= | Functions: cursor row / column | ** Control flow @@ -139,13 +143,13 @@ DIM names$(20) AS STRING ' AS type form | Statement | Notes | |-----------+-------| | =FOR v = a TO b [STEP s] … NEXT= | STEP 0 raises an error | -| =IF c THEN … [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms | +| =IF c THEN … [ELSEIF c THEN …] [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms; =ELSEIF= in block form only | | =WHILE c … WEND= | | | =DO [WHILE/UNTIL c] … LOOP [WHILE/UNTIL c]= | Condition on either end | | =SELECT CASE x … CASE a, b … CASE ELSE … END SELECT= | Equality cases (=CASE IS=, =CASE a TO b= not yet) | | =GOTO label= | Procedure-wide label scope | +| =GOSUB label= / =RETURN= | Nested calls OK; GOSUB and its label must live in the same block | | =END=, =SYSTEM= | Both end the program | -| =GOSUB= / =RETURN= | *Not yet implemented* | ** Procedures @@ -169,6 +173,8 @@ END FUNCTION variable. Expressions are passed by value. - A FUNCTION's return value is whatever was last assigned to the function's own name inside the body. +- A parameterless FUNCTION may be called without parentheses (=x = + getChar= calls =getChar()=, unless a variable by that name exists). - =DECLARE= statements carry parameter type suffixes: when a SUB/FUNCTION header uses bare names, the declared suffixes type the parameters. @@ -182,13 +188,14 @@ END FUNCTION | =DATA v, …= / =READ var, …= | DATA is module-wide, collected in program order; running out raises *Out of DATA* | | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= | | =OPEN path FOR INPUT AS #n= | Text files; paths resolve relative to the =.bas= file | -| =INPUT #n, var, …= | Comma-delimited items; numbers parse like =VAL= | +| =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= | | =LINE INPUT #n, var$= | Whole line | | =EOF(n)= | True at end of channel | | =CLOSE [#n]= | Bare form closes all | | =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping | | =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump | | =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet | +| =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* | | =OUT port, value= | VGA DAC ports only; others ignored | * Expressions and operators diff --git a/README.org b/README.org deleted file mode 100644 index 0ac292c..0000000 --- a/README.org +++ /dev/null @@ -1,132 +0,0 @@ -* CRT Basic - -A BASIC interpreter written in Java, built so an AI agent can -write, run, observe, interact with, and test classic =.bas= programs -with no human in the loop. - -* Why not an existing implementation? - -Surveyed existing Java BASIC implementations (2026-08): - -| Project | Dialect | Showstopper | -|----------------------------------------------------+-------------------+------------------------------------------------------| -| PuffinBASIC | GW-BASIC | Line numbers required; no SUB/FUNCTION, DEFINT, SCREEN 13 | -| lwiest/BASICCompiler | GW-BASIC | Same dialect mismatch; compiles to JVM bytecode | -| jvmBASIC | classic BASIC | Line-numbered dialect, no graphics/IO | -| Student interpreters (various repos) | BASIC-ish subsets | Text-only, abandoned, no graphics | - -None of them run the classic DOS-era programs this interpreter targets -unchanged. Hence this project. -* Architecture - -#+begin_example -.bas source - │ - ▼ -lexer → tokens (CP437/CRLF tolerant; DONE — lexer package) -parser → AST (DONE for a practical subset — parser/ast packages) -compiler → stack-based bytecode (planned; enables deterministic stepping) -vm → executes bytecode (planned) -interp → INTERIM: tree-walks the AST so whole programs run today -video → virtual VGA: text mode 80×25 and SCREEN 1–13 pixel modes (DONE) -input → keyboard queue (DONE — window frontend and headless drivers feed it) -frontend → Swing window: live screen view + keyboard capture (DONE) -audio → SOUND/PLAY stubbed (parsed, no-op), real output later -#+end_example - -Design decisions: - -- *Bytecode VM, not tree-walking.* SCREEN 13 programs sit in tight - PSET loops; a compact stack bytecode keeps interpretation overhead low - and gives a natural "execute N instructions" granularity for - deterministic, agent-driven stepping. -- *Introspection.* The VM exposes current instruction pointer, variable - values and screen state so an agent can verify program behavior without - guessing. -* Install and usage - -#+begin_src sh -cd "/path/to/crtbasic" -./install.sh # installs to ~/.local (CRT_BASIC_PREFIX to override) -crtbasic program.bas # interactive window -crtbasic app.bas --headless --screenshot=out.png --raw=out.raw --steps=100000 -#+end_src - -- Default is interactive: a Swing window shows the live screen (~30 fps) - and key presses feed the program's input queue. -- =--headless= (implied by =--screenshot= / =--raw=) runs with no window - and dumps the screen afterwards — the bot-driving path. =--raw= gives - the raw indexed pixels of every video page, suitable for byte-level - comparison between runs. -- =--steps=N= caps executed statements so looping games can be sampled - deterministically. -- =--command=text= sets what COMMAND$ returns (e.g. People.bas's - slideshow auto-advances slides on a timer when run with - =--command=t=). - -* Build - -#+begin_src sh -cd "/path/to/crtbasic" -mvn package -java -jar target/crt-basic-*-SNAPSHOT.jar program.bas -#+end_src - -* Status - -Implemented so far: - -- *video* package (=eu.svjatoslav.crtbasic.video=) — the virtual VGA: - - =ScreenMode= — table of screen modes 0, 1, 2, 7-13: pixel resolution, - glyph cell height (8/14/16), colors, video pages. - - =Framebuffer= — indexed pixels (one byte per pixel = palette index) so - PALETTE tricks and color cycling work; multiple video pages; PNG dumps. - - =VgaFont= — CP437 8x8/8x14/8x16 bitmap fonts (256 glyphs each, - public-domain VGA fonts from SeaBIOS) as embedded resources. - - =TextConsole= — PRINT/LOCATE/COLOR/CLS/VIEW PRINT semantics: glyph - blitting into the pixel framebuffer (opaque cells in every mode), - cursor wrap with the classic bottom-right-corner behavior, - band-confined scrolling. - - =VgaDevice= — facade the executor calls: mode switching, text, - PSET/POINT, LINE (plain/B/BF), CIRCLE (mode default aspect), palette, - active/visual page selection, graphics cursor, screenshots. -- *lexer* / *parser* / *ast* — tokenizer (CP437/CRLF tolerant, `_` line - continuations) and recursive-descent parser for a practical subset: - SCREEN, LOCATE, PRINT (incl. ; and , zones), CLS, COLOR, PSET, LINE - (B/BF), CIRCLE, SOUND, OUT, PAINT, GET/PUT (incl. array-element - offsets like =playerWalkingFrames(202, 1)=), DIM (SHARED, `TO` bounds, - AS type, fixed-length STRING * n), MID$ statement, OPEN FOR INPUT / - INPUT # / LINE INPUT # / CLOSE (DOS paths resolve case-insensitively - against the .bas directory), assignment, FOR/NEXT (STEP), single-line - and block IF/THEN/ELSE, WHILE/WEND, DO...LOOP, GOTO labels, - SUB/FUNCTION/CALL, DECLARE, DEFINT & siblings, SELECT CASE (equality - and inclusive =a TO b= ranges, CASE ELSE), SLEEP, END, and expressions - with the usual functions (POINT, POS, CSRLIN, ABS, INT, SQR, - SIN/COS/TAN/ATN/EXP/LOG, RND, CHR$, STR$, LEN, ASC, VAL, - LEFT$/RIGHT$/MID$, UCASE$/LCASE$, INKEY$, TIME$, TIMER, COMMAND$). - Unsupported statements fail loudly with the line number — that tells - us what to implement next. -- *interp* — INTERIM tree-walking executor (to be replaced by the - bytecode VM). Classic dialect semantics: doubles by default, - suffix-typed variables, true = -1, classic number formatting in PRINT. -- *input* — KeyboardQueue shared by all frontends. -- *frontend* — Swing window: live scaled screen view, keys into the queue. -- =Main= CLI — interactive by default, headless dumps for bots. -- =install.sh= — installs jar + =crtbasic= launcher to =~/.local=. -- JUnit tests cover video semantics, drawing primitives and the - interpreter end to end. -- =VideoDemoMain= (test sources) renders showcase frames to - =/tmp/crt-basic-demo/= for visual verification: - #+begin_src sh - java -cp target/classes:target/test-classes eu.svjatoslav.crtbasic.video.VideoDemoMain - #+end_src - -Notable semantics worth knowing: - -- Graphics-mode text paints the *whole glyph cell* (opaque background) — - that is why PRINTing spaces erases text. -- Default foreground after SCREEN is 15 in graphics modes, 7 in SCREEN 0. - -Not yet: bytecode compiler/VM (interim tree-walker runs programs now), -GOSUB/RETURN, DRAW, CASE IS, CIRCLE arcs, console INPUT statement, real -audio (SOUND is timing-modeled but silent). diff --git a/src/main/java/eu/svjatoslav/crtbasic/Main.java b/src/main/java/eu/svjatoslav/crtbasic/Main.java index 939c268..e711966 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/Main.java +++ b/src/main/java/eu/svjatoslav/crtbasic/Main.java @@ -2,6 +2,7 @@ package eu.svjatoslav.crtbasic; import eu.svjatoslav.crtbasic.ast.Ast; import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.capture.CrtrecEncoder; import eu.svjatoslav.crtbasic.frontend.SwingFrontend; import eu.svjatoslav.crtbasic.input.KeyboardQueue; import eu.svjatoslav.crtbasic.interp.Interpreter; @@ -43,6 +44,10 @@ public final class Main { if (args.length == 0) { usage(); } + if (args[0].startsWith("--encode=")) { + runEncoder(args); + return; + } final Path programPath = Path.of(args[0]); boolean headless = false; Path screenshot = null; @@ -136,7 +141,7 @@ public final class Main { final VgaDevice vga, final KeyboardQueue keys, final Path programPath) { SwingUtilities.invokeLater(() -> - new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName()).show()); + new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName(), programPath).show()); final Thread programThread = new Thread(() -> { try { interpreter.run(program); @@ -178,13 +183,28 @@ public final class Main { JOptionPane.ERROR_MESSAGE); } + private static void runEncoder(final String[] args) { + final Path recording = Path.of(args[0].substring("--encode=".length())); + final Path output = args.length > 1 + ? Path.of(args[1]) : CrtrecEncoder.defaultMp4For(recording); + try { + CrtrecEncoder.encodeToMp4(recording, output); + } catch (final IOException e) { + System.err.println("Encode failed: " + e.getMessage()); + System.exit(1); + } + System.err.println("MP4: " + output); + } + private static void usage() { System.err.println(""" Usage: crtbasic [--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); } } diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java index 80c748b..570a2c4 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java +++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java @@ -108,6 +108,14 @@ public final class Ast { public record ColorStmt(Expr foreground, Expr background, int line) implements Stmt { } + /** {@code WIDTH [columns][, rows]} — text grid size; null slot = omitted. */ + public record WidthStmt(Expr columns, Expr rows, int line) implements Stmt { + } + + /** {@code SWAP a, b} — exchanges two variables or array elements in place. */ + public record SwapStmt(ReadTarget first, ReadTarget second, int line) implements Stmt { + } + /** {@code PSET (x, y)[, color]} */ public record PsetStmt(Expr x, Expr y, Expr color, int line) implements Stmt { } @@ -178,6 +186,22 @@ public final class Ast { public record GotoStmt(String label, int line) implements Stmt { } + /** {@code GOSUB label} — pushes a return address, then jumps like GOTO */ + public record GosubStmt(String label, int line) implements Stmt { + } + + /** {@code RETURN} — resumes after the matching GOSUB */ + public record ReturnStmt(int line) implements Stmt { + } + + /** One {@code name = value} pair of a CONST statement. */ + public record ConstEntry(String name, Expr value) { + } + + /** {@code CONST name = expr [, name = expr …]} — module-level named constants */ + public record ConstStmt(List entries, 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 { } diff --git a/src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java b/src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java new file mode 100644 index 0000000..fc95b24 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/capture/CrtrecEncoder.java @@ -0,0 +1,227 @@ +package eu.svjatoslav.crtbasic.capture; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +/** + * Re-encodes a {@code .crtrec} recording (see {@link ScreenRecorder}) into + * an MP4 by piping raw RGB frames into ffmpeg. This is where compression + * quality is paid for: H.264 sees the full frame sequence and exploits + * motion between frames, which per-frame JPEG never could. + * + *

ffmpeg must be on PATH; {@link #ffmpegAvailable()} checks that.

+ */ +public final class CrtrecEncoder { + + private static final byte TYPE_FRAME = 1; + private static final byte TYPE_PALETTE = 2; + private static final byte TYPE_REPEAT = 3; + + private CrtrecEncoder() { + // Utility class, not meant to be instantiated. + } + + /** @return true when an ffmpeg executable is reachable on PATH */ + public static boolean ffmpegAvailable() { + try { + final Process probe = new ProcessBuilder("ffmpeg", "-version") + .redirectErrorStream(true) + .start(); + probe.getInputStream().readAllBytes(); + return probe.waitFor() == 0; + } catch (final IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return false; + } + } + + /** + * Encodes {@code recording} to {@code output} (H.264, CRF 18 with a + * VBV bitrate cap, yuv420p for player compatibility). The cap + * ({@link #maxBitRateBps}) keeps high-motion content from ballooning: + * calm scenes keep CRF's low bitrate, motion bursts get clamped. + * Blocks until ffmpeg finishes. + * + * @throws IOException on I/O or recording-format errors, or when ffmpeg + * exits with a non-zero status + */ + public static void encodeToMp4(final Path recording, final Path output) throws IOException { + encodeToMp4(recording, output, null); + } + + /** + * Like {@link #encodeToMp4(Path, Path)}, but appends ffmpeg's stderr to + * {@code logFile} (instead of inheriting this process's stderr) when + * non-null — for desktop launches where nobody can see stderr. + */ + public static void encodeToMp4(final Path recording, final Path output, final Path logFile) + throws IOException { + final DataInputStream in = new DataInputStream(new BufferedInputStream( + Files.newInputStream(recording), 1 << 16)); + final int width; + final int height; + final int fps; + try (in) { + final byte[] magic = new byte[8]; + in.readFully(magic); + if (!"CRTREC01".equals(new String(magic, java.nio.charset.StandardCharsets.US_ASCII))) { + throw new IOException(recording + ": not a CRTREC01 recording"); + } + width = in.readInt(); + height = in.readInt(); + fps = in.readInt(); + + // VBV cap: CRF 18 alone lets x264 spend unbounded bits on + // high-motion content (a full-screen Matrix crawl hits + // ~10 Mbit/s). maxrate/bufsize clamp the peaks; calm scenes + // are unaffected since CRF stays below the cap there. + final long maxRate = maxBitRateBps(width, height, fps); + final ProcessBuilder builder = new ProcessBuilder("ffmpeg", "-y", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-s", width + "x" + height, + "-r", Integer.toString(fps), + "-i", "pipe:0", + "-c:v", "libx264", + "-preset", "medium", + "-crf", "18", + "-maxrate", maxRate / 1000 + "k", + "-bufsize", maxRate / 500 + "k", + "-pix_fmt", "yuv420p", + output.toString()); + if (logFile != null) { + builder.redirectError(ProcessBuilder.Redirect.appendTo(logFile.toFile())); + } else { + builder.redirectError(ProcessBuilder.Redirect.INHERIT); + } + final Process ffmpeg = builder.start(); + final Thread pump = new Thread(() -> pumpFrames(in, ffmpeg.getOutputStream(), + width, height), "crtrec-encode-pump"); + pump.start(); + final int exit = await(ffmpeg); + try { + pump.join(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (exit != 0) { + throw new IOException("ffmpeg exited with status " + exit); + } + } + } + + /** @return default output path for a recording: same name with .mp4 */ + public static Path defaultMp4For(final Path recording) { + final String name = recording.getFileName().toString(); + final String base = name.endsWith(".crtrec") + ? name.substring(0, name.length() - ".crtrec".length()) : name; + return recording.resolveSibling(base + ".mp4"); + } + + // ------------------------------------------------------------ internals + + /** + * VBV peak bitrate in bit/s: 0.35 bits per pixel per frame, floored at + * 200 kbit/s so small SCREEN modes still get a useful budget. + */ + static long maxBitRateBps(final int width, final int height, final int fps) { + final long bps = (long) (width * (double) height * fps * 0.35); + return Math.max(bps, 200_000); + } + + private static void pumpFrames(final DataInputStream in, final OutputStream ffmpegIn, + final int width, final int height) { + final int pixelCount = width * height; + final byte[] frame = new byte[pixelCount]; + final int[] palette = new int[256]; + final byte[] rgb = new byte[pixelCount * 3]; + try (in; ffmpegIn) { + while (true) { + final byte type; + try { + type = in.readByte(); + } catch (final EOFException e) { + return; + } + switch (type) { + case TYPE_FRAME -> { + final int length = in.readInt(); + final byte[] compressed = new byte[length]; + in.readFully(compressed); + final byte[] delta = inflate(compressed, pixelCount); + for (int i = 0; i < pixelCount; i++) { + frame[i] ^= delta[i]; + } + writeRgb(frame, palette, rgb, ffmpegIn); + } + case TYPE_PALETTE -> { + for (int i = 0; i < palette.length; i++) { + final int r = in.readByte() & 0xFF; + final int g = in.readByte() & 0xFF; + final int b = in.readByte() & 0xFF; + palette[i] = (r << 16) | (g << 8) | b; + } + } + case TYPE_REPEAT -> { + final int count = in.readInt(); + for (int i = 0; i < count; i++) { + writeRgb(frame, palette, rgb, ffmpegIn); + } + } + default -> throw new IOException("Unknown record type " + type); + } + } + } catch (final IOException e) { + // ffmpeg closing the pipe early (e.g. on its own error) surfaces + // as an exception here; ffmpeg's exit code is checked by the caller. + } + } + + private static void writeRgb(final byte[] frame, final int[] palette, + final byte[] rgb, final OutputStream out) throws IOException { + for (int i = 0; i < frame.length; i++) { + final int color = palette[frame[i] & 0xFF]; + rgb[i * 3] = (byte) (color >> 16); + rgb[i * 3 + 1] = (byte) (color >> 8); + rgb[i * 3 + 2] = (byte) color; + } + out.write(rgb); + } + + private static byte[] inflate(final byte[] compressed, final int expectedLength) + throws IOException { + final Inflater inflater = new Inflater(); + try { + inflater.setInput(compressed); + final byte[] result = new byte[expectedLength]; + final int produced = inflater.inflate(result); + if (produced != expectedLength) { + throw new IOException("Corrupt FRAME record: expected " + expectedLength + + " bytes, got " + produced); + } + return result; + } catch (final DataFormatException e) { + throw new IOException("Corrupt FRAME record: " + e.getMessage(), e); + } finally { + inflater.end(); + } + } + + private static int await(final Process process) throws IOException { + try { + return process.waitFor(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for ffmpeg", e); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java b/src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java new file mode 100644 index 0000000..7ee1f52 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/capture/ScreenRecorder.java @@ -0,0 +1,259 @@ +package eu.svjatoslav.crtbasic.capture; + +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.zip.Deflater; + +/** + * Lossless screen recorder: samples the visual page of a {@link VgaDevice} + * at a fixed 30 Hz and writes a compact {@code .crtrec} file that can be + * re-encoded to video later (see {@link CrtrecEncoder}). + * + *

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.

+ * + *

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

+ * + *

.crtrec file format (all integers big-endian)

+ *
+ *   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.
+ * 
+ */ +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 { + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java index 9dea4f8..116f2e3 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java +++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java @@ -1,5 +1,7 @@ package eu.svjatoslav.crtbasic.frontend; +import eu.svjatoslav.crtbasic.capture.CrtrecEncoder; +import eu.svjatoslav.crtbasic.capture.ScreenRecorder; import eu.svjatoslav.crtbasic.input.KeyboardQueue; import eu.svjatoslav.crtbasic.video.VgaDevice; import java.awt.Graphics; @@ -7,8 +9,18 @@ import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.swing.BorderFactory; import javax.swing.JFrame; +import javax.swing.JLabel; import javax.swing.JPanel; +import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.WindowConstants; @@ -18,9 +30,25 @@ import javax.swing.WindowConstants; * on a timer. Key presses are pushed into the shared {@link KeyboardQueue} * so a running program sees them through {@code INKEY$}/{@code INPUT}. * - *

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.

+ *

Two hotkeys are intercepted here and never reach the program (F11/F12 + * produce no key code a classic program can observe):

+ *
    + *
  • {@code F12} — screenshot: the visual page as + * {@code - Screenshot .png} next to the .bas file.
  • + *
  • {@code Shift+F12} — toggles video recording: encoded to + * {@code - Screencast .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.
  • + *
+ * + *

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.

+ * + *

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.

*/ public final class SwingFrontend { @@ -29,10 +57,35 @@ public final class SwingFrontend { private final VgaDevice vga; private final JFrame frame; + private final String baseTitle; + private final Path programDirectory; + private final String programBaseName; + + private ScreenRecorder recorder; + private Timer titleTimer; + private Thread shutdownHook; + + /** Pending MP4 transcodes, drained by a single background worker. */ + private final List encodeQueue = new ArrayList<>(); + private int encodeTotal; + private int encodeDone; + private Thread encodeWorker; + /** Window close requested: exit as soon as the encode queue drains. */ + private boolean closing; + private JFrame progressFrame; + private JLabel progressLabel; @SuppressWarnings("serial") - public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, final String title) { + public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, + final String title, final Path programPath) { vga = vgaDevice; + baseTitle = title; + final Path absolute = programPath.toAbsolutePath(); + programDirectory = absolute.getParent() == null + ? Path.of("").toAbsolutePath() : absolute.getParent(); + final String fileName = programPath.getFileName().toString(); + programBaseName = fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".bas") + ? fileName.substring(0, fileName.length() - 4) : fileName; final int scale = vga.mode().pixelWidth() <= 320 ? 3 : 2; final JPanel canvas = new JPanel() { @@ -51,13 +104,28 @@ public final class SwingFrontend { canvas.addKeyListener(new KeyAdapter() { @Override public void keyPressed(final KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_F12) { + // Hotkey: swallowed here, never pushed into the queue. + if (e.isShiftDown()) { + toggleRecording(); + } else { + screenshot(); + } + return; + } keys.push(e.getKeyCode(), e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar()); } }); frame = new JFrame(title); - frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(final WindowEvent e) { + closeRequested(); + } + }); frame.setContentPane(canvas); frame.pack(); frame.setLocationRelativeTo(null); @@ -74,4 +142,250 @@ public final class SwingFrontend { public void modeChanged() { frame.pack(); } + + // ---------------------------------------------------------- screenshot + + private void screenshot() { + final Path output = nextScreenshotName(); + try { + vga.dumpPng(output); + System.err.println("Screenshot: " + output); + } catch (final IOException e) { + System.err.println("Screenshot failed: " + e.getMessage()); + } + } + + // ---------------------------------------------------------- recording + + private void toggleRecording() { + if (recorder == null) { + startRecording(); + } else { + stopRecording("Recording stopped"); + } + } + + private void startRecording() { + final Path output = nextScreencastName(".crtrec"); + try { + recorder = ScreenRecorder.start(vga, output); + } catch (final IOException e) { + System.err.println("Recording failed to start: " + e.getMessage()); + recorder = null; + return; + } + // Last-resort safety net for JVM termination paths that bypass the + // window close handler (kill signal, System.exit from elsewhere). + shutdownHook = new Thread(() -> { + final Path crtrec = recorder.stop(); + System.err.println("Recording stopped (JVM exiting): " + crtrec); + encodeOne(crtrec); + }, "crtrec-shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + recorder.setOnAutoStop(() -> SwingUtilities.invokeLater( + () -> stopRecording("Recording ended (video mode changed)"))); + System.err.println("Recording: " + output); + titleTimer = new Timer(500, e -> { + if (recorder != null) { + frame.setTitle(String.format("%s — REC %d:%02d", baseTitle, + recorder.elapsedSeconds() / 60, recorder.elapsedSeconds() % 60)); + } + }); + titleTimer.start(); + } + + private void stopRecording(final String reason) { + if (recorder == null) { + return; + } + if (titleTimer != null) { + titleTimer.stop(); + titleTimer = null; + } + if (shutdownHook != null) { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + shutdownHook = null; + } + final Path crtrec = recorder.stop(); + recorder = null; + System.err.println(reason + ": " + crtrec); + if (!closing) { + frame.setTitle(baseTitle); + } + enqueueEncode(crtrec); + } + + // ------------------------------------------------------------- closing + + /** + * Window close: finalize any recording in progress, then let pending + * transcodes finish behind a progress window before exiting. + */ + private void closeRequested() { + closing = true; + if (recorder != null) { + stopRecording("Recording stopped (window closed)"); + } + frame.setVisible(false); + synchronized (encodeQueue) { + if (encodeQueue.isEmpty() + && (encodeWorker == null || !encodeWorker.isAlive())) { + System.exit(0); + } + } + showProgressWindow(); + updateProgressUi(); + } + + private void showProgressWindow() { + progressLabel = new JLabel(); + progressLabel.setBorder(BorderFactory.createEmptyBorder(16, 24, 16, 24)); + progressFrame = new JFrame("CRT Basic — encoding screencasts"); + progressFrame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + progressFrame.setContentPane(progressLabel); + progressFrame.pack(); + progressFrame.setLocationRelativeTo(null); + progressFrame.setVisible(true); + } + + // ------------------------------------------------------------ encoding + + private void enqueueEncode(final Path crtrec) { + if (!CrtrecEncoder.ffmpegAvailable()) { + System.err.println("ffmpeg not found; re-encode later with: crtbasic --encode=" + crtrec); + log(encodeLogFor(crtrec), "ffmpeg not available at encode time" + + " (PATH=" + System.getenv("PATH") + ")"); + synchronized (encodeQueue) { + encodeQueue.clear(); // nothing can run; if closing, exit now + } + if (closing) { + System.exit(0); + } + return; + } + synchronized (encodeQueue) { + encodeQueue.add(crtrec); + encodeTotal++; + if (encodeWorker == null || !encodeWorker.isAlive()) { + encodeWorker = new Thread(this::encodeLoop, "crtrec-encoder"); + encodeWorker.start(); + } + } + updateProgressUi(); + } + + /** Drains the encode queue one file at a time; exits the JVM at the end when closing. */ + private void encodeLoop() { + while (true) { + final Path crtrec; + synchronized (encodeQueue) { + if (encodeQueue.isEmpty()) { + break; + } + crtrec = encodeQueue.remove(0); + } + updateProgressUi(); + encodeOne(crtrec); + synchronized (encodeQueue) { + encodeDone++; + } + updateProgressUi(); + } + SwingUtilities.invokeLater(() -> { + if (!closing) { + frame.setTitle(baseTitle); + } + }); + if (closing) { + System.exit(0); + } + } + + private void encodeOne(final Path crtrec) { + final Path mp4 = CrtrecEncoder.defaultMp4For(crtrec); + final Path log = encodeLogFor(crtrec); + log(log, "encode started: " + crtrec.getFileName() + " -> " + mp4.getFileName() + + " (PATH=" + System.getenv("PATH") + ")"); + try { + CrtrecEncoder.encodeToMp4(crtrec, mp4, log); + System.err.println("MP4: " + mp4); + // Intermediates are only worth keeping when something failed. + Files.deleteIfExists(crtrec); + Files.deleteIfExists(log); + } catch (final IOException e) { + System.err.println("MP4 encode failed: " + e.getMessage()); + log(log, "encode FAILED: " + e); + } + } + + /** Diagnostics next to the recording: {@code .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 - Screenshot .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 - Screencast .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; + } + } + } } diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index a5ef4d8..46271da 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -118,6 +118,15 @@ public final class Interpreter { } } + /** + * A GOSUB return address: the statement list the GOSUB sits in and the + * pc of the statement after it. RETURN only resolves when it executes in + * that same block — a GOSUB whose label lives in an outer block unwinds + * the originating block, making its continuation unreachable. + */ + private record GosubFrame(List block, int pc) { + } + /** * Signals a GOTO jump. Each enclosing block that does not contain the * target label rethrows it, which gives procedure-wide label scope. @@ -138,6 +147,8 @@ public final class Interpreter { private final SoundQueue sound; /** Scope stack: bottom is the global scope, top is the current SUB. */ private final Deque> scopes = new ArrayDeque<>(); + /** GOSUB return addresses: the block and pc to resume on RETURN. */ + private final Deque gosubStack = new ArrayDeque<>(); private final Map subs = new HashMap<>(); /** Functions, keyed by base name (uppercase, no type suffix). */ private final Map functions = new HashMap<>(); @@ -154,6 +165,8 @@ public final class Interpreter { 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<>(); + /** Canonical names fixed by CONST: reassignment is a Duplicate definition. */ + private final java.util.Set constants = new java.util.HashSet<>(); /** * Type suffix per lowercase base name fixed by {@code DIM name AS type}: * suffix-less references to that variable resolve to the DIM'd type. @@ -172,8 +185,8 @@ public final class Interpreter { */ private final Map 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<>(); + /** Unconsumed tail of the current INPUT # line, per channel. */ + private final Map fileRemainder = new HashMap<>(); /** Directory that relative OPEN paths resolve against (the .bas directory). */ private java.nio.file.Path baseDirectory = java.nio.file.Path.of(""); /** @@ -333,6 +346,26 @@ public final class Interpreter { if (stmt instanceof Ast.GotoStmt g) { throw new GotoJump(g.label(), g.line()); } + if (stmt instanceof Ast.GosubStmt g) { + // Return address: the statement after this GOSUB, in + // this block. The label jump itself is a GOTO. + gosubStack.addLast(new GosubFrame(stmts, pc)); + throw new GotoJump(g.label(), g.line()); + } + if (stmt instanceof Ast.ReturnStmt r) { + if (++executed > maxStatements) { + throw new StepsLimitException(executed); + } + final GosubFrame frame = gosubStack.pollLast(); + if (frame == null) { + throw error("RETURN without GOSUB", r.line()); + } + if (frame.block() != stmts) { + throw error("Cross-block GOSUB/RETURN is not supported", r.line()); + } + pc = frame.pc(); + continue; + } exec(stmt); } catch (final GotoJump jump) { if (++executed > maxStatements) { @@ -480,6 +513,18 @@ public final class Interpreter { : toInt(eval(s.background()), s.line()); vga.setTextColor(fg, bg); } + case Ast.WidthStmt s -> { + final int columns = s.columns() == null ? vga.columns() + : toInt(eval(s.columns()), s.line()); + final int rows = s.rows() == null ? 25 + : toInt(eval(s.rows()), s.line()); + try { + vga.setTextGrid(columns, rows); + } catch (final IllegalArgumentException e) { + throw error("Illegal function call: " + e.getMessage(), s.line()); + } + } + case Ast.SwapStmt s -> execSwap(s); case Ast.PrintStmt s -> execPrint(s); case Ast.PsetStmt s -> vga.pset(toInt(eval(s.x()), s.line()), toInt(eval(s.y()), s.line()), colorOrDefault(s.color(), s.line())); @@ -604,12 +649,32 @@ public final class Interpreter { // Parsed but intentionally not modeled. } case Ast.GotoStmt s -> throw new GotoJump(s.label(), s.line()); + case Ast.GosubStmt s -> + // Reached only through paths that bypass execBlock's pc loop. + throw error("GOSUB is only supported as a standalone statement", s.line()); + case Ast.ReturnStmt s -> throw error("RETURN without GOSUB", s.line()); case Ast.SubStmt s -> { // SUB definitions are registered by run(), never executed inline. } case Ast.CallStmt s -> execCall(s); + case Ast.ConstStmt s -> { + for (final Ast.ConstEntry entry : s.entries()) { + final String key = canonical(entry.name()); + final Map global = scopes.getLast(); + if (global.containsKey(key)) { + throw error("Duplicate definition", s.line()); + } + constants.add(key); + final Cell cell = new Cell(kindOf(key), null); + cell.set(eval(entry.value())); + global.put(key, cell); + } + } case Ast.AssignStmt s -> { if (s.indices().isEmpty()) { + if (constants.contains(canonical(s.variable()))) { + throw error("Duplicate definition", s.line()); + } cellFor(s.variable()).set(eval(s.value())); } else { final BasicArray array = arrayFor(s.variable(), s.indices().size(), s.line()); @@ -625,9 +690,16 @@ public final class Interpreter { // DATA values were collected by run(); executing is a no-op. } case Ast.ViewPrintStmt s -> { - // Text scroll viewport. Approximation: no framebuffer effect — - // CLS clears the whole screen and PRINT never scrolls past the - // bottom row, so the region has no observable effect yet. + // Text scroll viewport: printing and scrolling are confined + // to the band; the cursor homes to the band's top-left. + final int top = s.top() == null ? 1 : toInt(eval(s.top()), s.line()); + final int bottom = s.bottom() == null ? vga.rows() + : toInt(eval(s.bottom()), s.line()); + try { + vga.viewPrint(top, bottom); + } catch (final IllegalArgumentException e) { + throw error("Illegal function call: " + e.getMessage(), s.line()); + } } case Ast.ReadStmt s -> { for (final Ast.ReadTarget target : s.targets()) { @@ -696,7 +768,7 @@ public final class Interpreter { /** ',' in PRINT: advance to the next 14-column print zone. */ private void printZoneAdvance() { final int col = vga.cursorColumn(); - final int columns = vga.mode().columns(); + final int columns = vga.columns(); final int target = ((col - 1) / 14 + 1) * 14 + 1; if (target > columns) { vga.newLine(); @@ -938,6 +1010,38 @@ public final class Interpreter { array.data[offset] = value instanceof Double d ? array.narrow(d) : value; } + /** Reads the current value of a scalar or array-element target. */ + private Object getTarget(final Ast.ReadTarget target, final int line) { + if (target.indices().isEmpty()) { + final Object value = cellFor(target.name()).value; + if (value != null) { + return value; + } + return target.name().endsWith("$") ? "" : 0.0; + } + final BasicArray array = arrayFor(target.name(), target.indices().size(), line); + final int offset = array.offset(subscripts(target.indices(), line)); + if (offset < 0) { + throw error("Subscript out of range", line); + } + return array.data[offset]; + } + + /** + * {@code SWAP a, b}: exchanges two variables or array elements in + * place. Mixing a string with a number raises Type mismatch; each value + * is narrowed to its new home's type on store, like an assignment. + */ + private void execSwap(final Ast.SwapStmt s) { + final Object first = getTarget(s.first(), s.line()); + final Object second = getTarget(s.second(), s.line()); + if (first instanceof String != second instanceof String) { + throw error("Type mismatch", s.line()); + } + setTarget(s.first(), second, s.line()); + setTarget(s.second(), first, s.line()); + } + // ------------------------------------------------------------ expressions private Object eval(final Ast.Expr expr) { @@ -987,6 +1091,14 @@ public final class Interpreter { if (cell == null && scopes.size() > 1 && sharedNames.contains(key)) { cell = scopes.getLast().get(key); } + if (cell == null) { + // A bare reference to a parameterless user FUNCTION calls it + // (the dialect allows dropping the empty parentheses). + final Ast.FunctionStmt function = functions.get(baseName(v.name())); + if (function != null && function.params().isEmpty()) { + return invokeFunction(function, List.of(), v.line()); + } + } if (cell != null && cell.value != null) { return cell.value; } @@ -1530,26 +1642,73 @@ public final class Interpreter { } /** - * Reads the next comma-delimited data item for {@code INPUT #channel} - * and converts it for the target variable: numbers parse like VAL - * (empty/unparseable = 0), strings keep their raw text (surrounding - * quotes stripped). Items are buffered per channel so several INPUT # - * statements can share one line and one INPUT # can span lines. + * Reads the next data item for {@code INPUT #channel} and converts it + * for the target variable. Classic delimiters: numbers end at a comma, + * whitespace or end of line; strings end at a comma or end of line + * (inner spaces kept, surrounding quotes stripped). An empty slot + * between commas reads as 0 / "". A statement may span lines and + * several statements may share one line. */ private Object readFileItem(final int channel, final String variable, final int line) { - final ArrayDeque items = fileItems.computeIfAbsent( - channel, k -> new ArrayDeque<>()); - if (items.isEmpty()) { - final String lineText = readFileLine(channel, line); - if (lineText == null) { - throw error("Input past end of file", line); + final boolean stringTarget = canonical(variable).endsWith("$"); + String rest = fileRemainder.get(channel); + while (true) { + if (rest == null) { + rest = readFileLine(channel, line); + if (rest == null) { + throw error("Input past end of file", line); + } + continue; + } + rest = rest.stripLeading(); + if (rest.isEmpty()) { + // Line exhausted: the item comes from the next line. + rest = null; + fileRemainder.remove(channel); + continue; + } + if (rest.charAt(0) == ',') { + // Empty item between two commas. + fileRemainder.put(channel, rest.substring(1)); + return stringTarget ? "" : 0.0; + } + break; + } + if (stringTarget && rest.startsWith("\"")) { + final int close = rest.indexOf('"', 1); + if (close < 0) { + // Unterminated quote: take the rest of the line. + fileRemainder.remove(channel); + return rest.substring(1); + } + String tail = rest.substring(close + 1).stripLeading(); + if (tail.startsWith(",")) { + tail = tail.substring(1); + } + fileRemainder.put(channel, tail); + return rest.substring(1, close); + } + final int end; + if (stringTarget) { + final int comma = rest.indexOf(','); + end = comma < 0 ? rest.length() : comma; + } else { + int i = 0; + while (i < rest.length() && rest.charAt(i) != ',' + && !Character.isWhitespace(rest.charAt(i))) { + i++; } - items.addAll(List.of(lineText.split(",", -1))); + end = i; + } + final String item = stringTarget + ? rest.substring(0, end).stripTrailing() : rest.substring(0, end); + String tail = rest.substring(end).stripLeading(); + if (tail.startsWith(",")) { + tail = tail.substring(1); } - final String item = items.poll().trim(); - if (canonical(variable).endsWith("$")) { - return item.length() >= 2 && item.startsWith("\"") && item.endsWith("\"") - ? item.substring(1, item.length() - 1) : item; + fileRemainder.put(channel, tail); + if (stringTarget) { + return item; } try { return Double.parseDouble(item); @@ -1619,7 +1778,7 @@ public final class Interpreter { private void closeChannel(final int channel, final int line) { final java.io.BufferedReader reader = fileChannels.remove(channel); fileLookahead.remove(channel); - fileItems.remove(channel); + fileRemainder.remove(channel); if (reader == null) { return; // CLOSE on an unopened channel is ignored } diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java index 0714a18..e510d37 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java +++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java @@ -73,6 +73,8 @@ public final class Parser { yield new Ast.ClsStmt(token.line()); } case "COLOR" -> colorStatement(); + case "WIDTH" -> widthStatement(); + case "SWAP" -> swapStatement(); case "PSET" -> psetStatement(); case "LINE" -> lineStatement(); case "CIRCLE" -> circleStatement(); @@ -98,6 +100,9 @@ public final class Parser { case "WHILE" -> whileStatement(); case "DO" -> doStatement(); case "GOTO" -> gotoStatement(); + case "GOSUB" -> gosubStatement(); + case "RETURN" -> returnStatement(); + case "CONST" -> constStatement(); case "SUB" -> subStatement(); case "FUNCTION" -> functionStatement(); case "SELECT" -> selectStatement(); @@ -114,7 +119,7 @@ public final class Parser { next(); yield new Ast.EndStmt(token.line()); } - case "NEXT", "WEND", "ELSE", "THEN", "TO", "STEP", "ENDIF", "LOOP" -> + case "NEXT", "WEND", "ELSE", "ELSEIF", "THEN", "TO", "STEP", "ENDIF", "LOOP" -> throw error("Unexpected " + keyword, token); default -> assignmentOrCallStatement(); }; @@ -124,12 +129,43 @@ public final class Parser { private Ast.Stmt gotoStatement() { final Token keyword = next(); // GOTO + return new Ast.GotoStmt(jumpLabel(keyword), keyword.line()); + } + + private Ast.Stmt gosubStatement() { + final Token keyword = next(); // GOSUB + return new Ast.GosubStmt(jumpLabel(keyword), keyword.line()); + } + + private Ast.Stmt returnStatement() { + final Token keyword = next(); // RETURN + // Only the plain form: RETURN