feat: add console INPUT, BEEP, nested-label GOTO and screen fixes master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 22 Aug 2026 15:28:12 +0000 (18:28 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 22 Aug 2026 15:28:12 +0000 (18:28 +0300)
- Console INPUT: prompt with ';' (adds "? ") or ',' (prompt only),
  echoed line editing with Backspace, comma-delimited items; headless
  runs give up after 5 s of silence instead of hanging.
- BEEP desugars to SOUND 800, 4.55 (the classic ~1/4 s beep).
- GOTO may jump INTO a label nested in IF/FOR/DO bodies with flat
  QBasic semantics: headers are not re-evaluated and enclosing loops
  resume with current variable values (checkers2.bas "GoTo 8").
- AS-typed SUB/FUNCTION parameters claim their bare name inside the
  body (winning over DEFxxx defaults), while suffix parameters like
  a$ leave the bare name as a distinct DEFxxx-typed variable.
- Bare SUB calls accept a parenthesized first argument; the parens
  parse as grouping and the comma list continues the arguments.
- Graphics modes default the foreground to the mode's highest
  attribute (1/3/15/255) instead of always 15, so colorless PAINT
  stays inside the walls (checkers.bas SCREEN 2 flood); SCREEN 2's
  palette now maps attribute 1 to white.
- 640x200 modes (SCREEN 2, 8) are doubled vertically in the Swing
  window and in dumpPng to match their 4:3 CRT pixel aspect; the
  window re-packs on every mode switch via a mode listener.

Documentation/language/index.org
src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java
src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java
src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java
src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java
src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java
src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java
src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java

index 1a84013..16d2503 100644 (file)
@@ -114,7 +114,7 @@ DIM names$(20) AS STRING     ' AS type form
 
 | Statement | Notes |
 |-----------+-------|
 
 | Statement | Notes |
 |-----------+-------|
-| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count |
+| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count; default foreground becomes the mode's highest attribute (1/3/15/255), background black |
 | =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 |
 | =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 |
@@ -147,7 +147,7 @@ DIM names$(20) AS STRING     ' AS type form
 | =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) |
 | =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 |
+| =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 |
 | =END=, =SYSTEM= | Both end the program |
 
 | =GOSUB label= / =RETURN= | Nested calls OK; GOSUB and its label must live in the same block |
 | =END=, =SYSTEM= | Both end the program |
 
@@ -178,6 +178,13 @@ END FUNCTION
 - =DECLARE= statements carry parameter type suffixes: when a
   SUB/FUNCTION header uses bare names, the declared suffixes type the
   parameters.
 - =DECLARE= statements carry parameter type suffixes: when a
   SUB/FUNCTION header uses bare names, the declared suffixes type the
   parameters.
+- A parameter declared =AS type= claims its bare name inside the body:
+  under =DEFINT A-Z=, =SUB d (x AS SINGLE)= makes every bare =x= refer
+  to the SINGLE parameter. A suffix parameter does *not*: in
+  =SUB p (a$)=, a bare =a= is a separate DEFxxx-typed variable.
+- A bare call's first argument may be parenthesized:
+  =prn (x + 1) * 2, 10, a$= parses as three arguments, the parens are
+  plain grouping.
 - SUBs do *not* see the caller's variables (classic scoping), except
   names declared =DIM SHARED=.
 
 - SUBs do *not* see the caller's variables (classic scoping), except
   names declared =DIM SHARED=.
 
@@ -189,12 +196,14 @@ END FUNCTION
 | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= |
 | =OPEN path FOR INPUT AS #n= | Text files; paths resolve relative to the =.bas= file |
 | =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= |
 | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= |
 | =OPEN path FOR INPUT AS #n= | Text files; paths resolve relative to the =.bas= file |
 | =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= |
