Skip to content

Build Toolchain

Build commands, cc900 compiler rules (C89 compliance, NGP_FAR, volatile, ISR, inline ASM, confirmed ABI), the memory model, known compiler/linker pitfalls, the open-source NgpCraft toolchain, and a reference description of the official CC900 toolchain internals.


1. cc900 C89 Rules

cc900 is Toshiba's C compiler for the TLCS-900/H CPU. It is strict C89 with proprietary extensions. The following restrictions apply.

1.1 // Comments: Accepted, but Prefer /* */

/* PREFERRED — portable C89 */
x = 1; /* initialize x */

// ACCEPTED by cc900 (Package Version 1.10, -O3 -Nb2), but not ISO C89

Measured: a file using // comments compiles without error or warning. Use /* */ anyway so the code stays valid for any strict C89 tool (linters, other compilers).

The block-start rule (§1.2) applies to every block, including nested ones: if (c) { int x = 5; ... } is accepted, because x is declared at the start of its own block.

1.2 Declarations at Block Start Only

/* CORRECT */
void foo(void) {
    u8 i;
    u8 j;
    i = 0;
    j = 1;
}

/* FORBIDDEN — declaration after a statement */
void foo(void) {
    i = 0;
    u8 j = 1;  /* compile error */
}

1.3 No Declaration in for

/* CORRECT */
u8 i;
for (i = 0; i < 10; i++) { /* ... */ }

/* FORBIDDEN — C99 only */
for (u8 i = 0; i < 10; i++) { /* ... */ }

1.4 No 64-bit or Floating Point

/* FORBIDDEN */
long long x;  /* no 64-bit integers */
double y;     /* no FPU */
float z;      /* no FPU */

1.5 Available Types

Defined in ngpc_types.h:

u8   /* unsigned char  — 1 byte */
s8   /* signed char    — 1 byte */
u16  /* unsigned int   — 2 bytes */
s16  /* signed int     — 2 bytes */
u32  /* unsigned long  — 4 bytes */
s32  /* signed long    — 4 bytes */

Important: on cc900, unsigned long = 32 bits and unsigned int = 16 bits. Do not rely on int being a specific size without typedef.


2. Far Pointers — NGP_FAR

2.1 Why Far Pointers Are Required

cc900 uses 16-bit (near) pointers by default. Main RAM is at 0x004000-0x005FFF (within near range). Cartridge ROM starts at 0x200000 — completely out of near reach.

Without NGP_FAR, the compiler generates a truncated 16-bit address -> reads from the wrong location -> corrupted data, wrong image, or crash.

2.2 The NGP_FAR Macro

Defined in ngpc_types.h:

#define NGP_FAR  __far

2.3 When to Use NGP_FAR

Always for pointers to ROM data (tiles, palettes, tilemaps, audio, text strings):

/* CORRECT — ROM tile data */
void ngpc_gfx_load_tiles_at(const u16 NGP_FAR *tiles, u16 count, u16 offset);

/* WRONG — compiles but reads from wrong address */
void ngpc_gfx_load_tiles_at(const u16 *tiles, u16 count, u16 offset);

Near pointers are fine for RAM data:

static u8 s_buffer[256];      /* static RAM — near OK */
NgpcRect *rect;               /* struct in RAM — near OK */
const u16 NGP_FAR *tiles;     /* ROM data — NGP_FAR required */

2.4 Near Pointer Extension

When cc900 passes a near pointer to a function expecting NGP_FAR, it zero-extends the 16-bit address to 24 bits. For RAM (base 0x004000), this gives 0x004000 as 24-bit — correct. For ROM (0x200000), a near pointer cannot encode the address at all.

2.5 Fallback — Direct VRAM Macros

If display remains corrupted despite correct NGP_FAR usage (or for diagnostics), use the direct tilemap-blit macros:

/* Direct VRAM write without passing a pointer as parameter */
NGP_TILEMAP_BLIT_SCR1(prefix, tile_base)
NGP_TILEMAP_BLIT_SCR2(prefix, tile_base)

These macros index the generated symbol directly within the compilation unit — no pointer parameter, so no near/far problem is possible.

Recommended decision tree: 1. Normal project -> use helpers (ngpc_gfx_load_tiles_at(), etc.) 2. Unexplained corrupted rendering -> switch to NGP_TILEMAP_BLIT_* to unblock 3. Any new helper reading const ROM data -> annotate parameters with NGP_FAR


3. volatile Keyword

Required for all hardware register accesses and all variables modified by ISRs:

/* Hardware registers: always volatile (defined in ngpc_hw.h) */
#define HW_RAS_V  (*(volatile u8 *)0x8009)

/* Variables modified by ISR: volatile */
volatile u8 g_vb_counter;   /* incremented in isr_vblank */

/* DMA counter: volatile, modified by hardware */
static volatile u8 s_done_flag[4];

Without volatile, the compiler may cache the value in a CPU register and never re-read memory -> a wait loop never terminates.


4. Interrupt Functions

Use the __interrupt keyword for interrupt handlers. cc900 automatically generates the RETI prologue/epilogue:

static void __interrupt isr_vblank(void)
{
    HW_WATCHDOG = WATCHDOG_CLEAR;
    g_vb_counter++;
}

/* Install the vector */
HW_INT_VBL = isr_vblank;

ISR rules: - Keep ISRs as short as possible (budget is tight, especially HBlank ~ 30 cycles) - Never call ngpc_vsync() from an ISR (deadlock) - Shared variables must be declared volatile


5. Inline Assembly

5.1 Basic Syntax

One instruction per __asm() call:

__asm("ei");       /* enable interrupts */
__asm("di");       /* disable interrupts */
__asm("halt");     /* wait for interrupt */
__asm("swi 1");    /* BIOS call */

5.2 Stringifying C Constants

C constants are not directly visible in inline ASM. Use NGPC_STR(x) (defined in ngpc_hw.h):

