feat: add emulated mouse driver, file output, and GUI mouse grab
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 27 Aug 2026 22:08:34 +0000 (01:08 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 27 Aug 2026 22:08:34 +0000 (01:08 +0300)
- 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)

16 files changed:
Documentation/agent-integration/index.org
Documentation/language/index.org
src/main/java/eu/svjatoslav/crtbasic/Main.java
src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/machine/MachineMemory.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java
src/main/java/eu/svjatoslav/crtbasic/video/TextConsole.java
src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
src/test/java/eu/svjatoslav/crtbasic/interp/FileOutputTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java

index f8ae407..71430a2 100644 (file)
@@ -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
index fc85de7..fe9f975 100644 (file)
@@ -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
index a97c836..155604a 100644 (file)
@@ -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);
index 1d6eba9..f3334d2 100644 (file)
@@ -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<Item> items, boolean newline, int line) implements Stmt {
+    public record PrintStmt(Expr channel, List<Item> 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. */
index 0b9fea1..682fa16 100644 (file)
@@ -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}.
  *
- * <p>Two hotkeys are intercepted here and never reach the program (F11/F12
- * produce no key code a classic program can observe):</p>
+ * <p>Hotkeys intercepted here, never reaching the program (they produce no
+ * key code a classic program can observe):</p>
  * <ul>
  *   <li>{@code F12} — screenshot: the visual page as
  *       {@code <program> - Screenshot <n>.png} next to the .bas file.</li>
@@ -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.</li>
+ *   <li>{@code Ctrl+F10} — releases the captured mouse (DOSBox-style).</li>
  * </ul>
  *
+ * <p>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.</p>
+ *
  * <p>Closing the window never abandons work: a recording in progress is
  * finalized, all pending MP4 transcodes run to completion behind a small
  * progress window, and only then does the JVM exit.</p>
  *
  * <p>The window is otherwise deliberately dumb: it owns no emulation state,
- * it just renders and forwards keys. Headless drivers use the same
+ * it just renders and forwards keys and mouse. Headless drivers use the same
  * VgaDevice and KeyboardQueue without this class.</p>
  */
 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)");
         }
index 86e7f75..de3cdd8 100644 (file)
@@ -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<Map<String, Cell>> 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<Integer, java.io.BufferedReader> fileChannels = new HashMap<>();
+    /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */
+    private final Map<Integer, java.io.BufferedWriter> fileOutputs = new HashMap<>();
+    private final Map<Integer, Integer> fileOutputColumns = new HashMap<>();
     private final Map<Integer, String> fileLookahead = new HashMap<>();
     /** Unconsumed tail of the current INPUT # line, per channel. */
     private final Map<Integer, String> fileRemainder = new HashMap<>();
     /** Directory that relative OPEN paths resolve against (the .bas directory). */
     private java.nio.file.Path baseDirectory = java.nio.file.Path.of("");
+    /** 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<Ast.Stmt> 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 (file)
index 0000000..70090bc
--- /dev/null
@@ -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.
+ *
+ * <p>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.</p>
+ */
+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 (file)
index 0000000..4112ce3
--- /dev/null
@@ -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.
+ *
+ * <p>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.</p>
+ *
+ * <p>Table layout (offsets in bytes, little-endian words):</p>
+ * <pre>
+ *   0x00  magic number 1983
+ *   0x02  horizontal movement, accumulated
+ *   0x04  vertical movement, accumulated
+ *   0x06  button status, latched (bit 0 left, 1 right, 2 middle)
+ * </pre>
+ *
+ * <p>Transfer protocol, exactly like the real driver: the TSR side
+ * <em>accumulates</em> (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".</p>
+ *
+ * <p>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.</p>
+ */
+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;
+        };
+    }
+}
index 631de7f..5b357ae 100644 (file)
@@ -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<Ast.PrintStmt.Item> 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() {
index afbfb44..654c530 100644 (file)
@@ -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;
     }
 
     /**
index d0ee6c0..ca02d42 100644 (file)
@@ -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;
index eb31843..c088e53 100644 (file)
@@ -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);
index 35de341..55657d8 100644 (file)
@@ -21,7 +21,7 @@ import java.util.List;
  * <p>Usage:</p>
  * <pre>
  *   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$]
  * </pre>
  *
  * <p>Key script: each character is one input event, delivered roughly every
@@ -32,6 +32,13 @@ import java.util.List;
  *   <li>anything else — the typed character itself</li>
  * </ul>
  *
+ * <p>Mouse script: space-separated tokens, one event per interval, feeding
+ * the emulated qbext mouse TSR:</p>
+ * <ul>
+ *   <li>{@code dx,dy} — movement delta in video-mode pixels (e.g. {@code 10,0}, {@code 0,-5})</li>
+ *   <li>{@code b0} / {@code b1} / {@code b2} — left / right / middle button press</li>
+ * </ul>
+ *
  * <p>After the script is exhausted the program keeps running until it ends
  * on its own or the statement budget (default 8M) is spent. The named
  * variables are printed from the global scope afterwards for state
@@ -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<n>} 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<n>): " + 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 (file)
index 0000000..81df187
--- /dev/null
@@ -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 (file)
index 0000000..b83f61a
--- /dev/null
@@ -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"));
+    }
+}
index e01cefc..0e5987c 100644 (file)
@@ -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());
     }