| 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 |
| =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 |
- =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=.
| =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 |
/**
* {@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 {
}
+ /**
+ * {@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.
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 */
- 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 {
}
/**
* 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 {
}
/**
*
* @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 {
}
/**
private final String baseTitle;
private final Path programDirectory;
private final String programBaseName;
+ private JPanel canvas;
private ScreenRecorder recorder;
private Timer titleTimer;
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;
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
frame.setContentPane(canvas);
frame.pack();
frame.setLocationRelativeTo(null);
+ vga.setModeListener(this::modeChanged);
new Timer(REPAINT_MS, e -> canvas.repaint()).start();
}
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
* 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.
* 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;
- int pc = 0;
+ int pc = startPc;
while (pc < stmts.size()) {
final Ast.Stmt stmt = stmts.get(pc++);
try {
continue;
}
exec(stmt);
- } catch (final GotoJump jump) {
+ } catch (final GotoJump caught) {
if (++executed > maxStatements) {
throw new StepsLimitException(executed);
}
}
}
}
- 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
}
}
+ /**
+ * 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);
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
* 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<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 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).
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
case "LINE" -> lineStatement();
case "CIRCLE" -> circleStatement();
case "SOUND" -> soundStatement();
+ case "BEEP" -> beepStatement();
case "SLEEP" -> sleepStatement();
case "OUT" -> outStatement();
case "RANDOMIZE" -> randomizeStatement();
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(")")) {
- params.add(paramName());
+ params.add(param());
while (peek().isText(",")) {
next();
- params.add(paramName());
+ params.add(param());
}
}
expectOp(")");
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(")")) {
- params.add(paramName());
+ params.add(param());
while (peek().isText(",")) {
next();
- params.add(paramName());
+ params.add(param());
}
}
expectOp(")");
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, ...)]} */
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(")")) {
- params.add(paramName());
+ params.add(param());
while (peek().isText(",")) {
next();
- params.add(paramName());
+ params.add(param());
}
}
expectOp(")");
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();
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
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(":")) {
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, ...
palette[2] = 0xFF55FF;
palette[3] = 0xFFFFFF;
}
+ if (mode == ScreenMode.SCREEN_2) {
+ // CGA 640x200 monochrome: 1 = white, not EGA blue.
+ palette[1] = 0xFFFFFF;
+ }
}
}
/**
* @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;
}
/**
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
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() {
/**
* 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. */
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),
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("""
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());
}
// 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
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");
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());
}