#define BIOS_SHUTDOWN  0
#define NGPC_STR(x) #x

__asm("ldb rw3, " NGPC_STR(BIOS_SHUTDOWN));
/* generates: ldb rw3, 0 */

ld xhl, (symbol_c) does NOT compile with asm900 (Error-221: Undefined symbol). C symbols are not visible from inline ASM. Access arguments via the stack instead.

5.3 Stack Convention for Arguments

In a function with (void)arg to suppress unused warnings, arguments are at predictable stack offsets (far call convention):

void my_func(const u8 NGP_FAR *ptr, u16 count)
{
    (void)ptr;    /* suppress "unused" warning */
    (void)count;
    __asm("ld xwa, (xsp+4)");  /* xwa = ptr (24-bit far)  */
    __asm("ld wa,  (xsp+8)");  /* wa  = count (16-bit)    */
    /* ... */
}

Keep such functions minimal — no local variables, no other C statements — to ensure the stack prologue remains predictable for the xsp+N offset.


6. Memory Model and Sections

Region Address Size Usage
Main RAM 0x004000-0x005FFF 8 KB C variables, stack, BSS
Battery RAM 0x006000-0x006BFF 3 KB Lightweight saves (volatile)
Z80 RAM 0x007000-0x007FFF 4 KB Audio driver (shared)
Cartridge ROM 0x200000-0x3FFFFF 2 MB Code + assets (near pointers forbidden)

The linker script (ngpc.lcf) defines all sections. The runtime bootstrap (in ngpc_sys.c) copies initialized data ROM -> RAM and zeroes BSS at startup.

Useful linker symbols:

extern const u8 _DataROM_START;  /* start of initialized data in ROM */
extern const u8 _DataROM_END;
extern u8       _DataRAM_START;  /* destination in RAM */
extern u8       _Bss_START;      /* start of BSS */
extern u8       _Bss_END;

RAM budget reality: - Main RAM: 8 KB total - 2 tilemap stream buffers (SCR1 + SCR2, 32x32 x u16): 4096 bytes (50% of RAM, untouchable) - Remaining for stack + entities + other buffers: ~4 KB


7. Confirmed ABI

ABI confirmed by disassembly analysis of cc900-compiled code.

7.1 Return Registers

C type Return register
u8 / s8 L (NOT A)
u16 / s16 HL (NOT WA)
u32 / s32 XHL (NOT XWA)

7.2 Stack Layout (far call)

(XSP+0) = far return address (4 bytes)
(XSP+4) = arg0  (u8 uses 2 bytes on stack; u16=2B; far ptr=4B)
(XSP+6) = arg1  (if arg0 is u8 or u16)

7.3 Frame Allocation

cc900 uses lda XSP, XSP-N for stack frame allocation (NOT LINK/UNLK).

7.4 MUL/DIV — Hardware vs Library

Operation Codegen
u16 * u16 (same width) HW opcode MUL (direct, fast)
u16 * u8 (mixed width) Software call C9H_mullu (shift-and-add)
s16 * s16 Software call C9H_mulls (via C9H_mullu)
u16 / u16 Software call C9H_divlu

C9H_* routines are provided by ngpc_runtime.c — no c900ml.lib needed.

To force HW MUL: cast both operands to the same width before multiplying:

u16 result = (u16)a * (u16)b;   /* same type = hardware MUL opcode */

7.5 Array Stride Codegen

Array type Stride instruction
u8[] Direct index, no scaling
u16[] add XWA, XWA (left shift x1)
u32[] sll 0x2, WA (left shift x2)

7.6 Miscellaneous Codegen Facts

  • switch with >=8 dense cases -> cc900 generates a jump table automatically
  • cc900 never emits DJNZ — uses dec reg; jr NZ instead
  • Callee-saved: XIZ — pushed on entry by every function that uses it (QIZ included); cc900 keeps values in it across calls
  • Caller-saved: XWA, XBC, XDE, XHL, XIX, XIY (evidence: Assembly)

8. Known Bugs and Pitfalls

8.0 Missing prototypes — compile with -w3

C89 assumes int for a function with no visible prototype. cc900 then operates on the full HL register where the function returns u8 — the upper byte holds whatever the previous call left there. This compiles without error and often works, which is what makes it dangerous: it fails only when the upper byte happens to be non-zero.

cc900 -c -w3 -O3 source.c

THC1-Warning-521: Undefined return type marks every affected call. Fix by declaring the function before use.

Measured on a ~15 000-line file: five sites, and fixing them cost exactly zero cycles (232 299 → 232 299 cycles/frame, 17 675 → 17 675 instructions). The generated code differed in exactly one place — cp L,0xff instead of ld WA,HL / cp A,0xff — same instruction count, same states. It went well because the definition appears later in the same translation unit and cc900 corrects the call. Do not rely on that.

Contributed by Napsterix; independently reproduced, and also reported on the freeplaytech forum (Software Development, tid=5494).

8.0b Reading generated assembly

cc900 -S -O3 source.c        # writes source.asm

⚠️ Do not combine -S with -o file.rel — the driver reports cc900-Warning-520: The suffix not fit for output-file and writes nothing.

Contributed by Napsterix.

Counting MUL / MULS / DIV occurrences in the output is the fastest way to find non-power-of-two struct indexing; see Measuring Performance §4.2 and TLCS-900/H Reference §37 for what those instructions cost.

8.1 u8 x Constant: Silent 8-bit Overflow

Symptom: entities appear at completely wrong positions; coordinate calculations are corrupted without any compiler warning.

Cause: cc900 does not always perform standard C89 integer promotion for u8 * literal. The expression u8_var * 8 may stay in u8 and silently overflow for values > 31 (e.g., 32 * 8 = 256 -> truncated to 0 in u8).

/* WRONG — overflow: 74 * 8 = 592 -> u8 -> 80 (completely wrong position) */
world_x = (s16)((src->x * 8) - render_off_x);

