diff --git a/BUILT.md b/BUILT.md index b6b7d8d..2152c32 100644 --- a/BUILT.md +++ b/BUILT.md @@ -162,6 +162,8 @@ described is refused with that reason, so `vendor/raylib` describing thirteen structs is what makes the import thirteen structs wide, and describing a fourteenth widens it. Of raylib 5.5's 581 functions, 256 import, 153 are refused, and 172 are left alone because the package already binds them by hand. +(253 and 156 once `bindings` excludes raylib's three allocator entry points — +see the next section.) Not generating `defstruct`s is what makes the check possible at all. Generate them and the header becomes the authority on layout, and comparing the @@ -355,6 +357,93 @@ header cache and the same build is 0.92s against 0.83s. So the header read is compiling a shim with 428 wrappers in it, which is the object cache's business and already warm after one build. +### The bindings are committed now — `generated.flan`, `bindings`, `flan generate-c` + +The section above reads the header at build time, behind an opt-in +`?${FLAN_RAYLIB_H}` in `headers`, because a build should need libraylib +linkable and not raylib-devel installed. That opt-in was doing two jobs and +only one of them was defensible: it decided whether a *check* ran, which is +fine to make optional, and it decided whether a package had 172 bindings or +428, which is not. `DrawTexturePro` being reachable only by exporting an +environment variable is the shape of that second job, and it blocked a real +game — see PORTING.md. + +**Generate once, commit the result, regenerate when raylib moves.** +`flan generate-c vendor/raylib` reads the header named by `headers`, writes +`vendor/raylib/generated.flan`, and that file is checked in. No header is +needed by anybody: every build gets all 425 declarations, they are greppable, +and they show up in a diff when the library moves. **Caching is not the +argument** — the dump is already cached on disk and in memory, so a build that +reads a header pays for it once either way. The argument is the dependency and +the diff. + +**What it costs is the check, so regeneration runs it and the check gates the +write.** A file on disk has no second opinion, so nothing compares the bindings +against the library on an ordinary build any more. The one function that writes +`generated.flan` therefore compares first — every `defstruct` against the +header's record, every hand-written `declare-c` against the header's signature +— and writes nothing when they disagree. It is not possible to regenerate +without comparing, because there is no other way to write the file. Pointed at +the 5.1-dev header on this machine while the package is written for 5.5, it +reports the same ten real differences the diff above found and writes nothing. + +**The 172 hand-written lines stay, and the reason is not caution.** It was +tempting to delete them: 136 of the 172 are exactly what the kebab rule would +have produced, and the other 36 could be spelled as name overrides, so the +generated set really is a superset. The argument against is the one this +section is about. Everything the generator emits agrees with the header *by +construction* — the declaration and the prototype come from one dump — so +diffing generated output against the header it came from is a tautology, and +replacing the hand-written set would quietly reduce the signature half of the +check to nothing. The hand-written lines were transcribed from raylib's +documentation by a person; they are the only declarations in the package a +header can actually contradict. All ten of the 5.1-dev differences came from +them. They are not a parallel set to maintain — they are the second opinion, +and the check is what maintains them. + +The opt-in did not go away, it stopped deciding anything important. With +`FLAN_RAYLIB_H` set, an ordinary build still reads the header, and since every +C symbol is now bound — by hand or by generation — the importer generates +nothing and the read is purely the check. It is a *better* check than before: +425 declarations rather than 172, because `generated.flan` is a package file +like any other and is checked like one. + +**`bindings`, beside `headers`, is what survives regeneration.** A committed +generated file cannot be hand-corrected — the next run overwrites it and the +edit is destroyed without anybody being told, which is the worst shape an edit +can have — so the corrections have to live somewhere regeneration *reads*. Two +directives, which are the two things the header cannot decide: `exclude ` and `name `. A postprocessing transform pass +was considered and rejected: a second program to understand, run over text the +generator had already committed to. + +Both are applied *while* the declarations are made, which is not a detail. The +kebab rule is consulted in exactly one place, so collision groups are computed +on the name a function will really take — which means renaming one of two +colliding symbols dissolves the collision instead of leaving both refused, and +`Spin2D`/`spin2d` gains a way out that is not a hand-written line. An excluded +symbol still reports that it was excluded rather than going quiet: "there is no +such binding" and "the package decided against this binding" are different +answers. + +What is actually in raylib's: `exclude Mem*`, because raylib exports +malloc/realloc/free under its own names and binding them would put a second +untracked heap behind three innocuous-looking Flan names, against plan.org's +rule that an operation never falls back to a hidden allocator. And 19 `name` +lines giving the generated predicates the `?` spelling the hand-written ones +already use — `window-ready?` rather than `is-window-ready`, because +`key-pressed?` and `is-window-ready` living in one package is precisely the +split this change exists to remove. The C symbol is kept verbatim in +`Ast.DeclareC` either way, so a rename costs nothing: it is still what is +called and still what the check compares against. + +Two bindings the config cannot express stay hand-written, and they are the +reason `declare-c` remains the escape hatch: `LoadFontEx` and +`LoadImageFromMemory` are bound `-raw` and wrapped by a Flan function of the +same name without the suffix, one taking a slice and one answering with an +`Option`. The importer refuses them by name collision with those wrappers, +which is the correct answer. + ### What a headless FFI test can and cannot pin Worth knowing before writing another one, because two plausible tests in a row turned out to check nothing. diff --git a/DISCUSS.md b/DISCUSS.md index d76a798..854460a 100644 --- a/DISCUSS.md +++ b/DISCUSS.md @@ -182,6 +182,10 @@ build, and a permuted `Texture2D` or an `f64` for a `float` is caught by name. What is left is two decisions, and both are the author's. +**6a and 6b are answered. See BUILT.md, "The bindings are committed now".** It became a code generator whose +output is committed, and the 172 hand-written lines were *not* migrated — for a reason 6b did not reach. The two +sections below are kept as the reasoning that got there. + ### 6a. Does reading the header stay a build-time step, or become a code generator? The cost, measured, with the wrappers pruned by `Reach` as they already were: @@ -222,6 +226,17 @@ better than the rule's. `IsKeyPressed` is `key-pressed?` by hand and `is-key-pre would become an integer at the call site. A migration is therefore not a deletion; it is a deletion plus a kept list of the lines whose face is deliberately nicer than the header's. +**Answered: no, and the blocker is not the one above.** Vendoring stopped being the question once the *output* was +committed rather than the header — no header is needed at any build. The count was also smaller than feared: 136 of +the 172 are exactly what the rule produces, and the other 36 are expressible as `name` overrides in `bindings`. + +What actually decides it is that migration would gut the check. Everything the generator emits agrees with the header +by construction, so diffing generated output against its own source proves nothing; the hand-written lines have a +different author, so they are the only declarations a header can contradict — and all ten of the 5.1-dev differences +came from them. Delete them and the signature half of the check silently becomes a tautology. The enum point above +survives intact as a second reason: `(rl/key-down? :space)` keeps its `Key` parameter only because that line is +hand-written. + ## 7. Watching variables Raised while designing item 1, and deliberately separated from it. diff --git a/NEXT.md b/NEXT.md index 53ea38d..7356cd0 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1,27 +1,9 @@ -## Queued: commit the generated bindings, with a config beside them +## Queued: an idiomatic layer over the generated bindings -**Decided.** `flan import-c` already exists and prints the lines; kebab-casing is already implemented and reversible -(the C symbol is kept verbatim in `Ast.DeclareC`, so the rule never has to be undone). - -**Generate once, commit the result, regenerate when raylib moves.** What changes against today's opt-in header read: -no header is needed by anyone, so the `?${FLAN_RAYLIB_H}` split disappears and every build gets all 428 bindings; the -bindings become greppable and diffable in the repo; and the hand-written 172 stop being a separate set to maintain. -Caching is not the argument — the dump is already cached on disk and in memory. - -**What is given up, and it is the real cost:** the build-time check against the real header stops being automatic and -becomes something run at regeneration. That check earned its place — it verified all 172 hand-written declarations and -all 16 struct layouts against raylib 5.5 and found them exactly right, and against a 5.1-dev header on the same -machine it found ten genuine differences. Keep it as a step, and make regeneration run it. - -**A config file beside `headers`**, because the generated file is committed and therefore any hand-edit is destroyed by -the next regeneration — the config is what survives. Wanted: **exclude patterns**, and **name overrides** where -kebab-casing gives something ugly. A postprocessing transform pass was considered and rejected: it is a second program -to understand and the config covers the real cases. - -**Document it on the web page.** `web/index.html` has no section on the FFI's generated half. - -**Later, not now: an idiomatic layer.** Thin Flan-shaped wrappers *over* the generated bindings, not instead of them — -the generated set stays honest to C, and the layer is where a Flan-shaped API lives. +Thin Flan-shaped wrappers **over** the generated bindings, not instead of them. The generated set stays honest to C — +that is what makes it checkable against the header — and the layer is where a Flan-shaped API lives. Two of these +already exist by hand in `vendor/raylib/raylib.flan` and are the shape to copy: `collision-point-poly?` takes a slice +and `collision-lines` answers with an `Option`, each wrapping a `-raw` binding of the same name. ## Queued: a restart is not a transaction, and the docs must say so @@ -42,13 +24,15 @@ skip a frame and carry on rather than die — exactly the case where a non-idemp Three things, in order. The first two are one line each and unblock a real game. -**1. `DrawTexturePro` is not bound, and it is the one true blocker for `siam-farmer`.** See `PORTING.md`, written -against both the Clojure implementation and the WIP Common Lisp port. Every tile in both goes through it; -`DrawTextureRec` does not scale and `DrawTextureEx` takes no source rect, so the renderer **cannot be written at all** -in a default build. It is reachable only through the opt-in `FLAN_RAYLIB_H` import, which `vendor/raylib/headers` -deliberately keeps optional. One `declare-c` line. +**1. `DrawTexturePro` — done, and not by a hand-written line.** It was the one true blocker for `siam-farmer` +(see `PORTING.md`: every tile in both implementations goes through it, and neither `DrawTextureRec` nor +`DrawTextureEx` substitutes). It was reachable only through the opt-in `FLAN_RAYLIB_H` import. The bindings are +committed now, so `rl/draw-texture-pro` is in `vendor/raylib/generated.flan` and a default build has it. **Nothing +should add it by hand** — a second `declare-c` for the same C symbol is refused for the whole program. -**2. `Key` has no `left-shift`.** Both implementations use shift+1..5 to pick the tilemap. One enum member. +**2. `Key` has no `left-shift`.** Both implementations use shift+1..5 to pick the tilemap. One enum member, and +still a hand edit: the importer generates functions and only functions, so no `defenum` comes out of the header. +`vendor/raylib/raylib.flan` is where `Key` lives. **3. An out-of-bounds index should signal a condition, not `exit(134)`.** `PORTING.md`'s own first recommendation after the binding lines. The game indexes grids straight from mouse coordinates — `game.lisp` had to add an diff --git a/web/index.html b/web/index.html index 22601ef..01cc815 100644 --- a/web/index.html +++ b/web/index.html @@ -1140,15 +1140,77 @@ void flan_shim_get_mouse_position_5ad0e205(flan_ty_Vector2_1bebc5ae *out) { *out = GetMousePosition(); } -

