From: Svjatoslav Agejenko Date: Sat, 22 Aug 2026 15:28:12 +0000 (+0300) Subject: feat: add console INPUT, BEEP, nested-label GOTO and screen fixes X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;ds=inline;p=crtbasic.git feat: add console INPUT, BEEP, nested-label GOTO and screen fixes - 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. --- diff --git a/Documentation/language/index.org b/Documentation/language/index.org index 1a84013..16d2503 100644 --- a/Documentation/language/index.org +++ b/Documentation/language/index.org @@ -114,7 +114,7 @@ DIM names$(20) AS STRING ' AS type form | 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 | @@ -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) | -| =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 | @@ -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. +- 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=. @@ -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 / =""= | +| =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 | +| =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 | diff --git a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java index 570a2c4..1d6eba9 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java +++ b/src/main/java/eu/svjatoslav/crtbasic/ast/Ast.java @@ -64,11 +64,21 @@ public final class Ast { /** * {@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 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 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. @@ -202,8 +212,18 @@ public final class Ast { public record ConstStmt(List 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 */ - public record SubStmt(String name, List params, List body, int line) implements Stmt { + public record SubStmt(String name, List params, List 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). */ - public record FunctionStmt(String name, List params, List body, int line) implements Stmt { + public record FunctionStmt(String name, List params, List body, int line) implements Stmt { } /** @@ -312,7 +332,7 @@ public final class Ast { * * @param what e.g. {@code "SUB DrawLine"} (name as written) */ - public record DeclareStmt(String what, List params, int line) implements Stmt { + public record DeclareStmt(String what, List params, 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 116f2e3..0b9fea1 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java +++ b/src/main/java/eu/svjatoslav/crtbasic/frontend/SwingFrontend.java @@ -60,6 +60,7 @@ public final class SwingFrontend { private final String baseTitle; private final Path programDirectory; private final String programBaseName; + private JPanel canvas; 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 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; @@ -98,8 +98,8 @@ public final class SwingFrontend { 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 @@ -129,6 +129,7 @@ public final class SwingFrontend { frame.setContentPane(canvas); frame.pack(); frame.setLocationRelativeTo(null); + vga.setModeListener(this::modeChanged); new Timer(REPAINT_MS, e -> canvas.repaint()).start(); } @@ -138,9 +139,35 @@ public final class SwingFrontend { 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() { - frame.pack(); + SwingUtilities.invokeLater(() -> { + canvas.setPreferredSize(windowSize(vga.mode())); + frame.pack(); + }); } // ---------------------------------------------------------- screenshot diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 46271da..0ebd829 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -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. */ - private final Map> declaredParams = new HashMap<>(); + private final Map> declaredParams = new HashMap<>(); /** * 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 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 stmts, final int startPc) { Map labels = null; - int pc = 0; + int pc = startPc; while (pc < stmts.size()) { final Ast.Stmt stmt = stmts.get(pc++); try { @@ -367,7 +383,7 @@ public final class Interpreter { continue; } exec(stmt); - } catch (final GotoJump jump) { + } catch (final GotoJump caught) { 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 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 @@ -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 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 findNestedLabel(final List stmts, final String label) { + for (final Ast.Stmt stmt : stmts) { + for (final List 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 path = new ArrayList<>(); + path.add(new NestedLabelStep(stmt, child, i)); + return path; + } + } + final List 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> 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> 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 stmts, final List 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 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 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); @@ -494,6 +666,7 @@ public final class Interpreter { 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 @@ -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. */ - private Map bindScope(final String procName, final List headerParams, + private Map bindScope(final String procName, final List headerParams, final List args, final int line) { if (args.size() != headerParams.size()) { throw error("'" + procName + "' expects " + headerParams.size() + " argument(s), got " + args.size(), line); } - final List declared = declaredParams.getOrDefault(baseName(procName), List.of()); + final List declared = declaredParams.getOrDefault(baseName(procName), List.of()); final Map 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); + // 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; } + /** 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). @@ -1620,6 +1808,113 @@ public final class Interpreter { 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 diff --git a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java index e510d37..dd56223 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java +++ b/src/main/java/eu/svjatoslav/crtbasic/parser/Parser.java @@ -79,6 +79,7 @@ public final class Parser { case "LINE" -> lineStatement(); case "CIRCLE" -> circleStatement(); case "SOUND" -> soundStatement(); + case "BEEP" -> beepStatement(); 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); - final List params = new ArrayList<>(); + final List params = new ArrayList<>(); if (peek().isText("(")) { next(); if (!peek().isText(")")) { - params.add(paramName()); + params.add(param()); while (peek().isText(",")) { next(); - params.add(paramName()); + params.add(param()); } } 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); - final List params = new ArrayList<>(); + final List params = new ArrayList<>(); if (peek().isText("(")) { next(); if (!peek().isText(")")) { - params.add(paramName()); + params.add(param()); while (peek().isText(",")) { next(); - params.add(paramName()); + params.add(param()); } } expectOp(")"); @@ -297,14 +298,31 @@ public final class Parser { 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(); - 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, ...)]} */ @@ -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 List params = new ArrayList<>(); + final List params = new ArrayList<>(); if (peek().isText("(")) { next(); if (!peek().isText(")")) { - params.add(paramName()); + params.add(param()); while (peek().isText(",")) { next(); - params.add(paramName()); + params.add(param()); } } expectOp(")"); @@ -443,11 +461,11 @@ public final class Parser { 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("#")) { - throw error("Console INPUT is not implemented yet (only INPUT #file)", keyword); + return consoleInputStatement(keyword); } next(); // # final Ast.Expr channel = expression(); @@ -459,6 +477,39 @@ public final class Parser { 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 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 @@ -908,6 +959,16 @@ public final class Parser { 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(":")) { @@ -941,6 +1002,16 @@ public final class Parser { 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, ... diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java index 5d560f4..0db9f6c 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/DefaultPalettes.java @@ -78,5 +78,9 @@ final class DefaultPalettes { palette[2] = 0xFF55FF; palette[3] = 0xFFFFFF; } + if (mode == ScreenMode.SCREEN_2) { + // CGA 640x200 monochrome: 1 = white, not EGA blue. + palette[1] = 0xFFFFFF; + } } } diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java index 906611f..afbfb44 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/ScreenMode.java @@ -105,11 +105,15 @@ public enum ScreenMode { /** * @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() { - 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); } + /** + * 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 diff --git a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java index d3d87ed..eb31843 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java +++ b/src/main/java/eu/svjatoslav/crtbasic/video/VgaDevice.java @@ -41,6 +41,17 @@ public final class VgaDevice { 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() { @@ -532,10 +543,25 @@ public final class VgaDevice { /** * 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 { - 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. */ diff --git a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java index 728fb56..c61a022 100644 --- a/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java +++ b/src/test/java/eu/svjatoslav/crtbasic/interp/InterpreterFeaturesTest.java @@ -82,6 +82,16 @@ class InterpreterFeaturesTest { 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), @@ -100,6 +110,154 @@ class InterpreterFeaturesTest { 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(""" diff --git a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java index 9f24c45..e01cefc 100644 --- a/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java +++ b/src/test/java/eu/svjatoslav/crtbasic/video/VgaDeviceTest.java @@ -35,12 +35,29 @@ class VgaDeviceTest { 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"); - 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()); } @@ -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"); - 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 @@ -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 - 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"); @@ -125,7 +142,7 @@ class VgaDeviceTest { 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()); }