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> scopes = new ArrayDeque<>(); - /** GOSUB return addresses: the block and pc to resume on RETURN. */ - private final Deque gosubStack = new ArrayDeque<>(); + /** GOSUB return addresses: context chain snapshots for RETURN. */ + private final Deque> 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 gosubResumeBlock; + private int gosubResumeOuterPc; private final Map subs = new HashMap<>(); /** Functions, keyed by base name (uppercase, no type suffix). */ private final Map functions = new HashMap<>(); @@ -374,30 +424,67 @@ public final class Interpreter { private void execBlockFrom(final List stmts, final int startPc) { Map labels = null; int pc = startPc; - while (pc < stmts.size()) { - final Ast.Stmt stmt = stmts.get(pc++); + int stmtLine = 0; + List 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 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 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 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 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; diff --git a/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java b/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java index 4112ce3..d5d87fd 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java +++ b/src/main/java/eu/svjatoslav/crtbasic/machine/QbextMouseTsr.java @@ -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".

* + *

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