reference: TMS34010 SDK investigation; prove GSPA round trip

The 1987 DOS toolchain (GSPA/GSPLNK/GSPROM) runs under DOSBox and
reassembles 32 KB of the v2.2 ROM byte-for-byte after five documented
fixups to smart34010 output.  The 1990 r5.01 tools are OS/2 NE and do
not run in DOS.  Adds the converter, round-trip script, and the
DOSBox/dosemu benchmark.
This commit is contained in:
SDD committed 2026-10-18 00:39:32 +03:00
1 parent 960b256b00
commit 7d299451da
8 files changed
+923

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# extracted SDK material and run products — never staged
work/
cgtools/
sdb/
disk1987/
disk1985asm/
disk1985demo/
os2test/
bench/
dosemu-home/
*.bin
*.obj
*.out
*.map
*.lst
*.hex
*.tag
*.HI
*.LO
*.$$$
# local intermediate text/disassembly
*.txt
*.asm
bench.py
__pycache__/
*.pyc
+47
View File
@@ -0,0 +1,47 @@
# SDK scratch: the 1987 TMS34010 DOS toolchain
This directory is a scratch area for the investigation in
`reference/tms34010-sdk.md`. The SDK under `tools/tms34010-sdk/` is only read;
everything here is extracted work product.
## Round trip
```sh
./roundtrip.sh 0x1000 32768 3F208000
```
`OFFSET SIZE BASE_BIT`: disassemble that ROM region with
`tools/smart34010/dis34 -o`, convert it to GSPA syntax with `dis2as34.py`,
assemble/link/convert with the 1987 DOS tools under DOSBox, interleave the
high/low Intel-hex files, and diff against the ROM. It prints `BYTE-EXACT`
and the md5 of the reassembled region on success.
The tools are extracted on first use from:
```
tools/tms34010-sdk/1987-05 TMS34010 SDK/
1987-11-06 TMS34010 Simulator-C Tools-ASM Tools.img
```
into `work/` (A2 = assembler/linker, C2 = C compiler/runtime). The ROM is
never written.
## Benchmark
```sh
python3 -u bench2.py # DOSBox vs dosemu, 3 runs, two workloads
```
`bench2.py` uses `dosbox-bench.conf` (dynamic core, `cycles=max`) and a private
dosemu `HOME` in `dosemu-home/`. The dosemu batch must end in `exitemu`;
`-E` alone leaves DOS running. Results are in the report.
## Notes
* `dis2as34.py` needs the `-o` opcode comments from `dis34`; they are what
disambiguate `MOVI` short/long, long relative `JR` vs absolute `JA`, and the
B-file `SP`.
* `gsprom -i` writes two files (high and low byte); interleave low then high.
Its record addresses are 16-bit word indices, not bit addresses.
* DOSBox 8.3 names are uppercased; check for `RT.OBJ`, not `rt.obj`. dosemu's
`lredir` keeps lowercase names.
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""DOSBox vs dosemu on the real workload: GSPA assembling TMS34010 source.
Workload A: gspa t1 t1.obj t1.lst -l (4 KB region, listing)
Workload B: gspa -q rt16 rt16.obj (16 KB region, no listing)
Both headless: DOSBox with SDL dummy drivers and core=dynamic/cycles=max;
dosemu in -t (terminal/S-Lang) mode. Three measured runs each, plus one
warm-up. Raw COFF md5 differs only in the 4-byte time stamp, so a
timestamp-normalised md5 is also reported.
"""
import hashlib
import os
import statistics
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BENCH = ROOT / "bench"
DHOME = ROOT / "dosemu-home"
DBENCH = DHOME / ".dosemu" / "drive_c" / "bench"
RUNS = 3
def norm_md5(p):
if not p.exists():
return "-"
b = bytearray(p.read_bytes())
if len(b) >= 8:
b[4:8] = b"\0\0\0\0"
return hashlib.md5(bytes(b)).hexdigest()
def raw_md5(p):
return hashlib.md5(p.read_bytes()).hexdigest() if p.exists() else "-"
def clean(d, base):
for name in (base + ".OBJ", base + ".LST", "MACLIB.$$$",
base.lower() + ".obj", base.lower() + ".lst", "maclib.$$$"):
f = d / name
if f.exists():
f.unlink()
def run_dosbox(base, args):
clean(BENCH, base)
cmds = []
for _ in range(1):
cmds += ["-c", "gspa " + args]
env = dict(os.environ, SDL_VIDEODRIVER="dummy", SDL_AUDIODRIVER="dummy")
t0 = time.perf_counter()
rc = subprocess.run(
["timeout", "120", "dosbox", "-conf", str(ROOT / "dosbox-bench.conf"),
"-noprimaryconf", "-nolocalconf",
"-c", "mount c " + str(BENCH), "-c", "c:"] + cmds +
["-c", "exit", "-exit"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode
dt = time.perf_counter() - t0
# DOSBox uppercases 8.3 names
obj = BENCH / (base.upper() + ".OBJ")
return dt, rc, raw_md5(obj), norm_md5(obj), obj.stat().st_size if obj.exists() else 0
def run_dosemu(base, args, bat):
clean(DBENCH, base)
script = "@echo off\r\nc:\r\ncd \\bench\r\ngspa %s\r\nexitemu\r\n" % args
(DBENCH / bat).write_bytes(script.encode())
env = dict(os.environ, HOME=str(DHOME))
t0 = time.perf_counter()
rc = subprocess.run(
["timeout", "120", "dosemu", "-t", "-quiet", "-E", r"c:\bench" + "\\" + bat],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode
dt = time.perf_counter() - t0
# dosemu/lredir keeps the lowercase names written by FreeCom
obj = DBENCH / (base.lower() + ".obj")
if not obj.exists():
obj = DBENCH / (base.upper() + ".OBJ")
return dt, rc, raw_md5(obj), norm_md5(obj), obj.stat().st_size if obj.exists() else 0
def bench(name, base, args, bat):
print("\n== %s == gspa %s" % (name, args))
run_dosbox(base, args) # warm-up
run_dosemu(base, args, bat)
results = {}
for label, fn in (("dosbox", lambda: run_dosbox(base, args)),
("dosemu", lambda: run_dosemu(base, args, bat))):
rows = [fn() for _ in range(RUNS)]
results[label] = rows
for dt, rc, raw, norm, size in rows:
print(" %-7s %6.2fs rc=%d size=%d raw=%s norm=%s" %
(label, dt, rc, size, raw[:8], norm[:8]))
print(" %-7s median %.2fs" % (label, statistics.median(r[0] for r in rows)))
n0 = {r[3] for r in results["dosbox"]}
n1 = {r[3] for r in results["dosemu"]}
print(" normalised object identical: %s (%s)" % (n0 == n1 and len(n0) == 1, n0))
if __name__ == "__main__":
bench("A: 4 KB with listing", "t1", "t1 t1.obj t1.lst -l", "bench.bat")
bench("B: 16 KB, no listing", "rt16", "-q rt16 rt16.obj", "bench16.bat")
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Convert smart34010 (dis34 -o) output into TI gspa/as34 2.00 syntax.
Usage: dis2as34.py BASE_HEX SIZE_BYTES INPUT.ASM > OUT.ASM
The input must be produced with ``dis34 -o`` (opcode words in a trailing
comment) because the 1987 assembler is *not* byte-faithful on its own:
* ``MOVI`` is one mnemonic with a short (16-bit, sign-extended) and a long
(32-bit) form. gspa auto-selects by "does the value fit in 16 bits", so a
long MOVI of 0x53E0 (which the ROM encodes as a 3-word instruction) is
silently shortened -- and sign extension then changes its meaning. The
explicit ``W`` / ``L`` operand forces the form.
* the same applies to ADDI/CMPI/SUBI.
* dis34 prints a *long relative* JR (16-bit displacement) as ``ja<cc>``, the
same mnemonic it uses for the 3-word absolute JA. The opcode word count
disambiguates: 2 words = long JR, 3 words = absolute JA.
* dis34 prints B-file register 15 as ``sp`` too; gspa's bare ``sp`` is the
A-file register. B-file SP has to be written ``b15`` (the assembler rejects
``a15``).
* register-indirect displacements are signed 16-bit fields. dis34 prints the
raw two's-complement bits (0xFFE0); gspa rejects those as out of bounds, so
they are rendered as signed decimal (-32).
* constants: dis34 emits ``0xHHHH``; gspa wants ``0HHHHh`` (must start with a
decimal digit, so 0xC00000B0 becomes 0C00000B0h).
* a PC-relative branch whose target is not an instruction boundary in the
region (outside it, or -- as happens in data tables -- inside a multi-word
instruction) cannot be expressed as a label. gspa also mishandles a
negative absolute ``.set`` target in a JR. The exact opcode words are
therefore emitted as ``.word`` for those branches.
"""
import re
import sys
DISP = re.compile(r"(\*\s*[ab]\d+|\*\s*sp)\(\s*0x([0-9a-fA-F]+)\s*\)")
HEX = re.compile(r"0x([0-9a-fA-F]+)")
FORM_OPS = {"movi", "addi", "cmpi", "subi"}
RELATIVE = re.compile(r"^(jreq|jrne|jrlt|jrle|jrgt|jrge|jrc|jrnc|jrlo|jrls|jrhi|jrhs|jrnz|jrz|"
r"jruc|jrxn|jrxnn|jrxz|jrxeq|jrxgt|jrxle|jryn|jrynn|jryz|jryeq|jrygt|jryle|"
r"call|callr)$")
DSJ_OPS = {"dsj", "dsjs", "dsjeq", "dsjne"}
def ti_hex(digits: str) -> str:
return "0" + digits.upper() + "h"
def fix_disp(m):
base, digits = m.group(1), m.group(2)
v = int(digits, 16)
if v >= 0x8000:
return f"{base}({v - 0x10000})"
return f"{base}(0{digits.upper()}h)"
def raw_words(words):
return " .word " + ", ".join(ti_hex(w) for w in words)
def convert(src: str, base: int, size: int) -> str:
end = base + size * 8
lines = src.splitlines()
defined = set()
for line in lines:
code = line.partition(";")[0]
dm = re.match(r"^(L[0-9A-Fa-f]{8}):", code)
if dm:
defined.add(dm.group(1).upper())
out = []
referenced = set()
for line in lines:
code, sep, comment = line.partition(";")
for lm in re.findall(r"\b(L[0-9A-Fa-f]{8})\b", code):
referenced.add(lm.upper())
words = re.findall(r"\b[0-9a-fA-F]{4}\b", comment)
stripped = code.rstrip()
# long relative JR printed as JA (see module docstring)
mm = re.match(r"^(\s*)ja([a-z]+)(\s+)(\S.*)$", stripped)
if mm and len(words) == 2:
stripped = f"{mm.group(1)}jr{mm.group(2)}{mm.group(3)}{mm.group(4)}"
parts = stripped.split()
op = parts[0].lower() if parts else ""
# B-file SP (see docstring)
if op == "rl" and words and stripped.endswith(", sp"):
if int(words[0], 16) & 0x1f == 0x1f:
stripped = stripped[:-3] + "b15"
# short/long immediate forms (see docstring)
if op in FORM_OPS and words:
if len(words) == 2:
stripped += ", W"
mm = re.match(r"^(\s*\w+\s+)0x([0-9a-fA-F]+)(,.*)$", stripped)
if mm and int(mm.group(2), 16) >= 0x8000:
stripped = (mm.group(1) + str(int(mm.group(2), 16) - 0x10000)
+ mm.group(3))
elif len(words) == 3:
stripped += ", L"
# relative branch to a label that is not an instruction boundary
mm = re.match(r"^(\s*)(\w+)(\s+.*?)(L[0-9A-Fa-f]{8})\s*$", stripped)
if mm and mm.group(4).upper() not in defined:
op2 = mm.group(2).lower()
if op2 in DSJ_OPS or RELATIVE.match(op2):
stripped = raw_words(words)
# relative branch to an absolute address outside the region
mm = re.match(r"^(\s*)([a-z]+)(\s+)0x([0-9a-fA-F]+)\s*$", stripped)
if mm and RELATIVE.match(mm.group(2).lower()):
target = int(mm.group(4), 16)
if not (base <= target < end):
stripped = raw_words(words)
stripped = DISP.sub(fix_disp, stripped)
stripped = HEX.sub(lambda mm: ti_hex(mm.group(1)), stripped)
out.append(stripped)
missing = sorted(referenced - defined)
if missing:
out.append("")
for name in missing:
off = int(name[1:], 16) - base
out.append(f"{name} .set " + ("0%Xh" % off if off >= 0 else str(off)))
return "\n".join(out) + "\n"
if __name__ == "__main__":
base = int(sys.argv[1], 16)
size = int(sys.argv[2])
sys.stdout.write(convert(open(sys.argv[3]).read(), base, size))
+17
View File
@@ -0,0 +1,17 @@
[sdl]
fullscreen=false
autolock=false
output=surface
[dosbox]
machine=svga_s3
memsize=16
[cpu]
core=dynamic
cycles=max
[mixer]
nosound=true
[autoexec]
+17
View File
@@ -0,0 +1,17 @@
[sdl]
fullscreen=false
autolock=false
output=surface
[dosbox]
machine=svga_s3
memsize=16
[cpu]
core=normal
cycles=3000
[mixer]
nosound=true
[autoexec]
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
# Assemble a region of the VT1200 ROM with the 1987 TI TMS34010 toolchain
# (GSPA/GSPLNK/GSPROM, run under DOSBox) and compare the result byte-for-byte
# with the original.
#
# Usage: roundtrip.sh OFFSET SIZE BASE_BIT
# OFFSET byte offset into vt1200_v22.bin
# SIZE region size in bytes
# BASE_BIT bit address the region is linked at (e.g. 3F208000)
#
# Example: roundtrip.sh 0x1000 16384 3F208000
#
# The ROM is only read. All work happens in this directory.
set -eu
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
DIR=$(cd "$(dirname "$0")" && pwd)
WORK="$DIR/work"
ROM="$ROOT/vt1200_v22.bin"
DIS="$ROOT/tools/smart34010/dis34"
OFF=${1:?offset}
SIZE=${2:?size}
BASE=${3:?base bit address}
OFF=$((OFF))
SIZE=$((SIZE))
mkdir -p "$WORK"
# tools: extract once from the 1987-11-06 floppy image
if [ ! -x "$WORK/GSPA.EXE" ]; then
IMG="$ROOT/tools/tms34010-sdk/1987-05 TMS34010 SDK/1987-11-06 TMS34010 Simulator-C Tools-ASM Tools.img"
mcopy -s -n -i "$IMG" "::/A2/*" "$WORK/"
mcopy -s -n -i "$IMG" "::/C2/*" "$WORK/"
fi
# TI hex constants must begin with a decimal digit
TBASE=$BASE
case "$TBASE" in
[0-9]*) ;;
*) TBASE="0$TBASE" ;;
esac
dd if="$ROM" of="$WORK/rt.bin" bs=1 skip="$OFF" count="$SIZE" 2>/dev/null
"$DIS" -b "$BASE" -o "$WORK/rt.bin" > "$WORK/rt.dis"
python3 "$DIR/dis2as34.py" "$BASE" "$SIZE" "$WORK/rt.dis" > "$WORK/RT.ASM"
sed -i '1i .text' "$WORK/RT.ASM"
echo '.end' >> "$WORK/RT.ASM"
cat > "$WORK/RT.CMD" <<EOF
-a
-o rt.out
-m rt.map
rt.obj
SECTIONS
{
.text ${TBASE}h: {}
}
EOF
sed -i 's/$/\r/' "$WORK/RT.CMD"
rm -f "$WORK"/RT.OBJ "$WORK"/RT.OUT "$WORK"/RT.LST "$WORK"/RTHI.HI "$WORK"/RTLO.LO
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
timeout 180 dosbox -conf "$DIR/dosbox.conf" -noprimaryconf -nolocalconf \
-c "mount c $WORK" -c "c:" \
-c "gspa rt rt.obj rt.lst -l" \
-c "gsplnk rt.cmd" \
-c "gsprom -i rt.out rthi rtlo" \
-c "exit" -exit >/dev/null 2>&1
python3 - "$WORK" "$SIZE" <<'EOF'
import sys
work, size = sys.argv[1], int(sys.argv[2])
def readhex(p):
d = {}
for line in open(p):
line = line.strip()
if not line.startswith(':'):
continue
b = bytes.fromhex(line[1:])
if b[3] != 0:
continue
a = (b[1] << 8) | b[2]
for i, x in enumerate(b[4:4 + b[0]]):
d[a + i] = x
return d
hi = readhex(work + "/RTHI.HI")
lo = readhex(work + "/RTLO.LO")
out = bytearray()
for a in sorted(hi):
out.append(lo[a])
out.append(hi[a])
orig = open(work + "/rt.bin", "rb").read()
n = min(len(orig), len(out))
bad = [i for i in range(n) if orig[i] != out[i]]
print("assembled %d bytes, original %d bytes, %d differing" % (len(out), len(orig), len(bad)))
if len(out) != len(orig) or bad:
print("FAIL")
for i in bad[:5]:
print(" %#x: orig %s out %s" % (i, orig[i:i+8].hex(), out[i:i+8].hex()))
sys.exit(1)
print("md5 " + __import__('hashlib').md5(out).hexdigest() + " BYTE-EXACT")
EOF
+482
View File
@@ -0,0 +1,482 @@
# The TMS34010 SDK in `tools/tms34010-sdk`: what runs, and what it buys us
Investigated 2026-10-18. Short version:
* The **1987 DOS toolchain runs** under DOSBox (and dosemu) on Linux: GSPA
(assembler), GSPLNK (linker), GSPROM (hex converter), GSPAR (archiver), and
the K&R C compiler (GSPCPP/GSPCC/GSPCG).
* `smart34010` output **does** assemble with it, but not literally: five small
fixups are needed. With them, a **32 KB region of the v2.2 ROM reassembles
byte-for-byte**, md5-identical to the original. See the results table.
* The **1990 r5.01 tools are OS/2 1.x NE binaries**, not DOS. They do not run in
DOSBox; using them would need an OS/2 environment. The 1987 DOS tools are the
usable set.
* There is **no `as34` / `lnk34`** in this SDK. The assembler is `GSPA.EXE` and
the linker `GSPLNK.EXE` (the names `smart34010`'s README mentions are from a
later TI package). GSPA 2.00 is enough for the round trip.
* The C compiler works and links with `rts.lib` into an absolute COFF image.
It is **K&R C, not ANSI**: `int f(int a)` is a syntax error, `int f(a) int a;`
works.
* For a VT1200 payload the useful part is the **address model**: the linker's
`SECTIONS` origin is a **bit address**, the COFF carries the full 32-bit bit
address, and the bytes are extracted from GSPROM's high/low EPROM files. The
old converter's own hex addresses wrap at 64 KB and must not be used for
placement.
Everything below was run against the ROM with md5
`b46a3e442ccb86134d2356bd70d22fd2`; the ROM was only read.
---
## 1. What is in the SDK, and what actually executes
| package | files | host | runs here? |
|---|---|---|---|
| `1985-05-20 … Assembly Language Package` | GSPA/GSPLNK/GSPAR **v1.04** | DOS MZ | yes, but older |
| `1987-11-06 … Simulator-C Tools-ASM Tools.img` | GSPA **2.00**, GSPLNK **2.00**, GSPAR 2.00, GSPROM **2.01**, GSPCV 2.00 (A2); GSPCPP/GSPCC/GSPCG **2.01**, RTS.LIB, FLIB.LIB (C2) | DOS MZ | **yes — the set used here** |
| `1990-11-19 … Code Generation Tools r5.01` | gspa, gspac, gspar, gspcg, gspcl, gsplnk, gspopt, gsprom, clist | **OS/2 1.x NE** | **no** (DOSBox cannot run NE/OS2) |
| `1990-11-19 … C Source Debugger r5.00` | db340.exe (DOS MZ), gspmon | DOS + target monitor | debugger runs, but needs a monitor/target link |
| `1991-07 TIGA Promo Kit` | DOS art/demo disks, TIGA docs | DOS | not relevant to VT1200 |
| `Docs/` | Users Guide, Assembly Tools Guide, C Compiler Guide, reference cards, TIGA, production data | PDF | reference only |
| `Patents/` | five graphics-architecture patents | PDF | not relevant |
The 1990 NE files were checked both with `file` and by parsing the NE header:
signature `NE`, version 5.1, **target OS byte `0x01` = OS/2**. The 1985/1987
files are plain `MZ for MS-DOS`, 16-bit real mode, and need at most 640 KB.
The **1987 disk is the sweet spot**: v2.00 is the version whose tools guide
documents the `W`/`L` immediate-form operands and the `.usect`/`.if` features
used below. The 1985 v1.04 tools are older and were not needed.
### Running them headless
DOSBox 0.74-3 works with the SDL dummy drivers; a bare `-conf` file with
`core=dynamic` / `cycles=max` makes it ~5× faster than the `cycles=3000` config
used earlier. The commands in `roundtrip.sh` show the exact form.
dosemu 1.4.0.8 also works: `dosemu -t -quiet -E 'c:\bench\bench.bat'`, where the
batch ends in `exitemu`. `-E` alone does not exit DOS. See the benchmark
section.
---
## 2. The round-trip experiment (the proof)
### Method
For a region at file offset `OFF`, size `SIZE`, linked at bit address `BASE`:
1. `dis34 -b BASE -o region.bin > region.dis` — disassemble with the opcode
words in a trailing comment (`-o` is required; the fixups use them).
2. `dis2as34.py BASE SIZE region.dis > RT.ASM` — apply the five fixups
(section 3), prepend `.text`, append `.end`.
3. Assemble and link:
```
gspa rt rt.obj rt.lst -l
gsplnk rt.cmd
gsprom -i rt.out rthi rtlo
```
with `rt.cmd`:
```
-a
-o rt.out
-m rt.map
rt.obj
SECTIONS
{
.text 3F208000h: {}
}
```
The origin is a **bit address**. `-a` makes an absolute module; `-m` writes a
map; `-e` would set an entry point.
4. `gsprom -i` writes **two** Intel-hex files (high byte and low byte for a
16-bit EPROM pair). Interleave **low byte then high byte** per word.
5. Compare with `dd`-extracted original bytes.
`roundtrip.sh` does exactly this and prints the md5.
### Results
| region | file offset | base (bit) | size | result |
|---|---|---|---|---|
| boot/POST code | `0x1000` | `3F208000` | 4 KB | **0 differing**, md5 `a4018fed12e062658bc81957f149265b` |
| same, larger | `0x1000` | `3F208000` | 16 KB | **0 differing**, md5 `0a2e679bc864d52d32c59740142c5b96` |
| same, larger | `0x1000` | `3F208000` | **32 KB** | **0 differing**, md5 `43adb6669571041985f3e26fd9ff7cb9` |
| another code region | `0x8000` | `3F240000` | 4 KB | **0 differing**, md5 `438e743431d56e0c12ededce1c36952a` |
The ROM md5 after all runs is unchanged: `b46a3e442ccb86134d2356bd70d22fd2`.
A 64 KB region trips `dis34` itself (`unexpected end of input at byte 65536`);
split larger spans into ≤32 KB pieces. This is a disassembler limitation, not an
assembler one.
### What this proves, and what it does not
It proves the assembler accepts the disassembler's instruction syntax and
reproduces the **exact instruction encodings** for 32 KB of real firmware,
including PC-relative branches, absolute calls, data words, and immediate
forms. It does **not** prove the payload runs on a VT1200; nothing here was
executed on the target.
---
## 3. The five fixups (and why they are needed)
`dis34` output is *close* to GSPA syntax but not directly assemblable. The
converter `harness/sdk-scratch/dis2as34.py` is the executable form of this list.
1. **Hex constants.** GSPA has no `0x`: a hex constant must start with a decimal
digit and end in `h`. `0x220` → `0220h`; `0xC00000B0` → `0C00000B0h` (nine
digits are accepted and truncated to 32 bits). GSPCV does the same conversion
for the old `>xxxx` syntax.
2. **`MOVI` short vs long.** GSPA has one `MOVI` mnemonic with a 16-bit
sign-extended form and a 32-bit form, and auto-selects by "does the value fit
in 16 bits (unsigned)". That is **not** encoding-preserving: a long
`MOVI 0x53E0` (three words) becomes a short `MOVIW` (two words), and the
short form sign-extends to `0xFFFF53E0`. Force the form with the third
operand: `movi 053E0h, sp, L` or `..., W`. The opcode word count in the `-o`
comment tells which form the ROM used. The same applies to `ADDI`, `CMPI`,
and `SUBI`.
3. **B-file `SP`.** `dis34` prints register 15 as `sp` regardless of file.
Bit 4 of the encoded register field says which file: `0x0F` = A-file SP,
`0x1F` = B-file SP. GSPA's bare `sp` matched the ROM for most forms (memory
operands such as `*sp(offset)` assembled as B15; `MOVI`/`MMFM` register
operands as A15), but an `RL` destination chose A15 where the ROM has B15
(0x312F vs 0x313F). `b15` forces the B-file register; `a15` is rejected.
Check the register field, not the printed name.
4. **Long relative `JR` printed as `JA`.** In `dis34`, the 2-word relative jump
(opcode low byte `0x00`, 16-bit displacement) is printed with the `ja<cc>`
mnemonic, the same one used for the 3-word absolute `JA`. The word count
distinguishes them: 2 words → rewrite to `jr<cc>`, 3 words → leave `ja<cc>`.
Without this, every long relative branch grows by a word and the image
diverges.
5. **Branches whose target is not an instruction boundary.** This happens for
targets outside the disassembled region (common) and, in data-table areas,
for targets inside a multi-word instruction. GSPA also mishandles a negative
absolute `.set` value in a `JR` (it emits the absolute `JA` form and errors).
Emit the exact opcode words from the `-o` comment as `.word` for those
branches. For `DSJS`, whose short range is only ±32 words, this is the only
reliable way.
One practical note: with the slow `cycles=3000` DOSBox config, a 16 KB source
with `-l` takes about 35 s wall time; a 32 KB source takes about 1.5–2 minutes.
That is DOSBox being cycle-limited, not the assembler hanging. The benchmark
section uses `core=dynamic`/`cycles=max`, which removes most of it.
---
## 4. Addresses, sections, and output formats
### The unit is bits
The TMS34010 is bit-addressed. In this toolchain:
* The assembler's location counter and labels are in **bits**; each instruction
word advances it by 16. A listing address of `00000030` is 3 words in.
* `SECTIONS` binding is a **bit address**: `.text 3F208000h: {}` produced
`.text 0 3F208000 00020000` in the map (origin bit, length bits).
* The linker's `MEMORY` origins/lengths are bits too.
* The COFF output carries the true 32-bit bit address. In `RT16.OUT` the
optional header says `text = 0x3F208000`, `code size = 0x20000` bits, and the
`.text` section header says `paddr = vaddr = 0x3F208000`, `size = 0x20000`
bits. The file header magic is `0x0090`, flags `0x103` (including `0x40`,
the TMS34010 little-endian "16 bits per word, LSB first" flag).
* To place a payload at bit address `A`, link `.text A: {}` and put file byte
`i` at physical byte address `A>>3 + i`. Do not re-link at a different address
than the load address: the C compiler emits `CALLA` and absolute pointers.
### GSPROM's hex addresses are not the load address
`gsprom -i in out_hi out_lo` produces two 8-bit Intel-hex files, one high byte
and one low byte per 16-bit word. The address in those records is a **16-bit
word index inside a 64 KB byte window**, not the bit address and not the full
byte address. Measured:
| section bit origin | first record address |
|---|---|
| `0x00020000` | `0x2000` |
| `0x3F208000` | `0x0800` |
| `0x3F240000` | `0x4000` |
That matches `((bit >> 3) & 0xFFFF) >> 1`. The data order is still correct, so
take the byte stream in order and place it at the address from the map/COFF.
`gsprom -t` (TI-tagged) has the same 16-bit address limitation.
For a loader, the clean source of truth is the **COFF** (`-a` output): section
`paddr`/`vaddr` and the optional header's entry point are full 32-bit bit
addresses. A small COFF reader can emit a raw image with its true load address;
GSPROM is only a convenience for an EPROM programmer.
### Linker command syntax gotchas
* Comments are `/* … */`; `;` is a syntax error in a command file.
* `.text > prog` is a syntax error. Use either
`.text 3F208000h: {}` (bind directly) or `.text: {} > prog` (named memory).
* `-a` = absolute executable, `-m` = map, `-o` = output, `-e` = entry symbol,
`-l lib` = library. `-c` / `-cr` select the C ROM/RAM autoinit models.
---
## 5. The C compiler
It runs, and the whole pipeline works:
```
gspcpp cmain # .c -> .cpp
gspcc cmain # .cpp -> .if
gspcg cmain # .if -> .asm
gspa cmain # .asm -> .obj
gsplnk cmain.cmd # .obj + rts.lib -> .out, map
gsprom -i cmain.out …
```
A test with `fib` and `main` linked cleanly with:
```
-c
-o cmain.out
-m cmain.map
cmain.obj
-l rts.lib
MEMORY
{
prog: origin = 3F208000h, length = 10000h
ram: origin = 0h, length = 100000h
}
SECTIONS
{
.text: {} > prog
.cinit: {} > prog
.data: {} > prog
.bss: {} > ram
}
```
The map confirms:
```
ENTRY POINT SYMBOL: "_c_int00" address: 3F2082F0
.text 3F208000 00000A90
.cinit 3F208A90 00000060
.bss 00000000 0000FE40 (uninitialized)
```
So the compiler, assembler, runtime library, linker, and converter form a
complete C path. **It was not executed on a VT1200.**
### Language and ABI facts worth knowing
From `SPVU005A` and the generated code:
* **K&R C only.** ANSI prototypes (`int f(int a)`) are a syntax error;
`int f(a) int a; { … }` works. Plan for old-style declarations.
* `char` 8 bits, `short` 16, **`int` and `long` 32**, pointers **32-bit bit
addresses**, `float` 32, `double` 64.
* Alignment: objects are on 16-bit boundaries (bit addresses with four LSBs
zero); structure/array members are packed.
* Dedicated registers: `SP` = system stack, `A14` (`STK`) = program stack,
`A13` (`FP`) = frame pointer; `A8` = integer/pointer return value. `B0–B14`
are unused by the compiler and available to assembly. Callee saves the A
registers it uses except `A8`; `FS1` must be 32 inside C code.
* Arguments are pushed on the **program stack** in reverse order, widened to
32 bits; calls are `CALLA` (absolute), so **code is position-dependent**.
* Runtime: link `rts.lib` with `-c` (ROM autoinit; `boot.obj` is pulled in and
`_c_int00` becomes the entry) or `-cr` (loader does the `.cinit` copy). The
boot code allocates `SYS_STACK` in `.bss`, sets `FP`/`STK`/`SP`, calls
`_main`, then `_exit`.
* C sections: `.text` (code + string literals + float constants), `.cinit`
(initialized-global table), `.bss` (globals + both stacks + heap). `.data` is
the assembler's, not the C compiler's. `.bss` must be RAM.
For a payload, the simplest C model is: link `.text`/`.cinit` at the load
address, put `.bss` in a free RAM range, and enter at `_c_int00`. A pure-assembly
payload avoids the runtime entirely.
---
## 6. Building a VT1200 payload: practical recipe
Assembly-only payload (smallest, no runtime):
```
.text
start:
movi 01234h, a0
move a0, @0C0000010h, 0 ; example on-chip register
rets
.end
```
Link command:
```
-a
-e start
-o payload.out
-m payload.map
payload.obj
SECTIONS
{
.text 02000000h: {} /* example: bit 0x02000000 = byte 0x400000, in RAM */
}
```
Then either read `payload.out` as COFF (true bit address in the section header)
or `gsprom -i payload.out hi lo`, interleave low/high, and place the bytes at
`0x02000000 >> 3 = 0x400000`.
The RAM region is `0x0000000`–`0x1FFFFFF` (4 MiB) and the ROM is
`0x3F200000`–`0x3FFFFFFF` in the MAME model; choose a free RAM range for a
payload, or load into RAM and jump. The SDK supplies the image and its entry
point, **not** the network delivery mechanism. Delivery is a separate problem:
the COFF gives a loader the exact bit address and entry point; GSPROM's hex is
only for burning EPROMs.
For C, use the same idea with `.text`/`.cinit` at the payload address and
`.bss` in RAM; `-c` will include `boot.obj` and make `_c_int00` the entry.
---
## 7. Documentation: what the SDK adds over the repo
The repo already has `docs/TMS34010-users-guide.pdf`/`.txt` (the 1988 hardware
user's guide). The SDK's unique, useful additions are:
* **`SPVU004A TMS34010 Assembly Language Tools Users Guide (1987).pdf`** — the
key document: assembler directives and expression rules, the COFF appendix
(file/section/relocation/symbol formats), linker `MEMORY`/`SECTIONS`,
`gsprom`, and the simulator. This is where the bit-address and `W`/`L`
behaviour is specified.
* **`SPVU004A … Assembly Language Tools Reference Card (1987).pdf`** — compact
invocation/option/directive summary; the fastest lookup.
* **`SPVU005A TMS34010 C Compiler Reference Guide (1988-08).pdf`** — data
types, ABI, runtime environment, library reference. The ABI section is what
makes C/assembly interworking possible.
* **`SPVU005A … C Compiler Reference Card (1988-08).pdf`** — quick ABI/options.
* `SPVU005 TMS34010 C Compiler Users Guide (1986-12).pdf` — older; the 1988
guide supersedes it.
* `SPVU002A Software Development Board Users Guide` + pocket reference — the
SDB and its monitor; useful only if you have that board.
* `SPVU021A C Source Debugger Users Guide (1991-09)` — the `db340`/`gspmon`
monitor workflow; not directly applicable to the VT1200 unless a compatible
monitor can be loaded.
* `SPVU006 Math Graphics Function Library` and the 1987 `Graphics Math Function
Library` disk (`GRAFIX.LIB`, `TEXT.LIB`, `MATH.LIB` + sources) — optional
drawing primitives; not evaluated against the VT1200's mono framebuffer.
* `SPVS002A/C` production data — hardware electrical/timing detail; the repo's
user's guide covers the programming model.
* `SPVU015A/C` TIGA, `SPVU018A` TIGA art, `SPVU027` graphics library — TIGA is
a PC graphics-interface standard, not the VT1200 terminal; not relevant.
* `Docs/Video Interfaces/*` — TLC3407x RAMDAC/palette datasheets; the VT1200 is
monochrome, so not relevant.
* `Patents/` — graphics-architecture patents; no code or format value.
---
## 8. Findings about `smart34010` itself
These are worth fixing upstream (they cost real time here):
1. **Long relative `JR` is printed as `JA`.** In `dis34.c`, the `0x00` low-byte
case (16-bit displacement, 2-word instruction) calls `printf("ja%s …")`;
only the 8-bit case prints `jr%s`. This makes the output non-assemblable
without the word-count fix. Example: `jagt L3F211EE0 ; c700 0167` is a
relative jump, but `jagt` is the 3-word absolute instruction.
2. **B-file `SP` is printed as `sp`.** The register name hides bit 4; `sp`
(A-file) and `b15` (B-file) are different encodings.
3. **`movi` hides the short/long choice.** The disassembler prints one mnemonic
for both encodings, while the assembler auto-selects (and may change the
meaning via sign extension). The `-o` word count is the only clue.
4. **64 KB input fails** with `unexpected end of input at byte 65536`; use
≤32 KB regions or fix the offset arithmetic.
5. Data tables decoded linearly can produce branches into the middle of
instructions; `-a` (analyze) may help, but for byte-exact reassembly the
`.word` fallback in the converter is what matters.
The good news: the instruction syntax itself (mnemonics, operands, `@`/`*`
addressing, labels, `.word` data) matches GSPA 2.00 closely; the problems are
these specific ambiguities, not a different dialect.
---
## 9. DOSBox vs dosemu: benchmark
Separate from the toolchain result. The owner asked whether dosemu is actually
faster. Workload is the real assembler, **GSPA 2.00** (there is no `as34` in
this SDK), on the round-trip sources.
* **A:** `gspa t1 t1.obj t1.lst -l` — 4 KB region, 1,478 lines, with listing.
* **B:** `gspa -q rt16 rt16.obj` — 16 KB region, 6,124 lines, no listing.
* DOSBox 0.74-3, `core=dynamic`, `cycles=max`, `SDL_VIDEODRIVER=dummy`,
`SDL_AUDIODRIVER=dummy`.
* dosemu 1.4.0.8, `-t -quiet`, private `HOME`, batch ending `exitemu`.
* 1 warm-up + 3 measured runs; median; all exit status 0.
* COFF output compared with the 4-byte time stamp zeroed (raw COFF differs only
in that stamp).
| workload | DOSBox median | dosemu median | ratio |
|---|---:|---:|---:|
| A: 4 KB with listing | **1.99 s** | **0.35 s** | 5.7× |
| B: 16 KB no listing | **2.23 s** | **0.43 s** | 5.2× |
The timestamp-normalised object md5 is identical for every run of each
workload and across the two emulators:
* A: `e9c59beb4a5e29e391495ba2b312fc79`
* B: `69a525b4689f60f51afc3059b66b66a8`
Raw md5s differ because bytes 4–7 of the COFF header are the creation time; the
assembled bytes are the same.
Setup cost: DOSBox needed only an existing config; dosemu auto-created
`~/.dosemu`, required a drive C mapping and a batch (because `-E` does not
exit without `exitemu`), and prints harmless CD-ROM/`unknown window sizes`
warnings. That is a one-off few-minute setup, not per-run.
**Recommendation:** dosemu is materially faster (≈5×) under these settings and
is worth using for batch/iterative assembly. DOSBox remains the known-good
fallback and is what `roundtrip.sh` uses. Note that with DOSBox's original
`cycles=3000` config the gap is much larger (the 16 KB listing workload took
~35 s), because DOSBox was being cycle-limited, not because dosemu is 70×
faster.
---
## 10. Reproduction
Scripts live in `harness/sdk-scratch/` (scratch; not part of the SDK):
* `roundtrip.sh OFF SIZE BASE` — extract, disassemble, convert, assemble, link,
convert, interleave, diff. Example:
`./roundtrip.sh 0x1000 32768 3F208000`
* `dis2as34.py BASE SIZE IN.ASM` — the five fixups.
* `bench2.py` — the DOSBox/dosemu benchmark (needs the private dosemu `HOME`
under `harness/sdk-scratch/dosemu-home`).
* `dosbox.conf` / `dosbox-bench.conf` — headless DOSBox configs.
The SDK itself is untouched; all extraction goes to `harness/sdk-scratch/`
(`work/`, `cgtools/`, `sdb/`, `disk1987/`), which is not staged.
---
## 11. Open questions / not tested
* No payload was executed on a VT1200 or in MAME. The round trip proves the
encoder, not the runtime or the network delivery.
* No OS/2 environment was set up, so the r5.01 tools (and their `gspopt`
optimizer) were not exercised. If the newer code generator matters, that is a
separate OS/2-in-QEMU project.
* The 1987 `GSPSIM` simulator was not run; MAME is the better execution model
for this project.
* The Math Graphics Function Library and TIGA libraries were not evaluated
against the VT1200's monochrome display.
* The optimal free RAM range for a payload depends on what the OS has allocated;
the toolchain can link anywhere, but the loader must choose a safe range.