sand.flan opens in a browser: the sheet is embedded and the agent is a stub

Three things stood between the flagship program and the web target, and each
is answered here rather than worked around.

The brush was a path. (rl/load-texture "brush.png") hands raylib a filename to
open, and a bare relative path means nothing on a target with no filesystem.
It is (embed "brush.png") now, decoded through a new binding —
LoadImageFromMemory, declared (Ptr u8) plus an explicit count because the shim
generator refuses a slice parameter and says so, with a Flan wrapper taking
the slice apart exactly as collision-point-poly? and load-font-ex already do.
One decode now serves both textures: the unflipped upload first, then
ImageFlipHorizontal in place, then the mirrored one. load-texture and
load-image lose their only call site in this repository; that is deliberate,
because a path-based load is the thing that cannot work here.

A package's C may now be addressed to one target, the way a link line already
could. A .c file may carry a tag before its extension — flan_agent.web.c — and
on that target it is compiled and *replaces* the untagged file of the same
base name. Replacement rather than plain tagging, so that teaching a package
about a new target is additive: the file that was right on three targets is
not renamed to say so. Selection is in Build and not in Load, for the reason
select_lflags gives.

The dev agent on the web is a no-op, and the reasoning is written at length in
vendor/agent/flan_agent.web.c. Short version: the agent is a socket server and
a browser has no sockets, so the missing <sys/time.h> was the surface and not
the cause. Refusing vendor:agent on a web target was the other candidate and
is ruled out by arithmetic — Flan has no conditional compilation, sand.flan
calls agent/start unconditionally, Reach cannot prune a package something
reachable calls into, so a refusal means the program does not build for the
browser at all. This does not contradict the `barf` decision made earlier
today. `barf` is asked to make something durable, and a no-op returns success
to a program that now believes bytes are on disk. The agent is asked to accept
redefinitions, and on the web there is no editor, no socket and no session —
--dev is refused by name on every wasm target — so there is nothing to lose.
sand.flan already says the same of a native release build at the call site.

test/test_web.ml builds sand.flan for the browser and reads the module for
brush.png's own bytes, whole. Not "IHDR": stb_image carries that string itself,
linked in from raylib, so it would pass on a build where the embed emitted
nothing. It is not run — node has no DOM, so main reaches InitWindow and dies
inside glfwInit on `window is not defined`, which says the module is live and
nothing about whether the canvas paints.

test/dune gains brush.png, because an embed is read by the checker and the
headless case reaches sand.flan through ../../ from a sandboxed _build.
test_session's C-c C-k case now passes ~origin, which is what both editor
paths already send; omitting it was testing a request nobody makes.

dune test is green. Docs follow in the next commit.
This commit is contained in:
Joseph Ferano 2026-09-12 12:07:16 +07:00
parent eb98c5859a
commit ce346dd972
7 changed files with 268 additions and 11 deletions

View File

