sand.flan builds for the browser, with its brush baked in

This commit is contained in:
Joseph Ferano 2026-09-12 12:09:54 +07:00
commit bdb3f105f2
9 changed files with 416 additions and 11 deletions

115
BUILT.md
View File

@ -1927,3 +1927,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

@ -412,6 +412,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 ...)`
@ -440,6 +463,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.

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.