Skip to content

NanoVM.kt

src/jvmMain/kotlin/afield/lang/nano/NanoVM.kt · 544 lines

package afield.lang.nano

import kotlin.random.Random

Nano is a minimal 8-bit virtual machine designed for emergence and artificial-life simulations: four registers, 32 bytes of RAM, and all but five of the 256 possible opcode bytes decoding to an instruction.

The whole machine is:

  • r0–r3, 8-bit unsigned, so 0–255
  • 32 bytes of RAM, addressed modulo 32, so no address is out of range
  • flags Z (zero), C (carry), S (sign) and O (overflow)
  • a call stack eight deep, for CALL and RET
  • unsigned-byte arithmetic throughout, wrapping mod 256

Instructions are fetched from the program byte array passed to NanoVM.run, and ram is separate data. NanoVM.load also mirrors the program's first 32 bytes into RAM, which is what lets a program read its own text. NanoInterface's launch mode calls NanoVM.resetRegisters instead, leaving RAM alone so it carries state from one per-tick run to the next.

Opcode map

Opcode is the constant table, and Opcode.twoReg and Opcode.oneReg are the encoders.

form encoding instructions opcodes
no operand one byte NOP, HLT, RET 0x00–0x02
one register base + reg, 4 per op INC, DEC, NOT, NEG, CLR, OUT, RNG 0x04–0x1F
two registers base + (dst shl 2) + src, 16 per op MOV, ADD, SUB, MUL, AND, OR, XOR, CMP, LDR, STR 0x20–0xBF
register and immediate or address two bytes, 4 per op LDI, LDA, STA, ADDI, LDRR, STRR, SHL, SHR 0xC0–0xDF
relative branch two bytes, signed offset JMP, JZ, JNZ, JGT, JLT, JGE, JLE, JC, JNC, CALL 0xE0–0xE9
absolute branch two bytes, absolute address JMPA, CALLA 0xEA–0xEB
two registers base + (dst shl 2) + src DIV 0xEC–0xFB

Five bytes decode to nothing — 0x03, and 0xFC–0xFF past DIV's last (dst, src) pair. Each reports an unknown opcode and halts. That path matters here more than it would in a VM with a fixed program: artificial-life work mutates programs, and NanoAssembler's raw-byte directive puts an arbitrary byte in front of the decoder too, so every one of the 256 has to either execute or report. The GPU port in nano_compute.glsl decodes the same map.

A program

; comment
start:
    ldi r0, 10     ; r0 = 10
    ldi r1, 0      ; r1 = 0
loop:
    add r1, r0     ; r1 += r0
    dec r0         ; r0--  (sets Z when r0 reaches 0)
    jnz loop       ; jump back if not zero
    out r1         ; print r1
    hlt

NanoAssembler.kt has the rest of the syntax, including the directive that places raw bytes in the program stream and the fact that a label resolves to an offset for a relative branch and to an address for JMPA and CALLA.

class NanoVM

The machine itself: the register file, RAM, the flags and the fetch-decode-execute step.

maxSteps bounds one run, so a program that never reaches HLT still returns. rng is what RNG draws from, injectable so that a run can be made repeatable.

