No description
  • C 94.7%
  • Shell 5.3%
Find a file
2026-08-12 15:10:40 +11:00
examples the baseline moves to x86-64-v3 and the float bank writes VEX: three operands, so an operation writes a register that is neither source and no copy comes before it, 'z = x + y' one vaddss where it was a movss and an addss, a copy between two registers one vmovaps because the scalar form would read the bytes it does not write, and the scalar conversions taking the destination as the first source, which the Target section names as a cost rather than hiding a vxorps behind one word; the min and the max keep their table and their NaN rule -- the operand is still one side of the relation and still the second source -- and the destination comes free, so 'if v < w m = v' writes a third register and both sides survive, with the liveness following it because the statement no longer reads what it writes, unalias losing its float path, e031 rewritten to an operand that is no side at all, and s032-fltsel driving the new form; self/b99.b99 carries the same change with its instruction record widened to four operands for vroundsd, its conditional-move scan reading two token spans through wtmp and set_union, and its fixed point held, and no AVX beyond the encoding: no 256-bit register, so no vzeroupper, and no vfmadd yet 2026-08-12 14:48:58 +11:00
samples the fma: 'd = x * y + z' folds to one vfmadd, the one statement of the language that holds two operations right of '=', because the machine multiplies and adds in one instruction and rounds once where two round twice, and s033-fltfma shows the difference as one and zero from the same expression written two ways; the instruction reads the register it writes, so the destination must be one of the three values -- the one it adds to, giving the 231 form, or one of the two it multiplies, giving 213 -- and a fourth register is refused with the two statements named, with the comparison on the name the source wrote and not the register the coloring gave, so the allocation decides no refusal; 'd += x * y' and 'd -= x * y' spell the accumulate short, and the second one negates the product where 'd = x * y - d' subtracts the destination, which is vfnmadd against vfmsub; e035 and e036 pin the free destination and the integer bank, build.sh compiles every C reference with -ffp-contract=off and links libm so only fma() writes one there, and self/b99.b99 carries all of it with same_name, five names in a table of sixteen-byte cells, and its item loop reading a minus between two operands of an assignment as a joining token, which also makes 'n = a - b' compile there for the first time 2026-08-12 15:10:40 +11:00
self the fma: 'd = x * y + z' folds to one vfmadd, the one statement of the language that holds two operations right of '=', because the machine multiplies and adds in one instruction and rounds once where two round twice, and s033-fltfma shows the difference as one and zero from the same expression written two ways; the instruction reads the register it writes, so the destination must be one of the three values -- the one it adds to, giving the 231 form, or one of the two it multiplies, giving 213 -- and a fourth register is refused with the two statements named, with the comparison on the name the source wrote and not the register the coloring gave, so the allocation decides no refusal; 'd += x * y' and 'd -= x * y' spell the accumulate short, and the second one negates the product where 'd = x * y - d' subtracts the destination, which is vfnmadd against vfmsub; e035 and e036 pin the free destination and the integer bank, build.sh compiles every C reference with -ffp-contract=off and links libm so only fma() writes one there, and self/b99.b99 carries all of it with same_name, five names in a table of sixteen-byte cells, and its item loop reading a minus between two operands of an assignment as a joining token, which also makes 'n = a - b' compile there for the first time 2026-08-12 15:10:40 +11:00
vscode the floats: f32 and f64 in a second bank of fifteen xmm registers that the allocator colors as it colors the first, a literal kept as the text the source wrote and pooled after the code so the compiler converts no decimal, seven conversion words that each name a cost, ucomis with the sides swapped so a NaN fails every relation and the parity flag read for its equality, two samples and seven refusals that drive it, every earlier program byte for byte the same, and self/b99.b99 not reading them yet 2026-08-09 12:39:54 +11:00
.gitignore s000-hello 2026-07-28 17:46:19 +11:00
AGENTS.md init 2026-07-28 16:55:53 +11:00
b99.c the fma: 'd = x * y + z' folds to one vfmadd, the one statement of the language that holds two operations right of '=', because the machine multiplies and adds in one instruction and rounds once where two round twice, and s033-fltfma shows the difference as one and zero from the same expression written two ways; the instruction reads the register it writes, so the destination must be one of the three values -- the one it adds to, giving the 231 form, or one of the two it multiplies, giving 213 -- and a fourth register is refused with the two statements named, with the comparison on the name the source wrote and not the register the coloring gave, so the allocation decides no refusal; 'd += x * y' and 'd -= x * y' spell the accumulate short, and the second one negates the product where 'd = x * y - d' subtracts the destination, which is vfnmadd against vfmsub; e035 and e036 pin the free destination and the integer bank, build.sh compiles every C reference with -ffp-contract=off and links libm so only fma() writes one there, and self/b99.b99 carries all of it with same_name, five names in a table of sixteen-byte cells, and its item loop reading a minus between two operands of an assignment as a joining token, which also makes 'n = a - b' compile there for the first time 2026-08-12 15:10:40 +11:00
benchmark.md the docs and the comments in simplified technical english: one term for each concept across the tree, write for emit, refuse for rejection, check for verification and expand for splice, with every sentence of the prose under the limit of its kind, no semicolon and no imperative in a description, and the code untouched, which the byte for byte comparison of the two files with the comments stripped and of the assembly the self compiler writes both confirm 2026-08-08 12:12:51 +11:00
build.sh the fma: 'd = x * y + z' folds to one vfmadd, the one statement of the language that holds two operations right of '=', because the machine multiplies and adds in one instruction and rounds once where two round twice, and s033-fltfma shows the difference as one and zero from the same expression written two ways; the instruction reads the register it writes, so the destination must be one of the three values -- the one it adds to, giving the 231 form, or one of the two it multiplies, giving 213 -- and a fourth register is refused with the two statements named, with the comparison on the name the source wrote and not the register the coloring gave, so the allocation decides no refusal; 'd += x * y' and 'd -= x * y' spell the accumulate short, and the second one negates the product where 'd = x * y - d' subtracts the destination, which is vfnmadd against vfmsub; e035 and e036 pin the free destination and the integer bank, build.sh compiles every C reference with -ffp-contract=off and links libm so only fma() writes one there, and self/b99.b99 carries all of it with same_name, five names in a table of sixteen-byte cells, and its item loop reading a minus between two operands of an assignment as a joining token, which also makes 'n = a - b' compile there for the first time 2026-08-12 15:10:40 +11:00
CLAUDE.md init 2026-07-28 16:55:53 +11:00
README.md the fma: 'd = x * y + z' folds to one vfmadd, the one statement of the language that holds two operations right of '=', because the machine multiplies and adds in one instruction and rounds once where two round twice, and s033-fltfma shows the difference as one and zero from the same expression written two ways; the instruction reads the register it writes, so the destination must be one of the three values -- the one it adds to, giving the 231 form, or one of the two it multiplies, giving 213 -- and a fourth register is refused with the two statements named, with the comparison on the name the source wrote and not the register the coloring gave, so the allocation decides no refusal; 'd += x * y' and 'd -= x * y' spell the accumulate short, and the second one negates the product where 'd = x * y - d' subtracts the destination, which is vfnmadd against vfmsub; e035 and e036 pin the free destination and the integer bank, build.sh compiles every C reference with -ffp-contract=off and links libm so only fma() writes one there, and self/b99.b99 carries all of it with same_name, five names in a table of sixteen-byte cells, and its item loop reading a minus between two operands of an assignment as a joining token, which also makes 'n = a - b' compile there for the first time 2026-08-12 15:10:40 +11:00

