dt31

License: MIT pdoc Ruff Coverage Badge

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
; countdown.dt

; copy 5 to register a
CP 5, R.a
loop:
    ; print register a
    NOUT R.a, 1
    ; a = a - 1
    SUB R.a, 1
    ; jump to loop if a > 0
    JGT loop, R.a, 0

; run with: `dt31 run countdown.dt`
; output: 5 4 3 2 1
from dt31 import DT31, I, L, Label, R

# create vm with default settings
cpu = DT31()

program = [
    I.CP(5, R.a),
    loop := Label("loop"),
    I.NOUT(R.a, L[1]),
    I.SUB(R.a, L[1]),
    I.JGT(loop, R.a, L[0]),
]

cpu.run(program)

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 .dt assembly files with text-based syntax
  • Command-Line Interface: Execute .dt files directly with the dt31 command
  • 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], or LC["a"] as a shortcut for L[ord("a")]
  • Registers: CPU registers R.a, R.b, R.c
  • Memory: Memory addresses M[100], indirect addressing M[R.a], and offsets M[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, and c), plus ip
  • 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. ip is user-writable at your own risk. All instructions other than jumps will advance ip by 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

  • --debug or -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 PATH or -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)
  • --verbose or -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 PATH or -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 PATH or --output PATH: Write the generated source to a file instead of printing to stdout
  • -r a,b,c,d or --registers a,b,c,d: Explicit register list for the generated DT31(...) call (auto-detected by default, same validation as run --registers)
  • -m 512 or --memory 512: memory_size for the generated DT31(...) call (default: 256)
  • -s 512 or --stack-size 512: stack_size for the generated DT31(...) call (default: 256)
  • -d or --debug: Generate a cpu.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, and Add are 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 in COUT ';', 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:

  1. Literals: In text syntax, bare numbers are literals (no L[...] wrapper needed)
  2. Characters: Use single quotes 'A' instead of LC["A"]
  3. Memory: The M prefix is optional ([100] and M[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']
  4. Labels: Bare identifiers are labels (no Label(...) constructor needed)
  5. 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:

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

 1"""
 2.. include:: ../../README.md
 3   :start-line: 3
 4
 5"""
 6
 7import dt31.instructions as I  # noqa: F401
 8from dt31.cpu import DT31  # noqa: F401
 9from dt31.operands import LC, L, Label, M, R  # noqa: F401
10from dt31.parser import BlankLine, Comment  # noqa: F401