A transpiler that converts QBasic .bas source files into native Rust binaries.
The primary correctness target is GORILLAS.BAS — the classic gorilla-throwing game shipped with MS-DOS QBasic — running at full fidelity with graphics, sound, and game logic intact.
Native binaries, captured headless via the runtime's QBC_DUMP driver (see
Headless driver).
gorilla.bas → [qbc transpiler] → gorilla.rs → [rustc] → gorilla
The transpiler (qbc) reads a QBasic source file, walks the AST, and emits a self-contained Rust source file that links against a small runtime library. The result is a native binary with no QBasic interpreter involved at runtime.
basic-src/bench.bas measures the hot-loop operations the mega-demo actually uses (empty loops, integer math, array access, POKE/PEEK, PSET, LINE, GET/PUT sprites, empty SUB calls) and reports ops/sec plus an ops-per-60fps-frame budget. Run it two ways and compare:
- Real QBasic 1.1, interpreted, under DOSBox-X emulating a Pentium 66 MHz (
bench.sh, same cycle count as the demo) - Transpiled to native Rust via
qbc, run headless on Apple Silicon
| Test | DOSBox-X P66 (ops/sec) | Native Rust (ops/sec) | Speedup |
|---|---|---|---|
| EMPTY FOR/NEXT | 383,521 | 40,000,000 | ~104× |
| INTEGER ADD | 143,551 | 30,000,000 | ~209× |
| INT MUL+IDIV | 72,727 | 20,000,000 | ~275× |
| ARRAY RD+WR | 117,701 | 20,000,000 | ~170× |
| LUT SINE STEP | 67,546 | 10,000,000 | ~148× |
| POKE | 139,130 | 10,000,000 | ~72× |
| PEEK+POKE RMW | 73,405 | 8,000,000 | ~109× |
| PSET | 90,888 | 6,000,000 | ~66× |
| LINE 320PX | 36,312 | 1,082,834 | ~30× |
| LINE BF 24×24 | 19,033 | 1,885,546 | ~99× |
| GET 24×24 | 14,105 | 1,235,035 | ~88× |
| PUT 24×24 | 14,504 | 1,778,113 | ~123× |
| EMPTY SUB CALL | 121,040 | 20,000,000 | ~165× |
A few things stand out:
- Pure interpreter dispatch overhead is where the biggest wins are.
INT MUL+IDIV(~275×) andINTEGER ADD(~209×) are near-instant in native code but pay QBasic's per-statement bytecode-fetch cost on real hardware every time. - Graphics ops scale less dramatically (~30–123×) because both platforms spend real time touching pixel data — the work is bandwidth-bound, not dispatch-bound.
LINE 320PX(~30×) is the smallest multiplier for exactly this reason. - This is why the demo can afford more —
bench.bas's own comment notesPEEK+POKE RMW"killed shadebobs v1" on real hardware (73K ops/sec); at 8M ops/sec natively, that per-pixel read-modify-write loop stops being a constraint.
# Build everything
cargo build
# Transpile and run gorillas
cargo run -- basic-src/gorilla.bas -o bin/gorilla.rs
rustc bin/gorilla.rs --edition 2021 \
-L target/debug/deps \
--extern qbasic_runtime=target/debug/libqbasic_runtime.rlib \
-o bin/gorilla
bin/gorilla
# Or just inspect the emitted Rust
cargo run -- basic-src/gorilla.bas --emit-only| Program | Description | Status |
|---|---|---|
gorilla.bas |
Classic gorilla-throwing game (SCREEN 9 EGA, CIRCLE/PAINT/GET/PUT sprites, PLAY audio) — walkthrough | ✅ |
torus.bas |
Interactive 3-D torus (arrays of TYPE, WINDOW/PMAP, VGA DAC palette cycling) — walkthrough | ✅ |
reversi.bas |
Reversi/Othello with an AI opponent (2-D TYPE array, 3-D array, WINDOW SCREEN, ERASE) — walkthrough | ✅ |
mandel.bas |
Mandelbrot renderer (VIEW/WINDOW coords, PALETTE cycling, PACE) | ✅ |
donkey.bas |
Q-BASIC Donkey game (GOTO state machine, DRAW sprites, CGA SCREEN 1) — walkthrough | ✅ |
sortdemo.bas |
Animated sorting visualizer (SHARED vars, animation) | ✅ |
money.bas |
Money manager (DATA/READ, SELECT CASE, arrays) | ✅ |
pi.bas |
Arbitrary-precision pi via Machin's formula | ✅ |
hangman.bas |
Hangman word game (modern QBasic style, DO/LOOP, named GOSUB/GOTO) | ✅ |
hangman-gw.bas |
Hangman word game (GW-BASIC style, line numbers, GOTO state machine) | ✅ |
sound.bas |
Minimal PLAY/MML demo (text-mode, audible arpeggio) | ✅ |
screen13.bas |
SCREEN 13 (MCGA 320×200, 256-color VGA DAC palette) demo | ✅ |
screen13-sprite.bas |
SCREEN 13 GET/PUT sprites (8-bpp MCGA chunky layout) | ✅ |
kitchen_sink-gw.bas |
GW-BASIC "mega test" — menu loop, ON GOTO/GOSUB, DEF FN, RESTORE | ✅ |
kitchen_sink-qbasic.bas |
QBasic 4.5 "mega test" — 9 menu items, ON GOTO named labels, 3-D arrays | ✅ |
invaders.bas |
Space Invaders (SCREEN 13 VGA, TYPE records, GOTO-in-SUBs, binary file I/O) | ✅ |
duck.bas |
Cartoon duck — DRAW turtle-graphics + PAINT flood-fill (SCREEN 9 EGA) | ✅ |
etto.bas |
VGA photo display — 256-color custom palette, hex-decoded pixel DATA (SCREEN 13) | ✅ |
toccata.bas |
PLAY MML music demo | ✅ |
gotorama.bas |
GOTO stress test — complex branching patterns | ✅ |
evil.bas |
GW-BASIC "self-modifying POKE matrix" — physical line continuations, POKE/PEEK memory | ✅ |
pokeit.bas |
Minimal POKE→PEEK→PRINT regression test | ✅ |
demo1.bas |
SCREEN 13 demoscene-style intro — star field, sine-wave scroller | ✅ |
demo.bas |
15-scene SCREEN 13 megademo — POKE starfield, ROM-font bigtext, wireframe cube, plasma, shadebobs, dot sphere, copper bars, tunnel, rotozoomer, vector morph, starship flight, trench run, platformer vignette, wavy sine scroller, credits crawl (DEF SEG framebuffer POKEs, BSAVE texture cache, WAIT vsync, PLAY intro jingle) | ✅ |
bench.bas |
Interpreter-vs-native benchmark — hot-loop ops/sec + ops-per-frame budget for the demo's operations — see Performance | ✅ |
blackjak.bas |
Casino Blackjack (SCREEN 12 VGA, vector card rendering, TIMER deal animation, background PLAY music) — walkthrough | ✅ |
mario.bas |
MEGA WORLD platformer (SCREEN 13, 3 worlds from WORLD<n>.TXT, masked GET/PUT sprites, raw INP(&H60) scancode input, quarter-pixel physics, boss fight, persistent high score) — walkthrough |
✅ |
qbricks.bas |
Microsoft brick-breaker demo (SCREEN 7, paddle/ball physics, GET/PUT sprites) | ✅ |
textpaint.bas |
Text-mode paint program (SCREEN 0, color picker, keyboard drawing) | ✅ |
All 54 bundled programs in basic-src/ transpile and run
(bash basic-src/build-all.sh → 54/54). The full set also includes nibbles,
q_sort, fuzzbuzz, step, 256c, palette256_expanded, random-pixel,
qblocks, loopyloop, pixel-gw, pokemix, qmaze, farkle, pin,
towers, pride, bench, and the pi-gw/hangman-gw GW-BASIC variants.
- Control flow: IF/ELSEIF/ELSE, FOR/NEXT, WHILE/WEND, DO/LOOP, SELECT CASE (incl. ranges), GOTO, GOSUB/RETURN, ON…GOTO/GOSUB (computed branch)
- Subroutines: SUB/END SUB, FUNCTION/END FUNCTION, GOSUB/RETURN, STATIC locals (persist across calls). Parameters pass by reference (QB semantics), including TYPE records.
- Data: DIM, REDIM, ERASE, DIM SHARED, COMMON SHARED, DATA/READ/RESTORE, CONST, user-defined TYPE (nested, fixed-length strings, array fields), 1-D/2-D/3-D arrays incl. arrays of TYPE
- Graphics: SCREEN (0,1,2,7,8,9,10,12,13), LINE, CIRCLE, PAINT (solid and
CHR$(n)pattern tiling), PSET, PRESET, DRAW, GET, PUT (all action verbs), VIEW, WINDOW (+ WINDOW SCREEN), PMAP, POINT, PALETTE, STEP coordinates - Sound: PLAY (full MML parser, foreground + background mode,
PLAY(n)notes-remaining query), SOUND, BEEP — wired torodio - I/O: PRINT, PRINT USING (sequential and
PRINT #n, USINGfile output), INPUT, LOCATE, COLOR, CLS, INKEY$,MID$(s,p,l) = vin-place assignment, sequential files with realEOF(n)detection, random-access files (OPEN/GET/PUT/CLOSE) with TYPE-record serialization, SYSTEM (exit to DOS, no wait-for-key) - Memory: DEF SEG segment register with segment-aware POKE/PEEK —
&HA000+ SCREEN 13 pokes/peeks framebuffer pixels directly (the demoscene draw-via-POKE idiom),&HF000:FA6EPEEKs serve the ROM BIOS 8×8 font (scaled-bigtext trick), other segments use a simulated byte map. BLOAD/BSAVE load/save framebuffer images with the 7-byte BSAVE header.WAIT &H3DA, 8really syncs to a modeled vertical retrace (~60 Hz, presents at the flip)
Line-numbered BASIC programs that use GOTO are compiled to a match __pc { ... } state machine, with each line number becoming a match arm. Programs that use only GOSUB get clean named Rust functions instead.
A logical GW-BASIC line may wrap across multiple physical file lines — any physical line that doesn't begin with a line number is treated as a continuation of the previous logical line. The lexer detects line-numbered mode automatically and suppresses Newline tokens at continuation boundaries. Non-line-numbered programs are byte-identical.
Embed transpiler directives anywhere in a .bas source file:
REM QBC FULLSPEED
REM QBC FPS 30
REM QBC PACE 30
REM QBC SLOWMO 2
REM QBC TITLE My Cool Game
REM QBC SCALE 2| Directive | Example | Effect |
|---|---|---|
FULLSPEED |
REM QBC FULLSPEED |
Disables the frame-rate throttle; program runs at full native CPU speed. Best for computation-heavy programs (pi.bas). |
FPS N |
REM QBC FPS 30 |
Cap animation at N frames per second instead of the default 60. |
PACE N |
REM QBC PACE 30 |
Sleep-pace the computation to N blits/sec so an otherwise-instant native draw is watchable (it sweeps in roughly source-draw order). Unlike FPS/FULLSPEED (which only skip blits), PACE blocks. Used by mandel.bas. |
SLOWMO N |
REM QBC SLOWMO 3 |
Multiply every QB SLEEP duration by N — handy for slow-motion inspection of timed animations. |
TITLE text |
REM QBC TITLE Gorilla Wars |
Set the window title bar text. Default is QBasic. |
SCALE N |
REM QBC SCALE 2 |
Multiply the output window size by N (default 960×600 → 1920×1200 for N=2). Useful on HiDPI displays. |
Directives are case-insensitive. Multiple directives combine freely.
Any transpiled binary honors a set of QBC_* environment variables that run it
without a window — scripted input, a deterministic RNG, a framebuffer image
dump, and a guaranteed auto-exit. The emitted binary is unchanged; behavior only
differs when these are set. This makes a graphics program debuggable and
testable on a headless box (CI, SSH) with no code edits.
# Render torus deterministically, dump the frame, print stats, exit after 5 blits
QBC_HEADLESS=1 QBC_SEED=1 QBC_KEYS=ENTER QBC_FBSTATS=1 \
QBC_DUMP=/tmp/torus.ppm QBC_EXIT_AFTER=presents:5 ./bin/torus| Variable | Effect |
|---|---|
QBC_HEADLESS=1 |
Run with no window. |
QBC_KEYS="DOWN,DOWN,ENTER,Q" |
Scripted keystrokes (one per INKEY$/INPUT$). Names: UP/DOWN/LEFT/RIGHT/ENTER/ESC/SPACE/TAB/F1…, plus single chars. Maps identically to real keypresses. |
QBC_SEED=N |
Pin the RNG (overrides RANDOMIZE TIMER) so RND-using renders are reproducible. |
QBC_DUMP=path.ppm |
Write the framebuffer as a binary PPM image (native resolution). Convert to PNG with tools/ppm2png.py. |
QBC_TEXT_FB=1 |
Render text into the framebuffer (score panels, labels, titles) instead of stdout — for full-screen screenshots. Off by default so the golden tests stay graphics-only. |
QBC_DUMP_AT=exit|present:N|ms:T |
When to dump (default exit). |
QBC_CHECKSUM=1 |
Print QBC_CHECKSUM=<hex> (framebuffer fingerprint) on exit. |
QBC_FBSTATS=1 |
Print non-background pixel count + distinct colors on exit. |
QBC_EXIT_AFTER=idle|ms:T|presents:N |
Guaranteed termination (default idle + a 10 s safety cap), so a scripted run never hangs on input. |
tests/run-graphics-tests.sh runs each graphics program headless with a fixed
seed and key script, then compares its framebuffer checksum against a committed
golden in tests/golden/<name>.txt — regression coverage for rendering that the
stdout-based suite can't provide. Headless time is fully simulated (TIMER,
INKEY$ yields, SLEEP, vsync WAITs and frame pacing all run on a virtual clock),
so every golden is bit-deterministic on any machine and the whole suite runs in
~8 seconds. On a mismatch it writes <name>.actual.ppm for visual inspection.
Regenerate goldens after an intended change with --write-golden.
tools/fuzz/run-fuzz.sh [count] [start-seed] generates seeded random QBasic
programs (genfuzz.py) over a deliberately exact-agreement subset — structured
programs with IF/FOR/WHILE/DO/SELECT (numeric and string selectors), GOSUB
subroutines, SWAP, 1-D/2-D arrays, string builtins, PRINT USING, and a second
flat line-numbered style with forward/backward GOTO targeting the __pc state
machine — transpiles and runs each natively, and diffs the output against an
independent ~600-line Python reference interpreter (qbref.py; Python floats
are IEEE f64, matching the transpiler's numeric model bit-for-bit). Any
transpile/compile/run failure or output mismatch is a finding saved to
tools/fuzz/failures/. The harness has found 13 real bugs to date; 500/500
seeds currently pass.
There's no independent renderer to diff graphics output against, so
tools/fuzz/run-fuzz-gfx.sh [count] [start-seed] checks a narrower but real
property instead: random SCREEN 13 drawing programs (genfuzz_gfx.py — PSET,
LINE/LINE B/LINE BF, CIRCLE, PAINT bounded inside a drawn box, GET/PUT sprite
round-trips, and the WAIT &H3DA vsync pair) are compiled once and run
headless twice; the two QBC_CHECKSUM values must match bit-for-bit. This
is exactly the property the old wall-clock-paced headless timing violated
(see "Graphics golden tests" above) — before the simulated headless clock,
this harness would have failed almost every seed. 300/300 seeds currently
pass.
qbasic-rust/
├── src/ # Transpiler (qbc binary)
│ ├── lexer.rs # Source text → tokens
│ ├── parser.rs # Tokens → AST
│ ├── analyzer.rs # AST → symbol table + AnalyzedProgram
│ └── emitter.rs # AnalyzedProgram → Rust source (~5370 lines)
│
├── runtime/src/
│ ├── lib.rs # Runtime struct, graphics, I/O, math (~3875 lines)
│ └── sound.rs # PLAY/SOUND/BEEP via rodio (~300 lines)
│
└── basic-src/ # .bas source files
See ARCHITECTURE.md for a full description of the pipeline, design decisions, and runtime internals.
qbc <INPUT> [-o OUTPUT] [--emit-only] [--dump-ast] [--verbose] [--explain]
| Flag | Effect |
|---|---|
-o <file> |
Output .rs path |
--emit-only |
Write .rs only; skip rustc |
--dump-ast |
Print the parsed AST and exit |
--verbose |
Print per-stage timing and stats |
--explain |
Print why each GameState field exists — see below |
Every .bas variable that ends up living in the emitted GameState struct
got there for one of four reasons, and two of those reasons are implicit —
nothing in the source marks the variable as shared, the emitter inferred it
from usage crossing a GOSUB-extracted function boundary. --explain prints
which bucket each field came from, and for the implicit ones, exactly which
scopes (main / which GOSUB blocks) reference it — that scope list is
the evidence the promotion pass acted on:
$ qbc basic-src/farkle.bas --emit-only --explain
── qbc --explain: GameState field origins ──────────────────────────
23 field(s): 9 DIM SHARED · 0 SHARED-in-SUB/STATIC · 9 cross-GOSUB scalar · 5 cross-GOSUB array
Cross-GOSUB scalar promotion (IMPLICIT — nothing in the .bas source
marks these; inferred because the name is used in more than one
scope below):
k String used in: main, GOSUB SelectDicePhase, GOSUB BankOrRoll
...
Combine freely with --verbose; it compiles normally in addition to
printing the report (nothing about --explain changes the emitted Rust).
basic-src/explain.sh <file.bas> is a one-liner wrapper (mirrors
show-asm.sh) if you don't want to remember the flags.
qbc auto-locates the runtime rlib relative to its own executable, so cargo run works without manual -L flags.
| Crate | Used for |
|---|---|
minifb |
Window creation and pixel buffer display |
crossterm |
Terminal input (non-blocking key read) |
rodio |
Audio playback for PLAY/SOUND/BEEP |
- All numerics are
f64— QB SINGLE precision is widened for simplicity - QB-accurate integer math —
CINTuses banker's rounding (ties to even);\(integer divide) andMODround both operands to integers first, then*//bind tighter than\tighter thanMOD(QB precedence);^is left-associative - QB booleans:
0.0= false,-1.0= true (bitwise NOT convention) - Bitwise operators:
AND,OR,XOR,NOT,EQV(≡ bitwise XNOR),IMP(≡(NOT a) OR b) — all operate on the integer part of f64 values - Authentic QB RNG — 24-bit LCG (
x = (x*16598013 + 12820163) AND &HFFFFFF); firstRNDfrom power-on seed is the canonical QB value 0.7055475;RND(0)repeats the last value,RND(negative)reseeds deterministically - Palette-indexed framebuffer —
POINT(x,y)returns a palette index, enabling QB-style collision detection - SHARED variables →
GameStatestruct passed as&mut __gsto every SUB - Arrays of TYPE → flattened to one parallel
Vecper field (gg(r,c).player→gg__player[r][c]); N-D arrays nestVec<Vec<…>>; TYPE body array fields (Bar(4) AS INTEGER) fully supported —arr(i).Field(j)emitsarr__field[i][j] - GOSUB targets → named Rust
fn(clean path, covers all of gorilla.bas) - GOTO →
match __pcstate machine (fallback for line-numbered programs) - Coordinate systems —
WINDOW(Cartesian, Y-up) vsWINDOW SCREEN(screen Y-down); both map a logical rect onto the VIEW/screen withPOINT/PMAPround-tripping







