PTC-701 R2.0 / EM
Information
The PTC-701 system ROM contains a stack-based bytecode interpreter that executes application programs stored in the banked app ROM. For general hardware information, see the General Information section.
The interpreter dispatch loop is copied from ROM (0x0400) into RAM at 0x7900 for execution. It uses a 16-bit evaluation stack (pointed to by RD, located at 0x797F) and variables stored in RAM at 0x7800+.
Opcodes are single bytes. Bit 7 selects between two dispatch methods:
- Low opcodes (0x00–0x7F): Handler address looked up from a 128-entry table at ROM 0x0100–0x01FF
- High opcodes (0x80–0xFF): Handler address partially encoded inline in the bytecode stream
Each handler's first byte is an entry-point selector loaded into R3, allowing multiple opcodes to share handler code with different setup paths. This compact design fits a complete virtual machine (VM) in ~1.5K of native code.
Architecture
| CPU | CDP1802 @ 2.4576 MHz |
| System ROM | 16K at 0x0000–0x3FFF |
| App ROM | 32K, bank-switched at 0x4000–0x77FF |
| RAM | 8K at 0x6000–0x7FFF |
| Interpreter loop | RAM at 0x7900 (copied from ROM 0x0400) |
| Dispatch table | ROM at 0x0100–0x01FF (128 entries × 2 bytes) |
| Data stack | RAM 0x797F (RD pointer, grows downward, 16-bit values) |
| Variables | RAM 0x7800+ (indexed by bytecode operand) |
| Return stack | RAM 0x79FF (R2 pointer, grows downward, saves/restores RC) |
| Bytecode PC | Register RC (walks through banked app ROM) |
Register Usage
| R0 | Inline dispatch via address 0x00FE |
| R1 | Interrupt register 0x083A/0x077D |
| R2 | Return stack (0x79FF, saves/restores RC for CALL/RETURN) |
| R3 | Handler entry point selector |
| R9 | Data pointer (X register target in some handlers) |
| RB | Handler address / temporary pointer |
| RC | Bytecode program counter |
| RD | Data stack pointer (0x797F, grows downward) |
| RE | Dispatch table index (RE.1 = 0x01) |
| RF | Interpreter PC |
Definitions
| kk | 8-bit constant / variable index / branch offset |
| bb | 8-bit signed branch offset |
| aaaa | 16-bit absolute RAM address |
| [aaaa] | Value at RAM address aaaa (16-bit) |
| TOS | Top of data stack (16-bit value at M(RD)/M(RD+1)) |
| NOS | Next on stack (16-bit value below TOS) |
| 3OS | Third on stack (16-bit value below NOS) |
| VE | Fixed 16-bit variable at M(785F/7860) (R2.0 system variable page 0x78) |
| 0..9, A..F | Hexadecimal digits |
Note: All binary operations (TOS = NOS op TOS) pop both operands and push the result unless otherwise noted.
Note: All byte loads are zero-extended to 16-bit before pushing to the data stack.
Bank Address Encoding
The PTC-701 uses a 3-byte encoded address format for all banked ROM/RAM access. Opcodes LDBNK (0x29) and STBNK (0x51) decode addresses using this scheme. Byte 0 bit 7 selects between two memory windows:
Window 1 (byte 0 bit 7 = 1) - bank register 0x3FA2, address range 0x4000–0x5FFF:
bank = ((byte0 << 3) | 0x80) + (byte1 >> 5) addr_high = (byte1 & 0x1F) + 0x40 addr_low = byte2
Writes bank value to M(784F) and hardware register M(3FA2).
Window 2 (byte 0 bit 7 = 0) - bank registers 0x3FC0–0x3FC3, address range 0x8000–0xFFFF:
raw = (byte0 << 1) | (byte1 >> 7) M(784E) = raw − 1 bank_reg[n] = M(784E) × 4 + n (n = 0..3, written to 0x3FC0–0x3FC3) read_addr = ((byte1 | 0x80) << 8) | byte2
Only writes bank registers when M(784E) changes from its previous value.
On exit, all bank operations restore the home bank from M(78C9).
Handler Dispatch
Dispatch Table at ROM 0x0100
The handler address table occupies ROM 0x0100–0x01FF and contains 128 entries (one per low opcode 0x00–0x7F). Each entry is 2 bytes stored as high byte first, low byte second at address 0x0100 + (opcode × 2).
The interpreter dispatch loop uses register RE (with RE.1 pre-set to 0x01) as a pointer into this table. After fetching an opcode and shifting left (SHL), the result is placed in RE.0, giving the table offset directly. Two reads via RE retrieve the 16-bit handler address into RB:
Both low opcodes (0x00–0x7F) and high opcodes (0x80–0xFF / SYS) use the same dispatch chain. The SYS path computes the handler address differently (from the opcode itself and the following byte) but then follows the identical pattern: LDA RB reads the first byte at the handler as an entry-point selector, loads it into R3.0, and executes via SEP R3. SYS handlers therefore use the same entry-point mechanism as low opcodes.
; From the dispatch table lookup at ROM 0x0504: ; (this routine is copied to RAM 0x7900 during startup) PLO RE ; RE.0 = (opcode << 1) - table offset LDA RE ; D = M(0x01xx) = handler address high byte, RE++ PHI RB ; RB.1 = handler high LDN RE ; D = M(0x01xx+1) = handler address low byte PLO RB ; RB.0 = handler low -> RB = full handler address
Valid entries point to handler code in the system ROM (typically pages 0x05–0x21). The first byte at each handler address is not executed as an instruction - it is consumed by the dispatch loop as the entry-point selector for R3.
Example: opcode 0x00 (ADD) → table offset = 0x00 × 2 = 0x00 → reads ROM at 0x0100/0x0101 = 0x04/0xB3 → handler at 0x04B3. First byte there is 0x06 (entry-point selector), so handler body starts at 0x04B4.
Dispatch Chain Notation
The Dispatch column shows the execution chain from the handler address to the actual code. The first byte at each handler address is an entry-point selector - it is loaded into R3.0 and causes a jump to address 0x02xx. Some entry points perform a second dispatch by reading the next handler byte, creating a two-level chain shown as XX·YY→ZZZZ.
Entry-Point Routines (page 0x02)
Reached via three mechanisms: 1st = first-byte dispatch (entry selector at handler address), 2nd = second-stage via entry A4 or 03 (LDA RB→PLO R3), R0 = SEP R0 trampoline (0x00FE→LDA RB→PLO R3→SEP R3).
| Byte | Address | Via | Function |
| 06 | 0206 | 1st | Pop TOS into R9, set X=RD, return to handler body |
| 15 | 0215 | R0 | Procedure CALL: save RC to return stack, set RC=RB |
| 20 | 0220 | 1st, R0 | Push constant from handler body (2 bytes: high, low) |
| 28 | 0228 | 1st | No operation: SEP RF, immediate return |
| 29 | 0229 | 1st, R0 | Push zero (0x0000) to data stack |
| 2B | 022B | 1st, R0 | Push one (0x0001) to data stack |
| 32 | 0232 | 1st, R0 | Indirect CALL: read address from stack, validate tag, chain to 0x15 |
| 39 | 0239 | R0 | Pop TOS, OR high+low to test zero, SEP RB |
| 82 | 0282 | 1st, R0 | Pop TOS into R9 and NOS into RA (two 16-bit values from data stack) |
| 88 | 0288 | 1st, R0 | Pop TOS into RA (SEX RD; LDXA→PHI RA; LDXA→PLO RA; NBR; SEP RB) |
| 9D | 029D | 1st, R0 | Copy R2→RA (return stack pointer), SEP RB |
| AD | 02AD | 1st | SEX RC; multi-level dispatch via page 0x03 (R2.0-specific) |
Syntax
Instructions
| Opcode | Mnemonic | Parameter | Forth | Handler | Dispatch | Description |
|---|---|---|---|---|---|---|
| 00 | ADD | TOS, NOS | + | 04B3 | 06→04B4 | TOS = NOS + TOS (16-bit add) |
| 01 | SHL | TOS | 2* | 04BA | 06→04BB | TOS = TOS × 2 (16-bit shift left) |
| 02 | SUB | TOS, NOS | - | 04BF | 06→04C0 | TOS = NOS − TOS (16-bit subtract) |
| 03 | ADD | TOS, 1 | 1+ | 04C6 | 06→04C7 | TOS = TOS + 1 |
| 04 | ADD | TOS, 2 | 2+ | 04CA | 06→04CB | TOS = TOS + 2 |
| 05 | ISLT | < | 0644 | 82→0645 | TOS = (TOS < NOS) ? 1 : 0 (unsigned). Swaps operands then uses ISGT logic (SD/SDB). R1.7 equiv = opcode 0x4D | |
| 06 | ISGT | > | 064C | 06→064D | TOS = (TOS > NOS) ? 1 : 0 (16-bit unsigned) | |
| 07 | ISEQ | = | 065C | 06→065D | TOS = (TOS == NOS) ? 1 : 0 (16-bit) | |
| 08bb | JZ | aaaa | ?BRANCH | 04E5 | 39→04E6 | Pop TOS; if TOS == 0, jump to aaaa (forward, aaaa = PC + 1 + bb) |
| 09bb | JZ | aaaa | ?BRANCH | 0516 | 39→0517 | Pop TOS; if TOS == 0, jump to aaaa (backward, aaaa = PC + 1 + bb - 256) |
| 0Abb | JP | aaaa | BRANCH | 04EA | 8F→04EB | Unconditional jump to aaaa (forward, aaaa = PC + 1 + bb) |
| 0Bbb | JP | aaaa | BRANCH | 051B | 8F→051C | Unconditional jump backward (aaaa = PC + 1 + bb − 256) |
| 0Cbb | LOOP | aaaa | (LOOP) | 04F4 | 2B→04F5 | Increment 16-bit counter at top of return stack (SP+0/1) by 1. Compare with limit at SP+2/3. If counter > limit (signed): branch backward to aaaa. Otherwise: exit loop. |
| 0Dbb | LOOP | TOS, aaaa | (+LOOP) | 04F6 | 9D→04F7 | Same as LOOP but increment by TOS (variable step) instead of 1. Branch backward to aaaa. |
| 0E | LOOP | FRAME | (DO) | 0525 | 82→0526 | Push loop frame to return stack: TOS→SP+0/1 (counter), NOS→SP+2/3 (limit). Pops both from data stack. |
| 0F | POPR | R> | 0635 | 8F→0636 | Pop 16-bit value from return stack (R2), push to data stack | |
| 10 | PUSHR | >R | 063C | 88→063D | Pop TOS from data stack, push to return stack (R2) | |
| 11 | RET | EXIT | 054A | 8F→054B | Return from subroutine: pop RC from return stack | |
| 12 | AND | TOS, NOS | AND | 0550 | 06→0551 | TOS = NOS AND TOS (16-bit bitwise AND) |
| 13 | ISZ | TOS | 0= | 0562 | 8E→0563 | TOS = (TOS == 0) ? 1 : 0. |
| 14 | ISNEG | TOS | 0< | 0570 | 8F→0571 | TOS = (TOS < 0) ? 1 : 0. Tests bit 7 of TOS high byte. |
| 15 | LD | TOS, [TOS] | @ | 059D | 88→059E | TOS = M(TOS):M(TOS+1). Load 16-bit value from M(TOS). |
| 16 | LD | [TOS], NOS | ! | 05B2 | 88→05B3 | M(TOS) = NOS (store 16-bit NOS to M(TOS), pops both) |
| 17 | LD | TOS.0, [TOS] | C@ | 05C6 | 88→05C7 | TOS.0 = M(TOS) (load single byte from M(TOS)) |
| 18 | XOR | TOS, NOS | XOR | 057C | 06→057D | TOS = TOS XOR NOS (16-bit bitwise XOR) |
| 19 | LD | [TOS], NOS.0 | C! | 05D9 | 88→05DA | M(TOS) = NOS.low. Store low byte of NOS to M(TOS), pops both. |
| 1A | MCPY | [NOS], [3OS] | CMOVE | 05DE | 7C→05DF | Memory copy: pops 3 values (TOS=count, NOS=dest, 3OS=source); copies full 16-bit count bytes from source to dest |
| 1B | MCPY3 | [TOS], [NOS] | CMOVE (3) | 0692 | 82→0693 | Copy 3 bytes from NOS (source) to TOS (dest). Pops both. |
| 1C | DROP | DROP | 05F5 | 8F→05F6 | Drop TOS (discard top of stack) | |
| 1D | OVER | OVER | 05F9 | 90→05FA | Copy NOS to TOS (push copy of second stack value on top) | |
| 1E | DUP | DUP | 05FD | 92→05FE | Duplicate TOS (push copy of top value) | |
| 1F | DUPNZ | ?DUP | 0603 | 8E→0604 | Duplicate TOS if nonzero (push copy); if TOS == 0, no change | |
| 20 | SWAP | SWAP | 0610 | 82→0611 | Exchange TOS and NOS on data stack (swap top two 16-bit values) | |
| 21 | SUB | TOS, 1 | 1- | 04CE | 0C→04CF | TOS = TOS − 1 |
| 22aaaa | PUSH | aaaa | LIT | 061F | 8F→0620 | Push 16-bit immediate value aaaa onto data stack |
| 23kk | PUSH | kk | LIT | 0624 | 8F→0625 | Push 8-bit constant kk onto data stack |
| 24 | PUSH | R | R@ | 062E | 9D→062F | Read 16-bit value from top of return stack (R2), push to data stack (return stack unchanged) |
| 25 | LD | [TOS], 0 | 0 C! | 067D | 88→067E | M(TOS) = 0x00. Pops TOS. |
| 26 | ADD24 | [NOS], [TOS] | D+ (24-bit) | 06A5 | 82→06A6 | 24-bit (3-byte) add: M(NOS) = M(NOS) + M(TOS), big-endian. Pops both addresses. |
| 27 | OR | TOS, NOS | OR | 069A | 06→069B | TOS = NOS OR TOS (16-bit bitwise OR) |
| 28 | LD24 | [TOS], NOS | ! (24-bit) | 06B7 | 88→06B8 | Store NOS as 24-bit (zero-extended) at M(TOS): M(TOS)=0x00, M(TOS+1)=NOS.1, M(TOS+2)=NOS.0. Pops both. |
| 29 | LDBNK | [TOS] | C@ (banked) | 08DC | B3→08DF | Read 1 byte from banked ROM at 3-byte encoded address on TOS (byte0=bank code, byte1:byte2=16-bit offset). Entry 0xB3 sets R6=0x7848 (bank shadow var), R7=0x3FC0 (bank reg base). Temporarily switches bank registers 0x3FC0–0x3FC3 to the target bank, reads the byte, pushes it zero-extended to TOS, then restores home bank (shadow=0, regs=00/01/02/03). R1.7 equivalent = opcode 0x39. |
| 2Aaaaa | LD | TOS, [aaaa] | @ | 0599 | AD·89→059B | TOS = [aaaa] (load 16-bit, push to stack) |
| 2B | LD | [TOS], 1 | 1 C! | 0682 | 88→0683 | M(TOS) = 0x01. Pops TOS. |
| 2Baaaa | LD | [aaaa], TOS | ! | 05AF | AD·89→05B1 | [aaaa] = TOS (pop 16-bit, store to address) |
| 2Caaaa | LD | TOS.0, [aaaa] | C@ | 05C2 | AD·89→05C4 | TOS = [aaaa] (load byte, push) |
| 2Daaaa | LD | [aaaa], TOS.0 | C! | 05D6 | AD·89→05D8 | [aaaa] = TOS (pop, store low byte only) |
| 2Ebb | LD | TOS.0, [78bb] | C@ | 075F | AD·38→0760 | Load 1 byte from variable at 0x78bb, zero-extend, push to stack |
| 2Fbb | LD | [78bb], TOS.0 | C! | 076D | AD·00→0771 | Pop TOS, store low byte to variable at 0x78bb |
| 30 | FILL | FILL | 068E | 7E→068F | Fill memory: pops 3 values (TOS=count, NOS=dest addr, 3OS=fill byte); fills count.low bytes at dest with fill.low | |
| 31 | PUSH |
VE, kk | VE @ kk + | 0583 | 98→0585 | TOS = VE + kk. VE unchanged. |
| 32 | PUSH |
[VE].0, kk | VE @ kk + C@ | 05B9 | 98→05BB | TOS = M(VE + kk) (load byte). VE unchanged. |
| 33 | POP | [VE].0, kk | VE @ kk + C! | 05CD | 98→05CF | M(VE + kk) = TOS.0 (store low byte). VE unchanged. |
| 34aaaakk | LDAND | [aaaa], kk | C@ kk AND | 0557 | AD·89→055A | TOS = [aaaa] & kk (load byte, AND with mask, push) |
| 35 | ADD24 | [NOS], TOS | D+ (24-bit) | 0740 | 82→0741 | M(NOS) = M(NOS) + TOS - Add 16-bit TOS to 24-bit (3-byte) value at M(NOS), big-endian. Pops both. |
| 36 | SUB24 | [NOS], TOS | D- (24-bit) | 0750 | 82→0751 | M(NOS) = M(NOS) − TOS - Subtract 16-bit TOS from 24-bit (3-byte) value at M(NOS), big-endian. Pops both. |
| 37 | NOOP | NOOP | 077C | 28→SEP RF | No operation | |
| 38 | ISZ | [TOS] | C@ 0= | 1046 | 88→1047 | TOS = (M(TOS) == 0) ? 1 : 0. |
| 39 | EXEC | TOS | EXECUTE | 18AE | 88→18AF | Call handler at M(TOS). Pops TOS, dispatches to target. |
| 3A | PUSH | 78B7 | LIT | 19F2 | ||
| 3B | PUSH | 7C06 | LIT | 19F9 | ||
| 3C | PUSH | 7C09 | LIT | 19FC | ||
| 3D | PUSH | 7C0C | LIT | 19FF | ||
| 3E | PUSH | 7C0E | LIT | 1A02 | ||
| 3F | PUSH | 7C11 | LIT | 1A05 | ||
| 40 | PUSH | 7C2C | LIT | 1A0B | ||
| 41 | PUSH | 7C4E | LIT | 1A0E | ||
| 42 | PUSH | 7C70 | LIT | 1A11 | ||
| 43 | PUSH | 7C92 | LIT | 1A14 | ||
| 44 | PUSH | 7CB4 | LIT | 1A17 | ||
| 45 | PUSH | 7CD6 | LIT | 1A1A | ||
| 46 | PUSH | 7D08 | LIT | 1A21 | ||
| 47 | PUSH | FFFF | LIT | 1BDB | 20 | Push 0xFFFF (constant in handler body) |
| 48 | PUSH | 20 | LIT | 1BDE | 20 | Push 0x0020 / space (constant in handler body) |
| 49 | PUSH | 0 | LIT | 1BE1 | 20 | Push 0x0000 (constant in handler body) |
| 4A | PUSH | 1 | LIT | 1BE4 | 20 | Push 0x0001 (constant in handler body) |
| 4B | PUSH | 2 | LIT | 1BE7 | 20 | Push 0x0002 (constant in handler body) |
| 4C | PUSH | 4 | LIT | 1BEA | 20 | Push 0x0004 (constant in handler body) |
| 4D | PUSH | 7 | LIT | 1BED | 20 | Push 0x0007 (constant in handler body) |
| 4E | PUSH | D | LIT | 1BF0 | 20 | Push 0x000D / CR (constant in handler body) |
| 4F | PUSH | 2B | LIT | 1BF3 | 20 | Push 0x002B / + (constant in handler body) |
| 50 | PUSH | 2D | LIT | 1BF6 | 20 | Push 0x002D / - (constant in handler body) |
| 51 | STBNK | [TOS], 0 | ! (banked) | 1C01 | 15→1C02 | Store zero to banked memory at 24-bit address pointed to by [TOS]. Converts 24-bit linear addr to bank+offset, switches bank regs (0x3FC0-3FC3), writes 0x00, restores home bank. |
| 52 | MCPY | [TOS], [NOS] | CMOVE | 1C1E | 15→1C1F | Conditional copy: reads length byte M(NOS). If zero, drops both addresses (no copy). If nonzero, copies M(NOS)+2 bytes from NOS to TOS (includes 2-byte header). |
| 53 | MCPY6 | [TOS], [NOS] | CMOVE (6) | 1C4F | 15→1C50 | Copy 6 bytes from NOS (source) to TOS (dest). Pops both. |
| 54 | SUB24 | [TOS], 1 | DEC (24-bit) | 1CB3 | 15→1CB4 | Decrement 24-bit (3-byte) value at address TOS by 1. Pops TOS. |
| 55 | CMP24 | [TOS], [NOS] | COMPARE (24-bit) | 1CBC | 15→1CBD | Compare 3 bytes (24-bit) at [TOS] vs [NOS]. Returns 0 if equal, nonzero if different. Pops both. |
| 56 | LD | TOS, [7C16] | @ | 1E0D | 15→1E0E | Load 16-bit value from fixed address 0x7C16, push to stack |
| 57 | IDX24 | 7AC8 | index (custom) | 1E1D | 15→1E1E | Index into 24-bit array: TOS = 7AC8 + (TOS - 1) * 3. Converts 1-based index to address of 3-byte entry in table at 0x7AC8. |
| 58 | MCPY6 | 78B7, [TOS] | CMOVE (6) | 1E66 | 15→1E67 | Copy 6 bytes from TOS address to fixed destination 0x78B7. Pops TOS. |
| 59 | MCPY6 | [TOS], 78B7 | CMOVE (6) | 1E6A | 15→1E6B | Copy 6 bytes from fixed source 0x78B7 to [TOS]. Pops TOS. |
| 5B | PUSH | 3CDE | LIT | 1BD8 | 20 | Push constant 0x3CDE |
| 5C | PUSH | FFFF | LIT | 1BDB | 20 | Push 0xFFFF - DUPLICATE of opcode 0x47 |
| 5D | PUSH | 7 | LIT | 1BED | 20 | Push 0x0007 - DUPLICATE of opcode 0x4D |
| 5E | PUSH | 8000 | LIT | 1BFE | 20 | Push constant 0x8000 |
| 60 | SUB24 | [TOS], 1 | DEC (24-bit) | 1CB3 | 15→1CB4 | DUPLICATE of opcode 0x54 (same handler address) |
| 61 | CMP24 | [TOS], [NOS] | COMPARE (24-bit) | 1CBC | 15→1CBD | DUPLICATE of opcode 0x55 (same handler address) |
| 63 | PUSH | 1A11 | LIT | 1DAC | 20 | Push constant 0x1A11 (possibly a procedure address) |
| 80kk–FFkk | SYS | aaaa | CODE (native) | - | - | System dispatch to target address aaaa. Address calculation: aaaa = (kk << 8) | ((opcode & 0x7F) << 1). Only even target addresses are reachable. Note that kk is a program memory page value; multiple opcodes can target the same page of code. Example: opcode DD 07 → kk = 07, kk << 8 = 0x0700, (opcode & 0x7F) = 0x5D, 0x5D << 1 = 0xBA, address = 0x0700 | 0xBA = 0x07BA |
SYS Targets
| Opcode | Mnemonic | Handler | Dispatch | Description |
|---|---|---|---|---|
| E9 04 | SYS 04D2 | 04D2 | 82→04D3 | ADD [TOS], NOS M(TOS) = M(TOS) + NOS (16-bit add in-place). Pops both. |
| 9D 05 | SYS 053A | 053A | AD·89→053C | CALLI [aaaa] Indirect call: reads 16-bit target from table at aaaa + TOS×2, calls handler at that address. Pops TOS (index). |
| C8 05 |
SYS 0590 | 0590 | 98→0594 | PUSH [VE], kk TOS = M(VE + kk) : M(VE + kk + 1) (load 16-bit word, big-endian). VE unchanged. |
| 8C 06 | SYS 0618 | 0618 | 7C→0619 | ROT Rotate 3OS item to TOS (Forth ROT). Before: TOS=A, NOS=B, 3OS=C → After: TOS=C, NOS=A, 3OS=B |
| C3 06 | SYS 0686 | 0686 | 88→0687 | ADD [TOS], NOS.0 M(TOS) = M(TOS) + NOS.low (byte add). Pops both. |
| E1 06 |
SYS 06C2 | 06C2 | 7C→06C3 | COMP Multi-byte compare: compares TOS.0 bytes at NOS against 3OS. Returns 0 (equal), 1 (NOS > 3OS), or FFFF (NOS < 3OS) |
| E7 0B | SYS 0BCE | 0BCE | B3→0BD2 | DELAY Software delay. Duration controlled by M(6009): if 0, exits immediately; if nonzero, delays proportional to value. No I/O. |