/* CORRECT — explicit cast before multiplication */
world_x = (s16)(((s16)src->x * 8) - render_off_x);

Rule: always cast to s16 or u16 before multiplying a u8 by a constant that can produce a result > 255. Common cases: tile coordinates x 8, indices x stride.

8.1b The width of an expression is DECLARED — even for a plain addition

§8.1 is not limited to multiplications by a literal. Two more forms, both measured on a shipped ROM:

u8 n = 7;
u8 bound = (u8)((n * 30u + 50u) / 100u);       /* WRONG: 0 */
u8 bound = (u8)(((u16)n * 30u + 50u) / 100u);  /* right: 2 */

u16 step = (u16)(frac + speed);                /* WRONG: the SUM is 8-bit */
u16 step = (u16)((u16)frac + (u16)speed);      /* right */

The first computed 7 × 30 + 50 = 260 on eight bits, u suffix or not → 4 → 0. It was one of three difficulty thresholds; the two others (120 and 225) fitted in a byte, so one wrong threshold out of three — the best way to go unnoticed. The second has no literal and no multiplication at all — two u8, the result cast — and cc900 still adds on eight bits. The emitted assembly says it (cc900 -S on a minimal fragment):

    ld   C,(_frac)
    add  C,(XSP+0x4)      ; EIGHT bits: the carry is lost
    extz BC               ; the widening comes AFTER

It was a scenery step in 16ths: as soon as frac + speed passed 255 — at 96 mph — the step wrapped to zero and roadside posts froze on screen for up to 74 % of frames at 100 mph. Reported from the console as "at 100 mph the posts freeze".

Rules: cast the FIRST operand (it decides the width of the whole expression), and for an addition cast every operand. ⛔ A symptom that depends on a VALUE THRESHOLD is a width symptom: compile a minimal fragment with -S before suspecting the logic — it takes a minute and settles it.

8.1c An int constant expression is 16 bits

#define GAIN  (512 * 256 / (N * (N + 1)))   /* 131072 wraps to 0 in a 16-bit int */

The constant folded to zero, the gain to zero, and a whole rendering path silently produced zeros — which looked like "nothing happens", not like an overflow. Write such constants in two steps ((256 / ...) * 512 with care for rounding) or with an explicit UL, and check the emitted value.

8.1d Array sizes are rounded UP to an even size

u8 tune[9] occupies 10 bytes and u8 gear_top[5] 6, not just aligned: the SIZE is rounded. A save struct planned at "+2 bytes" measured +4; a state block's counters sat at offsets 84/85, not 83/84. Find a field by DIFFING RAM before/after an action that changes it, not by adding up sizes — and assert offsets at compile time (§8.5c).

8.1e Right-shifting negative values, and signs in general

A right shift of a signed value is not a reliable arithmetic shift on this toolchain: work on the unsigned magnitude and re-sign. An expression mixing shifts and signs produced a non-monotonic output (30, 33, 32) for a steadily decreasing input; replacing >> by divisions did not fix it. Do not trust such an expression — measure it.

8.1f % and / compile to a NARROW hardware divide — indeterminate on overflow

Without the -A (ANSI) option, cc900 does not widen the operands of a multiply or a divide: it uses the machine instruction at the operands' own width. The Toshiba compiler manual says so explicitly (section 3.12.2, Arithmetic Conversion of Multiplication and Division): div may be used for division and remainder, and "it does not get an expected result when a quotient overflows".

A typical victim is a shuffle:

u16 state = prng_next();
j = (u8)(state % i);            /* i is a u8 loop counter */