b99

A programming language between assembly and C99. You write what the machine runs: labels, registers, jumps and syscalls. The language adds names, types, checked calls and a register allocator. There are no parentheses, no commas, no runtime and no libc. The compiler is one C99 file.

section rodata

msg: u8 "hello, world\n"

section text

fn cstrlen reg in s   u8* reg out n  i64
fn print   reg in buf u8* reg in len i64

fn _start:
reg slen i64
do cstrlen msg slen
do print   msg slen
rax = 60; rdi = 0; @syscall		# exit(0)

# the direction: s only goes in, and n only carries the result back
fn cstrlen:
reg in s u8*
reg out n i64
reg c u8
n = 0
addr loop
c = [s]
if c == 0 goto done
n += 1; s += 1
goto loop
addr done
end

fn print:
reg in buf u8*
reg in len i64
@syscall 1 1 buf len			# write(1, buf, len)
end

slen, c, s and n are virtual registers. The compiler assigns the physical registers, and refuses the program when they run out. A value never moves to memory without an instruction in the source. A call is the body of its callee, expanded in place. The language adds nothing else.

Method

  • A library of samples drives the design.
  • Each sample is a folder under samples/. The folder name says what the sample does.
  • A folder holds three files: asm.asm (Intel-syntax assembly), b99.b99 and c99.c. They show the level below the language, the language, and the level above.
  • For each sample, the assembly, the b99 source and the C file must produce the same behavior.
  • The C file is a behavioral reference, not a third version of the subject. It is plain C99 with write(), and it exits by return from main.
  • Some samples have machine mechanics as the subject: register pressure, scratch registers, or expansion. Only the behavior survives the translation to C, and the header comment of the C file says what is lost.
  • An error sample (samples/e...) holds a b99.b99 file that must not compile. It also holds err.err, the exact message that the compiler must give. The diagnostics are part of the design, so the library includes them.
  • Every word that is not code follows ASD-STE100 Simplified Technical English: this file, each other document, each comment of the compiler, each comment of a sample, and each message the compiler gives. One term names one thing through the whole tree. A description uses the simple present and the active voice, and holds 25 words at most. An instruction uses the imperative, states its condition first, and holds 20 words at most. The standard is a free download at asd-ste100.org.

Target

  • The compiler writes assembly in Intel syntax for the GNU assembler. The program runs on x86-64, it calls the kernel of Linux directly, and it links to nothing.
  • The baseline is x86-64-v3. Intel has it since Haswell in 2013, and AMD since Excavator in 2015, so roundsd stands with the other instructions of the float bank and no word of the language asks what the machine has. Each part of the low-power line before Gracemont in 2021 is out of the baseline, because those have no AVX2.
  • The compiler writes the VEX encoding of each instruction of the float bank, and it writes scalar instructions alone: no 256-bit register, and no operation on more than one value at a time. Thus the upper half of each register stays zero, the two encodings never mix, and the program needs no vzeroupper.
  • The VEX encoding takes three operands, so an operation of the bank writes a register that is neither source, and no copy comes before it. The scalar forms that write less than the full register take the bytes above the result from the first source. Where the language names no value for those bytes, the compiler gives the destination, which is the register that the statement writes anyway.
  • vfmadd is in the baseline, and the compiler writes it where the source writes the multiply and the addition together. The instruction rounds once where two instructions round twice, so the two are different programs, and the source says which one the machine runs.
  • The x87 stack is not a bank of this language. fcmov moves a float on a condition, and it writes ST(0) alone, so a value crosses memory to reach it. The bank is the xmm registers, and vminss and vmaxss are the conditional move it has.

Language

