From 66099d60f2768212523568970cbd5eccd58b3991 Mon Sep 17 00:00:00 2001
From: Svjatoslav Agejenko
Date: Sun, 30 Aug 2026 01:54:42 +0300
Subject: [PATCH] feat: broaden QB45 compatibility across interpreter, mouse,
and grab
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- mouse release token
- tests: regression coverage for all of the above
---
Documentation/language/index.org | 19 +-
.../java/eu/svjatoslav/crtbasic/ast/Ast.java | 6 +-
.../crtbasic/frontend/SwingFrontend.java | 130 +++++++----
.../crtbasic/interp/Interpreter.java | 205 +++++++++++++++---
.../crtbasic/machine/QbextMouseTsr.java | 46 +++-
.../eu/svjatoslav/crtbasic/parser/Parser.java | 51 +++--
.../crtbasic/drivers/GameDriver.java | 62 +++++-
.../interp/InterpreterFeaturesTest.java | 112 ++++++++++
.../interp/MouseDriverEmulationTest.java | 37 +++-
9 files changed, 564 insertions(+), 104 deletions(-)
diff --git a/Documentation/language/index.org b/Documentation/language/index.org
index fe9f975..14b31ee 100644
--- a/Documentation/language/index.org
+++ b/Documentation/language/index.org
@@ -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:
diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
index f3334d2..e3d008f 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
@@ -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 {
}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
index 682fa16..430c64a 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
@@ -55,14 +55,16 @@ import javax.swing.WindowConstants;
*
*
* 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.
+ * 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.
*
* 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 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)");
}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
index de3cdd8..a0f9ebf 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
@@ -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 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 frames;
+ private final int line;
+
+ GosubReturnJump(final List 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
*
+ * The real TSR hooks the timer interrupt and calls INT 33h function
+ * 03h on every tick (~18.2 Hz), ORing the currently depressed
+ * 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.
+ *
* 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.
+ * 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.
*/
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) {
diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
index 5b357ae..52276fa 100644
--- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
+++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
@@ -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 stmts [ELSEIF ...] [ELSE stmts] END IF
final List 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 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();
diff --git a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
index 55657d8..da0d572 100644
--- a/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
+++ b/src/test/java/eu/svjatoslav/crtbasic/drivers/GameDriver.java
@@ -22,6 +22,7 @@ import java.util.List;
*
* GameDriver program.bas [--steps=N] [--screenshot=out.png]
* [--keys=SCRIPT] [--mouse=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
+ * [--block]
*
*
* Key script: each character is one input event, delivered roughly every
@@ -29,14 +30,24 @@ import java.util.List;
*
* - {@code R L U D} â arrow keys (right/left/up/down, VK codes)
* - {@code ~} â pause (no key, just waits one interval)
+ * - {@code $} â dump a PNG screenshot NOW to the --screenshot path,
+ * numbered (-1, -2, ...) when dumped more than once
* - anything else â the typed character itself
*
*
+ * {@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.
+ *
* Mouse script: space-separated tokens, one event per interval, feeding
* the emulated qbext mouse TSR:
*
* - {@code dx,dy} â movement delta in video-mode pixels (e.g. {@code 10,0}, {@code 0,-5})
- * - {@code b0} / {@code b1} / {@code b2} â left / right / middle button press
+ * - {@code b0} / {@code b1} / {@code b2} â left / right / middle button press (held)
+ * - {@code b0-} / {@code b1-} / {@code b2-} â release that button
*
*
* 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} presses button bit n (0 left, 1 right, 2 middle).
+ * {@code b} presses button bit n, {@code b-} 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): " + token);
+ System.err.println("Bad mouse token (want dx,dy, b or b-): " + token);
return;
}
interpreter.machine().mouse().pushMove(
diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
index 11df273..4848b08 100644
--- a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
+++ b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
@@ -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("""
diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java
index b83f61a..1e0e2c5 100644
--- a/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java
+++ b/src/test/java/eu/svjatoslav/crtbasic/interp/MouseDriverEmulationTest.java
@@ -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("""
--
2.20.1