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:
parent
eb98c5859a
commit
ce346dd972
64
lib/build.ml
64
lib/build.ml
@ -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 " "
|
||||
|
||||
40
sand.flan
40
sand.flan
@ -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
|
||||
|
||||
13
test/dune
13
test/dune
@ -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/*)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
69
vendor/agent/flan_agent.web.c
vendored
Normal 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; }
|
||||
31
vendor/raylib/raylib.flan
vendored
31
vendor/raylib/raylib.flan
vendored
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user