- 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.
| =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) |
| 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
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:
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 {
}
* </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
*/
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;
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);
/** 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);
+ }
}
/**
}
/**
- * 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
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);
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);
});
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
default -> -1;
};
if (bit >= 0) {
- mouse.pushButton(bit);
+ if (down) {
+ mouse.buttonDown(bit);
+ } else {
+ mouse.buttonUp(bit);
+ }
}
}
*/
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)");
}
/** 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;
* 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;
}
}
+ /**
+ * 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;
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<>();
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);
}
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;
}
// 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;
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
}
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;
}
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.
// 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 -> {
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);
}
}
}
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.
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());
}
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());
*/
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;
* 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 {
/** 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
}
/**
- * 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)];
} 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) {
* 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 {
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(",");
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();
}
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());
}
}
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(")");
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();
* <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
* <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
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]);
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];
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);
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);
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]);
}
programThread.join();
- if (screenshot != null) {
+ if (screenshot != null && shotCount == 0) {
interpreter.video().dumpPng(screenshot);
System.out.println("Screenshot: " + screenshot);
}
}
}
+ /** 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);
/**
* 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(
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
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("""
@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
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("""