Making startrek.bas run, and the two interpreter bugs it was hiding¶
Created: 2026-08-05
Status: Fixed - basic/games/startrek.bas, basic/games/STINSTR.TXT (new),
src/interpreter.py, src/runtime.py
Regression test: tests/regression/interpreter/test_gosub_in_then.py
basic/games/startrek.bas would not run. Getting it to run took four changes to
the program and two to the interpreter, and the interpreter ones matter far more
than the game does: between them they were silently breaking 35 of the shipped
programs.
The program was GW-BASIC, not MBASIC¶
Checked against the 5.21 reference manual (recoverable at
git show cfa65e76^:doc/external/basic_ref.txt) and against the real binary
under cpmemu:
CLS(lines 5, 162, 280) appears nowhere in the manual, and the real binary answersSyntax error. It arrived with IBM BASICA/GW-BASIC. A CP/M console is a serial terminal, so clearing it means sending whatever that terminal wants - which is why the language has no statement for it. Replaced withPRINT CHR$(26);, the CP/M screen clear thatbasic/games/spacewar.basalready uses. cpmemu renders it asESC[2J ESC[H.OPEN "STINSTR.TXT" FOR INPUT AS #1(line 30) is the GW-BASIC form. 5.21 isOPEN <mode>,[#]<file number>,<filename>(manual §3), so it becomesOPEN "I", #1, "STINSTR.TXT".TIMER(line 165) is not in the manual either - the only occurrence of the word is a filename in aCSAVEexample. The program already asks the user for a random number, which is what now seedsRANDOMIZE.- The instructions file did not exist. Opening a missing file is
File not foundon the real binary too, so line 30 is guarded withON ERROR GOTO 1540and resumes at 90 - the game runs from any directory.STINSTR.TXTis now shipped alongside, written from what the program itself does: its six commands, the course dial, the display symbols, docking.
CLEAR 1000 was left alone. The manual's syntax summary suggests a leading
comma, but the real binary accepts CLEAR 1000 and rejects CLEAR ,1000 with
Out of memory - the comma form is the memory-size variant.
The fixed program runs on real MBASIC 5.21 under cpmemu, which is the strongest check available that the dialect is right.
Bug 1: a GOSUB inside THEN dropped the rest of the clause¶
30 IF I > 0 THEN GOSUB 100: PRINT "NEVER PRINTED"
Real 5.21 prints it. This interpreter did not.
A THEN clause is not addressable by PC - the statement table holds one entry for
the whole IF, and the clause's statements hang off it in a list - so a GOSUB
inside one had nowhere to point its return address except the statement after
the IF. execute_if then broke out of its loop the moment npc was set, and
everything after the GOSUB was gone.
startrek fills its quadrant entirely in that shape:
350 FOR I = 0 TO 7: K3(I) = 0: X = 8: IF I < K THEN GOSUB 540: S(X,Y) = 3: K3(I) = S9
370 IF B > 0 THEN GOSUB 540: S(X, Y) = 4
380 IF I > 0 THEN GOSUB 540: S(X, Y) = 5: I = I - 1: GOTO 380
GOSUB 540 picks an empty sector into X,Y, and the statements that use it
never ran. The map came up as nothing but dots and the Enterprise while the
status panel said CONDITION: RED and KLINGONS LEFT = 21.
The fix gives the clause tail to the GOSUB frame:
execute_clause_statements() hands the remaining statements to
runtime.set_gosub_continuation(), and RETURN runs them before the return
address takes effect - so a GOTO at the end of the clause still wins, which is
what makes line 380's loop terminate.
Two details that cost a debugging round each:
- A
GOSUBinside a continuation computes its return address from wherever the interpreter is standing, which by then is the subroutine that just returned.set_gosub_return()corrects it to where the clause was going to end up. Without this,THEN GOSUB 100: GOSUB 200: PRINTreturns into the middle of subroutine 200 and eventually dies withRETURN without GOSUB. - Only a
GOSUBgets a continuation. Any other jump out of a clause -GOTO,THEN <line>- is a departure, and what follows it is unreachable, which is what MBASIC does too.
This affects 180 lines across 35 of the shipped programs, not just
startrek - asm2mac.bas:530 is
IF OPC$="LDAX" THEN OPC$="LD":GOSUB 1630:OPD$="A,("+OPD$+")", where the
assignment that produces the output was being dropped.
Bug 2: NEXT with no variable¶
580 FOR I = 0 TO 7: IF K3(I) <= 0 THEN 605
...
605 NEXT: RETURN
_find_most_recent_for_variable() scanned backwards through the source for a
FOR, and called statement_table.get_statement() - a method that has never
existed - so the first bare NEXT reached this way died with AttributeError,
and after that was fixed, with NEXT without FOR: the scan starts from the
NEXT and line 580 jumps forward over its own body to get there.
Which FOR a bare NEXT belongs to is a question about what is running, not
about the text. runtime.for_loop_states is keyed by variable and, being a
dict, preserves the order the loops were entered - so the innermost active loop
is simply the last one still in it.
Verifying¶
python3 tests/regression/interpreter/test_gosub_in_then.py
10 checks: statements after a GOSUB in THEN and in ELSE, a false condition
running none of the clause, the THEN GOSUB: ...: GOTO self loop, nested
GOSUBs from clauses, two GOSUBs in one clause, a bare NEXT reached by a jump,
nested bare NEXTs, and startrek itself drawing a quadrant that contains
something. Each behavior was compared against real MBASIC 5.21 under cpmemu
before being written down.
Known limitation, unrelated but visible here¶
The game prints ENERGY = 3934.027898842015. Real 5.21 stores that as single
precision and would print 3934.03. This implementation prints Python's full
double-precision repr for computed values, which is a general numeric-formatting
gap rather than anything to do with startrek - but this is a good place to see
it.