feat: broaden QB45 compatibility across interpreter, mouse, and grab
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 29 Aug 2026 22:54:42 +0000 (01:54 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 29 Aug 2026 22:54:42 +0000 (01:54 +0300)
Driven by making more qbasicapps programs run correctly:

- interp: cross-block GOSUB/RETURN. GosubJump collects the origin
  context chain as it unwinds; RETURN rebuilds the nested origin via a
  pending resume inside the pc loop's try, so enclosing loops keep
  iterating and later GOSUBs still resolve (truth.bas NOT handling)
- interp: Cell stores arrays separately from scalars, so a scalar and
  an array with the same name coexist like in QB45 (3D graph.bas)
- interp: LINE accepts the omitted-start form, drawing from the
  graphics cursor (Interferogram.bas)
- parser: THEN may be omitted before GOTO/GOSUB; punctuation peeks are
  type-aware so string literals like ASC(")") parse (Lottery
  analysis.bas, truth.bas)
- machine: mouse TSR tracks held buttons and re-arms the latch on
  every read-and-reset, like the real per-tick poll (mousedrv.bas)
- frontend: one transparent grab overlay per screen fixes cursor
  escape on multi-monitor setups; mouseReleased forwarded as button-up;
  -Dcrtbasic.grab.auto diagnostic
- drivers: GameDriver gains --block, $ mid-run screenshot token, and
  b<n>- mouse release token
- tests: regression coverage for all of the above

Documentation/language/index.org
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/QbextMouseTsr.java
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java

index fe9f975..14b31ee 100644 (file)
@@ -103,6 +103,9 @@ DIM names$(20) AS STRING     ' AS type form
 - An array used *without* =DIM= is auto-dimensioned =0 TO 10= per
   dimension on first indexed use — the classic default.
 - Indexing out of range raises *Subscript out of range*.
+- A scalar and an array may share one name: =xo = 42= and =xo(3) = 7=
+  use independent storage and never disturb each other (QB45 behavior,
+  oracle-verified).
 - =ERASE name= frees an array; it can be re-=DIM=med afterwards.
 - Element types follow the same suffix rules as scalars; INTEGER
   arrays narrow and range-check each stored element.
@@ -119,7 +122,7 @@ DIM names$(20) AS STRING     ' AS type form
 | =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 |
+| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original; start point may be omitted (=LINE -(x2,y2)= draws from the last point referenced) |
 | =LINE …, , B= / =BF= | Rectangle outline / filled rectangle (inclusive corners) |
 | =CIRCLE (x,y), r[, color][,,,aspect]= | Ellipse via aspect; arcs (start/end angles) *not yet* |
 | =PAINT (x,y)[, fill[, border]]= | Flood fill: paints over any color up to the border (border defaults to fill) |
@@ -145,12 +148,12 @@ DIM names$(20) AS STRING     ' AS type form
 | Statement | Notes |
 |-----------+-------|
 | =FOR v = a TO b [STEP s] … NEXT= | STEP 0 raises an error |
-| =IF c THEN … [ELSEIF c THEN …] [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms; =ELSEIF= in block form only |
+| =IF c THEN … [ELSEIF c THEN …] [ELSE …]= | Single-line and block (=END IF= / =ENDIF=) forms; =ELSEIF= in block form only; =THEN= may be omitted before =GOTO=/=GOSUB= (=IF c GOTO label=) |
 | =WHILE c … WEND= | |
 | =DO [WHILE/UNTIL c] … LOOP [WHILE/UNTIL c]= | Condition on either end |
 | =SELECT CASE x … CASE a, b … CASE ELSE … END SELECT= | Equality cases (=CASE IS=, =CASE a TO b= not yet) |
 | =GOTO label= | Procedure-wide label scope; may jump INTO a label nested inside IF/FOR/DO bodies (headers are not re-evaluated, loops resume with current variable values) |
-| =GOSUB label= / =RETURN= | Nested calls OK; GOSUB and its label must live in the same block |
+| =GOSUB label= / =RETURN= | Nested calls OK; the label may live in an enclosing block of the same procedure — RETURN rebuilds the nested origin and loops keep iterating. Not supported: GOSUB to a label nested inside a block statement |
 | =END=, =SYSTEM= | Both end the program |
 
 ** Procedures
@@ -341,9 +344,13 @@ Table layout (little-endian words):
 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.
+means "no new activity". A button held down re-arms its bit after
+every consumer reset for the whole depression — the real TSR polls the
+live button state (INT 33h function 03h) on every timer tick, so a
+program that reads-and-resets each frame sees the bit set on every
+frame until the physical release (plus one stale latched read). 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:
index f3334d2..e3d008f 100644 (file)
@@ -154,7 +154,11 @@ public final class Ast {
     public record PsetStmt(Expr x, Expr y, Expr color, int line) implements Stmt {
     }
 
-    /** {@code LINE (x1, y1)-(x2, y2)[, color[, B|BF]]} */
+    /**
+     * {@code LINE [(x1, y1)]-(x2, y2)[, color[, B|BF]]}. Null x1/y1 =
+     * start point omitted: draw from the graphics cursor (last point
+     * referenced).
+     */
     public record LineStmt(Expr x1, Expr y1, Expr x2, Expr y2, Expr color,
                            boolean box, boolean fill, int line) implements Stmt {
     }
index 682fa16..430c64a 100644 (file)
@@ -55,14 +55,16 @@ import javax.swing.WindowConstants;
  * </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>
+ * the window. Capturing covers every screen with a transparent,
+ * always-on-top overlay window (one per monitor — a single spanning window
+ * gets clamped to one output by the Wayland compositor) 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
@@ -98,16 +100,19 @@ public final class SwingFrontend {
      */
     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:
+     * While captured: one undecorated, fully transparent, always-on-top
+     * overlay window PER SCREEN (a single giant window spanning all
+     * monitors gets clamped to one output by mutter under Wayland —
+     * the pointer then leaves it on the neighbor screen and the cursor
+     * reappears). Every screen's overlay receives that screen's mouse
+     * events, 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
+     * popup, and it deadlocks on some X servers). Empty when translucent
      * windows are unsupported (no compositor) — the grab then degrades to
      * plain in-window tracking.
      */
-    private JFrame grabOverlay;
+    private final List<JFrame> grabOverlays = new ArrayList<>();
     /** Fractional mode-pixel deltas carried between events (scaling residue). */
     private double pendingDeltaX;
     private double pendingDeltaY;
@@ -190,7 +195,14 @@ public final class SwingFrontend {
                     captureMouse(); // the capture click is consumed
                     return;
                 }
-                forwardButton(e);
+                forwardButton(e, true);
+            }
+
+            @Override
+            public void mouseReleased(final MouseEvent e) {
+                if (mouseCaptured) {
+                    forwardButton(e, false);
+                }
             }
         };
         canvas.addMouseListener(mouseHandler);
@@ -249,6 +261,12 @@ public final class SwingFrontend {
     /** Shows the window. Must be called on or before the program starts. */
     public void show() {
         frame.setVisible(true);
+        if (Boolean.getBoolean("crtbasic.grab.auto")) {
+            // Diagnostics: grab immediately, without waiting for the click
+            // (Wayland input injection can't deliver one), so overlay
+            // geometry can be inspected from outside (xwininfo/xdotool).
+            SwingUtilities.invokeLater(this::captureMouse);
+        }
     }
 
     /**
@@ -280,7 +298,7 @@ public final class SwingFrontend {
     }
 
     /**
-     * Captures the mouse: covers the whole desktop with a transparent
+     * Captures the mouse: covers every screen 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
@@ -296,26 +314,32 @@ public final class SwingFrontend {
                 new Point(0, 0), "blank");
         canvas.setCursor(blank);
         try {
-            grabOverlay = createGrabOverlay(blank);
+            for (final GraphicsDevice device : GraphicsEnvironment
+                    .getLocalGraphicsEnvironment().getScreenDevices()) {
+                grabOverlays.add(createGrabOverlay(device, blank));
+            }
         } catch (final RuntimeException | java.awt.AWTException e) {
-            grabOverlay = null; // no translucency (e.g. no compositor): plain grab
+            // No translucency (e.g. no compositor): plain grab.
+            for (final JFrame overlay : grabOverlays) {
+                overlay.dispose();
+            }
+            grabOverlays.clear();
         }
         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.
+     * Builds one grab overlay: an undecorated, fully transparent,
+     * always-on-top window exactly covering ONE screen, with the same
+     * mouse/key handling as the canvas. Per-screen windows because a
+     * single window spanning the union of all screens gets repositioned
+     * onto one output by the Wayland compositor, which lets the pointer
+     * escape on the other screens.
      */
-    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();
+    private JFrame createGrabOverlay(final GraphicsDevice device, final Cursor blank)
+            throws java.awt.AWTException {
+        final Rectangle bounds = device.getDefaultConfiguration().getBounds();
+        final JFrame overlay = new JFrame("CRT Basic grab overlay");
         overlay.setUndecorated(true);
         overlay.setAlwaysOnTop(true);
         overlay.setBounds(bounds);
@@ -338,9 +362,22 @@ public final class SwingFrontend {
                 trackMovement(e);
             }
 
+            @Override
+            public void mouseEntered(final MouseEvent e) {
+                // Pointer crossed over from a neighbor screen's overlay:
+                // coordinates are relative to THIS window, so the previous
+                // anchor is meaningless — re-anchor without a delta jump.
+                lastWindowX = -1;
+            }
+
             @Override
             public void mousePressed(final MouseEvent e) {
-                forwardButton(e);
+                forwardButton(e, true);
+            }
+
+            @Override
+            public void mouseReleased(final MouseEvent e) {
+                forwardButton(e, false);
             }
         };
         glass.addMouseListener(overlayMouse);
@@ -353,23 +390,32 @@ public final class SwingFrontend {
         });
         glass.setFocusable(true);
         overlay.setVisible(true);
-        glass.requestFocusInWindow();
+        if (bounds.contains(java.awt.MouseInfo.getPointerInfo().getLocation())) {
+            // The overlay under the pointer takes the keyboard focus
+            // (Ctrl+F10 must work wherever the pointer is).
+            glass.requestFocusInWindow();
+        }
         return overlay;
     }
 
