From 0b97fa61953b0e4b183ec5563f63873608d9811e Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Fri, 28 Aug 2026 01:08:34 +0300 Subject: [PATCH] feat: add emulated mouse driver, file output, and GUI mouse grab - machine/: 1 MiB emulated RAM behind DEF SEG/PEEK/POKE, with the qbext mouse TSR always loaded (INT 79h vector, 8-byte table: magic, X/Y accumulators, button latch; accumulate + read-and-reset) - mouse input: SwingFrontend pushes scaled window deltas and button latches; GameDriver gains scripted --mouse deltas/buttons for headless verification; mousedrv.bas runs unmodified - GUI mouse grab: first click captures (transparent always-on-top overlay spans all screens, cursor hidden), Ctrl+F10 releases; no Robot warping (Wayland RemoteDesktop popup, Xvfb EDT hang) - language: VAL parses &H/&O with signed literal semantics, HEX$, LOCATE with omitted column, OPEN FOR OUTPUT/APPEND + PRINT # (CRLF, console formatting), CHDIR/MKDIR with DOS path semantics (backslashes, drive letters, case-insensitive components) - SCREEN 13 default foreground corrected to 15 (QB.EXE/DOSBox oracle; 255 is black in the BIOS palette, so text printed black-on-black) --- Documentation/agent-integration/index.org | 28 +- Documentation/language/index.org | 47 ++- .../java/eu/svjatoslav/crtbasic/Main.java | 3 +- .../java/eu/svjatoslav/crtbasic/ast/Ast.java | 32 ++- .../crtbasic/frontend/SwingFrontend.java | 270 +++++++++++++++++- .../crtbasic/interp/Interpreter.java | 250 +++++++++++++++- .../crtbasic/machine/MachineMemory.java | 66 +++++ .../crtbasic/machine/QbextMouseTsr.java | 102 +++++++ .../eu/svjatoslav/crtbasic/parser/Parser.java | 56 +++- .../svjatoslav/crtbasic/video/ScreenMode.java | 19 +- .../crtbasic/video/TextConsole.java | 9 +- .../svjatoslav/crtbasic/video/VgaDevice.java | 5 + .../crtbasic/drivers/GameDriver.java | 65 ++++- .../crtbasic/interp/FileOutputTest.java | 75 +++++ .../interp/MouseDriverEmulationTest.java | 188 ++++++++++++ .../crtbasic/video/VgaDeviceTest.java | 12 +- 16 files changed, 1157 insertions(+), 70 deletions(-) create mode 100644 src/main/java/eu/svjatoslav/crtbasic/machine/MachineMemory.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java create mode 100644 src/test/java/eu/svjatoslav/crtbasic/interp/FileOutputTest.java create mode 100644 src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java diff --git a/Documentation/agent-integration/index.org b/Documentation/agent-integration/index.org index f8ae407..71430a2 100644 --- a/Documentation/agent-integration/index.org +++ b/Documentation/agent-integration/index.org @@ -28,14 +28,27 @@ piece of the runtime is therefore observable and scriptable: | Bound endless loops | =--steps=N= statement budget | | See the screen | =--screenshot=out.png= (visual page as PNG) | | Compare pixels exactly | =--raw=out.raw= (raw indexed video memory) | -| Press keys programmatically | =GameDriver= scripted keystrokes | -| Read program state | =--vars=a%,score= prints variables after the run | -| Count work done | "Statements executed" report | +|| Press keys programmatically | =GameDriver= scripted keystrokes | +|| Move/click the mouse programmatically | =GameDriver --mouse= scripted deltas and button presses | +|| Read program state | =--vars=a%,score= prints variables after the run | +|| Count work done | "Statements executed" report | The interactive window and the headless path share the same =VgaDevice= and =KeyboardQueue=, so a program cannot tell which one it runs under. +In the interactive window the mouse uses a grab model, DOSBox-style: +the program sees no mouse input until you click the window, which +captures the pointer (cursor hidden, movement reported as deltas). +=Ctrl+F10= releases the grab. The grab never warps the pointer — +on Wayland, XTest injection (=Robot=, xdotool) goes through a +RemoteDesktop permission popup, and =Robot.mouseMove= hangs on some X +servers. Instead, capturing covers the whole desktop (all screens) +with a transparent, always-on-top overlay window that receives every +mouse event, so the pointer effectively cannot leave the program. +Without a compositor (translucency unsupported) the grab degrades to +in-window tracking. + * Command line :PROPERTIES: :CUSTOM_ID: command-line @@ -80,6 +93,14 @@ The key script delivers one input event per =--interval= milliseconds | =~= | Pause: no key, just let time pass | | anything else | The typed character itself | +A =--mouse= script feeds the emulated mouse TSR: space-separated +tokens, one event per interval, interleaved with the key script: + +| Token | Meaning | +|---------+---------| +| =dx,dy= | movement delta in video-mode pixels (=-10,0=, =0,5=) | +| =b0= =b1= =b2= | left / right / middle button press | + After the script is exhausted the program keeps running until it ends or the statement budget (default 8M) is spent. The named =--vars= are then printed from the global scope — so an agent can verify "after @@ -115,6 +136,7 @@ interpreter.setBlockOnInput(false); // headless: input never blocks interpreter.setBaseDirectory(path.getParent()); // OPEN resolves here keys.pushChar(' '); // inject input any time +interpreter.machine().mouse().pushMove(5, 0); // inject mouse (qbext TSR) interpreter.run(program); vga.dumpPng(Path.of("out.png")); // screenshot diff --git a/Documentation/language/index.org b/Documentation/language/index.org index fc85de7..fe9f975 100644 --- a/Documentation/language/index.org +++ b/Documentation/language/index.org @@ -116,7 +116,7 @@ DIM names$(20) AS STRING ' AS type form | Statement | Notes | |-----------+-------| -| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count; default foreground becomes the mode's highest attribute (1/3/15/255), background black | +| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count; default foreground becomes the mode's highest attribute (1/3/15), except SCREEN 13 where real QBasic defaults to 15; background black | | =PSET (x, y)[, color]= | One pixel; default color = current text foreground | | =POINT(x, y)= | Function: palette index of a pixel | | =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original | @@ -133,7 +133,7 @@ DIM names$(20) AS STRING ' AS type form | Statement | Notes | |-----------+-------| | =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline | -| =LOCATE row, col= | 1-based cursor position | +| =LOCATE row[, col]= | 1-based cursor position; omitted col keeps the current column | | =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 | @@ -196,7 +196,9 @@ 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 | +| =OPEN path FOR INPUT AS #n= | Text files; DOS paths resolve relative to the =.bas= file (or the =CHDIR= state): backslash separators and case-insensitive components like the DOS filesystem | +| =OPEN path FOR OUTPUT/APPEND AS #n=, =PRINT #n, ...= | Writing; OUTPUT truncates, APPEND adds at end; =PRINT #= uses console formatting with CRLF line endings | +| =CHDIR path$=, =MKDIR path$= | Current-directory state and directory creation; =MKDIR= on an existing directory raises *Path/file access error* like real QBasic | | =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= | | =INPUT [;]["prompt"{;|,}] var, …= | Console line input: prompt echoed (="; "= adds =? =), typed line echo with Backspace editing, items comma-delimited like =INPUT #= | | =LINE INPUT #n, var$= | Whole line | @@ -208,6 +210,8 @@ END FUNCTION | =BEEP= | Desugars to =SOUND 800, 4.55= (the classic ~1/4 s beep) | | =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* | | =OUT port, value= | VGA DAC ports only; others ignored | +| =DEF SEG = segment= | Selects the segment =PEEK= / =POKE= operate on; bare =DEF SEG= resets to 0 | +| =POKE addr, value= | Writes the low byte of value at DEF SEG:addr in the emulated machine RAM | * Expressions and operators :PROPERTIES: @@ -253,7 +257,9 @@ Semantics worth knowing: | =POINT(x, y)= | palette index of a pixel | | =POS(0)=, =CSRLIN= | cursor column / row (1-based) | | =CHR$(n)=, =ASC(s$)= | character ↔ code | -| =STR$(n)=, =VAL(s$)= | number ↔ string (=VAL= of junk is 0) | +| =STR$(n)=, =VAL(s$)= | number ↔ string; =VAL= parses the longest numeric prefix (junk tail ignored, junk start is 0) and understands =&H…= / =&O…= with the literal's signed semantics (=VAL("&HFFFD")= is -3) | +| =HEX$(n)= | uppercase hex, no prefix; INTEGER-range negatives print as 16-bit two's complement (=-3= becomes ="FFFD") | +| =PEEK(addr)= | byte at DEF SEG:addr in the emulated machine RAM | | =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) | | =UCASE$=, =LCASE$= | case conversion | | =LTRIM$=, =RTRIM$= | strip spaces | @@ -306,6 +312,39 @@ In headless mode input never blocks: both functions simply drain whatever a driver pushed into the keyboard queue and return =""= when it is empty. +* Machine memory and the emulated mouse driver +:PROPERTIES: +:CUSTOM_ID: machine-memory +:END: + +=DEF SEG=, =PEEK= and =POKE= operate on an emulated 1 MiB real-mode +address space (physical address = segment * 16 + offset, wrapping at +20 bits). Most of it is plain RAM; one region is live. + +CRT Basic behaves as if the qbext mouse TSR (qbasicapps "Mouse +driver") is always loaded: the IVT slot of INT 79h holds a far pointer +to an 8-byte data table, and mouse events from the window frontend (or +a headless driver) accumulate into it. Programs that speak the TSR +protocol — discover the pointer with =DEF SEG = 0= and =PEEK(&H79 * 4 ++ n)=, verify the magic word 1983, then read-and-reset the fields — +work unmodified. + +Table layout (little-endian words): + +| Offset | Contents | +|--------+----------| +| =0x00= | magic number 1983 | +| =0x02= | horizontal movement, accumulated | +| =0x04= | vertical movement, accumulated | +| =0x06= | button status, latched: bit 0 left, 1 right, 2 middle | + +Like the real driver, movement deltas wrap at 16 bits (read them as +signed, e.g. via =VAL("&H" + HEX$(...))=), button bits stay latched +until the consumer POKEs the field back to 0, and a field reading 0 +means "no new activity". In the window frontend, mouse motion is +converted to video-mode pixels before being accumulated, so movement +feels 1:1 at any window size. + * Error behavior :PROPERTIES: :CUSTOM_ID: errors diff --git a/src/main/java/eu/svjatoslav/crtbasic/Main.java b/src/main/java/eu/svjatoslav/crtbasic/Main.java index a97c836..155604a 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/Main.java +++ b/src/main/java/eu/svjatoslav/crtbasic/Main.java @@ -141,7 +141,8 @@ public final class Main { final VgaDevice vga, final KeyboardQueue keys, final Path programPath) { SwingUtilities.invokeLater(() -> - new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName(), programPath).show()); + new SwingFrontend(vga, keys, interpreter.machine().mouse(), + "CRT Basic - " + programPath.getFileName(), programPath).show()); final Thread programThread = new Thread(() -> { try { interpreter.run(program); diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java index 1d6eba9..f3334d2 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java +++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java @@ -25,16 +25,32 @@ public final class Ast { public record PcopyStmt(Expr sourcePage, Expr targetPage, int line) implements Stmt { } - /** {@code LOCATE row, col} */ + /** {@code LOCATE row[, col]} — a null col keeps the current column. */ public record LocateStmt(Expr row, Expr col, int line) implements Stmt { } + /** + * {@code DEF SEG = segment} — sets the segment {@code PEEK}/{@code POKE} + * operate on. The bare form ({@code DEF SEG}) resets to the default + * segment (0 in the emulator); a null segment means the bare form. + */ + public record DefSegStmt(Expr segment, int line) implements Stmt { + } + + /** + * {@code POKE address, value} — writes the low byte of value to + * emulated machine memory at DEF SEG:address. + */ + public record PokeStmt(Expr address, Expr value, int line) implements Stmt { + } + /** * {@code PRINT item;item,item} — items are expressions plus the * separator that follows them (';' compact, ',' print zone). * {@code newline} is false when the statement ends with ; or ,. + * {@code channel} is non-null for {@code PRINT #n, ...} (file output). */ - public record PrintStmt(List items, boolean newline, int line) implements Stmt { + public record PrintStmt(Expr channel, List items, boolean newline, int line) implements Stmt { public record Item(Expr expr, char separatorAfter) { } } @@ -50,8 +66,16 @@ public final class Ast { public record RandomizeStmt(Expr seed, int line) implements Stmt { } - /** {@code OPEN path FOR INPUT AS #channel} (only INPUT mode is modeled). */ - public record OpenStmt(Expr path, Expr channel, int line) implements Stmt { + /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */ + public record OpenStmt(Expr path, String mode, Expr channel, int line) implements Stmt { + } + + /** {@code CHDIR path$} — changes the current directory. */ + public record ChdirStmt(Expr path, int line) implements Stmt { + } + + /** {@code MKDIR path$} — creates a directory. */ + public record MkdirStmt(Expr path, int line) implements Stmt { } /** {@code CLOSE [#channel]} — a null channel closes all files. */ diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java index 0b9fea1..682fa16 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java +++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java @@ -3,12 +3,23 @@ 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.machine.QbextMouseTsr; import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.awt.Color; +import java.awt.Cursor; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Point; +import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.IOException; @@ -30,8 +41,8 @@ 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}. * - *

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