@ -517,6 +517,65 @@ let select_lflags opts flags =
else Some (expand_vars ~where:"link" f))
flags
(* ── A package's C may be addressed to one target too ─────────────────
[link] lines carry a @tag; a package's .c files had no such channel, and
[Load] collects every one of them by listing the directory. There is nowhere
in a directory listing to put a tag except the name, so the tag goes there,
before the extension:
flan_agent.c compiled everywhere, unless displaced
flan_agent.web.c compiled for the browser, and displaces the above
The rule is one sentence: **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 does not opt in changes.
Replacement rather than pure tagging, which is what Go's `_windows.go` and
Odin's `file_js.odin` do, and the difference is deliberate: pure tagging
would mean renaming the file that already works [flan_agent.c] becoming
[flan_agent.native.c] to teach a package about a target it had never
heard of. Making the browser's answer *additive* means a package gains a
target by gaining a file, and the file that was right on three targets is
not touched to say so.
The selection is here and not in [Load] for the reason [select_lflags] is:
[Load] resolves imports before a target is chosen, and the same program is
built for both.
The base name is what is matched, not the path: two packages each with a
[flan_agent.c] would already collide at the link, so there is nothing new
to disambiguate here. *)
let split_csrc_tag path =
let base = Filename.basename path in
let stem = Filename.remove_extension base in
match Filename.extension stem with
| "" -> (stem, None)
| dot_tag ->
let tag = String.sub dot_tag 1 (String.length dot_tag - 1) in
if List.mem tag link_tags then (Filename.remove_extension stem, Some tag)
else (stem, None)
let select_csrcs opts csrcs =
let want = target_tag opts in
(* The base names a tagged file speaks for on *this* target. Only these
displace; a [foo.wasi.c] is invisible to a native build in both
directions, so it neither compiles nor hides [foo.c]. *)
let displaced =
List.filter_map
(fun c ->
match split_csrc_tag c with
| (base, Some tag) when String.equal tag want -> Some base
| _ -> None)
csrcs
in
List.filter
(fun c ->
match split_csrc_tag c with
| (_, Some tag) -> String.equal tag want
| (base, None) -> not (List.mem base displaced))
csrcs
(* What the compiler itself is, cheaply: its path, size and mtime. A clang
upgrade changes one of those, so the key changes with it without paying a
[clang --version] subprocess on every build, which would cost most of what
@ -663,7 +722,10 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = []) ?(pnames = [])
| [] -> []
| parts ->
[ cc (String.concat "" (List.map snd parts)) "flan_shim.c" ])
@ List.map (fun c -> cc (read_file c) (Filename.basename c)) csrcs
(* Per-target selection, the same shape [select_lflags] applies to a
package's linker arguments. See [select_csrcs]. *)
@ List.map (fun c -> cc (read_file c) (Filename.basename c))
(select_csrcs opts csrcs)
in
let cmd =
String.concat " "

View File

@ -165,22 +165,44 @@
(defvar brush-mirrored rl/Texture2D)
(defvar brush-mirrored-ok bool)
;; A missing file is not a crash and not silence: LoadTexture hands back a
;; The sheet itself, baked into the binary at compile time. This used to be
;; (rl/load-texture "brush.png") against a bare relative path, and that is the
;; one line that kept this program off the browser: a relative path has no
;; meaning on a target with no filesystem, so raylib would have opened nothing
;; and the cursor would have been missing with no way to say why.
;;
;; The path is resolved relative to *this file*, not to wherever flan was
;; invoked from, which is what lets test/programs/sand-headless.flan import
;; this file as a package from _build and still find the PNG. Nothing is read
;; at run time on either target, so there is one code path and not two.
(defconst brush-png (embed "brush.png"))
;; A brush that does not load is not a crash and not silence: an Image that
;; failed to decode has a null buffer, LoadTextureFromImage hands back a
;; texture with an id of 0, every draw with it is a no-op, and the program
;; looks like it has a drawing bug. So it is asked and said once, here.
;;
;; Note what this can no longer mean: the file is missing. A missing
;; brush.png is a compile error now, at the embed, which is the whole point of
;; embedding it. What is left is a decode that failed or an upload with no GL
;; context behind it.
;;
;; One decode serves both textures. The unflipped upload happens first,
;; because ImageFlipHorizontal rewrites the buffer in place and the second
;; upload has to see the changed pixels — if the two badges look the same,
;; either the flip did nothing or the order here was swapped.
(defn load-brush []
(set brush (rl/load-texture "brush.png"))
(set brush-ok (rl/texture-valid? brush))
(unless brush-ok
(println "sand: cannot load brush.png — drawing the cursor is off"))
;; The other route to a texture: the file into RAM, changed there, and only
;; then uploaded. An Image that failed to load has a null buffer and
;; unloading it is still safe, so there is one unload and not two.
(let [sheet (rl/load-image "brush.png")]
(let [sheet (rl/load-image-from-memory ".png" brush-png)]
(when (rl/image-valid? sheet)
(set brush (rl/load-texture-from-image sheet))
(rl/image-flip-horizontal (addr sheet))
(set brush-mirrored (rl/load-texture-from-image sheet)))
;; An Image that failed to decode has a null buffer and unloading it is
;; still safe, so there is one unload and not two.
(rl/unload-image sheet))
(set brush-ok (rl/texture-valid? brush))
(unless brush-ok
(println "sand: brush.png would not decode — drawing the cursor is off"))
(set brush-mirrored-ok (rl/texture-valid? brush-mirrored)))
;; Four draws, one per shape the call comes in, because none of them can be in

View File

@ -12,6 +12,12 @@
(deps
(file %{workspace_root}/calc-me.flan)
(file %{workspace_root}/sand.flan)
; The brush sheet, which sand.flan now (embed ...)s rather than opening by
; path. An embed is read by the *checker*, relative to the file the form is
; written in, so it is a dependency of every build of sand.flan including the
; headless one — which reaches sand.flan through ../../ from programs/ and
; would otherwise find nothing at _build/default/brush.png.
(file %{workspace_root}/brush.png)
; The raylib bindings, because sand.flan and the FFI case import them and an
; import reads the directory at build time. sand.flan itself is above: the
; headless case imports it as a single-file package.
@ -59,6 +65,12 @@
; example imports examples/digits.flan, so the directory comes whole.
(glob_files %{workspace_root}/vendor/raylib/*)
(glob_files %{workspace_root}/examples/*)
; sand.flan for the browser, with the sheet it embeds and the dev agent it
; imports — the agent's directory has to be whole, because the file that
; makes a web build possible is the one Build selects out of it.
(file %{workspace_root}/sand.flan)
(file %{workspace_root}/brush.png)
(glob_files %{workspace_root}/vendor/agent/*)
; flan run --target=web is refused by the CLI, so the CLI has to be here.
(file %{workspace_root}/bin/main.exe)))
@ -82,6 +94,7 @@
test_sanitize.exe
(file %{workspace_root}/calc-me.flan)
(file %{workspace_root}/sand.flan)
(file %{workspace_root}/brush.png)
(glob_files %{workspace_root}/vendor/raylib/*)
(glob_files %{workspace_root}/vendor/agent/*)
(glob_files %{workspace_root}/vendor/edn/*)

View File

@ -181,7 +181,14 @@ let () =
place rather than appended a second time and rejected as duplicates. *)
let t, _ = Session.create ~file:"../sand.flan" () in
let src = In_channel.with_open_bin "../sand.flan" In_channel.input_all in
(match Session.eval t src with
(* [~origin] is the buffer's own path and both editor paths send it
(flan-dev.el's `:file (or buffer-file-name "<buffer>")`). It became
load-bearing here when sand.flan started embedding brush.png: an embedded
path is resolved relative to the file the form is written in, so the
default origin of "<eval>" would look for ./brush.png beside the test's
working directory and find nothing. Omitting it here was testing a request
the editor never sends. *)
(match Session.eval ~origin:"../sand.flan" t src with
| c ->
if not (List.mem "game-draw" c.Session.fns) then
fail "reloading sand.flan did not include its own functions"

View File

@ -208,6 +208,59 @@ let () =
browser";
if not (contains bytes "glViewport") then
fail "no GL imports in the module — raylib did not link";
cleanup out);
(* ── sand.flan, the flagship, for the browser ──────────────────
The program the whole target was wanted for, and the last web build
in this process on purpose see [raylib_web] on why anything after
it would pick the archive up silently.
Two things had to change for this to build at all, and this case
watches both of them rather than only watching the exit code:
- The brush was (rl/load-texture "brush.png"), a bare relative path,
which means nothing on a target with no filesystem. It is an
(embed "brush.png") now, decoded through LoadImageFromMemory. The
assertion is the PNG's *own bytes*, whole, found in the module: a
build that succeeded while the embed silently emitted nothing
would pass every other check here, and "IHDR" would not do it
either stb_image's decoder, linked in from raylib, carries that
string itself.
- The dev agent is a socket server and a browser has no sockets.
vendor/agent/flan_agent.web.c is selected over flan_agent.c here
and nowhere else. It needs no assertion of its own: flan_agent.c
does not compile under emcc at all, so a build that reached this
point compiled the stub.
Not run, and this is the ceiling rather than an omission. node has
no DOM and no WebGL, so the module instantiates, main runs, and
InitWindow dies inside glfwInit on `window is not defined` which
says the module is live but says nothing about whether the canvas
paints. Only a human opening it can say that; BUILT.md carries the
commands. *)
let out = Filename.concat scratch "flan-web-sand.html" in
(match web_build "../sand.flan" out with
| exception Failure m -> fail "sand.flan for the browser: %s" m
| () ->
let html, js, wasm = parts out in
List.iter
(fun (what, f) ->
if not (Sys.file_exists f) then
fail "the sand web build made no %s (%s)" what f)
[ ("page", html); ("JS", js); ("module", wasm) ];
let bytes = read wasm in
if not (contains bytes "asyncify_start_unwind") then
fail
"no asyncify in sand's module — its `until` loop would block the browser";
if not (contains bytes "glViewport") then
fail "no GL imports in sand's module — raylib did not link";
let png = read "../brush.png" in
if String.length png = 0 then
fail "brush.png is empty, so the embed assertion below proves nothing"
else if not (contains bytes png) then
fail
"brush.png's bytes are not in sand's module — the embed did not reach the browser";
cleanup out)));
(* ── Refused by name ───────────────────────────────────────────────

69
vendor/agent/flan_agent.web.c vendored Normal file
View File

@ -0,0 +1,69 @@
/* The dev agent in a browser: the three calls, doing nothing, and saying so.
*
* Build selects this file over flan_agent.c on --target=web, and only there
* (see Build.select_csrcs). The reason is not the compile error that led here
* emscripten's headers do not pull <sys/time.h> in transitively, so
* flan_agent.c fails on `struct timeval` at line 426 because an #include
* would have fixed that and fixed nothing real. The reason is structural:
*
* THE AGENT IS A SOCKET SERVER, AND A BROWSER HAS NO SOCKETS.
*
* flan_agent_start binds an AF_UNIX socket and hands it to a listener thread.
* There is no such address family under emscripten, nothing to listen on, and
* nothing that could connect if there were. Adding the include produces an
* agent that compiles, links, starts, and can never accept a connection.
*
* Why a no-op here, when `barf` on the web signals instead
*
* NEXT.md decision 2 rejected a silent no-op for `barf` in as many words: a
* no-op write is how a save file disappears with nothing said. The two 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; the loss is real, it is the
* user's, and it is discovered later or never. So the web signals a condition
* and the program decides.
*
* 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. There is nothing to lose because there was never
* anything there. sand.flan already says the same thing 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 same outcome by a shorter route. The no-op is not
* hiding a failure; it is the truthful implementation of "nothing is
* available here", which is the case where Odin's own `.Unsupported` stubs are
* right and the case decision 2 was careful to say it was not.
*
* Why not refuse vendor:agent on a web target
*
* It was the other candidate and it is ruled out by arithmetic, not taste.
* Flan has no conditional compilation, so a program cannot say "skip this on
* web". sand.flan calls (agent/start ...) unconditionally, Reach cannot prune
* a package something reachable calls into, and a build-time refusal would
* therefore mean sand.flan does not build for the browser at all without being
* edited into a second program. Refusing is only honest when the caller has a
* way to not ask; here it has none.
*
* What the return values say
*
* start returns -1, which is the same "could not listen" flan_agent.c returns
* for a path it cannot bind, so a caller that checks gets the answer it
* already knows how to read. poll and wait return 0 no redefinitions
* installed which is the truth and is what the native build returns on every
* frame that nothing arrived on. */
#include <stdint.h>
int32_t flan_agent_start(const uint8_t *path, int64_t len) {
(void)path; (void)len;
return -1;
}
int32_t flan_agent_poll(void) { return 0; }
int32_t flan_agent_wait(int32_t ms) { (void)ms; return 0; }

View File

@ -378,6 +378,37 @@
;; does not exist here — the same rename that took IsTextureReady.
(declare-c image-valid? [image Image] bool "IsImageValid")
;; The same decode, from bytes already in memory rather than from a path. This
;; is what an (embed "brush.png") is for, and it is the only route to a texture
;; on a target with no filesystem: a bare relative path has no meaning in a
;; browser, so LoadImage there opens nothing and hands back an image with a
;; null buffer.
;;
;; `fileType` is the extension *with* the dot — ".png" — because that is what
;; raylib compares against (rtextures.c, strcmp(fileType, ".png")). It is how
;; the decoder is chosen; there is no sniffing of the bytes.
;;
;; Declared with (Ptr u8) and an explicit count for the reason
;; collision-point-poly?-raw is: a slice crosses as ptr+len with an i64 length,
;; raylib wants a pointer and an `int`, and the shim generator refuses to guess
;; which integer type a C count parameter is. The Flan wrapper below takes the
;; slice apart, which is where that idiom lives everywhere else in this file.
(declare-c load-image-from-memory-raw
[file-type string file-data (Ptr u8) data-size i32] Image
"LoadImageFromMemory")
;; Empty is answered here rather than passed on, exactly as in
;; collision-point-poly?: (at data 0) on an empty slice is an out-of-bounds
;; read, and raylib's own answer to a zero-length buffer is an image with a
;; null buffer — which is what a zeroed one already is. image-valid? reports
;; false for it either way, so a caller that checks sees the same thing.
(defvar no-image Image)
(defn load-image-from-memory [file-type string data [u8]] Image
(if (= (len data) 0)
no-image
(load-image-from-memory-raw file-type (addr (at data 0)) (len data))))
;; By value, as raylib has it. The caller's copy is dangling afterwards —
;; `data` pointed at the buffer this just freed — so an Image is used or
;; unloaded, never both.