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', 0
COUT 'i', 0
COUT '!', 0
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', 0
COUT 'i', 0
COUT '!', 0
COUT ' ', 0
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] - 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,JNE,JGT,JGE,JIF - Functions:
CALL,RCALL,RET - Stack:
PUSH,POP,SEMP - I/O:
NOUT,COUT,NIN,CIN - Data Movement:
CP
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) - Memory: Fixed-size byte array (default: 256 slots)
- Stack: For temporary values and function calls (default: 256 slots)
- Instruction Pointer: Tracks current instruction in register
ip
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
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 in bytes (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)
Check Command
--custom-instructions PATHor-i PATH: Load custom instruction definitions from a Python file
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, BRK)
- Steps (always shown): Number of instructions executed
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": "ZeroDivisionError",
"message": "integer division or modulo by zero",
"instruction": {
"repr": "DIV(a=R.a, b=R.b, out=R.a)",
"str": "DIV R.a, R.b, R.a"
},
"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 - 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', 0 |
| 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 |
| 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 (both[100]andM[100]work) - 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
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.
Planned work
- Data section
- Globbing support for CLI
- Interpreter resume from dump (maybe)
- Input error-handling (maybe)
- File I/O (maybe)
License
MIT License