dt31
A toy computer and assembly language written in Python. Build programs with 60 built-in instructions for interacting with registers, memory, and the stack. Write your programs in the native assembly syntax or directly with the Python API.
| countdown.dt | countdown.py |
|---|---|
|
|
Features
- Simple CPU Architecture: Configurable registers, fixed-size memory, and stack-based operations
- Rich Instruction Set: 60 instructions including arithmetic, bitwise operations, logic, control flow, and I/O
- Assembly Support: Two-pass assembler with label resolution for jumps and function calls
- Assembly Parser: Parse and execute
.dtassembly files with text-based syntax - Command-Line Interface: Execute
.dtfiles directly with thedt31command - Python API: Build and run programs programmatically with an intuitive API
- Debug Mode: Step-by-step execution with state inspection and breakpoints
- Pure Python: Zero dependencies
Installation
pip install dt31
Getting Started
Hello World
Create a file hello.dt
; Output "Hi!"
COUT 'H'
COUT 'i'
COUT '!'
and run it with the dt31 interpreter
dt31 hello.dt
# Hi!
Basic Arithmetic
; Add two numbers
CP 10, R.a ; Copy 10 into register a
CP 5, R.b ; Copy 5 into register b
ADD R.a, R.b ; a = a + b
NOUT R.a, 1 ; Output a with newline
Save as add.dt and run: dt31 add.dt to output 15.
Loops with Labels
; Count from 1 to 10
CP 1, R.a ; Start counter at 1
loop:
NOUT R.a, 1 ; Print counter
ADD R.a, 1 ; Increment counter
JLT loop, R.a, 11 ; Jump to loop if a < 11
Save as count.dt and run: dt31 count.dt to output 1 2 3 4 5 6 7 8 9 10.
Function Calls
; Print a greeting multiple times
CP 3, R.a ; Counter: print 3 times
print_loop:
CALL greet ; Call the greeting function
SUB R.a, 1 ; R.a -= 1
JGT print_loop, R.a, 0 ; loop if R.a > 0
JMP end
greet:
; Reusable greeting function
COUT 'H'
COUT 'i'
COUT '!'
COUT ' '
RET
end:
; output: `Hi! Hi! Hi! `
Functions use the stack for return addresses and can be called multiple times. See the examples directory for more complex examples.
Core Concepts
Operands
dt31 provides several operand types for referencing values:
- Literals: Constant values
L[42], orLC["a"]as a shortcut forL[ord("a")] - Registers: CPU registers
R.a,R.b,R.c - Memory: Memory addresses
M[100], indirect addressingM[R.a], and offsetsM[R.a + 5],M[100 + R.i],M[R.a - R.b] - Labels: Named jump targets
Label("loop")
See the operands documentation for details.
Instructions
The instruction set includes:
- Arithmetic:
ADD,SUB,MUL,DIV,MOD - Bitwise:
BAND,BOR,BXOR,BNOT,BSL,BSR - Comparisons:
LT,GT,LE,GE,EQ,NE - Logic:
AND,OR,XOR,NOT - Control Flow:
JMP,RJMP,JEQ,RJEQ,JNE,RJNE,JGT,RJGT,JGE,RJGE,JLT,RJLT,JLE,RJLE,JIF,RJIF,NOOP,EXIT - Functions:
CALL,RCALL,RET - Stack:
PUSH,POP,SEMP - I/O:
NOUT,COUT,NIN,CIN,STRIN,STROUT,SNIN,SCIN,SSTRIN - Data Movement:
CP - Utility:
NEXT,RND,RINT - Debug:
BRK,BRKD
Users can easily define their own custom instructions by subclassing dt31.instructions.Instruction.
See the instructions documentation for the complete reference.
CPU Architecture
The DT31 CPU includes:
- Registers: General-purpose registers (default:
a,b, andc), plusip - Memory: Fixed-size array of slots (default: 256), each holding a Python int
- Stack: For temporary values and function calls (default: 256 slots)
- Instruction Pointer: Tracks the current instruction in register
ip.ipis user-writable at your own risk. All instructions other than jumps will advanceipby 1.
See the CPU documentation for API details.
Command-Line Interface
Basic Usage
Execute .dt assembly files directly:
dt31 run program.dt # Execute program
dt31 check program.dt # Validate syntax
dt31 format program.dt # Format file in-place
dt31 to-python program.dt # Convert to a standalone Python source file
check and format accept multiple files and glob patterns (quote the pattern so
your shell doesn't expand it first):
dt31 check "*.dt" # Validate every .dt file in the current directory
dt31 format "**/*.dt" # Format every .dt file recursively
run and to-python take exactly one file each.
CLI Options
Run Command
--debugor-d: Enable step-by-step debug output--registers a,b,c,d: Specify custom registers (auto-detected by default)--memory 512: Set memory size (default: 256)--stack-size 512: Set stack size (default: 256)--custom-instructions PATHor-i PATH: Load custom instruction definitions from a Python file--dump {none,error,success,all}: When to dump CPU state (default: none)--dump-file FILE: File path for CPU state dump (auto-generates timestamped filename if not specified)--verboseor-v: Show runtime statistics (wall time, instruction time, execution time, and step count)--max-steps N: Maximum number of instructions to execute before raising an error (default: unlimited); useful for catching accidental infinite loops
Check Command
--custom-instructions PATHor-i PATH: Load custom instruction definitions from a Python file
To-Python Command
Convert a .dt assembly file to a standalone Python source file using the Python API (I.ADD(...), Label(...), etc.) — the same style as the hand-written examples in examples/.
-o PATHor--output PATH: Write the generated source to a file instead of printing to stdout-r a,b,c,dor--registers a,b,c,d: Explicit register list for the generatedDT31(...)call (auto-detected by default, same validation asrun --registers)-m 512or--memory 512:memory_sizefor the generatedDT31(...)call (default: 256)-s 512or--stack-size 512:stack_sizefor the generatedDT31(...)call (default: 256)-dor--debug: Generate acpu.run(..., debug=True)call
dt31 to-python program.dt # Print generated Python to stdout
dt31 to-python program.dt -o program.py # Write generated Python to a file
dt31 to-python program.dt --memory 1024 -d # Larger memory, debug=True in the generated call
Comments are preserved as .with_comment(...) calls, so the generated program
round-trips back through program_to_text. Custom instructions aren't currently
supported.
Examples:
# Validate syntax only (no execution)
dt31 check program.dt
# Validate with custom instructions
dt31 check --custom-instructions my_instructions.py program.dt
# Run with debug output
dt31 run --debug program.dt
# Use custom memory size
dt31 run --memory 1024 program.dt
# Specify registers explicitly
dt31 run --registers a,b,c,d,e program.dt
# Use custom instructions
dt31 run --custom-instructions my_instructions.py program.dt
# Dump CPU state on error (for debugging crashes)
dt31 run --dump error program.dt # Auto-generates program_crash_TIMESTAMP.json
# Show runtime statistics
dt31 run --verbose program.dt
# Output (to stderr):
# Wall time: 4.51s
# Execution time: 156.23ms
# Steps: 14
See the CLI documentation for complete details.
Verbose Mode Timing Metrics:
The --verbose flag displays timing metrics to help understand program performance:
- Wall time: Total elapsed time including I/O waits. Automatically formatted with appropriate units (s, ms, or µs)
- Execution time: Pure computation time, excluding I/O waits from input instructions (NIN, CIN, STRIN, SNIN, SCIN, SSTRIN, BRK)
- Steps (always shown): Number of instructions executed
Per-instruction timing (which powers execution time) has a small runtime cost, so it's off
by default; --verbose turns it on automatically. To record it from Python without the rest
of the verbose output, set track_step_time=True when constructing a DT31 directly.
All timing values accumulate across multiple run() calls on the same CPU instance.
Code Formatting
The dt31 format command formats .dt assembly files with consistent style, following Black/Ruff conventions (formats in-place by default).
Basic Usage:
dt31 format program.dt # Format file in-place
dt31 format --check program.dt # Check if formatting needed (exit 1 if yes)
dt31 format --diff program.dt # Show formatting changes without modifying
Exit Codes:
| Code | Meaning |
|---|---|
0 |
Success (formatted, already formatted, or --check passed) |
1 |
Error (file not found, parse error, --check failed, IO error) |
Formatting Options:
All formatting options from program_to_text() are available as CLI flags:
# Indentation (default: 4 spaces)
dt31 format --indent-size 2 program.dt
# Comment margin (default: 2 spaces before semicolon)
dt31 format --comment-margin 3 program.dt
# Inline labels (default: labels on separate lines)
dt31 format --label-inline program.dt
# Control blank lines (default: preserve from source)
dt31 format --blank-lines auto program.dt # Add blank lines before labels
dt31 format --blank-lines none program.dt # Remove automatic blank lines
dt31 format --blank-lines preserve program.dt # Preserve source formatting (default)
# Auto-align inline comments (calculates column based on longest instruction)
dt31 format --align-comments program.dt
# Align inline comments at specific column
dt31 format --align-comments --comment-column 40 program.dt
# Auto-align with custom margin (default: 2 spaces after longest instruction)
dt31 format --align-comments --comment-margin 4 program.dt
# Strip all comments from output
dt31 format --strip-comments program.dt
# Show default arguments (default: hidden)
dt31 format --show-default-args program.dt
Custom Instructions:
Format files that use custom instructions:
dt31 format --custom-instructions my_instructions.py program.dt
Common Workflows:
# Check if files need formatting (CI/pre-commit)
dt31 format --check program.dt
# Preview formatting changes
dt31 format --diff program.dt
# Format with custom style
dt31 format --indent-size 2 --label-inline program.dt
# Check formatting and show diff if needed
dt31 format --check --diff program.dt
Examples:
Before formatting:
CP 5,R.a
loop:NOUT R.a,1
SUB R.a,1
JGT loop,R.a,0
After dt31 format program.dt:
CP 5, R.a
loop:
NOUT R.a, 1
SUB R.a, 1, R.a
JGT loop, R.a, 0
After dt31 format --label-inline program.dt (default hides args):
CP 5, R.a
loop: NOUT R.a, 1
SUB R.a, 1
JGT loop, R.a, 0
After dt31 format --align-comments program.dt (with comments):
; Input with unaligned comments
CP 5, R.a ; Initialize counter
ADD R.a, R.b, R.c ; Add values
; Output with auto-aligned comments
CP 5, R.a ; Initialize counter
ADD R.a, R.b, R.c ; Add values
Custom Instructions
Define custom instructions in a Python file and load them with --custom-instructions. Your file must export an INSTRUCTIONS dict and instruction names must be all-caps:
# my_instructions.py
from dt31.instructions import UnaryOperation
from dt31.operands import Operand, Reference
class TRIPLE(UnaryOperation):
"""Triple a value."""
def __init__(self, a: Operand, out: Reference | None = None):
super().__init__("TRIPLE", a, out)
def _calc(self, cpu: "DT31") -> int:
return self.a.resolve(cpu) * 3
INSTRUCTIONS = {"TRIPLE": TRIPLE}
Use in assembly:
CP 5, R.a
TRIPLE R.a
NOUT R.a, 1 ; Outputs 15
Run with: dt31 run --custom-instructions my_instructions.py program.dt
Security Warning: Loading custom instruction files executes arbitrary Python code. Only load files from trusted sources.
See the instructions documentation for more details on creating custom instructions.
CPU State Dumps
The --dump option captures complete CPU state to JSON for debugging. Dumps include:
- CPU state: registers, memory, stack, and loaded program (as assembly text)
- Error information (on error dumps): exception type, message, traceback, and the instruction that caused the error
Error dumps include both repr and str formats of the failing instruction for easier debugging:
{
"cpu_state": {
"registers": {"a": 10, "b": 0, "ip": 2},
"memory": [...],
"stack": [],
"program": "CP 10, R.a\nCP 0, R.b\nDIV R.a, R.b",
"config": {"memory_size": 256, "stack_size": 256, "wrap_memory": false}
},
"error": {
"type": "DivisionByZero",
"message": "DIV by zero; got a=10, b=0",
"instruction": {
"repr": "DIV(a=R.a, b=R.b, out=R.a)",
"str": "DIV R.a, R.b, R.a",
"line": 3
},
"traceback": "..."
}
}
Assembly Language Reference
Syntax Rules
Instructions and Operands:
INSTRUCTION operand1, operand2, operand3
- Instructions are case-insensitive (
ADD,add, andAddare all valid) - Register names and label names are case-sensitive
- Operands are separated by commas (spaces around commas are optional)
- Comments start with
;and continue to end of line (a;inside a character literal, as inCOUT ';', is not a comment) - Blank lines and indentation are ignored
Operand Types
The assembly text syntax differs from Python syntax:
| Operand Type | Assembly Syntax | Python Syntax | Example |
|---|---|---|---|
| Numeric Literal | 42, -5 |
L[42], L[-5] |
CP 42, R.a |
| Character Literal | 'A' |
LC["A"] |
COUT 'H' |
| Register | R.a |
R.a |
ADD R.a, R.b |
| Memory (direct) | [100] or M[100] |
M[100] |
CP 42, [100] |
| Memory (indirect) | [R.a] or M[R.a] |
M[R.a] |
CP [R.a], R.b |
| Memory (offset) | [R.a + 5], [100 - R.i], [R.c - 'a'] |
M[R.a + 5], M[100 - R.i], M[R.c - LC["a"]] |
CP [R.a + 1], R.b |
| Label | loop |
Label("loop") |
JMP loop |
Key Differences:
- Literals: In text syntax, bare numbers are literals (no
L[...]wrapper needed) - Characters: Use single quotes
'A'instead ofLC["A"] - Memory: The
Mprefix is optional ([100]andM[100]are equivalent). An address can also be a register plus or minus a register, integer or character, in either order:[R.a + 5],[100 - R.i],[R.c - 'a'] - Labels: Bare identifiers are labels (no
Label(...)constructor needed) - Registers: Must use
R.prefix in both syntaxes
Label Definition
Labels mark positions in code:
; Label on its own line
loop:
ADD R.a, 1
JLT loop, R.a, 10
; Label on same line as instruction
start: CP 0, R.a
Label names must contain only alphanumeric characters and underscores.
Python API
While assembly syntax is the primary way to use dt31, you can also build and run programs programmatically using the Python API.
Creating and Running Programs
from dt31 import DT31, I, L, M, R
# Create CPU instance
cpu = DT31()
# Write program as list of instructions
program = [
I.CP(42, R.a),
I.CP(100, M[R.a]),
I.NOUT(R.a, L[1]),
I.NOUT(M[R.a], L[1])
]
# Run the program
cpu.run(program)
# 42
# 100
run() also accepts max_steps to cap the number of instructions this call may
execute (e.g. cpu.run(program, max_steps=10_000)), raising StepLimitExceeded
if the program hasn't finished by then. Like debug, it's scoped to this call.
Parsing Assembly from Python
from dt31 import DT31
from dt31.parser import parse_program
cpu = DT31()
assembly = """
CP 5, R.a
loop:
NOUT R.a, 1
SUB R.a, 1
JGT loop, R.a, 0
"""
program = parse_program(assembly)
cpu.run(program)
# 5 4 3 2 1
Converting Programs to Text
Convert Python programs to assembly text format with configurable formatting:
from dt31 import I, L, Label, LC, R
from dt31.assembler import program_to_text
# Create a program in Python
program = [
I.CP(5, R.a),
loop := Label("loop"),
I.COUT(LC["*"]),
I.SUB(R.a, L[1]),
I.JGT(loop, R.a, L[0]),
]
# Convert to assembly text (default formatting)
text = program_to_text(program)
print(text)
# CP 5, R.a
#
# loop:
# COUT '*', 0
# SUB R.a, 1, R.a
# JGT loop, R.a, 0
Formatting Options
The program_to_text function supports various formatting options:
# Custom indentation (default: 4 spaces)
text = program_to_text(program, indent_size=2)
# Inline labels (default: False, labels on separate lines)
text = program_to_text(program, label_inline=True)
# loop: COUT '*', 0
# Control blank lines (default: "preserve")
text = program_to_text(program, blank_lines="auto") # Add blank lines before labels
text = program_to_text(program, blank_lines="none") # No automatic blank lines
text = program_to_text(program, blank_lines="preserve") # Preserve source formatting
# Auto-align inline comments (default: False, comment_column: None)
commented_program = [
I.CP(5, R.a).with_comment("Initialize"),
I.ADD(R.a, L[1]).with_comment("Increment"),
]
text = program_to_text(commented_program, align_comments=True)
# CP 5, R.a ; Initialize
# ADD R.a, 1, R.a ; Increment
# Align comments at specific column
text = program_to_text(commented_program, align_comments=True, comment_column=30)
# CP 5, R.a ; Initialize
# ADD R.a, 1, R.a ; Increment
# Auto-align with custom margin (default: 2)
text = program_to_text(commented_program, align_comments=True, comment_margin=4)
# CP 5, R.a ; Initialize
# ADD R.a, 1, R.a ; Increment
# Strip all comments
text = program_to_text(commented_program, strip_comments=True)
# CP 5, R.a
# ADD R.a, 1, R.a
# Show default arguments (default is to hide them)
text = program_to_text(program, hide_default_args=False)
# CP 5, R.a
#
# loop:
# COUT '*', 0
# SUB R.a, 1, R.a
# JGT loop, R.a, 0
Labels and Function Calls
Labels offer a great use-case for the Python walrus operator.
from dt31.operands import I, LC, Label
program = [
I.CALL(print_hi := Label("print_hi")),
I.JMP(end := Label("end")),
print_hi,
I.COUT(LC['H']),
I.COUT(LC['i']),
I.RET(),
end,
]
cpu.run(program)
# Hi
Debugging with Step Execution
cpu = DT31()
cpu.load(program)
# Execute one instruction at a time
cpu.step(debug=True) # Prints instruction and state
print(cpu.state) # Inspect CPU state
# Execute a full program one instruction at a time
cpu.run(program, debug=True)
Accessing CPU State
# Get register values
value = cpu.get_register('a')
# Set register values
cpu.set_register('b', 42)
# Access memory
cpu.set_memory(100, 255)
byte = cpu.get_memory(100)
# Get full state snapshot
state = cpu.state # Returns dict with registers, memory, stack, ip
Documentation
Full API documentation is available at the docs site. Generate the latest docs with:
uv run invoke docs
uv run invoke serve-docs # Serve locally at http://localhost:8080
Key documentation pages:
- DT31 CPU Class - CPU methods and state management
- Instructions - Complete instruction reference
- Operands - Operand types and usage
- Parser - Assembly text parsing
- Assembler - Label resolution and assembly
- CLI - Command-line interface
Development
# Install dependencies
uv sync --dev
# Set up pre-commit hooks
uv run prek install --install-hooks
uv run prek install --hook-type pre-push
# Run tests
uv run invoke test
DT31 is open-source and contributors are welcome on Github.
License
MIT License