Say where sand.flan lands for the browser, and how to open it

BUILT.md gains the section: why the path had to become an embed, the rule for
a target-tagged .c file, why the agent is a no-op there and why that is not the
barf decision reversed, the exact commands including the .html output name and
the server a wasm module needs, and the four things only a human opening it can
settle.

NEXT.md strikes web blocker 1 and rewrites blocker 3 — nothing has been opened
in a browser is still true, and is now the only thing left.
This commit is contained in:
Joseph Ferano 2026-09-12 12:09:08 +07:00
parent ce346dd972
commit c0bf4ac34c
2 changed files with 148 additions and 0 deletions

115
BUILT.md
View File

@ -1795,3 +1795,118 @@ and node runs the emitted JS and gets `ok`. For raylib it builds `core-basic-win
module for the two things that would be false if the mechanism were wrong: an `asyncify_start_unwind` export, and a
`glViewport` import that can only have come from raylib's web platform. Import and export names are plain strings in
the binary, so this needs no wasm reader.
## sand.flan in a browser
**The flagship program builds for the browser and the artifact opens.** Three things were between it and the target,
and none of them was the `#include` the earlier note named.
**The brush was a path, and a path is what cannot work.** `(rl/load-texture "brush.png")` hands raylib a filename to
open; a bare relative path has no meaning where there is no filesystem, so raylib would have opened nothing and the
cursor would simply have been missing. It is `(embed "brush.png")` now, decoded by a new binding —
`LoadImageFromMemory`, which completes the chain embedded bytes -> `Image` -> `Texture2D` that `LoadTextureFromImage`
already had the other half of. The declaration is `(Ptr u8)` plus an explicit `i32` count, because `shim.ml` refuses a
slice parameter and its refusal says exactly that; the Flan wrapper beside it takes the slice apart, which is the
idiom `collision-point-poly?` and `load-font-ex` already established. One decode serves both textures now: the
unflipped upload first, then `ImageFlipHorizontal` in place, then the mirrored upload — **the order is load-bearing**,
and if the two badges look the same it was swapped.
`load-texture` and `load-image` lose their only call site in this repository by this change. That is deliberate rather
than an accident of editing: a path-based load is the one shape the browser cannot have, and the bindings stay for the
desktop programs that will want them.
The embedded path resolves **relative to the file the form is written in**, which is why
`test/programs/sand-headless.flan` still works: it reaches `sand.flan` through `../../` out of a sandboxed `_build`,
and the PNG is found beside `sand.flan` and not beside the working directory. `test/dune` therefore lists `brush.png`
as a dependency of every stanza that builds sand — an embed is read by the *checker*, so it is a build input and not a
run-time one. The same fact reached `test_session`'s `C-c C-k` case, which re-evaluates sand.flan's whole text: it now
passes `~origin`, which is the buffer path both editor paths already send, because the default `<eval>` origin would
resolve the embed against the working directory instead.
**A package's C may be addressed to one target, the way a `link` line already could.** `Load` collects a package's
`.c` files by listing the directory, and there is nowhere in a directory listing to put a tag except the name, so the
tag goes there, before the extension:
```
vendor/agent/flan_agent.c compiled everywhere, unless displaced
vendor/agent/flan_agent.web.c compiled for the browser, and displaces the above
```
One rule: **a tagged file is compiled only on its own target, and there it replaces the untagged file of the same base
name.** Untagged is the default and every existing package is untagged, so nothing that did not opt in changed.
Replacement rather than the pure tagging Go's `_windows.go` and Odin's `file_js.odin` use, and the difference is the
point — pure tagging would mean renaming `flan_agent.c` to `flan_agent.native.c` to teach the package about a target
it had never heard of, and this way a package gains a target by gaining a file. The selection is in `Build` and not in
`Load`, for the reason `select_lflags` gives: `Load` resolves imports before a target is chosen.
**The dev agent on the web is a no-op, and it is not the `barf` decision being contradicted.** The compile error that
led here — `struct timeval` incomplete, because emscripten's headers do not pull `<sys/time.h>` in transitively — is
the surface. Underneath: *the agent is a socket server and a browser has no sockets*. Adding the include produces an
agent that compiles, links, starts and can never accept a connection.
Refusing `vendor:agent` on a web target was the other candidate and is ruled out by arithmetic, not taste. Flan has no
conditional compilation, `sand.flan` calls `(agent/start ...)` unconditionally, and `Reach` cannot prune a package
something reachable calls into — so a build-time refusal means the flagship program does not build for the browser at
all without being edited into a second program. **A refusal is only honest when the caller has a way to not ask.**
The two decisions look contradictory and are not, and the difference is *what the caller loses*. `barf` is asked to
make something durable; a no-op returns success to a program that now believes the bytes are on disk, and the loss is
real, is the user's, and is discovered later or never. The agent is asked to accept redefinitions from an editor; on
the web there is no editor, no socket and no session — `--dev` is refused by name on every wasm target, so a web build
has no cells to install a redefinition into even if one arrived. **Nothing is lost because there was never anything
there.** `sand.flan` already says the same about a *native* release build, at the call site: "Building without `--dev`
is fine — nothing has cells to install into, so a module is refused on the listener thread and the loop never
notices." A web build reaches that outcome by a shorter route. `start` returns `-1`, which is what `flan_agent.c`
returns for a path it cannot bind; `poll` and `wait` return 0, which is what the native build returns on every frame
nothing arrived on. The whole argument is written at the top of `vendor/agent/flan_agent.web.c`, where the next reader
will meet it.
### Building it and opening it
The raylib archive is built once and is not in the tree. From the repository root:
```sh
sh vendor/raylib/build-web.sh # clones raylib 5.5 and compiles it with emcc
export FLAN_RAYLIB_WEB=$PWD/vendor/raylib/web/libraylib-5.5.a
```
`build-web.sh` prints that `export` line itself. Then:
```sh
flan build sand.flan --target=web -o sand.html
```
**The output must be named `.html`.** `--shell-file` is passed only when it is, because emcc accepts and ignores it
otherwise — so `-o sand` produces a module with no shell, no canvas, and a page that looks like it built fine and
paints nothing. Three files land beside it, in whatever directory `-o` names: `sand.html`, `sand.js`, `sand.wasm`.
**A `file://` URL will not work.** The page fetches `sand.wasm`, and a browser refuses that from the filesystem. Serve
the directory holding the three files:
```sh
python3 -m http.server 8000
```
and open **`http://localhost:8000/sand.html`**. Left mouse paints; the keys are the ones the native build has.
### What only a human opening it can settle
Verified headlessly, by `test/test_web.ml`: the three files exist, the module carries `asyncify_start_unwind` and
`glViewport`, and **brush.png's own bytes are in the module, whole** — the assertion that keeps the embed from
rotting. Not `IHDR`: stb_image, linked in from raylib, carries that string itself, so an `IHDR` check would pass on a
build where the embed emitted nothing.
Not verified, and not verifiable here. `node sand.js` instantiates the module, runs `main`, and dies inside `glfwInit`
on `window is not defined` — which says the module is live and says nothing about the canvas.
- **Whether it paints at all.** Nothing in CI has ever seen a pixel of this.
- **Audio.** `start-audio` generates a tone, `ExportWave`s it to `/tmp/flan-sand-tone.wav` and loads it back as a
music stream. That is raylib's own `fopen`, not Flan's `barf`, so the `#ifdef` in `flan_rt.c` does not cover it and
emscripten's MEMFS may well give it a writable `/tmp`. Every use is behind `music-ok`/`tone-ok`, so a failure is
silence and not a crash. Separately, browsers suspend the audio context until a user gesture, so `audio-ok` may be
true while nothing is heard until the first click.
- **The loop never exits.** `WindowShouldClose()` on `PLATFORM_WEB` is an `emscripten_sleep` that returns false, so
`until` never terminates and **none of `main`'s `defer`s ever run** — no `CloseWindow`, no `UnloadTexture`. That is
correct for a page, which is torn down by the tab closing, and it is worth knowing before reading anything into it.
- **Canvas size against `screen-width`/`screen-height`.** The shell is a string in `Build` and its canvas is not sized
from the program, so 900x600 may be letterboxed or cropped.

