| Bound endless loops | =--steps=N= statement budget |
| See the screen | =--screenshot=out.png= (visual page as PNG) |
| Compare pixels exactly | =--raw=out.raw= (raw indexed video memory) |
-| Press keys programmatically | =GameDriver= scripted keystrokes |
-| Read program state | =--vars=a%,score= prints variables after the run |
-| Count work done | "Statements executed" report |
+|| Press keys programmatically | =GameDriver= scripted keystrokes |
+|| Move/click the mouse programmatically | =GameDriver --mouse= scripted deltas and button presses |
+|| Read program state | =--vars=a%,score= prints variables after the run |
+|| Count work done | "Statements executed" report |
The interactive window and the headless path share the same
=VgaDevice= and =KeyboardQueue=, so a program cannot tell which one
it runs under.
+In the interactive window the mouse uses a grab model, DOSBox-style:
+the program sees no mouse input until you click the window, which
+captures the pointer (cursor hidden, movement reported as deltas).
+=Ctrl+F10= releases the grab. The grab never warps the pointer —
+on Wayland, XTest injection (=Robot=, xdotool) goes through a
+RemoteDesktop permission popup, and =Robot.mouseMove= hangs on some X
+servers. Instead, capturing covers the whole desktop (all screens)
+with a transparent, always-on-top overlay window that receives every
+mouse event, so the pointer effectively cannot leave the program.
+Without a compositor (translucency unsupported) the grab degrades to
+in-window tracking.
+
* Command line
:PROPERTIES:
:CUSTOM_ID: command-line
| =~= | Pause: no key, just let time pass |
| anything else | The typed character itself |
+A =--mouse= script feeds the emulated mouse TSR: space-separated
+tokens, one event per interval, interleaved with the key script:
+
+| Token | Meaning |
+|---------+---------|
+| =dx,dy= | movement delta in video-mode pixels (=-10,0=, =0,5=) |
+| =b0= =b1= =b2= | left / right / middle button press |
+
After the script is exhausted the program keeps running until it ends
or the statement budget (default 8M) is spent. The named =--vars= are
then printed from the global scope — so an agent can verify "after
interpreter.setBaseDirectory(path.getParent()); // OPEN resolves here
keys.pushChar(' '); // inject input any time
+interpreter.machine().mouse().pushMove(5, 0); // inject mouse (qbext TSR)
interpreter.run(program);
vga.dumpPng(Path.of("out.png")); // screenshot
| Statement | Notes |
|-----------+-------|
-| =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 |
+| =SCREEN mode=, =SCREEN m,,,apage,vpage= | Modes 0, 1, 2, 7–13; switches resolution, palette, page count; default foreground becomes the mode's highest attribute (1/3/15), except SCREEN 13 where real QBasic defaults to 15; background black |
| =PSET (x, y)[, color]= | One pixel; default color = current text foreground |
| =POINT(x, y)= | Function: palette index of a pixel |
| =LINE (x1,y1)-(x2,y2)[, color]= | Pixel-exact DDA line, clipped like the original |
| Statement | Notes |
|-----------+-------|
| =PRINT expr [; or ,] …= | =;= compact, =,= next 14-column print zone; trailing separator suppresses the newline |
-| =LOCATE row, col= | 1-based cursor position |
+| =LOCATE row[, col]= | 1-based cursor position; omitted col keeps the current column |
| =COLOR fg[, bg]= | Text colors (palette indices) |
| =WIDTH cols[, rows]= | Text grid: 80x25 (default), 40x25 or 80x50 in =SCREEN 0=; 40 columns render double-wide glyphs, 50 rows swap to the 8x8 font; clears the screen |
| =CLS= | Clears to background, homes cursor |
|-----------+-------|
| =DATA v, …= / =READ var, …= | DATA is module-wide, collected in program order; running out raises *Out of DATA* |
| =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 |
+| =OPEN path FOR INPUT AS #n= | Text files; DOS paths resolve relative to the =.bas= file (or the =CHDIR= state): backslash separators and case-insensitive components like the DOS filesystem |
+| =OPEN path FOR OUTPUT/APPEND AS #n=, =PRINT #n, ...= | Writing; OUTPUT truncates, APPEND adds at end; =PRINT #= uses console formatting with CRLF line endings |
+| =CHDIR path$=, =MKDIR path$= | Current-directory state and directory creation; =MKDIR= on an existing directory raises *Path/file access error* like real QBasic |
| =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 |
| =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 |
+| =DEF SEG = segment= | Selects the segment =PEEK= / =POKE= operate on; bare =DEF SEG= resets to 0 |
+| =POKE addr, value= | Writes the low byte of value at DEF SEG:addr in the emulated machine RAM |
* Expressions and operators
:PROPERTIES:
| =POINT(x, y)= | palette index of a pixel |
| =POS(0)=, =CSRLIN= | cursor column / row (1-based) |
| =CHR$(n)=, =ASC(s$)= | character ↔ code |
-| =STR$(n)=, =VAL(s$)= | number ↔ string (=VAL= of junk is 0) |
+| =STR$(n)=, =VAL(s$)= | number ↔ string; =VAL= parses the longest numeric prefix (junk tail ignored, junk start is 0) and understands =&H…= / =&O…= with the literal's signed semantics (=VAL("&HFFFD")= is -3) |
+| =HEX$(n)= | uppercase hex, no prefix; INTEGER-range negatives print as 16-bit two's complement (=-3= becomes ="FFFD") |
+| =PEEK(addr)= | byte at DEF SEG:addr in the emulated machine RAM |
| =LEN=, =LEFT$=, =RIGHT$=, =MID$= | string length and slices (1-based) |
| =UCASE$=, =LCASE$= | case conversion |
| =LTRIM$=, =RTRIM$= | strip spaces |
whatever a driver pushed into the keyboard queue and return =""= when
it is empty.
+* Machine memory and the emulated mouse driver
+:PROPERTIES:
+:CUSTOM_ID: machine-memory
+:END:
+
+=DEF SEG=, =PEEK= and =POKE= operate on an emulated 1 MiB real-mode
+address space (physical address = segment * 16 + offset, wrapping at
+20 bits). Most of it is plain RAM; one region is live.
+
+CRT Basic behaves as if the qbext mouse TSR (qbasicapps "Mouse
+driver") is always loaded: the IVT slot of INT 79h holds a far pointer
+to an 8-byte data table, and mouse events from the window frontend (or
+a headless driver) accumulate into it. Programs that speak the TSR
+protocol — discover the pointer with =DEF SEG = 0= and =PEEK(&H79 * 4
++ n)=, verify the magic word 1983, then read-and-reset the fields —
+work unmodified.
+
+Table layout (little-endian words):
+
+| Offset | Contents |
+|--------+----------|
+| =0x00= | magic number 1983 |
+| =0x02= | horizontal movement, accumulated |
+| =0x04= | vertical movement, accumulated |
+| =0x06= | button status, latched: bit 0 left, 1 right, 2 middle |
+
+Like the real driver, movement deltas wrap at 16 bits (read them as
+signed, e.g. via =VAL("&H" + HEX$(...))=), button bits stay latched
+until the consumer POKEs the field back to 0, and a field reading 0
+means "no new activity". In the window frontend, mouse motion is
+converted to video-mode pixels before being accumulated, so movement
+feels 1:1 at any window size.
+
* Error behavior
:PROPERTIES:
:CUSTOM_ID: errors
final VgaDevice vga, final KeyboardQueue keys,
final Path programPath) {
SwingUtilities.invokeLater(() ->
- new SwingFrontend(vga, keys, "CRT Basic - " + programPath.getFileName(), programPath).show());
+ new SwingFrontend(vga, keys, interpreter.machine().mouse(),
+ "CRT Basic - " + programPath.getFileName(), programPath).show());
final Thread programThread = new Thread(() -> {
try {
interpreter.run(program);
public record PcopyStmt(Expr sourcePage, Expr targetPage, int line) implements Stmt {
}
- /** {@code LOCATE row, col} */
+ /** {@code LOCATE row[, col]} — a null col keeps the current column. */
public record LocateStmt(Expr row, Expr col, int line) implements Stmt {
}
+ /**
+ * {@code DEF SEG = segment} — sets the segment {@code PEEK}/{@code POKE}
+ * operate on. The bare form ({@code DEF SEG}) resets to the default
+ * segment (0 in the emulator); a null segment means the bare form.
+ */
+ public record DefSegStmt(Expr segment, int line) implements Stmt {
+ }
+
+ /**
+ * {@code POKE address, value} — writes the low byte of value to
+ * emulated machine memory at DEF SEG:address.
+ */
+ public record PokeStmt(Expr address, Expr value, int line) implements Stmt {
+ }
+
/**
* {@code PRINT item;item,item} — items are expressions plus the
* separator that follows them (';' compact, ',' print zone).
* {@code newline} is false when the statement ends with ; or ,.
+ * {@code channel} is non-null for {@code PRINT #n, ...} (file output).
*/
- public record PrintStmt(List<Item> items, boolean newline, int line) implements Stmt {
+ public record PrintStmt(Expr channel, List<Item> items, boolean newline, int line) implements Stmt {
public record Item(Expr expr, char separatorAfter) {
}
}
public record RandomizeStmt(Expr seed, int line) implements Stmt {
}
- /** {@code OPEN path FOR INPUT AS #channel} (only INPUT mode is modeled). */
- public record OpenStmt(Expr path, Expr channel, int line) implements Stmt {
+ /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */
+ public record OpenStmt(Expr path, String mode, Expr channel, int line) implements Stmt {
+ }
+
+ /** {@code CHDIR path$} — changes the current directory. */
+ public record ChdirStmt(Expr path, int line) implements Stmt {
+ }
+
+ /** {@code MKDIR path$} — creates a directory. */
+ public record MkdirStmt(Expr path, int line) implements Stmt {
}
/** {@code CLOSE [#channel]} — a null channel closes all files. */
import eu.svjatoslav.crtbasic.capture.CrtrecEncoder;
import eu.svjatoslav.crtbasic.capture.ScreenRecorder;
import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.machine.QbextMouseTsr;
import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.awt.Color;
+import java.awt.Cursor;
import java.awt.Graphics;
import java.awt.Graphics2D;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
+import java.awt.Point;
+import java.awt.Rectangle;
import java.awt.RenderingHints;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
* on a timer. Key presses are pushed into the shared {@link KeyboardQueue}
* so a running program sees them through {@code INKEY$}/{@code INPUT}.
*
- * <p>Two hotkeys are intercepted here and never reach the program (F11/F12
- * produce no key code a classic program can observe):</p>
+ * <p>Hotkeys intercepted here, never reaching the program (they produce no
+ * key code a classic program can observe):</p>
* <ul>
* <li>{@code F12} — screenshot: the visual page as
* {@code <program> - Screenshot <n>.png} next to the .bas file.</li>
* the recording stops (the intermediate .crtrec and encode log are
* deleted on success, kept on failure). Ongoing recording is shown
* in the window title.</li>
+ * <li>{@code Ctrl+F10} — releases the captured mouse (DOSBox-style).</li>
* </ul>
*
+ * <p>Mouse grab: mouse input reaches the program only after the user clicks
+ * the window. Capturing covers the whole desktop with a transparent,
+ * always-on-top overlay window that receives every mouse event — the cursor
+ * is hidden and effectively cannot leave the program. {@code Ctrl+F10}
+ * releases the grab; the capturing click is consumed. The pointer is never
+ * warped: on Wayland, XTest injection ({@code Robot}, xdotool) goes through
+ * a RemoteDesktop permission popup, and {@code Robot.mouseMove} hangs on
+ * some X servers (Xvfb). Without a compositor (translucency unsupported),
+ * the grab degrades to in-window tracking.</p>
+ *
* <p>Closing the window never abandons work: a recording in progress is
* finalized, all pending MP4 transcodes run to completion behind a small
* progress window, and only then does the JVM exit.</p>
*
* <p>The window is otherwise deliberately dumb: it owns no emulation state,
- * it just renders and forwards keys. Headless drivers use the same
+ * it just renders and forwards keys and mouse. Headless drivers use the same
* VgaDevice and KeyboardQueue without this class.</p>
*/
public final class SwingFrontend {
private static final int REPAINT_MS = 33;
private final VgaDevice vga;
+ private final QbextMouseTsr mouse;
+ private final KeyboardQueue keys;
private final JFrame frame;
private final String baseTitle;
private final Path programDirectory;
private final String programBaseName;
private JPanel canvas;
+ /**
+ * Mouse grab state: the program sees mouse input only while captured
+ * (user clicked the window). While captured the cursor is hidden and
+ * movement feeds the emulated TSR as video-mode-pixel deltas measured
+ * from the previous event ({@link #lastWindowX}/{@link #lastWindowY},
+ * in window coordinates; -1 = no anchor yet). Sub-pixel scaling residue
+ * accumulates in {@link #pendingDeltaX}/{@link #pendingDeltaY} so slow
+ * drags are not rounded away. No pointer warping: on Wayland, Robot
+ * rides the RemoteDesktop portal (permission popup per grab), and
+ * Robot.mouseMove hangs on some X servers (Xvfb).
+ */
+ private boolean mouseCaptured;
+ /**
+ * While captured: an undecorated, fully transparent, always-on-top
+ * window spanning all screens. It receives every mouse event anywhere
+ * on the desktop, so the pointer effectively cannot leave the program
+ * — a true grab with no pointer warping (Robot.mouseMove is unusable:
+ * Wayland routes XTest injection through a RemoteDesktop permission
+ * popup, and it deadlocks on some X servers). Null when translucent
+ * windows are unsupported (no compositor) — the grab then degrades to
+ * plain in-window tracking.
+ */
+ private JFrame grabOverlay;
+ /** Fractional mode-pixel deltas carried between events (scaling residue). */
+ private double pendingDeltaX;
+ private double pendingDeltaY;
+ /** Last mouse position in window pixels; negative = unknown (no delta yet). */
+ private double lastWindowX = -1;
+ private double lastWindowY = -1;
private ScreenRecorder recorder;
private Timer titleTimer;
@SuppressWarnings("serial")
public SwingFrontend(final VgaDevice vgaDevice, final KeyboardQueue keys,
+ final QbextMouseTsr mouseTsr,
final String title, final Path programPath) {
vga = vgaDevice;
+ mouse = mouseTsr;
+ this.keys = keys;
baseTitle = title;
final Path absolute = programPath.toAbsolutePath();
programDirectory = absolute.getParent() == null
canvas.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(final KeyEvent e) {
- if (e.getKeyCode() == KeyEvent.VK_F12) {
- // Hotkey: swallowed here, never pushed into the queue.
- if (e.isShiftDown()) {
- toggleRecording();
- } else {
- screenshot();
- }
+ handleKey(e);
+ }
+ });
+
+ // Mouse events feed the emulated qbext TSR, so programs that read
+ // the driver through DEF SEG / PEEK / POKE see a live mouse —
+ // but only while the grab is active (click the window to capture,
+ // Ctrl+F10 to release).
+ final MouseAdapter mouseHandler = new MouseAdapter() {
+ @Override
+ public void mouseMoved(final MouseEvent e) {
+ trackMovement(e);
+ }
+
+ @Override
+ public void mouseDragged(final MouseEvent e) {
+ trackMovement(e);
+ }
+
+ @Override
+ public void mouseExited(final MouseEvent e) {
+ lastWindowX = -1; // next entry starts a fresh delta
+ }
+
+ @Override
+ public void mousePressed(final MouseEvent e) {
+ if (!mouseCaptured) {
+ captureMouse(); // the capture click is consumed
return;
}
- keys.push(e.getKeyCode(),
- e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar());
+ forwardButton(e);
+ }
+ };
+ canvas.addMouseListener(mouseHandler);
+ canvas.addMouseMotionListener(mouseHandler);
+ canvas.addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(final ComponentEvent e) {
+ lastWindowX = -1; // coordinate mapping changed; drop stale anchor
+ pendingDeltaX = 0;
+ pendingDeltaY = 0;
}
});
new Timer(REPAINT_MS, e -> canvas.repaint()).start();
}
+ /**
+ * Key handling shared by the canvas and the mouse-grab overlay: F12 /
+ * Shift+F12 screenshot + recording, Ctrl+F10 releases the grab, all
+ * other keys go to the program.
+ */
+ private void handleKey(final KeyEvent e) {
+ if (e.getKeyCode() == KeyEvent.VK_F12) {
+ // Hotkey: swallowed here, never pushed into the queue.
+ if (e.isShiftDown()) {
+ toggleRecording();
+ } else {
+ screenshot();
+ }
+ return;
+ }
+ if (e.getKeyCode() == KeyEvent.VK_F10 && e.isControlDown()) {
+ // Hotkey: release the mouse grab (swallowed like F12).
+ if (mouseCaptured) {
+ releaseMouse();
+ }
+ return;
+ }
+ keys.push(e.getKeyCode(),
+ e.getKeyChar() == KeyEvent.CHAR_UNDEFINED ? KeyboardQueue.CHAR_NONE : e.getKeyChar());
+ }
+
/** Shows the window. Must be called on or before the program starts. */
public void show() {
frame.setVisible(true);
}
+ /**
+ * Pushes a mouse movement event into the emulated TSR as a delta in
+ * video-mode pixels. Only events arriving while the grab is active
+ * count. The canvas paints the framebuffer stretched to its full size,
+ * so window pixels convert to mode pixels by the plain ratio; the
+ * sub-pixel residue accumulates so slow drags are not rounded away.
+ */
+ private void trackMovement(final MouseEvent e) {
+ if (!mouseCaptured) {
+ return;
+ }
+ if (lastWindowX >= 0) {
+ pendingDeltaX += (e.getX() - lastWindowX) * vga.mode().pixelWidth()
+ / Math.max(1, canvas.getWidth());
+ pendingDeltaY += (e.getY() - lastWindowY) * vga.mode().pixelHeight()
+ / Math.max(1, canvas.getHeight());
+ final int modeDx = (int) pendingDeltaX;
+ final int modeDy = (int) pendingDeltaY;
+ if (modeDx != 0 || modeDy != 0) {
+ mouse.pushMove(modeDx, modeDy);
+ pendingDeltaX -= modeDx;
+ pendingDeltaY -= modeDy;
+ }
+ }
+ lastWindowX = e.getX();
+ lastWindowY = e.getY();
+ }
+
+ /**
+ * Captures the mouse: covers the whole desktop with a transparent
+ * input-grabbing overlay (hidden cursor), so every movement and click
+ * feeds the program until Ctrl+F10. Invoked by the first click on the
+ * canvas; that click is not forwarded. Without compositor support the
+ * grab degrades to in-window tracking (cursor leaves the window freely).
+ */
+ private void captureMouse() {
+ mouseCaptured = true;
+ pendingDeltaX = 0;
+ pendingDeltaY = 0;
+ lastWindowX = -1;
+ final Cursor blank = canvas.getToolkit().createCustomCursor(
+ new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB),
+ new Point(0, 0), "blank");
+ canvas.setCursor(blank);
+ try {
+ grabOverlay = createGrabOverlay(blank);
+ } catch (final RuntimeException | java.awt.AWTException e) {
+ grabOverlay = null; // no translucency (e.g. no compositor): plain grab
+ }
+ frame.setTitle(baseTitle + " — mouse captured, Ctrl+F10 to release");
+ }
+
+ /**
+ * Builds the grab overlay: an undecorated, fully transparent,
+ * always-on-top window spanning the union of all screens, with the
+ * same mouse/key handling as the canvas.
+ */
+ private JFrame createGrabOverlay(final Cursor blank) throws java.awt.AWTException {
+ Rectangle bounds = null;
+ for (final GraphicsDevice device : GraphicsEnvironment.getLocalGraphicsEnvironment()
+ .getScreenDevices()) {
+ final Rectangle b = device.getDefaultConfiguration().getBounds();
+ bounds = bounds == null ? b : bounds.union(b);
+ }
+ final JFrame overlay = new JFrame();
+ overlay.setUndecorated(true);
+ overlay.setAlwaysOnTop(true);
+ overlay.setBounds(bounds);
+ final JPanel glass = new JPanel();
+ glass.setOpaque(false);
+ overlay.setContentPane(glass);
+ // Throws UnsupportedOperationException when per-pixel translucency
+ // is unavailable (no compositor) — the caller falls back.
+ overlay.setBackground(new Color(0, 0, 0, 0));
+ overlay.setCursor(blank);
+
+ final MouseAdapter overlayMouse = new MouseAdapter() {
+ @Override
+ public void mouseMoved(final MouseEvent e) {
+ trackMovement(e);
+ }
+
+ @Override
+ public void mouseDragged(final MouseEvent e) {
+ trackMovement(e);
+ }
+
+ @Override
+ public void mousePressed(final MouseEvent e) {
+ forwardButton(e);
+ }
+ };
+ glass.addMouseListener(overlayMouse);
+ glass.addMouseMotionListener(overlayMouse);
+ glass.addKeyListener(new KeyAdapter() {
+ @Override
+ public void keyPressed(final KeyEvent e) {
+ handleKey(e);
+ }
+ });
+ glass.setFocusable(true);
+ overlay.setVisible(true);
+ glass.requestFocusInWindow();
+ return overlay;
+ }
+
+ /** Releases the grab: drops the overlay, restores the cursor and title. */
+ private void releaseMouse() {
+ mouseCaptured = false;
+ if (grabOverlay != null) {
+ grabOverlay.dispose();
+ grabOverlay = null;
+ }
+ canvas.setCursor(java.awt.Cursor.getDefaultCursor());
+ frame.setTitle(baseTitle);
+ }
+
+ /** Forwards a captured click to the emulated TSR (INT 33h bit order). */
+ private void forwardButton(final MouseEvent e) {
+ final int bit = switch (e.getButton()) {
+ case MouseEvent.BUTTON1 -> 0; // left
+ case MouseEvent.BUTTON3 -> 1; // right
+ case MouseEvent.BUTTON2 -> 2; // middle
+ default -> -1;
+ };
+ if (bit >= 0) {
+ mouse.pushButton(bit);
+ }
+ }
+
/**
* Window content size for a video mode. Integer scaling (3x for
* 320-wide modes, 2x otherwise) keeps pixels crisp; 640-wide
*/
private void closeRequested() {
closing = true;
+ if (grabOverlay != null) {
+ grabOverlay.dispose();
+ grabOverlay = null;
+ }
if (recorder != null) {
stopRecording("Recording stopped (window closed)");
}
import eu.svjatoslav.crtbasic.ast.Ast;
import eu.svjatoslav.crtbasic.audio.SoundQueue;
import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.machine.MachineMemory;
import eu.svjatoslav.crtbasic.video.CapturedRegion;
import eu.svjatoslav.crtbasic.video.ScreenMode;
import eu.svjatoslav.crtbasic.video.VgaDevice;
private final VgaDevice vga;
private final KeyboardQueue keys;
private final SoundQueue sound;
+ /**
+ * Emulated 1 MiB machine RAM for DEF SEG / PEEK / POKE, with the
+ * qbext mouse TSR always "loaded" (INT 79h IVT slot -> live data table).
+ */
+ private final MachineMemory machine = new MachineMemory();
+ /** Current DEF SEG segment for PEEK/POKE (0 = default). */
+ private int defSeg;
/** Scope stack: bottom is the global scope, top is the current SUB. */
private final Deque<Map<String, Cell>> scopes = new ArrayDeque<>();
/** GOSUB return addresses: the block and pc to resume on RETURN. */
* one-line lookahead so EOF(n) can answer without consuming input.
*/
private final Map<Integer, java.io.BufferedReader> fileChannels = new HashMap<>();
+ /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */
+ private final Map<Integer, java.io.BufferedWriter> fileOutputs = new HashMap<>();
+ private final Map<Integer, Integer> fileOutputColumns = new HashMap<>();
private final Map<Integer, String> fileLookahead = new HashMap<>();
/** Unconsumed tail of the current INPUT # line, per channel. */
private final Map<Integer, String> fileRemainder = new HashMap<>();
/** Directory that relative OPEN paths resolve against (the .bas directory). */
private java.nio.file.Path baseDirectory = java.nio.file.Path.of("");
+ /** CHDIR state; null = resolve relative to {@link #baseDirectory}. */
+ private java.nio.file.Path currentDirectory;
/**
* ON ERROR GOTO target (lowercased label), null when trapping is off.
* Trapping is module-global.
return vga;
}
+ /** Emulated machine memory (PEEK/POKE space); the mouse TSR lives here. */
+ public MachineMemory machine() {
+ return machine;
+ }
+
public void run(final List<Ast.Stmt> program) {
// SUB definitions register and do not execute inline.
// DEFxxx statements are collected up front: their effect is
case Ast.PcopyStmt s -> vga.pcopy(toInt(eval(s.sourcePage()), s.line()),
toInt(eval(s.targetPage()), s.line()));
case Ast.LocateStmt s -> vga.locate(toInt(eval(s.row()), s.line()),
- toInt(eval(s.col()), s.line()));
+ s.col() == null ? vga.textColumn() : toInt(eval(s.col()), s.line()));
case Ast.ClsStmt s -> vga.cls();
case Ast.RandomizeStmt s -> {
// RANDOMIZE n reseeds like RND(-n); no seed = RANDOMIZE TIMER.
case Ast.OpenStmt s -> {
final int channel = toInt(eval(s.channel()), s.line());
final String path = str(eval(s.path()), s.line());
+ if ("OUTPUT".equals(s.mode()) || "APPEND".equals(s.mode())) {
+ try {
+ // OUTPUT truncates/creates; APPEND creates and writes
+ // at end of file.
+ fileOutputs.put(channel, java.nio.file.Files.newBufferedWriter(
+ resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437"),
+ "APPEND".equals(s.mode())
+ ? new java.nio.file.OpenOption[]{
+ java.nio.file.StandardOpenOption.CREATE,
+ java.nio.file.StandardOpenOption.APPEND}
+ : new java.nio.file.OpenOption[0]));
+ fileOutputColumns.put(channel, 1);
+ } catch (final java.io.IOException e) {
+ throw error("Cannot create file: " + path, s.line());
+ }
+ } else {
+ try {
+ fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
+ resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437")));
+ } catch (final java.io.IOException e) {
+ throw error("File not found: " + path, s.line());
+ }
+ }
+ }
+ case Ast.ChdirStmt s -> {
+ final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line()));
+ if (!java.nio.file.Files.isDirectory(dir)) {
+ throw error("Path not found", s.line());
+ }
+ currentDirectory = dir.normalize();
+ }
+ case Ast.MkdirStmt s -> {
+ final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line()));
try {
- fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
- resolveOpenPath(path), java.nio.charset.Charset.forName("IBM437")));
+ java.nio.file.Files.createDirectory(dir);
+ } catch (final java.nio.file.FileAlreadyExistsException e) {
+ throw error("Path/file access error", s.line());
} catch (final java.io.IOException e) {
- throw error("File not found: " + path, s.line());
+ throw error("Path not found", s.line());
}
}
case Ast.CloseStmt s -> {
for (final int channel : List.copyOf(fileChannels.keySet())) {
closeChannel(channel, s.line());
}
+ for (final int channel : List.copyOf(fileOutputs.keySet())) {
+ closeOutputChannel(channel, s.line());
+ }
} else {
- closeChannel(toInt(eval(s.channel()), s.line()), s.line());
+ final int channel = toInt(eval(s.channel()), s.line());
+ closeChannel(channel, s.line());
+ closeOutputChannel(channel, s.line());
}
}
case Ast.LineInputStmt s -> {
case Ast.SleepStmt s -> execSleep(s);
case Ast.OutStmt s -> vga.out(toInt(eval(s.port()), s.line()),
toInt(eval(s.value()), s.line()));
+ case Ast.DefSegStmt s -> {
+ // DEF SEG = segment; the bare form resets to the default segment 0.
+ defSeg = s.segment() == null ? 0
+ : toInt(eval(s.segment()), s.line()) & 0xFFFF;
+ }
+ case Ast.PokeStmt s -> machine.poke(defSeg,
+ toInt(eval(s.address()), s.line()),
+ toInt(eval(s.value()), s.line()));
case Ast.PaintStmt s -> {
final int fill = s.fill() == null ? vga.textForeground()
: toInt(eval(s.fill()), s.line());
}
private void execPrint(final Ast.PrintStmt s) {
+ if (s.channel() != null) {
+ execPrintFile(s);
+ return;
+ }
for (final Ast.PrintStmt.Item item : s.items()) {
final Object value = eval(item.expr());
vga.print(value instanceof String text ? text
return s.isEmpty() ? 0.0 : (double) Cp437.byteOf(s.charAt(0));
}
case "VAL" -> {
- try {
- return single(Double.parseDouble(str(singleArgValue(c), c.line()).trim()));
- } catch (final NumberFormatException e) {
- return 0.0;
+ return single(basicVal(str(singleArgValue(c), c.line())));
+ }
+ case "HEX$" -> {
+ // QBasic HEX$: rounded to an integer first; INTEGER-range
+ // negatives print as 16-bit two's complement (HEX$(-3) =
+ // "FFFD"), LONG-range as 32-bit; no prefix, no leading zeros.
+ final long v = (long) Math.rint(num(singleArgValue(c), c.line()));
+ if (v >= -32768 && v <= 32767) {
+ return Integer.toHexString((int) v & 0xFFFF).toUpperCase(java.util.Locale.ROOT);
+ }
+ if (v >= Integer.MIN_VALUE && v <= Integer.MAX_VALUE) {
+ return Integer.toHexString((int) v).toUpperCase(java.util.Locale.ROOT);
}
+ return Long.toHexString(v).toUpperCase(java.util.Locale.ROOT);
+ }
+ case "PEEK" -> {
+ // PEEK(offset): the byte at DEF SEG:offset in emulated machine RAM.
+ return single(machine.peek(defSeg, toInt(singleArgValue(c), c.line())));
}
case "LEFT$" -> {
requireArgs(name, args, 2, c.line());
return (float) value;
}
+ /**
+ * QBasic VAL: leading whitespace is skipped; {@code &H…} hex and
+ * {@code &O…} octal strings follow the literal's signed semantics
+ * (up to 4 hex digits = int16, so VAL("&HFFFD") is -3); otherwise the
+ * longest valid numeric prefix is parsed and a junk tail is ignored.
+ * A string with no number at the start converts to 0.
+ */
+ private static double basicVal(final String text) {
+ final String s = text.stripLeading();
+ if (s.length() >= 2 && s.charAt(0) == '&'
+ && (s.charAt(1) == 'H' || s.charAt(1) == 'h'
+ || s.charAt(1) == 'O' || s.charAt(1) == 'o')) {
+ final boolean hex = s.charAt(1) == 'H' || s.charAt(1) == 'h';
+ final int radix = hex ? 16 : 8;
+ int end = 2;
+ while (end < s.length() && Character.digit(s.charAt(end), radix) >= 0) {
+ end++;
+ }
+ if (end == 2) {
+ return 0;
+ }
+ final String digits = s.substring(2, end);
+ try {
+ final long value = Long.parseLong(digits, radix);
+ if (hex && digits.length() <= 4) {
+ final int v = (int) (value & 0xFFFF);
+ return v >= 0x8000 ? v - 0x10000 : v;
+ }
+ if (value <= 0xFFFFFFFFL) {
+ final long v = value & 0xFFFFFFFFL;
+ return v >= 0x80000000L ? v - 0x100000000L : v;
+ }
+ return value;
+ } catch (final NumberFormatException e) {
+ return 0;
+ }
+ }
+ // Decimal: longest valid prefix [sign] digits [. digits] [eEdD [sign] digits].
+ int end = 0;
+ if (end < s.length() && (s.charAt(end) == '+' || s.charAt(end) == '-')) {
+ end++;
+ }
+ final int digitsStart = end;
+ while (end < s.length() && Character.isDigit(s.charAt(end))) {
+ end++;
+ }
+ boolean sawDigit = end > digitsStart;
+ if (end < s.length() && s.charAt(end) == '.') {
+ end++;
+ final int fractionStart = end;
+ while (end < s.length() && Character.isDigit(s.charAt(end))) {
+ end++;
+ }
+ sawDigit = sawDigit || end > fractionStart;
+ }
+ if (!sawDigit) {
+ return 0;
+ }
+ if (end < s.length() && "eEdD".indexOf(s.charAt(end)) >= 0) {
+ final int save = end;
+ end++;
+ if (end < s.length() && (s.charAt(end) == '+' || s.charAt(end) == '-')) {
+ end++;
+ }
+ if (end < s.length() && Character.isDigit(s.charAt(end))) {
+ while (end < s.length() && Character.isDigit(s.charAt(end))) {
+ end++;
+ }
+ } else {
+ end = save; // was not an exponent after all
+ }
+ }
+ try {
+ return Double.parseDouble(s.substring(0, end).replace('D', 'E').replace('d', 'e'));
+ } catch (final NumberFormatException e) {
+ return 0;
+ }
+ }
+
/**
* Debug aid: when the {@code crtbasic.linelog}
* system property names a file, every LINE call is appended as
* directory listing (e.g. {@code IMG/pilv.i01} finds {@code img/}).
*/
private java.nio.file.Path resolveOpenPath(final String path) {
- final String normalized = path.replace('\\', '/');
- final java.nio.file.Path direct = baseDirectory.resolve(normalized);
+ String normalized = path.replace('\\', '/');
+ if (normalized.matches("^[A-Za-z]:.*")) {
+ normalized = normalized.substring(2); // drop DOS drive letter
+ }
+ final java.nio.file.Path base = currentDirectory != null ? currentDirectory : baseDirectory;
+ final boolean absolute = normalized.startsWith("/");
+ final java.nio.file.Path direct = absolute
+ ? java.nio.file.Path.of(normalized)
+ : base.resolve(normalized);
if (java.nio.file.Files.exists(direct)) {
return direct;
}
- java.nio.file.Path current = baseDirectory;
+ java.nio.file.Path current = absolute ? java.nio.file.Path.of("/") : base;
for (final String component : normalized.split("/")) {
if (component.isEmpty() || component.equals(".")) {
continue;
}
}
+ private void closeOutputChannel(final int channel, final int line) {
+ final java.io.BufferedWriter writer = fileOutputs.remove(channel);
+ if (writer == null) {
+ return;
+ }
+ fileOutputColumns.remove(channel);
+ try {
+ writer.close();
+ } catch (final java.io.IOException e) {
+ throw error("File close error: " + e.getMessage(), line);
+ }
+ }
+
+ /**
+ * {@code PRINT #channel, items}: the same item formatting as console
+ * PRINT (classic number style, ';' compact, ',' 14-column zones),
+ * written as DOS text — lines end with CRLF. Zones wrap at column 255
+ * (QBasic's default file width).
+ */
+ private void execPrintFile(final Ast.PrintStmt s) {
+ final int channel = toInt(eval(s.channel()), s.line());
+ final java.io.BufferedWriter writer = fileOutputs.get(channel);
+ if (writer == null) {
+ throw error("Bad file number", s.line());
+ }
+ int column = fileOutputColumns.get(channel);
+ try {
+ for (final Ast.PrintStmt.Item item : s.items()) {
+ final Object value = eval(item.expr());
+ final String text = value instanceof String string ? string
+ : basicNumber(num(value, s.line()), exprIsDouble(item.expr()));
+ writer.write(text);
+ column += text.length();
+ if (item.separatorAfter() == ',') {
+ final int target = ((column - 1) / 14 + 1) * 14 + 1;
+ if (target > 255) {
+ writer.write("\r\n");
+ column = 1;
+ } else {
+ final String padding = " ".repeat(target - column);
+ writer.write(padding);
+ column = target;
+ }
+ }
+ }
+ if (s.newline()) {
+ writer.write("\r\n");
+ column = 1;
+ }
+ // Flush eagerly: programs that record while running (flight
+ // scripts) must not lose data when the JVM is killed.
+ writer.flush();
+ } catch (final java.io.IOException e) {
+ throw error("File write error: " + e.getMessage(), s.line());
+ }
+ fileOutputColumns.put(channel, column);
+ }
+
/**
* RND(-n): reseeds the generator from n. The SINGLE bit pattern of
* |n| folds into the 24-bit seed as
--- /dev/null
+package eu.svjatoslav.crtbasic.machine;
+
+/**
+ * Emulated machine memory for {@code DEF SEG} / {@code PEEK} / {@code POKE}:
+ * the 8086's 1 MiB (20-bit) real-mode address space. Segment:offset pairs
+ * translate to a physical address ({@code segment * 16 + offset}) that
+ * wraps at 20 bits, like the original hardware.
+ *
+ * <p>Most of the space is plain RAM backed by a byte array. One region is
+ * live: the {@link QbextMouseTsr} data table, whose bytes are served by
+ * the emulated TSR so that mouse events pushed by frontends show up in
+ * PEEKs and consumer POKEs reset the accumulators. The INT 79h IVT slot
+ * is pre-populated with the far pointer to that table — CRT Basic behaves
+ * as if the mouse TSR is always loaded.</p>
+ */
+public final class MachineMemory {
+
+ /** Address space size: 1 MiB (8086, 20-bit addresses). */
+ public static final int SIZE = 0x100000;
+
+ private final byte[] ram = new byte[SIZE];
+ private final QbextMouseTsr mouseTsr = new QbextMouseTsr();
+
+ public MachineMemory() {
+ // IVT slot of INT 79h: far pointer (offset, then segment,
+ // little-endian words) to the emulated TSR data table.
+ final int vector = QbextMouseTsr.INTERRUPT * 4;
+ ram[vector] = (byte) (QbextMouseTsr.OFFSET & 0xFF);
+ ram[vector + 1] = (byte) ((QbextMouseTsr.OFFSET >> 8) & 0xFF);
+ ram[vector + 2] = (byte) (QbextMouseTsr.SEGMENT & 0xFF);
+ ram[vector + 3] = (byte) ((QbextMouseTsr.SEGMENT >> 8) & 0xFF);
+ }
+
+ /** The emulated mouse driver; frontends push movement/button events here. */
+ public QbextMouseTsr mouse() {
+ return mouseTsr;
+ }
+
+ /** PEEK: the byte at segment:offset (0..255). */
+ public int peek(final int segment, final int offset) {
+ final int physical = physical(segment, offset);
+ if (isTable(physical)) {
+ return mouseTsr.readByte(physical - QbextMouseTsr.PHYSICAL);
+ }
+ return ram[physical] & 0xFF;
+ }
+
+ /** POKE: writes the low 8 bits of value at segment:offset. */
+ public void poke(final int segment, final int offset, final int value) {
+ final int physical = physical(segment, offset);
+ if (isTable(physical)) {
+ mouseTsr.writeByte(physical - QbextMouseTsr.PHYSICAL, value);
+ return;
+ }
+ ram[physical] = (byte) (value & 0xFF);
+ }
+
+ private static boolean isTable(final int physical) {
+ return physical >= QbextMouseTsr.PHYSICAL
+ && physical < QbextMouseTsr.PHYSICAL + QbextMouseTsr.TABLE_SIZE;
+ }
+
+ private static int physical(final int segment, final int offset) {
+ return (((segment & 0xFFFF) << 4) + (offset & 0xFFFF)) & (SIZE - 1);
+ }
+}
--- /dev/null
+package eu.svjatoslav.crtbasic.machine;
+
+/**
+ * Emulation of the qbext mouse TSR (qbasicapps/Miscellaneous/Mouse driver):
+ * the resident assembly program a QBasic program loads before it can use
+ * the mouse. CRT Basic behaves as if the TSR is always loaded.
+ *
+ * <p>The TSR publishes a small data table and stores a far pointer to it
+ * in the interrupt vector table slot of INT 79h (the vector is never
+ * invoked as an interrupt; the slot is just a well-known place for the
+ * pointer). A program reads the pointer with {@code DEF SEG = 0} +
+ * {@code PEEK(&H79 * 4 + n)}, switches {@code DEF SEG} to the table
+ * segment and verifies the magic word 1983 at offset 0.</p>
+ *
+ * <p>Table layout (offsets in bytes, little-endian words):</p>
+ * <pre>
+ * 0x00 magic number 1983
+ * 0x02 horizontal movement, accumulated
+ * 0x04 vertical movement, accumulated
+ * 0x06 button status, latched (bit 0 left, 1 right, 2 middle)
+ * </pre>
+ *
+ * <p>Transfer protocol, exactly like the real driver: the TSR side
+ * <em>accumulates</em> (movement deltas are added in, button bits are
+ * ORed in and stay latched), so the consumer must read-and-reset each
+ * field with POKE. A field that reads 0 means "no new activity".</p>
+ *
+ * <p>Frontends (Swing window) and headless drivers (tests, GameDriver)
+ * feed events through {@link #pushMove(int, int)} and
+ * {@link #pushButton(int)}. All state is synchronized: the program
+ * thread PEEKs/POKEs while the EDT pushes events.</p>
+ */
+public final class QbextMouseTsr {
+
+ /** Interrupt whose IVT slot carries the far pointer to the table. */
+ public static final int INTERRUPT = 0x79;
+ /** Segment the emulated table lives at (arbitrary, free low memory). */
+ public static final int SEGMENT = 0x0790;
+ /** Offset of the table within the segment. */
+ public static final int OFFSET = 0x0000;
+ /** Physical address of the table (SEGMENT * 16 + OFFSET). */
+ public static final int PHYSICAL = SEGMENT * 16 + OFFSET;
+ /** Table size in bytes. */
+ public static final int TABLE_SIZE = 8;
+ /** Magic word at offset 0: "driver is loaded". */
+ public static final int MAGIC = 1983;
+
+ private static final int FIELD_MAGIC = 0;
+ private static final int FIELD_X = 1;
+ private static final int FIELD_Y = 2;
+ private static final int FIELD_BUTTONS = 3;
+
+ /** The four 16-bit table fields (values kept in 0..0xFFFF). */
+ private final int[] fields = {MAGIC, 0, 0, 0};
+
+ /**
+ * Accumulates a mouse movement delta (in video-mode pixels). Values
+ * wrap at 16 bits like the real TSR's 16-bit ADD, so a negative
+ * delta reads back as two's complement (consumer interprets it as
+ * signed, e.g. via {@code VAL("&H" + HEX$(...))}).
+ */
+ public synchronized void pushMove(final int dx, final int dy) {
+ fields[FIELD_X] = (fields[FIELD_X] + dx) & 0xFFFF;
+ fields[FIELD_Y] = (fields[FIELD_Y] + dy) & 0xFFFF;
+ }
+
+ /**
+ * Latches a button press: bit 0 = left, 1 = right, 2 = middle (INT 33h
+ * function 03h order). Releases are not reported — like the real
+ * driver, the field stays set until the consumer clears it.
+ */
+ public synchronized void pushButton(final int bit) {
+ if (bit >= 0 && bit <= 15) {
+ fields[FIELD_BUTTONS] |= 1 << bit;
+ }
+ }
+
+ /** Reads one byte of the data table (offset 0..7). */
+ public synchronized int readByte(final int tableOffset) {
+ final int field = fields[fieldAt(tableOffset)];
+ return (tableOffset & 1) == 0 ? field & 0xFF : (field >> 8) & 0xFF;
+ }
+
+ /** Writes one byte of the data table (offset 0..7) — the read-and-reset POKE. */
+ public synchronized void writeByte(final int tableOffset, final int value) {
+ final int index = fieldAt(tableOffset);
+ if ((tableOffset & 1) == 0) {
+ fields[index] = (fields[index] & 0xFF00) | (value & 0xFF);
+ } else {
+ fields[index] = (fields[index] & 0x00FF) | ((value & 0xFF) << 8);
+ }
+ }
+
+ private static int fieldAt(final int tableOffset) {
+ return switch (tableOffset >> 1) {
+ case 0 -> FIELD_MAGIC;
+ case 1 -> FIELD_X;
+ case 2 -> FIELD_Y;
+ default -> FIELD_BUTTONS;
+ };
+ }
+}
case "BEEP" -> beepStatement();
case "SLEEP" -> sleepStatement();
case "OUT" -> outStatement();
+ case "DEF" -> defSegStatement();
+ case "POKE" -> pokeStatement();
case "RANDOMIZE" -> randomizeStatement();
case "OPEN" -> openStatement();
case "CLOSE" -> closeStatement();
+ case "CHDIR" -> {
+ next();
+ yield new Ast.ChdirStmt(expression(), token.line());
+ }
+ case "MKDIR" -> {
+ next();
+ yield new Ast.MkdirStmt(expression(), token.line());
+ }
case "ON" -> onErrorStatement();
case "RESUME" -> resumeStatement();
case "DATA" -> dataStatement();
return new Ast.OutStmt(port, value, keyword.line());
}
+ /** {@code DEF SEG = segment} — the bare form (no = expr) resets to the default segment. */
+ private Ast.Stmt defSegStatement() {
+ final Token keyword = next(); // DEF
+ expectText("SEG");
+ Ast.Expr segment = null;
+ if (peek().isText("=")) {
+ next(); // =
+ segment = expression();
+ }
+ return new Ast.DefSegStmt(segment, keyword.line());
+ }
+
+ /** {@code POKE address, value} — writes one byte to emulated machine memory. */
+ private Ast.Stmt pokeStatement() {
+ final Token keyword = next(); // POKE
+ final Ast.Expr address = expression();
+ expectOp(",");
+ final Ast.Expr value = expression();
+ return new Ast.PokeStmt(address, value, keyword.line());
+ }
+
/** {@code RANDOMIZE [seed]} — no seed means RANDOMIZE TIMER. */
private Ast.Stmt randomizeStatement() {
final Token keyword = next(); // RANDOMIZE
return new Ast.SleepStmt(seconds, keyword.line());
}
- /** {@code OPEN path FOR INPUT AS #channel} — only INPUT mode is supported. */
+ /** {@code OPEN path FOR INPUT|OUTPUT|APPEND AS #channel}. */
private Ast.Stmt openStatement() {
final Token keyword = next(); // OPEN
final Ast.Expr path = expression();
expectText("FOR");
final Token mode = expect(Token.Type.IDENT);
- if (!"INPUT".equalsIgnoreCase(mode.text())) {
- throw error("OPEN: only FOR INPUT is supported", mode);
+ if (!"INPUT".equalsIgnoreCase(mode.text()) && !"OUTPUT".equalsIgnoreCase(mode.text())
+ && !"APPEND".equalsIgnoreCase(mode.text())) {
+ throw error("OPEN: only FOR INPUT, FOR OUTPUT and FOR APPEND are supported", mode);
}
expectText("AS");
expectOp("#");
final Ast.Expr channel = expression();
- return new Ast.OpenStmt(path, channel, keyword.line());
+ return new Ast.OpenStmt(path, mode.text().toUpperCase(), channel, keyword.line());
}
/** {@code CLOSE [#channel]}. */
private Ast.Stmt locateStatement() {
final Token keyword = next(); // LOCATE
final Ast.Expr row = expression();
- expectOp(",");
- final Ast.Expr col = expression();
+ Ast.Expr col = null;
+ if (peek().isText(",")) {
+ next(); // ,
+ col = expression();
+ }
return new Ast.LocateStmt(row, col, keyword.line());
}
private Ast.Stmt printStatement() {
final Token keyword = next(); // PRINT or ?
+ Ast.Expr channel = null;
+ if (peek().isText("#")) {
+ // PRINT #channel, items — file output.
+ next(); // #
+ channel = expression();
+ expectOp(",");
+ }
final List<Ast.PrintStmt.Item> items = new ArrayList<>();
boolean newline = true;
while (!peek().is(Token.Type.SEP) && !peek().is(Token.Type.EOF)
}
}
}
- return new Ast.PrintStmt(items, newline, keyword.line());
+ return new Ast.PrintStmt(channel, items, newline, keyword.line());
}
private Ast.Stmt colorStatement() {
/**
* @return the foreground color active right after
* {@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).
+ * mode's highest color number, EXCEPT SCREEN 13 where real QBasic
+ * defaults to 15 (verified 2026-08-27 against QB.EXE in DOSBox via
+ * BSAVE of the mode-13 framebuffer: default PRINT/PSET/CIRCLE all
+ * paint index 15 — white in the BIOS default palette, whose entries
+ * 248-255 are black, so the long-assumed 255 printed black-on-black).
+ * 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 : colors - 1;
+ if (this == SCREEN_0) {
+ return 7;
+ }
+ return this == SCREEN_13 ? 15 : colors - 1;
}
/**
advanceLine();
}
- /**
- * {@code LOCATE row, col}: moves the text cursor. Coordinates are 1-based.
- */
+ /** {@code LOCATE row, col}: moves the text cursor. Coordinates are 1-based. */
public void locate(final int row, final int col) {
if (row < 1 || row > rows || col < 1 || col > columns) {
throw new IllegalArgumentException(
pendingWrap = false;
}
+ /** Current cursor column (1-based). */
+ public int column() {
+ return cursorCol;
+ }
+
/** {@code COLOR foreground, background}. */
public void setColor(final int fg, final int bg) {
foreground = fg & 0xFF;
consoles[activePage].locate(row, col);
}
+ /** @return current text cursor column (1-based) of the active page */
+ public int textColumn() {
+ return consoles[activePage].column();
+ }
+
/** {@code COLOR foreground, background}. */
public void setTextColor(final int foreground, final int background) {
consoles[activePage].setColor(foreground, background);
* <p>Usage:</p>
* <pre>
* GameDriver program.bas [--steps=N] [--screenshot=out.png]
- * [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
+ * [--keys=SCRIPT] [--mouse=SCRIPT] [--interval=MS] [--vars=a%,b%,c$]
* </pre>
*
* <p>Key script: each character is one input event, delivered roughly every
* <li>anything else — the typed character itself</li>
* </ul>
*
+ * <p>Mouse script: space-separated tokens, one event per interval, feeding
+ * the emulated qbext mouse TSR:</p>
+ * <ul>
+ * <li>{@code dx,dy} — movement delta in video-mode pixels (e.g. {@code 10,0}, {@code 0,-5})</li>
+ * <li>{@code b0} / {@code b1} / {@code b2} — left / right / middle button press</li>
+ * </ul>
+ *
* <p>After the script is exhausted the program keeps running until it ends
* on its own or the statement budget (default 8M) is spent. The named
* variables are printed from the global scope afterwards for state
public static void main(final String[] args) throws Exception {
if (args.length == 0) {
System.err.println("Usage: GameDriver program.bas [--steps=N]"
- + " [--screenshot=out.png] [--keys=SCRIPT] [--interval=MS] [--vars=a%,b%]");
+ + " [--screenshot=out.png] [--keys=SCRIPT] [--mouse=SCRIPT]"
+ + " [--interval=MS] [--vars=a%,b%]");
System.exit(1);
}
final Path programPath = Path.of(args[0]);
long steps = 8_000_000;
Path screenshot = null;
String keys = "";
+ String mouse = "";
long interval = 12;
String[] vars = new String[0];
for (int i = 1; i < args.length; i++) {
screenshot = Path.of(arg.substring("--screenshot=".length()));
} else if (arg.startsWith("--keys=")) {
keys = arg.substring("--keys=".length());
+ } else if (arg.startsWith("--mouse=")) {
+ mouse = arg.substring("--mouse=".length());
} else if (arg.startsWith("--interval=")) {
interval = Long.parseLong(arg.substring("--interval=".length()));
} else if (arg.startsWith("--vars=")) {
programThread.setDaemon(true);
programThread.start();
- for (final char c : keys.toCharArray()) {
+ final String[] mouseTokens = mouse.isEmpty() ? new String[0] : mouse.split(" ");
+ final int scriptLength = Math.max(keys.length(), mouseTokens.length);
+ for (int i = 0; i < scriptLength; i++) {
if (!programThread.isAlive()) {
break;
}
- switch (c) {
- case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE);
- case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE);
- case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE);
- case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE);
- case '~' -> {
- // Pause: let the simulation advance without input.
- }
- default -> queue.pushChar(c);
+ if (i < keys.length()) {
+ pushKey(queue, keys.charAt(i));
+ }
+ if (i < mouseTokens.length) {
+ pushMouse(interpreter, mouseTokens[i]);
}
Thread.sleep(interval);
}
System.out.println(variable + " = " + interpreter.variable(variable));
}
}
+
+ private static void pushKey(final KeyboardQueue queue, final char c) {
+ switch (c) {
+ case 'R' -> queue.push(KeyEvent.VK_RIGHT, KeyboardQueue.CHAR_NONE);
+ case 'L' -> queue.push(KeyEvent.VK_LEFT, KeyboardQueue.CHAR_NONE);
+ case 'U' -> queue.push(KeyEvent.VK_UP, KeyboardQueue.CHAR_NONE);
+ case 'D' -> queue.push(KeyEvent.VK_DOWN, KeyboardQueue.CHAR_NONE);
+ case '~' -> {
+ // Pause: let the simulation advance without input.
+ }
+ default -> queue.pushChar(c);
+ }
+ }
+
+ /**
+ * One mouse-script token: {@code dx,dy} pushes a movement delta,
+ * {@code b<n>} presses button bit n (0 left, 1 right, 2 middle).
+ */
+ private static void pushMouse(final Interpreter interpreter, final String token) {
+ if (token.length() >= 2 && (token.charAt(0) == 'b' || token.charAt(0) == 'B')) {
+ interpreter.machine().mouse().pushButton(Integer.parseInt(token.substring(1)));
+ return;
+ }
+ final int comma = token.indexOf(',');
+ if (comma < 0) {
+ System.err.println("Bad mouse token (want dx,dy or b<n>): " + token);
+ return;
+ }
+ interpreter.machine().mouse().pushMove(
+ Integer.parseInt(token.substring(0, comma)),
+ Integer.parseInt(token.substring(comma + 1)));
+ }
}
--- /dev/null
+package eu.svjatoslav.crtbasic.interp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+
+/**
+ * OPEN ... FOR OUTPUT + PRINT # (Universe explorer.bas flight-script
+ * recorder): formatted items written as DOS text (CRLF), readable back
+ * through OPEN FOR INPUT + INPUT #.
+ */
+class FileOutputTest {
+
+ @Test
+ void printHashOutputRoundTripsThroughInputHash() throws Exception {
+ final Path dir = Files.createTempDirectory("crtbasic-io");
+ final Interpreter interpreter = new Interpreter(
+ new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+ interpreter.setBlockOnInput(false);
+ interpreter.setBaseDirectory(dir);
+ interpreter.run(Parser.parse("""
+ OPEN "t.dat" FOR OUTPUT AS #1
+ PRINT #1, 42
+ PRINT #1, 1.5; 2; -3
+ PRINT #1, "hello"
+ CLOSE #1
+ OPEN "t.dat" FOR INPUT AS #2
+ INPUT #2, a
+ INPUT #2, b, c, d
+ INPUT #2, s$
+ CLOSE
+ """));
+ assertEquals(42.0, interpreter.variable("a"));
+ assertEquals(1.5, interpreter.variable("b"));
+ assertEquals(2.0, interpreter.variable("c"));
+ assertEquals(-3.0, interpreter.variable("d"));
+ assertEquals("hello", interpreter.variable("s$"));
+
+ // DOS text file convention: CRLF line endings, zones/classic number style.
+ final String content = Files.readString(dir.resolve("t.dat"), Charset.forName("IBM437"));
+ assertTrue(content.contains("\r\n"));
+ assertTrue(content.startsWith(" 42 \r\n"), "classic number formatting: " + content);
+
+ // APPEND keeps existing content and adds at end of file.
+ interpreter.run(Parser.parse("""
+ OPEN "t.dat" FOR APPEND AS #1
+ PRINT #1, 99
+ CLOSE #1
+ """));
+ final String appended = Files.readString(dir.resolve("t.dat"), Charset.forName("IBM437"));
+ assertTrue(appended.startsWith(content), "append must keep old content: " + appended);
+ assertTrue(appended.endsWith(" 99 \r\n"), "append must add at end: " + appended);
+ }
+
+ @Test
+ void printHashToUnopenedChannelRaisesBadFileNumber() {
+ final Interpreter interpreter = new Interpreter(
+ new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+ interpreter.setBlockOnInput(false);
+ try {
+ interpreter.run(Parser.parse("PRINT #9, 1"));
+ throw new AssertionError("expected Bad file number");
+ } catch (final Interpreter.InterpreterException e) {
+ assertTrue(e.getMessage().contains("Bad file number"), e.getMessage());
+ }
+ }
+}
--- /dev/null
+package eu.svjatoslav.crtbasic.interp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import eu.svjatoslav.crtbasic.audio.SoundQueue;
+import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.parser.Parser;
+import eu.svjatoslav.crtbasic.video.VgaDevice;
+import org.junit.jupiter.api.Test;
+
+/**
+ * DEF SEG / PEEK / POKE against the emulated machine memory, plus the
+ * always-loaded qbext mouse TSR protocol used by qbasicapps
+ * "Miscellaneous/Mouse driver/mousedrv.bas": the INT 79h IVT slot points
+ * at a data table with magic 1983, movement fields accumulate signed
+ * deltas, button bits latch, and the consumer reads-and-resets with POKE.
+ */
+class MouseDriverEmulationTest {
+
+ /**
+ * The TSR-discovery plumbing of mousedrv.bas, verbatim: read the far
+ * pointer from the INT 79h vector, switch DEF SEG to the table segment.
+ */
+ private static final String DRIVER_PROLOGUE = """
+ DECLARE FUNCTION getword! (addr!)
+ DECLARE SUB putword (addr!, dat!)
+ DIM SHARED extSEG, extADDR
+ DEF SEG = 0
+ extSEG = PEEK(&H79 * 4 + 3) * 256
+ extSEG = extSEG + PEEK(&H79 * 4 + 2)
+ extADDR = PEEK(&H79 * 4 + 1) * 256
+ extADDR = extADDR + PEEK(&H79 * 4 + 0)
+ DEF SEG = extSEG
+ """;
+
+ /** getword/putword copied from mousedrv.bas (word read / write-and-reset). */
+ private static final String DRIVER_HELPERS = """
+ FUNCTION getword (addr)
+ a = PEEK(extADDR + addr)
+ b = PEEK(extADDR + addr + 1)
+ c$ = HEX$(a)
+ IF LEN(c$) = 1 THEN c$ = "0" + c$
+ IF LEN(c$) = 0 THEN c$ = "00"
+ c = VAL("&H" + HEX$(b) + c$)
+ getword = c
+ END FUNCTION
+ SUB putword (addr, dat)
+ b$ = HEX$(dat)
+ 2
+ IF LEN(b$) < 4 THEN b$ = "0" + b$: GOTO 2
+ n1 = VAL("&H" + LEFT$(b$, 2))
+ n2 = VAL("&H" + RIGHT$(b$, 2))
+ POKE (extADDR + addr), n2
+ POKE (extADDR + addr + 1), n1
+ END SUB
+ """;
+
+ private static Interpreter newInterpreter() {
+ final Interpreter interpreter = new Interpreter(
+ new VgaDevice(), new KeyboardQueue(), new SoundQueue());
+ interpreter.setBlockOnInput(false);
+ return interpreter;
+ }
+
+ private static Interpreter run(final String source) {
+ final Interpreter interpreter = newInterpreter();
+ interpreter.run(Parser.parse(source));
+ return interpreter;
+ }
+
+ @Test
+ void pokePeekRoundTripInPlainRam() {
+ final Interpreter interpreter = run("""
+ DEF SEG = &H100
+ POKE 32, 123
+ POKE 33, 259
+ a = PEEK(32)
+ b = PEEK(33)
+ DEF SEG = &H101
+ c = PEEK(16)
+ """);
+ assertEquals(123.0, interpreter.variable("a"));
+ // POKE stores the low byte only.
+ assertEquals(3.0, interpreter.variable("b"));
+ // Segment:offset arithmetic: &H101:16 = &H100:32.
+ assertEquals(123.0, interpreter.variable("c"));
+ }
+
+ @Test
+ void bareDefSegResetsToZero() {
+ final Interpreter interpreter = run("""
+ DEF SEG = &H100
+ DEF SEG
+ x = PEEK(&H79 * 4 + 2)
+ """);
+ // After the reset, PEEK reads the IVT again (table segment low byte, &H90).
+ assertEquals(144.0, interpreter.variable("x"));
+ }
+
+ @Test
+ void ivtSlot79hPointsToTableWithMagic() {
+ final Interpreter interpreter = run(DRIVER_PROLOGUE + """
+ magic = getword(0)
+ """ + DRIVER_HELPERS);
+ assertEquals(1936.0, interpreter.variable("extSEG")); // &H0790
+ assertEquals(0.0, interpreter.variable("extADDR"));
+ assertEquals(1983.0, interpreter.variable("magic"));
+ }
+
+ @Test
+ void movementAccumulatesSignedAndReadResets() {
+ final Interpreter interpreter = newInterpreter();
+ interpreter.machine().mouse().pushMove(5, -3);
+ interpreter.machine().mouse().pushMove(2, 0);
+ interpreter.run(Parser.parse(DRIVER_PROLOGUE + """
+ xr = getword(2)
+ yr = getword(4)
+ putword 2, 0
+ putword 4, 0
+ xr2 = getword(2)
+ yr2 = getword(4)
+ """ + DRIVER_HELPERS));
+ // Deltas accumulate (5 + 2), negative reads back signed via &H.
+ assertEquals(7.0, interpreter.variable("xr"));
+ assertEquals(-3.0, interpreter.variable("yr"));
+ // Read-and-reset: fields read 0 after the consumer cleared them.
+ assertEquals(0.0, interpreter.variable("xr2"));
+ assertEquals(0.0, interpreter.variable("yr2"));
+ }
+
+ @Test
+ void buttonPressLatchesUntilReset() {
+ final Interpreter interpreter = newInterpreter();
+ interpreter.machine().mouse().pushButton(0); // left
+ interpreter.machine().mouse().pushButton(2); // middle
+ interpreter.run(Parser.parse(DRIVER_PROLOGUE + """
+ buttons = getword(6)
+ putword 6, 0
+ buttons2 = getword(6)
+ """ + DRIVER_HELPERS));
+ assertEquals(5.0, interpreter.variable("buttons"));
+ assertEquals(0.0, interpreter.variable("buttons2"));
+ }
+
+ @Test
+ void valUnderstandsQBasicHexAndNumericPrefixes() {
+ final Interpreter interpreter = run("""
+ a = VAL("&HFFFD")
+ b = VAL("&HFF")
+ c = VAL("&H007B")
+ d = VAL("12abc")
+ e = VAL("junk")
+ f = VAL("&H10000")
+ """);
+ assertEquals(-3.0, interpreter.variable("a")); // 16-bit signed
+ assertEquals(255.0, interpreter.variable("b"));
+ assertEquals(123.0, interpreter.variable("c"));
+ assertEquals(12.0, interpreter.variable("d")); // junk tail ignored
+ assertEquals(0.0, interpreter.variable("e"));
+ assertEquals(65536.0, interpreter.variable("f")); // > 4 digits: 32-bit
+ }
+
+ @Test
+ void hexDollarFormatsLikeQBasic() {
+ final Interpreter interpreter = run("""
+ a$ = HEX$(255)
+ b$ = HEX$(-3)
+ c$ = HEX$(0)
+ d$ = HEX$(1983)
+ """);
+ assertEquals("FF", interpreter.variable("a$"));
+ assertEquals("FFFD", interpreter.variable("b$")); // 16-bit two's complement
+ assertEquals("0", interpreter.variable("c$"));
+ assertEquals("7BF", interpreter.variable("d$"));
+ }
+
+ @Test
+ void locateRowOnlyKeepsColumn() {
+ final Interpreter interpreter = run("""
+ LOCATE 3, 10
+ LOCATE 4
+ col = POS(0)
+ row = CSRLIN
+ """);
+ assertEquals(10.0, interpreter.variable("col"));
+ assertEquals(4.0, interpreter.variable("row"));
+ }
+}
}
@Test
- void defaultForegroundIsHighestAttributeOfMode() {
+ void defaultForegroundMatchesRealQBasic() {
// 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.
vga.setMode(ScreenMode.SCREEN_7);
assertEquals(15, vga.textForeground());
vga.setMode(ScreenMode.SCREEN_13);
- assertEquals(255, vga.textForeground());
+ assertEquals(15, vga.textForeground()); // SCREEN 13: real QBasic default is 15, not 255 (QB.EXE oracle, 2026-08-27)
}
@Test
final VgaDevice vga = new VgaDevice();
vga.setMode(ScreenMode.SCREEN_13);
vga.print("A");
- assertEquals(255, vga.point(A_STROKE_X, A_STROKE_Y)); // default fg = highest attribute (white in mode 13 default palette)
+ assertEquals(15, vga.point(A_STROKE_X, A_STROKE_Y)); // SCREEN 13 default fg = 15 (white), NOT 255 (black in the real DAC 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(255, vga.point(A_STROKE_X, A_STROKE_Y), "stroke pixel must be fg color");
+ assertEquals(15, 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(255, vga.point(A_STROKE_X, A_STROKE_Y), "no scroll yet: marker intact");
+ assertEquals(15, 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(255, vga.point(A_STROKE_X, 32), "content above VIEW PRINT band must not move");
+ assertEquals(15, 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());
}