+ *

Hotkeys intercepted here, never reaching the program (they produce no + * key code a classic program can observe):

*
    *
  • {@code F12} — screenshot: the visual page as * {@code - Screenshot .png} next to the .bas file.
  • @@ -40,14 +51,25 @@ import javax.swing.WindowConstants; * the recording stops (the intermediate .crtrec and encode log are * deleted on success, kept on failure). Ongoing recording is shown * in the window title. + *
  • {@code Ctrl+F10} — releases the captured mouse (DOSBox-style).
  • *
* + *

Mouse grab: mouse input reaches the program only after the user clicks + * the window. Capturing covers the whole desktop with a transparent, + * always-on-top overlay window that receives every mouse event — the cursor + * is hidden and effectively cannot leave the program. {@code Ctrl+F10} + * releases the grab; the capturing click is consumed. The pointer is never + * warped: on Wayland, XTest injection ({@code Robot}, xdotool) goes through + * a RemoteDesktop permission popup, and {@code Robot.mouseMove} hangs on + * some X servers (Xvfb). Without a compositor (translucency unsupported), + * the grab degrades to in-window tracking.

+ * *

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 + * it just renders and forwards keys and mouse. Headless drivers use the same * VgaDevice and KeyboardQueue without this class.

*/ public final class SwingFrontend { @@ -56,11 +78,42 @@ public final class SwingFrontend { private static final int REPAINT_MS = 33; private final VgaDevice vga; + private final QbextMouseTsr mouse; + private final KeyboardQueue keys; private final JFrame frame; private final String baseTitle; private final Path programDirectory; private final String programBaseName; private JPanel canvas; + /** + * Mouse grab state: the program sees mouse input only while captured + * (user clicked the window). While captured the cursor is hidden and + * movement feeds the emulated TSR as video-mode-pixel deltas measured + * from the previous event ({@link #lastWindowX}/{@link #lastWindowY}, + * in window coordinates; -1 = no anchor yet). Sub-pixel scaling residue + * accumulates in {@link #pendingDeltaX}/{@link #pendingDeltaY} so slow + * drags are not rounded away. No pointer warping: on Wayland, Robot + * rides the RemoteDesktop portal (permission popup per grab), and + * Robot.mouseMove hangs on some X servers (Xvfb). + */ + private boolean mouseCaptured; + /** + * While captured: an undecorated, fully transparent, always-on-top + * window spanning all screens. It receives every mouse event anywhere + * on the desktop, so the pointer effectively cannot leave the program + * — a true grab with no pointer warping (Robot.mouseMove is unusable: + * Wayland routes XTest injection through a RemoteDesktop permission + * popup, and it deadlocks on some X servers). Null when translucent + * windows are unsupported (no compositor) — the grab then degrades to + * plain in-window tracking. + */ + private JFrame grabOverlay; + /** Fractional mode-pixel deltas carried between events (scaling residue). */ + private double pendingDeltaX; + private double pendingDeltaY; + /** Last mouse position in window pixels; negative = unknown (no delta yet). */ + private double lastWindowX = -1; + private double lastWindowY = -1; private ScreenRecorder recorder; private Timer titleTimer; @@ -78,8 +131,11 @@ public final class SwingFrontend { @SuppressWarnings("serial") public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys, + final QbextMouseTsr mouseTsr, final String title, final Path programPath) { vga = vgaDevice; + mouse = mouseTsr; + this.keys = keys; baseTitle = title; final Path absolute = programPath.toAbsolutePath(); programDirectory = absolute.getParent() == null @@ -104,17 +160,47 @@ 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(); - } + handleKey(e); + } + }); + + // Mouse events feed the emulated qbext TSR, so programs that read + // the driver through DEF SEG / PEEK / POKE see a live mouse — + // but only while the grab is active (click the window to capture, + // Ctrl+F10 to release). + final MouseAdapter mouseHandler = new MouseAdapter() { + @Override + public void mouseMoved(final MouseEvent e) { + trackMovement(e); + } + + @Override + public void mouseDragged(final MouseEvent e) { + trackMovement(e); + } + + @Override + public void mouseExited(final MouseEvent e) { + lastWindowX = -1; // next entry starts a fresh delta + } + + @Override + public void mousePressed(final MouseEvent e) { + if (!mouseCaptured) { + captureMouse(); // the capture click is consumed return; } - keys.push(e.getKeyCode(), - e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar()); + forwardButton(e); + } + }; + canvas.addMouseListener(mouseHandler); + canvas.addMouseMotionListener(mouseHandler); + canvas.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(final ComponentEvent e) { + lastWindowX = -1; // coordinate mapping changed; drop stale anchor + pendingDeltaX = 0; + pendingDeltaY = 0; } }); @@ -134,11 +220,167 @@ public final class SwingFrontend { new Timer(REPAINT_MS, e -> canvas.repaint()).start(); } + /** + * Key handling shared by the canvas and the mouse-grab overlay: F12 / + * Shift+F12 screenshot + recording, Ctrl+F10 releases the grab, all + * other keys go to the program. + */ + private void handleKey(final KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_F12) { + // Hotkey: swallowed here, never pushed into the queue. + if (e.isShiftDown()) { + toggleRecording(); + } else { + screenshot(); + } + return; + } + if (e.getKeyCode() == KeyEvent.VK_F10 && e.isControlDown()) { + // Hotkey: release the mouse grab (swallowed like F12). + if (mouseCaptured) { + releaseMouse(); + } + return; + } + keys.push(e.getKeyCode(), + e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar()); + } + /** Shows the window. Must be called on or before the program starts. */ public void show() { frame.setVisible(true); } + /** + * Pushes a mouse movement event into the emulated TSR as a delta in + * video-mode pixels. Only events arriving while the grab is active + * count. The canvas paints the framebuffer stretched to its full size, + * so window pixels convert to mode pixels by the plain ratio; the + * sub-pixel residue accumulates so slow drags are not rounded away. + */ + private void trackMovement(final MouseEvent e) { + if (!mouseCaptured) { + return; + } + if (lastWindowX >= 0) { + pendingDeltaX += (e.getX() - lastWindowX) * vga.mode().pixelWidth() + / Math.max(1, canvas.getWidth()); + pendingDeltaY += (e.getY() - lastWindowY) * vga.mode().pixelHeight() + / Math.max(1, canvas.getHeight()); + final int modeDx = (int) pendingDeltaX; + final int modeDy = (int) pendingDeltaY; + if (modeDx != 0 || modeDy != 0) { + mouse.pushMove(modeDx, modeDy); + pendingDeltaX -= modeDx; + pendingDeltaY -= modeDy; + } + } + lastWindowX = e.getX(); + lastWindowY = e.getY(); + } + + /** + * Captures the mouse: covers the whole desktop with a transparent + * input-grabbing overlay (hidden cursor), so every movement and click + * feeds the program until Ctrl+F10. Invoked by the first click on the + * canvas; that click is not forwarded. Without compositor support the + * grab degrades to in-window tracking (cursor leaves the window freely). + */ + private void captureMouse() { + mouseCaptured = true; + pendingDeltaX = 0; + pendingDeltaY = 0; + lastWindowX = -1; + final Cursor blank = canvas.getToolkit().createCustomCursor( + new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB), + new Point(0, 0), "blank"); + canvas.setCursor(blank); + try { + grabOverlay = createGrabOverlay(blank); + } catch (final RuntimeException | java.awt.AWTException e) { + grabOverlay = null; // no translucency (e.g. no compositor): plain grab + } + frame.setTitle(baseTitle + " — mouse captured, Ctrl+F10 to release"); + } + + /** + * Builds the grab overlay: an undecorated, fully transparent, + * always-on-top window spanning the union of all screens, with the + * same mouse/key handling as the canvas. + */ + private JFrame createGrabOverlay(final Cursor blank) throws java.awt.AWTException { + Rectangle bounds = null; + for (final GraphicsDevice device : GraphicsEnvironment.getLocalGraphicsEnvironment() + .getScreenDevices()) { + final Rectangle b = device.getDefaultConfiguration().getBounds(); + bounds = bounds == null ? b : bounds.union(b); + } + final JFrame overlay = new JFrame(); + overlay.setUndecorated(true); + overlay.setAlwaysOnTop(true); + overlay.setBounds(bounds); + final JPanel glass = new JPanel(); + glass.setOpaque(false); + overlay.setContentPane(glass); + // Throws UnsupportedOperationException when per-pixel translucency + // is unavailable (no compositor) — the caller falls back. + overlay.setBackground(new Color(0, 0, 0, 0)); + overlay.setCursor(blank); + + final MouseAdapter overlayMouse = new MouseAdapter() { + @Override + public void mouseMoved(final MouseEvent e) { + trackMovement(e); + } + + @Override + public void mouseDragged(final MouseEvent e) { + trackMovement(e); + } + + @Override + public void mousePressed(final MouseEvent e) { + forwardButton(e); + } + }; + glass.addMouseListener(overlayMouse); + glass.addMouseMotionListener(overlayMouse); + glass.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(final KeyEvent e) { + handleKey(e); + } + }); + glass.setFocusable(true); + overlay.setVisible(true); + glass.requestFocusInWindow(); + return overlay; + } + + /** Releases the grab: drops the overlay, restores the cursor and title. */ + private void releaseMouse() { + mouseCaptured = false; + if (grabOverlay != null) { + grabOverlay.dispose(); + grabOverlay = null; + } + canvas.setCursor(java.awt.Cursor.getDefaultCursor()); + frame.setTitle(baseTitle); + } + + /** Forwards a captured click to the emulated TSR (INT 33h bit order). */ + private void forwardButton(final MouseEvent e) { + final int bit = switch (e.getButton()) { + case MouseEvent.BUTTON1 -> 0; // left + case MouseEvent.BUTTON3 -> 1; // right + case MouseEvent.BUTTON2 -> 2; // middle + default -> -1; + }; + if (bit >= 0) { + mouse.pushButton(bit); + } + } + /** * Window content size for a video mode. Integer scaling (3x for * 320-wide modes, 2x otherwise) keeps pixels crisp; 640-wide @@ -250,6 +492,10 @@ public final class SwingFrontend { */ private void closeRequested() { closing = true; + if (grabOverlay != null) { + grabOverlay.dispose(); + grabOverlay = null; + } if (recorder != null) { stopRecording("Recording stopped (window closed)"); } diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 86e7f75..de3cdd8 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -4,6 +4,7 @@ import eu.svjatoslav.crtbasic.Cp437; import eu.svjatoslav.crtbasic.ast.Ast; import eu.svjatoslav.crtbasic.audio.SoundQueue; import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.machine.MachineMemory; import eu.svjatoslav.crtbasic.video.CapturedRegion; import eu.svjatoslav.crtbasic.video.ScreenMode; import eu.svjatoslav.crtbasic.video.VgaDevice; @@ -145,6 +146,13 @@ public final class Interpreter { private final VgaDevice vga; private final KeyboardQueue keys; private final SoundQueue sound; + /** + * Emulated 1 MiB machine RAM for DEF SEG / PEEK / POKE, with the + * qbext mouse TSR always "loaded" (INT 79h IVT slot -> live data table). + */ + private final MachineMemory machine = new MachineMemory(); + /** Current DEF SEG segment for PEEK/POKE (0 = default). */ + private int defSeg; /** 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. */ @@ -184,11 +192,16 @@ public final class Interpreter { * one-line lookahead so EOF(n) can answer without consuming input. */ private final Map fileChannels = new HashMap<>(); + /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */ + private final Map fileOutputs = new HashMap<>(); + private final Map fileOutputColumns = new HashMap<>(); private final Map fileLookahead = 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(""); + /** CHDIR state; null = resolve relative to {@link #baseDirectory}. */ + private java.nio.file.Path currentDirectory; /** * ON ERROR GOTO target (lowercased label), null when trapping is off. * Trapping is module-global. @@ -274,6 +287,11 @@ public final class Interpreter { return vga; } + /** Emulated machine memory (PEEK/POKE space); the mouse TSR lives here. */ + public MachineMemory machine() { + return machine; + } + public void run(final List program) { // SUB definitions register and do not execute inline. // DEFxxx statements are collected up front: their effect is @@ -625,7 +643,7 @@ public final class Interpreter { case Ast.PcopyStmt s -> vga.pcopy(toInt(eval(s.sourcePage()), s.line()), toInt(eval(s.targetPage()), s.line())); case Ast.LocateStmt s -> vga.locate(toInt(eval(s.row()), s.line()), - toInt(eval(s.col()), s.line())); + s.col() == null ? vga.textColumn() : toInt(eval(s.col()), s.line())); case Ast.ClsStmt s -> vga.cls(); case Ast.RandomizeStmt s -> { // RANDOMIZE n reseeds like RND(-n); no seed = RANDOMIZE TIMER. @@ -636,11 +654,45 @@ public final class Interpreter { case Ast.OpenStmt s -> { final int channel = toInt(eval(s.channel()), s.line()); final String path = str(eval(s.path()), s.line()); + if ("OUTPUT".equals(s.mode()) || "APPEND".equals(s.mode())) { + try { + // OUTPUT truncates/creates; APPEND creates and writes + // at end of file. + fileOutputs.put(channel, java.nio.file.Files.newBufferedWriter( + resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437"), + "APPEND".equals(s.mode()) + ? new java.nio.file.OpenOption[]{ + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND} + : new java.nio.file.OpenOption[0])); + fileOutputColumns.put(channel, 1); + } catch (final java.io.IOException e) { + throw error("Cannot create file: " + path, s.line()); + } + } else { + try { + fileChannels.put(channel, java.nio.file.Files.newBufferedReader( + resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437"))); + } catch (final java.io.IOException e) { + throw error("File not found: " + path, s.line()); + } + } + } + case Ast.ChdirStmt s -> { + final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line())); + if (!java.nio.file.Files.isDirectory(dir)) { + throw error("Path not found", s.line()); + } + currentDirectory = dir.normalize(); + } + case Ast.MkdirStmt s -> { + final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line())); try { - fileChannels.put(channel, java.nio.file.Files.newBufferedReader( - resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437"))); + java.nio.file.Files.createDirectory(dir); + } catch (final java.nio.file.FileAlreadyExistsException e) { + throw error("Path/file access error", s.line()); } catch (final java.io.IOException e) { - throw error("File not found: " + path, s.line()); + throw error("Path not found", s.line()); } } case Ast.CloseStmt s -> { @@ -648,8 +700,13 @@ public final class Interpreter { for (final int channel : List.copyOf(fileChannels.keySet())) { closeChannel(channel, s.line()); } + for (final int channel : List.copyOf(fileOutputs.keySet())) { + closeOutputChannel(channel, s.line()); + } } else { - closeChannel(toInt(eval(s.channel()), s.line()), s.line()); + final int channel = toInt(eval(s.channel()), s.line()); + closeChannel(channel, s.line()); + closeOutputChannel(channel, s.line()); } } case Ast.LineInputStmt s -> { @@ -725,6 +782,14 @@ public final class Interpreter { case Ast.SleepStmt s -> execSleep(s); case Ast.OutStmt s -> vga.out(toInt(eval(s.port()), s.line()), toInt(eval(s.value()), s.line())); + case Ast.DefSegStmt s -> { + // DEF SEG = segment; the bare form resets to the default segment 0. + defSeg = s.segment() == null ? 0 + : toInt(eval(s.segment()), s.line()) & 0xFFFF; + } + case Ast.PokeStmt s -> machine.poke(defSeg, + toInt(eval(s.address()), s.line()), + toInt(eval(s.value()), s.line())); case Ast.PaintStmt s -> { final int fill = s.fill() == null ? vga.textForeground() : toInt(eval(s.fill()), s.line()); @@ -922,6 +987,10 @@ public final class Interpreter { } private void execPrint(final Ast.PrintStmt s) { + if (s.channel() != null) { + execPrintFile(s); + return; + } for (final Ast.PrintStmt.Item item : s.items()) { final Object value = eval(item.expr()); vga.print(value instanceof String text ? text @@ -1462,11 +1531,24 @@ public final class Interpreter { return s.isEmpty() ? 0.0 : (double) Cp437.byteOf(s.charAt(0)); } case "VAL" -> { - try { - return single(Double.parseDouble(str(singleArgValue(c), c.line()).trim())); - } catch (final NumberFormatException e) { - return 0.0; + return single(basicVal(str(singleArgValue(c), c.line()))); + } + case "HEX$" -> { + // QBasic HEX$: rounded to an integer first; INTEGER-range + // negatives print as 16-bit two's complement (HEX$(-3) = + // "FFFD"), LONG-range as 32-bit; no prefix, no leading zeros. + final long v = (long) Math.rint(num(singleArgValue(c), c.line())); + if (v >= -32768 && v <= 32767) { + return Integer.toHexString((int) v & 0xFFFF).toUpperCase(java.util.Locale.ROOT); + } + if (v >= Integer.MIN_VALUE && v <= Integer.MAX_VALUE) { + return Integer.toHexString((int) v).toUpperCase(java.util.Locale.ROOT); } + return Long.toHexString(v).toUpperCase(java.util.Locale.ROOT); + } + case "PEEK" -> { + // PEEK(offset): the byte at DEF SEG:offset in emulated machine RAM. + return single(machine.peek(defSeg, toInt(singleArgValue(c), c.line()))); } case "LEFT$" -> { requireArgs(name, args, 2, c.line()); @@ -1704,6 +1786,85 @@ public final class Interpreter { return (float) value; } + /** + * QBasic VAL: leading whitespace is skipped; {@code &H…} hex and + * {@code &O…} octal strings follow the literal's signed semantics + * (up to 4 hex digits = int16, so VAL("&HFFFD") is -3); otherwise the + * longest valid numeric prefix is parsed and a junk tail is ignored. + * A string with no number at the start converts to 0. + */ + private static double basicVal(final String text) { + final String s = text.stripLeading(); + if (s.length() >= 2 && s.charAt(0) == '&' + && (s.charAt(1) == 'H' || s.charAt(1) == 'h' + || s.charAt(1) == 'O' || s.charAt(1) == 'o')) { + final boolean hex = s.charAt(1) == 'H' || s.charAt(1) == 'h'; + final int radix = hex ? 16 : 8; + int end = 2; + while (end < s.length() && Character.digit(s.charAt(end), radix) >= 0) { + end++; + } + if (end == 2) { + return 0; + } + final String digits = s.substring(2, end); + try { + final long value = Long.parseLong(digits, radix); + if (hex && digits.length() <= 4) { + final int v = (int) (value & 0xFFFF); + return v >= 0x8000 ? v - 0x10000 : v; + } + if (value <= 0xFFFFFFFFL) { + final long v = value & 0xFFFFFFFFL; + return v >= 0x80000000L ? v - 0x100000000L : v; + } + return value; + } catch (final NumberFormatException e) { + return 0; + } + } + // Decimal: longest valid prefix [sign] digits [. digits] [eEdD [sign] digits]. + int end = 0; + if (end < s.length() && (s.charAt(end) == '+' || s.charAt(end) == '-')) { + end++; + } + final int digitsStart = end; + while (end < s.length() && Character.isDigit(s.charAt(end))) { + end++; + } + boolean sawDigit = end > digitsStart; + if (end < s.length() && s.charAt(end) == '.') { + end++; + final int fractionStart = end; + while (end < s.length() && Character.isDigit(s.charAt(end))) { + end++; + } + sawDigit = sawDigit || end > fractionStart; + } + if (!sawDigit) { + return 0; + } + if (end < s.length() && "eEdD".indexOf(s.charAt(end)) >= 0) { + final int save = end; + end++; + if (end < s.length() && (s.charAt(end) == '+' || s.charAt(end) == '-')) { + end++; + } + if (end < s.length() && Character.isDigit(s.charAt(end))) { + while (end < s.length() && Character.isDigit(s.charAt(end))) { + end++; + } + } else { + end = save; // was not an exponent after all + } + } + try { + return Double.parseDouble(s.substring(0, end).replace('D', 'E').replace('d', 'e')); + } catch (final NumberFormatException e) { + return 0; + } + } + /** * Debug aid: when the {@code crtbasic.linelog} * system property names a file, every LINE call is appended as @@ -2039,12 +2200,19 @@ public final class Interpreter { * directory listing (e.g. {@code IMG/pilv.i01} finds {@code img/}). */ private java.nio.file.Path resolveOpenPath(final String path) { - final String normalized = path.replace('\\', '/'); - final java.nio.file.Path direct = baseDirectory.resolve(normalized); + String normalized = path.replace('\\', '/'); + if (normalized.matches("^[A-Za-z]:.*")) { + normalized = normalized.substring(2); // drop DOS drive letter + } + final java.nio.file.Path base = currentDirectory != null ? currentDirectory : baseDirectory; + final boolean absolute = normalized.startsWith("/"); + final java.nio.file.Path direct = absolute + ? java.nio.file.Path.of(normalized) + : base.resolve(normalized); if (java.nio.file.Files.exists(direct)) { return direct; } - java.nio.file.Path current = baseDirectory; + java.nio.file.Path current = absolute ? java.nio.file.Path.of("/") : base; for (final String component : normalized.split("/")) { if (component.isEmpty() || component.equals(".")) { continue; @@ -2104,6 +2272,64 @@ public final class Interpreter { } } + private void closeOutputChannel(final int channel, final int line) { + final java.io.BufferedWriter writer = fileOutputs.remove(channel); + if (writer == null) { + return; + } + fileOutputColumns.remove(channel); + try { + writer.close(); + } catch (final java.io.IOException e) { + throw error("File close error: " + e.getMessage(), line); + } + } + + /** + * {@code PRINT #channel, items}: the same item formatting as console + * PRINT (classic number style, ';' compact, ',' 14-column zones), + * written as DOS text — lines end with CRLF. Zones wrap at column 255 + * (QBasic's default file width). + */ + private void execPrintFile(final Ast.PrintStmt s) { + final int channel = toInt(eval(s.channel()), s.line()); + final java.io.BufferedWriter writer = fileOutputs.get(channel); + if (writer == null) { + throw error("Bad file number", s.line()); + } + int column = fileOutputColumns.get(channel); + try { + for (final Ast.PrintStmt.Item item : s.items()) { + final Object value = eval(item.expr()); + final String text = value instanceof String string ? string + : basicNumber(num(value, s.line()), exprIsDouble(item.expr())); + writer.write(text); + column += text.length(); + if (item.separatorAfter() == ',') { + final int target = ((column - 1) / 14 + 1) * 14 + 1; + if (target > 255) { + writer.write("\r\n"); + column = 1; + } else { + final String padding = " ".repeat(target - column); + writer.write(padding); + column = target; + } + } + } + if (s.newline()) { + writer.write("\r\n"); + column = 1; + } + // Flush eagerly: programs that record while running (flight + // scripts) must not lose data when the JVM is killed. + writer.flush(); + } catch (final java.io.IOException e) { + throw error("File write error: " + e.getMessage(), s.line()); + } + fileOutputColumns.put(channel, column); + } + /** * RND(-n): reseeds the generator from n. The SINGLE bit pattern of * |n| folds into the 24-bit seed as diff --git a/src/main/java/eu/svjatoslav/crtbasic/machine/MachineMemory.java b/src/main/java/eu/svjatoslav/crtbasic/machine/MachineMemory.java new file mode 100644 index 0000000..70090bc --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/machine/MachineMemory.java @@ -0,0 +1,66 @@ +package eu.svjatoslav.crtbasic.machine; + +/** + * Emulated machine memory for {@code DEF SEG} / {@code PEEK} / {@code POKE}: + * the 8086's 1 MiB (20-bit) real-mode address space. Segment:offset pairs + * translate to a physical address ({@code segment * 16 + offset}) that + * wraps at 20 bits, like the original hardware. + * + *

