diff --git a/README.md b/README.md index 6ab89f0..684707a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Designed for rapid experimentation, Tiny8 embraces minimalism with zero unnecess ![bubblesort](/docs/_static/examples/bubblesort.gif) +⭐️ NEW FEATURE! + + + Why Tiny8? - Lightweight: tiny install footprint and no heavy runtime dependencies. @@ -69,6 +73,58 @@ This flow sets up a development virtual environment, installs development requir uv add tiny8 ``` +## CLI Visualizer + +Tiny8 includes a lightweight terminal-based visualizer that lets you step through a program's execution trace in your terminal. It shows the status register (SREG), the 32 general-purpose registers, and a compact view of a configurable memory range for each step. + +Key points: + +- The CLI visualizer expects the CPU to have a populated `step_trace` (run the CPU first with `cpu.run(...)`). +- Controls are keyboard-driven (play/pause, step forward/back, jump, quit) and work in most POSIX terminals that support curses. +- For higher-fidelity animations (GIFs) and interactive matplotlib views, use the `Visualizer` class which requires `matplotlib`. + +Interactive controls: +``` +Space - toggle play/pause +l or > - next step +h or < - previous step +w - jump forward 10 steps +b - jump back 10 steps +0 - jump to first step +$ - jump to last step +q or ESC - quit +``` + +Programmatic usage + +You can invoke the terminal visualizer directly from Python after running the CPU: + +```python +from tiny8 import CPU, assemble_file +from tiny8.cli_visualizer import run_cli + +prog, labels = assemble_file("examples/bubblesort.asm") +cpu = CPU() +cpu.load_program(prog, labels) +cpu.run(max_cycles=15000) + +# Run the curses-based CLI visualizer +run_cli(cpu, mem_addr_start=100, mem_addr_end=131) +``` + +Tiny8 provides a `tiny8` console script (see `pyproject.toml`). You can run the CLI or the animation mode directly: + +```bash +# Run the curses-based CLI visualizer for an assembly file +tiny8 examples/bubblesort.asm # --mode cli --mem-start 100 --mem-end 131 + +# Produce an animated GIF using matplotlib (requires matplotlib) +tiny8 examples/bubblesort.asm --mode ani -o bubblesort.gif --mem-start 100 --mem-end 131 --plot-every 100 --fps 60 +``` + +> [!IMPORTANT] +> Tiny8 uses Python's built-in curses module (Unix-like systems). On Windows, use an appropriate terminal that supports curses or run via WSL. + ## Examples ### Bubble sort @@ -99,7 +155,7 @@ bubblesort.asm: init_loop: ; PRNG step: r2 := lowbyte(r2 * 75), then tweak - mul r18, r25 ; r18 = low byte of (r18 * 75) + mul r18, r25 ; r18 = low byte of (r18 * 75) inc r18 ; small increment to avoid repeating patterns ; store generated byte into memory at base + index st r16, r18 ; RAM[base] = r18 diff --git a/pyproject.toml b/pyproject.toml index 758d2ed..0eed59a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,6 @@ dev = [ "shibuya>=2025.10.20", "sphinx>=8.2.3", ] + +[project.scripts] +tiny8 = "tiny8.cli:main" diff --git a/src/tiny8/__init__.py b/src/tiny8/__init__.py index 5a4c34d..2b422cf 100644 --- a/src/tiny8/__init__.py +++ b/src/tiny8/__init__.py @@ -5,9 +5,10 @@ """ from .assembler import assemble, assemble_file +from .cli import run_cli from .cpu import CPU from .visualizer import Visualizer -__all__ = ["CPU", "assemble", "assemble_file", "Visualizer"] +__all__ = ["CPU", "assemble", "assemble_file", "Visualizer", "run_cli"] __version__ = "0.1.1" diff --git a/src/tiny8/cli.py b/src/tiny8/cli.py new file mode 100644 index 0000000..f42f5e4 --- /dev/null +++ b/src/tiny8/cli.py @@ -0,0 +1,267 @@ +"""Terminal based visualizer for tiny8 CPU step traces. + +Provides a simple UI to inspect SREG, registers and a memory range +for a selected step from ``cpu.step_trace``. Interactive keyboard controls +allow play/pause and single-stepping. + +Controls: + .. code-block:: text + + Space - toggle play/pause + l or > - next step + k or < - previous step + w - jump forward 10 steps + b - jump back 10 steps + 0 - jump to first step + $ - jump to last step + q or ESC - quit + +Usage: + .. code-block:: python + + from tiny8.cli_visualizer import run_cli + run_cli(cpu, mem_addr_start=0, mem_addr_end=127) +""" + +from __future__ import annotations + +import argparse +import curses +import math +import time + + +def _format_byte(b: int) -> str: + return f"{b:02X}" + + +def run_cli(cpu, mem_addr_start: int = 0, mem_addr_end: int = 31, delay: float = 0.15): + """Run the curses-based CLI visualizer for the given CPU. + + Args: + cpu: CPU instance with a populated `step_trace` list. + mem_addr_start: start address for memory display. + mem_addr_end: end address for memory display. + delay: seconds between automatic steps when playing. + """ + + traces = getattr(cpu, "step_trace", None) + if not traces: + raise RuntimeError( + "cpu.step_trace is empty — run the CPU to populate step_trace first" + ) + + n_steps = len(traces) + + def draw_step(stdscr, idx: int): + stdscr.erase() + entry = traces[idx] + + pc = entry.get("pc", getattr(cpu, "pc", 0)) + sp = entry.get("sp", getattr(cpu, "sp", 0)) + instr = entry.get("instr", "") + + # Header + header = f"Step {idx:{len(str(n_steps - 1))}}/{n_steps - 1} PC:0x{pc:04X} SP:0x{sp:04X}" + if instr: + header += f" RUN: {instr}" + stdscr.addstr(0, 0, header) + + # SREG + s = entry.get("sreg", 0) + # flag_names = "I T H S V N Z C".split() + bits = [(s >> b) & 1 for b in reversed(range(8))] + sstr = " ".join(str(bit) for bit in bits) + stdscr.addstr(2, 0, f"SREG: {sstr} 0x{s:02X}") + + # Registers (compact grid) + regs = entry.get("regs", []) + reg_count = 32 + reg_cols = min(8, int(math.ceil(math.sqrt(reg_count)))) + reg_rows = int(math.ceil(reg_count / reg_cols)) + + stdscr.addstr(4, 0, "Registers:") + for r in range(reg_rows): + row_vals = [] + row_addr = r * reg_cols + for c in range(reg_cols): + i = row_addr + c + if i >= reg_count: + break + val = regs[i] if i < len(regs) else 0 + row_vals.append(_format_byte(val)) + # show register row reference (like memory rows) using hex index + stdscr.addstr(5 + r, 2, f"0x{row_addr:02X}: " + " ".join(row_vals)) + + # Memory (compact grid) + memsnap = entry.get("mem", {}) + mem_count = max(0, mem_addr_end - mem_addr_start + 1) + mem_cols = min(32, int(math.ceil(math.sqrt(mem_count)))) if mem_count > 0 else 1 + mem_rows = int(math.ceil(mem_count / mem_cols)) if mem_count > 0 else 1 + + mem_top = 6 + reg_rows + stdscr.addstr(mem_top, 0, f"Memory {hex(mem_addr_start)}..{hex(mem_addr_end)}:") + for r in range(mem_rows): + row_addr = mem_addr_start + r * mem_cols + row_vals = [] + for c in range(mem_cols): + a = row_addr + c + if a > mem_addr_end: + break + if a in memsnap: + val = memsnap[a] + else: + try: + val = cpu.read_ram(a) + except Exception: + val = 0 + row_vals.append(_format_byte(val)) + stdscr.addstr( + mem_top + 1 + r, 2, f"0x{row_addr:04X}: " + " ".join(row_vals) + ) + + # Footer + footer_y = mem_top + 2 + mem_rows + stdscr.addstr( + footer_y, + 0, + "Controls: space: play/pause l: next h: back w: +10 b: -10\n" + + " " * 10 + + "0: start $: end q: quit", + ) + stdscr.refresh() + + def _curses_main(stdscr): + curses.curs_set(0) + stdscr.nodelay(True) + idx = 0 + playing = False + + draw_step(stdscr, idx) + + while True: + ch = stdscr.getch() + if ch != -1: + # handle keys + if ch in (ord("q"), 27): + break + elif ch == ord(" "): + playing = not playing + elif ch in (ord("l"), curses.KEY_RIGHT): + idx = min(n_steps - 1, idx + 1) + draw_step(stdscr, idx) + elif ch in (ord("h"), curses.KEY_LEFT): + idx = max(0, idx - 1) + draw_step(stdscr, idx) + elif ch == ord("w"): + idx = min(n_steps - 1, idx + 10) + draw_step(stdscr, idx) + elif ch == ord("b"): + idx = max(0, idx - 10) + draw_step(stdscr, idx) + elif ch == ord("0"): + idx = 0 + draw_step(stdscr, idx) + elif ch == ord("$"): + idx = n_steps - 1 + draw_step(stdscr, idx) + + if playing: + time.sleep(delay) + if idx < n_steps - 1: + idx += 1 + draw_step(stdscr, idx) + else: + playing = False + else: + time.sleep(0.05) + + curses.wrapper(_curses_main) + + +def main(): + from tiny8 import CPU, assemble_file + + parser = argparse.ArgumentParser(description="Tiny8 CLI Visualizer") + parser.add_argument( + "asm_file", + type=str, + help="Path to the assembly file to simulate", + ) + parser.add_argument( + "--mode", + "-m", + type=str, + default="cli", + help="Mode to run the simulator in (default: cli)", + ) + parser.add_argument( + "--max_cycles", type=int, default=15000, help="Maximum CPU cycles to run" + ) + parser.add_argument( + "--mem-start", + "-ms", + type=int, + default=100, + help="Start address for memory display (default: 100)", + ) + parser.add_argument( + "--mem-end", + "-me", + type=int, + default=131, + help="End address for memory display (default: 131)", + ) + parser.add_argument( + "--delay", + "-d", + type=float, + default=0.15, + help="Delay in seconds between automatic steps when playing (default: 0.15)", + ) + parser.add_argument( + "--interval", + "-i", + type=int, + default=1, + help="Interval in milliseconds between frames for animation mode (default: 1)", + ) + parser.add_argument( + "--fps", + "-f", + type=int, + default=60, + help="Frames per second for animation mode (default: 60)", + ) + parser.add_argument( + "--plot-every", + "-pe", + type=int, + default=100, + help="Plot every N steps in animation mode (default: 100)", + ) + parser.add_argument( + "--output", + "-o", + default=None, + help="Output filename for animation mode (e.g., bubblesort.gif)", + ) + args = parser.parse_args() + prog, labels = assemble_file(args.asm_file) + cpu = CPU() + cpu.load_program(prog, labels) + cpu.run(max_cycles=args.max_cycles) + if args.mode == "cli": + run_cli(cpu, mem_addr_start=args.mem_start, mem_addr_end=args.mem_end) + elif args.mode == "ani": + from tiny8 import Visualizer + + viz = Visualizer(cpu) + viz.animate_combined( + interval=args.interval, + mem_addr_start=args.mem_start, + mem_addr_end=args.mem_end, + plot_every=args.plot_every, + output_file=args.output, + fps=args.fps, + )