Storage and labels

  • A label gives the address of its storage. Brackets read through it: [msg] loads the value. The declared type sets the access width.

  • A colon shows that a definition comes after the name: storage after msg:, and a function body after fn print:. A line that only declares takes no colon: reg, mem, addr, and fn without a body.

  • _ declares anonymous storage. The storage exists, but no name reaches it.

  • A storage line takes a list, and the list is the storage in order: one element of the declared type for each number, the bytes of each string, and the address of each label. Only a pointer type can hold an address. @len counts the whole line.

  • Under section bss, storage takes a count of elements in place of the list. buf: u8 64 reserves 64 zeroed bytes. The count is one when the line has none. The section holds no bytes, so nothing else can go there.

  • Each name must have a declaration before its first use.

  • Only storage and file-scope labels are global.

  • The words the language reads as syntax are not names. A statement that took one would read as something else, so the compiler refuses the declaration: section, rodata, data, bss, text, fn, addr, goto, if, rec, off, reg, in, out, io, mem, do, end and def. This holds for a value, storage, a record, a number, a function and a label. A field is not one of these, because it comes after the record and a dot. _ is not one, because it declares what no name reaches.

    msg: u8 "hello, world\n" storage p10: i64 1 10 100 1000 a table of four words: u8* one two three a table of addresses

Named numbers

  • def gives a name to a number. It reserves no byte and emits no instruction. The compiler holds the value, and the name stands wherever a number stands: an operand, a count of elements, a scale, a displacement, and the value of another def.

  • The value is a number, a character, a field, @len of a record, or another def. One operation can follow the first, as an operation follows = in a body: + - * / & | ^ << >>. The compiler does that arithmetic, so the program pays nothing for it. There is no fma here, because a def names an integer.

  • The compiler reads rec and def lines before the rest of the file, in the order of the file. Thus a record can count a field with a name that a def above it gives. A def can take @len of a record above it. @len of storage belongs to a body, because the walk over the file measures the storage.

  • A def name is not storage and not a register. Nothing assigns to it, and no other declaration takes the name.

    def WBITS 64 def WMASK WBITS - 1 one operation, before the program runs def STEP @len cell the bytes of a record

Registers

  • Physical registers are machine words without a type. You can use each by name. The compiler examines assignments to them for width only.

  • reg declares a virtual register: a name and a type. It has no initializer, because an initializer makes a declaration into storage.

    reg buf u8* a virtual register

  • The compiler selects the physical register that backs a virtual one. If the physical registers run out, the compiler refuses the program. The error names the statement and the values that are live there. Then move the values that the registers cannot hold to mem storage, in the source.

  • A virtual register is local to the function that declares it. Two functions can each declare reg c u8 without a conflict.

  • A physical register that the source names is "raw". The compiler does not touch a raw register: it does not save, load or reassign it. rbx = 1 does that, and nothing more.

Live ranges and allocation

  • Before it emits a function, the compiler calculates the live range of each virtual register, from the first write to the last read. It follows all jumps, and the back edges of loops.
  • Two virtual registers with live ranges that do not overlap share one physical register. Thus a function can name more registers than the machine has, if few enough are live at one time. If too many are live, the compiler refuses the program. The limit is 512 virtual registers per body, with the expanded calls included. The live ranges are a bitmask, one bit for each register, held in as many machine words as that number needs.
  • The compiler never puts a live value in a register that a statement writes while the value is live. It knows the exact clobbers of each statement:
    • A syscall clobbers rax, rcx, r11 and its argument registers.
    • A division clobbers rax and rdx, the registers that it uses.
    • A raw assignment clobbers the register that it names.
    • A call clobbers nothing of its own. A call is an expansion, and the compiler sees the writes of the spliced statements one at a time.

Frame storage

  • mem declares storage in the stack frame of the function: a name, a type and an element count.

  • It takes no initializer, because the frame does not exist until the function runs. The code of the function fills it.

  • The name is the address, as for a label. [buf] accesses the first element, and a pointer walks the other elements.

  • mem blocks have live ranges, as virtual registers do: from the declaration to the last mention. Two blocks with live ranges that do not overlap share one frame slot.

  • If the address escapes, because the source passes it on or stores it, the compiler can no longer see its uses. A mention of the bare name counts as raw, and the compiler does not reuse or move raw storage.

    mem buf u8 64 64 bytes of the frame

