Skip to content

Types

TypeSizeMeaning
u8, i81 byte8-bit unsigned / signed integer
u16, i162 bytes16-bit unsigned / signed integer
u32, i324 bytes32-bit unsigned / signed integer
u64, i648 bytes64-bit unsigned / signed integer
float4 bytesIEEE-754 binary32
double8 bytesIEEE-754 binary64
bool1 bytealias of u8; values are true and false
voidabsence of value (function returns, parameter list (void))
stringpointeralias of u8* (pointer to null-terminated bytes)
pointertarget-definedtypeless pointer

Every width is the same on every target — a u32 is four bytes on the 6502 as well as on arm64. Only pointers vary: 3 bytes on the banked xt6502 ({lo, hi, bank}), 8 bytes on the 64-bit hosts, 4 on arm9/m68k. Code that needs the number should use sizeof(T*) rather than a baked-in constant.

Floating point is IEEE-754 on every target, including the 6502, where the arithmetic is done by a software runtime or hardware on the FPGA.

64-bit arithmetic works on all six targets, not just the 64-bit hosts. The difference is only how: arm64, x86_64 and win64 do it in registers, while the narrow targets do it out of line — m68k through line-A HLE selectors, arm9 through inline adds/adc plus libgcc, and xt6502 through hand-written routines in support/xt6502/asm/{i64,u64}/.

The answers are identical everywhere, including 64-bit literals. Add, multiply, divide, shift, unsigned wraparound and comparison of a 2^40 value all agree byte-for-byte between a 6502 and an arm64.

sizeof(i64) is 8 on every target, because width is a layout contract: a struct containing an i64 lays out identically everywhere.

  • Assigning a wider integer to a narrower one truncates, with no sign extension.
  • Assigning float/double to an integer takes the integral part, truncated toward zero: (i32)3.7 is 3, (i32)-3.7 is -3. Magnitudes that overflow the destination saturate to 0.
  • Same-width arithmetic stays at that width. There is no C-style promotion to int, so u8 + u8 wraps at 8 bits. Only genuinely mixed-width operands widen (u8 + u16u16). To get a wider result, widen the operands:
u8 a = (u8)200, b = (u8)100;
u8 narrow = a + b; // 44 — 300 & 0xFF
u16 wide = a + b; // 44 — STILL a u8 add
u16 real = (u16)a + (u16)b; // 300 — widen the OPERANDS

The destination cannot change how the operator computes, which is the point: an expression means the same thing wherever its result goes. This differs from C, which promotes both operands to int and would give 300 for the second line.

Structs gather related data into a value type with copy semantics — passed and returned by value. Field alignment is target-defined: the 6502 packs them byte-by-byte (padding would waste bytes on a byte-oriented CPU), while the register machines insert padding so each field lands on its natural boundary. Declaration order is preserved regardless.

typedef struct {
u16 x;
u8 y;
} CursorPos;
CursorPos topRight = {319, 0};
CursorPos middle = {159, 100};

Initialisers use { … }, as C does. Members are listed in declaration order; omitted trailing members are zero-filled, and supplying more elements than the struct holds is a compile-time error.

([ … ] was accepted here too, for the same reason (( )) was accepted as a block — an Atari 8-bit keyboard has no brace keys. It went when (( )) did, so there is one spelling to learn rather than two. An enum body still takes either.)

A struct can be returned by value:

CursorPos centre(void) {
CursorPos c = {160, 96};
return c;
}

But you may not return a pointer to a stack-resident struct — the storage goes away when the scope ends:

CursorPos* bad(void) {
CursorPos c = {1, 2};
return &c; // illegal — c dies at scope exit
}

Passing &struct as an argument is fine: the callee only holds the pointer for the duration of the call.

enum suits = {hearts, clubs, diamonds, spades};
enum directions = {N = 4, S, E, W}; // 4, 5, 6, 7

Enumerations start at 0 unless given an explicit value; subsequent entries increment by 1. The compiler picks the smallest unsigned type that holds every value.

u8 cakes[3];
u8 spaces[] = {' ', '\t', '\n'}; // size inferred from the initialiser
u16 scores[8] = {100, 87}; // remaining 6 slots zero-filled

Array size is part of the type; with an initialiser present the size in [ ] may be omitted.

Fixed-size arrays with an integer element type also accept a range:

u8 buf[10] = 0..10; // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
u8 b2[5] = 1...5; // 1, 2, 3, 4, 5 (inclusive)
u16 b3[4] = 100..104; // 100, 101, 102, 103
i8 b4[3] = -2..1; // -2, -1, 0

Both bounds must constant-fold, and the resulting count must match the declared element count; mismatches and non-literal bounds are rejected at compile time. Float, struct and class arrays still need the { … } form.

Fixed-size arrays and heap-allocated pointers both expose a .length pseudo-property:

u16 local[8];
u16 n1 = local.length; // compile-time constant: 8
u16* heap = new u16[64];
u16 n2 = heap.length; // 64

