The simulation was in a package of its own for one reason: importing raylib
linked libraylib on every target, so the headless run could not name the
package the interactive one needs. That reason is gone, and the split was
never anything else — the physics is the same code either way.
So sim.flan is back inside sand.flan, and test/programs/sand-headless.flan
imports sand.flan itself: window, raylib bindings, dev agent and all. It builds
for wasm32 anyway. Nothing it calls reaches raylib, so no shim is compiled, no
-lraylib is passed, and the front-end's functions are never emitted; sand.flan's
main is not exported, so the only main is the headless one. The hash is
unchanged on both targets at both optimisation levels, which is the point —
a refactor that moved the number would have moved the simulation.
The new cases cover what made it possible rather than only the result: a
package nothing calls into, native and wasm32; raylib reached both directly and
through sand.flan and read once; and the three refusals — sand/main, one
directory under two aliases, and two mains.
test_session's package-qualification case moves to vendor/agent, which is now
the package in the tree with a defn in it.
A package handed over its .c files and its `link` arguments the moment it was
imported, whatever the importing program did with it. That is what made sand's
two halves two files: anything naming vendor:raylib linked libraylib on every
target, and on wasm32 that link cannot succeed, so the headless run could not
so much as mention the package the interactive one needs.
Reach.link answers it from the checked program instead. Start at main and at
the globals that run before it, follow every call — including the Handled
frames, where a lifted handler clause is reached by address and by nothing
else — and keep what is reached. A package none of whose externs survive
contributes no C and no linker argument.
Dropping the flags alone would only move the failure: the bodies that called
into raylib would still be emitted, and wasm-ld would fail on the symbols
rather than on the argument. So the same walk prunes the functions and externs
too. Only those — globals, structs and unions stay, because an unreferenced
global is bytes in BSS and a dropped one is a silently different program.
Dev builds keep everything. What a REPL may redefine next is not a function of
what has been called so far.
The wasi-sdk candidate had an LLVM version in it, which moves release to
release — so the path advertised as the proper article would have matched only
by coincidence, while the emscripten one beside it was derived. Both are
derived now.
calc-me on wasm32 covers what the other three cases cannot: flan_argv hands
Flan an array of flan_slice built in C, so what it pins is the element stride
of a ptr+len pair — 16 bytes native, 12 on wasm32 — rather than a field
offset. It is also the claim in this file's own header, that the table runs on
the second target, honoured for the first time.
flan emit refuses --target rather than stripping it. The IR really is
target-free, so ignoring it is correct and silence about it is not.
Image first and deliberately: it is CPU-side, so it is the only large piece of
raylib that can be asserted headlessly rather than looked at. gen-image-color,
the pixel reads, both flips, a PNG round trip through export and load, and the
resize and crop dimensions and contents are all in the table at -O2 and -O0.
The shapes, text and timing calls are observed only, by running sand under Xvfb
and looking, and the program and NEXT.md both say which is which.
Five permutations were run red and restored: Image's width against height and
mipmaps against format, GetImageColor's two indices, the two flip wrappers
bound to each other, and the crop rectangle's width against height. The third
of those also broke the export and load lines, which is what makes the PNG
round trip verified rather than merely plausible.
Two corrections to the brief it was given. MeasureText is not headless material
- it measures with the default font, which only InitWindow loads, and a C probe
returns 0 - and the same is true of the frame-time and screen-size calls. And
the raylib.h on this machine is 5.1-dev while the linked library is 5.5, so
every signature was checked against nm -D instead: IsImageValid rather than
IsImageReady, and DrawRectangleRoundedLines takes no thickness.
Font loading is refused by name. A Font carries a Texture2D, a Rectangle* and a
GlyphInfo*, and a GlyphInfo carries an Image - two more aggregates and two owned
arrays, for something with no headless test.
sand-headless imports no raylib so that it can run here, and the point of the
case is not that a module exists — it is that the number matches native byte
for byte, which is only possible because rand-f32 is Flan's rather than libc's.
It does, at -O2 and at -O0; -O0 is the cheap way to say the agreement is not a
coincidence of how LLVM folded the float arithmetic. values and machine run
there too, which is where a 32-bit pointer would have shown.
Four separate things can be missing — clang's wasm target, the sysroot, the
builtins, a WASI runtime — so the skip is a probe rather than a lookup: build
the smallest program and run it, and print what went wrong. A which(1) would go
red on the machine where Node is too old, with a reason nobody could read.
No wasmtime and no wasmer here, so the runner is node:wasi, with wasmtime and
wasmer preferred if either appears. --no-warnings because node:wasi writes to
stderr on every run and this harness compares combined output.
floor, ceil and round over f32, which is what a position and a tile coordinate
are here. The only rounding mode available is the cast's truncation toward
zero, so each of these is that cast plus the correction the mode does not
make, and the content is which inputs make the cast itself undefined. NaN
fails every comparison, so it needs its own (not (= x x)) and nothing else
finds it; the infinities fall out of the magnitude test; and above 2^23 an f32
has no fractional bits left, which makes returning the input there the exact
answer and also the guard that keeps the cast inside i32.
round is half away from zero, written as floor of the magnitude and mirrored.
The obvious (floor-f32 (+ x 0.5)) is wrong twice: half-up rather than
half-away, so -2.5 comes out -2, and at the largest f32 below 0.5 the addition
alone rounds to 1.0 and answers 1 for a number under a half. Both are in the
table, which is why every case there is a negative or a half.
sqrt is the decision in this commit and it goes out to libm, which is a change
to the release link and so is said out loud. Every other number in the prelude
is reachable from the four operations and a cast; a square root is not.
Newton's method needs a starting guess, the good guess comes from
reinterpreting the exponent bits, and the language has only value-preserving
casts - no bit-cast between f32 and u32. Without one the iteration needs a
scaling loop to normalise and still produces a result that is merely close,
which is the one thing a standard library must not hand back. IEEE-754 makes
sqrt correctly rounded, so libm's answer is the same bit pattern on native and
on wasm32; for this function the byte-identical argument points at C rather
than away from it.
The cost is -lm on every link, and its placement matters. It goes after the
objects, not in the leading flags, because --as-needed drops a library named
before the object that wants it. Worse, at -O2 LLVM folds most sqrtf calls
into the hardware instruction and the symbol never has to resolve - so this
looked linked before the flag existed and failed only at -O0, which is exactly
why the table runs both. Untested against --target=wasm32: wasi-libc ships
libm.a as a stub because the symbols live in libc, so it should be inert
there, but nothing here exercises it.
The better fix is not in this lane. llvm.sqrt.f32 as a builtin in check.ml and
emit.ml is one instruction, no symbol and no flag, and it belongs to whoever
owns the compiler.
Finishing the text family the previous lane started. All three are over [u8]
and none of them allocates, which is what decides their shapes.
trim answers a slice of its input. That is the only shape available without an
allocator, and it is also the better one: there is no new storage, only a
narrower view of the caller's, so the result dies with its owner and trimming
modifies nothing. Both loops test (< lo hi), because an all-whitespace input
otherwise walks lo past hi and (slice s lo hi) traps on a reversed range - the
same trap the bounds table already asserts on. That input is in the case list.
index-of-bytes is naive and stays naive. Boyer-Moore wants a skip table sized
by the needle, which is an array, which is an allocation. The empty needle
answers Some 0 so that index-of-bytes and starts-with? agree on every needle,
and the length test returns before the loop so a needle longer than the
haystack cannot build a window off the end.
parse-f64 splits the work where the two halves actually differ: the grammar is
Flan's and the rounding is libc's. parse-i64 is entirely Flan because strtoll's
answers are wrong for a caller - 0 for "", 0 for "abc", 12 for "12x" - and not
because decimal-to-binary conversion is suspect. Reimplementing correctly
rounded conversion is a different and much larger problem than rejecting junk,
and IEEE-754 already guarantees strtod gives the same bits everywhere. So this
validates the whole slice and only a slice that is entirely a number reaches
bytes->f64. Every refusal in the table - "", "abc", "1x", ".", "1e", " 1",
"1 ", "0x10", "nan" - is a plausible number out of strtod.
Two caveats, both written into the source rather than discovered later. The
locale worry that keeps parse-i64 in Flan does apply to strtod's decimal point,
and is moot only because nothing in the runtime calls setlocale; if that stops
being true this is what breaks. And the length is capped at 511 because
flan_bytes_to_f64 truncates there - a validator that approved 600 digits would
be approving a different number than the one strtod reads.
digit? and space? exist because parse-f64 and trim need them, and calc-me loses
its own byte-identical digit?. One top-level namespace makes the second
definition an error rather than a shadow, which is the rule doing its job: two
copies that later drift apart is exactly what it prevents.
Every other struct in the package is handed to raylib and handed back, and
that proves nothing: store-and-return is symmetric, so C writes and reads the
same wrong slots for any field order. An Image is different. raylib computes
with it, and two computations answer differently per axis.
gen-image-color takes two scalars and returns a struct reading 4, 2, 1, 7 —
four distinct values in four adjacent i32 slots, with no input struct for a
permutation to cancel against. Texture2D never got that: nothing without a GPU
reads its width, height or mipmaps at all.
And get-image-color indexes y*width + x, so on a 4-wide, 2-tall image (3,0)
exists and its transpose does not. That is the axis discriminator the
collision family could not be — exchange x and y in the wrapper and the read
goes out of bounds. The two flips say it twice more: on two rows, one moves a
mark the other leaves alone.
The PNG round trip is not the symmetric trap either. stb's encoder and decoder
are external ground truth; they agree with each other, not with whatever field
order Flan believes in.
Verified to fail, each restored after: width against height, mipmaps against
format, x against y in the shim, the two flips bound to each other, and the
crop rectangle's width against its height.
Six of them were bound, linked, and had never been called by anything. That is
the state a wrong argument order survives indefinitely: the link succeeds, the
program runs, and the answer is nonsense that nobody has looked at. An audit for
wrappers with no caller is worth doing after any binding lane.
All six turned out to be correct, which is worth recording either way - the
point of the audit is not that it finds bugs but that it converts "probably
fine" into "called, and the answer checked".
Each has a case that must come out the other way, because a predicate that
always said yes would pass a single one.
The one that earns the most is the polygon, the only binding here that crosses a
slice, so the only place ptr+len has to arrive as raylib's pointer and count.
Everything else about it would pass with a hardcoded count or with the pointer
alone; the same point against the same array with three corners instead of four
is what pins the length. Verified by hardcoding the count in the shim and
watching it go the wrong way.
Finishing the 2D lane's unfinished work: the collision family was written and
had no tests when the session ended. It is the best material a headless table
gets, since every one of these is pure and needs no GL context.
Two plausible tests in a row turned out to check nothing, and that is the part
worth keeping. A struct round trip is symmetric and passes for any field order -
the texture lane found that one. The second is subtler: no axis-aligned geometry
can pin Vector2's fields, because exchanging x and y is a reflection that is
applied on the way in and undone on the way out. Swapping the shim's own typedef
leaves every collision case passing. Distances never even see it.
What does pin Vector2 is the rotated camera, because a rotation is not
axis-aligned and does not commute with the reflection. That case is load-bearing
and the comment now says so, because the collision cases look like they cover
the same ground and do not.
What the new cases do pin is Rectangle, completely: swapping width and height
turns three of the four predicates the wrong way. Verified by doing it.
collision-lines answers (Option Vector2) rather than a bool and an
out-parameter, because raylib leaves the out-parameter untouched when the
segments do not meet and a caller who forgets reads whatever was there.
GetScreenToWorld2D computes from every field of a Camera2D - offset, target,
rotation and zoom - so a wrong field order produces a wrong coordinate rather
than the same numbers back. That is the standard the texture lane arrived at
the hard way: a struct round trip is symmetric and passes for any layout.
bytes->i64 is strtoll behind a primitive, and strtoll returns 0 for "", for
"abc", for a lone "-", and for the "12" in "12x". None of those is
distinguishable from a real 0, so any program that parses input it did not
write is already wrong and has no way to find out. parse-i64 takes the whole
slice or refuses it and says so with None. It is also the version that answers
the same on wasm32: strtoll is libc's and locale-sensitive, which is the same
argument that put the PRNG in the prelude rather than leaving it to rand().
The byte predicates are over [u8] rather than over string on purpose. (bytes s)
is one call at the call site, and in exchange one copy of each function serves
strings and byte slices both — which is as near a generic as this gets. Each
tests its length before it slices, and `and` short-circuits, so a prefix longer
than the subject answers false instead of tripping the slice bounds check.
sign-f32 and lerp are the only two numeric helpers here, because they are the
only two that decide something. clamp is (min hi (max lo x)) and abs is
(max x (- 0 x)) over builtins that already exist — a prelude wrapper is a
function emitted into every program to save a caller nothing. sign-f32 answers
0.0 for NaN, which is a choice and is written down. lerp is the weighted sum
and not a + t*(b - a): the latter does not land on b exactly at t = 1.0, and a
position that never quite arrives is what interpolation gets bug reports for.
floor, ceil and round are deliberately absent. (f32 (i32 x)) is fptosi, which
is poison out of range, and shipping that as a documented limitation is the
same class of bug NEXT.md already records twice under Sharp edges. Correct
lowering is llvm.floor.f32 in emit.ml, which is not this lane. sqrt is absent
for a different reason: it is an extern to libm, and what libm means on wasm32
is a decision the FFI owns, not the prelude.
rand-i32-range answers lo for an empty or reversed range rather than dividing
by zero, which is immediate undefined behaviour and not merely a wrong number.
Both range functions draw exactly one rand-u32 and neither changes it, so the
sand hash still pins the generator; the new test pins the derivations off a
fixed seed, which nothing else would have caught.
GetScreenToWorld2D and GetWorldToScreen2D are pure arithmetic over every
field of a Camera2D, so they run with no window at all — the best headless
material the package has had. Both directions are asserted as absolute
answers rather than as a round trip, because an inverse cancels a permuted
layout exactly the way store-and-return does.
The rotated case earns its awkwardness: exchanging x and y in Vector2
mirrors every component-wise formula and the answer comes back mirrored
too, so nothing until now could tell the two floats apart. A rotation mixes
them. It reports ok/bad against a tolerance because 90 degrees goes through
sinf and the answer is 27.9999981, and the table compares stdout byte for
byte at -O0 and -O2.
A sequence library normally returns new sequences. There is no allocator, so
every one of these mutates the storage it was handed and a slice is the handle
that makes that useful: (slice grid 4 9) is ptr+len into grid, so sorting it
sorts those five elements and leaves the rest of grid alone. The test asserts
exactly that — it sorts a subslice and prints the whole owning array — because
it is the property that would die silently the day a slice parameter started
being copied rather than passed by value, and -O2's mem2reg would hide it.
Insertion sort rather than anything faster. Quicksort wants a stack and
mergesort wants a buffer, and neither exists; insertion sort needs a swap and
two indices. It is also the only one of the three whose inner loop is short
enough to read, which matters more than the asymptotics on the slice sizes a
frame loop actually sorts. The `and` guarding it short-circuits, and that is
load-bearing: at j = 0 the left test fails and (at s -1) is never evaluated,
so the bounds check never fires.
Over [i32] and nothing else. There are no generics, so a second element type
is a second copy of all seven functions emitted into every program that links
the prelude, and i32 is the type indices, ids and tile values already have.
An f32 set waits for a program that wants one.
min-i32 and max-i32 return (Option i32) rather than a sentinel because there
is no i32 that means "the slice was empty" and is not also a possible element.
sum-i32 accumulates in i64 and widens each element explicitly — there is no
implicit widening anywhere, and an i32 total over a screenful of i32 is how a
sum wraps without anyone noticing.
Every other program in the table is run twice for the reason stated next to
them: at -O2 mem2reg launders a sloppy alloca, so -O0 is what tests the IR
actually emitted. The raylib case had been the exception, and it is the worst
one to exempt — five of its calls hand C the address of a local struct, which
is exactly the alloca that comment is about.
It passes as it stands. That is the point: the case that only ever ran
optimised was a coincidence away from hiding something.
Texture2D and Rectangle are the two structs the texture calls need, and they
are the ones whose layout can be silently wrong: five 4-byte fields in a row,
and four floats in a row, so a permutation still reads as plausible numbers
everywhere.
The obvious test — hand raylib a struct, read it back, compare — is worthless
here, and I only found that out by trying it. Storing and returning is
symmetric: swap two fields in the Flan defstruct and the round trip still
agrees with itself, because C writes and reads the same wrong slots. That test
passes whatever the layout is, which is the kind of test this project would
rather not have at all.
So the headless case uses the two things raylib computes from the fields
without a GPU. GetCollisionRec turns (0,0,10,4) and (6,1,10,10) into
(6,1,4,3), four different numbers each derived from a different pair of
fields, and no permutation of Rectangle survives it. SetShapesTexture keeps a
Texture2D without touching GL and substitutes 1 1 1 1 7 when the id is zero,
so a zero id pins the first field, the 7 pins the last, and a zero width
stored rather than substituted is what stops that pair from passing with id
and width swapped. Each of those was checked by permuting the defstruct and
watching the case fail.
What is left unpinned is width, height and mipmaps against each other; nothing
raylib does without a GL context reads them. That is stated in the program
rather than papered over, because the alternative is a case that looks like it
covers them.
set-shapes-texture, get-shapes-texture, get-shapes-texture-rectangle and
get-collision-rec are real bindings, not test scaffolding — they are bound
here because they are also the only pure consumers of these two structs.
spec-conditions.md §2. The same lookup as signal, and the difference is
entirely what happens when the walk ends: signal returns Unit and the
signalling function carries on, error has type Never and the program stops.
Only a transfer gets past it, so emit puts a guard after the call and then
unreachable - and flan_error cannot be marked noreturn for the same reason, it
does return, on exactly one path.
Being Never is what lets it stand where a value was expected, which is the
fall-through shape §1's load-texture example needs and the reason it is worth
having before the break loop rather than after. An unhandled one names the
condition on stderr and dies the way every other trap does; flan_error is where
the dev-build break loop will go.
The two spellings share one AST and IR node with a kind beside them, the same
shape Ast.unwrap already uses for some and try, because they differ in one
decision and nothing else. test/programs/error.flan is the unhandled case,
asserted on the exit code and the reason rather than through the outputs table,
which only has room for a program that exits 0.
spec-conditions.md §3 to §6. A handler runs where the signal was, decides, and
control resumes at a restart-case further out - so unlike step 1 this one does
alter control flow, and it is lowered explicitly rather than through platform
unwinding, because wasm32 cannot unwind and because a cmp/jne after a call
reads like ordinary code.
The channel is the out-parameter §6 settled on: one ptr appended to every Flan
signature, written by an invoke-restart and checked after every call. The
return type stays what the source says, one pointer threads down the whole
chain, and a frame that sees the channel set just returns early - which reuses
the existing return path and with it §5's defers for free. Emit.signature was
already the one place a signature is spelled, which is what made that part
small.
Every function is transfer-transparent, release included. §6's escape analysis
is an optimisation; in a dev build a cell can hold anything, so the honest
answer to what a call can reach is anything, and uniform means redefinition
acquires no new refusal class.
The transfer target is the restart frame's own address and not a static clause
id, which corrects what the handoff note had settled. An id has to be unique
against every module a running program may later load, and a hash is only
probably unique - two restart-cases colliding means the inner one silently
catches a transfer aimed at the outer. The frame is an alloca in the function
that offers it, so the address is exact and it also says which clause, which is
how clause ids disappeared. Re-entering a restart-case then needs nothing
extra, since each activation allocates its own frames.
Cleanup is landing blocks, one per region rather than one per function: a
restart-case's pops its frames and either dispatches or forwards, a
handler-bind's pops the handler frames on the way past, and the function's own
runs its defers and returns. One function-wide block would have jumped straight
past the very restart-case that was meant to catch the transfer. The channel is
cleared before any cleanup runs and put back after, or a defer's first call
would branch straight back into the block it came from.
flan_signal takes the channel and passes it to each handler, stopping once one
writes to it. That makes the one C frame every handler is reached through
transparent to a transfer, which it has to be; it is also the only one, since
extern is Flan-to-C only and there are no function values yet.
Refused by name with the reason, each with a test on the reason: restarts with
parameters, return inside a restart-case body, one restart-case offering a name
twice, and invoke-restart inside a defer - a defer is the cleanup a transfer
already runs, so starting one there leaves the defers half run with two targets
and no way to choose. The lexical case is the checker's and the one that
reaches a function through a call is trapped at run time. No restart of that
name is a located runtime error at the invoke site, because there is nowhere to
resume.
Two things found on the way. `{ ctx with in_handler = true }` was a latent bug:
ctx.slots is mutable, so a copy allocated the body's slots into a record the
function never saw again - harmless only because no handler-bind body in the
tests had a let in it. And test/reload_host.c calls flan.outer through an asm
label, which does not fail at link time when the prototype is a parameter
short; it reads garbage as the channel and dies somewhere else.
test/programs/restarts.flan runs at -O2, at -O0 and as a dev build. -O0 is not
redundant: the guard after every call is control flow the optimiser would
otherwise launder, and the dev build is where each of those calls goes through
a cell.
spec-conditions.md §1 and §2 and nothing else, because those two are worth
having alone: signal returns Unit whatever it finds, a handler that returns
normally leaves the signalling function to carry on, and with nothing matching
it is a no-op. So none of §6's transfer machinery exists yet and no signature
changed - which is the whole reason to do this step first.
The runtime is a linked list. Establishing a handler is two stores and a push
onto a frame on the establishing function's own stack, and signal with an empty
stack is a null check, which is what §2 asks for. Popping is by frame rather
than by count, so restoring what this one displaced is right even if something
below it left the stack out of step.
A condition's type is a hash of its name and not an index: an index would shift
the moment a struct were added, and every handler a running program had already
pushed would match the wrong type. The condition crosses as a pointer, since a
handler runs while the signalling frame is alive and there is nothing to copy -
but what the clause binds is the condition itself, the pointer being a hidden
parameter and the name a slot loaded from it, so a handler passing c to
something expecting the struct is not handed an address.
A clause is lifted into a function of its own, because a handler runs from
wherever the signal was and cannot be a branch in the function that wrote it.
That gives two refusals, both by the house rule. A handler cannot see the
establishing function's locals - that is a closure with an explicit
environment, so a reference to one is refused for that reason rather than
reported as an unknown name. And return inside a handler-bind body is refused,
since the frames are popped on the way out and an early exit would leave them
pointing into a function that has gone.
Settled in advance for the next step: in a dev build every function is
transfer-transparent, because a cell can hold anything and the honest answer to
what it can call is anything. Same bargain as the indirect call, and it means
redefinition acquires no new refusal class. Still open is whether the
discriminated result is returned by value or through an out-parameter.
Two things, and either alone is useless, so they are one commit.
Emit.redefinition compiles one function into its own module against a host
that is already running. What it does *not* define is the design: a global is
external, so state survives a reload and sand's grid is not reset by editing
the code; every other function is a declare, so a redefined settle calls the
host's move-grain rather than a frozen copy; there is no main. Build.shared
puts that text through llc + ld -shared. ld, not clang, because a shared object
is allowed undefined symbols and that is the whole mechanism - and because the
driver is 50ms of a 20ms job. Measured here: llc 16ms, ld 3ms, dlopen 0.04ms.
Loading a body is not installing it, though. A call bound at link time cannot
notice a new one, so a dev build routes every Flan-to-Flan call through a cell
- a mutable global holding the address of the function that is current - and a
module publishes itself with one store. The cell load is emitted after the
arguments, so a redefinition between two calls cannot land inside one.
Three details that are not free choices. flan_reload_install is a named
function rather than an ELF constructor, because the agent has to choose when
the store happens and a constructor would do it during dlopen, mid-frame, on
whatever thread called it. A redefinition's own body is hidden, because default
visibility in a shared object is interposable and that applies to taking the
address too: plain @"flan.bump" inside the module resolves to the host's copy,
so the installer would publish the function it was replacing and the reload
would silently do nothing. And -rdynamic is what exports the cells at all, so
it and cells are one flag: Build.opts.dev, flan build --dev, the first time
opts means something semantic rather than an optimisation level.
The test is one process, because two runs would prove nothing about a swap,
and two .so paths, because dlopen caches by path and would hand back the first
handle. Every call in it goes through outer, compiled once into the host and
never rebuilt, so a changed answer can only mean its call site followed. v2
recurses through its own cell, which is the interposition case; it would print
the old body's text if it did not. helper differs between the fixtures purely
as a tripwire for a module that grew its own copy.
LLVM cannot fold the indirection - the cell is an external mutable global - and
a --dev calc-me keeps 46 indirect calls at -O2. values, machine and
sand-headless now run as dev builds in the acceptance table too; the sand hash
is the one result that would notice a call reaching the wrong function.