Functions

  • fn _start: is the entry point of the program, and the only function that the compiler emits. Each other function is a pattern, and a call is its body, spliced in place of the do statement. There is no calling convention, no prologue and no saved registers. Only the statements of the bodies remain.

  • A call site lists its arguments in order, and the compiler compares them with the declaration. Bare do f is legal when f declares no arguments. There are no return values. By convention, the last argument carries the result.

  • An argument is a declaration with a direction: reg or mem, then in, out or io, then the name and the type. Without the direction, the line is the declaration that the body would write. A reg argument is a virtual register, and a mem argument is storage.

    reg in x T copies the operand in reg out x T carries the argument back reg io x T both copies mem in b T n binds storage: the callee only reads it mem out b T n binds storage: the callee only writes it mem io b T n binds storage: the callee does both

  • A reg argument moves through copies. The expansion copies the operand in at the top, back out at the end, or both, as the direction says. If the caller and the callee use the same physical register, a copy costs no instruction.

  • in carries nothing back, so its operand is anything a statement can read: a register, a number, a label or a bracket. The argument dies at its last read, and the operand is not live after the call. Write in for each argument that the call only reads. It is most of them.

  • out and io carry back, so their operand must be a virtual or a raw register. A number receives nothing, and the compiler refuses it.

  • out copies nothing in, so the argument holds what its register held, as a reg does. The callee must write it before it reads it. The call does not read the operand, so the caller sets no value before the call, and the operand is not live there. Write out for each argument that carries a result and takes none.

  • A mem argument copies nothing. The name of the callee binds to the storage of the operand: a mem block of the caller, or a label. The callee reads and writes that storage as its own, so the binding costs no instruction and no register. The access in the expansion is [rsp + slot], or [rip + label] when the operand is file storage.

  • The element type and the count of a mem argument must match the block of the call site. @len of the argument must be a number that the compiler knows.

  • The direction of a mem argument is a promise, and the compiler enforces it: no store goes through a mem in binding, and no read through a mem out one. A binding passed to a further call binds the storage of the first call site, and the direction only narrows: io passes as anything, in and out pass only as themselves. The bare name is an address, as the name of a block is, and the direction does not follow an address.

  • A declaration and its body must give each argument the same direction, as they already give it the same name and the same type.

    fn cstrlen reg in s u8* reg out n i64 n comes back, and takes nothing in

  • A call is do, and the word that leaves a body is end. Neither word names the machine instruction that it resembles: do f splices the body of f in place, and in an expansion end jumps to the end of that spliced body. An end that is already at that position emits nothing.

  • A function cannot call itself, directly or through other functions. A call is the body of the callee in place, and a body cannot contain itself. Loops iterate. Calls do not.

  • Two expansions of one function share its virtual registers. Their labels get different names, so each expansion jumps inside itself.

    fn print: reg in buf u8* reg in len i64 ... do print msg [msg_len]

  • A fn line without the colon at the end is a forward declaration. Thus a function can call one that the file defines later. The declaration and the definition must agree exactly, with the argument names included, because the compiler compares each call with the declaration.

    fn print reg in buf u8* reg in len i64 declaration fn print: body

  • Code can appear only in a function. At file scope there are only storage, labels, section lines, rec and def declarations, and fn declarations.

Assignments and stores

  • An assignment copies one value: n = a. Each operation has an in-place form: n += 1, n *= 10, n >>= 2.

  • A number can be negative: n = -1. The minus sign belongs to the number, so it works in each position where a number works.

  • At most one operation can follow =: i = n + 1, d = m >> cl. The operations are + - * / & | ^ << >>. The fma of the float bank is the one exception, and it takes two, because the machine has one instruction for them. An addition or a subtraction with only 8-byte registers as operands compiles to one lea. Any other one compiles to a copy, then the operation.

  • An addition can carry a scaled index. p = duos + i*@len duo is one lea of the machine, when the scale is 1, 2, 4 or 8. A label as the base goes into a register first, which costs one more lea. No lea multiplies by another scale, so ** writes that multiply: q = p + i**@ is one imul into the scratch register, then a lea with no scale. The base of a ** is a register, as p is here.

  • A field is a number, so it adds in that same lea. A leading dot takes its record from the value on the left: q = p + .n and p += .n.

  • A division gives both results at once, because the division of the machine does. One idiv leaves the quotient in rax and the remainder in rdx. A pair of destinations receives them in that order, and _ discards one of them. There is no modulo operator, and the remainder is the second result.

    n d = n / 10 quotient and remainder, one idiv _ d = n / 10 the remainder alone n /= 10 the quotient, in place

  • Multiply and divide take 2, 4 or 8 bytes, and they occur in a register, never in memory.

  • A shift counts by a number or by cl, the register that the machine shifts by. The compiler refuses a count in another register.

  • The type of the destination selects the signed instructions (idiv, sar). A raw destination takes its sign from the operand, or it is unsigned.

  • Pointer arithmetic counts bytes, not elements.

  • An assignment does not change the width of a value. The compiler refuses n = c when the two types have different widths. @cast and @narrow write the change, and they are the only way to make one.

  • A store is an assignment with a bracket expression on the left side. [count] = n writes through a label, and [p] = c writes through a pointer. The in-place forms that the machine does in memory also work: [count] += 1 increments in place, and [flags] |= 4 sets a bit.

  • Storage under section data, together with stores, gives you global variables.