33
NEXT.md
View File

@ -365,6 +365,29 @@ array with a struct element, a 2-D struct array, and `[N string]` as both `defco
"The browser is the third target", for the mechanism and why asyncify rather than `emscripten_set_main_loop`. Four
things it does not cover.
~~**1. `sand.flan` has no web build, and the cause is one missing `#include`.**~~ **Built. It opens.** See BUILT.md,
"sand.flan in a browser", for the whole of it. Three summary lines, because the diagnosis below was right about the
structure and wrong about the cause:
- The `#include` was never the fix. **The agent is a socket server and a browser has no sockets**, so an agent that
compiles there is an agent that can never accept a connection. `vendor/agent/flan_agent.web.c` is three no-ops, and
`Build` selects it over `flan_agent.c` on `--target=web` and nowhere else.
- **Refusing `vendor:agent` on web was the honest-looking option and is ruled out by arithmetic.** There is no
conditional compilation, `sand.flan` calls `agent/start` unconditionally, `Reach` cannot prune a package something
reachable calls into — so a refusal means the flagship program does not build for the browser at all. A refusal is
only honest when the caller has a way to not ask. This does **not** reverse decision 2 above: `barf`'s no-op loses a
file the program believed it wrote, and there is nothing for the agent to lose because `--dev` is already refused by
name on every wasm target. The argument is written out at the top of `flan_agent.web.c`.
- **A package's `.c` files can now be addressed to a target**, by a tag in the name before the extension, and a tagged
file *replaces* the untagged file of the same base name on that target. This is the C-source half of the
`@native`/`@wasi`/`@web` link-line mechanism decision 2 pointed at for per-package target isolation.
The brush is `(embed "brush.png")` decoded through a new `LoadImageFromMemory` binding. `load-texture` and
`load-image` now have no call site anywhere in this repository — deliberately, because a path-based load is the one
shape the browser cannot have, and said here so it is not read later as an accident.
The original entry follows.
**1. `sand.flan` has no web build, and the cause is one missing `#include`.** `vendor/agent/flan_agent.c` does not
compile under emcc: *variable has incomplete type 'struct timeval'* at line 426, because emscripten's headers do not
pull `<sys/time.h>` in transitively the way glibc's do. `sand.flan`'s `main` calls `(agent/start ...)`
@ -393,6 +416,16 @@ The original text follows. `sand.flan` does `(rl/load-texture "brush.png")` agai
all. Answering this means either giving a single-file program a way to carry build arguments, or making assets their
own declaration rather than a linker flag. No flag was invented for it here.
**3. Nothing has been opened in a browser.** *Still true, and now it is the only thing left between here and
"someone played with it".* `sand.flan` builds for the web, the module carries asyncify, raylib's GL imports and
brush.png's own bytes whole, and `node sand.js` gets as far as `glfwInit` before dying on `window is not defined`
which proves the module is live and proves nothing about the canvas. BUILT.md carries the exact commands to serve and
open it, and the list of what only a human will discover: whether it paints, whether the audio round trip through
MEMFS survives, and the canvas size. The `until` loop never exits on the web, so none of `main`'s `defer`s run —
expected, and worth knowing before reading anything into it.
The original entry follows.
**3. Nothing has been opened in a browser.** The test is headless and permanently so: it asserts the artifact's shape,
the `asyncify_start_unwind` export and the `glViewport` import, and that node runs the emitted JS. Whether the canvas
actually paints is unverified by anything in CI, and a human should look once.