compiles to a 16/8 divide — div BC,H, bytes CE 53 — whose quotient must fit in 8 bits. 38457 % 71 has a quotient of 541: the CPU sets V and the destination is indeterminate. The "remainder" is then used as an index, unchecked, and the swap reads and writes outside the array. u32 % u16 is the same trap at the next size (it is the manual's own example).

  • Casting the divisor to u16 is NOT enough — cc900 still emits div BC,H.
  • Emulators usually hide it: they compute the remainder with the host's % and only raise V, so the game works on the emulator and misbehaves on the console from the same seed.
  • u8 % u8 is safe: an 8-bit dividend cannot overflow an 8-bit quotient.

Fixes, by increasing locality:

  1. compile the file with -A — cc900 then emits a 32/16 divide with zero-extension; -A also changes every other expression of the file (including the char additions of §8.1b, which the same manual section documents), so re-check the file;
  2. two narrow divides whose quotients always fit:
    u8 hi = (u8)((u8)(state >> 8) % n);
    j     = (u8)((((u16)hi << 8) | (u8)state) % n);   /* dividend < n*256 */
    
  3. a shift/subtract reduction (16 steps, no RAM, no call).

Detect it: look at the emitted assembly (cc900 -S) of every % and / that feeds an index; a div whose destination is one size wider than a small divisor, with a dividend that can be large, is the pattern. (ngpc_bug_check query: modulo.)

8.1g A folded constant above 255 is truncated when it meets a u8

u8 i;
tab[10u * 32u + i] = 1;     /* WRONG: index computed as (u8)(64 + i) */
tab[(u16)320u + i] = 1;     /* right */
g = 248u + i * 8u;          /* WRONG: the whole sum stays on 8 bits */
g = (u16)248u + (u16)i * 8u;   /* right */

cc900 -S -O3 on that fragment:

    ld   A,0x40      ; 64 / 320   <- the folded constant, already truncated
    add  A,C         ; 8-bit add with the u8 index
    extz WA          ; widened too late

The same constant alone (g = 10u * 32u;) compiles to 320. The truncation only happens when the folded value is combined with an 8-bit operand — even though the constant is the first operand. So the "cast the first operand" rule of §8.1b has to be read literally: a literal above 255 does not widen the expression by itself, an explicit (u16) does.

Met twice in one project: a map-row address (row 10 of a 32-wide map landed on row 2) and a 260-byte offset. Rule: write (u16) on any constant term that can exceed 255 in an expression that also contains a u8, or precompute the constant into a u16 variable.

8.2 Linker Order: Sprites After Maps -> Wrong Bank

Symptom: sprites are invisible (only 1 visible out of N), corrupted display for specific entities only.

Cause: the linker places f_const section data in ROM in the order .rel files appear. Alphabetical sorting typically puts tilemaps before sprites. If tilemaps are large, they fill bank 0x20 and sprites end up in bank 0x21 or 0x22. cc900's 16-bit near pointers can only address bank 0x20 (0x200000-0x20FFFF). Result: a near NgpcMetasprite *def pointer reads the wrong address -> invisible sprites.

Fix: in the Makefile (or generated asset Makefile), place *_mspr.rel files before *_map.rel files. Sprites stay in bank 0x20.

# CORRECT order: sprites first, maps last
OBJS += $(OBJ_DIR)/player_mspr.rel
OBJS += $(OBJ_DIR)/enemy_mspr.rel
OBJS += $(OBJ_DIR)/level_map.rel       # maps come last

Alternatively, in the asset export script:

def link_order_key(path):
    name = path.name.lower()
    if name.endswith("_mspr.c"):  return (0, str(path))  # sprites first
    if "_map.c" in name:          return (2, str(path))  # maps last
    return (1, str(path))

General rule: keep metasprites in bank 0x20. If the project grows and sprites risk overflowing, migrate to far pointers in MsprAnimFrame.frame and ngpc_mspr_draw().

8.2b Section order at the entry point — the ROM executes whatever lands at 0x200040

The cartridge entry point is 0x200040, and the CPU starts there unconditionally. It does not look for a symbol: it executes the first byte the linker happened to place at that address. Put a const table first and the CPU runs your font as instructions — an immediate, total crash with no diagnostic.

sections
{
    far_area    org=0x004000                             : {*(f_area)}
    far_code    org=0x200040                             : {*(f_code)}   /* CODE FIRST */
    far_const   org=org(far_code)+sizeof(far_code)       : {*(f_const)}
    far_data    org=org(far_const)+sizeof(far_const)
                addr=org(far_area)+sizeof(far_area)      : {*(f_data)}
}

Two rules, and both are needed:

  1. f_code before f_const in the linker script. A ROM with no const data at all works either way, which is exactly how this ships broken: it starts failing the day someone adds a lookup table.
  2. The function you want at the entry must be the first function of the first object you pass the linker. Object order on the command line decides it, and so does source order inside the file — helpers go after, forward-declared.

Verify it, do not assume it: the .map must show your entry symbol at 00200040.

      _main          200040 f_code

8.2c The linker prints its errors and still returns success

tulink reports an unresolved symbol as TULINK-Error-209 on stdout and does not necessarily fail the build. A shell script that only checks exit codes carries on: the converter then runs on the previous .abs, and out comes a .ngc that looks fine, is correctly sized, and is not the code you just wrote.

Gate on the output, not on the status:

python tools/build_utils.py link main.abs ngpc.lcf $OBJS 2>&1 | tee build.log
grep -qi "error" build.log && { grep -i error build.log; exit 1; }

⚠️ Grepping for Error is a floor, not a ceiling — it catches this class and says nothing about a ROM that links cleanly and does not run. Whatever the build says, the ROM still has to be run.

8.3 RAM: Oversized Entity Pools

Context: template runtimes allocate fixed-size arrays on main()'s stack for entities (enemies, FX, props) and trigger regions. Generic defaults waste hundreds of bytes from a total of only 8 KB of RAM.

Typical default values and RAM costs:

Pool Default Struct size RAM cost
Max enemies 16 ~44 bytes 704 bytes
Max FX 8 ~22 bytes 176 bytes
Max props 16 ~38 bytes 608 bytes
Max regions 64 1 byte 64 bytes
Total ~1552 bytes

A minimal scene with 3 enemies and 6 props needs only ~572 bytes for pools. Savings: ~980 bytes = 12% of total RAM.

Define pool sizes via CDEFS in the Makefile:

CDEFS += -DNGPNG_AUTORUN_MAX_ENEMIES=4
CDEFS += -DNGPNG_AUTORUN_MAX_FX=4
CDEFS += -DNGPNG_AUTORUN_MAX_PROPS=8
CDEFS += -DNGPNG_AUTORUN_MAX_REG=8

Place pool overrides after any auto-generated CDEFS block to take precedence.

8.4 Windows Encoding

  • Keep .asm source files in CRLF line endings, ASCII/ANSI encoding.
  • asm900 rejects files with BOM or non-ASCII characters.
  • Keep documentation files in ASCII to avoid "ghost character" issues in PowerShell.
  • File paths with spaces require quotes in make/batch commands.

8.5 Nested Initialized Declarations

Declaring and initializing a local inside a nested block can produce wrong code on both cc900 and the open-source t900cc compiler. The stack slot can alias another local, so subsequent reads return stale or unrelated values. Observed symptoms include corrupted camera coordinates, false SOLID tile results in dungeon collision, and sporadic gameplay bugs that disappear when the declaration is moved.

/* FORBIDDEN — nested initialized decl */
if (runtime_flag) {
    s16 _dgn_dzx = (s16)sc->cam_follow_deadzone_x;
    ...
}

/* CORRECT — hoist decl to enclosing block, assign in the nested block */
s16 _dgn_dzx;
...
if (runtime_flag) {
    _dgn_dzx = (s16)sc->cam_follow_deadzone_x;
    ...
}

Non-initialized nested decls are safer but not 100% confirmed. Prefer hoisting.

Same-family bug: t900cc also miscompiles s8 != s8 || s8 != s8. Store intermediate comparison results in explicit u8 variables if the expression mixes multiple s8 comparisons.

8.5b The Makefile does not track headers — stale objects, green build

A typical .rel: .c rule makes each object depend only on its .c. Change a .h and nothing rebuilds: the object stays the old one, the link succeeds, the ROM is built, and make says nothing. Paid twice in one evening on a finished game: a count raised in a header (8 → 9 bodies) while one object still had 8 — the new entry fell out of range and took the fallback art; and a screen photographed from an object whose source had already been rewritten.

Rules:

  • after any .h change, delete the objects before measuring anything — or declare the header dependencies explicitly in the Makefile (generated asset headers included: a tile count in a header is read by the loader);
  • a source file restored within the same second as its .rel is not recompiled — delete the .rel; a file copied with its original timestamp (e.g. PowerShell Copy-Item) is not rebuilt either — touch it;
  • a header of the cartridge (title bytes) not listed as a dependency keeps the old title in a green build — check the bytes at the header offset;
  • prove freshness by the ROM's bytes: a string you just added must be in the ROM, one you just removed must not (Measuring Performance §5.3).

8.5c Separate .asm modules called from C — what a finished game measured

  • Calling convention: parameters on the stack, (xsp+4) for the first; only XIZ must be preserved (cc900 pushes it whenever it uses it); XWA XBC XDE XHL XIX XIY belong to the callee. Safest: pass ONE pointer to a parameter block filled by C on its own stack, read it with (xsp+4) on entry ((xsp+8) after push xiz), and hard-code the field offsets in the .asm — guarded on the C side by
    typedef char chk_block[(sizeof(Block) == 24) ? 1 : -1];   /* THC1-Error-239 if it moves */
    
    cc900 then refuses to compile ("Negative subscript") if a field moves.
  • Two arrays through ONE register: when a loop reads several arrays per step, group them in a struct so their distances are fixed, and read them by displacement (xix+OFFSET) on one pointer. When registers run out, put the loop's constants on ITS OWN stack (lda xsp,xsp-F_SIZE, read by (xsp+d)).
  • ⛔ A is the low byte of XWA (W the next; B, C of XBC; etc.). ld a,(...) while XWA holds a pointer DESTROYS it — a counter ended up written into the stack instead of its block.
  • INC/DEC on 16/32-bit registers do not set the flags (Assembly §3.1b): end a loop with sub bc,1 then j ne, or decb 1,(mem), or an 8-bit dec. There is no pre-decrement in what cc900 emits: go down with a separate dec 2,xix; post-increment (xix+:1) exists.
  • Only use instruction FORMS the compiler already emits in your project (mnemonic + operand class: register size, addressing mode, displacement size). The known silicon traps of this CPU all came from encodings Toshiba's compiler never emits. A script that compares every instruction of the hand-written file against the -S output of the project is cheap and settles it.
  • The file must be ASCII and CRLF ("ASM900-Fatal-152: Illegal source file format"), and $MAXIMUM must precede the equs ("Illegal control").
  • Public twin labels <function>__L<n> emit no byte and give a profiler the per-block detail of a hand-written routine.
  • Keep the C version behind a switch: it is the readable reference, and an equivalence gate can compare both (Measuring Performance §5.1).

8.5d A ROM table of pointers to far data is not reliable

In this memory model, cc900 did not read correctly a ROM table of pointers to far data — a set of background bands came out as one repeated character. What works: fill a struct in RAM from a branch (switch/if chain) once per load, then read the struct. And do not leave the chain as a per-word accessor: a page renderer calling "which asset?" for each 16-bit word did twelve thousand comparisons to copy 3 200 bytes (Game Loop §5.4).

8.5e Scripted edits can inject raw NUL bytes

'\0' written through a shell heredoc into a Python patch script became a raw zero byte in the .c file. It compiles; but file then says "data", grep refuses the file, and a missing terminator gets suspected. Count b.count(0) in every source file after a scripted edit, and write patch scripts to a file rather than through a heredoc. And encode BEFORE opening for write: open(p, "w") truncates first, so an encoding error leaves an empty file.

8.6 Full Pitfall Table

Symptom Probable cause Fix
Corrupted / shifted image ROM pointer without NGP_FAR Add NGP_FAR to parameter
Infinite wait loop ISR variable not volatile Add volatile
Compile error // comments or late declarations Fix per section 1
Crash or random behavior Loop variable declared in for Declare u8 i; before for
Wrong values from unsigned long Assumed 16-bit, cc900 uses 32-bit Verify: cc900 u32 = unsigned long
False struct size assertion Unexpected padding Use u8 _pad[N] for explicit alignment
Entities at completely wrong positions u8 * 8 silent overflow Cast to (s16) before multiply: (s16)src->x * 8
Sprites invisible (1 of N visible) Sprites in bank 0x21/0x22 (link order) Put *_mspr.rel before *_map.rel
Low FPS with few entities Full tilecol run for off-screen entities Guard: skip physics if screen_x < -96 || > 256
Enemies vanish as camera advances Kill condition using camera-relative bounds Use map bounds: world_x < -24 || world_x > map_w*8+24
Out-of-RAM / random crash Pool defaults 16/8/16/64 too large Size pools to scene entity counts
Entity on path stuck and not moving step_toward stops at +/-step without reaching exact target Snap-to-target: return diff when |diff| <= step
Enemies at wrong position before camera reaches them Off-screen guard applies velocity to all Freeze if screen_x > 256 (ahead), drift only if screen_x < -96 (behind)
Works up to a value, then breaks Expression computed on 8 bits — including u8 + u8 Cast every operand to u16; check with cc900 -S (§8.1b)
A whole effect silently zero int constant expression above 32 767 folded Two-step constant or UL; check the emitted value (§8.1c)
Struct field one byte off Array size rounded up to even Diff RAM to locate fields; compile-time size assert (§8.1d)
A header change has no effect Makefile does not track headers Delete objects; prove freshness by ROM bytes (§8.5b)
Hand-written .asm corrupts a neighbour variable ld a,(...) while XWA held a pointer (A = low byte of XWA) Only compiler-emitted forms; register map per routine (§8.5c)
Shuffle/index wrong on console only u16 % u8 (or u32 % u16) compiled to a narrow div without -A; quotient overflow → indeterminate remainder -A, two narrow divides, or shift/subtract (§8.1f)

Quick Reference

Item Value / Rule
Comments /* */ preferred — // is accepted by cc900 but is not C89 (§1.1)
Declarations Block start only — no declaration after a statement
Loop variable Declare before for — no for (u8 i = ...)
No 64-bit No long long, no double, no float
u32 type unsigned long (4 bytes on cc900)
NGP_FAR __far — required for all ROM pointers
ROM base 0x200000 — near pointer cannot reach it
volatile Required for all HW regs + ISR-shared vars
__interrupt Generates RETI prologue/epilogue automatically
Return u8 Register L
Return u16 Register HL
Return u32 Register XHL
arg0 far call (XSP+4)
arg1 far call (XSP+6)
Same-width MUL (u16)a * (u16)b -> HW opcode (fast)
Mixed-width MUL u16 * u8 -> software C9H_mullu
DIV Always software C9H_divlu
u8 * const bug Silent 8-bit overflow — cast to s16 first
Link order *_mspr.rel before *_map.rel — sprites in bank 0x20
Main RAM 8 KB — 4 KB consumed by SCR1+SCR2 stream buffers
ASM encoding CRLF + ASCII/ANSI — no UTF-8 BOM
Nested init decls Hoist to block start — cc900/NgpCraft codegen bug

9. NgpCraft Open-Source Toolchain

The NgpCraft toolchain is a clean-room, Python-only replacement for Toshiba's proprietary tools (cc900, asm900, tulink, tuconv, s242ngp). Its components are t900cc (C compiler), t900as (assembler), t900ld (linker), and ngpc_romtool (ROM packager). It can compile a full non-trivial game.

Two lines, and where they stand (certified 2026-09-09). The hardware-validated v1 described in this section (tools/t900cc.py) is a frozen baseline. Active development is a clean-room v2 that rewrites the code generator to emit asm byte-identical to the proprietary compiler, building on the decoded pipeline and TAC IR documented in §10.

stage state
frontend 73/73 byte-exact against the official one
backend 170/420 byte-exact — ⚠️ no longer tracked as progress, see below
assembler ISA 780/780 encodings
object reader Toshiba .rel / .lib (IEEE-695) — 107/107
linker reproduces a released homebrew's official ROM byte for byte
porting 132/132 files, 7 projects at 100 %
tests 1 204 passing, 59 xfail, 0 failures

⚠️ Byte-exactness stopped being the metric on purpose. Chasing it indefinitely was abandoned in favour of a harder question: does the ROM run, judged by executing it on a second, independent emulator core. Byte-identity remains a diagnostic — it names the cause — but it is no longer what "progress" means.

9.0b Writing your own tools for this CPU — five things that cost us ROMs

Independent of our toolchain; they apply to anyone building an assembler, a linker or a code generator for the TLCS-900/H.

1. ⚠️ The Toshiba fascicle you will find is the 900/L1 volume, not the 900/H. Its table 1 groups both parts in the same column for the instruction set, which is identical — that part is safe to use. The states (cycle) column is not transposable. An external contribution to this project was once accepted on the assumption that "900/H ≡ 900/L1" for cycles, and it was wrong. Never build a timing model on it; time the hardware instead.

2. An assembler that ACCEPTS your text is not an assembler that emits the right bytes. Instruction tables obtained by reverse-engineering the official assembler describe what it does — never the universe of what is legal. So an encoding you never emit is not flagged red by your audit: it is absent from the audit's universe entirely. Audit against the documented instruction table, not against your own output. Ours went from an unknown coverage to 780/780 the day it was audited against the document, and that pass found 16 wrong encodings and 33 wrong mnemonics that every existing gate had been silent about.

3. Branch relaxation is a separate stage, and it needs its own bench. The short/long form of a local jump is not chosen by the encoder — it is a fixed point over a sequence of jumps that influence one another. Per-instruction gates cannot see it by construction. This has already killed a ROM here: in the first-generation assembler, every backward jump spanning relaxed branches came out one byte short per shortened branch, which turned a timer routine into an infinite loop. Bench it explicitly: both thresholds (+127/+128 and -127/-128), every condition code in both forms, and a backward jump that spans branches which themselves relax.

4. ⛔ A gate that runs both ROMs on the SAME emulator cannot see what that emulator does not do. A ROM of ours passed a pixel-difference gate with zero pixels of deviation at three checkpoints while being, in fact, dead: from frame 13 it executed address 0x000000 and spent 71 % of its cycles there, because the epilogue of an __interrupt handler did not release the frame its prologue had allocated, so reti resumed from an address taken inside the frame. The fault cancelled out on both sides because the emulator did not deliver the interrupt that exposed it. Verified rather than assumed: deliberately re-breaking an otherwise correct ROM the same way leaves the gate silent.

🔑 Differential testing against yourself is blind to anything you and your reference share. Add a gate that asks a different question — "does the program counter ever leave ROM, RAM and BIOS?" is cheap and catches this whole family — and run it on a core you did not write.

5. Measure per STAGE, not per function. A stage that has never been measured contains wrong code, and "it compiles" proves nothing about any of them. See also §8.2c: a link error can print and still return success.

9.1 Pipeline

source.c
  -> cpp -E                               (external preprocessor)
  -> t900cc   source.c   -> source.asm
  -> t900as   source.asm -> source.t9obj
  -> t900as   crt0.asm   -> crt0.t9obj
  -> t900ld  -m ngpc.lcf crt0.t9obj source.t9obj -> program.bin
  -> ngpc_romtool program.bin --title "MYGAME" -> program.ngc

9.2 ABI __cdecl (args on stack)

The NgpCraft __cdecl ABI passes all arguments on the stack. It is not the same as CC900's ABI:

Aspect NgpCraft ABI (__cdecl) CC900 (reference)
Arg passing All args on stack, right-to-left All args on stack, right-to-left (default); __adecl passes arg1..3 in XWA/XBC/XDE
1-byte args Extended to 2 bytes on stack Same
Return <= 4 B XHL XHL
Return > 4 B Hidden pointer in XWA Hidden pointer in XWA
Frame link XIY, 0 / unlk XIY lda XSP, XSP-N (no link on leaf)
Locals access XIY + disp XSP + disp or XIZ subregs
Caller-saved XWA, XBC, XDE, XIX, XIY, XIZ XWA, XBC, XDE, XHL, XIX, XIY
Callee-saved XHL XIZ

Consequence: mixing cc900-compiled object files with NgpCraft-compiled objects in the same ROM is not supported. Pick one toolchain per ROM.

9.3 Type Sizes and Pointer Model

char            1 B   (signed default)
short / int     2 B
long            4 B
float / double  -- excluded (no FPU, no soft-float)
pointers        4 B  (all pointers are far — `large` addressing mode)

Near pointers and the tiny / small / medium addressing qualifiers are excluded. NGP_FAR in project source is redundant on NgpCraft (every pointer is far) but still required when the same code must build with both cc900 and NgpCraft.

9.4 Hardware-Validated Codegen — Silicon Quirks

The NgpCraft t900cc codegen emits only instructions confirmed safe by hardware bisect on real NGPC silicon. Known broken opcodes explicitly avoided by codegen:

Opcode pattern Status Workaround
D0 prefix emitted for word register ops (cpl WA, neg WA, sll N WA, sra ...) Mis-decode, not silicon — D0..D7 is the WORD MEMORY family; word reg-direct is D8..DF. Emitting D0 for a word-register op makes the CPU decode it as a memory access (historic "crash"). Codegen must not emit D0 for register ops. Use D8 prefix (word reg-direct) or extz XWA + E8 (32-bit) or HL-based alternative
adc W, B with W > 0 (CA 90) Broken Loop count capped at 255 (W stays 0)
add A, C (CB 81) — byte ALU add/adc/sub/sbc with C-register source Broken (sub-op-specific — NOT the whole CB family) Use add A, L (CF 81) via HL
byte reg-reg MUL/DIV (CB prefix, sub-op 0x40..0x5F; e.g. CB 51 = div A, C) Safe — HW-cleared on real NGPC 2026-07-08 (hw_test_bytediv: div A,C executes correctly, quotient/remainder OK). Parallels the word mul/div clearing (D8..DF, 2026-07-06). Emit directly
link XIY, N with N >= 5 Broken Frame capped at N <= 4, extra locals accessed via separate stack arithmetic
inc WA (D0 61) Mis-encode, not silicon — D0 is a memory-addressing byte; inc WA reg-direct is D8 61. Codegen must not emit the D0 form. ld BC, 1; add A, C; adc W, B, or emit the reg-direct D8 61
srl/sll A, XDE with A=0 Zeroes XDE (not no-op) or A, L guard (CF E1) before shift
D1..D7 standalone Safe Confirmed in CC900 production code
LD R32, imm32 (0x40+R) Safe Used for all 32-bit literal loads (function pointers etc.)
ldirw / ldiw on NGPC Source = XIY, dest = XIX (not XDE/XHL) Required save/restore of XIY through XHL

All of this is enforced inside the codegen — project C source does not need to know about individual opcode bans.

9.5 Runtime Environment

  • crt0.asm copies initialized data ROM -> RAM and zeroes BSS before main().
  • ngpc_sys.c and ngpc_vramq.c are both compiled C modules (no hand ASM).
  • ISR install pattern (mandatory for input to work): install VBL_ISR at 0x6FCC and enable with ei 0. Without this, the BIOS never updates the joypad byte at 0x6F82 and buttons stay silent (see section 4, Interrupt Functions).
  • Current inactivity workaround: clear HW_USR_ANSWER bit 6 at initialization. Never stub out ngpc_shutdown, because low-battery requests still need handling.
  • Power button is BIOS-trapped — a ngpc_shutdown = ret stub does NOT disable the hardware power button; it only suppresses TIME_SHUTDOWN_REQ (10-min idle) and BAT_SHUTDOWN_REQ (low battery). The power button is honored regardless.

9.6 ABI Tradeoffs vs CC900

The stack-based __cdecl ABI is simpler than CC900's register-based __adecl, but it produces larger code and more frequent stack traffic. The structural differences that account for most of the size delta are the per-call frame setup (link XIY / unlk XIY), XIY + disp local access instead of XSP + disp, and fewer short relative calls (calr). A register-passing __adecl ABI, no-frame leaf functions, relaxed jp/calr selection, a u8 zero-extend peephole, and register auto-promotion are the main avenues for closing the gap.


10. CC900 Toolchain Internals (Reference)

A full disassembly and dry-run analysis of the six Toshiba toolchain binaries decoded how the official cc900 toolchain works. This is reference-grade information for anyone reverse-engineering CC900 output, matching its codegen, or driving the proprietary tools directly. It is observed behaviour and hardware facts, not Toshiba source.

10.1 Official Pipeline and Tool Roles

cc900.exe is just a driver. Its -# dry-run reveals it spawns two non-trivial stages, then standard back-end tools:

source.c
  +-[thc1.exe]-> TAC      (frontend: lex/parse/typecheck/lower -> three-address-code text)
       +-[thc2.exe]-> .asm (code generator: register allocation + all optimisation)
            +-[asm900.exe]-> .rel   (relocatable object)
                 +-[tulink.exe]-> .abs (absolute object; GNU-ld-style script)
                      +-[tuconv.exe -Fs24]-> .s24 (Motorola S2 record, 24-bit address)
                           +-[s242ngp]-> .ngp/.ngc (raw binary, BASE 0x200000, 0xFF fill)
Tool Role Replaced by (NgpCraft)
thc1.exe C -> TAC (parser/frontend) NgpCraft frontend
thc2.exe TAC -> asm (code generator — where optimisation lives) NgpCraft backend
asm900.exe asm -> .rel (assembler / ISA encoder) t900as
tulink.exe .rel -> .abs (linker) t900ld
tuconv.exe .abs -> Intel HEX / Motorola SREC ngpc_romtool
s242ngp S24 SREC -> .ngp binary (small open homebrew utility, not Toshiba) ngpc_romtool

Exact stage invocations (the driver passes -XO -XM defaults to both stages; -XM ~ maximum addressing = $MAXIMUM):

thc1 -XO -XM source.c <tac>      # frontend -> TAC
thc2 -XO -XM <tac> out.asm       # codegen -> asm
  • -O<n> (optimisation) is passed to thc2 only. The code generator owns all optimisation.
  • The TAC is optimisation-independent: compiling with or without -O produces the same TAC. Useful corollary for reproduction work — a stable TAC can be captured once and a code generator developed against it.
  • -g adds debug info to both stages.

10.2 TAC — the thc1->thc2 Intermediate Representation

thc1.exe emits a readable three-address-code text on stdout. Each line is a quad (OP, A1, A2, Re) where Re is the result/destination and A1/A2 are operands (the operand order matches thc2's internal A1/A2/Re naming).

Operand forms:

Form Meaning
Tn virtual temp n (typed)
In named local n (frame slot)
Pn parameter n
A return register (WA/HL convention)
#U<n> / #LU<n> / #<n> immediate: unsigned 16-bit / unsigned long 32-bit / raw (e.g. byte count)
*Tn dereference (load/store via pointer)
@_name global / function label
Ln branch label

Opcodes:

OP Semantics
= assign / copy (implicit type conversion via the Re-temp)
+ - * / % binary arithmetic
& \| ^ bitwise
+= -= ... compound assign (kept as a single op -> codegen may emit INC/RMW)
== != < <= > >= comparisons -> bool temp (type 16)
FALSE,cond,,L / TRUE,cond,,L conditional branch (if cond == 0 / != 0)
JMP,L / LAB,L unconditional branch / label definition
CARG,t push call argument
CCALL,@_fn,#nbytes,Re call (nbytes = arg size, Re = result)
BEGIN,fn / END,fn function begin / end (return)
{,n / },n scope open / close
LINE,n[,col] / FILE,path source markers (debug)

Type table (numeric IDs used throughout the TAC):

# C type # C type
1 void 8 signed long (s32)
2 signed char (s8) 9 unsigned long (u32)
3 unsigned char (u8) 16 bool (comparison result)
4 signed short (s16) 19 pointer to type 3 (u8*)
5 unsigned short (u16) 21 pointer to type 5 (u16*)
6 int (signed 16-bit on NGPC) 64+ user-defined struct types
7 unsigned int (u16) (pointer types are derived via a symbol-table entry)

The frontend does the heavy semantic work — &&/|| and switch are lowered to branch chains, for to init; FALSE cond exit; body; step; loop, a->x and arr[i] to address-compute + deref. The code generator receives an already-lowered IR.

10.3 Runtime Helpers (C9H_*)

The code generator emits library calls for operations the CPU cannot do in hardware. Naming convention C9H_<op>l<u|s> (the l = long / 32-bit):

Helper Operation
C9H_mullu / C9H_mulls u32 x u32 / s32 x s32
C9H_divlu / C9H_divls u32 / u32 / s32 / s32
C9H_remlu / C9H_remls u32 % u32 / s32 % s32 (remainder)
_fadd_, _fcmp_, _fdiv_, _fld_, ... float (no FPU on TLCS-900)

16-bit mul/div use native instructions; only 32-bit and float route through the runtime. (Compare with section 7.4, which describes the same split from the project side.) The NgpCraft runtime provides its own C9H_* implementations in ngpc_runtime.c — no c900ml.lib needed.

10.4 asm900 Syntax and Directives

The asm emitted by thc2 (and accepted by asm900 / t900as) is tab-indented, lowercase-mnemonic, with hex as 0x... and memory as (...):

        ld   WA,(XSP+0x6)
        add  HL,WA
        inc  0x1,HL
        pushw (XSP+0x4)
        cpw  (XSP+0x4),0x0
        j    ne,L1
        cal  _fn

Directives:

  • module <name> — module name; end — end of module.
  • <name> section <type> <attrs> — e.g. f_code section code large align=1,1. Section types: code / data / const / area. Addressing class: large / medium / small. Attributes: align=N,M, abs=0xADDR, NOLOAD, ROMDATA.
  • public _sym / extern large _sym — symbol visibility.
  • $MAXIMUM — CPU addressing mode (set by the -XM default).
  • Data: db / dw / dsw / ds.

The mnemonic->byte encoding is a hardware fact (the ngdis disassembler is a faithful port of the Toshiba ISA spec; see TLCS-900/H Reference), so the assembler does not need to be reverse-engineered from the binary.

10.5 Linker Script and ROM Packaging

tulink uses a GNU-ld-style script. Directives relevant to NGPC:

  • MEMORY { name : ORIGIN=..., LENGTH=... } — memory regions.
  • SECTIONS { ... } — section placement; OVERLAY for ROM banking; ALIGN for alignment.
  • NOLOAD — sections not loaded (BSS).
  • ROMDATA — initialised data resident in ROM and copied to RAM at boot. This is the near/far data model: the crt0/startup performs the ROM->RAM copy (the same job the runtime bootstrap does in the NgpCraft runtime, section 6).
  • Targets: -T900 / -T9K16 / -T9K32 (TLCS-900 variants), -pic900 (PIC).

ROM packaging (s242ngp): maps the ROM at BASE = 0x200000, fills unused space with 0xFF, writes each S2 record's bytes at addr - BASE, and dumps the binary up to the last used address. No cartridge header is added by the packager — the 64-byte NGPC header (LICENSED BY SNK... at 0x200000) lives in the compiled startup code itself. NgpCraft's ngpc_romtool follows the same convention.


See Also