Floats

  • f32 and f64 are the float types, of 4 and 8 bytes. A float lives in an xmm register, and an integer in a general one. Each set is a bank, and the banks meet only in a conversion.

  • The compiler allocates xmm0 to xmm14 as it allocates the general registers: live ranges, interference, and the same refusal when the values run out. A program cannot name an xmm register, because the name carries no width. The width goes in the instruction, as vmovss and vmovsd show. Between two registers the copy is one vmovaps, which moves the register and reads nothing.

  • A syscall writes no xmm register. Thus a float value crosses a syscall with no help, and takes none of the general registers that survive one.

  • A float literal is digits, a dot, digits, and an exponent when one follows: 1.5, 0.001, 6.02e23. The minus sign belongs to the literal, as it belongs to a number.

  • No instruction takes a float immediate. The compiler puts the literal in a pool after the code, and the instruction reads the entry. One entry serves each different text, and the labels stay out of the object file.

  • The compiler converts no decimal. It writes the text of the literal after .double or .float, and the assembler makes the bytes. Thus the compiler holds no conversion that could disagree with another one.

  • Storage takes a float the same way: pi: f64 3.14159 writes .double 3.14159. A number is refused there, because a float takes a float literal. The same holds in a body: x = 3 is refused, and x = 3.0 loads the pool entry.

  • A float adds, subtracts, multiplies and divides. Each operation is one instruction, on a register or straight from memory. The bank has no & | ^ << >>, no lea, and no remainder, so the compiler refuses them. An operation in place on float storage is refused too: the machine computes a float in a register alone.

  • The instruction takes three operands, so the destination is neither source and no copy comes before it. z = x + y is one vaddss, and x += y is the same instruction with the destination as the first source. The first source is a register, so a first operand that reads memory goes through xmm15 and costs one load.

  • A float comparison is one ucomis, and it sets the unsigned flags. The machine knows above and below, so < and <= compare the two sides the other way around. Thus a NaN fails all four relations. == and != also read the parity flag, so a NaN is equal to nothing, itself included.

  • Under a float condition the conditional move still writes an integer: if x < y n = m is one cmova. The compiler refuses == and != there, because a NaN needs the parity flag and a cmov reads one condition.

  • No cmov writes an xmm register. The bank has vminss and vmaxss instead, and each one compares the two values that it moves. Thus the conditional move of a float is one instruction, and it takes no comparison and no branch.

    if v < m m = v vminss: the smaller of the two if v > m m = v vmaxss: the larger if v < w m = v the same, into a third register if x > 10.0 x = 10.0 a clamp, straight from the pool

  • The operand is one side of the relation, and the other side is the value that the statement keeps. The compiler compares them, and it refuses each other move, because the machine writes a float on a condition in no other way.

  • The destination is free, because the instruction takes three operands. It can be either side, as if v < m m = v shows, and it can be a register that the relation does not name. There the two sides both survive the statement.

  • The two instructions compare with < and >. The machine has no form for <= and >=, so the compiler refuses those two and names the jump.

  • The statement writes the operand twice: once in the relation, and once in the move. Thus the address of a label needs a register first, as it does for **, and the compiler refuses the two names of one address.

  • The instruction compares by itself, and the corner cases are its own: it gives the operand when a value is a NaN, and when the two are zeroes of different signs. A jump and a copy give the other side there. Thus the two are not the same program, and the source says which one the machine runs.

  • The machine multiplies and adds in one instruction, and it rounds once. A multiply and then an addition round twice, so the two give different values, and the source says which one it wants. Thus d = x * y + z is one instruction, and it is the one place where two operations follow =.

    d = x * y + z vfmadd: one rounding d = x * y - z vfmsub d += x * y the same, spelled short d -= x * y vfnmadd: the product negates

  • The instruction reads the register that it writes, so the destination must be one of the three values: the one that the instruction adds to, or one of the two that it multiplies. Each place has its own form of the instruction. A fourth register is none of them, and the compiler refuses it and names the two statements that round twice.

  • The comparison is on the name that the source wrote, and not on the register that the compiler gave it. Thus the allocation decides no refusal.

  • d -= x * y negates the product, and d = x * y - d subtracts the destination. The two are different values, and each one has its own instruction.

  • The second operand can read memory, and the first one goes through xmm15. The integer bank has no fma, so two operations there are an error.

  • A syscall takes no float operand. @bits gives the bytes to an integer, and the kernel receives an integer.

  • The pool also holds an integer that no instruction takes as an immediate. A cmp reads 4 bytes of immediate, so a comparison with a wider number reads the pool.

Addressing

  • A bracket expression is the addressing mode of the machine, written as the machine defines it: a base, at most one index register, and a byte displacement.

  • The base sets the access width. Bare [s] is a plain dereference.

  • The index is always 8 bytes wide, and the machine always adds it. It can carry a scale of 1, 2, 4 or 8, the scales that the hardware supports. Thus i*8 indexes a table of 8-byte elements.

  • The stride names itself three ways, and each way says more than the last. A number is the operand of the machine, and the compiler examines nothing: i*8. The compiler compares a type with the element that the base addresses, and refuses a stride of another width: i*i64, i*rle, i*@len rle. @ takes the stride from that element, so the source repeats nothing and no number goes stale: i*@. @ needs a base with a type, and a raw register has none.

  • * is the scale of the hardware and nothing else. ** is the stride that the hardware has no scale for, and the compiler multiplies it: one imul into the scratch register, then the one access. Each stride has one spelling and the compiler refuses the other: * by 24 and ** by 8 are both errors, so the line says what it costs before the compiler runs. [p + i**@ + rle.ch] and [tab + i*8] are one access each. Only the first pays for an instruction of its own.

  • The address of a label takes the same scratch register that the product does, so ** addresses from a register. Put the address there first, which also lifts the lea out of the loop.

  • The displacement names itself two ways. rle.ch names the record, and the compiler compares it with the base. A leading dot takes the record from that same base, so the line writes the field and nothing else: [p + .ch]. Like @, it needs a base with a type, and a raw register has none.

  • A physical register can be a base. It carries no width, so the context must give one.

  • To index from a label, the address of the label must be in a register first. The compiler loads it into a scratch register.

    c = [s + i] k = [tab + i8] the scale of the machine k = [tab + i@] the same, from the type of tab c = [p + i**@ + rle.ch] one imul, then one access c = [p + i**@ + .ch] the same, the record from p [buf + i + 1] = c if [digits + i] == 0 goto done

