P8X applications¶
Standalone TPA programs — assembled to load and execute at $5900 (the
transient program area), launched from P8X/OS with RUN. Each is built entirely
on the BIOS jump table ($0100..); none depend on OS internals, so they return
to the shell with a plain RTS.
Build one with the host assembler and place it on a disk:
python3 assembler/p8xasm.py apps/p8xedit.asm -o edit.bin --base 0x5900
python3 tools/p8xfs.py put disk.img edit.bin --name /BIN/EDIT.BIN --load 0x5900 --exec 0x5900
os/run.sh already builds and installs these into a fresh demo disk under
/BIN, so a clean ./os/run.sh boots an OS where RUN EDIT.BIN NAME works.
EDIT — line-oriented text editor (p8xedit.asm)¶
RUN EDIT.BIN NAME.EXT
On entry the OS hands the program its argument tail in P2 (the program-arg
ABI); EDIT copies it to FNAME and loads that file if it exists, else starts an
empty buffer. Text is held as LF-separated lines in $C000..$F000 (12 KB).
A file larger than that buffer is refused — FILE TOO LARGE (MAX 12K), and
EDIT returns to the OS without a buffer or a filename. It deliberately does not
load the first 12 KB: a part-loaded file is indistinguishable from a short one,
and the first W would write it back over the original. Input lines are capped
at 255 characters (LBUF is one page at $BE00, and the editor's own state
begins at $BF00).
| cmd | action |
|---|---|
L |
list every line with its 1-based number |
A |
append: type lines, end with a line containing only . |
I n |
insert before line n (n past the end appends); end with . |
D n |
delete line n |
W |
write the buffer back to the file (FDELETE then FCREATE) |
Q |
quit to the shell |
? |
command summary |
Notes / current limits: line numbers are 8-bit (≤255 lines); W rewrites the
whole file, orphaning the old data sectors until the next PACK; the editor
reads/writes the root directory (the BIOS FS layer is flat — path-aware
saves are a future item). Files use LF ($0A) line endings — the form the
on-target assembler (ASM) expects as input.
ASM — native two-pass assembler (p8xasm.asm)¶
RUN ASM.BIN SRC.ASM OUT.BIN
Assembles SRC.ASM (read from the disk) and writes the binary OUT.BIN. The
output carries load/exec = 0 from FCREATE, which the OS reads as the TPA
base $5900 — so a program written .org $5900 is directly RUNnable right
after assembling it. Pair with EDIT for a complete on-target edit → assemble →
run loop.
Accepted syntax is a subset of the host assembler, with identical encodings:
| form | example |
|---|---|
| label | loop: |
| equate | COUNT = 3 |
| instruction | LDA #COUNT · STA $C000 · LDA (P1)+ · JSR done |
| two-operand forms | MOVW __ax,__V+4 · ADDW __ax,__t0 · CMPW __ax,#300 · LDW __V+2,(P3+3) · STW (P3+1),__ax — since 2026-09-12 the native parser handles every Tier A shape the host assembler does (a,b, a,#imm8, a,#imm16, (Pn+d), a,(Pn+d), (Pn+d),a; PARSEOP/CLASSOP, byte-literal rule LIT8 = the host's), so cc's output and hand sources assemble byte-identically on both. Only the relative branches (.relax/.R) stay host-only |
LDPn #imm16 |
LDP1 #msg → the 3-byte LDPn opcode ($38–$3A) + imm16 (Tier A; was the LPLn/LPHn pair) |
| directives | .org .byte .word .ascii .asciiz .fill — a string decodes \n \t \r \0 and \\ \" \' as the host assembler does (since 2026-09-13; the on-board cc emits C escapes raw for the assembler to decode) |
| expressions | $hex · decimal · 'c' · symbol, joined with +/-, optional </> prefix |
A ;#use NAME line (at column 0) appends the shared include /lib/NAME.inc
after the program body (up to 4 per file, in declared order) — the on-target
mirror of the host mkasm.sh, letting a hand-asm command share the same helper
includes (stdin/glob/globx/regex) that the C //#use shares.
;#useis live here but inert host-side. The hostassembler/p8xasm.pysees the leading;and skips the line as an ordinary comment; only this assembler acts on it. So a source spliced host-side must not keep the directive, or the on-target build hunts for an include that isn't on the disk while host builds stay green —mkasm.shtherefore rewrites the line as it splices. The same asymmetry applies to any;-prefixed pseudo-directive: reproduce toolchain bugs on-target, not just on the host. Both theSRCandOUTarguments are path-aware (a full path, not a 12-char root-only name), so a source under/srccan be assembled straight into a build-output dir:asm /src/commands/asm/pwd.asm /src/commands/asm/bin/pwd.bin. Together these let ASM rebuild the hand-asm/bincommands from source on the machine — theasmhalf of the on-target rebuild loop (cchandles the C half); theshbuilt-in drives it (see os/commands/).
The opcode table is generated from genucode.OPC by
generators/gen_p8xopc.py and concatenated after the assembler logic at build
time, so the mnemonic/encoding map can never drift from the microcode.
Source and output are both streamed to/from disk through the BIOS file
streams — input via FOPEN/FGETB (a line at a time), output via
FWOPEN/FPUTB/FCLOSE (a sector at a time). So source and output size are
bounded by the disk, not RAM, and the freed RAM gives a large symbol table
(1,664 entries at $8000–$E7FF). As a result the assembler can assemble
its own source on-target, producing a binary byte-identical to the host build
(emulator/test/asm_selfhost_test.sh, make test-asm-selfhost).
Correctness is checked by assembling a feature source both on-target and with
the host assembler and comparing the bytes (emulator/test/os_asm_test.sh).
Limits: 1,664 symbols, 12-char names, 127-char source lines, single .org
(use .org $5900; a backward .org is rejected).
Redesigned for the Tier A ISA (2026-09-12). p8xasm.asm was rewritten
from scratch as a drop-in (same syntax, same error messages, byte-identical
output; the four os_asm*/asm_selfhost tests lock it to the host): 16-bit
values are word variables handled with ADDW/SUBW/CMPW/INCW/MOVW; the
symbol table is a 256-bucket chained hash of 16-byte entries (name[12]
value[2] next[2]) read and written through (P2+d) — LDW CNT,(P2+12) fetches
a value in one instruction — instead of a linear scan comparing all 12 bytes of
every entry; the opcode table gets a first-letter index at startup so a mnemonic
lookup scans only its letter group, shape byte first; operand emission is a
jump table (DISPTAB + LPW1/JSR (P1)); MOVW and LDPn need no special
cases (a lone # that fails as imm8 is retried as imm16); .org pads forward
itself so EMIT is a plain write-and-INCW; fixed-size copies are MOVW/STW
runs. Binary 5,178 → 4,065 B. Cycles (p8xemu -L): the coverage source 6.40 M →
2.62 M (2.4×), assembling its own source 282 M → 49.5 M (5.7×), a 1,000-symbol
stress source > 900 M (did not finish) → 29 M. The code + opcode table must
stay below $8000 (the table start); os_asm_test.sh asserts it.
The C version (asm.c, 2026-09-13) — size and speed against the asm one.
apps/asm.c is the same assembler in the p8cc subset: same syntax, error
messages and output, the same raw-memory map above the image (source and
include sectors, path buffers, chain heads). Built by the host toolchain — the
opcode table as C (opctab.c, from gen_p8xopc.py --c) concatenated ahead of
the source, //#use abi spliced, p8cc.py — and installed as /binc/asm.bin;
emulator/test/asm_c_test.sh assembles the coverage source, the feature
program, a ;#use command, a relative .include and the asm assembler's own
source with it, all byte-identical to the host.
asm p8xasm.asm |
C asm.c |
ratio | |
|---|---|---|---|
| binary | 4,116 B | 9,945 B | 2.4× |
| the all-opcode coverage source | 2.62 M cycles | 11.0 M | 4.2× |
| its own 71 KB source (self-host) | 49.5 M | 194 M | 3.9× |
| symbol capacity | 1,664 | 480 (the table starts above the larger image, at $A800) |
Tuned for the compiler like the C BASIC (an add-only hash instead of a 7-step
shift per character, the identifier scan and the byte loop inlined), which
took it from 4.7× to the figures above. The remaining gap is the per-call frame
and the byte-at-a-time pointer walks the compiler emits around every bios
byte in and out.
CC — native C compiler (p8xcc.asm)¶
A from-scratch, single-pass C compiler written directly in assembly — small
enough (~10 KB) to compile C entirely on the machine, front and back end.
It streams the source in (BIOS FOPEN/FGETB, one-char pushback) and emits P8X
assembly to stdout (SYS_PUTC, shell-redirectable), which the native asm
turns into a RUNnable binary — so C is compiled, assembled, and run on-target:
cc hello.c >hello.asm # native compiler
asm hello.asm HELLO.BIN # native assembler
run HELLO.BIN
This is Milestone B (the native route) — the host p8cc.c codegen compiles
to far more than the 64 KB address space (its tables are host-sized), so it
can't run on the machine; cc uses a deliberately small static-slot codegen
instead.
Tier A output (2026-09-12). cc now emits the new instructions: a literal is
LDW __ax,#n, + - & | ^ are ADDW/SUBW/ANDW/ORW/XORW on the __ax/__t0
words, a struct offset is ADDW __ax,#k, ++/-- are INCW/DECW in place, a
condition test is CMPW __ax,#0, an ordering is one CMPW and one branch (only
==/!= still call the 16-bit __cmp), unary minus is XORW #65535/INCW.
Arguments no longer go through the software arg stack: the caller pushes them
left to right with PHW, the callee copies them into its static slots with
LDW __V+2s,(P3+d) (arg i at P3+3+2(n-1-i)), and the caller drops them
with ADDP3; the caller's live-slot saves (re-entrancy) now precede the
arguments and are discarded rather than restored when an argument took an
address. Programs start like p8cc's: the caller's P3 is kept in __sp0 and
the program runs on a stack below CSTACKTOP unless launched nested. The
__add/__sub/__and/__or/__xor/__neg/__pusharg/__pop runtime texts
are gone. Condition mode: if/while/for hand GEXPR a false-label
(CONDF/CONDLBL); a relational that ends the condition emits its compare and
ONE branch to that label (EMITCF) instead of a 0/1 value and a re-test, and
the statement skips its own CMPW #0/JZ (CONDDONE). A nested GEXPR
(call arguments, parentheses, an index) sees the flag cleared and still
produces a value. A statement-level NAME++/NAME-- is one INCW/DECW on
the slot. Results on the same test program compiled and run on the board:
5,546 → 2,041 bytes (−63%), identical output; vi.c built on the board
26,632 → 25,228 bytes. For scale, the Python compiler makes 1,066 bytes of the
same (subset) program and the C-written host compiler 1,107: the on-board
compiler's static-slot model and single pass cost about 2× in size, by design.
The compiler's own body went through tools/tierA_rewrite.py too (88 word
moves / pointer loads / carry chains → one instruction each; cc.bin
22,924 → 20,905 bytes including the new condition-mode code), as did the
assembler's (36 sites; verified byte-identical by asm_selfhost_test).
Rewritten from scratch for the Tier A ISA (2026-09-13). p8xcc.asm is a
drop-in: the code it GENERATES is the same instruction for instruction (checked
by compiling the spliced pwd, wc, grep and vi sources with the old and
the new compiler on the machine and diffing the text; only the indentation of
the emitted lines changed, from eight spaces to one tab, which makes the output
about 35% smaller and the following asm step correspondingly quicker). What
changed inside: every name table (locals, globals, functions, macros, struct
tags and members, spliced libraries) is one mechanism — arena entries
[next][len][flag][value][chars] chained from a 32-way first-letter head
array, read and written through (P1+d), an entry rejected on its length
before a character is compared (the old packed pools walked every name byte by
byte, and every identifier paid that for the macro table alone); the lexer
classifies keywords once (KWFIND → CURKW) instead of the parser running up
to ten string compares per statement; slot numbers, literals and the decimal
emitter use the word ops; emitted text is walked with one pointer (the OS's
SYS_PUTC preserves it); the tables live at $B000–$DFFF (free TPA while the
compiler runs, cleared at start) so the binary is code only. cc.bin 20,915 →
10,075 bytes (the old file carried 7.6 KB of tables as zeros; code alone
13.3 KB → 10.1 KB). Compile cycles on the machine: pwd.c 2.60 M → 2.10 M,
wc.c 30.8 M → 20.6 M, grep.c 55.8 M → 36.1 M, vi.c 48.8 M → 30.0 M (1.2–1.6×,
the rest being the BIOS byte stream and SYS_PUTC themselves). Three
behaviour changes, all fixes: a char array declared after an int array in
the same function was compiled with word elements (the differential run caught
it in grep.c's collect; puts of such an array printed one character),
a call to a function not yet declared now emits its name (the assembler resolves
it) instead of a garbage label, and a syntax error stops with cc: syntax
error instead of looping. Limits (raised later the same day, for the C twin
below): 250 functions, 250 //#defines, 5 nested //#use; the arenas hold
11.5 KB of global names (the tables now start at $A000) and 768 bytes of
locals per function (cc: symbol table full past that).
Three more fixes (2026-09-13, found by diffing the C twin's output against
this compiler's on the machine): the right operand of && now leaves
condition mode — if (a && b == c) emitted the relational's single branch and
then fell into the body when a was false (|| was always right; the &&
form is what real sources use, e.g. while (*a && *a == *b)); label numbers
are 16-bit — the byte counter wrapped at 256, so any on-board build of
grep.c (600 labels) or vi.c (353) got duplicate labels; and a local
array's size is 16-bit arithmetic — char b[300] was given 21 slots instead
of 150. cc.bin 10,075 → 10,182 bytes. cc_c_test.sh guards all three.
The C version (cc.c, 2026-09-13) — size and speed against the asm one.
apps/cc.c is the same compiler in the p8cc subset, mirroring p8xcc.asm
routine for routine: the same static-slot codegen, the same name-table
mechanism (arena entries chained from first-letter heads, as raw memory above
the image from $B800), the same messages and — the point — the same emitted
text. emulator/test/cc_c_test.sh compiles six sources with both compilers on
the machine and diffs the text (that differential is what found the three
bugs above); compiling cc.c itself with both gives 136,032 identical bytes.
Built by the host toolchain (//#use abi spliced, p8cc.py) as /binc/cc.bin.
asm p8xcc.asm |
C cc.c |
ratio | |
|---|---|---|---|
| binary | 10,182 B | 18,089 B | 1.8× |
pwd.c |
2.09 M cycles | 4.54 M | 2.2× |
wc.c |
20.6 M | 37.7 M | 1.8× |
vi.c |
30.2 M | 54.4 M | 1.8× |
cc.c (its own source) |
68.0 M | 130.2 M | 1.9× |
The gap is the smallest of the three twins (BASIC 3.6–4.9×, the assembler
3.9–4.2×) because both compilers spend most of their cycles in the BIOS byte
stream and SYS_PUTC. The source keeps to the subset BOTH compilers accept
(no break/continue, no initialised globals, no string literal over 127
characters, byte stores through pointers written p[0] = v because the
on-board compiler stores a word through *p =), so the on-board compiler
compiles it — and the self-hosting round trip now WORKS (2026-09-14): cc.c
uses the P3-stack-frame codegen (recursion-correct, ~13% smaller), which brings
its self-compile to 30,843 bytes; the native assembler assembles that (its
symbol table grew 1,120 → 1,664 for the 1,548-symbol output), and the
self-compiled compiler runs and reproduces its own output byte-for-byte — a
fixed point. It took the frame model plus the +2 KB of TPA (the ROM 8K → 6K
reclaim dropped TPABASE to $5900) plus a raised table layout so the 30.8 KB
image clears its own tables. Regression test: emulator/test/cc_selfhost_test.sh.
Language (through v0.28): functions, direct and mutual recursion (via a
forward prototype), pointers + pass-by-reference, int/char, arrays with []
and name decay, structs (./->), file-scope globals, the full operator set
(+ - * / % << >> & ^ | && || ?:, ++ -- += -=, comparisons, unary - ! * &),
hex/char/string literals with escapes, // and /* */ comments, a recursive
//#use preprocessor (splices /lib/lib_*.c) plus object-like //#define
macros, and the putchar/puts/getchar/peek/poke/argstr/bios builtins.
It compiles real OS command
source (e.g. pwd.c). Codegen is verified behaviourally (compile → asm →
run → diff output; emulator/test/os_cc_test.sh).
Known gaps are listed under "cc — KNOWN LIMITATIONS" in BACKLOG.md
(highlights: 16-bit int only; no unsigned/typedef/enum/union/sizeof;
struct member names must be unique program-wide, no struct params/returns; +
doesn't scale pointers by element size; preprocessor is //#use + object-like
//#define only — no #include/#if/function-like macros).