+| =INPUT [;]["prompt"{;|,}] var, …= | Console line input: prompt echoed (="; "= adds =? =), typed line echo with Backspace editing, items comma-delimited like =INPUT #= |
 | =LINE INPUT #n, var$= | Whole line |
 | =EOF(n)= | True at end of channel |
 | =CLOSE [#n]= | Bare form closes all |
 | =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping |
 | =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump |
 | =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet |
 | =LINE INPUT #n, var$= | Whole line |
 | =EOF(n)= | True at end of channel |
 | =CLOSE [#n]= | Bare form closes all |
 | =ON ERROR GOTO label= / =ON ERROR GOTO 0= | Enable / disable trapping |
 | =RESUME=, =RESUME NEXT=, =RESUME label= | Retry, continue, or jump |
 | =SOUND freq, duration= | Timing modeled (18.2 ticks/s), no audio yet |
+| =BEEP= | Desugars to =SOUND 800, 4.55= (the classic ~1/4 s beep) |
 | =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* |
 | =OUT port, value= | VGA DAC ports only; others ignored |
 
 | =SWAP a, b= | Exchanges two variables or array elements; string/number mixes raise *Type mismatch* |
 | =OUT port, value= | VGA DAC ports only; others ignored |
 
index 570a2c4..1d6eba9 100644 (file)
@@ -64,11 +64,21 @@ public final class Ast {
 
     /**
      * {@code INPUT #channel, var, array(i), ...} — reads comma-delimited data
 
     /**
      * {@code INPUT #channel, var, array(i), ...} — reads comma-delimited data
-     * items from an open file (console INPUT is not modeled).
+     * items from an open file.
      */
     public record InputFileStmt(Expr channel, List<ReadTarget> targets, int line) implements Stmt {
     }
 
      */
     public record InputFileStmt(Expr channel, List<ReadTarget> targets, int line) implements Stmt {
     }
 
+    /**
+     * {@code INPUT [;]["prompt"{;|,}] var, array(i), ...} — console line
+     * input. The prompt is null for the bare form; questionMark selects
+     * the trailing "? " (semicolon form and bare form show it, the comma
+     * form does not). Entered items are comma-delimited like INPUT #.
+     */
+    public record ConsoleInputStmt(Expr prompt, boolean questionMark,
+                                   List<ReadTarget> targets, int line) implements Stmt {
+    }
+
     /**
      * {@code ERASE name, ...} — frees the array(s) held by the named
      * variables, returning the slots to their never-DIMmed state.
     /**
      * {@code ERASE name, ...} — frees the array(s) held by the named
      * variables, returning the slots to their never-DIMmed state.
@@ -202,8 +212,18 @@ public final class Ast {
     public record ConstStmt(List<ConstEntry> entries, int line) implements Stmt {
     }
 
     public record ConstStmt(List<ConstEntry> entries, int line) implements Stmt {
     }
 
+    /**
+     * A SUB/FUNCTION parameter: the (possibly type-folded) name, plus whether
+     * the source used {@code AS type} syntax. An {@code AS}-declared
+     * parameter claims the bare name inside the body (it wins over any DEFxxx
+     * default); a suffix-declared parameter like {@code a$} does not — a bare
+     * {@code a} in the body is a different variable (DEFxxx-typed).
+     */
+    public record Param(String name, boolean asTyped) {
+    }
+
     /** {@code SUB name (param, ...) ... END SUB} — definition, not executed inline */
     /** {@code SUB name (param, ...) ... END SUB} — definition, not executed inline */
-    public record SubStmt(String name, List<String> params, List<Stmt> body, int line) implements Stmt {
+    public record SubStmt(String name, List<Param> params, List<Stmt> body, int line) implements Stmt {
     }
 
     /**
     }
 
     /**
@@ -211,7 +231,7 @@ public final class Ast {
      * executed inline. The return value is whatever was last assigned to
      * the function's own name inside the body (0 / "" when never assigned).
      */
      * executed inline. The return value is whatever was last assigned to
      * the function's own name inside the body (0 / "" when never assigned).
      */
-    public record FunctionStmt(String name, List<String> params, List<Stmt> body, int line) implements Stmt {
+    public record FunctionStmt(String name, List<Param> params, List<Stmt> body, int line) implements Stmt {
     }
 
     /**
     }
 
     /**
@@ -312,7 +332,7 @@ public final class Ast {
      *
      * @param what e.g. {@code "SUB DrawLine"} (name as written)
      */
      *
      * @param what e.g. {@code "SUB DrawLine"} (name as written)
      */
-    public record DeclareStmt(String what, List<String> params, int line) implements Stmt {
+    public record DeclareStmt(String what, List<Param> params, int line) implements Stmt {
     }
 
     /**
     }
 
     /**
index 116f2e3..0b9fea1 100644 (file)
@@ -60,6 +60,7 @@ public final class SwingFrontend {
     private final String baseTitle;
     private final Path programDirectory;
     private final String programBaseName;
     private final String baseTitle;
     private final Path programDirectory;
     private final String programBaseName;
+    private JPanel canvas;
 
     private ScreenRecorder recorder;
     private Timer titleTimer;
 
     private ScreenRecorder recorder;
     private Timer titleTimer;
@@ -86,9 +87,8 @@ public final class SwingFrontend {
         final String fileName = programPath.getFileName().toString();
         programBaseName = fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".bas")
                 ? fileName.substring(0, fileName.length() - 4) : fileName;
         final String fileName = programPath.getFileName().toString();
         programBaseName = fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".bas")
                 ? fileName.substring(0, fileName.length() - 4) : fileName;
-        final int scale = vga.mode().pixelWidth() <= 320 ? 3 : 2;
 
 
-        final JPanel canvas = new JPanel() {
+        final JPanel newCanvas = new JPanel() {
             @Override
             protected void paintComponent(final Graphics g) {
                 final Graphics2D g2 = (Graphics2D) g;
             @Override
             protected void paintComponent(final Graphics g) {
                 final Graphics2D g2 = (Graphics2D) g;
@@ -98,8 +98,8 @@ public final class SwingFrontend {
                         0, 0, getWidth(), getHeight(), null);
             }
         };
                         0, 0, getWidth(), getHeight(), null);
             }
         };
-        canvas.setPreferredSize(new java.awt.Dimension(
-                vga.mode().pixelWidth() * scale, vga.mode().pixelHeight() * scale));
+        canvas = newCanvas;
+        canvas.setPreferredSize(windowSize(vga.mode()));
         canvas.setFocusable(true);
         canvas.addKeyListener(new KeyAdapter() {
             @Override
         canvas.setFocusable(true);
         canvas.addKeyListener(new KeyAdapter() {
             @Override
@@ -129,6 +129,7 @@ public final class SwingFrontend {
         frame.setContentPane(canvas);
         frame.pack();
         frame.setLocationRelativeTo(null);
         frame.setContentPane(canvas);
         frame.pack();
         frame.setLocationRelativeTo(null);
+        vga.setModeListener(this::modeChanged);
 
         new Timer(REPAINT_MS, e -> canvas.repaint()).start();
     }
 
         new Timer(REPAINT_MS, e -> canvas.repaint()).start();
     }
@@ -138,9 +139,35 @@ public final class SwingFrontend {
         frame.setVisible(true);
     }
 
         frame.setVisible(true);
     }
 
-    /** Re-packs the window after a video mode change (new resolution). */
+    /**
+     * Window content size for a video mode. Integer scaling (3x for
+     * 320-wide modes, 2x otherwise) keeps pixels crisp; 640-wide
+     * 200-line modes (SCREEN 2, 8) double vertically — on a 4:3 CRT
+     * their pixels are roughly twice as tall as wide, and rendering
+     * them square looks horizontally stretched. 320-wide 200-line
+     * modes (SCREEN 1, 7, 13) stay square: their true CRT stretch is
+     * only ~1.2x, and a 2x stretch looks vertically squashed instead.
+     */
+    private static java.awt.Dimension windowSize(final eu.svjatoslav.crtbasic.video.ScreenMode mode) {
+        final int scale = mode.pixelWidth() <= 320 ? 3 : 2;
+        final int yStretch = eu.svjatoslav.crtbasic.video.ScreenMode
+                .needsVerticalDoubling(mode) ? 2 : 1;
+        return new java.awt.Dimension(mode.pixelWidth() * scale,
+                mode.pixelHeight() * scale * yStretch);
+    }
+
+    /**
+     * Re-packs the window after a video mode change (new resolution).
+     * Fired from the program thread via {@link VgaDevice#setModeListener};
+     * hops to the EDT. The initial preferred size in the constructor
+     * already reflects the current mode, so a SCREEN statement that ran
+     * before this window existed is covered too.
+     */
     public void modeChanged() {
     public void modeChanged() {
-        frame.pack();
+        SwingUtilities.invokeLater(() -> {
+            canvas.setPreferredSize(windowSize(vga.mode()));
+            frame.pack();
+        });
     }
 
     // ---------------------------------------------------------- screenshot
     }
 
     // ---------------------------------------------------------- screenshot
index 46271da..0ebd829 100644 (file)
@@ -157,7 +157,7 @@ public final class Interpreter {
      * name (uppercase, no suffix). When a SUB/FUNCTION header uses bare
      * parameter names, the DECLARE'd suffixes decide the parameter types.
      */
      * name (uppercase, no suffix). When a SUB/FUNCTION header uses bare
      * parameter names, the DECLARE'd suffixes decide the parameter types.
      */
-    private final Map<String, List<String>> declaredParams = new HashMap<>();
+    private final Map<String, List<Ast.Param>> declaredParams = new HashMap<>();
     /**
      * Declared procedure names as written (with type suffix), keyed by
      * base name. A DECLARE'd FUNCTION suffix types its return value.
     /**
      * Declared procedure names as written (with type suffix), keyed by
      * base name. A DECLARE'd FUNCTION suffix types its return value.
@@ -338,8 +338,24 @@ public final class Interpreter {
      * giving labels procedure-wide scope.
      */
     private void execBlock(final List<Ast.Stmt> stmts) {
      * giving labels procedure-wide scope.
      */
     private void execBlock(final List<Ast.Stmt> stmts) {
+        execBlockFrom(stmts, 0);
+    }
+
+    /**
+     * Executes a statement list with a program counter so that
+     * {@code GOTO} can jump to any {@link Ast.LabelStmt} in the list. A jump
+     * whose target is not in this list propagates to the enclosing block,
+     * giving labels procedure-wide scope. When no enclosing block holds
+     * the label at its top level either, the label may still be NESTED
+     * inside a statement of this block (IF/FOR/DO bodies): classic QBasic
+     * executes a procedure as a flat line sequence, so such an inward
+     * jump is legal (checkers2.bas: {@code GoTo 8} re-enters the
+     * finished capture-scan loops) and is honored via
+     * {@link #resumeNestedLabel}.
+     */
+    private void execBlockFrom(final List<Ast.Stmt> stmts, final int startPc) {
         Map<String, Integer> labels = null;
         Map<String, Integer> labels = null;
-        int pc = 0;
+        int pc = startPc;
         while (pc < stmts.size()) {
             final Ast.Stmt stmt = stmts.get(pc++);
             try {
         while (pc < stmts.size()) {
             final Ast.Stmt stmt = stmts.get(pc++);
             try {
@@ -367,7 +383,7 @@ public final class Interpreter {
                     continue;
                 }
                 exec(stmt);
                     continue;
                 }
                 exec(stmt);
-            } catch (final GotoJump jump) {
+            } catch (final GotoJump caught) {
                 if (++executed > maxStatements) {
                     throw new StepsLimitException(executed);
                 }
                 if (++executed > maxStatements) {
                     throw new StepsLimitException(executed);
                 }
@@ -379,11 +395,28 @@ public final class Interpreter {
                         }
                     }
                 }
                         }
                     }
                 }
-                final Integer target = labels.get(jump.label.toLowerCase());
-                if (target == null) {
-                    throw jump;
+                GotoJump jump = caught;
+                while (true) {
+                    final Integer target = labels.get(jump.label.toLowerCase());
+                    if (target != null) {
+                        pc = target;
+                        break;
+                    }
+                    // Flat QBasic semantics: the label may be nested inside
+                    // IF/FOR/DO bodies of this block — jump INTO it.
+                    final List<NestedLabelStep> path = findNestedLabel(stmts, jump.label);
+                    if (path == null) {
+                        throw jump;
+                    }
+                    try {
+                        pc = resumeNestedLabel(stmts, path);
+                        break;
+                    } catch (final GotoJump again) {
+                        // A GOTO issued from inside the resumed stretch:
+                        // resolve it against this same block.
+                        jump = again;
+                    }
                 }
                 }
-                pc = target;
             } catch (final InterpreterException e) {
                 // ON ERROR GOTO: transfer control to the handler label. The
                 // handler lives in module-level code; blocks that do not
             } catch (final InterpreterException e) {
                 // ON ERROR GOTO: transfer control to the handler label. The
                 // handler lives in module-level code; blocks that do not
@@ -436,6 +469,145 @@ public final class Interpreter {
         }
     }
 
         }
     }
 
+    /**
+     * One step on the path to a label nested inside a block statement:
+     * the statement of the enclosing list whose child list leads to the
+     * label. In the path's last step, {@code labelIndex} is the label's
+     * position inside {@code childList} (-1 for the other steps).
+     */
+    private record NestedLabelStep(Ast.Stmt stmt, List<Ast.Stmt> childList, int labelIndex) {
+    }
+
+    /**
+     * Finds a label nested inside the statements of {@code stmts} (any
+     * depth), returning the chain of enclosing statements from this
+     * block down to the list that directly holds the label — or null
+     * when the label is nowhere below this block.
+     */
+    private List<NestedLabelStep> findNestedLabel(final List<Ast.Stmt> stmts, final String label) {
+        for (final Ast.Stmt stmt : stmts) {
+            for (final List<Ast.Stmt> child : childListsOf(stmt)) {
+                for (int i = 0; i < child.size(); i++) {
+                    if (child.get(i) instanceof Ast.LabelStmt l
+                            && l.label().equalsIgnoreCase(label)) {
+                        final List<NestedLabelStep> path = new ArrayList<>();
+                        path.add(new NestedLabelStep(stmt, child, i));
+                        return path;
+                    }
+                }
+                final List<NestedLabelStep> sub = findNestedLabel(child, label);
+                if (sub != null) {
+                    sub.add(0, new NestedLabelStep(stmt, child, -1));
+                    return sub;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** Statement lists nested directly inside a statement. */
+    private static List<List<Ast.Stmt>> childListsOf(final Ast.Stmt stmt) {
+        return switch (stmt) {
+            case Ast.IfStmt s -> s.elseBody().isEmpty()
+                    ? List.of(s.thenBody()) : List.of(s.thenBody(), s.elseBody());
+            case Ast.ForStmt s -> List.of(s.body());
+            case Ast.WhileStmt s -> List.of(s.body());
+            case Ast.DoStmt s -> List.of(s.body());
+            case Ast.SelectStmt s -> {
+                final List<List<Ast.Stmt>> lists = new ArrayList<>();
+                for (final Ast.CaseBranch branch : s.cases()) {
+                    lists.add(branch.body());
+                }
+                if (!s.elseBody().isEmpty()) {
+                    lists.add(s.elseBody());
+                }
+                yield lists;
+            }
+            default -> List.of();
+        };
+    }
+
+    /**
+     * Executes a GOTO whose target label is nested inside block
+     * statements of {@code stmts}, with flat QBasic semantics: enclosing
+     * IF/SELECT headers are not re-evaluated, loops resume with the
+     * variable values the program left behind (the FOR variable is NOT
+     * re-initialized), and once the innermost list runs off its end the
+     * enclosing loops keep iterating and the enclosing lists continue.
+     *
+     * @return the pc at which this block continues once the outermost
+     *         enclosing statement finishes
+     */
+    private int resumeNestedLabel(final List<Ast.Stmt> stmts, final List<NestedLabelStep> path) {
+        final NestedLabelStep last = path.get(path.size() - 1);
+        execBlockFrom(last.childList(), last.labelIndex());
+        for (int level = path.size() - 1; level >= 0; level--) {
+            final Ast.Stmt enclosing = path.get(level).stmt();
+            continueLoopAfterResume(enclosing);
+            final List<Ast.Stmt> parentList = level == 0 ? stmts : path.get(level - 1).childList();
+            final int enclosingIndex = indexOfIdentity(parentList, enclosing);
+            if (level == 0) {
+                return enclosingIndex + 1;
+            }
+            execBlockFrom(parentList, enclosingIndex + 1);
+        }
+        throw new IllegalStateException("empty label path");
+    }
+
+    /**
+     * Continues a loop statement whose current body pass just finished
+     * after an inward GOTO resume: remaining iterations run normally.
+     * Non-loop statements (IF/SELECT) have nothing to continue.
+     */
+    private void continueLoopAfterResume(final Ast.Stmt stmt) {
+        switch (stmt) {
+            case Ast.ForStmt s -> {
+                final Cell cell = cellFor(s.variable());
+                final double to = num(eval(s.to()), s.line());
+                final double step = s.step() == null ? 1 : num(eval(s.step()), s.line());
+                if (step == 0) {
+                    throw error("STEP cannot be 0", s.line());
+                }
+                cell.set(num(cell.value, s.line()) + step);
+                while (step > 0 ? num(cell.value, s.line()) <= to
+                        : num(cell.value, s.line()) >= to) {
+                    execBlock(s.body());
+                    cell.set(num(cell.value, s.line()) + step);
+                }
+            }
+            case Ast.WhileStmt s -> {
+                while (truthy(eval(s.condition()))) {
+                    execBlock(s.body());
+                }
+            }
+            case Ast.DoStmt s -> {
+                while (true) {
+                    if (s.postCondition() != null
+                            && truthy(eval(s.postCondition())) == s.postIsUntil()) {
+                        break;
+                    }
+                    if (s.preCondition() != null
+                            && truthy(eval(s.preCondition())) == s.preIsUntil()) {
+                        break;
+                    }
+                    execBlock(s.body());
+                }
+            }
+            default -> {
+                // IF / SELECT: the resumed branch simply ends.
+            }
+        }
+    }
+
+    private static int indexOfIdentity(final List<Ast.Stmt> stmts, final Ast.Stmt needle) {
+        for (int i = 0; i < stmts.size(); i++) {
+            if (stmts.get(i) == needle) {
+                return i;
+            }
+        }
+        throw new IllegalStateException("statement not found in its own block");
+    }
+
     private void exec(final Ast.Stmt stmt) {
         if (++executed > maxStatements) {
             throw new StepsLimitException(executed);
     private void exec(final Ast.Stmt stmt) {
         if (++executed > maxStatements) {
             throw new StepsLimitException(executed);
@@ -494,6 +666,7 @@ public final class Interpreter {
                     setTarget(target, readFileItem(channel, target.name(), s.line()), s.line());
                 }
             }
                     setTarget(target, readFileItem(channel, target.name(), s.line()), s.line());
                 }
             }
+            case Ast.ConsoleInputStmt s -> execConsoleInput(s);
             case Ast.EraseStmt s -> {
                 // The classic dialect frees dynamic arrays; our arrays all behave like
                 // dynamic ones, so ERASE returns the slot to its
             case Ast.EraseStmt s -> {
                 // The classic dialect frees dynamic arrays; our arrays all behave like
                 // dynamic ones, so ERASE returns the slot to its
@@ -838,28 +1011,43 @@ public final class Interpreter {
      * cell is registered under both names' canonical forms, so the body
      * can refer to the parameter with or without the type suffix.
      */
      * cell is registered under both names' canonical forms, so the body
      * can refer to the parameter with or without the type suffix.
      */
-    private Map<String, Cell> bindScope(final String procName, final List<String> headerParams,
+    private Map<String, Cell> bindScope(final String procName, final List<Ast.Param> headerParams,
                                         final List<Ast.Expr> args, final int line) {
         if (args.size() != headerParams.size()) {
             throw error("'" + procName + "' expects " + headerParams.size()
                     + " argument(s), got " + args.size(), line);
         }
                                         final List<Ast.Expr> args, final int line) {
         if (args.size() != headerParams.size()) {
             throw error("'" + procName + "' expects " + headerParams.size()
                     + " argument(s), got " + args.size(), line);
         }
-        final List<String> declared = declaredParams.getOrDefault(baseName(procName), List.of());
+        final List<Ast.Param> declared = declaredParams.getOrDefault(baseName(procName), List.of());
         final Map<String, Cell> scope = new HashMap<>();
         for (int i = 0; i < args.size(); i++) {
             final Ast.Expr arg = args.get(i);
         final Map<String, Cell> scope = new HashMap<>();
         for (int i = 0; i < args.size(); i++) {
             final Ast.Expr arg = args.get(i);
-            final String headerKey = canonical(headerParams.get(i));
-            final String declaredKey = i < declared.size()
-                    ? canonical(declared.get(i)) : headerKey;
+            final Ast.Param header = headerParams.get(i);
+            final Ast.Param declare = i < declared.size() ? declared.get(i) : header;
+            final String headerKey = canonical(header.name());
+            final String declaredKey = canonical(declare.name());
             final Cell cell = arg instanceof Ast.Var v
                     ? cellFor(v.name()) // by reference
                     : new Cell(kindOf(declaredKey), eval(arg)); // by value
             scope.put(headerKey, cell);
             scope.put(declaredKey, cell);
             final Cell cell = arg instanceof Ast.Var v
                     ? cellFor(v.name()) // by reference
                     : new Cell(kindOf(declaredKey), eval(arg)); // by value
             scope.put(headerKey, cell);
             scope.put(declaredKey, cell);
+            // An "AS type" parameter claims the bare name: body references
+            // canonicalize through the DEFxxx default ("delayInSeconds" →
+            // "…%" under DEFINT A-Z), so register the same cell under that
+            // key too. A suffix parameter ("a$") does NOT claim it — a bare
+            // "a" in the body is a distinct DEFxxx-typed variable.
+            if (header.asTyped() || declare.asTyped()) {
+                scope.putIfAbsent(canonical(stripSuffix(header.name())), cell);
+            }
         }
         return scope;
     }
 
         }
         return scope;
     }
 
+    /** Parameter name without any type suffix character. */
+    private static String stripSuffix(final String name) {
+        final char last = name.charAt(name.length() - 1);
+        return "%&!#$".indexOf(last) >= 0 ? name.substring(0, name.length() - 1) : name;
+    }
+
     /**
      * Invokes a FUNCTION and returns its result: the value last assigned
      * to the function's own name inside the body (0 / "" when unassigned).
     /**
      * Invokes a FUNCTION and returns its result: the value last assigned
      * to the function's own name inside the body (0 / "" when unassigned).
@@ -1620,6 +1808,113 @@ public final class Interpreter {
         return single(now.toSecondOfDay() + now.getNano() / 1e9);
     }
 
         return single(now.toSecondOfDay() + now.getNano() / 1e9);
     }
 
+    /**
+     * Console INPUT: prints the prompt (plus "? " when selected), reads
+     * one echoed line from the keyboard queue (Backspace edits, Enter
+     * commits) and distributes the comma-delimited items over the
+     * targets. Numbers convert with VAL semantics; missing items read
+     * as 0 / "".
+     */
+    private void execConsoleInput(final Ast.ConsoleInputStmt s) {
+        if (s.prompt() != null) {
+            vga.print(str(eval(s.prompt()), s.line()));
+        }
+        if (s.questionMark()) {
+            vga.print("? ");
+        }
+        final String entered = readConsoleLine();
+        vga.newLine();
+        final String[] items = entered.split(",", -1);
+        int slot = 0;
+        for (final Ast.ReadTarget target : s.targets()) {
+            final String item = slot < items.length ? items[slot].trim() : "";
+            slot++;
+            if (canonical(target.name()).endsWith("$")) {
+                setTarget(target, item, s.line());
+            } else {
+                double value;
+                try {
+                    value = Double.parseDouble(item);
+                } catch (final NumberFormatException e) {
+                    value = 0.0;
+                }
+                setTarget(target, value, s.line());
+            }
+        }
+    }
+
+    /**
+     * Reads one console line for INPUT. Printable keys echo and append,
+     * Backspace erases, Enter finishes; extended keys are ignored.
+     * Interactive mode waits indefinitely; headless mode returns what
+     * was typed after a few seconds of silence so a driverless run
+     * cannot hang forever.
+     */
+    private String readConsoleLine() {
+        final StringBuilder line = new StringBuilder();
+        long silentSince = -1;
+        while (true) {
+            final String chars = nextInputSequence();
+            if (chars == null) {
+                // No key right now: headless gives up after 5 silent seconds.
+                if (!blockOnInput) {
+                    if (silentSince < 0) {
+                        silentSince = System.currentTimeMillis();
+                    } else if (System.currentTimeMillis() - silentSince > 5000) {
+                        return line.toString();
+                    }
+                }
+                continue;
+            }
+            silentSince = -1;
+            for (int i = 0; i < chars.length(); i++) {
+                final char c = chars.charAt(i);
+                if (c == '\0') {
+                    break; // Extended key: skip it and its scancode byte.
+                }
+                if (c == '\r') {
+                    return line.toString();
+                }
+                if (c == '\b') {
+                    if (!line.isEmpty()) {
+                        line.deleteCharAt(line.length() - 1);
+                        final int row = vga.cursorRow();
+                        final int col = vga.cursorColumn();
+                        if (col > 1) {
+                            vga.locate(row, col - 1);
+                            vga.print(" ");
+                            vga.locate(row, col - 1);
+                        }
+                    }
+                    continue;
+                }
+                line.append(c);
+                vga.print(String.valueOf(c));
+            }
+        }
+    }
+
+    /**
+     * Next key sequence for console INPUT: pending INPUT$ tail bytes
+     * first, then the keyboard queue mapped through the classic byte
+     * sequences. Returns null when nothing is available right now.
+     */
+    private String nextInputSequence() {
+        if (!pendingKeys.isEmpty()) {
+            final String chars = pendingKeys;
+            pendingKeys = "";
+            return chars;
+        }
+        try {
+            final KeyboardQueue.Key key = blockOnInput
+                    ? keys.poll(Long.MAX_VALUE) : keys.poll(100);
+            return key == null ? null : keyToQb(key);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return null;
+        }
+    }
+
     /**
      * Reads the next line from an open channel, consuming the one-line
      * lookahead first when EOF(n) filled it. Returns null at end of file
     /**
      * Reads the next line from an open channel, consuming the one-line
      * lookahead first when EOF(n) filled it. Returns null at end of file
index e510d37..dd56223 100644 (file)
@@ -79,6 +79,7 @@ public final class Parser {
                 case "LINE" -> lineStatement();
                 case "CIRCLE" -> circleStatement();
                 case "SOUND" -> soundStatement();
                 case "LINE" -> lineStatement();
                 case "CIRCLE" -> circleStatement();
                 case "SOUND" -> soundStatement();
+                case "BEEP" -> beepStatement();
                 case "SLEEP" -> sleepStatement();
                 case "OUT" -> outStatement();
                 case "RANDOMIZE" -> randomizeStatement();
                 case "SLEEP" -> sleepStatement();
                 case "OUT" -> outStatement();
                 case "RANDOMIZE" -> randomizeStatement();
@@ -172,14 +173,14 @@ public final class Parser {
     private Ast.Stmt subStatement() {
         final Token keyword = next(); // SUB
         final Token name = expect(Token.Type.IDENT);
     private Ast.Stmt subStatement() {
         final Token keyword = next(); // SUB
         final Token name = expect(Token.Type.IDENT);
-        final List<String> params = new ArrayList<>();
+        final List<Ast.Param> params = new ArrayList<>();
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
-                params.add(paramName());
+                params.add(param());
                 while (peek().isText(",")) {
                     next();
                 while (peek().isText(",")) {
                     next();
-                    params.add(paramName());
+                    params.add(param());
                 }
             }
             expectOp(")");
                 }
             }
             expectOp(")");
@@ -194,14 +195,14 @@ public final class Parser {
     private Ast.Stmt functionStatement() {
         final Token keyword = next(); // FUNCTION
         final Token name = expect(Token.Type.IDENT);
     private Ast.Stmt functionStatement() {
         final Token keyword = next(); // FUNCTION
         final Token name = expect(Token.Type.IDENT);
-        final List<String> params = new ArrayList<>();
+        final List<Ast.Param> params = new ArrayList<>();
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
-                params.add(paramName());
+                params.add(param());
                 while (peek().isText(",")) {
                     next();
                 while (peek().isText(",")) {
                     next();
-                    params.add(paramName());
+                    params.add(param());
                 }
             }
             expectOp(")");
                 }
             }
             expectOp(")");
@@ -297,14 +298,31 @@ public final class Parser {
         return body;
     }
 
         return body;
     }
 
-    /** Parameter name with optional {@code AS type} suffix (type not modeled). */
-    private String paramName() {
+    /**
+     * Parameter with optional {@code AS type} clause. The explicit type is
+     * folded into the name as a type suffix character (e.g. {@code x AS
+     * SINGLE} → {@code x!}) so it wins over any DEFxxx default when the name
+     * is canonicalized; {@code asTyped} records that the bare name was
+     * claimed by an AS clause (vs an explicit suffix like {@code a$}).
+     */
+    private Ast.Param param() {
         final Token name = expect(Token.Type.IDENT);
         if (isAny("AS")) {
             next();
         final Token name = expect(Token.Type.IDENT);
         if (isAny("AS")) {
             next();
-            expect(Token.Type.IDENT); // type name: SINGLE, INTEGER, ...
+            final Token type = expect(Token.Type.IDENT); // SINGLE, INTEGER, ...
+            final char last = name.text().charAt(name.text().length() - 1);
+            if ("%&!#$".indexOf(last) >= 0) {
+                return new Ast.Param(name.text(), true); // Already suffixed; keep it.
+            }
+            return new Ast.Param(name.text() + switch (type.text().toUpperCase()) {
+                case "INTEGER" -> '%';
+                case "LONG" -> '&';
+                case "DOUBLE" -> '#';
+                case "STRING" -> '$';
+                default -> '!'; // SINGLE and anything else
+            }, true);
         }
         }
-        return name.text();
+        return new Ast.Param(name.text(), false);
     }
 
     /** {@code CALL name [(arg, ...)]} */
     }
 
     /** {@code CALL name [(arg, ...)]} */
@@ -336,14 +354,14 @@ public final class Parser {
         final Token keyword = next(); // DECLARE
         final String kind = expect(Token.Type.IDENT).text().toUpperCase();
         final Token name = expect(Token.Type.IDENT);
         final Token keyword = next(); // DECLARE
         final String kind = expect(Token.Type.IDENT).text().toUpperCase();
         final Token name = expect(Token.Type.IDENT);
-        final List<String> params = new ArrayList<>();
+        final List<Ast.Param> params = new ArrayList<>();
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
         if (peek().isText("(")) {
             next();
             if (!peek().isText(")")) {
-                params.add(paramName());
+                params.add(param());
                 while (peek().isText(",")) {
                     next();
                 while (peek().isText(",")) {
                     next();
-                    params.add(paramName());
+                    params.add(param());
                 }
             }
             expectOp(")");
                 }
             }
             expectOp(")");
@@ -443,11 +461,11 @@ public final class Parser {
         return new Ast.CloseStmt(expression(), keyword.line());
     }
 
         return new Ast.CloseStmt(expression(), keyword.line());
     }
 
-    /** {@code INPUT #channel, var, ...} — file form only; console INPUT unsupported. */
+    /** {@code INPUT [#channel, | [;]["prompt"{;|,}]] var, ...}. */
     private Ast.Stmt inputStatement() {
         final Token keyword = next(); // INPUT
         if (!peek().isText("#")) {
     private Ast.Stmt inputStatement() {
         final Token keyword = next(); // INPUT
         if (!peek().isText("#")) {
-            throw error("Console INPUT is not implemented yet (only INPUT #file)", keyword);
+            return consoleInputStatement(keyword);
         }
         next(); // #
         final Ast.Expr channel = expression();
         }
         next(); // #
         final Ast.Expr channel = expression();
@@ -459,6 +477,39 @@ public final class Parser {
         return new Ast.InputFileStmt(channel, targets, keyword.line());
     }
 
         return new Ast.InputFileStmt(channel, targets, keyword.line());
     }
 
+    /**
+     * Console INPUT: optional {@code ;} after the keyword (its
+     * cursor-on-same-line effect is cosmetic and ignored), then an
+     * optional string prompt closed by {@code ;} (append "? ") or
+     * {@code ,} (prompt only), then the comma-separated targets. The
+     * bare form prints "? ".
+     */
+    private Ast.Stmt consoleInputStatement(final Token keyword) {
+        if (peek().isText(";")) {
+            next();
+        }
+        Ast.Expr prompt = null;
+        boolean questionMark = true;
+        if (peek().is(Token.Type.STRING)) {
+            prompt = expression();
+            if (peek().isText(";")) {
+                next();
+            } else if (peek().isText(",")) {
+                next();
+                questionMark = false;
+            } else {
+                throw error("INPUT: expected ';' or ',' after the prompt", peek());
+            }
+        }
+        final List<Ast.ReadTarget> targets = new ArrayList<>();
+        targets.add(readTarget());
+        while (peek().isText(",")) {
+            next();
+            targets.add(readTarget());
+        }
+        return new Ast.ConsoleInputStmt(prompt, questionMark, targets, keyword.line());
+    }
+
     /** {@code ERASE name, name, ...}. */
     private Ast.Stmt eraseStatement() {
         final Token keyword = next(); // ERASE
     /** {@code ERASE name, name, ...}. */
     private Ast.Stmt eraseStatement() {
         final Token keyword = next(); // ERASE
@@ -908,6 +959,16 @@ public final class Parser {
         return new Ast.SoundStmt(frequency, duration, keyword.line());
     }
 
         return new Ast.SoundStmt(frequency, duration, keyword.line());
     }
 
+    /**
+     * {@code BEEP}: the classic 800 Hz beep, about 1/4 second — desugars
+     * to {@code SOUND 800, 4.55} (durations are 18.2 Hz timer ticks).
+     */
+    private Ast.Stmt beepStatement() {
+        final Token keyword = next(); // BEEP
+        return new Ast.SoundStmt(new Ast.Num(800, false, keyword.line()),
+                new Ast.Num(4.55, false, keyword.line()), keyword.line());
+    }
+
     private Ast.Stmt assignmentOrCallStatement() {
         final Token name = next(); // variable, label or SUB name
         if (peek().isText(":")) {
     private Ast.Stmt assignmentOrCallStatement() {
         final Token name = next(); // variable, label or SUB name
         if (peek().isText(":")) {
@@ -941,6 +1002,16 @@ public final class Parser {
                 next(); // =
                 return new Ast.AssignStmt(name.text(), indices, expression(), name.line());
             }
                 next(); // =
                 return new Ast.AssignStmt(name.text(), indices, expression(), name.line());
             }
+            if (peek().isText(",")) {
+                // Bare SUB call whose first argument is parenthesized:
+                // `prn (x - 1) * 2, 10, a$` (checkers2). Classic QBasic reads
+                // the parens as grouping, not as call syntax, so the comma
+                // list continues the argument list.
+                while (peek().isText(",")) {
+                    next();
+                    indices.add(expression());
+                }
+            }
             return new Ast.CallStmt(name.text(), indices, name.line());
         }
         // Bare SUB call: Name arg, arg, ...
             return new Ast.CallStmt(name.text(), indices, name.line());
         }
         // Bare SUB call: Name arg, arg, ...
index 5d560f4..0db9f6c 100644 (file)
@@ -78,5 +78,9 @@ final class DefaultPalettes {
             palette[2] = 0xFF55FF;
             palette[3] = 0xFFFFFF;
         }
             palette[2] = 0xFF55FF;
             palette[3] = 0xFFFFFF;
         }
+        if (mode == ScreenMode.SCREEN_2) {
+            // CGA 640x200 monochrome: 1 = white, not EGA blue.
+            palette[1] = 0xFFFFFF;
+        }
     }
 }
     }
 }
index 906611f..afbfb44 100644 (file)
@@ -105,11 +105,15 @@ public enum ScreenMode {
 
     /**
      * @return the foreground color active right after
 
     /**
      * @return the foreground color active right after
-     * {@code SCREEN}: 7 (light gray) in text mode, 15 (bright white) in
-     * graphics modes.
+     * {@code SCREEN}: 7 (light gray) in text mode; in graphics modes the
+     * mode's highest color number (1 for SCREEN 2/11, 3 for SCREEN 1/10,
+     * 15 for the 16-color modes, 255 for SCREEN 13). Programs that draw
+     * or PAINT without a color argument get this, so a wrong default is
+     * visible immediately (checkers.bas flooded the whole screen when
+     * SCREEN 2 defaulted to 15: PAINT's border never matched the walls).
      */
     public int defaultForeground() {
      */
     public int defaultForeground() {
-        return this == SCREEN_0 ? 7 : 15;
+        return this == SCREEN_0 ? 7 : colors - 1;
     }
 
     /**
     }
 
     /**
@@ -121,6 +125,18 @@ public enum ScreenMode {
         return 4.0 * pixelHeight / (3.0 * pixelWidth);
     }
 
         return 4.0 * pixelHeight / (3.0 * pixelWidth);
     }
 
+    /**
+     * Whether display output (window, screenshots) should double this
+     * mode vertically: true for the 640x200 modes (SCREEN 2, 8), whose
+     * CRT pixels are roughly twice as tall as wide. The 320x200 modes
+     * (SCREEN 1, 7, 13) stay square — their true CRT stretch is only
+     * ~1.2x, so doubling them looks vertically squashed.
+     */
+    public static boolean needsVerticalDoubling(final ScreenMode mode) {
+        return mode.pixelHeight <= 200 && mode.pixelWidth >= 640
+                && mode != SCREEN_0;
+    }
+
     /**
      * @param number SCREEN mode number
      * @return the matching mode
     /**
      * @param number SCREEN mode number
      * @return the matching mode
index d3d87ed..eb31843 100644 (file)
@@ -41,6 +41,17 @@ public final class VgaDevice {
         for (int page = 0; page < consoles.length; page++) {
             consoles[page] = new TextConsole(framebuffer, newMode, page);
         }
         for (int page = 0; page < consoles.length; page++) {
             consoles[page] = new TextConsole(framebuffer, newMode, page);
         }
+        if (modeListener != null) {
+            modeListener.run();
+        }
+    }
+
+    /** Listener notified after every mode switch (may run on the program thread). */
+    private Runnable modeListener;
+
+    /** Registers a listener notified after every mode switch. */
+    public void setModeListener(final Runnable listener) {
+        modeListener = listener;
     }
 
     public ScreenMode mode() {
     }
 
     public ScreenMode mode() {
@@ -532,10 +543,25 @@ public final class VgaDevice {
 
     /**
      * Writes the visual page as a PNG screenshot — the headless equivalent of
 
     /**
      * Writes the visual page as a PNG screenshot — the headless equivalent of
-     * looking at the monitor.
+     * looking at the monitor. Aspect-corrected like the Swing window: the
+     * 640x200 modes (SCREEN 2, 8) are doubled vertically, since a 4:3 CRT
+     * shows their pixels twice as tall as wide; 320-wide modes stay square.
      */
     public void dumpPng(final Path output) throws IOException {
      */
     public void dumpPng(final Path output) throws IOException {
-        framebuffer.dumpPng(visualPage, output);
+        final java.awt.image.BufferedImage nativeImage = framebuffer.toImage(visualPage);
+        if (!ScreenMode.needsVerticalDoubling(mode)) {
+            javax.imageio.ImageIO.write(nativeImage, "png", output.toFile());
+            return;
+        }
+        final java.awt.image.BufferedImage scaled = new java.awt.image.BufferedImage(
+                nativeImage.getWidth(), nativeImage.getHeight() * 2,
+                java.awt.image.BufferedImage.TYPE_INT_RGB);
+        final java.awt.Graphics2D g = scaled.createGraphics();
+        g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,
+                java.awt.RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+        g.drawImage(nativeImage, 0, 0, scaled.getWidth(), scaled.getHeight(), null);
+        g.dispose();
+        javax.imageio.ImageIO.write(scaled, "png", output.toFile());
     }
 
     /** Raw framebuffer access for tests and VM introspection. */
     }
 
     /** Raw framebuffer access for tests and VM introspection. */
index 728fb56..c61a022 100644 (file)
@@ -82,6 +82,16 @@ class InterpreterFeaturesTest {
         assertEquals("", interpreter.variable("a$"));
     }
 
         assertEquals("", interpreter.variable("a$"));
     }
 
+    @Test
+    void beepDesugarsToClassicSound() {
+        // Games/checkers.bas line 222: BEEP after a move.
+        final Interpreter interpreter = run("""
+                BEEP
+                r = 1
+                """);
+        assertEquals(1.0, interpreter.variable("r"));
+    }
+
     @Test
     void sleepWithoutArgumentReturnsImmediatelyWhenHeadless() {
         assertTimeoutPreemptively(Duration.ofSeconds(2),
     @Test
     void sleepWithoutArgumentReturnsImmediatelyWhenHeadless() {
         assertTimeoutPreemptively(Duration.ofSeconds(2),
@@ -100,6 +110,154 @@ class InterpreterFeaturesTest {
         assertTrue(!keys.isEmpty());
     }
 
         assertTrue(!keys.isEmpty());
     }
 
+    /** Feeds the queue one typed line (Enter-terminated) and runs the source. */
+    private static Interpreter runWithInput(final String source, final String typedLine) {
+        final KeyboardQueue keys = new KeyboardQueue();
+        for (final char c : typedLine.toCharArray()) {
+            keys.pushChar(c);
+        }
+        keys.pushChar('\n');
+        final Interpreter interpreter = new Interpreter(
+                new VgaDevice(), keys, new SoundQueue());
+        interpreter.setBlockOnInput(false);
+        interpreter.run(Parser.parse(source));
+        return interpreter;
+    }
+
+    @Test
+    void consoleInputReadsNumberAfterCommaPrompt() {
+        // Games/Worm/worm.bas: INPUT "How many players (1 - 5):", playerCount
+        final Interpreter interpreter = runWithInput("INPUT \"n:\", n", "12");
+        assertEquals(12.0, interpreter.variable("n"));
+    }
+
+    @Test
+    void consoleInputReadsStringWithSemicolonPrompt() {
+        final Interpreter interpreter = runWithInput("INPUT \"name\"; n$", "abc def");
+        assertEquals("abc def", interpreter.variable("n$"));
+    }
+
+    @Test
+    void consoleInputSplitsCommaSeparatedTargets() {
+        final Interpreter interpreter = runWithInput("INPUT a, b$, c", "3, hi ,7.5");
+        assertEquals(3.0, interpreter.variable("a"));
+        assertEquals("hi", interpreter.variable("b$"));
+        assertEquals(7.5, interpreter.variable("c"));
+    }
+
+    @Test
+    void consoleInputEmptyLineReadsAsZero() {
+        final Interpreter interpreter = runWithInput("INPUT n", "");
+        assertEquals(0.0, interpreter.variable("n"));
+    }
+
+    @Test
+    void subParamAsSingleOverridesDefint() {
+        // Games/Worm/worm.bas: DEFINT A-Z must not narrow a SUB parameter
+        // declared AS SINGLE (delay .5 / spd must stay 0.5, not round to 0).
+        final Interpreter interpreter = run("""
+                DECLARE SUB probe (x AS SINGLE)
+                DEFINT A-Z
+                DIM SHARED result!
+                probe .5
+                SUB probe (x AS SINGLE)
+                result! = x
+                END SUB
+                """);
+        assertEquals(0.5, interpreter.variable("result!"));
+    }
+
+    @Test
+    void gotoJumpsIntoNestedBlockAndLoopsKeepIterating() {
+        // Flat QBasic semantics: GOTO may land on a label nested inside
+        // IF/FOR bodies; the enclosing loop resumes with current
+        // variable values (the FOR variable is NOT re-initialized).
+        final Interpreter interpreter = run("""
+                DIM SHARED log$
+                FOR i = 1 TO 3
+                    IF i = 2 THEN
+                inner:
+                        log$ = log$ + STR$(i)
+                    END IF
+                NEXT i
+                IF jumped = 1 THEN GOTO past
+                i = 2
+                jumped = 1
+                GOTO inner
+                past:
+                log$ = log$ + " done"
+                """);
+        // " 2" from the normal loop pass, " 2" from the resumed pass
+        // (the FOR then steps i = 3, 4 and finishes), then " done".
+        assertEquals(" 2 2 done", interpreter.variable("log$"));
+    }
+
+    @Test
+    void gotoIntoNestedBlockCanJumpBackOut() {
+        // Games/checkers2.bas compgo: GoTo 8 re-enters the finished
+        // capture-scan loops; the resumed stretch ends with a GOTO back
+        // to a body-level label (GoTo 9).
+        final Interpreter interpreter = run("""
+                DIM SHARED hits
+                FOR x = 1 TO 2
+                    IF x = 1 THEN
+                scan:
+                        hits = hits + 1
+                        IF done1 = 1 THEN GOTO fin
+                    END IF
+                NEXT x
+                done1 = 1
+                x = 1
+                GOTO scan
+                fin:
+                hits = hits + 100
+                """);
+        // 1 from the normal pass, 1 from the resumed pass, +100 at fin.
+        assertEquals(102.0, interpreter.variable("hits"));
+    }
+
+    @Test
+    void suffixParamDoesNotClaimBareNameUnderDefint() {
+        // Games/checkers2.bas prn: parameter `a$` must NOT alias the bare
+        // name `a` — under DEFINT A-Z the loop variable `a` is a distinct
+        // integer, while `a$` stays the string argument.
+        final Interpreter interpreter = run("""
+                DECLARE SUB prn (a$)
+                DEFINT A-Z
+                DIM SHARED total, first
+                prn "hey"
+                SUB prn (a$)
+                total = 0
+                FOR a = 1 TO LEN(a$)
+                total = total + 1
+                NEXT a
+                first = ASC(LEFT$(a$, 1))
+                END SUB
+                """);
+        assertEquals(3.0, interpreter.variable("total"));
+        assertEquals((double) 'h', interpreter.variable("first"));
+    }
+
+    @Test
+    void bareCallAllowsParenthesizedFirstArgument() {
+        // Games/checkers2.bas show: `prn ((x - 1) * rs + 12 + sp), 2, 10, c$`
+        // — the parens are grouping, not call syntax; the comma list
+        // continues the argument list.
+        final Interpreter interpreter = run("""
+                DECLARE SUB probe (a, b, c)
+                DIM SHARED ra, rb, rc
+                probe (1 + 2), 4, 5
+                SUB probe (a, b, c)
+                ra = a
+                rb = b
+                rc = c
+                END SUB
+                """);
+        assertEquals(3.0, interpreter.variable("ra"));
+        assertEquals(4.0, interpreter.variable("rb"));
+        assertEquals(5.0, interpreter.variable("rc"));
+    }
+
     @Test
     void elseIfPicksTheFirstMatchingBranch() {
         final Interpreter interpreter = run("""
     @Test
     void elseIfPicksTheFirstMatchingBranch() {
         final Interpreter interpreter = run("""
index 9f24c45..e01cefc 100644 (file)
@@ -35,12 +35,29 @@ class VgaDeviceTest {
         assertEquals(200, vga.framebuffer().height());
     }
 
         assertEquals(200, vga.framebuffer().height());
     }
 
+    @Test
+    void defaultForegroundIsHighestAttributeOfMode() {
+        // Games/checkers.bas: PAINT without a color uses the default
+        // foreground; when SCREEN 2 defaulted to 15 the fill flooded
+        // through the color-1 grid walls and painted the whole screen.
+        final VgaDevice vga = new VgaDevice();
+        assertEquals(7, vga.textForeground()); // SCREEN 0 text default
+        vga.setMode(ScreenMode.SCREEN_2);
+        assertEquals(1, vga.textForeground());
+        vga.setMode(ScreenMode.SCREEN_1);
+        assertEquals(3, vga.textForeground());
+        vga.setMode(ScreenMode.SCREEN_7);
+        assertEquals(15, vga.textForeground());
+        vga.setMode(ScreenMode.SCREEN_13);
+        assertEquals(255, vga.textForeground());
+    }
+
     @Test
     void printDrawsGlyphPixelsInGraphicsMode() {
         final VgaDevice vga = new VgaDevice();
         vga.setMode(ScreenMode.SCREEN_13);
         vga.print("A");
     @Test
     void printDrawsGlyphPixelsInGraphicsMode() {
         final VgaDevice vga = new VgaDevice();
         vga.setMode(ScreenMode.SCREEN_13);
         vga.print("A");
-        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = bright white in gfx modes
+        assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = highest attribute (white in mode 13 default palette)
         assertEquals(1, vga.cursorRow());
         assertEquals(2, vga.cursorColumn());
     }
         assertEquals(1, vga.cursorRow());
         assertEquals(2, vga.cursorColumn());
     }
@@ -55,7 +72,7 @@ class VgaDeviceTest {
         // The whole cell is painted; that is why PRINTing spaces
         // erases text even in graphics modes.
         assertEquals(0, vga.point(0, 0), "off-pixel must be painted with background color");
         // The whole cell is painted; that is why PRINTing spaces
         // erases text even in graphics modes.
         assertEquals(0, vga.point(0, 0), "off-pixel must be painted with background color");
-        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color");
+        assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color");
     }
 
     @Test
     }
 
     @Test
@@ -94,7 +111,7 @@ class VgaDeviceTest {
         vga.print("A"); // marker on row 1
         vga.locate(25, 1);
         vga.print("X".repeat(40)); // fills the last cell exactly
         vga.print("A"); // marker on row 1
         vga.locate(25, 1);
         vga.print("X".repeat(40)); // fills the last cell exactly
-        assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact");
+        assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact");
 
         vga.print("X"); // one more character wraps and scrolls
         assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "scroll: marker scrolled off the top");
 
         vga.print("X"); // one more character wraps and scrolls
         assertEquals(0, vga.point(A_STROKE_X, A_STROKE_Y), "scroll: marker scrolled off the top");
@@ -125,7 +142,7 @@ class VgaDeviceTest {
         for (int i = 0; i < 15; i++) {
             vga.print("scrolling line " + i + "\n");
         }
         for (int i = 0; i < 15; i++) {
             vga.print("scrolling line " + i + "\n");
         }
-        assertEquals(15, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move");
+        assertEquals(255, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move");
         assertEquals(0, vga.point(A_STROKE_X, (20 - 1) * 8), "band bottom row was cleared");
         assertEquals(20, vga.cursorRow());
     }
         assertEquals(0, vga.point(A_STROKE_X, (20 - 1) * 8), "band bottom row was cleared");
         assertEquals(20, vga.cursorRow());
     }