Records

  • rec declares a record: a name for each byte offset in a layout. An off line follows for each field, with a name, a type, and an element count when there is more than one. In this language a field is its offset, as a label is its address. The fields sit in declaration order, and the compiler pads nothing. Padding is a _ field, written on purpose.

    rec rle off ch u8 off _ u8 7 off n i64

  • A record never generates an instruction. A field reference is the byte offset of the field. rle.n is 8, a number that the compiler knows, and it is legal in each position where a number is legal. @len rle is the number of bytes of the full record.

  • In a bracket expression, a field is a displacement with a width and a condition: the base must hold the record that the field belongs to, and the type of the field sets the access width. c = [p + rle.ch] is the one instruction of c = [p], with the displacement written as a name. A raw register as the base carries no type, so the compiler accepts the type of the field.

  • The base already names the record, so the field can come alone, after a dot and nothing else: c = [p + .ch]. The compiler reads the record off the base. Write rle.ch where the record says something, and .ch where repeating it says nothing. A raw register has no type and no record, so the dot needs the name there.

  • Outside a bracket, a field is a number, and the addition of it is a lea. The value on the left holds the record, so the dot works there too: q = p + .n, and p += .n. The dot changes no instruction. It says which record the offset comes from, so the compiler examines it. The named field in that position is a number like any other, and the compiler examines nothing.

  • In each other position where a field is legal, nothing stands to its left, so the compiler refuses the dot: the value of a def, a stride, an argument of a call. Write the record there.

  • A field of record type nests. [p + msg.hd + hdr.len] adds two offsets into one displacement, and it is still one access. Each field gives the record of the next, so [p + .hd + .len] is the same access.

  • The name of a record is a type. reg p rle* declares a pointer to a record. Storage under section bss reserves an array of records (runs: rle 3), and mem takes a block of them in the frame.

  • A record is never a value. It does not fit in a register, no assignment copies one, and no argument passes one. To copy a record, write the moves. To walk an array of records, advance a pointer: p += @len rle. A scaled index reaches only the records with a size that the hardware scales by. ** reaches every other size, and the compiler multiplies nothing that the source did not write.

  • The compiler reads a record before everything else, as it reads a function. Thus storage and bodies can name a record that the file declares below them. rec and def share that pass, and there the order of the file counts: a def sees the records above it, and a record counts its fields with the numbers above it.

Control flow

  • Control flow is labels and jumps: goto, and the conditional if x == y goto l.

  • addr declares a code label. The label is only a name, so it can share a line with the statement that comes after it.

    addr loop if c == 0 goto done n += 1; s += 1 goto loop addr done

  • The machine takes a condition on more than a jump, and each form puts the condition in front, as if already does. if x == y n = m is one cmov, and if x == y end is one jump.

    if hi < v hi = v # cmovl, and no branch if c < '0' end # jb to the end of the expansion

  • A conditional move writes a register of 2, 4 or 8 bytes, from a register or from memory. The destination keeps its old value when the condition fails, so the statement reads it as well as writes it, and the live range follows. The machine has no conditional store, no conditional number and no one-byte form, so the compiler refuses those three.

  • An end of an expansion is a jump to the end of the spliced body. Thus if c end is one conditional jump, and it costs what if c goto costs. The entry point returns with the ret of the machine, and the machine has no conditional one, so the compiler refuses if c end outside an expansion. Write the jump and the label there.

  • The position of the label sets its scope. In a function, the label is local: two functions can use the same label name, and the name does not go into the object file. At file scope, a label is global and names storage, not code.

  • A function body goes from its fn line to the next fn, the next section, or the end of the file. The compiler reads the full body before it emits it, so a forward jump needs no special syntax.

Compiler builtins

  • Builtins start with @, so they can never collide with the names of the program. A program cannot declare an @ name.

  • @syscall takes the syscall number first, then the arguments, in position. The compiler assigns them to the registers of the kernel. It puts the moves in an order that writes no value before it reads it. Bare @syscall, with the registers set in the source, is also legal.

    @syscall 1 1 buf len write(1, buf, len)

  • @len takes the name of storage and gives the number of bytes that it holds: the bytes of a string, the size of a number, or the reserve of a bss block. It is a number that the compiler knows, so it costs nothing at run time.

    @syscall 1 1 msg @len msg write the whole string

  • @frame gives the number of bytes that the body took off rsp for its mem blocks. The entry point has no caller and no prologue. Thus the kernel leaves argc at the top of the stack, and the arguments above it. The environment comes above the zero that ends them. That address is rsp + @frame, and the number follows the mem lines of the body, so a declaration added later does not move it.

  • The compiler knows the number once it has placed the blocks, which is after it reads a body and before it writes it. Thus @frame belongs to a statement. The compiler reads a def, a storage line and a count before any body, and it refuses @frame there.

    n = [rsp + @frame] argc, over the frame p = rsp + @frame the address of argc p += 8 and the arguments above it

  • @cast and @narrow change the width of a value. The destination gives the width, and the source gives the sign. The two of them give the instruction, so neither word names one.

  • @cast loses no byte. It widens a value into a larger destination: movzx for an unsigned source, movsx for a signed one, and movsxd from i32 to i64. If the two widths are equal, @cast retypes the value, and it costs no instruction when the two registers are the same.

  • @narrow reads the low bytes of a value and discards the others. The destination must be strictly narrower. This conversion destroys data, so it has its own word. There is no check at run time, because the language has no runtime.

  • The source can be a bracket expression. n = @cast [s] is one instruction, and it needs no register of the width of the source.

  • The destination can be a store, because a store also gives a width. [p] = @narrow n writes one byte with one instruction. A widening store is two instructions: the machine widens into a register, so the value goes through a scratch register, then into memory.

  • A source with no type of its own widens with zeroes. A number has no width, so the compiler refuses to convert one. A raw register as the base of a store gives no width either, so convert into a register first.

  • A conversion is the whole right side of an assignment. No operation follows it, and the in-place forms do not take one.

    n = @cast c a u8 into an i64: movzx n = @cast [s] the same, straight from memory q = @cast p u8* to i8*: no byte moves c = @narrow d the low byte of a value [p + i] = @narrow d the same, straight into memory

  • With the floats, seven words convert, and each one names a cost. @cast keeps the value. @round gives the nearest value. @trunc goes toward zero, @floor down, and @ceil up. @narrow discards the high bytes. @bits moves the bytes between the banks, and converts nothing.

  • The destination gives the width and the bank. The word and the destination select the instruction, so no word names one.

    n = @trunc x f64 to i64, toward zero: cvttsd2si n = @round x f64 to i64, to the nearest even: cvtsd2si y = @trunc x f64 to f64, and the fraction goes: roundsd y = @floor x the same, down x = @cast m i32 to f64, and no bit is lost x = @round n i64 to f64, and it can round f = @cast s f32 to f64 s = @round f f64 to f32 n = @bits x the eight bytes, unchanged: movq x = @bits n the same bytes, back

  • The machine converts a signed value of 4 or 8 bytes, and nothing else. The compiler refuses the unsigned types and the narrow ones, and the message names the step to take. It writes no sequence behind one word.

  • The compiler refuses the word that misstates the cost, in both directions: @cast where the value can round, and @round where nothing rounds. Thus x = @cast m holds an exact conversion, and x = @round n a rounding one, and the source says which.

  • A store also converts: [p] = @trunc x goes through the scratch register of the bank, then into memory, as a widening store does. [p] = @bits x is one movq, because the bytes move.