Most of the space is plain RAM backed by a byte array. One region is + * live: the {@link QbextMouseTsr} data table, whose bytes are served by + * the emulated TSR so that mouse events pushed by frontends show up in + * PEEKs and consumer POKEs reset the accumulators. The INT 79h IVT slot + * is pre-populated with the far pointer to that table — CRT Basic behaves + * as if the mouse TSR is always loaded.

+ */ +public final class MachineMemory { + + /** Address space size: 1 MiB (8086, 20-bit addresses). */ + public static final int SIZE = 0x100000; + + private final byte[] ram = new byte[SIZE]; + private final QbextMouseTsr mouseTsr = new QbextMouseTsr(); + + public MachineMemory() { + // IVT slot of INT 79h: far pointer (offset, then segment, + // little-endian words) to the emulated TSR data table. + final int vector = QbextMouseTsr.INTERRUPT * 4; + ram[vector] = (byte) (QbextMouseTsr.OFFSET & 0xFF); + ram[vector + 1] = (byte) ((QbextMouseTsr.OFFSET >> 8) & 0xFF); + ram[vector + 2] = (byte) (QbextMouseTsr.SEGMENT & 0xFF); + ram[vector + 3] = (byte) ((QbextMouseTsr.SEGMENT >> 8) & 0xFF); + } + + /** The emulated mouse driver; frontends push movement/button events here. */ + public QbextMouseTsr mouse() { + return mouseTsr; + } + + /** PEEK: the byte at segment:offset (0..255). */ + public int peek(final int segment, final int offset) { + final int physical = physical(segment, offset); + if (isTable(physical)) { + return mouseTsr.readByte(physical - QbextMouseTsr.PHYSICAL); + } + return ram[physical] & 0xFF; + } + + /** POKE: writes the low 8 bits of value at segment:offset. */ + public void poke(final int segment, final int offset, final int value) { + final int physical = physical(segment, offset); + if (isTable(physical)) { + mouseTsr.writeByte(physical - QbextMouseTsr.PHYSICAL, value); + return; + } + ram[physical] = (byte) (value & 0xFF); + } + + private static boolean isTable(final int physical) { + return physical >= QbextMouseTsr.PHYSICAL + && physical < QbextMouseTsr.PHYSICAL + QbextMouseTsr.TABLE_SIZE; + } + + private static int physical(final int segment, final int offset) { + return (((segment & 0xFFFF) << 4) + (offset & 0xFFFF)) & (SIZE - 1); + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java b/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java new file mode 100644 index 0000000..4112ce3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java @@ -0,0 +1,102 @@ +package eu.svjatoslav.crtbasic.machine; + +/** + * Emulation of the qbext mouse TSR (qbasicapps/Miscellaneous/Mouse driver): + * the resident assembly program a QBasic program loads before it can use + * the mouse. CRT Basic behaves as if the TSR is always loaded. + * + *

The TSR publishes a small data table and stores a far pointer to it + * in the interrupt vector table slot of INT 79h (the vector is never + * invoked as an interrupt; the slot is just a well-known place for the + * pointer). A program reads the pointer with {@code DEF SEG = 0} + + * {@code PEEK(&H79 * 4 + n)}, switches {@code DEF SEG} to the table + * segment and verifies the magic word 1983 at offset 0.

+ * + *

Table layout (offsets in bytes, little-endian words):

+ *
+ *   0x00  magic number 1983
+ *   0x02  horizontal movement, accumulated
+ *   0x04  vertical movement, accumulated
+ *   0x06  button status, latched (bit 0 left, 1 right, 2 middle)
+ * 
+ * + *

Transfer protocol, exactly like the real driver: the TSR side + * accumulates (movement deltas are added in, button bits are + * ORed in and stay latched), so the consumer must read-and-reset each + * field with POKE. A field that reads 0 means "no new activity".

+ * + *

Frontends (Swing window) and headless drivers (tests, GameDriver) + * feed events through {@link #pushMove(int, int)} and + * {@link #pushButton(int)}. All state is synchronized: the program + * thread PEEKs/POKEs while the EDT pushes events.

+ */ +public final class QbextMouseTsr { + + /** Interrupt whose IVT slot carries the far pointer to the table. */ + public static final int INTERRUPT = 0x79; + /** Segment the emulated table lives at (arbitrary, free low memory). */ + public static final int SEGMENT = 0x0790; + /** Offset of the table within the segment. */ + public static final int OFFSET = 0x0000; + /** Physical address of the table (SEGMENT * 16 + OFFSET). */ + public static final int PHYSICAL = SEGMENT * 16 + OFFSET; + /** Table size in bytes. */ + public static final int TABLE_SIZE = 8; + /** Magic word at offset 0: "driver is loaded". */ + public static final int MAGIC = 1983; + + private static final int FIELD_MAGIC = 0; + private static final int FIELD_X = 1; + private static final int FIELD_Y = 2; + private static final int FIELD_BUTTONS = 3; + + /** The four 16-bit table fields (values kept in 0..0xFFFF). */ + private final int[] fields = {MAGIC, 0, 0, 0}; + + /** + * Accumulates a mouse movement delta (in video-mode pixels). Values + * wrap at 16 bits like the real TSR's 16-bit ADD, so a negative + * delta reads back as two's complement (consumer interprets it as + * signed, e.g. via {@code VAL("&H" + HEX$(...))}). + */ + public synchronized void pushMove(final int dx, final int dy) { + fields[FIELD_X] = (fields[FIELD_X] + dx) & 0xFFFF; + fields[FIELD_Y] = (fields[FIELD_Y] + dy) & 0xFFFF; + } + + /** + * Latches a button press: bit 0 = left, 1 = right, 2 = middle (INT 33h + * function 03h order). Releases are not reported — like the real + * driver, the field stays set until the consumer clears it. + */ + public synchronized void pushButton(final int bit) { + if (bit >= 0 && bit <= 15) { + fields[FIELD_BUTTONS] |= 1 << bit; + } + } + + /** Reads one byte of the data table (offset 0..7). */ + public synchronized int readByte(final int tableOffset) { + final int field = fields[fieldAt(tableOffset)]; + return (tableOffset & 1) == 0 ? field & 0xFF : (field >> 8) & 0xFF; + } + + /** Writes one byte of the data table (offset 0..7) — the read-and-reset POKE. */ + public synchronized void writeByte(final int tableOffset, final int value) { + final int index = fieldAt(tableOffset); + if ((tableOffset & 1) == 0) { + fields[index] = (fields[index] & 0xFF00) | (value & 0xFF); + } else { + fields[index] = (fields[index] & 0x00FF) | ((value & 0xFF) << 8); + } + } + + private static int fieldAt(final int tableOffset) { + return switch (tableOffset >> 1) { + case 0 -> FIELD_MAGIC; + case 1 -> FIELD_X; + case 2 -> FIELD_Y; + default -> FIELD_BUTTONS; + }; + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java index 631de7f..5b357ae 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java +++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java @@ -82,9 +82,19 @@ public final class Parser { case "BEEP" -> beepStatement(); case "SLEEP" -> sleepStatement(); case "OUT" -> outStatement(); + case "DEF" -> defSegStatement(); + case "POKE" -> pokeStatement(); case "RANDOMIZE" -> randomizeStatement(); case "OPEN" -> openStatement(); case "CLOSE" -> closeStatement(); + case "CHDIR" -> { + next(); + yield new Ast.ChdirStmt(expression(), token.line()); + } + case "MKDIR" -> { + next(); + yield new Ast.MkdirStmt(expression(), token.line()); + } case "ON" -> onErrorStatement(); case "RESUME" -> resumeStatement(); case "DATA" -> dataStatement(); @@ -423,6 +433,27 @@ public final class Parser { return new Ast.OutStmt(port, value, keyword.line()); } + /** {@code DEF SEG = segment} — the bare form (no = expr) resets to the default segment. */ + private Ast.Stmt defSegStatement() { + final Token keyword = next(); // DEF + expectText("SEG"); + Ast.Expr segment = null; + if (peek().isText("=")) { + next(); // = + segment = expression(); + } + return new Ast.DefSegStmt(segment, keyword.line()); + } + + /** {@code POKE address, value} — writes one byte to emulated machine memory. */ + private Ast.Stmt pokeStatement() { + final Token keyword = next(); // POKE + final Ast.Expr address = expression(); + expectOp(","); + final Ast.Expr value = expression(); + return new Ast.PokeStmt(address, value, keyword.line()); + } + /** {@code RANDOMIZE [seed]} — no seed means RANDOMIZE TIMER. */ private Ast.Stmt randomizeStatement() { final Token keyword = next(); // RANDOMIZE @@ -439,19 +470,20 @@ public final class Parser { return new Ast.SleepStmt(seconds, keyword.line()); } - /** {@code OPEN path FOR INPUT AS #channel} — only INPUT mode is supported. */ + /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */ private Ast.Stmt openStatement() { final Token keyword = next(); // OPEN final Ast.Expr path = expression(); expectText("FOR"); final Token mode = expect(Token.Type.IDENT); - if (!"INPUT".equalsIgnoreCase(mode.text())) { - throw error("OPEN: only FOR INPUT is supported", mode); + if (!"INPUT".equalsIgnoreCase(mode.text()) && !"OUTPUT".equalsIgnoreCase(mode.text()) + && !"APPEND".equalsIgnoreCase(mode.text())) { + throw error("OPEN: only FOR INPUT, FOR OUTPUT and FOR APPEND are supported", mode); } expectText("AS"); expectOp("#"); final Ast.Expr channel = expression(); - return new Ast.OpenStmt(path, channel, keyword.line()); + return new Ast.OpenStmt(path, mode.text().toUpperCase(), channel, keyword.line()); } /** {@code CLOSE [#channel]}. */ @@ -826,13 +858,23 @@ public final class Parser { private Ast.Stmt locateStatement() { final Token keyword = next(); // LOCATE final Ast.Expr row = expression(); - expectOp(","); - final Ast.Expr col = expression(); + Ast.Expr col = null; + if (peek().isText(",")) { + next(); // , + col = expression(); + } return new Ast.LocateStmt(row, col, keyword.line()); } private Ast.Stmt printStatement() { final Token keyword = next(); // PRINT or ? + Ast.Expr channel = null; + if (peek().isText("#")) { + // PRINT #channel, items — file output. + next(); // # + channel = expression(); + expectOp(","); + } final List items = new ArrayList<>(); boolean newline = true; while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF) @@ -856,7 +898,7 @@ public final class Parser { } } } - return new Ast.PrintStmt(items, newline, keyword.line()); + return new Ast.PrintStmt(channel, items, newline, keyword.line()); } private Ast.Stmt colorStatement() { diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java index afbfb44..654c530 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java @@ -106,14 +106,21 @@ public enum ScreenMode { /** * @return the foreground color active right after * {@code SCREEN}: 7 (light gray) in text mode; in graphics modes the - * mode's highest color number (1 for SCREEN 2/11, 3 for SCREEN 1/10, - * 15 for the 16-color modes, 255 for SCREEN 13). Programs that draw - * or PAINT without a color argument get this, so a wrong default is - * visible immediately (checkers.bas flooded the whole screen when - * SCREEN 2 defaulted to 15: PAINT's border never matched the walls). + * mode's highest color number, EXCEPT SCREEN 13 where real QBasic + * defaults to 15 (verified 2026-08-27 against QB.EXE in DOSBox via + * BSAVE of the mode-13 framebuffer: default PRINT/PSET/CIRCLE all + * paint index 15 — white in the BIOS default palette, whose entries + * 248-255 are black, so the long-assumed 255 printed black-on-black). + * Programs that draw or PAINT without a color argument get this, so a + * wrong default is visible immediately (checkers.bas flooded the whole + * screen when SCREEN 2 defaulted to 15: PAINT's border never matched + * the walls). */ public int defaultForeground() { - return this == SCREEN_0 ? 7 : colors - 1; + if (this == SCREEN_0) { + return 7; + } + return this == SCREEN_13 ? 15 : colors - 1; } /** diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java index d0ee6c0..ca02d42 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java @@ -100,9 +100,7 @@ public final class TextConsole { advanceLine(); } - /** - * {@code LOCATE row, col}: moves the text cursor. Coordinates are 1-based. - */ + /** {@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 > rows || col < 1 || col > columns) { throw new IllegalArgumentException( @@ -114,6 +112,11 @@ public final class TextConsole { pendingWrap = false; } + /** Current cursor column (1-based). */ + public int column() { + return cursorCol; + } + /** {@code COLOR foreground, background}. */ public void setColor(final int fg, final int bg) { foreground = fg & 0xFF; diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java index eb31843..c088e53 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java @@ -112,6 +112,11 @@ public final class VgaDevice { consoles[activePage].locate(row, col); } + /** @return current text cursor column (1-based) of the active page */ + public int textColumn() { + return consoles[activePage].column(); + } + /** {@code COLOR foreground, background}. */ public void setTextColor(final int foreground, final int background) { consoles[activePage].setColor(foreground, background); diff --git a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java index 35de341..55657d8 100644 --- a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java +++ b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java @@ -21,7 +21,7 @@ import java.util.List; *

Usage:

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

Key script: each character is one input event, delivered roughly every @@ -32,6 +32,13 @@ import java.util.List; *

  • anything else — the typed character itself
  • * * + *

    Mouse script: space-separated tokens, one event per interval, feeding + * the emulated qbext mouse TSR:

    + *
      + *
    • {@code dx,dy} — movement delta in video-mode pixels (e.g. {@code 10,0}, {@code 0,-5})
    • + *
    • {@code b0} / {@code b1} / {@code b2} — left / right / middle button press
    • + *
    + * *

    After the script is exhausted the program keeps running until it ends * on its own or the statement budget (default 8M) is spent. The named * variables are printed from the global scope afterwards for state @@ -48,13 +55,15 @@ public final class GameDriver { public static void main(final String[] args) throws Exception { if (args.length == 0) { System.err.println("Usage: GameDriver program.bas [--steps=N]" - + " [--screenshot=out.png] [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%]"); + + " [--screenshot=out.png] [--keys=SCRIPT] [--mouse=SCRIPT]" + + " [--interval=MS] [--vars=a%,b%]"); System.exit(1); } final Path programPath = Path.of(args[0]); long steps = 8_000_000; Path screenshot = null; String keys = ""; + String mouse = ""; long interval = 12; String[] vars = new String[0]; for (int i = 1; i < args.length; i++) { @@ -65,6 +74,8 @@ public final class GameDriver { screenshot = Path.of(arg.substring("--screenshot=".length())); } else if (arg.startsWith("--keys=")) { keys = arg.substring("--keys=".length()); + } else if (arg.startsWith("--mouse=")) { + mouse = arg.substring("--mouse=".length()); } else if (arg.startsWith("--interval=")) { interval = Long.parseLong(arg.substring("--interval=".length())); } else if (arg.startsWith("--vars=")) { @@ -96,19 +107,17 @@ public final class GameDriver { programThread.setDaemon(true); programThread.start(); - for (final char c : keys.toCharArray()) { + final String[] mouseTokens = mouse.isEmpty() ? new String[0] : mouse.split(" "); + final int scriptLength = Math.max(keys.length(), mouseTokens.length); + for (int i = 0; i < scriptLength; i++) { if (!programThread.isAlive()) { break; } - switch (c) { - case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE); - case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE); - case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE); - case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE); - case '~' -> { - // Pause: let the simulation advance without input. - } - default -> queue.pushChar(c); + if (i < keys.length()) { + pushKey(queue, keys.charAt(i)); + } + if (i < mouseTokens.length) { + pushMouse(interpreter, mouseTokens[i]); } Thread.sleep(interval); } @@ -123,4 +132,36 @@ public final class GameDriver { System.out.println(variable + " = " + interpreter.variable(variable)); } } + + private static void pushKey(final KeyboardQueue queue, final char c) { + switch (c) { + case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE); + case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE); + case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE); + case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE); + case '~' -> { + // Pause: let the simulation advance without input. + } + default -> queue.pushChar(c); + } + } + + /** + * One mouse-script token: {@code dx,dy} pushes a movement delta, + * {@code b} presses button bit n (0 left, 1 right, 2 middle). + */ + private static void pushMouse(final Interpreter interpreter, final String token) { + if (token.length() >= 2 && (token.charAt(0) == 'b' || token.charAt(0) == 'B')) { + interpreter.machine().mouse().pushButton(Integer.parseInt(token.substring(1))); + return; + } + final int comma = token.indexOf(','); + if (comma < 0) { + System.err.println("Bad mouse token (want dx,dy or b): " + token); + return; + } + interpreter.machine().mouse().pushMove( + Integer.parseInt(token.substring(0, comma)), + Integer.parseInt(token.substring(comma + 1))); + } } diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/FileOutputTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/FileOutputTest.java new file mode 100644 index 0000000..81df187 --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/interp/FileOutputTest.java @@ -0,0 +1,75 @@ +package eu.svjatoslav.crtbasic.interp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.parser.Parser; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** + * OPEN ... FOR OUTPUT + PRINT # (Universe explorer.bas flight-script + * recorder): formatted items written as DOS text (CRLF), readable back + * through OPEN FOR INPUT + INPUT #. + */ +class FileOutputTest { + + @Test + void printHashOutputRoundTripsThroughInputHash() throws Exception { + final Path dir = Files.createTempDirectory("crtbasic-io"); + final Interpreter interpreter = new Interpreter( + new VgaDevice(), new KeyboardQueue(), new SoundQueue()); + interpreter.setBlockOnInput(false); + interpreter.setBaseDirectory(dir); + interpreter.run(Parser.parse(""" + OPEN "t.dat" FOR OUTPUT AS #1 + PRINT #1, 42 + PRINT #1, 1.5; 2; -3 + PRINT #1, "hello" + CLOSE #1 + OPEN "t.dat" FOR INPUT AS #2 + INPUT #2, a + INPUT #2, b, c, d + INPUT #2, s$ + CLOSE + """)); + assertEquals(42.0, interpreter.variable("a")); + assertEquals(1.5, interpreter.variable("b")); + assertEquals(2.0, interpreter.variable("c")); + assertEquals(-3.0, interpreter.variable("d")); + assertEquals("hello", interpreter.variable("s$")); + + // DOS text file convention: CRLF line endings, zones/classic number style. + final String content = Files.readString(dir.resolve("t.dat"), Charset.forName("IBM437")); + assertTrue(content.contains("\r\n")); + assertTrue(content.startsWith(" 42 \r\n"), "classic number formatting: " + content); + + // APPEND keeps existing content and adds at end of file. + interpreter.run(Parser.parse(""" + OPEN "t.dat" FOR APPEND AS #1 + PRINT #1, 99 + CLOSE #1 + """)); + final String appended = Files.readString(dir.resolve("t.dat"), Charset.forName("IBM437")); + assertTrue(appended.startsWith(content), "append must keep old content: " + appended); + assertTrue(appended.endsWith(" 99 \r\n"), "append must add at end: " + appended); + } + + @Test + void printHashToUnopenedChannelRaisesBadFileNumber() { + final Interpreter interpreter = new Interpreter( + new VgaDevice(), new KeyboardQueue(), new SoundQueue()); + interpreter.setBlockOnInput(false); + try { + interpreter.run(Parser.parse("PRINT #9, 1")); + throw new AssertionError("expected Bad file number"); + } catch (final Interpreter.InterpreterException e) { + assertTrue(e.getMessage().contains("Bad file number"), e.getMessage()); + } + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java new file mode 100644 index 0000000..b83f61a --- /dev/null +++ b/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java @@ -0,0 +1,188 @@ +package eu.svjatoslav.crtbasic.interp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import eu.svjatoslav.crtbasic.audio.SoundQueue; +import eu.svjatoslav.crtbasic.input.KeyboardQueue; +import eu.svjatoslav.crtbasic.parser.Parser; +import eu.svjatoslav.crtbasic.video.VgaDevice; +import org.junit.jupiter.api.Test; + +/** + * DEF SEG / PEEK / POKE against the emulated machine memory, plus the + * always-loaded qbext mouse TSR protocol used by qbasicapps + * "Miscellaneous/Mouse driver/mousedrv.bas": the INT 79h IVT slot points + * at a data table with magic 1983, movement fields accumulate signed + * deltas, button bits latch, and the consumer reads-and-resets with POKE. + */ +class MouseDriverEmulationTest { + + /** + * The TSR-discovery plumbing of mousedrv.bas, verbatim: read the far + * pointer from the INT 79h vector, switch DEF SEG to the table segment. + */ + private static final String DRIVER_PROLOGUE = """ + DECLARE FUNCTION getword! (addr!) + DECLARE SUB putword (addr!, dat!) + DIM SHARED extSEG, extADDR + DEF SEG = 0 + extSEG = PEEK(&H79 * 4 + 3) * 256 + extSEG = extSEG + PEEK(&H79 * 4 + 2) + extADDR = PEEK(&H79 * 4 + 1) * 256 + extADDR = extADDR + PEEK(&H79 * 4 + 0) + DEF SEG = extSEG + """; + + /** getword/putword copied from mousedrv.bas (word read / write-and-reset). */ + private static final String DRIVER_HELPERS = """ + FUNCTION getword (addr) + a = PEEK(extADDR + addr) + b = PEEK(extADDR + addr + 1) + c$ = HEX$(a) + IF LEN(c$) = 1 THEN c$ = "0" + c$ + IF LEN(c$) = 0 THEN c$ = "00" + c = VAL("&H" + HEX$(b) + c$) + getword = c + END FUNCTION + SUB putword (addr, dat) + b$ = HEX$(dat) + 2 + IF LEN(b$) < 4 THEN b$ = "0" + b$: GOTO 2 + n1 = VAL("&H" + LEFT$(b$, 2)) + n2 = VAL("&H" + RIGHT$(b$, 2)) + POKE (extADDR + addr), n2 + POKE (extADDR + addr + 1), n1 + END SUB + """; + + private static Interpreter newInterpreter() { + final Interpreter interpreter = new Interpreter( + new VgaDevice(), new KeyboardQueue(), new SoundQueue()); + interpreter.setBlockOnInput(false); + return interpreter; + } + + private static Interpreter run(final String source) { + final Interpreter interpreter = newInterpreter(); + interpreter.run(Parser.parse(source)); + return interpreter; + } + + @Test + void pokePeekRoundTripInPlainRam() { + final Interpreter interpreter = run(""" + DEF SEG = &H100 + POKE 32, 123 + POKE 33, 259 + a = PEEK(32) + b = PEEK(33) + DEF SEG = &H101 + c = PEEK(16) + """); + assertEquals(123.0, interpreter.variable("a")); + // POKE stores the low byte only. + assertEquals(3.0, interpreter.variable("b")); + // Segment:offset arithmetic: &H101:16 = &H100:32. + assertEquals(123.0, interpreter.variable("c")); + } + + @Test + void bareDefSegResetsToZero() { + final Interpreter interpreter = run(""" + DEF SEG = &H100 + DEF SEG + x = PEEK(&H79 * 4 + 2) + """); + // After the reset, PEEK reads the IVT again (table segment low byte, &H90). + assertEquals(144.0, interpreter.variable("x")); + } + + @Test + void ivtSlot79hPointsToTableWithMagic() { + final Interpreter interpreter = run(DRIVER_PROLOGUE + """ + magic = getword(0) + """ + DRIVER_HELPERS); + assertEquals(1936.0, interpreter.variable("extSEG")); // &H0790 + assertEquals(0.0, interpreter.variable("extADDR")); + assertEquals(1983.0, interpreter.variable("magic")); + } + + @Test + void movementAccumulatesSignedAndReadResets() { + final Interpreter interpreter = newInterpreter(); + interpreter.machine().mouse().pushMove(5, -3); + interpreter.machine().mouse().pushMove(2, 0); + interpreter.run(Parser.parse(DRIVER_PROLOGUE + """ + xr = getword(2) + yr = getword(4) + putword 2, 0 + putword 4, 0 + xr2 = getword(2) + yr2 = getword(4) + """ + DRIVER_HELPERS)); + // Deltas accumulate (5 + 2), negative reads back signed via &H. + assertEquals(7.0, interpreter.variable("xr")); + assertEquals(-3.0, interpreter.variable("yr")); + // Read-and-reset: fields read 0 after the consumer cleared them. + assertEquals(0.0, interpreter.variable("xr2")); + assertEquals(0.0, interpreter.variable("yr2")); + } + + @Test + void buttonPressLatchesUntilReset() { + final Interpreter interpreter = newInterpreter(); + interpreter.machine().mouse().pushButton(0); // left + interpreter.machine().mouse().pushButton(2); // middle + interpreter.run(Parser.parse(DRIVER_PROLOGUE + """ + buttons = getword(6) + putword 6, 0 + buttons2 = getword(6) + """ + DRIVER_HELPERS)); + assertEquals(5.0, interpreter.variable("buttons")); + assertEquals(0.0, interpreter.variable("buttons2")); + } + + @Test + void valUnderstandsQBasicHexAndNumericPrefixes() { + final Interpreter interpreter = run(""" + a = VAL("&HFFFD") + b = VAL("&HFF") + c = VAL("&H007B") + d = VAL("12abc") + e = VAL("junk") + f = VAL("&H10000") + """); + assertEquals(-3.0, interpreter.variable("a")); // 16-bit signed + assertEquals(255.0, interpreter.variable("b")); + assertEquals(123.0, interpreter.variable("c")); + assertEquals(12.0, interpreter.variable("d")); // junk tail ignored + assertEquals(0.0, interpreter.variable("e")); + assertEquals(65536.0, interpreter.variable("f")); // > 4 digits: 32-bit + } + + @Test + void hexDollarFormatsLikeQBasic() { + final Interpreter interpreter = run(""" + a$ = HEX$(255) + b$ = HEX$(-3) + c$ = HEX$(0) + d$ = HEX$(1983) + """); + assertEquals("FF", interpreter.variable("a$")); + assertEquals("FFFD", interpreter.variable("b$")); // 16-bit two's complement + assertEquals("0", interpreter.variable("c$")); + assertEquals("7BF", interpreter.variable("d$")); + } + + @Test + void locateRowOnlyKeepsColumn() { + final Interpreter interpreter = run(""" + LOCATE 3, 10 + LOCATE 4 + col = POS(0) + row = CSRLIN + """); + assertEquals(10.0, interpreter.variable("col")); + assertEquals(4.0, interpreter.variable("row")); + } +} diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java index e01cefc..0e5987c 100644 --- a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java +++ b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java @@ -36,7 +36,7 @@ class VgaDeviceTest { } @Test - void defaultForegroundIsHighestAttributeOfMode() { + void defaultForegroundMatchesRealQBasic() { // Games/checkers.bas: PAINT without a color uses the default // foreground; when SCREEN 2 defaulted to 15 the fill flooded // through the color-1 grid walls and painted the whole screen. @@ -49,7 +49,7 @@ class VgaDeviceTest { vga.setMode(ScreenMode.SCREEN_7); assertEquals(15, vga.textForeground()); vga.setMode(ScreenMode.SCREEN_13); - assertEquals(255, vga.textForeground()); + assertEquals(15, vga.textForeground()); // SCREEN 13: real QBasic default is 15, not 255 (QB.EXE oracle, 2026-08-27) } @Test @@ -57,7 +57,7 @@ class VgaDeviceTest { final VgaDevice vga = new VgaDevice(); vga.setMode(ScreenMode.SCREEN_13); vga.print("A"); - assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = highest attribute (white in mode 13 default palette) + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y)); // SCREEN 13 default fg = 15 (white), NOT 255 (black in the real DAC palette) assertEquals(1, vga.cursorRow()); assertEquals(2, vga.cursorColumn()); } @@ -72,7 +72,7 @@ class VgaDeviceTest { // The whole cell is painted; that is why PRINTing spaces // erases text even in graphics modes. assertEquals(0, vga.point(0, 0), "off-pixel must be painted with background color"); - assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color"); + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color"); } @Test @@ -111,7 +111,7 @@ class VgaDeviceTest { vga.print("A"); // marker on row 1 vga.locate(25, 1); vga.print("X".repeat(40)); // fills the last cell exactly - assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact"); + assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact"); vga.print("X"); // one more character wraps and scrolls assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "scroll: marker scrolled off the top"); @@ -142,7 +142,7 @@ class VgaDeviceTest { for (int i = 0; i < 15; i++) { vga.print("scrolling line " + i + "\n"); } - assertEquals(255, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move"); + assertEquals(15, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move"); assertEquals(0, vga.point(A_STROKE_X, (20 - 1) * 8), "band bottom row was cleared"); assertEquals(20, vga.cursorRow()); } -- 2.20.1