.length on a pointer the compiler did not record a count for — one that crossed a function boundary, or came from anywhere but new T[N] — is a compile error, not a wrong number: there is no map entry to answer from.

Pointer syntax uses *, as C does. & takes an address, and * dereferences:

u16 value = (u16)1234;
u16* p = &value;
u16 v = *p; // load
*p = (u16)4321; // store

-> is sugar for “dereference and reach a member”: p->x is (*p).x. Unlike C, . on a pointer-to-struct or pointer-to-class also works — the compiler auto-dereferences. Class receivers conventionally use ., because a class instance is nearly always reached through a pointer, and sprite.draw() reads better than sprite->draw().

A hardware register is a pointer to a fixed address, reached by casting:

volatile u8* COLBK = (u8*)$D01A;
*COLBK = *COLBK + (u8)1; // both accesses happen even at -O3 (volatile)

Casting uses C’s (type) syntax:

u16 n = (u16)x;

Two extensions handle class-pointer traffic:

  • (Dog*) animal — runtime-checked downcast. On a mismatch the program traps.
  • (Dog* ?) animalfailable downcast. On a mismatch it yields (Dog*)0; on success, the retyped pointer. Pair it with an if (d != 0) guard.

Upcasts, same-class casts and non-class-pointer casts are unaffected. The full story is on Inheritance & protocols.

auto infers a variable’s type from its initialiser:

auto x = 3; // u8
auto x = -3; // i8
auto x = 257; // u16
auto x = -259; // i16
auto x = 65589; // u32
auto x = -555_555; // i32
auto x = 4.5; // float
auto x = "hi"; // string (u8*)
auto x = true; // bool

Integer literals pick the smallest type that holds them; positive values become unsigned, negative values signed.

Inference follows expressions and function returns too, widening where a genuinely mixed-width expression requires it:

u8 a = 4, b = 5;
auto c = a + b; // c is u8
u8 a = 4; u16 d = 500;
auto e = a + d; // e is u16 (widened)
// given: u8 fn(void) { … }
auto v = fn(); // v is u8

Explicit types are still preferred — auto is for cases where the expression makes the type obvious and restating it would be noise.

Any type can be aliased:

typedef u16 Tick;
typedef u8* bytes;
typedef RGB[] palette;

Aliases are transparent: Tick and u16 are interchangeable everywhere.

A typedef of a function signature is also how a bound-method type is spelled — typedef void Handler(i32 v); gives you Handler^. See Bound methods & callbacks.

A protocol is a named interface — method signatures with no bodies. The type-system view is simply that a protocol name in a type position (usually as a pointer, e.g. Drawable*) accepts any conforming class instance. Conformance and optional methods are covered on Inheritance & protocols.

// types.xc — the scalar types, integer width rules, and pointers.
#import "Foundation.xc"
#import "Stdio.xc"
i32 main(void)
{
u8 small = (u8)200;
u16 mid = (u16)60000;
i32 wide = (i32)-100000;
u64 huge = (u64)1 << (u64)40;
// printf's width contract: %d is 16-BIT and %ld is 32-bit, and both are
// signed — which is why 60000 in a u16 prints as -5536.
Stdio.printf("u8=%d u16=%d (as i32 %ld) i32=%ld\n", small, mid, (i32)mid, wide);
Stdio.printf("2^40 = %ld:%ld (hi:lo)\n", (u32)(huge >> (u64)32), (u32)huge);
// Same-width arithmetic stays at that width.
u8 a = (u8)200, b = (u8)100;
u8 wrapped = a + b; // 300 & 0xFF = 44
// Widening the DESTINATION does not help — `u16 w = a + b;` is still a u8
// add, and still 44. To get the true sum, widen the OPERANDS.
u16 widened = (u16)a + (u16)b; // 300
Stdio.printf("u8 200+100 -> %d widened -> %d\n", (u16)wrapped, widened);
// Literal prefixes: $ hex, % binary, _ ignored anywhere in a literal.
u16 hex = $BEEF;
u8 bin = %1010_0101;
u32 big = 1_000_000;
Stdio.printf("hex=%ld bin=%d big=%ld\n", (i32)hex, (u16)bin, big);
// Pointers use *, & takes an address.
u16 value = (u16)1234;
u16* p = &value;
Stdio.printf("*p = %d\n", *p);
*p = (u16)4321;
Stdio.printf("value now %d\n", value);
// The sigil binds to the TYPE, so this declares TWO pointers.
u16* x, y;
x = &value; y = &value;
Stdio.printf("both pointers: %d %d\n", *x, *y);
bool ok = true;
float f = 1.5;
double d = 3.1d;
Stdio.printf("bool=%d float=%f double=%lf\n", ok ? (u16)1 : (u16)0, f, d);
return 0;
}
u8=200 u16=-5536 (as i32 60000) i32=-100000
2^40 = 256:0 (hi:lo)
u8 200+100 -> 44 widened -> 300
hex=48879 bin=165 big=1000000
*p = 1234
value now 4321
both pointers: 4321 4321
bool=1 float=1.500000 double=3.1000000000