Syntax

  • The language has no parentheses and no commas. Spaces separate argument lists, calls, syscalls and value lists.

  • A statement ends at the end of the line. Thus the compiler knows where a list stops. ; also ends a statement, so related statements can share a line.

  • A character literal is a number. 'a' is 97, so if c == 'a' compares bytes. The escapes are \n, \t, \r, \0, \\ and \'.

    rdi = 1; rsi = buf; rdx = len; @syscall

Layout

b99.c			the compiler (single file, C99)
samples/s000-hello/	hello, world
samples/s001-reg/	virtual registers
samples/s002-do/	function call with named arguments
samples/s003-syscall/	syscall with positional arguments
samples/s004-cstrlen/	arithmetic, pointers and a loop
samples/s005-count/	a register per body, and two results
samples/s006-spill/	live ranges reused, and the frame written in the source
samples/s007-store/	a global counted in place, and written through a pointer
samples/s008-mem/	the storage of the body: two buffers, one place
samples/s009-rev/	arguments, registers and the frame in one body
samples/s010-index/	a base, an index and a displacement in one operand
samples/s011-scratch/	labels walked by an index, through the scratch registers
samples/s012-scale/	a scaled index: a u64 table walked by eights
samples/s013-nest/	a call inside a call inside a call, expanded into one body
samples/s014-upper/	a string made uppercase in bss: characters, @len, and a minus one
samples/s015-radix/	a square by multiply, its digits by divide, shift and mask
samples/s016-rec/	a record: named offsets into a layout that the source writes
samples/s018-lea/	the address of the i-th record in one lea, the scale written as @len
samples/s019-cast/	the same bytes summed twice: @cast, @narrow, and the sign of the source
samples/s020-out/	the direction of an argument: out carries a result and takes none
samples/s021-in/	the direction in: numbers and @len as operands, and nothing back
samples/s022-memarg/	a mem argument: the callee bound to the storage of the call site
samples/s023-cmov/	the conditional move: a largest and a smallest without a branch
samples/s024-ifend/	the conditional end: a guard that leaves the expansion
samples/s025-def/	a name for a number, and the arithmetic behind it
samples/s026-table/	a storage line that takes a list: numbers and addresses
samples/s027-stride/	the stride of an index: the type gives it, and '**' pays for it
samples/s028-frame/	the frame as a number: the stack of the kernel over the storage of the body
samples/s029-float/	float values: the literal pool, arithmetic and @trunc
samples/s030-convert/	the words of a conversion, both directions, and @bits
samples/s031-fltminmax/	the min and the max of the float bank, and a clamp
samples/s032-fltsel/	the same two, into a register that the relation does not name
samples/s033-fltfma/	the multiply and the addition in one instruction, and one rounding
samples/e000-noreg/	fourteen values live at once
samples/e001-recurse/	a function that calls itself
samples/e002-shift/	a shift counted by the wrong register
samples/e003-discard/	a division with both results discarded
samples/e004-field/	a field reached through the wrong record
samples/e005-scale/	a stride that the machine has no scale for
samples/e007-lea/	a lea scaled by sixteen
samples/e008-cast/	a @cast that would discard bytes
samples/e009-out/	an out argument passed a number
samples/e010-io/	an io argument passed a number
samples/e011-mem/	a mem argument passed a number
samples/e012-memin/	a store through a mem in binding
samples/e013-cmovnum/	a conditional move of a number
samples/e014-cmovmem/	a conditional store
samples/e015-cmovbyte/	a conditional move of one byte
samples/e016-topend/	a conditional end at the top
samples/e017-defwrite/	an assignment to a def name
samples/e018-defptr/	a label in a list of numbers
samples/e019-stride/	a stride of the wrong type
samples/e020-mulbase/	a '**' that walks a label
samples/e021-mulscale/	a '**' by a scale the machine has
samples/e022-frame/	'@frame' outside a body
samples/e023-word/	a word of the language as a name
samples/e024-dot/	a leading dot on a base with no record
samples/e025-dotlea/	a leading dot on a lea that adds the wrong record
samples/e026-fltcast/	a @cast that would round a float
samples/e027-fltuns/	an unsigned type in a conversion
samples/e028-fltbits/	a @bits between two widths
samples/e029-fltpair/	a float division with two results
samples/e030-fltop/	a shift of a float
samples/e031-fltcmov/	a conditional move of a float that no side of the relation feeds
samples/e032-fltnum/	a number where a float stands
samples/e033-fltminmax/	a min on a relation the machine has no form for
samples/e034-fltmmaddr/	a min that names one address twice
samples/e035-fmadst/	an fma whose destination is none of its three values
samples/e036-fmaint/	a multiply and an addition in the integer bank
self/b99.b99		the compiler again, written in b99
self/t/			its own tests
self/check.sh		the three stages, and the samples with both
self/README.md		what it takes, and where it differs
build/			build output (generated)
build.sh		./build.sh <sample>|all [run]
vscode/			an editor extension: the reserved words, and no more
  • build.sh builds the compiler, then builds the sample three times: from its reference assembly, from its b99 source, and from its C99 equivalent. With run, it runs the three binaries, and their output and exit status must be the same. With all, it does each sample, with the error samples included. The error samples have no C file, because what they refuse is legal C. It compiles each C file with -ffp-contract=off, so the C compiler writes no fma of its own and each float operation keeps its own rounding, as the b99 source says.

  • self/check.sh builds self/b99.b99 with b99.c. It then compiles every test and every sample with both compilers, and it compares what the two programs write and what they return. It then compiles self/b99.b99 with itself twice: the second stage and the third must be the same file.

  • To install vscode/, make a symlink to it in the extensions directory of the editor. Then start the editor again. The directory is ~/.vscode/extensions for Visual Studio Code, and ~/.vscode-oss/extensions for Code OSS.

    ln -s "$PWD/vscode" ~/.vscode-oss/extensions/b99

  • The extension gives TextMate scopes, and no colors, because colors belong to the theme. The scopes are the standard ones, for the reserved words, comments, strings, numbers, types, records, fields and labels. Thus the theme colors them, and settings.json needs no rule. A character literal takes the scope of a number, because it is one. A record name takes the scope of a type, as u8 does.