No library header is read, deliberately, so a build needs the shared library to be -linkable and not the -devel package to be installed. +

No library header is read during a build, deliberately, so a build needs the shared +library to be linkable and not the -devel package to be installed. Guaranteed: the C typedef and the Flan struct come from the same defstruct, so they cannot disagree, and clang type-checks the wrapper against the generated prototype. Trusted: that the defstruct matches the library's real struct, and that the declare-c signature is the function's real signature. A scalar's width now carries ABI weight — f64 where the library says -float emits double, and the library reads garbage.

+float emits double, and the library reads garbage. That +trusted half is what the generator below checks.

+ +

Generated bindings, committed

+ +

Writing a binding per function by hand does not scale past the ones a program +happens to call, so most of raylib's package is not written by hand. A package +directory may carry a headers file naming the library's own C header; +flan generate-c <package-dir> reads it with +clang -Xclang -ast-dump=json, turns every function it can represent into +the same declare-c line a person would have written, and writes them to +generated.flan in the package — which is committed.

+ +
$ export FLAN_RAYLIB_H=/path/to/raylib-5.5/src/raylib.h
+$ flan generate-c vendor/raylib
+wrote vendor/raylib/generated.flan: 253 declarations, 156 refused, of 581 functions.
+Every defstruct and every hand-written declare-c agrees with it.
+ +