-    /** Releases the grab: drops the overlay, restores the cursor and title. */
+    /** Releases the grab: drops the overlays, restores the cursor and title. */
     private void releaseMouse() {
         mouseCaptured = false;
-        if (grabOverlay != null) {
-            grabOverlay.dispose();
-            grabOverlay = null;
+        // A button still held at grab release may never deliver its
+        // mouseReleased here; drop all held bits so they don't stick.
+        for (int bit = 0; bit <= 2; bit++) {
+            mouse.buttonUp(bit);
+        }
+        for (final JFrame overlay : grabOverlays) {
+            overlay.dispose();
         }
+        grabOverlays.clear();
         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) {
+    private void forwardButton(final MouseEvent e, final boolean down) {
         final int bit = switch (e.getButton()) {
             case MouseEvent.BUTTON1 -> 0; // left
             case MouseEvent.BUTTON3 -> 1; // right
@@ -377,7 +423,11 @@ public final class SwingFrontend {
             default -> -1;
         };
         if (bit >= 0) {
-            mouse.pushButton(bit);
+            if (down) {
+                mouse.buttonDown(bit);
+            } else {
+                mouse.buttonUp(bit);
+            }
         }
     }
 
@@ -492,10 +542,10 @@ public final class SwingFrontend {
      */
     private void closeRequested() {
         closing = true;
-        if (grabOverlay != null) {
-            grabOverlay.dispose();
-            grabOverlay = null;
+        for (final JFrame overlay : grabOverlays) {
+            overlay.dispose();
         }
+        grabOverlays.clear();
         if (recorder != null) {
             stopRecording("Recording stopped (window closed)");
         }
index de3cdd8..a0f9ebf 100644 (file)
@@ -50,6 +50,14 @@ public final class Interpreter {
         /** 0 = SINGLE, 1 = INTEGER (int16), 2 = LONG (int32), 3 = DOUBLE. */
         private final int kind;
         private Object value;
+        /**
+         * Array content of the SAME name: a {@link BasicArray} after
+         * DIM/auto-dim, or a {@link CapturedRegion} after an unindexed
+         * GET. QB45 keeps scalar and array storage fully independent —
+         * {@code xo = 42} and {@code xo(3) = 7} coexist (verified with
+         * QB.EXE in DOSBox; 3D graph.bas uses xo/yo/zo both ways).
+         */
+        private Object array;
 
         Cell(final int kind, final Object initial) {
             this.kind = kind;
@@ -132,7 +140,7 @@ public final class Interpreter {
      * Signals a GOTO jump. Each enclosing block that does not contain the
      * target label rethrows it, which gives procedure-wide label scope.
      */
-    private static final class GotoJump extends RuntimeException {
+    private static class GotoJump extends RuntimeException {
         private final String label;
         private final int line;
 
@@ -143,6 +151,40 @@ public final class Interpreter {
         }
     }
 
+    /**
+     * A GOSUB in flight: a GOTO that additionally collects its origin
+     * context chain as it unwinds. Every execBlockFrom the jump passes
+     * through prepends its (block, pc-after-current-statement) frame, so
+     * when the label resolves the chain reads outermost → innermost and
+     * RETURN can rebuild the exact nested execution state (truth.bas:
+     * GOSUB mkneg inside FOR → SELECT → IF, label at SUB body level).
+     */
+    private static final class GosubJump extends GotoJump {
+        private final List<GosubFrame> chain = new ArrayList<>();
+
+        GosubJump(final String targetLabel, final int sourceLine) {
+            super(targetLabel, sourceLine);
+        }
+    }
+
+    /**
+     * A RETURN heading for a GOSUB origin in an ENCLOSING block of the
+     * same procedure. Block pc loops rethrow it until the block that
+     * resolved the GOSUB label (frame 0 of the chain) catches it and
+     * rebuilds the origin inline — keeping its own pc loop on the stack
+     * so jumps issued by the continued execution still resolve.
+     */
+    private static final class GosubReturnJump extends RuntimeException {
+        private final List<GosubFrame> frames;
+        private final int line;
+
+        GosubReturnJump(final List<GosubFrame> frames, final int line) {
+            super(null, null, false, false);
+            this.frames = frames;
+            this.line = line;
+        }
+    }
+
     private final VgaDevice vga;
     private final KeyboardQueue keys;
     private final SoundQueue sound;
@@ -155,8 +197,16 @@ public final class Interpreter {
     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. */
-    private final Deque<GosubFrame> gosubStack = new ArrayDeque<>();
+    /** GOSUB return addresses: context chain snapshots for RETURN. */
+    private final Deque<List<GosubFrame>> gosubStack = new ArrayDeque<>();
+    /**
+     * While a cross-block GOSUB origin is being rebuilt in a block's pc
+     * loop: that block, and the pc its frame should carry in any new GOSUB
+     * chain (the loop's own pc points past the RETURN then). Cleared when
+     * the rebuild completes or a jump consumes it.
+     */
+    private List<Ast.Stmt> gosubResumeBlock;
+    private int gosubResumeOuterPc;
     private final Map<String, Ast.SubStmt> subs = new HashMap<>();
     /** Functions, keyed by base name (uppercase, no type suffix). */
     private final Map<String, Ast.FunctionStmt> functions = new HashMap<>();
@@ -374,30 +424,67 @@ public final class Interpreter {
     private void execBlockFrom(final List<Ast.Stmt> stmts, final int startPc) {
         Map<String, Integer> labels = null;
         int pc = startPc;
-        while (pc < stmts.size()) {
-            final Ast.Stmt stmt = stmts.get(pc++);
+        int stmtLine = 0;
+        List<GosubFrame> pendingGosubResume = null;
+        while (pc < stmts.size() || pendingGosubResume != null) {
             try {
+                if (pendingGosubResume != null) {
+                    // A cross-block RETURN reached this block (its GOSUB
+                    // label resolved here). Rebuild the origin INSIDE the
+                    // try: jumps from the continued execution (e.g. the
+                    // FOR's next iterations hitting GOSUB again) are
+                    // caught by this pc loop, which stays on the stack.
+                    final List<GosubFrame> frames = pendingGosubResume;
+                    pendingGosubResume = null;
+                    if (frames.size() > 1) {
+                        // While the rebuild runs, a new GOSUB chain's frame
+                        // for this block must use the origin pc, not the
+                        // pc loop's current position (past the RETURN).
+                        gosubResumeBlock = stmts;
+                        gosubResumeOuterPc = frames.get(0).pc();
+                        resumeChain(frames, 1);
+                        // Frame 0's own enclosing statement (e.g. the FOR
+                        // the GOSUB was issued from) keeps iterating too —
+                        // same order as resumeNestedLabel.
+                        continueLoopAfterResume(
+                                frames.get(0).block().get(frames.get(0).pc() - 1));
+                        gosubResumeBlock = null;
+                    }
+                    pc = frames.get(0).pc();
+                    continue;
+                }
+                final Ast.Stmt stmt = stmts.get(pc++);
+                stmtLine = stmt.line();
                 if (stmt instanceof Ast.GotoStmt g) {
                     throw new GotoJump(g.label(), g.line());
                 }
                 if (stmt instanceof Ast.GosubStmt g) {
-                    // Return address: the statement after this GOSUB, in
-                    // this block. The label jump itself is a GOTO.
-                    gosubStack.addLast(new GosubFrame(stmts, pc));
-                    throw new GotoJump(g.label(), g.line());
+                    // The jump collects the origin context chain as it
+                    // unwinds (each block's catch prepends its frame,
+                    // including this one); the snapshot is pushed where
+                    // the label resolves, and RETURN rebuilds from it.
+                    throw new GosubJump(g.label(), g.line());
                 }
                 if (stmt instanceof Ast.ReturnStmt r) {
                     if (++executed > maxStatements) {
                         throw new StepsLimitException(executed);
                     }
-                    final GosubFrame frame = gosubStack.pollLast();
-                    if (frame == null) {
+                    final List<GosubFrame> snapshot = gosubStack.pollLast();
+                    if (snapshot == null) {
                         throw error("RETURN without GOSUB", r.line());
                     }
-                    if (frame.block() != stmts) {
+                    if (snapshot.isEmpty()) {
+                        // GOSUB to a label nested inside a block statement:
+                        // the resumed stretch can't be rebuilt on RETURN.
                         throw error("Cross-block GOSUB/RETURN is not supported", r.line());
                     }
-                    pc = frame.pc();
+                    if (snapshot.get(0).block() != stmts) {
+                        // The RETURN sits in a block nested inside the
+                        // subroutine stretch: unwind to the resolving block.
+                        throw new GosubReturnJump(snapshot, r.line());
+                    }
+                    // Resume at the top of the loop, inside the try.
+                    pendingGosubResume = snapshot;
                     continue;
                 }
                 exec(stmt);
@@ -415,8 +502,25 @@ public final class Interpreter {
                 }
                 GotoJump jump = caught;
                 while (true) {
+                    // A GOSUB issued while this block was rebuilding a
+                    // RETURN origin: this block's frame belongs to the
+                    // ORIGIN pc (past the statement that owned the GOSUB),
+                    // not the loop's current pc. Consumed on first use.
+                    final int originPc;
+                    if (stmts == gosubResumeBlock) {
+                        originPc = gosubResumeOuterPc;
+                        gosubResumeBlock = null;
+                    } else {
+                        originPc = pc;
+                    }
                     final Integer target = labels.get(jump.label.toLowerCase());
                     if (target != null) {
+                        if (jump instanceof GosubJump gosub) {
+                            // Label resolved at this level: this block's
+                            // frame is the outermost of the return chain.
+                            gosub.chain.add(0, new GosubFrame(stmts, originPc));
+                            gosubStack.addLast(gosub.chain);
+                        }
                         pc = target;
                         break;
                     }
@@ -424,8 +528,18 @@ public final class Interpreter {
                     // IF/FOR/DO bodies of this block — jump INTO it.
                     final List<NestedLabelStep> path = findNestedLabel(stmts, jump.label);
                     if (path == null) {
+                        if (jump instanceof GosubJump gosub) {
+                            // Not here either: record this level's frame and
+                            // keep unwinding toward the enclosing block.
+                            gosub.chain.add(0, new GosubFrame(stmts, originPc));
+                        }
                         throw jump;
                     }
+                    if (jump instanceof GosubJump) {
+                        // RETURN from a nested-resume stretch can't rebuild
+                        // the origin: mark the snapshot unsupported.
+                        gosubStack.addLast(List.of());
+                    }
                     try {
                         pc = resumeNestedLabel(stmts, path);
                         break;
@@ -435,6 +549,19 @@ public final class Interpreter {
                         jump = again;
                     }
                 }
+            } catch (final GosubReturnJump ret) {
+                // A RETURN aiming for an origin that passes through an
+                // ENCLOSING block. Only the block that resolved the GOSUB
+                // label (frame 0) may resume the chain — everyone else
+                // keeps unwinding. The resume runs at the top of the loop,
+                // inside the try.
+                if (++executed > maxStatements) {
+                    throw new StepsLimitException(executed);
+                }
+                if (ret.frames.isEmpty() || ret.frames.get(0).block() != stmts) {
+                    throw ret;
+                }
+                pendingGosubResume = ret.frames;
             } catch (final InterpreterException e) {
                 // ON ERROR GOTO: transfer control to the handler label. The
                 // handler lives in module-level code; blocks that do not
@@ -478,7 +605,7 @@ public final class Interpreter {
                     }
                     pc = target;
                 } else if (resumePc < 0) {
-                    throw error("RESUME without error", stmt.line());
+                    throw error("RESUME without error", stmtLine);
                 } else {
                     pc = resume.next ? resumePc + 1 : resumePc;
                 }
@@ -572,6 +699,30 @@ public final class Interpreter {
         throw new IllegalStateException("empty label path");
     }
 
+    /**
+     * Rebuilds a cross-block GOSUB origin on RETURN, starting at the given
+     * chain level (1 from the resolving block's GosubReturnJump catch —
+     * frame 0 is that block itself, resumed by its pc loop). The chain
+     * (outermost → innermost) was captured while the GOSUB jump unwound.
+     * The innermost block resumes at the statement after the GOSUB; each
+     * outer level then continues its enclosing statement (loops keep
+     * iterating via {@link #continueLoopAfterResume}) and runs the rest
+     * of its block — mirroring {@link #resumeNestedLabel}.
+     */
+    private void resumeChain(final List<GosubFrame> chain, final int level) {
+        final GosubFrame frame = chain.get(level);
+        if (level == chain.size() - 1) {
+            execBlockFrom(frame.block(), frame.pc());
+            return;
+        }
+        // The statement executing at this level when the GOSUB flew: the
+        // owner of the next level's block (an IF/FOR/WHILE/DO/SELECT).
+        final Ast.Stmt owner = frame.block().get(frame.pc() - 1);
+        resumeChain(chain, level + 1);
+        continueLoopAfterResume(owner);
+        execBlockFrom(frame.block(), frame.pc());
+    }
+
     /**
      * Continues a loop statement whose current body pass just finished
      * after an inward GOTO resume: remaining iterations run normally.
@@ -729,7 +880,7 @@ public final class Interpreter {
                 // dynamic ones, so ERASE returns the slot to its
                 // never-DIMmed state (re-DIMming afterwards works).
                 for (final String name : s.names()) {
-                    cellFor(name).value = null;
+                    cellFor(name).array = null;
                 }
             }
             case Ast.OnErrorStmt s -> {
@@ -825,10 +976,12 @@ public final class Interpreter {
                         final boolean stringArray = key.endsWith("$");
                         final int elementKind = stringArray ? 0 : kindOf(key);
                         final Cell cell = target.computeIfAbsent(key, k -> new Cell(kindOf(k), null));
-                        if (cell.value == null) {
+                        if (cell.array == null) {
                             // Fresh DIM — or a re-DIM after ERASE freed the
-                            // slot (classic dynamic-array behavior).
-                            cell.value = new BasicArray(lows, sizes, stringArray, elementKind);
+                            // slot (classic dynamic-array behavior). A scalar
+                            // of the same name lives in cell.value and is
+                            // unaffected.
+                            cell.array = new BasicArray(lows, sizes, stringArray, elementKind);
                         }
                     }
                 }
@@ -838,7 +991,7 @@ public final class Interpreter {
                         toInt(eval(s.x1()), s.line()), toInt(eval(s.y1()), s.line()),
                         toInt(eval(s.x2()), s.line()), toInt(eval(s.y2()), s.line()));
                 if (s.offsetIndices().isEmpty()) {
-                    cellFor(s.arrayName()).value = region;
+                    cellFor(s.arrayName()).array = region;
                 } else {
                     // GET ..., array(i, j): anchor the capture at that
                     // element — one slot of a sprite-sheet array.
@@ -854,7 +1007,7 @@ public final class Interpreter {
             case Ast.PutStmt s -> {
                 final Object captured;
                 if (s.offsetIndices().isEmpty()) {
-                    captured = cellFor(s.arrayName()).value;
+                    captured = cellFor(s.arrayName()).array;
                 } else {
                     final BasicArray array = arrayFor(s.arrayName(),
                             s.offsetIndices().size(), s.line());
@@ -1032,8 +1185,10 @@ public final class Interpreter {
     }
 
     private void execLine(final Ast.LineStmt s) {
-        final int x1 = toInt(eval(s.x1()), s.line());
-        final int y1 = toInt(eval(s.y1()), s.line());
+        // Omitted start point: draw from the graphics cursor (QB45's
+        // LINE -(x, y) form).
+        final int x1 = s.x1() == null ? vga.graphicsCursorX() : toInt(eval(s.x1()), s.line());
+        final int y1 = s.y1() == null ? vga.graphicsCursorY() : toInt(eval(s.y1()), s.line());
         final int x2 = toInt(eval(s.x2()), s.line());
         final int y2 = toInt(eval(s.y2()), s.line());
         final int color = colorOrDefault(s.color(), s.line());
@@ -1197,14 +1352,14 @@ public final class Interpreter {
      */
     private BasicArray arrayFor(final String name, final int dimensions, final int line) {
         final Cell cell = cellFor(name);
-        if (cell.value == null) {
+        if (cell.array == null) {
             final int[] lows = new int[dimensions];
             final int[] sizes = new int[dimensions];
             Arrays.fill(sizes, 11);
-            cell.value = new BasicArray(lows, sizes, name.endsWith("$"),
+            cell.array = new BasicArray(lows, sizes, name.endsWith("$"),
                     name.endsWith("$") ? 0 : kindOf(canonical(name)));
         }
-        if (!(cell.value instanceof BasicArray array) || array.sizes.length != dimensions) {
+        if (!(cell.array instanceof BasicArray array) || array.sizes.length != dimensions) {
             throw error("Subscript out of range", line);
         }
         return array;
index 4112ce3..d5d87fd 100644 (file)
@@ -25,10 +25,20 @@ package eu.svjatoslav.crtbasic.machine;
  * 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>The real TSR hooks the timer interrupt and calls INT 33h function
+ * 03h on every tick (~18.2 Hz), ORing the <em>currently depressed</em>
+ * buttons into the latch. A held button therefore keeps re-arming the
+ * field after every consumer reset for the whole depression, not just
+ * at the click instant. The emulation mirrors this: frontends report
+ * press and release ({@link #buttonDown(int)}/{@link #buttonUp(int)}),
+ * and the held mask is ORed back into the button field whenever the
+ * consumer's read-and-reset POKE clears it.</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>
+ * feed events through {@link #pushMove(int, int)},
+ * {@link #buttonDown(int)} and {@link #buttonUp(int)}. All state is
+ * synchronized: the program thread PEEKs/POKEs while the EDT pushes
+ * events.</p>
  */
 public final class QbextMouseTsr {
 
@@ -53,6 +63,9 @@ public final class QbextMouseTsr {
     /** The four 16-bit table fields (values kept in 0..0xFFFF). */
     private final int[] fields = {MAGIC, 0, 0, 0};
 
+    /** Bits of the buttons physically held down right now. */
+    private int heldMask;
+
     /**
      * 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
@@ -65,16 +78,30 @@ public final class QbextMouseTsr {
     }
 
     /**
-     * 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.
+     * Reports a button going down: bit 0 = left, 1 = right, 2 = middle
+     * (INT 33h function 03h order). The bit latches into the table field
+     * and stays in the held mask until {@link #buttonUp(int)} — like the
+     * real TSR's per-tick poll, the held bit re-arms after every
+     * consumer reset for as long as the button is depressed.
      */
-    public synchronized void pushButton(final int bit) {
+    public synchronized void buttonDown(final int bit) {
         if (bit >= 0 && bit <= 15) {
+            heldMask |= 1 << bit;
             fields[FIELD_BUTTONS] |= 1 << bit;
         }
     }
 
+    /**
+     * Reports a button going up. The latched table bit is NOT cleared —
+     * like the real driver it stays until the consumer resets the field —
+     * but it stops re-arming, so the next read-and-reset sticks.
+     */
+    public synchronized void buttonUp(final int bit) {
+        if (bit >= 0 && bit <= 15) {
+            heldMask &= ~(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)];
@@ -89,6 +116,11 @@ public final class QbextMouseTsr {
         } else {
             fields[index] = (fields[index] & 0x00FF) | ((value & 0xFF) << 8);
         }
+        if (index == FIELD_BUTTONS) {
+            // The next timer tick of the real TSR: buttons still
+            // physically depressed re-arm immediately after the reset.
+            fields[FIELD_BUTTONS] |= heldMask;
+        }
     }
 
     private static int fieldAt(final int tableOffset) {
index 5b357ae..52276fa 100644 (file)
@@ -522,16 +522,18 @@ public final class Parser {
      * bare form prints "? ".
      */
     private Ast.Stmt consoleInputStatement(final Token keyword) {
-        if (peek().isText(";")) {
+        // Type-aware peeks: INPUT ";" prompts with a semicolon string —
+        // only an OP ";" is the cursor-suppression marker.
+        if (peek().is(Token.Type.OP) && peek().isText(";")) {
             next();
         }
         Ast.Expr prompt = null;
         boolean questionMark = true;
         if (peek().is(Token.Type.STRING)) {
             prompt = expression();
-            if (peek().isText(";")) {
+            if (peek().is(Token.Type.OP) && peek().isText(";")) {
                 next();
-            } else if (peek().isText(",")) {
+            } else if (peek().is(Token.Type.OP) && peek().isText(",")) {
                 next();
                 questionMark = false;
             } else {
@@ -937,12 +939,20 @@ public final class Parser {
             expectOp(",");
             return new Ast.LineInputStmt(channel, readTarget(), keyword.line());
         }
-        expectOp("(");
-        final Ast.Expr x1 = expression();
-        expectOp(",");
-        final Ast.Expr y1 = expression();
-        expectOp(")");
-        expectOp("-");
+        Ast.Expr x1 = null;
+        Ast.Expr y1 = null;
+        if (peek().isText("-")) {
+            // LINE -(x2, y2): start from the graphics cursor (last point
+            // referenced) — x1/y1 stay null (Interferogram.bas).
+            next();
+        } else {
+            expectOp("(");
+            x1 = expression();
+            expectOp(",");
+            y1 = expression();
+            expectOp(")");
+            expectOp("-");
+        }
         expectOp("(");
         final Ast.Expr x2 = expression();
         expectOp(",");
@@ -1098,7 +1108,13 @@ public final class Parser {
     private Ast.Stmt ifStatement() {
         final Token keyword = next(); // IF
         final Ast.Expr condition = expression();
-        expectText("THEN");
+        if (peek().isText("THEN")) {
+            next();
+        } else if (!peek().isText("GOTO") && !peek().isText("GOSUB")) {
+            // QB45 allows omitting THEN only before GOTO/GOSUB:
+            // IF cond GOTO label
+            throw error("Expected THEN, got '" + peek().text() + "'", peek());
+        }
         if (peek().is(Token.Type.SEP) || peek().is(Token.Type.EOF)) {
             // Block form: IF cond THEN <newline> stmts [ELSEIF ...] [ELSE stmts] END IF
             final List<Ast.Stmt> thenBody = ifBranchBody();
@@ -1307,12 +1323,15 @@ public final class Parser {
         }
         if (token.is(Token.Type.IDENT)) {
             next();
-            if (peek().isText("(")) {
+            if (peek().is(Token.Type.OP) && peek().isText("(")) {
                 next();
                 final List<Ast.Expr> args = new ArrayList<>();
-                if (!peek().isText(")")) {
+                // Type-aware peeks: ASC(")") passes a string literal whose
+                // text is punctuation (truth.bas) — a STRING token must
+                // never read as the closing paren or an arg separator.
+                if (!(peek().is(Token.Type.OP) && peek().isText(")"))) {
                     args.add(expression());
-                    while (peek().isText(",")) {
+                    while (peek().is(Token.Type.OP) && peek().isText(",")) {
                         next();
                         args.add(expression());
                     }
@@ -1322,7 +1341,7 @@ public final class Parser {
             }
             return new Ast.Var(token.text(), token.line());
         }
-        if (token.isText("(")) {
+        if (token.is(Token.Type.OP) && token.isText("(")) {
             next();
             final Ast.Expr inner = expression();
             expectOp(")");
@@ -1418,7 +1437,9 @@ public final class Parser {
 
     private void expectOp(final String op) {
         final Token token = peek();
-        if (!token.isText(op)) {
+        // Type-aware: a STRING literal whose text is punctuation (ASC(")"))
+        // must never satisfy an operator expectation.
+        if (!token.is(Token.Type.OP) || !token.isText(op)) {
             throw error("Expected '" + op + "', got '" + token.text() + "'", token);
         }
         next();
index 55657d8..da0d572 100644 (file)
@@ -22,6 +22,7 @@ import java.util.List;
  * <pre>
  *   GameDriver program.bas [--steps=N] [--screenshot=out.png]
  *       [--keys=SCRIPT] [--mouse=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
+ *       [--block]
  * </pre>
  *
  * <p>Key script: each character is one input event, delivered roughly every
@@ -29,14 +30,24 @@ import java.util.List;
  * <ul>
  *   <li>{@code R L U D} — arrow keys (right/left/up/down, VK codes)</li>
  *   <li>{@code ~} — pause (no key, just waits one interval)</li>
+ *   <li>{@code $} — dump a PNG screenshot NOW to the --screenshot path,
+ *       numbered (-1, -2, ...) when dumped more than once</li>
  *   <li>anything else — the typed character itself</li>
  * </ul>
  *
+ * <p>{@code --block} makes INPUT/INPUT$ block on an empty key queue, like
+ * the GUI. Use it (and end the script with a key that exits the program)
+ * when a graph must stay on screen until the {@code $} screenshot lands;
+ * in the default non-blocking mode a program waiting for input spins,
+ * so an end-of-run screenshot only catches screens reachable without
+ * input.</p>
+ *
  * <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>
+ *   <li>{@code b0} / {@code b1} / {@code b2} — left / right / middle button press (held)</li>
+ *   <li>{@code b0-} / {@code b1-} / {@code b2-} — release that button</li>
  * </ul>
  *
  * <p>After the script is exhausted the program keeps running until it ends
@@ -56,7 +67,7 @@ public final class GameDriver {
         if (args.length == 0) {
             System.err.println("Usage: GameDriver program.bas [--steps=N]"
                     + " [--screenshot=out.png] [--keys=SCRIPT] [--mouse=SCRIPT]"
-                    + " [--interval=MS] [--vars=a%,b%]");
+                    + " [--interval=MS] [--vars=a%,b%] [--block]");
             System.exit(1);
         }
         final Path programPath = Path.of(args[0]);
@@ -65,6 +76,7 @@ public final class GameDriver {
         String keys = "";
         String mouse = "";
         long interval = 12;
+        boolean block = false;
         String[] vars = new String[0];
         for (int i = 1; i < args.length; i++) {
             final String arg = args[i];
@@ -80,6 +92,8 @@ public final class GameDriver {
                 interval = Long.parseLong(arg.substring("--interval=".length()));
             } else if (arg.startsWith("--vars=")) {
                 vars = arg.substring("--vars=".length()).split(",");
+            } else if (arg.equals("--block")) {
+                block = true;
             } else {
                 System.err.println("Unknown option: " + arg);
                 System.exit(1);
@@ -93,7 +107,7 @@ public final class GameDriver {
         final Interpreter interpreter = new Interpreter(vga, queue, new SoundQueue());
         interpreter.setMaxStatements(steps);
         interpreter.setSourceLines(source.split("\n", -1));
-        interpreter.setBlockOnInput(false);
+        interpreter.setBlockOnInput(block);
         final Path parent = programPath.toAbsolutePath().getParent();
         interpreter.setBaseDirectory(parent == null ? Path.of("").toAbsolutePath() : parent);
 
@@ -109,12 +123,22 @@ public final class GameDriver {
 
         final String[] mouseTokens = mouse.isEmpty() ? new String[0] : mouse.split(" ");
         final int scriptLength = Math.max(keys.length(), mouseTokens.length);
+        int shotCount = 0;
         for (int i = 0; i < scriptLength; i++) {
             if (!programThread.isAlive()) {
                 break;
             }
             if (i < keys.length()) {
-                pushKey(queue, keys.charAt(i));
+                final char c = keys.charAt(i);
+                if (c == '$' && screenshot != null) {
+                    // Mid-run screenshot: number the path (out-1.png, ...).
+                    shotCount++;
+                    final Path target = numbered(screenshot, shotCount);
+                    interpreter.video().dumpPng(target);
+                    System.out.println("Screenshot: " + target);
+                } else {
+                    pushKey(queue, c);
+                }
             }
             if (i < mouseTokens.length) {
                 pushMouse(interpreter, mouseTokens[i]);
@@ -123,7 +147,7 @@ public final class GameDriver {
         }
         programThread.join();
 
-        if (screenshot != null) {
+        if (screenshot != null && shotCount == 0) {
             interpreter.video().dumpPng(screenshot);
             System.out.println("Screenshot: " + screenshot);
         }
@@ -133,6 +157,17 @@ public final class GameDriver {
         }
     }
 
+    /** Inserts {@code -N} before the file extension: out.png -> out-2.png. */
+    private static Path numbered(final Path path, final int n) {
+        final String name = path.getFileName().toString();
+        final int dot = name.lastIndexOf('.');
+        final String numbered = dot > 0
+                ? name.substring(0, dot) + "-" + n + name.substring(dot)
+                : name + "-" + n;
+        final Path parent = path.getParent();
+        return parent == null ? Path.of(numbered) : parent.resolve(numbered);
+    }
+
     private static void pushKey(final KeyboardQueue queue, final char c) {
         switch (c) {
             case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE);
@@ -148,16 +183,27 @@ public final class GameDriver {
 
     /**
      * One mouse-script token: {@code dx,dy} pushes a movement delta,
-     * {@code b<n>} presses button bit n (0 left, 1 right, 2 middle).
+     * {@code b<n>} presses button bit n, {@code b<n>-} releases it
+     * (0 left, 1 right, 2 middle). A pressed button stays held —
+     * re-arming the TSR latch after every consumer reset — until its
+     * release token, mirroring the real driver's per-tick poll.
      */
     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)));
+            final boolean release = token.endsWith("-");
+            final String digits = release
+                    ? token.substring(1, token.length() - 1) : token.substring(1);
+            final int bit = Integer.parseInt(digits);
+            if (release) {
+                interpreter.machine().mouse().buttonUp(bit);
+            } else {
+                interpreter.machine().mouse().buttonDown(bit);
+            }
             return;
         }
         final int comma = token.indexOf(',');
         if (comma < 0) {
-            System.err.println("Bad mouse token (want dx,dy or b<n>): " + token);
+            System.err.println("Bad mouse token (want dx,dy, b<n> or b<n>-): " + token);
             return;
         }
         interpreter.machine().mouse().pushMove(
index 11df273..4848b08 100644 (file)
@@ -192,6 +192,51 @@ class InterpreterFeaturesTest {
         assertEquals(" 2 2 done", interpreter.variable("log$"));
     }
 
+    @Test
+    void scalarAndArrayWithSameNameAreIndependent() {
+        // QB.EXE oracle (DOSBox): scalar and array with the same name keep
+        // INDEPENDENT storage — xo = 42 and xo(3) = 7 coexist and neither
+        // disturbs the other. 3D graph.bas uses xo/yo/zo as both scalar
+        // temporaries and DIMmed arrays; sharing one slot surfaced as
+        // "Subscript out of range" under the ON ERROR handler.
+        final Interpreter interpreter = run("""
+                DIM SHARED log$
+                DIM xo(4000)
+                xo = 42
+                log$ = log$ + "scalar=" + STR$(xo)
+                xo(3) = 7
+                log$ = log$ + " arr=" + STR$(xo(3))
+                log$ = log$ + " scalar2=" + STR$(xo)
+                xo = xo + 1
+                log$ = log$ + " scalar3=" + STR$(xo) + " arr2=" + STR$(xo(3))
+                """);
+        assertEquals("scalar= 42 arr= 7 scalar2= 42 scalar3= 43 arr2= 7",
+                interpreter.variable("log$"));
+    }
+
+    @Test
+    void ifWithoutThenBeforeGotoAndGosub() {
+        // QB45 lets THEN be omitted before GOTO/GOSUB:
+        // IF cond GOTO label (Lottery analysis.bas used this form).
+        final Interpreter interpreter = run("""
+                DIM SHARED log$
+                x = 5
+                IF x = 5 GOTO five
+                log$ = log$ + "skip "
+                GOTO done
+                five:
+                log$ = log$ + "five "
+                IF x <> 5 GOSUB nosub
+                done:
+                log$ = log$ + "done"
+                END
+                nosub:
+                log$ = log$ + "BAD"
+                RETURN
+                """);
+        assertEquals("five done", interpreter.variable("log$"));
+    }
+
     @Test
     void gotoIntoNestedBlockCanJumpBackOut() {
         // Games/checkers2.bas compgo: GoTo 8 re-enters the finished
@@ -350,6 +395,73 @@ class InterpreterFeaturesTest {
         return false;
     }
 
+    @Test
+    void gosubAcrossBlocksReturnsIntoLoopAndKeepsIterating() {
+        // truth.bas tee(): GOSUB inside FOR → SELECT → IF, label at SUB
+        // body level. RETURN must rebuild the nested origin; the FOR keeps
+        // iterating and the GOSUB fires again on later iterations.
+        final Interpreter interpreter = run("""
+                DIM SHARED log$
+                DECLARE SUB tee ()
+                tee
+                SUB tee
+                    FOR a = 1 TO 3
+                        SELECT CASE a
+                            CASE 1 TO 3
+                                ng = 1
+                                IF ng = 1 THEN GOSUB mkneg
+                                log$ = log$ + STR$(a)
+                        END SELECT
+                    NEXT a
+                    GOTO done
+                mkneg:
+                    log$ = log$ + "n"
+                    RETURN
+                done:
+                END SUB
+                """);
+        assertEquals("n 1n 2n 3", interpreter.variable("log$"));
+    }
+
+    @Test
+    void stringLiteralsContainingPunctuation() {
+        // String literals whose TEXT is punctuation must not confuse the
+        // parser: ASC(")") — the ")" string was read as the call's closing
+        // paren, leaving the real ')' to kill the statement (truth.bas).
+        final Interpreter interpreter = run("""
+                DIM SHARED log$
+                x = ASC(")")
+                log$ = log$ + STR$(x)
+                y = ASC("(")
+                log$ = log$ + STR$(y)
+                z = LEN(",;)(")
+                log$ = log$ + STR$(z)
+                w = ASC("-") + ASC("+")
+                log$ = log$ + STR$(w)
+                """);
+        assertEquals(" 41 40 4 88", interpreter.variable("log$"));
+    }
+
+    @Test
+    void lineWithOmittedStartDrawsFromGraphicsCursor() {
+        // QB45's LINE -(x, y) form: start point defaults to the last
+        // point referenced (here the PSET). Interferogram.bas draws its
+        // whole curve as one continuing polyline this way.
+        final Interpreter interpreter = run("""
+                SCREEN 12
+                PSET (10, 10), 15
+                LINE -(20, 10), 15
+                LINE -(20, 20), 15
+                """);
+        // First segment (10,10)-(20,10): horizontal, then (20,10)-(20,20).
+        assertEquals(15, interpreter.video().point(15, 10));
+        assertEquals(15, interpreter.video().point(20, 15));
+        assertEquals(0, interpreter.video().point(10, 15));
+        // The graphics cursor ends on the last endpoint.
+        assertEquals(20, interpreter.video().graphicsCursorX());
+        assertEquals(20, interpreter.video().graphicsCursorY());
+    }
+
     @Test
     void gosubReturnsToTheStatementAfterTheCall() {
         final Interpreter interpreter = run("""
index b83f61a..1e0e2c5 100644 (file)
@@ -131,8 +131,10 @@ class MouseDriverEmulationTest {
     @Test
     void buttonPressLatchesUntilReset() {
         final Interpreter interpreter = newInterpreter();
-        interpreter.machine().mouse().pushButton(0); // left
-        interpreter.machine().mouse().pushButton(2); // middle
+        interpreter.machine().mouse().buttonDown(0); // left
+        interpreter.machine().mouse().buttonDown(2); // middle
+        interpreter.machine().mouse().buttonUp(0);   // released again:
+        interpreter.machine().mouse().buttonUp(2);   // latch must NOT clear
         interpreter.run(Parser.parse(DRIVER_PROLOGUE + """
                 buttons = getword(6)
                 putword 6, 0
@@ -142,6 +144,37 @@ class MouseDriverEmulationTest {
         assertEquals(0.0, interpreter.variable("buttons2"));
     }
 
+    /**
+     * The real TSR polls INT 33h function 03h every timer tick, so a HELD
+     * button re-arms the latch after every consumer reset for the whole
+     * depression — the consumer keeps reading non-zero until the physical
+     * release (plus one stale latched read, like the real driver).
+     */
+    @Test
+    void heldButtonRearmsAfterEveryResetUntilRelease() {
+        final Interpreter interpreter = newInterpreter();
+        interpreter.machine().mouse().buttonDown(1); // right, held
+        interpreter.run(Parser.parse(DRIVER_PROLOGUE + """
+                held1 = getword(6)
+                putword 6, 0
+                held2 = getword(6)
+                putword 6, 0
+                """ + DRIVER_HELPERS));
+        // Still depressed: every read-and-reset sees the button again.
+        assertEquals(2.0, interpreter.variable("held1"));
+        assertEquals(2.0, interpreter.variable("held2"));
+
+        interpreter.machine().mouse().buttonUp(1);
+        interpreter.run(Parser.parse(DRIVER_PROLOGUE + """
+                stale = getword(6)
+                putword 6, 0
+                gone = getword(6)
+                """ + DRIVER_HELPERS));
+        // One leftover latched read after release, then solid 0.
+        assertEquals(2.0, interpreter.variable("stale"));
+        assertEquals(0.0, interpreter.variable("gone"));
+    }
+
     @Test
     void valUnderstandsQBasicHexAndNumericPrefixes() {
         final Interpreter interpreter = run("""