class NanoVM(
    val maxSteps: Int = 100_000,
    var rng: () -> Int = { Random.nextInt(256) }
) {

companion object

const val RAM_SIZE = 32

const val NUM_REGS = 4

const val STACK_DEPTH = 8

Machine state

val reg = IntArray(NUM_REGS)

val ram = IntArray(RAM_SIZE)

var pc = 0

var flagZ = false // Zero

var flagC = false // Carry

var flagS = false // Sign (negative)

var flagO = false // Overflow

var halted = false

    private val callStack = IntArray(STACK_DEPTH)
    private var callSP = 0

var onOutput: (Int) -> Unit = {}

var onError: (String, Int) -> Unit = { _, _ -> }

data class StepResult

    data class StepResult(
        val executedPc: Int,
        val currentPc: Int,
        val halted: Boolean,
        val executed: Boolean
    )

Reset

fun reset()

    fun reset() {
        reg.fill(0)
        ram.fill(0)
        pc = 0
        flagZ = false; flagC = false; flagS = false; flagO = false
        halted = false
        callSP = 0
        callStack.fill(0)
    }

fun resetRegisters()

Reset only registers and execution state, preserving RAM contents. Used in NanoInterface launch mode so RAM acts as persistent working memory that survives across per-tick executions (important for ALife state machines).

    fun resetRegisters() {
        reg.fill(0)
        pc = 0
        flagZ = false; flagC = false; flagS = false; flagO = false
        halted = false
        callSP = 0
        callStack.fill(0)
    }

fun load(program: ByteArray)

Load a program into a fresh machine (resets full state including RAM).

    fun load(program: ByteArray) {
        reset()
        for (i in program.indices) {
            if (i < RAM_SIZE) ram[i] = program[i].toInt() and 0xFF
        }
    }

fun run(program: ByteArray): Int

Run up to maxSteps instructions from current pc. Returns the number of steps executed.

    fun run(program: ByteArray): Int {
        if (program.isEmpty()) return 0
        var steps = 0
        while (!halted && steps < maxSteps) {
            val stepResult = stepOnce(program)
            if (!stepResult.executed) break
            steps++
        }
        return steps
    }

fun stepOnce(program: ByteArray): StepResult

Execute exactly one instruction at the current pc.

    fun stepOnce(program: ByteArray): StepResult {
        if (program.isEmpty() || halted) {
            return StepResult(executedPc = pc, currentPc = pc, halted = halted, executed = false)
        }
        if (pc < 0 || pc >= program.size) {
            halted = true
            return StepResult(executedPc = pc, currentPc = pc, halted = true, executed = false)
        }
        val executedPc = pc
        val opcode = program[pc].toInt() and 0xFF
        val nextPc = step(opcode, program)
        if (nextPc >= 0) {
            pc = nextPc
        } else {
            halted = true
        }
        return StepResult(
            executedPc = executedPc,
            currentPc = pc,
            halted = halted,
            executed = true
        )
    }

Flag helpers

    private fun setArithFlags(result: Int) {
        flagZ = (result and 0xFF) == 0
        flagS = (result and 0x80) != 0
    }

    private fun setAddFlags(a: Int, b: Int, result: Int) {
        flagC = result > 0xFF
        flagO = ((a xor result) and (b xor result) and 0x80) != 0
        setArithFlags(result)
    }

    private fun setSubFlags(a: Int, b: Int, result: Int) {
        flagC = a < b
        flagO = ((a xor b) and (a xor result) and 0x80) != 0
        setArithFlags(result)
    }

Single step

    private fun readByte(prog: ByteArray, addr: Int): Int {
        if (addr < 0 || addr >= prog.size) {
            onError("PC out of bounds: $addr", pc)
            halted = true
            return 0
        }
        return prog[addr].toInt() and 0xFF
    }

    private fun ramRead(addr: Int): Int = ram[addr and (RAM_SIZE - 1)]
    private fun ramWrite(addr: Int, v: Int) { ram[addr and (RAM_SIZE - 1)] = v and 0xFF }

Execute the instruction at current pc with opcode. @return the new PC value, or -1 on fatal error / halt.

source — 254 lines, from line 216
    private fun step(opcode: Int, prog: ByteArray): Int {
        return when {
            // ── No-operand ───────────────────────────────────────────────────
            opcode == 0x00 -> pc + 1 // NOP
            opcode == 0x01 -> { halted = true; -1 } // HLT
            opcode == 0x02 -> { // RET
                if (callSP <= 0) { onError("RET with empty call stack", pc); halted = true; -1 }
                else { callSP--; callStack[callSP] }
            }

            // ── Single-register instructions (4 per op, base + regIndex) ────
            opcode in 0x04..0x07 -> { // INC rX
                val r = opcode - 0x04
                val res = reg[r] + 1
                flagC = res > 0xFF
                reg[r] = res and 0xFF
                setArithFlags(reg[r])
                pc + 1
            }
            opcode in 0x08..0x0B -> { // DEC rX
                val r = opcode - 0x08
                val res = reg[r] - 1
                flagC = res < 0
                reg[r] = res and 0xFF
                setArithFlags(reg[r])
                pc + 1
            }
            opcode in 0x0C..0x0F -> { // NOT rX
                val r = opcode - 0x0C
                reg[r] = reg[r].inv() and 0xFF
                setArithFlags(reg[r])
                pc + 1
            }
            opcode in 0x10..0x13 -> { // NEG rX
                val r = opcode - 0x10
                val res = (0x100 - reg[r]) and 0xFF
                flagC = reg[r] != 0
                reg[r] = res
                setArithFlags(reg[r])
                pc + 1
            }
            opcode in 0x14..0x17 -> { // CLR rX
                val r = opcode - 0x14
                reg[r] = 0; flagZ = true; flagS = false
                pc + 1
            }
            opcode in 0x18..0x1B -> { // OUT rX
                val r = opcode - 0x18
                onOutput(reg[r])
                pc + 1
            }
            opcode in 0x1C..0x1F -> { // RNG rX
                val r = opcode - 0x1C
                reg[r] = rng() and 0xFF
                setArithFlags(reg[r])
                pc + 1
            }

            // ── Two-register instructions (OP_BASE + (dst<<2) + src) ─────────
            opcode in 0x20..0x2F -> { // MOV rD, rS
                val d = (opcode - 0x20) shr 2; val s = (opcode - 0x20) and 3
                reg[d] = reg[s]
                pc + 1
            }
            opcode in 0x30..0x3F -> { // ADD rD, rS
                val d = (opcode - 0x30) shr 2; val s = (opcode - 0x30) and 3
                val res = reg[d] + reg[s]
                setAddFlags(reg[d], reg[s], res)
                reg[d] = res and 0xFF
                pc + 1
            }
            opcode in 0x40..0x4F -> { // SUB rD, rS
                val d = (opcode - 0x40) shr 2; val s = (opcode - 0x40) and 3
                val res = reg[d] - reg[s]
                setSubFlags(reg[d], reg[s], res)
                reg[d] = res and 0xFF
                pc + 1
            }
            opcode in 0x50..0x5F -> { // MUL rD, rS
                val d = (opcode - 0x50) shr 2; val s = (opcode - 0x50) and 3
                val res = reg[d] * reg[s]
                flagC = res > 0xFF
                reg[d] = res and 0xFF
                setArithFlags(reg[d])
                pc + 1
            }
            opcode in 0x60..0x6F -> { // AND rD, rS
                val d = (opcode - 0x60) shr 2; val s = (opcode - 0x60) and 3
                reg[d] = reg[d] and reg[s]
                setArithFlags(reg[d])
                pc + 1
            }
            opcode in 0x70..0x7F -> { // OR rD, rS
                val d = (opcode - 0x70) shr 2; val s = (opcode - 0x70) and 3
                reg[d] = reg[d] or reg[s]
                setArithFlags(reg[d])
                pc + 1
            }
            opcode in 0x80..0x8F -> { // XOR rD, rS
                val d = (opcode - 0x80) shr 2; val s = (opcode - 0x80) and 3
                reg[d] = reg[d] xor reg[s]
                setArithFlags(reg[d])
                pc + 1
            }
            opcode in 0x90..0x9F -> { // CMP rD, rS  (flags only, no reg change)
                val d = (opcode - 0x90) shr 2; val s = (opcode - 0x90) and 3
                val res = reg[d] - reg[s]
                setSubFlags(reg[d], reg[s], res)
                pc + 1
            }
            opcode in 0xA0..0xAF -> { // LDR rD, (rS)  rD = RAM[rS]
                val d = (opcode - 0xA0) shr 2; val s = (opcode - 0xA0) and 3
                reg[d] = ramRead(reg[s])
                setArithFlags(reg[d])
                pc + 1
            }
            opcode in 0xB0..0xBF -> { // STR (rD), rS  RAM[rD] = rS
                val d = (opcode - 0xB0) shr 2; val s = (opcode - 0xB0) and 3
                ramWrite(reg[d], reg[s])
                pc + 1
            }

            // ── Reg + immediate/address (2-byte instructions) ─────────────────
            opcode in 0xC0..0xC3 -> { // LDI rX, imm
                val r = opcode - 0xC0
                val imm = readByte(prog, pc + 1)
                reg[r] = imm
                setArithFlags(reg[r])
                pc + 2
            }
            opcode in 0xC4..0xC7 -> { // LDA rX, addr
                val r = opcode - 0xC4
                val addr = readByte(prog, pc + 1)
                reg[r] = ramRead(addr)
                setArithFlags(reg[r])
                pc + 2
            }
            opcode in 0xC8..0xCB -> { // STA rX, addr
                val r = opcode - 0xC8
                val addr = readByte(prog, pc + 1)
                ramWrite(addr, reg[r])
                pc + 2
            }
            opcode in 0xCC..0xCF -> { // ADDI rX, imm (signed)
                val r = opcode - 0xCC
                val imm = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                val res = reg[r] + imm
                setAddFlags(reg[r], imm and 0xFF, res)
                reg[r] = res and 0xFF
                pc + 2
            }
            opcode in 0xD0..0xD3 -> { // LDRR rX, rel  rX = RAM[PC + rel]
                val r = opcode - 0xD0
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                reg[r] = ramRead(pc + rel)
                setArithFlags(reg[r])
                pc + 2
            }
            opcode in 0xD4..0xD7 -> { // STRR rX, rel  RAM[PC + rel] = rX
                val r = opcode - 0xD4
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                ramWrite(pc + rel, reg[r])
                pc + 2
            }
            opcode in 0xD8..0xDB -> { // SHL rX, imm
                val r = opcode - 0xD8
                val imm = readByte(prog, pc + 1) and 7
                val res = reg[r] shl imm
                flagC = (res and 0xFF00) != 0
                reg[r] = res and 0xFF
                setArithFlags(reg[r])
                pc + 2
            }
            opcode in 0xDC..0xDF -> { // SHR rX, imm
                val r = opcode - 0xDC
                val imm = readByte(prog, pc + 1) and 7
                reg[r] = (reg[r] ushr imm) and 0xFF
                setArithFlags(reg[r])
                pc + 2
            }

            // ── Branch instructions (2-byte: opcode + signed offset) ──────────
            opcode == 0xE0 -> { // JMP rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                pc + 2 + rel
            }
            opcode == 0xE1 -> { // JZ rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (flagZ) pc + 2 + rel else pc + 2
            }
            opcode == 0xE2 -> { // JNZ rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (!flagZ) pc + 2 + rel else pc + 2
            }
            opcode == 0xE3 -> { // JGT rel  (> 0: not zero and not negative)
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (!flagZ && !flagS) pc + 2 + rel else pc + 2
            }
            opcode == 0xE4 -> { // JLT rel  (< 0: negative)
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (flagS) pc + 2 + rel else pc + 2
            }
            opcode == 0xE5 -> { // JGE rel  (>= 0: not negative)
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (!flagS) pc + 2 + rel else pc + 2
            }
            opcode == 0xE6 -> { // JLE rel  (<= 0: zero or negative)
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (flagZ || flagS) pc + 2 + rel else pc + 2
            }
            opcode == 0xE7 -> { // JC rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (flagC) pc + 2 + rel else pc + 2
            }
            opcode == 0xE8 -> { // JNC rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (!flagC) pc + 2 + rel else pc + 2
            }
            opcode == 0xE9 -> { // CALL rel
                val rel = readByte(prog, pc + 1).let { if (it > 127) it - 256 else it }
                if (callSP >= STACK_DEPTH) {
                    onError("Call stack overflow", pc); halted = true; -1
                } else {
                    callStack[callSP++] = pc + 2
                    pc + 2 + rel
                }
            }
            opcode == 0xEA -> { // JMPA addr (absolute)
                val addr = readByte(prog, pc + 1)
                addr
            }
            opcode == 0xEB -> { // CALLA addr (call absolute)
                val addr = readByte(prog, pc + 1)
                if (callSP >= STACK_DEPTH) {
                    onError("Call stack overflow", pc); halted = true; -1
                } else {
                    callStack[callSP++] = pc + 2
                    addr
                }
            }

            // ── DIV (two-reg, 0xEC–0xFB) ──────────────────────────────────────
            // Sixteen encodings, not twenty: 0xFC–0xFF are past the last (dst,src) pair and fall
            // through to the unknown-opcode arm below. A mutated program will contain them.
            opcode in 0xEC..0xFB -> { // DIV rD, rS  rD = rD / rS (rS==0 → rD=0xFF)
                val d = (opcode - 0xEC) shr 2; val s = (opcode - 0xEC) and 3
                reg[d] = if (reg[s] == 0) 0xFF else (reg[d] / reg[s]) and 0xFF
                setArithFlags(reg[d])
                pc + 1
            }

            else -> { onError("Unknown opcode 0x${opcode.toString(16)}", pc); halted = true; -1 }
        }
    }

State inspection

fun stateString(): String

    fun stateString(): String {
        val flags = buildString {
            if (flagZ) append('Z') else append('.')
            if (flagC) append('C') else append('.')
            if (flagS) append('S') else append('.')
            if (flagO) append('O') else append('.')
        }
        val regs = reg.mapIndexed { i, v -> "r$i=${v.toString(16).padStart(2, '0')}" }.joinToString(" ")
        val ramStr = ram.mapIndexed { i, v -> v.toString(16).padStart(2, '0') }.joinToString(" ")
        return "PC=${pc.toString(16).padStart(2, '0')} flags=$flags  $regs\nRAM: $ramStr"
    }
}

