A from-scratch PowerBASIC-family compiler that turns vintage BASIC into real 16-bit DOS executables — written in modern C#, runnable on any 64-bit host.
PB-Compiler (pbc) reads unmodified BASIC source from the DOS era and emits real
binaries you can run on actual DOS or in DOSBox:
.EXE— DOS MZ executables for 8086+ real mode,.PBU— compiled units ($COMPILE UNIT),.PBL— unit libraries (linkable via$LINK).
Two things make it interesting. First, fidelity: for the historic dialects
it doesn't merely resemble the genuine compilers — it is driven against the
original binaries and produces byte-identical output, documented bugs and
all. Second, a forward path: a real SSA-based optimization pipeline and
lean-output backend — available in every dialect via --optimize and on by
default in the pb36 language-features superset — that drops a hello world from a
fat always-linked runtime to a 25-byte image while keeping every existing program
byte-identical.
PowerBASIC for DOS is proprietary, 16-bit and long out of print — it cannot run
on modern 64-bit hosts. PB-Compiler is a clean-room, cross-platform
reimplementation so that PB codebases (such as
PB-SvgaLibrary) can be built and
verified on modern machines and CI, while the binaries it produces still run on
the original target: 8086+ real mode under DOS or DOSBox. Along the way it grew
into a broader DOS-BASIC toolchain — Turbo Basic, QuickBASIC and BASIC PDS — and
into pb36, a what-if "next PowerBASIC" that keeps the language but rebuilds the
back end around modern optimization.
The dialect is chosen with --dialect (default pb35). Selecting an older
dialect both gates newer language features (they raise a diagnostic) and
re-enables that version's documented bugs — bug compatibility is part of
fidelity (see docs/QUIRKS.md). Two product families share the
flag: the Borland lineage (Turbo Basic → PowerBASIC, same author, Bob Zale) and
the Microsoft lineage (QuickBASIC → BASIC PDS).
| Dialect | Flag | Family / era | Notes |
|---|---|---|---|
| Turbo Basic 1.0 / 1.1 | tb10 tb11 |
Borland, 1987 | PowerBASIC's direct ancestor; 16-significant-digit "double-everything" runtime, three-digit exponents. Byte-identical to genuine TB.EXE. |
| PowerBASIC 2.0 / 2.1 | pb20 pb21 |
Borland | Core BASIC baseline (no inline ! asm, no QUAD, no pointers). pb21 verified byte-identical against PB.EXE 2.10. |
| PowerBASIC 3.0 | pb30 |
Borland, 1993 | Inline assembler, 80386 codegen, unsigned types, QUAD, TYPE/UNION, HUGE arrays. Byte-identical against PBC.EXE 3.0c. |
| PowerBASIC 3.1 | pb31 |
Borland | Typed radix literals, whole-UDT compare, ALIAS, ANY parameters. |
| PowerBASIC 3.2 | pb32 |
Borland | Data and code pointers, VARPTR32/STRPTR32/CODEPTR32, identifier underscores. |
| PowerBASIC 3.5 | pb35 |
Borland, 1997 | The reference dialect (default). ASCIIZ, & concat, VIRTUAL arrays, REDIM PRESERVE, indexed pointers, STDIN/STDOUT, TRIM$, SIZEOF, and more. Byte-identical against PBC.EXE 3.50. |
| PowerBASIC 3.6 | pb36 |
Hawkynt | The language-features superset. A strict superset of pb35: every pb35 program compiles unchanged with byte-identical behavior, plus opt-in modern syntax. |
| BASICA / GW-BASIC | basica gw |
Microsoft (interpreters) | The classic line-numbered interpreters. SINGLE is stored in genuine Microsoft Binary Format (MBF) with x87 conversion on load/store; DOUBLE (MBF 8-byte) and the interpreter-oracle harness are in progress (see roadmap below). |
| QuickBASIC 1.0–4.5 | qb10 qb20 qb30 qb40 qb45 |
Microsoft | QB display model (D exponents), BASCOM runtime heritage (^Z, half-away rounding) through 3.0. Verified byte-identical against the genuine BASCOM/BC/QB toolchains. |
| QBasic | qbasic |
Microsoft (interpreter) | The MS-DOS 5.0+ interpreter — the QuickBASIC 4.5 language (IEEE floats) minus the compiler. Compiles via the QB 4.5 front end; oracle-verified by interpreter output diff (in progress). |
| BASIC PDS 7.0 / 7.1 | pds70 pds71 |
Microsoft | "QB Extended"; 15-digit DOUBLE display. Byte-identical against BC.EXE 7.0/7.1. |
The historic dialects (everything except pb36) are validated by an
oracle-driven differential harness: when the genuine compiler is dropped into
tools/<dialect>/, scripts/run-diff-tests.sh compiles each test program with
both pbc and the original and asserts the outputs match byte for byte. See
docs/DIALECTS.md for the PowerBASIC feature matrix and
docs/BASIC-FAMILY.md for the cross-family lineage.
pb36 answers a simple question: what if there had been one more DOS release?
It is the language-features dialect — a strict superset of pb35 that adds
opt-in modern syntax and compiler sugar while keeping the same observable behavior.
Every pb36 construct is rejected below 3.6 with a requires PowerBASIC 3.6
diagnostic, and none of it changes the meaning of an existing pb35 program. Full
detail in docs/PB36.md; the highlights:
Optimization is a separate, dialect-independent axis. The full optimization pipeline (below) is always reachable from the command line in any dialect via
--optimize;pb36simply turns it on by default. So you can compile faithfulpb35(or QuickBASIC, or Turbo Basic) source with the optimizer on, or compilepb36with--no-optimize— syntax level and optimization level are chosen independently.
Declarations and initialization
DIMwith initializer —DIM x = value(type inferred) orDIM x AS type = value.- Array initializer literals —
DIM a%() = {10, 20, 30}, with inclusive ranges (lo..hi) and spread of a static array (..arr); the same literal in square brackets —DIM a%() = [99..105]— doubles as a range/collection literal. - Object initializers —
DIM p = NEW Udt { .field = value, ... }. ENUMblocks — named compile-time integer constants in their own namespace; the enum name aliases its underlying integer type.
Expressions and operators
- Compound assignment —
+= -= *= /= \= ^= &=(e.g.n% += 1,s$ &= t$). - Short-circuit ternary
IF()—IF(cond, whenTrue, whenFalse)evaluates only the taken branch. ANDALSO/ORELSE— short-circuiting boolean operators (vs. PB's bitwiseAND/OR).- Shift / rotate / bitwise operators —
<<,>>,<<<,>>>,<<>,<>>,|, each with a compound-assignment form. - Scaled pointer arithmetic —
ptr +* index/ptr -* indexstep a typed pointer by element size (leaving rawptr + nunscaled, as before). - From-end array index —
arr(^1)is the last element.
Procedures
- Expression-bodied
FUNCTION—FUNCTION F(...) AS T = expression. FUNCTIONreturning a TYPE by value —FUNCTION MakeP(...) AS Point; the result is written straight into the assignment target (struct return, no copy).- Tuples / multiple return values —
FUNCTION DivMod(...) AS (LONG, LONG)returns an anonymous tuple (struct return);q, r = DivMod(a, b)destructures it. A tuple type(T1, T2, …)is an ordinary value aggregate (fieldsItem1…ItemN). - Overloading — several SUBs/FUNCTIONs may share a name with different signatures; calls resolve to the best match.
- Default and named parameters —
SUB Foo(x%, y% = 10)andFoo(y := 5). - Nested local SUB/FUNCTION — defined inside another proc, with true stack capture of the enclosing proc's locals (lifted, captured BYREF).
- Lambdas and closures —
FUNCTION(params) AS T => expr, with concise(a, b) => a + band single-parameterx => 2 * xforms (the=>arrow is a distinct token from>=), and parameter/result types inferred from the delegate they're bound to. A lambda may capture the enclosing proc's locals by reference (a stack closure: the environment travels with the delegate value). - Typed procedure pointers (delegates) —
DIM f AS FUNCTION(types) AS type, assignable from a lambda orCODEPTR32and callable directly (PRINT f(7)). - Named delegate types — a
DECLAREd SUB/FUNCTION name doubles as a procedure-pointer type, usable as a variable type or a parameter type for statically-checked higher-order procedures.
Object model (compile-time, no vtables)
- TYPE methods and properties — declare
SUB/FUNCTION/PROPERTY GET/PROPERTY SETinside theTYPEblock; the receiver is the keywordTHIS. Each lifts to a plain procedure with the instance passed BYREF, fully resolved at compile time (no inheritance, no late binding).o.Method(a),o.Prop, ando.Prop = xdesugar to those calls. - Auto-implemented properties — a
PROPERTY GET/SETwith no body gets a hidden backing field; inside a body,FIELDis that backing field andVALUEis the setter's incoming value. Expression bodies use=>:PROPERTY GET Area AS LONG => THIS.W * THIS.H,PROPERTY SET Size() => FIELD = 2 * VALUE. - Anonymous full properties —
PROPERTY Count AS LONG(noGET/SET) synthesizes both a trivial getter and setter over one backing field. - Trivial methods inline — the optimizer inlines any trivial method body
(auto-generated accessor or hand-written), treating the
THISreceiver as the ordinary BYREF argument it is; a method inlined at every call site is purged. Soo.Count(an anonymous property) is as cheap as a field, and a hand-writtenFUNCTION Sum() = THIS.x + THIS.yinlines the same way — no property-specific path. - Constructors — a
SUBnamed like theTYPEis its constructor (withTHISaccess);p = Point(3, 4)runs it with the target as the BYREF receiver. READONLYtypes —TYPE Point READONLY … END TYPEmakes every field write-once: assignable only inside the type's own constructor, rejected elsewhere at compile time.- Bit-field members —
Mode AS BIT * 3/Enabled AS BIT(1..16 bits) packs consecutive bit-fields into a hidden 16-bit storage word; a read becomes a shift-and-mask and a write a read-modify-write that preserves the neighbouring fields — pure binder desugar over WORD arithmetic, ideal for register/hardware structs. - Layout control —
TYPE T PACKED(the byte-packed default),TYPE T ALIGN n(each field on an n-byte boundary capped at its natural alignment, total rounded to n),TYPE T SIZE n(fix the total size), and per-fieldfield AS LONG AT 8(explicit placement, with gaps or overlapping/union-style views) — for hardware registers and file/wire formats. Pure binder layout, so field access and block copies follow the offsets. - Nullable types —
DIM x AS LONG?is a value plus a presence flag;x = vsets it,x = NOTHINGempties it,x ?? dis null-coalescing (value or fallback), and a nullable auto-unwraps to.Valuein arithmetic / plain assignment (.HasValue/.Valuealso explicit). The lexer disambiguates?/??from the BYTE/WORD suffixes by context (an operand after??makes it the coalescing operator). - Wide integer types —
INT128/INT256/INT512and unsignedUINT128/256/512, emulated as multi-word values (8/16/32 little-endian words) so they run on any 8086+ target. Covers declaration/sizing, conversions to/from the native scalars (constant and runtime sign-/zero-extension,wide = wideVarcopy, truncation), and add/subtract via multi-word ADC/SBB chains (carry/borrow propagates across all words); compare, multiply and decimal print are follow-ups. OPERATORoverloading — defineOPERATOR + (other AS Vec) AS Vec(and=,<,MOD, …) inside aTYPE;THISis the left operand, the body sets theRESULT.a + bresolves to the overload at compile time (a value-type feature, no dispatch). A TYPE-returning operator writes through struct return (c = a + b); a scalar-returning one (a = b) works in any expression.- Compile-time generics — generic types
TYPE Stack OF T … END TYPE(DIM s AS Stack OF LONG) and generic proceduresFUNCTION Max OF T (…) AS T(the type argument is inferred from the call:Max(3, 9),Max("a", "b")). Each instantiation is monomorphized into ordinary concrete code (fully resolved at compile time, no runtime type info or boxing), so methods, properties and the trivial-method inliner all apply per instantiation.
Generators (YIELD coroutines)
- First-class generators — any
SUB/FUNCTIONwhose body containsYIELDis automatically a generator; calling it returns a synthesized enumerator value (a UDT named after it) you can store in a variable and drive with.MoveNext/.Current/.Reset, or consume withFOR EACH. Parameters and locals persist across suspensions as enumerator fields. YIELDanywhere in structured control flow — insideFOR,WHILE/DOloops,IF,SELECT CASE, aFOR EACHover another generator (the inner iterator's state is preserved across the outer yields), andTRY/CATCH/FINALLY(the ON ERROR handler is saved in enumerator fields and re-armed per resume, since a yield unwinds the frame); all flattened to a resumable state machine. (Only aTRYthat yields while nested inside another yieldingTRYis not yet supported.)
Structured exception handling
TRY/CATCH/FINALLY— block-structured error handling lowered onto the ON ERROR trap;FINALLYruns on every exit path.DEFER <statement>— scope guard: runs the statement when the enclosing block exits, on normal completion or while a fault unwinds; nested DEFERs run last-in-first-out.- Filtered / typed
CATCH—CATCH <errnum>,CATCH WHEN <cond>, orCATCH <errnum> WHEN <cond>; several filtered clauses are tried in order (theWHENguard is evaluated only when the number matches), an unfilteredCATCHis the catch-all, and if no clause matches the error re-raises to the outer handler afterFINALLY. It's sugar over the handler switch (anIF/ELSEIFchain).
Memory and control flow
WITH expr … END WITH— leading-dot member access on a subject.FOR EACH v IN source— iterate an array's elements, a[lo..hi]range, or a generator's yielded sequence.- XMS / EMS arrays —
DIM XMS a(...)/DIM EMS a(...)storage classes alongsideVIRTUAL.
Closures are stack-based for now — a closure that escapes its defining procedure (e.g. returned to outlive its frame) is roadmap, to be backed by a heap environment (see docs/PB36.md).
The optimizer sits between the binder and the emitter, working on the bound
SemanticModel — the shared intermediate representation every dialect produces.
Because it reads each dialect's own types, wrap rules and semantics, it preserves
observable behavior for all dialects, not just PB. It is therefore
dialect-agnostic machinery (the what if there had been one more release? point
of pb36), driven entirely by the --optimize / --no-optimize switches described
above. Even with the optimizer forced on for every historic dialect, all
differential batteries still pass byte-identically — so optimization never costs
fidelity.
At its center is a real SSA mid-end (CodeGen/Ssa/): control-flow graph →
dominator tree and dominance frontiers → SSA construction → sparse conditional
constant propagation → dead-store elimination.
| # | Optimization | What it does |
|---|---|---|
| O1 | Constant folding | Folds pure integral expressions at the emitter, wrapped to the bound type (bit-equal to the runtime ALU). |
| O2 | Dead-code / dead-store elimination | Drops unreachable statements (OptPruner) and, over SSA, removes stores whose value is never really read (Ssa/DeadStore). |
| O3 | Common-subexpression elimination | Block-local CSE, with inheritance into dominated branches and across barrier-free merges (OptCommonSubexpr). |
| O4 | Strength reduction | x * 2^n, x \ 2^n, x MOD 2^n lower to shift/mask sequences (with PB's truncation fix-ups); richer multiplier shapes under $OPTIMIZE SPEED. |
| O5 | Register allocation | Keeps a FOR counter and one hot integer accumulator in SI/DI across a loop; broader allocation is roadmap. |
| O6 | Inlining | Inlines a single-result-expression FUNCTION at its call sites; induction-variable pointer-stepping for array loops. |
| O7 | Loop unrolling | Fully unrolls small constant-trip INTEGER FOR loops under $OPTIMIZE SPEED. |
| O8 | Peephole / zero-idiom | XOR r,r for zero, immediate-folded ALU ops, INC/DEC and OR AX,AX collapses (16- and 32-bit paths). |
| O9 | String-temp / concat folding | Folds pure literal concatenations into one pooled literal. |
| O10 | Redundant-statement / DEF SEG coalescing | Drops a DEF SEG whose window contains only segment-transparent statements. |
| O11 | Literal overlap pooling | Overlapping/contained string literals share bytes in one pool. |
| O12 | Float demotion | Re-types accidental SINGLE/DOUBLE loop counters back to INTEGER/LONG when every use is value-exact (OptFloatDemotion). |
| O13 | Fixed-point lowering | Integral-promotion trees over 16-bit leaves run on the plain 16-bit ALU instead of round-tripping the x87. |
| O14 | Tail-call optimization | Self-calls in tail position become a jump to frame entry — recursion in constant stack space. |
| O15 | UDT zero-cost copy/compare | Word/DWORD-wide block copy & compare; self-copy elided, self-compare folded. |
| O16 | Range / bounds-check elimination | A FOR-counter range lattice removes provably-safe $ERROR BOUNDS/OVERFLOW/divide-by-zero checks and folds invariant comparisons. |
| O17 | SCCP / branch folding | Sparse conditional constant propagation over SSA folds constant branches and proves zero-initialized reads (Ssa/Sccp). |
| O18 | Interprocedural constant propagation | A parameter that is the same constant at every call site and never written reads as that literal inside the callee (OptIpcp). |
| O19 | Definite-assignment zero elision | Drops per-invocation frame zeroing when a straight-line proof shows no local is read before assignment. |
| O20 | Idiom replacement | Recognizes and replaces common code idioms. |
| O21 | Copy propagation | A copy y = x redirects reads of y to x and drops the copy (OptCopyProp). |
| O22 | Loop-invariant code motion | Hoists a pure loop-invariant subexpression to the FOR preheader under $OPTIMIZE SPEED. |
| O23 | SELECT CASE → jump table |
A dense integer SELECT CASE dispatches through a word jump table instead of a compare chain (TryEmitSelectJumpTable). |
| O25 | Pure-function compile-time evaluation | An inferred-pure integer FUNCTION (result depends only on its BYVAL args) called with constant arguments is interpreted at compile time and the call replaced by the literal — no CONSTEXPR keyword (OptPureFold). |
Lean-output passes complement them (also gated on the optimizer, not the
dialect): P1 runtime trimming (emit only the runtime sections reachable code
references), P2 data-on-demand, P3 BSS instead of image bytes, P4
right-sized memory footprint, P6 header squeeze, and P7 trivial-I/O
lowering (a PRINT-only program becomes a raw COM-style image). Codegen passes
C1/C2 target 386/486 (32-bit value flow, alignment, BSWAP) and R3
widens string/block moves to DWORDs under $CPU 80386. R4 adds MMX
(64-bit MM0..MM7) and SSE2 (128-bit XMM0..XMM7) integer-SIMD intrinsics to the
inline assembler (! PADDW MM0, MM1 / ! PADDW XMM0, XMM1 — the same mnemonic picks the
MMX or 66-prefixed SSE2 encoding by register class — MOVQ/MOVDQA, packed
multiply/compare/shift, EMMS) — a variable named in inline asm is kept
live by the optimizer, and MMX runs under DOSBox — plus auto-vectorization:
under $CPU 80586 MMX + $OPTIMIZE SPEED the optimizer rewrites an integer
FOR i: c(i)=a(i) OP b(i) loop into 4-wide MMX with a scalar tail (wrap-correct,
so byte-identical to the scalar loop). Several passes are
implemented as verified subsets with deeper forms on the roadmap — see the method
coverage matrix in docs/PB36.md. Also on the roadmap: collapsing a
chain of mutually-exclusive IF x = k tests (IF x = 1 … ELSEIF x = 2 …) into the
same kind of jump table as SELECT CASE.
git clone https://github.com/Hawkynt/PB-Compiler
cd PB-Compiler
dotnet build -c Releasepbc (the CLI front end) is built from pbc/; the compiler itself lives in the
PowerBasic.Compiler/ library.
' HELLO.BAS
PRINT "Hello, World!"dotnet run --project pbc -- HELLO.BAS # -> HELLO.EXE (DOS MZ, real mode)Then run the result in DOSBox:
dosbox -c "mount c ." -c "c:" -c "HELLO.EXE"pbc HELLO.BAS # -> HELLO.EXE (DOS MZ, real mode)
pbc --dialect pb36 HELLO.BAS # pb36 syntax features (optimizer on by default)
pbc --dialect qb45 OLD.BAS # compile a QuickBASIC 4.5 source
pbc --optimize OLD.BAS # run the optimizer for any dialect
pbc --no-optimize APP.BAS # disable the optimizer (faithful codegen)
pbc -G386 TEST.BAS # allow 80386 instructions ($CPU 80386)
pbc UNIT.BAS # $COMPILE UNIT inside -> UNIT.PBU
pbc MAIN.BAS # $LINK "UNIT.PBU" / "MY.PBL" inside -> linked EXE
pbc lib build MY.PBL *.PBU # bundle units into a library
pbc lib list MY.PBL # show exports/imports of a library or unitUseful options: -O <file> (output name), -I <dir> ($INCLUDE search path),
-L <dir> ($LINK search path), the runtime-check switches -EB/-EN/-EO/-ES
(bounds/numeric/overflow/stack), and -OZF ($OPTIMIZE SPEED). Run pbc --help
for the full list.
Under construction — see REQUIREMENTS.md for the MoSCoW
breakdown and CHANGELOG.md for progress. In short: the full
PowerBASIC 3.5 surface (lexer/preprocessor, parser, semantics, 8086–386 + x87
assembler, MZ/PBU/PBL emitters and DOS runtime) is in place and exercised against
the PB-SvgaLibrary corpus; the
cross-vendor dialects, the pb36 language features, and the optimizer (run across
every dialect) are validated by the oracle differential harness and DOSBox
execution tests.
| Path | What |
|---|---|
PowerBasic.Compiler/ |
Compiler library: lexer, parser, semantics, 8086 assembler, code generator, optimizer, MZ/PBU/PBL emitters, DOS runtime |
pbc/ |
Command-line front end |
PowerBasic.Compiler.Tests/ |
NUnit test suite (TDD, Given-When-Then) |
tests/ |
PowerBASIC test battery executed under DOSBox (incl. tests/diff/ differential oracles) |
scripts/ |
DOSBox integration & differential harness |
docs/ |
Dialect matrices, quirks, and the pb36 design |
Contributions are welcome. The bar is the same one the project holds itself to: historic-dialect changes must stay byte-identical under the differential harness, and the test suite (NUnit, Given-When-Then) must stay green. Start with REQUIREMENTS.md and the docs above.
If this project saves you time or money, consider supporting its development:
PowerBASIC compiler · PB 3.5 · PowerBASIC 3.x · Turbo Basic · QuickBASIC · QBasic · GW-BASIC · BASICA · Microsoft BASIC PDS 7.1 · BASIC compiler · retro BASIC · 16-bit DOS compiler · MS-DOS executable · MZ EXE · real mode x86 · 8086 assembler · DOSBox · retrocomputing · vintage programming languages · BASIC dialects · transpiler / decompiler back to BASIC · optimizing compiler (SSA, GVN, LICM, SCCP, peephole, instruction scheduling) · written in C# / .NET · cross-platform DOS toolchain · OMF object files · PBU units · PBL libraries · EMS/XMS memory · coroutines, generics and pattern matching for BASIC (the pb3.6 dialect).
Licensed under LGPL-3.0-or-later — see LICENSE.