TI-BASIC execution
TI-BASIC is a token-stream interpreter built around three kinds of state: a
cursor over program bytes, a recursive expression evaluator whose result lives
in OP1, and control records that remember where loops and subprograms resume.
This page follows one statement through those layers before describing the
special cases.
The addresses and byte-level decisions below refer to TI-84 Plus OS 2.55MP. Claims marked [confirmed] are tied to ROM bytes or calculator traces. A [hypothesis] marks the remaining interpretation of an incompletely decoded structure.
The execution pipeline
A stored program is a VAT object of type ProgObj (05h) or ProtProgObj
(06h). Its data begins with a little-endian size word followed by exactly that
many token bytes. There are no stored line numbers: 3Fh separates lines, and
3Eh separates colon-delimited statements. [confirmed]
Execution can be read as a pipeline:
flowchart LR
V["VAT program object"] -->|"size selects token body"| C["parse cursor<br/>nextParseByte … basic_end"]
C -->|"fetch and classify"| H["statement or expression handler"]
H --> E["recursive expression"]
H --> M["variable or list access"]
H --> D["command or control transfer"]
E --> R["OP1 result"]
M --> R
D --> R
R -->|"advance or replace cursor"| C
The page-38 evaluator owns the cursor and grammar. Statement commands can cross to page 02, control-flow bodies to page 33, display code to pages 01/03/37, and variable lookup to the VAT routines. Those page changes are continuations of one interpreter, not separate parsers. [confirmed]
The token cursor
TIBasicParserState (0x9652) keeps the program identity and cursor interval
contiguous in RAM: [confirmed]
#pragma pack(push, 1)
typedef struct {
uint8_t basic_prog[9];
uint16_t basic_start;
uint16_t next_parse_byte;
uint16_t basic_end;
uint8_t num_arguments;
} TIBasicParserState;
#pragma pack(pop)
next_parse_byte is the current token position. basic_start begins the
current body, and basic_end is its inclusive parser/refill boundary.
The small page-38 helpers are the useful way to reason about cursor movement:
| Routine | Address | Operation |
|---|---|---|
parse_cur_tok | 38:72DA | Fetch the current byte and classify 00h, 3Eh, and 3Fh |
parse_advance | 38:7248 | Increment nextParseByte, compare it with basic_end, and refill when needed |
parse_expect_or_err | 38:5CD8 | Require one token or restore the fault position and raise syntax error |
parse_scan_tokens | 38:4180 | Scan to a statement delimiter without splitting a two-byte token or quoted string |
parse_init | 38:5B7B | Reset parser-position bytes and parser flags |
Encoded width matters whenever the interpreter skips rather than evaluates.
_IsA2ByteTok (00:1FE8) searches the 11-byte
two_byte_token_lead_table (00:1FF6); a match
means the lead and following byte must move together. The scanner also treats a
quoted string as one region, so Then, Else, or End bytes inside a string
cannot terminate an outer scan. [confirmed]
scan_to_delimiter():
loop:
token = current_token()
if token is end, colon, or EOL:
return
if token is quote:
advance through the closing quote or EOL
else if token is a two-byte lead:
advance once for the second byte
advance to the next token
This routine does not know the grammar of an expression. Its job is narrower: preserve token boundaries while another routine searches for a statement or block delimiter.
Expressions are nested productions
_ParseInp (38:5987) initializes parser state for a homescreen expression or
formula and enters the shared evaluator. Stored-program execution arrives with
the program body and parser frame already selected. Both converge on the
recursive expression machinery around parse_eval_expr (38:5AB3) and the
statement loop at 38:59C5. [confirmed]
The evaluator is not a flat “token to function” switch. It first maps the
current token to a grammar class, selects a production family, and lets that
handler recursively consume tighter-binding operands. The selector at
38:7010 chooses among three bases:
Selector C | Handler family | Role |
|---|---|---|
Other than 02h or 03h | grammar_handler_table (38:4000) | Main grammar productions |
02h | code at 38:478C | Postfix/power production |
03h | leaf_production_handler_table (38:7175) | Six leaf-production offsets |
For the main family, the grammar class in A indexes
grammar_handler_table. Its 87 page-local offsets contain 84 valid pointers and 81
distinct handler destinations. The bytes there are data — beginning 9F 41 F0 45 1C 42 ...—not executable Z80. The selector doubles the class, reads the
pointer, and calls the chosen production. [confirmed]
evaluate_production(class, level):
if level == 2:
return postfix_production_478C(class)
else if level == 3:
handler = leaf_production_handler_table[class]
else:
handler = grammar_handler_table[class]
return handler(parse_cursor, OP1)
This nesting is what gives ^, multiplication, and addition their precedence.
Binary productions move operands through the OS floating-point stack and apply
operations such as _FPAdd, _FPMult, or _BinOPExec; the completed value is
left in OP1. 38:6FB7–6FC2 also folds token classes F2h and above by adding
12h before dispatch. [confirmed]
The other indirect jumps in the declared interpreter graph also have bounded ROM-owned destinations:
| Jump | Selector source | Valid destinations |
|---|---|---|
38:4390 | 14 entry wrappers load literal continuations | 14 |
38:7244 | 49-class table at 38:4FDB; five rows are zero/invalid | 27 distinct |
02:5675 | Five preceding token comparisons load literal targets | 5 |
33:4380 | Bounds-checked 13-row table at 33:4381 | 13 |
The CFG follows those destinations without treating adjacent table bytes as instructions. The bounds describe valid interpreter state, not arbitrary register or stack corruption. [confirmed]
The shared statement loop can then do one of three things with the result:
- store it through a parsed variable name;
- write it to
Ansthrough_StoAns(38:6251); or - pass it to a command or control-flow continuation.
_AnsName (38:74B7) constructs the internal name with class byte 72h;
_RclAns (38:679F) recalls it through the ordinary variable machinery.
[confirmed]
Variable identity and value payload are separate
The parser first builds a variable-name descriptor in OP1. The descriptor’s
type byte selects a VAT object class and the following bytes encode the token or
name. findsym_scan (07:565F) resolves that identity to a VAT entry and data
pointer; only then does the recall path copy or address the value payload.
[confirmed]
| Object class | Type | Payload used by BASIC |
|---|---|---|
| Real | 00h | One 9-byte TIFloat |
| Real list | 01h | 2-byte length, then 9-byte elements |
| Matrix | 02h | Two dimensions, then 9-byte elements |
| String | 04h | 2-byte length, then token/character bytes |
| Program | 05h | 2-byte length, then token bytes |
| Protected program | 06h | Program payload with protected edit semantics |
| Complex list | 0Dh | 2-byte length, then complex elements |
flowchart LR
T["name token"] --> N["OP1 name descriptor"]
N --> V["07:565F<br/>VAT scan"]
V -->|"recall"| P["typed payload"]
P --> R["OP1 value or element address"]
R --> A["FP/list/matrix operation"]
A -->|"store"| S["create, resize, or replace VAT payload"]
Scalar arithmetic copies a 9-byte value into the OP registers. List and matrix
access instead checks the container type and dimensions, computes one element
address, and moves that element through OP1. Stores can therefore fail before
arithmetic runs: name lookup, type compatibility, dimensions, and allocation
are distinct boundaries. [confirmed]
Statements end locally; blocks scan structurally
A false single-line If only has to skip one statement. A false If ... Then,
While, Repeat, or For( must find a matching structural boundary without
executing the intervening tokens. That is the purpose of
blockmatch_end_else (38:4130). [confirmed]
find_matching_boundary():
depth = 0
loop:
token = current_token()
if token == Else and depth == 0: return Else
if token == End and depth == 0: return End
if token == End: depth -= 1
if token in {For, While, Repeat}: depth += 1
if token == If:
scan_to_delimiter()
if current_token() == Then: depth += 1
scan_to_delimiter()
The distinction between If condition and If condition:Then is structural:
only the latter opens a block. Nested Else tokens are ignored until the depth
returns to zero. The comparisons and counter changes are visible at
38:4137–417E; the counter is the 16-bit DE register. [confirmed]
Natural loops use FPS and OPS records
Natural For( execution reaches parse_for_production (38:41E5). Natural
End execution reaches parse_end_ops_record (38:4200). Each loop owns
floating-point operands on the FPS and three control words on the OPS.
[confirmed]
| Loop | Offset below body FPS | Meaning | OPS continuation |
|---|---|---|---|
For( | -36 | loop-variable descriptor | 38:5836 |
For( | -27 | initial value | — |
For( | -18 | limit | — |
For( | -9 | step | — |
While | -9 | condition operand | 38:57C3 |
Repeat | -9 | condition operand | 38:57E7 |
Each OPS record contains three little-endian words. Offset +0 is the
continuation address. Offset +2 is the one-based parse offset. Offset +4
is the previous cleanTmp depth. OPS grows downward, so transient evaluator
objects can precede the record in an ascending RAM dump. [confirmed]
38:595A computes nextParseByte - basic_start + 1. The path at 38:5965
restores the cursor as basic_start + offset, then pushes the offset and
continuation through 38:7511. A For(I,1,3) trace records offset 7; the
tracked While and Repeat cases record offset 8. [confirmed]
38:58CE pushes the condition for While and Repeat, then saves the cleanup
link. For( enters the same save path at 38:58D1 because its parsed operands
already occupy the FPS. 38:58DF restores the previous cleanup depth.
[confirmed]
A terminating For( removes 0x24 FPS bytes at 38:582A. Its complete FPS
record is 36 bytes, but the handler grows FPS by 27 bytes because it adopts the
parsed loop-variable descriptor. A false While takes 38:57B0, scans to the
matching End, drops one FPS slot at 38:57B7, and restores the cleanup link.
Repeat reverses the condition at 38:57F8. [confirmed]
Nested-loop traces observe saved cleanTmp depths 1, 2, and 3. A
Return inside For( reaches 38:5836, removes the 36-byte FPS record, and
restores the cleanup link. Stop inside While bypasses 38:57C3 and exits
the program chain. Divide-by-zero inside Repeat bypasses 38:57E7; the OS
error cleanup resets cleanTmp, FPS, and OPS before displaying the error.
[confirmed]
Six TilEm cases cover normal For(, While, and Repeat, nesting,
Return/Stop, and an OS error. [confirmed]
The optional closing ) in For( changes parser work. The measured effect is
covered in the For( parenthesis trap.
Labels rescan instead of indexing
goto_lbl_name_scanner (38:4870) reads the label name after Goto or Lbl.
The search path at 38:7600 rescans the program body for a matching Lbl, then
moves nextParseByte to it. This explains both the linear cost of Goto and
why jumping out of structured loops can bypass normal loop cleanup. The token
and name-scanning path is [confirmed]; the complexity and stack consequence are
standard TI-BASIC behavior.
Program calls share data but preserve parser control
prgmNAME resolves another ProgObj, saves the caller’s interpreter state,
and evaluates the callee body. It does not create a local variable frame.
Scalars, lists, strings, and Ans remain global, so they form the practical
calling convention. [confirmed]
| Event | Effect |
|---|---|
prgmNAME | Enter the callee with a nested parser/control frame |
Return | Unwind one BASIC program frame and resume the caller |
| End of body | Return through the same program-frame machinery |
Stop | Terminate the whole BASIC program chain |
The run-confirmed callee transition passes through 38:6910, calls at
38:6914, and enters the body evaluator at 38:778F. The public parser bcalls
are not substitutes for that prepared state: calling _ParseInpLastEnt or
_Find_Parse_Formula from an arbitrary AsmPrgm reaches parser setup but not a
working BASIC call frame. The negative fixtures end at ERR:INVALID and
ERR:UNDEFINED, respectively. [confirmed]
For source-level conventions and the Ans/scalar/list calling fixture, see
TI-BASIC examples and ASM interop.
Commands parse arguments, then hand off
Commands use the same expression evaluator for arguments and then cross to a specialized subsystem. The important boundary is between argument parsing and the operation itself:
| Command | Parse/dispatch evidence | Downstream operation |
|---|---|---|
Disp | page-38 statement handler | _Disp at 37:51D3, then _NewLine |
Output( | 38:6AE6, page-02 handler | _OutputExpr at 03:4AF2 |
Input | 02:54EF | entry editor, _ParseInpGraphReset, variable store |
Prompt | 02:562F | repeated named-variable entry and store |
Menu( | 02:555D | _DispMenuTitle at 39:4D21, then label transfer |
Pause | 02:55E7 | display and key-wait loop |
getKey | expression token ADh | non-blocking _GetKey bcall 4972h |
Input accepts either an optional prompt string or a row/column prefix before
one store target. Prompt loops over comma-separated variables and generates
the NAME= labels itself. Accepted text enters _ParseInpGraphReset (bcall
4B04h, body 38:5978) and continues through the parser handoff at 38:5B2B.
It does not enter _ParseInp at 38:5987 in the tracked cases. [confirmed]
The entry editor stores tokenized text in a split buffer: [confirmed]
| Word | Address | Boundary |
|---|---|---|
editTop | 0x96F4 | start of the left span |
editCursor | 0x96F6 | end of the left span and start of the gap |
editTail | 0x96F8 | end of the gap and start of the right span |
editBtm | 0x96FA | end of the right span |
Insertion at 06:4341 writes at the cursor. Cursor movement transfers tokens
across the gap through 06:441E/06:445C; 06:43CA and 06:472F repack the
two sides. Delete and redraw run through 06:45F8 and 06:46AF. The
EDINPUT trace submits an empty line, retries, edits an expression that wraps
from row 0 to row 1, and displays 45 after parser handoff. [confirmed]
The [ON] cancellation fixture reaches _ErrBreak at ram:273D and the
error cleanup at ram:2793. It never reaches the parser handoff or the later
Disp 99. Both editOpen (0x89F1 bit 2) and promptEdit (0x8A01 bit 0)
are clear after cleanup. Menu( uses its separate title/key path at
39:4D21/39:5955; the tracked numeric selection reaches the label scanner
at 38:4870 and displays 22. [confirmed]
Four TilEm cases cover Input, Prompt, cancellation, and Menu(. [confirmed]
getKey is an expression value, not a statement. The table at 37:6700 is a
token-attribute table; returned key codes come from _GetKey on page 06. This
distinction prevents a common false inference from the nearby CP ADh bytes.
[confirmed]
Errors unwind saved relative stack state
The error entries first select an error code, then join the common path at
00:270A. For example, _ErrDivBy0 at 00:26EC selects 82h, while the
syntax entry at 00:2700 selects 88h. Natural Disp 1/0 and Disp 1+
traces reach those entries, respectively. [confirmed]
The entry identifies the error message, but its incoming guard identifies the cause. Twelve natural programs separate all six numeric error entries into 12 guard paths. This table groups related paths so the mechanism stays visible:
| Program | Originating guard | Predicate | Error shim |
|---|---|---|---|
Disp 1/0 | 00:2548–254B | divisor in OP2 is zero | _ErrDivBy0 at 00:26EC |
Disp 10^100 | 02:7076–7078, then 02:7053–7059 | positive exponent argument is at least 100 | _ErrOverflow at 00:26E8 |
Disp 1E99*1E99 | 00:2513–251D | adjusted sum of biased decimal exponents overflows | _ErrOverflow at 00:26E8 |
Disp ln(0) | 02:6F1E, then 00:212D–2131 | logarithm operand in OP1 is zero | _ErrDomain at 00:26F4 |
Disp sin⁻¹(2) / cos⁻¹(2) | 02:76F1–76F5 / 02:76DF–76E2 | operand lies outside $[-1,1]$ | _ErrDomain at 00:26F4 |
Disp (-1)! / (-1) nCr 1 | 35:79CF–79D2 / 02:4FC8, then 00:2125–211D | operand fails the operation’s sign or integer check | _ErrDomain at 00:26F4 |
Disp sqrt(-1) | 00:1B8F–1B93 | a complex result reaches the real-mode guard | _ErrNon_Real at 00:26FC |
Disp [[1,2][2,4]]⁻¹ | 02:439C–43A5 | the pivot helper rejects the rank-deficient matrix | _ErrSingularMat at 00:26F0 |
For(I,1,3,0) / For(I,1E99,1E99) | 37:4268–426B / 38:586D–5876 | the step is zero / adding it makes no progress | _ErrIncrement at 00:26F8 |
The two OVERFLOW rows reach the same shim through different predicates. A trace
that records only 00:26E8 therefore merges distinct numeric behavior. The
compact numeric-error report retains the ordered guard path, the register state
after each guard instruction, and the final error code in A. [confirmed]
A whole-ROM direct-reference scan finds 114 candidate CALL or JP operands
to the six shims. The natural corpus reaches 11 distinct direct callers:
| Error entry | Direct-reference candidates | Witnessed callers |
|---|---|---|
_ErrOverflow at 00:26E8 | 9 | 2 |
_ErrDivBy0 at 00:26EC | 2 | 1 |
_ErrSingularMat at 00:26F0 | 3 | 1 |
_ErrDomain at 00:26F4 | 91 | 4 |
_ErrIncrement at 00:26F8 | 6 | 2 |
_ErrNon_Real at 00:26FC | 3 | 1 |
These 114 sites are linear-disassembly candidates, not 114 established predicates. The scan can decode data as instructions. It also omits indirect transfers and helpers that load an error code before entering the common path. The report preserves that distinction. [confirmed]
flowchart LR
S["TI-BASIC expression"] --> E["operator evaluator"]
E --> G["numeric guard<br/>zero, range, or exponent"]
G --> R["shared error shim<br/>00:26E8–2708"]
R --> C["00:270A<br/>common error path"]
C --> U["restore OPS, FPS,<br/>error SP, and page"]
The shared context wrapper at 00:27DA does not save absolute FPS and OPS
pointers. It saves each pointer as a delta from the corresponding base at
0x9822 or 0x9826, together with the previous error stack and mapped page.
The unwind path at 00:27BB–27D9 restores them in reverse order. [confirmed]
save_error_context(target):
push current_flash_page
push previous_error_stack
push FPS - word_at(0x9822)
push OPS - word_at(0x9826)
error_stack = SP
jump target
unwind_error(error_code):
SP = error_stack
OPS = word_at(0x9826) + pop_word()
FPS = word_at(0x9822) + pop_word()
error_stack = pop_word()
restore_flash_page(pop_word())
return error_code
flowchart LR
E["error entry<br/>82h or 88h"] --> C["00:270A<br/>common error path"]
C --> U["00:27BB<br/>load saved error SP"]
U --> O["restore OPS delta"]
O --> F["restore FPS delta"]
F --> P["restore previous error SP and page"]
P --> H["error UI / caller continuation"]
The traces confirm the entry, common unwind, and pointer restoration. The
error-screen Goto editor and every nested-error caller remain outside the
current interpreter model.
What the coverage model establishes
tools/ti84re/tibasic/analyze_coverage.py ties eight finite models to byte signatures
in the pinned ROM. It exhausts 591,360 states across token width, delimiters,
one scan step, one block-depth transition, the extended-class fold, precedence
family selection, command finalization, and page-33 table bounds. Z3 proves a
minimum representative set for the semantic outcomes. [confirmed]
Dynamic evidence is a separate layer. Natural programs reach 38 of 52 outcomes at 26 selected branch sites. Public-bcall and internal-entry probes bring the declared outcome set to 52 of 52 while preserving provenance. The report records only trace hashes and compact counts; raw traces remain outside the repository. See TI-BASIC dynamic tracing for commands and exact boundaries. [confirmed]
The broader saturation audit starts at all 81 grammar-handler destinations and selected command, control-flow, value-storage, and numeric-error entries. It reaches 8,490 ROM instructions and 1,351 conditional branches, or 2,702 possible branch outcomes. The retained traces observe 924 outcomes; natural TI-BASIC programs account for 898. [confirmed]
This is deliberately not a claim of complete interpreter coverage. The four
declared computed jumps are expanded over their ROM-defined valid domains, but
corrupted or otherwise out-of-domain dispatch state is not modeled. Calls into
display, graphing, and other ROM pages leave the declared regions, and arbitrary
token streams, recursion depths, VAT layouts, and floating-point values remain
open. The compact tools/oracles/tibasic/tibasic-saturation.json report records those
boundaries explicitly.
Address map
| Address | Role |
|---|---|
00:1FE8 | _IsA2ByteTok |
38:4000 | Grammar-handler pointer table |
38:4130 | Matching End/Else scanner |
38:4180 | Token-aware skip scanner |
38:41E5 | Natural For( production entry |
38:4200 | Natural End record consumer |
38:4870 | Goto/Lbl name scanner |
38:5987 | _ParseInp |
38:5AB3 | Recursive expression evaluator |
38:6251 | _StoAns |
38:6910 | Stored-program statement-body entry |
38:6FB7 | Grammar-class validation and high-token fold |
38:7010 | Production-family selector |
38:7248 | Cursor advance/refill |
38:72DA | Current-token fetch and delimiter classification |
38:758A | _Find_Parse_Formula |
38:7600 | Store/label name scanning region |
38:778F | Nested stored-program body evaluator |
02:5676 | Command finalization gate |
33:435F | Bounded control-flow command dispatcher |
The local finite-model evidence is tools/oracles/tibasic/tibasic-coverage.json. The broader
direct-CFG evidence is tools/oracles/tibasic/tibasic-saturation.json. The selected backward
error slices are in tools/oracles/tibasic/tibasic-numeric-errors.json. All three generators
verify the pinned ROM before producing a report.