object Opcode

Opcode constants for the Nano VM, with the two encoders that build a byte out of a base and a register index. These are the ranges NanoVM.stepOnce decodes.

const val NOP = 0x00

const val HLT = 0x01

const val RET = 0x02

const val INC_BASE = 0x04

const val DEC_BASE = 0x08

const val NOT_BASE = 0x0C

const val NEG_BASE = 0x10

const val CLR_BASE = 0x14

const val OUT_BASE = 0x18

const val RNG_BASE = 0x1C

const val MOV_BASE = 0x20

const val ADD_BASE = 0x30

const val SUB_BASE = 0x40

const val MUL_BASE = 0x50

const val AND_BASE = 0x60

const val OR_BASE = 0x70

const val XOR_BASE = 0x80

const val CMP_BASE = 0x90

const val LDR_BASE = 0xA0 // rD = RAM[rS]

const val STR_BASE = 0xB0 // RAM[rD] = rS

const val LDI_BASE = 0xC0

const val LDA_BASE = 0xC4

const val STA_BASE = 0xC8

const val ADDI_BASE = 0xCC

const val LDRR_BASE = 0xD0

const val STRR_BASE = 0xD4

const val SHL_BASE = 0xD8

const val SHR_BASE = 0xDC

const val JMP = 0xE0

const val JZ = 0xE1

const val JNZ = 0xE2

const val JGT = 0xE3

const val JLT = 0xE4

const val JGE = 0xE5

const val JLE = 0xE6

const val JC = 0xE7

const val JNC = 0xE8

const val CALL = 0xE9

const val JMPA = 0xEA

const val CALLA= 0xEB

const val DIV_BASE = 0xEC

fun twoReg(base: Int, dst: Int, src: Int) = base + (dst shl 2) + src

Encode a two-register opcode: op_base + (dst << 2) + src

fun oneReg(base: Int, reg: Int) = base + reg

Encode a single-register opcode: op_base + regIndex