Committing the output rather than generating at build time is what keeps the +no-header property honest: the declarations are in the repository, so every build gets +all of them, they are greppable, and they show up in a diff when the library moves. The +argument is not caching — the clang dump is already cached on disk and in memory.

+ +

Regeneration is the check. The cost of committing the output is that +nothing compares the bindings against reality on every build any more, so the one +function that writes the file compares first and refuses to write when the +package and the header disagree: every defstruct against the header's +record, and every hand-written declare-c against the header's signature. +Pointed at a raylib 5.1-dev header while the package is written for 5.5, it reports ten +real differences and writes nothing — which is exactly the silent version skew a +generated file would otherwise bake in and make look reviewed.

+ +

This is also why the hand-written bindings are kept rather than replaced by generated +ones. Everything the generator emits agrees with the header by construction, so diffing +generated output against the header it came from proves nothing; the hand-written lines +were transcribed by a person, so they are the only declarations a header can actually +contradict. All ten of those differences came from them.

+ +

A committed generated file cannot be hand-corrected — the next regeneration destroys +the edit without telling anybody — so the corrections live in a bindings +file beside headers, which is read while the declarations are made. +Two directives:

+ +
# raylib's own malloc/realloc/free, which would be a second untracked heap
+# behind three innocuous Flan names.
+exclude Mem*
+
+# The kebab rule gives is-window-ready. Lisp spells a predicate with a ?.
+name IsWindowReady  window-ready?
+ +

An excluded function still says it was excluded rather than going quiet, and a name +override changes only the Flan face — the C symbol is kept verbatim in the declaration, +so it is still what is called and still what the check compares. Renaming is also the +way out of a collision: Spin2D and spin2d both kebab to +spin-2d, so neither takes the name, because which one won would otherwise +depend on the order the header happens to declare them in.

+ +

Anything neither directive can express is a hand-written declare-c in the +package's own source, which wins over the generated file and is left alone by the +generator. That is the escape hatch for a signature the importer gets wrong and for a +Flan face the header cannot describe — raylib keeps two, each a raw binding wrapped by a +Flan function of the same name, one taking a slice and one answering with an +Option.

Everything the boundary cannot represent is refused by name with the reason, rather than half-supported: an Option, a union, a fixed array, a map, a returned