tms-c-compiler
A modern C toolchain for the DEC VT1200 / TI TMS34010, built as a small fork of upstream LLVM.
Status: M3. llc -march=tms34010 emits GSPA-compatible assembly using the
TI ABI: integer/pointer arguments are pushed on the program stack, the result is
returned in A8, frames use A13/A14/A15, and functions end in RETS 2
(RETS 0 without arguments). M3 adds conditional/unconditional branches,
loops, integer ALU/shift operations, and the first compiled C Xlib client: a
four-call window opened with XOpenDisplay, created with
XCreateSimpleWindow, mapped with XMapWindow and synchronized with XSync.
It is still assembly-only: no object writer, no clang -c, and no native
COFF; GSPA/GSPLNK under DOSBox produce the COFF. XImage/XEvent, the real client,
structs by value, function pointers, floats and i64 remain M4/M5.
- Design:
docs/superpowers/specs/2026-11-10-tms34010-llvm-design.md - M0 plan:
docs/superpowers/plans/2026-11-10-tms34010-m0-fork-and-bit-address-semantics.md - M1 plan:
docs/superpowers/plans/2026-11-11-tms34010-m1-minimal-backend.md - M2 plan:
docs/superpowers/plans/2026-11-11-tms34010-m2-ti-abi-and-rom-calls.md - M3 plan:
docs/superpowers/plans/2026-11-11-tms34010-m3-control-flow-and-xlib-window.md - Spike:
spike/REPORT-23.md(current, LLVM 23.1.5);spike/REPORT.md(historical, 19.1.7)
Repositories
- Compiler fork: https://git.wut.ee/opencode.siil.arti.ee/llvm-tms34010
(branch
m3) — the full buildable LLVM 23.1.5 tree with the TMS34010 target, clang bit-address changes and the M2/M3 backend. Branchm2(the repository default) is the M2 snapshot; branchtms34010is the M1 tip and keeps the original M1 backend and suite. - This project: https://git.wut.ee/opencode.siil.arti.ee/tms-c-compiler
(branch
m3) — plans/specs, VT1200 integration tooling,tms-bitaddr,tms-clang, symbols, the M2/M3 harnesses and the spike. Branchm2(the repository default) is the M2 snapshot and branchm1is the reproducible M1 snapshot, including the retired M1 harness.
The compiler fork is the canonical source for all target code. patches/llvm
here is a frozen M0 mirror used by the default upstream+patches build; M2/M3
do not change it.
What M3 provides
M3 adds integer control flow and the first compiled C Xlib client, still assembly-only:
- Conditional and unconditional branches for
i32and pointer values:CMP/CMPIplusJAEQ/JANE, the signedJALT/JALE/JAGT/JAGE, the unsignedJALO/JALS/JAHI/JAHS, andJAUCfor an unconditional jump. Loops, if/else and?:work;?:expands to a compare plus a two-block diamond (SELECTCC), andsetccbecomes that diamond over 1/0. - Integer ALU and shift operations:
SUB,AND,OR,XOR,NEG,NOT,SLL,SRL,SRA, plusADDI/ANDI/SLLK/SRAK/SRLKimmediates. - The first compiled C Xlib window:
XOpenDisplay,XCreateSimpleWindow(nine arguments in the ROM's order: display, root, x, y, width, height, border width, border, background),XMapWindowandXSync. All four go through validated ROM import stubs. - The ROM's
Display/Screenfield displacements are bit offsets0x480(Display.screens) and0x40(Screen.root), i.e. byte offsets0x90and0x8inscripts/m3/vt1200_xlib.h. - Misaligned accesses:
MOVE/MOVBand the FS0/FS1 field-size forms have no alignment requirement on the TMS34010, so packed and bit-addressed struct fields select directly instead of being split into byte-wide accesses. - Eight
m3-*.lllit tests, for a total of 45 with the M2 suite; the M3 end-to-end, dis34, layout and differential gates (below).
M3 remains an i32/pointer milestone. Narrow integer loads/stores and setcc
are supported, but narrow (i8/i16) parameters and returns are still
rejected. Branches on i64, floats, aggregates, varargs and indirect calls are
not supported. The backend still emits assembly only.
What M2 provides (branch m2)
M2 replaces the M1 test convention with the TI ABI and stops at assembly:
llc -march=tms34010 -filetype=asmfor straight-linei32/pointer functions with calls, frames and locals.- A caller saves
A14(STK) on the system stack, pushes arguments on the program stack in reverse order (each a 32-bit slot), emitsCALLA, then re-establishes both field sizes (SETF 16,0,0,SETF 32,0,1). - A callee starts with
SETF 16,0,0/SETF 32,0,1, saves the used A0–A7/A9–A12 registers and the old FP on the system stack, setsFP = STK, allocates the frame withADDI, and restores both stacks in the epilogue. The first explicit argument is atFP(-32), the second atFP(-64), and so on; locals live at positive FP offsets. void,i32and pointer returns, inA8;RETS 2when the function has explicit arguments,RETS 0otherwise.- Initialized globals, string/byte-array literals and pointer initializers in
.data; zero-initialized mutable globals stay in.bss. The loader copies.dataalong with.text. - ROM import stubs generated from
symbols/vt1200-v22.syms. M2 consumesstrlenonly:_strlen: jauc 3FB458D0h.CALLA _strlenpushes the return PC, the stub jumps to the ROM, and the ROM'sRETS 2returns directly to our caller. - Thirty-seven
llvm-lit/FileCheck tests underllvm/test/CodeGen/TMS34010/m2-*.ll, including negative tests that pin the loud refusals. The ABI/data coverage includesm2-returned,m2-returned-call,m2-call-noret,m2-data-align,m2-data-avail,m2-data-z64andm2-data-i1. - End-to-end MAME and dis34 gates, plus a differential check against assembly generated once by the 1987 TI compiler (see below).
The TI ABI
M1 test convention (branch m1) |
TI ABI (M2/M3) | |
|---|---|---|
| Integer/pointer arguments | A0–A3, four maximum |
program stack via A14 (STK), pushed in reverse |
| Argument slots | registers | first at FP(-32), second at FP(-64), … |
| Return value | A0 |
A8 |
| Frame | none; leaf functions only | A13 FP, A14 STK, A15 system stack |
| Locals | none | positive FP offsets |
| Return instruction | RETS 0 |
RETS 2 with arguments, RETS 0 without |
| Calls, callee-saves, stack args | rejected | required |
| Field size | harness sets FS1/FS0 before entry | FS0 = 16 and FS1 = 32, set in the prologue and after every call |
The callee-saved set is A0–A7 and A9–A12; A8 is the return register
and A13–A15 are reserved. After n system-stack pushes the saved old STK
is at SP + (n+1)*32, and RETS N pops the return PC and advances SP by
32 + 16N. FS0 must be 16 and FS1 must be 32 at function entry and
immediately after every CALLA (SETF 16,0,0 then SETF 32,0,1), because
ROM code may change either field size. FS0 drives short loads/stores and FS1
drives 32-bit words.
Run the M3 gates
These are the documented commands on branch m3; all must pass.
# 1. Build llc, opt, clang and the test tools
ninja -C build/llvm-clean-build llc opt clang llvm-config FileCheck count not
# 2. LLVM codegen tests: 45 passed (37 m2-* + 8 m3-*)
build/llvm-clean-build/bin/llvm-lit -v \
build/llvm-clean-build/test/CodeGen/TMS34010 --filter='(m2|m3)-'
# 3. Layout/symbols, then compile, assemble, link and run in MAME
scripts/tests/m3/run.sh
# -> layout.sh: PASS (Display.screens=0x480, Screen.root=0x40; ...)
# -> M3 MAME: PASS
# (display/root/window nonzero, sum=55 max=22 lt=1, screens=0x480 root=0x40)
# 4. Rebuild the COFF and check the GSPA encodings with dis34
scripts/tests/m3/dis-roundtrip.sh
# -> M3 dis34 round-trip: PASS
# 5. Differential TI-compiler control-flow/Xlib check (no DOSBox)
scripts/tests/m3/diff/check.sh
# -> M3 diff: PASS
# 6. GEP scaling/normalization tool tests
tools/tms-bitaddr/tests/run.sh
# -> tms-bitaddr: PASS
scripts/tests/m3/run.sh first runs scripts/tests/m3/layout.sh (the ROM's
0x480/0x40 bit displacements in both our asm and the 1987 golden) and
scripts/m3/validate-syms.sh (the four Xlib imports), then compiles
scripts/m3/branch.c, layout.c and xhello.c with scripts/tms-clang -O2,
runs llc -march=tms34010 -O0 -filetype=asm, generates the per-ROM import
stubs, concatenates the result with scripts/m3/entry.asm, assembles/links
with GSPA/GSPLNK 2.00 under DOSBox, and loads the COFF in MAME. branch.c is
built with opt -replexitval=never, because the default exit-value rewrite
turns sumto's loop into an i33 multiply that this target cannot select;
the loop itself is the point of the test.
The MAME run pins VT1200_MONITOR=VR319 (the real 2048-bit row pitch) and
checks the ABI markers — nonzero display/root/window ids, sumto(10) == 55,
max2(20, 22) == 22, lt(5, 7) == 1, layout_screens() == 0x480,
layout_root() == 0x40 — and, decisively, two all-white 32-bit client words in
VRAM at (150,150) and (250,200) inside the mapped window. A markers-only run or
a root-window bit is a failure. Paths are overridable with TMS_CLANG,
TMS_OPT, TMS_BITADDR, TMS_LLC, MAME, DOSBOX, SDK_IMG, M3_WORK,
M3_COFF and M3_SDK.
scripts/tests/m3/dis-roundtrip.sh rebuilds through the normal GSPA/GSPLNK
path and uses dis34 to check the linked Xlib stubs (JAUC to the ROM bit
addresses), the nine-argument XCreateSimpleWindow push order, the branch code
(CMP, JALT, JAGT, JAEQ, JAUC, LBB… labels), the old-STK restore
offsets, and the "0::0" .data bytes.
scripts/tests/m3/diff/check.sh is the CI golden check. It compares the ABI
shape of our assembly with stored output from the 1987 TI compiler for the same
K&R sources: a conditional branch and an unconditional loop back-edge in
sumto, a conditional branch in max2, RETS 2, nine
XCreateSimpleWindow argument pushes, and the 0x480/0x40 bit
displacements. It does not compare instruction selection byte-for-byte. The
stored ti-branch.s and ti-xhello.s were generated once with
gspcpp/gspcc/gspcg (GSP C Compiler 2.01) under DOSBox; CI never runs
DOSBox, it greps the stored files.
Run the M2 gates (default branch)
These are the M2 commands; they also pass on m3. The M1 harness and suite are
on branch m1 and are not M2 gates: they encode the retired M1 register
convention.
# 1. Build llc, opt, clang and the test tools
ninja -C build/llvm-clean-build llc opt clang llvm-config FileCheck count not
# 2. LLVM codegen tests: 37 passed
build/llvm-clean-build/bin/llvm-lit -v \
build/llvm-clean-build/test/CodeGen/TMS34010 --filter='m2-'
# 3. Compile, assemble, link and run in MAME
scripts/tests/m2/run.sh
# -> M2 MAME: PASS
# (check=4 first=20 second=22 caller=42 locals=7 keep=16)
# 4. Rebuild the COFF and check the GSPA encodings with dis34
scripts/tests/m2/dis-roundtrip.sh
# -> M2 dis34 round-trip: PASS
# 5. Differential TI-compiler ABI check (no DOSBox)
scripts/tests/m2/diff/check.sh
# -> M2 diff: PASS
# 6. GEP scaling/normalization tool tests
tools/tms-bitaddr/tests/run.sh
# -> tms-bitaddr: PASS
scripts/tests/m2/run.sh compiles five C inputs with scripts/tms-clang -O2,
runs llc -march=tms34010 -O0 -filetype=asm on the normalized IR, generates
the per-ROM import stubs, concatenates the result with scripts/m2/entry.asm,
assembles/links with GSPA/GSPLNK 2.00 under DOSBox, then loads the COFF in
MAME. It checks strlen("0::0") == 4, two-argument order, a direct call, a
local frame, and callee-saved preservation. Paths are overridable with
TMS_CLANG, TMS_OPT, TMS_BITADDR, TMS_LLC, MAME, DOSBOX, SDK_IMG,
M2_WORK, M2_COFF and M2_SDK.
dis-roundtrip.sh runs the same build and disassembles the linked .text with
the VT1200 tree's dis34, checking the TI-ABI instructions at their linked bit
addresses, the old-STK restore offset against the push count, and the copied
.data bytes.
scripts/tests/m2/diff/check.sh is the CI golden check. It compares the ABI
shape of our assembly with stored output from the 1987 TI compiler for the same
K&R sources: first argument at FP(-32), second at FP(-64), result in A8,
RETS 2 for add2, a nonzero frame and RETS 0 for locals. It does not
compare instruction selection byte-for-byte. The stored ti-add2.s and
ti-locals.s were generated once with gspcpp/gspcc/gspcg (GSP C Compiler
2.01) under DOSBox; CI never runs DOSBox, it greps the stored files.
.data, strings and the loader
GSPA 2.00 has no ELF sections. M2 emits raw .data text, never .rodata,
.asciz, .p2align or .section:
i8scalar or byte/string array:.bytewith GSPA hex (068h)i16scalar:.wordi32scalar:.long- pointer:
.long _symbol+<bit addend>(the addend is already in bits) .evenbefore a.dataglobal whose alignment is at least 16 bits, so the label and the following.word/.longagree.
A scalar's directive must match its width, otherwise the next global's offset
is wrong. GSPA's .even aligns to a 16-bit boundary only, so an explicit
align 4/align 8 on a .data object is not honored in M2: the target's
standalone object alignment is 2 bytes. Private/local symbol names are
sanitized to GSPA-safe names (_HH for non-alphanumerics). Unsupported
initializer shapes — including arrays whose element width has no GSPA
directive (i1, i64) — fail with
M2 .data supports only integer, pointer and byte-array initializers.
Zero-initialized mutable globals remain .bss and are zeroed by the loader.
ROM imports
ROM entry points are per-ROM-revision bit addresses, kept out of the compiler
in symbols/vt1200-v22.syms. scripts/m2/gen-rom-imports.sh turns each entry
into an absolute-jump stub:
.globl _strlen
_strlen:
jauc 3FB458D0h
JAUC is the TMS34010 unconditional absolute jump; GSPA 2.00 rejects bare
ja. M2 uses only strlen. M3 adds the four Xlib entries
(XOpenDisplay, XCreateSimpleWindow, XMapWindow, XSync) and validates
their stubs in scripts/m3/validate-syms.sh and in MAME; symbols/README.md
records the evidence. Do not add other Xlib dependencies until they are
validated.
M2 non-goals
M2 deliberately rejects or omits:
- Branches/control flow: conditional and unconditional branches, loops, and spill/fixup paths that need them. A branch that reaches ISel fails loudly; M3 owns control flow.
- Floating point: float/double arguments, returns and arithmetic (M5).
- Aggregates: structs/unions by value,
sret,byval, bitfields (M4/M5). - Varargs:
...functions and calls (M5). - Function pointers and indirect calls (M4).
- Tail calls: ordinary
tailmarkers are demoted to ordinary calls;musttailis not implemented (M2 C does not emit it). Real tail calls remain a later milestone. - Narrow ABI types:
i8/i16parameters,i64, and narrow returns remain rejected; narrow loads/stores inside a function are supported. - Object output: no
MCCodeEmitter, object writer,llc -filetype=obj,clang -cor native COFF. GSPA assembles and GSPLNK links the asm. - Xlib and clients: no X11 headers, Xlib calls or real VT1200 clients in
M2; only the
strlenROM call.
The optimizer's returned argument attribute is accepted because it does not
affect the stack-slot ABI; sret/byval/inreg/nest remain rejected.
M3 non-goals
M3 deliberately stops at the first window and ordinary integer control flow:
- XImage/XEvent and the real client: no image transfer, event handling, full X11R4 headers or real VT1200 client (M4). M3 uses exactly the four Xlib calls above and no other Xlib entry.
- Aggregates by value: structs/unions passed or returned by value,
sret,byval, bitfields (M4/M5). Struct fields through pointers are supported. - Function pointers and indirect calls: the callee must be a direct global symbol (M4).
- Floating point and
i64: no float/double or 64-bit integer arguments, returns, arithmetic or branches (M5). - Object output: still no
MCCodeEmitter, object writer,llc -filetype=obj,clang -cor native COFF; GSPA/GSPLNK produce the COFF. TheJR/branch-relaxation code-size pass is M5 as well. - Varargs,
musttail, narrow ABI parameters/returns: unchanged from M2.
Repository layout
build/llvm-clean/ compiler fork checkout used for M2/M3 (ignored)
build/llvm-clean-build/ its Ninja build tree (ignored)
build/llvm-src/ M0 working fork (ignored)
patches/llvm/ frozen M0 patch series against llvmorg-23.1.5
tools/tms-bitaddr/ GEP scale/normalize tool
scripts/tms-clang IR pipeline wrapper (clang + tms-bitaddr + opt)
scripts/m2/ M2 harness: C inputs, entry.asm, link.cmd, stubs, checks
scripts/m3/ M3 harness: branch/xhello/layout inputs, Xlib header, entry/link
scripts/tests/m2/ M2 gates (run.sh, dis-roundtrip.sh)
scripts/tests/m2/diff/ TI golden asm and the differential ABI check
scripts/tests/m3/ M3 gates (run.sh, dis-roundtrip.sh, layout.sh)
scripts/tests/m3/diff/ TI branch/Xlib goldens and the differential check
scripts/m1/ M1 harness (historical; also on branch `m1`, not an M2 gate)
scripts/tests/m1/ M1 gates (historical; also on branch `m1`, not an M2 gate)
scripts/tests/m0/ M0 integration tests
scripts/build-llvm.sh fork or upstream+patches build
scripts/fetch-vt1200.sh fetch the VT1200 RE tree at its pinned commit
spike/ measurement corpus and reports
symbols/ per-ROM import tables (v2.2 subset)
vendor/ fetched assets and LLVM sources (ignored)
docs/ design and plans
Prerequisites
cmake, ninja-build, ccache, a C++17 host compiler (GCC 14 is used here),
Python 3, and about 20 GB of disk for the LLVM source and build. The M2/M3 MAME
and dis34 gates additionally need dosbox, mtools (mcopy), the VT1200 SDK
image, the dwt COFF loader, dis34, and a built MAME vt1200 binary
(build/mame-src/vt1200). The differential golden check needs no
DOSBox/MAME/SDK; it needs only the built clang, opt, llc and
tools/tms-bitaddr/tms-bitaddr. On Debian:
sudo apt-get install -y cmake ninja-build ccache dosbox mtools
Fetch the VT1200 assets
The compiler contract is checked against the VT1200 tree (TI hello.s, the
Xlib call sequence, as34, dis34, SDKs, manuals). It is not vendored:
./scripts/fetch-vt1200.sh # -> vendor/vt1200, pinned 17dfd24...
Build the compiler
Compiler fork (M3, the normal path)
The fork checkout already exists at build/llvm-clean on m3. Build it in
place:
LLVM_SRC=$PWD/build/llvm-clean LLVM_BRANCH=m3 ./scripts/build-llvm.sh
# binaries: build/llvm-clean-build/bin/{llc,clang,opt,llvm-config,FileCheck}
# lit driver: build/llvm-clean-build/bin/llvm-lit
To clone the fork first instead of using an existing checkout:
LLVM_REPO=gitea@git.wut.ee:opencode.siil.arti.ee/llvm-tms34010.git \
LLVM_BRANCH=m3 ./scripts/build-llvm.sh
Fork mode never checks out, resets, cleans or patches the source tree; it
only runs CMake and Ninja. Local commits on the branch are safe. Use
LLVM_BUILD=... to keep a separate build directory. It builds llc, clang,
opt, llvm-config, FileCheck and the test helpers with X86 (host) and
TMS34010; a first build takes roughly 20–40 minutes.
Upstream + patches (frozen M0 path)
./scripts/build-llvm.sh
This mode clones llvmorg-23.1.5, applies patches/llvm/*.patch, and builds
build/llvm-clean-build. It owns and resets only a plain llvm-project clone:
if build/llvm-clean has some other origin (for example the M2/M3 fork), it
refuses rather than destroying it. Use fork mode for that checkout.
scripts/export-patches.sh regenerates the committed M0 patch series with
git diff (including new files via git add -N); it is not used for M1/M2/M3.
Build the M0 support tools
The M2/M3 pipeline uses tms-bitaddr and tms-clang:
LLVM_CONFIG=$PWD/build/llvm-clean-build/bin/llvm-config \
tools/tms-bitaddr/build.sh
M0 regression test (optional in M2/M3):
TMS_CLANG=$PWD/build/llvm-clean-build/bin/clang \
TMS_OPT=$PWD/build/llvm-clean-build/bin/opt \
TMS_BITADDR=$PWD/tools/tms-bitaddr/tms-bitaddr \
./scripts/tests/m0/run.sh
# -> M0 pipeline: PASS
Emit assembly by hand
TMS_CLANG=$PWD/build/llvm-clean-build/bin/clang \
TMS_OPT=$PWD/build/llvm-clean-build/bin/opt \
TMS_BITADDR=$PWD/tools/tms-bitaddr/tms-bitaddr \
scripts/tms-clang -O2 -o /tmp/add2.ll scripts/tests/m2/diff/ti-add2.c
build/llvm-clean-build/bin/llc -march=tms34010 -O0 -filetype=asm \
/tmp/add2.ll -o /tmp/add2.s
The resulting function loads its arguments at FP(-32) and FP(-64), returns
in A8, and ends in RETS 2. Always produce the IR with scripts/tms-clang
(or run tms-bitaddr --scale) before llc: raw clang IR carries byte GEP
offsets, but llc treats every surviving GEP offset as a bit offset, so
addresses would be 8× too small. Run llc -O0 on the normalized IR that
tms-clang already optimized and scaled. tms-bitaddr --normalize consumes
that scaled IR and rejects a typed (non-i8) or vector GEP instead of emitting
its byte offset as bits. The lit tests use
llc -march=tms34010 -O2 -filetype=asm on hand-written IR to exercise the
selector pipeline directly. Output is assembly only: llc cannot emit an
object file or a usable COFF for this target.
Later milestones
M4 adds the real Xlib client (XImage/XEvent, the full X11R4 headers),
structs by value and function pointers. M5 adds the native COFF path, floats,
varargs, bitfields, long long and the JR/branch-relaxation code-size pass;
2ine r5.01 is a later speed-up for the GSPA/GSPLNK step.