Status

All samples match: the reference assembly and the compiled b99 produce the same output.

self/b99.b99 is a second compiler for the language, written in the language. Its _start is the walk over the file. Each pass of the compiler is a function beside it, and one call expands it.

It reads the whole language: the direction of an argument, the conditions, def and the storage list, the strides, @frame, the leading dot, the widths of @narrow, and the float bank with its pool, its comparisons, its min and max, and its seven conversion words. It compiles every sample, every test under self/t/, and examples/flash/, the largest b99 program that is not a compiler. Each one gives the behavior that b99.c gives, and most of them the same bytes. The rest differ by the scratch register alone.

It refuses every error sample, and it gives the message that err.err holds, with the path and the line before it. The two compilers write the same line for each of the thirty-seven. b99.c holds many more messages, and this compiler gives one line for the errors that no sample names.

It compiles itself to a fixed point: the compiler it produces writes the same file again. self/README.md keeps the account, and it says what the language costs when you write a compiler in it.

The compiler makes four passes over the file:

  1. Tokenize.
  2. Read each rec and each def: the layouts and the numbers.
  3. Collect each fn: its signature and the position of its body.
  4. Emit _start, with each call expanded in place.

The compiler counts the registers that the program names raw after the expansion, over the statements that _start contains. A function that no call expands changes nothing, because its clobbers exist only where the compiler splices it.

The compiler expands the calls while it reads _start into statements. A call becomes four parts:

  • a copy-in of its operands
  • the statements of the callee, under its own names and labels
  • an end label for its ret statements
  • a copy-back into the operands

If the compiler expands a function that is already in expansion, it refuses the recursion. For each statement, the compiler records what it reads, what it writes, which physical registers it clobbers, and its possible successors. One statement alone does not give this information, because a forward jump names a label that the file defines later.

Register allocation then continues in order:

  • The compiler calculates the live-in and live-out sets of each statement. It iterates backwards until the sets do not change. From these sets it knows which virtual registers are live at the same time, and which physical registers each one must avoid. The result is an interference graph, and the compiler colors it, with the node with the most reads first.
  • Among the registers that a value can take, the compiler prefers one that its uncolored neighbors cannot take. A value that does not cross a syscall stays out of the registers that survive one. Those registers stay free for the values that need them.
  • If the compiler cannot color a virtual register, it refuses the program. The error names the statement where the most values are live beside it, and names those values. Thus the correction starts at the correct line: keep fewer values live, or move one to mem.
  • The entry point has no caller, so the compiler saves, pushes and restores nothing. Each allocatable register is available.

s006 shows the two limits. It names sixteen virtual registers on a machine with thirteen allocatable ones, because the second eight are declared after the first eight are dead. It also has nine values that cross a syscall, and a syscall leaves only eight registers. Thus the source puts the ninth value in a mem slot for that time.

Three registers are reserved as scratch, and the compiler never allocates them: r11 for addresses, rbp for integer values, and xmm15 for float ones. The machine has no addressing mode for a label plus an index register. Thus the compiler puts the address in r11 with a lea, then makes the access. A comparison of two such loads uses one scratch register for each. A store through such an address keeps the address in r11, and brings a memory value through rbp, or through xmm15 when the value is a float. An address is always an integer, so the float bank needs no address scratch of its own. A write to r11 or rbp in the source is raw, and the compiler gives you no protection from it.

Both compilers read the floats: the two types, the literals, the pool, the arithmetic, the comparisons, the fma and the seven conversion words. Both write the VEX encoding, with the three operands of an operation and of a min and a max. The five float samples are on the list of check.sh, and both compilers write the same bytes for them.

The author decides the design choices, not the tooling.

AGENTS.md and CLAUDE.md are symlinks to this file.