213 lines
14 KiB
Markdown
213 lines
14 KiB
Markdown
# Let's discuss
|
||
|
||
Open questions, raised and deliberately not answered yet. Nothing here is a decision or a task. Each entry is the
|
||
question as asked, plus what is already known in this repo that bears on it — so the investigation starts from what
|
||
exists rather than from scratch.
|
||
|
||
Settled decisions live in `NEXT.md`. Reasons for what already exists live in `BUILT.md`.
|
||
|
||
---
|
||
|
||
## 1. `i`, the inspector, and the frame it cannot see
|
||
|
||
Two things got conflated here and they should be separated.
|
||
|
||
**What the inspector already does.** Most of what was asked for is built. `flan-inspect` opens its own buffer, lays a
|
||
value's fields one per line, `RET` walks into one, `l` comes back, `g` re-reads. The renderer bounds its walk at depth 4
|
||
and span 8, and entering a field renders *that field* from depth 0 — so the elision moves with you rather than
|
||
truncating permanently. It is CIDER's inspector adapted, and the file says what the adaptation changed.
|
||
|
||
**What is actually missing** is detail on the leaves: a number shows in decimal only, with no hex and no binary, and a
|
||
pointer does not show its address. Purely additive, small, and worth doing.
|
||
|
||
**The real problem, and it is sharper than "a bug".** The locals listing renders from each frame's own slot addresses,
|
||
so it is frame-accurate. The inspector is built on a stack of **expressions** — going into a field means sending a
|
||
different expression (`(.pos b)` where the last was `b`), and `l` works by popping back to the previous one. That design
|
||
is forced: a Flan value has no header, the thunk that rendered it is `dlclose`d as soon as it returns, and there is no
|
||
heap to retain anything in, so nothing can be held server-side the way CIDER holds a JVM object.
|
||
|
||
The consequence is that `i` evaluates a name wherever the evaluator stands, **not in the frame being looked at**. On the
|
||
innermost frame that happens to be right. On any other it may resolve to a global, to a different binding, or fail —
|
||
with nothing saying so.
|
||
|
||
**An earlier suggestion in this conversation — "root the inspector at the slot's address" — does not work**, and the
|
||
reason is worth keeping: an address is not an expression, so the first `RET` has nothing to build the next expression
|
||
from and navigation dies at step one. Recorded because it is the obvious fix and it is wrong.
|
||
|
||
So the options are genuinely three, and none is free:
|
||
|
||
1. **Teach the program to evaluate an expression relative to a frame.** The most useful and the most work: the frame's
|
||
slots would have to be in scope for a compiled thunk, which means the daemon building a thunk whose free names bind
|
||
to that frame's addresses. It would also fix `C-x C-e` while stopped, which has the same blindness.
|
||
2. **Give the inspector a second rooting mode** — an address root that can still walk, by carrying a type alongside the
|
||
address and stepping to a field's address rather than to a sub-expression. Navigation then works, but the two modes
|
||
have different capabilities and `l` has to cross between them.
|
||
3. **Refuse `i` outside the innermost frame**, honestly and by name. Cheapest, and it gives up the feature exactly where
|
||
it is most wanted, since the innermost frame is the one already fully visible.
|
||
|
||
## 2. Annotating the IR and the disassembly with the source
|
||
|
||
**The IR half is nearly free and should just be done.** `emit.ml` writes `.ll` as text, so a comment costs nothing and
|
||
cannot break anything, and every typed IR node already carries a `Loc.t`.
|
||
|
||
**The disassembly half, with the optimisation question settled.** `objdump` already interleaves source into a listing
|
||
when DWARF is present (`-S`), and the daemon already shells out to objdump — so the `-O0` case is close to a flag.
|
||
|
||
Settled in conversation: **`-O0` is expected to follow the source and gets the full annotation; `-O2` is not expected to
|
||
and gets either nothing or whatever best-effort mapping falls out.** That removes what looked like the blocking
|
||
question. `--debug` forcing `-O0` is therefore fine and does not need decoupling for this.
|
||
|
||
**How SBCL does it, since it came up.** SBCL does *not* shell out. `sb-disassem` is its own disassembler, written in
|
||
Lisp, that knows the instruction encodings directly. What that buys is annotation from the inside: it labels constants
|
||
the function references, names the functions being called, marks entry points, and shows its own calling conventions —
|
||
because it compiled the code object and still holds the metadata.
|
||
|
||
The relevant observation is that **this project is closer to SBCL's position than the objdump route suggests.** The
|
||
daemon owns the build and holds the metadata too; it simply is not feeding much of it into the listing yet. Naming the
|
||
function behind an indirection cell, or a constant by its source name, needs no instruction decoder — only the
|
||
information the daemon already has. Writing a disassembler is not the interesting part and should stay off the table;
|
||
richer annotation of objdump's output is cheap and is where SBCL's advantage actually comes from.
|
||
|
||
## 3. `def`, `defvar`, `defconst` — three roles, currently two words
|
||
|
||
There is no `def`. `defvar` is a mutable global that is always re-initialised on redefinition; `defconst` is a
|
||
compile-time constant, folded into its use sites.
|
||
|
||
The problem in practice: `C-c C-k` re-evaluates every top-level form against a *running* program, so today it wipes
|
||
state you may have spent a session accumulating.
|
||
|
||
**Settled in conversation — three roles, three words:**
|
||
|
||
- **`def`** — always re-initialised. The tweakable: some number you are adjusting and do not care about preserving.
|
||
- **`defvar`** — re-initialised *only if it would come out different*. For heavier initialisers: an N×M grid that should
|
||
recompute only when the rows or columns actually changed.
|
||
- **`defconst`** — folded, and reserved for what genuinely cannot change for the life of the program. `rows`/`cols` in
|
||
`sand.flan` are honest `defconst`s: the program does not support resizing on the fly and changing them means resetting
|
||
everything anyway.
|
||
|
||
Note this **`defvar` is not Common Lisp's**, which is "assign only if unbound". This one is value-dependent, which suits
|
||
a live-edited game better and is the harder of the two to implement.
|
||
|
||
**The open question is what "different" means**, and the grid example is what makes it sharp. If the grid is sized by
|
||
`rows` and `cols`, the initialising expression's *text* is unchanged when `rows` changes — only its value is. So:
|
||
|
||
1. **Compare the expression** (textual or AST). Cheap, and does not catch the case above.
|
||
2. **Compare the expression plus everything it depends on.** Catches it. Needs dependency tracking.
|
||
3. **Evaluate and compare the result.** Correct, and defeats the entire purpose whenever the initialiser is expensive —
|
||
which is the only case this feature exists for.
|
||
|
||
Option 2 is what is wanted.
|
||
|
||
**The reason to do it: it is the same machinery `defconst` needs.** A folded constant cannot be tuned live, because its
|
||
value is baked into every function that used it — so the values you most want to tweak while the game runs are exactly
|
||
the ones you cannot. Fixing that means knowing which functions depend on which constants, and rebuilding those. That is
|
||
the same dependency tracking `defvar`'s "if different" requires. Built once, it buys live-tunable constants *and* the
|
||
conditional re-initialisation. Neither is worth building alone; together they are clearly worth it.
|
||
|
||
## 4. Structural typing, row polymorphism, anonymous structs
|
||
|
||
The goal as stated: make this feel Clojure-like while still being typed.
|
||
|
||
**The representation is already structural.** `types.ml` does structural equality on resolved types, and a Flan struct is
|
||
exactly its C layout with no header and no tag word. What is nominal is the *checking*, not the data — so this is a
|
||
front-end question, not a data-model change.
|
||
|
||
**What it buys, in rough order of value here:**
|
||
|
||
1. **Your destructuring already looks like this, and that is the strongest argument.** Clojure's `{:keys [x y]}` works in
|
||
binding position today. Structural typing makes the same notation work in *parameter* position — the pattern you write
|
||
to pull fields out becomes the signature saying what you accept. One notation, two places, no separate declaration.
|
||
That is precisely the "Clojure-like but typed" feel being asked for, and it needs no new syntax, only a new meaning
|
||
for syntax that exists.
|
||
2. **Functions over "anything with these fields".** Magnitude over anything with an `x` and a `y` — a position, a
|
||
velocity, an enemy, a bullet. In a game this recurs constantly because everything has a position.
|
||
3. **Ad-hoc returns.** Returning a hit flag and a point without declaring a type for the pair. The Clojure habit of
|
||
returning a map, typed and free.
|
||
4. **The FFI stops needing conversions.** raylib's `Vector2` and a locally-defined vector are byte-identical and are
|
||
today two nominal types. Structurally they are one.
|
||
|
||
**Two costs, and the second is the real work:**
|
||
|
||
- **It cuts against the project's standing instinct.** This language has repeatedly chosen explicit over convenient: no
|
||
implicit numeric conversions, a bare integer cannot become an enum, `:spcae` must fail at the call site. Structural
|
||
typing says things are compatible when they happen to line up, which is the opposite reflex. Worth deciding
|
||
deliberately rather than drifting into — and worth asking whether structural compatibility should be *written* at the
|
||
site that relies on it, the way `(GamepadAxis n)` made an enum conversion explicit without making it implicit.
|
||
- **Field order is layout, and that is the hard part.** `{x f32, y f32}` and `{y f32, x f32}` have the same fields and
|
||
different memory. So either field order becomes significant — surprising, since nothing else about a structural type
|
||
should be — or matching a differently-ordered type requires a copy, which breaks the zero-cost property the whole data
|
||
model rests on. This is the question to settle first; the type checking is comparatively easy.
|
||
|
||
Also unresolved: the shim generates a C typedef per *named* struct, and an anonymous struct has no name to generate one
|
||
from. Either anonymous structs cannot cross the FFI, or the generator learns to name them.
|
||
|
||
## 5. A performant JS transpiler
|
||
|
||
Asked as a feasibility question. Bear in mind the web target already exists and ships real machine code via wasm, so this
|
||
is not the only route to a browser and the case for it needs stating: smaller artifacts, no wasm toolchain, debuggability
|
||
in browser devtools, or something else.
|
||
|
||
The hard parts are the ones the wasm target got for free from clang: the memory model (Flan is pointers and explicit
|
||
layout; JS is not), the FFI, and the fact that the whole raylib layer is C. A JS backend that cannot run raylib is a
|
||
different product from the one that can.
|
||
|
||
## 6. C interop as seamless as Zig's
|
||
|
||
Today: `declare-c` names one C function per line, and the compiler generates the wrapper, the typedefs and the flattened
|
||
declaration. 175 of them for raylib. **No header is ever read, deliberately** — which means nothing can check that a
|
||
declaration matches the real signature, and that is written down as trusted rather than guaranteed.
|
||
|
||
The proposal is to read the header, prefix a namespace, and get `rl/InitWindow` for free — plus possibly automatic
|
||
kebab-casing to `rl/init-window`.
|
||
|
||
This is a large change in kind, not just in size: it means a C parser or a libclang dependency in the build, and it
|
||
trades an explicit, checkable list for an implicit surface. Worth weighing against what `declare-c` already buys, which
|
||
is that a binding is one line and the wrapper is generated. The auto-kebab-case question is separable and much smaller —
|
||
and note the FFI currently keeps C's own spelling on purpose, so the mapping would need to be reversible.
|
||
|
||
## 7. Where `defclass` stands
|
||
|
||
Specified in `plan.org` and **deliberately not started** — its own last line says nothing happens until ordinary
|
||
`struct`, `Handle` and reload semantics work. Those have largely landed since that was written, so the gate may be closer
|
||
than the document assumes.
|
||
|
||
What it is meant to add: identity, runtime shape metadata, an implementation-defined representation, generic-function
|
||
dispatch, and live schema change with an explicit migration at a frame boundary — which is the answer to the one thing
|
||
redefinition still cannot do, changing a struct's layout while instances exist.
|
||
|
||
Three findings from an earlier review, recorded in `NEXT.md` and not yet in `plan.org`:
|
||
|
||
- **A generic function is a cell.** Adding a method from a later module is the same problem indirection cells already
|
||
solve, so the expensive half is built and tested.
|
||
- **The pool is not one storage option among three.** `migrate-instances` has to enumerate live instances, which a pool
|
||
behind a generational `Handle` gives by construction and the other two options do not.
|
||
- **Every layout version must stay resolvable** for as long as any instance holds it — the same rule as "nothing is ever
|
||
`dlclose`d".
|
||
|
||
Also open and related: whether a *condition* can be a class, which decides whether handler matching has one path or two.
|
||
`NEXT.md` records the argument; decision 4 there took the cheaper parent-link route for now and explicitly left real
|
||
inheritance possible later.
|
||
|
||
## 8. Watching variables
|
||
|
||
Raised while designing item 1, and deliberately separated from it.
|
||
|
||
Item 1 shows globals *per frame*, chosen by what the code in that frame references. That is the right default and it
|
||
cannot cover the case where the thing you care about is not named by the frame you are standing in — stopped deep in a
|
||
helper, still wanting to see the grid.
|
||
|
||
So: a way to say "always show me this", surviving a resume and the next break.
|
||
|
||
Questions it opens:
|
||
|
||
- **What can be watched.** A global is easy — it has a name and an address that does not move. A *local* is harder: it
|
||
belongs to a frame that is gone as soon as you resume, so "watch `y`" either means a different `y` every time or means
|
||
nothing. A watched expression — `(at velocity 40 12)` — is the most useful and the most expensive, since it has to be
|
||
compiled and run in the program each time, which is what `eval-expr` already does.
|
||
- **Where the list lives.** Per project, per session, or in the file. A watch list that vanishes when Emacs restarts is
|
||
one you stop using; one in the repo is one you accidentally commit.
|
||
- **When it updates.** Only on entering a break is cheap and probably enough. Live-updating while the program runs is a
|
||
different feature — closer to a HUD than a debugger — and worth not conflating.
|
||
- **Whether it belongs in the break buffer at all**, or in its own window that is useful while the program is *running*,
|
||
which is arguably where a game developer wants it.
|