Commit graph

29 commits

Author SHA1 Message Date
42deb76c28 schema: ludicc --emit-schema / ludic schema, --check --diagnostics=json, and editor attributes
--emit-schema FILE writes the compiler's resolved view once the program type-checks: every
record (fields, types, defaults as written, docs, places, attributes), every registry with its
entries in their final order after the open-registry merge (key, constant, index, file:line:col
of the entry and of each field value, and which file contributed which keys), every const, and
the zero-argument functions a fn value can name. Deterministic, schema_version 1; the runtime is
left out. `ludic schema [file] [-o FILE]` wraps it.

--check --diagnostics=json prints every error as one JSON array on stdout: the checker's and the
module rules' all, a parse or lowering error as the last. Tokens and nodes now carry a column.

Fields take several @attributes; @Ref(Registry), @OneOf(PREFIX_), @Range(lo, hi), @Unit("..."),
@Asset("..."), @Color on a field and @AppendOnly / @ByKey on a registry change nothing but go into
the schema, and @Ref naming no registry is an error (every one reported). Fixtures:
examples/lang/attributes.ludic, examples/rejected/ref_unknown.ludic, cases in ludic-dev test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:06:54 +03:00
1381c6c903 Merge branch 'lang/dispatch-check' into lang/foundations
# Conflicts:
#	selfhost/ludicc.seed.ll
#	selfhost/ludicc.win.seed.ll
2026-09-28 18:03:09 +03:00
a0b030290b region rule (25.3c): keep() and intern(), frame_keeps, and --arena-strict
keep(x) copies a string, a slice (header and elements) or a record (shallow) onto the heap; intern(s)
hands back one heap string per distinct text from a fixed table in the runtime (FNV-1a, 65536 slots,
copied the first time; past 49152 only copied). Both are how frame code keeps what it made on purpose:
the escape analysis takes the copy as the heap's and leaves the argument LOCAL.

The analysis now records why a class escapes (the store, the event, the global it reached) and
ludic deps lists every allocation frame code makes and keeps - fkeep lines, 'ludic deps --keeps',
the frame_keeps number --check ratchets - leaving out what is under @alloc_ok and a push's growth
(25.5's capacities). --arena-strict (or 'arena strict') makes each an error naming the store, before
anything is emitted. A test: a template stored into a state is the one error; keep and intern of the
next two, an @alloc_ok push and a scratch temporary are not; 195 frames of arena resets under
R3D_ARENA_CHECK=1 later the kept and interned texts read as made, and intern gives the same string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:53 +03:00
8e113f749a fix(check): a Math.* call has the type the emitter gives it
Math.max, Math.sqrt and the rest (and the bare min/max/abs/clamp) are computed inline by the
emitter, and L4 gave each the unknown type, which agrees with everything: Maroon Lake's trail
put Math.max(5, n) into Notify's string field a1 and it failed in LLVM ("%t63 defined with type
i32 but expected ptr"). It was never about two dispatches on a line - one is enough. The checker
now mirrors the emitter: a float/double first argument gives that type (sign an int); otherwise
min/max/abs/clamp keep the first argument's type, sign/floor/ceil/round/posmod/wrap/ping_pong are
ints, the rest fixed. Named arguments or an argument it cannot type leave it unknown.

rejected/math_into_text is the case. Reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:12:28 +03:00
67d8079c47 fix(migrate): 0.R5 - a state an argument to a C function comes out of keeps the mutability it was declared with, so --tighten leaves the mut on a wrapper writing through a native handle (phys_force) and does not add one to a query that reads through one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 04:23:11 +03:00
54f5efb23f feat(migrate): 0.R5 - ludic migrate state --tighten takes mut off every state parameter nothing down the chain writes; --root DIR lets the edits reach a directory without running its programs; a write through a local holding part of a mut state counts as a write to it
Maroon Lake: 149 parameters became read-only. What stays mut is a real write - in the packages mostly a
lazy start inside a question (things_all, gear__ensure), which is what to take out next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:20:44 +03:00
2eefae0618 fix(check): 0.R4 - a misspelled type in a parameter, a result or a field is refused where it is written
`function kind_of(f: CharFact)` for a CharacterFact was taken on trust and failed in the code writer
as "member access on non-aggregate". A capitalised type - plain, in a slice, or a generic's argument -
must name a declared property, record, state, event, enum, action or packed value type, or a type
parameter of its declaration: `kind_of's parameter f: there is no type CharFact`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 05:58:35 +03:00
c35481f344 fix(migrate): 0.R4 - --prune keeps a state declared after a plain parameter; a parameter named twice is refused
On Maroon Lake the prune gave home_keep_records(base_app_st, home_st, r: RunRecords, save_app_st) a
second save_app_st at the front, and every call a second argument: a state declared after a plain
parameter was not counted as declared. It is now, and a call to such a function is never given the
state again. `ludic build --check` let the duplicate through and clang refused it; the checker now
refuses a function that names two parameters alike (`add names two parameters n`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 05:32:08 +03:00
71735b10a2 feat(base): 0.R4 - a queue keeps its own count, so every package verb takes only its own state; ludic migrate state --prune
ludic.base's Queue<T> carries a QueueTag (its name and pending count): queue_new(name), q_push(q, v),
q_drain(q), q_clear(q) take no BaseState, and core_undrained(tags) names the given queues still holding
facts. A reducer on a package's state can now call that package's verbs (wallet_earn(wallet_st, n)).

ludic migrate state --prune (ludicc --migrate-prune) takes out each state parameter a function no
longer uses, nor anything it calls, and the argument that fills it - including an argument for a
parameter the callee has dropped, which is taken out before the call is checked, so a generic's T is
told by the argument that says it. A reducer keeps its state. --dry-run now counts the edits it would
make. Every package was moved with it: 608 base_st parameters and their arguments, 1889 edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 04:19:56 +03:00
8cf4b3fed6 fix(lang): the action drain is the program's; --check runs every check a build makes; a registry key named count is refused; name lookups are tables
- the function that calls every reducer (and the action queue) is written in the program's own
  file: in the first action's file it belonged to that module, depended on every module with a
  reducer, and joined a game's modules into one 69-module cycle (examples/actions/modules and a
  ludic deps case hold it); the state instances, the queue and the reducers make no deps edges
- ludicc --check / ludic build --check lower the program too and write nothing, so the code
  writer's refusals are in it: a bind to a function that is gone, an unknown name (and the checker
  now refuses fn <missing> itself); rejects bind_missing_fn, unknown_name, registry_count_key
- def R count is refused: its constant would be PREFIX_COUNT, the registry's size
- a file's module, package, trust and numbers-float are tables, and from the check on the lookups
  of functions, enums, records, globals and externs are too (tagged enums kept as a list): Maroon
  Lake's check-only build went from about 20 s to 7 s including lowering, its IR from about 2
  minutes to under 10 s; duplicate declarations are found by table, not a pair of loops
- threads.ludic's pool check gives each call a little work, so a busy machine cannot run them all
  on the caller before a worker wakes (it failed one run in three under load)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:34:43 +03:00
808c4a6f7a feat(lang): 0.R1 - actions and reducers
action Name { fields } is a typed record; reducer State on Action(s: mut State, a: Action) { ... }
in the module that owns the state takes exactly that state and the action (a second state is
refused); dispatch Action { fields } queues one from anywhere, the queue supplied by the runtime.
The queue is drained at the end of every phase of the frame loop, after every phase of ludic.base's
core_tick_all, and by drain_actions(): in dispatch order, each action's reducers in the order of
their states' names, an action a reducer dispatches queued behind, a queue still growing after 64
rounds stopped with the action named. Examples actions/pack, phases, runaway; rejects for a second
state, a reducer on a non-action and an unknown dispatch; ludic.base's actions_test; LANGUAGE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:13:46 +03:00
ce80e64b24 feat(lang): 0.S - components take states in their header; migrate writes them, never inside a name, never outside the programs given, never into a package's state
- component Name (a: mut A, b: B) { ... }: every getter, default, function and event takes the
  header's states before the instance; a member's call to another passes them on; the glue is
  supplied them; the template never sees them; a read-only one is read-only in every member
- ludic migrate state: a component's members' needs go into its header (added to an existing one,
  mut added where now changed); a field read or a member call inside a component is the compiler's,
  so nothing is written inside a name and no ', )' is left; an entry point that declares states
  already gets the rest after them
- a program's module named like a package gets <Name>AppState; a program's own file its own state;
  a friend module's files go by directory; a package's settable var stays state
- it writes only under the programs and directories given (and runtime/ with --runtime), and
  refuses the whole run naming any other file that would have to change
- a name a package already moved into its state is rewritten through it; a read of the runtime's
  var through the runtime function that answers it (gl_w: gl_width())
- a state's instance supplied by the runtime is not a uses reference
- tests: state/component, rejected/state_component_ro, rendering/ui_render3d, migrate component
  and foreign cases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:11:15 +03:00
c167ecc714 feat(lang): 0.S - a reference out of a read-only state is read-only (named so), a program's type named like the runtime's is refused (PadButton), tests for both, the migrate case covers lets; changeset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:17:03 +03:00
d70b00f30d wip(0.S3): calls into the runtime get its states supplied; the emitter's own calls to runtime functions reach their thunks; the migration names the runtime's states per file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:44:50 +03:00
d490d4f9f1 wip(0.S2): a migration makes a declared read-only state mut where it is now changed; net_sync builds --unsafe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:02:45 +03:00
e7f8ee6b91 wip(0.S3): ui blocks, scene on enter/exit, hooks, machines over a state's field, namespace and engine-system injection; a reference out of a read-only state or a module let is read-only; deps counts writes into another module's state; the rejected examples hold states
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:49:03 +03:00
07505e7ef2 wip(0.S3): the packages migrated by ludic migrate state packages - every package test green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:02:21 +03:00
1e8b5b0523 wip(0.S1, 0.S2): state records, mut and read-only state parameters, entry injection, module var refused; ludic migrate state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:45:09 +03:00
8892f51096 feat(cli): ludic build --check / ludicc --check - check without building
The parse, the types and the module rules (export, uses, layers, ports,
registries) run and nothing is emitted or linked: about three seconds
on Maroon Lake. In this mode the checker asks vis_check at each
reference it resolves, with a global's initializer and a registry's
entries seen from the files the emitter would use. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:42 +03:00
afa4d23a4f fix(lang): a template inside another's hole, and integer literals past 2^31 - 1
A template literal nested in a {...} hole crashed the parser: the outer
literal ended at the inner backtick. The lexer (and ludic-fmt's) now
reads a hole as code, taking strings, chars and templates in it whole.
A decimal literal past 2147483647, or a hex one of more than eight
digits, was wrapped into a negative int; it is a long with its value
now, and giving one to an int is refused. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:10:46 +03:00
73129b59d5 fix(lang): a function named like an engine namespace method's target is refused
Random.range is rng_range, so a package's own rng_range(a, b, c) took
every Random.range call silently. Where the program calls such a method
and the target resolves to a function of its own, the function is
refused, naming the namespace method and the call. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 05:14:45 +03:00
a2012a5afe fix(test): a generic function called from a test block works
Test blocks were never type-checked, and the checker is what makes a
generic call real; they are checked like entry now. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:46:08 +03:00
3d2a103dfb fix(lang): a function named like a compiler built-in is refused; reseed
A program's own `run` was never called: every call to it lowered to the
built-in System.run (C's system()), and clang failed on the IR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:09:29 +03:00
66a2bc2214 feat(lang): L11 views and templates - the UI is markup, not code
A `view Name { field = x; function q(..); on e(..) }` declaration is the one bridge between a
program and its UI: it writes view_<name>() -> UiView, whose model is a Value of every field and
whose call runs a query or an event by name.

ludic.ui is a template runtime:
- HTML-shaped XML screens and components, loaded at run time;
- {expression} bindings, if/else/each, props, slots, per-instance state;
- on-press / onclick actions (event, set, emit);
- component libraries (export="true", <import src as>).

Styling:
- stylesheets in <style> or importable .lss files (@import);
- CSS selectors (#id, .class, [attr=v], descendant and > combinators, :hover, :disabled,
  :first-child, :last-child, :nth-child, :not) weighed by specificity;
- the box model and flex under CSS's property names.

Also:
- default parameters, and positional-then-named calls;
- Value gains a float kind;
- a function shadowing a runtime one is refused;
- an index is evaluated before the slice is read;
- runtime errors name the right file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:58:47 +03:00
7e7f3ab297 feat(lang): L8 registries by declaration - registry and def
registry NAME of RECORD [as PREFIX] is a global table; def NAME key { ... } in
any file is one entry, collected in source order and filled before any code
runs. Each entry gets an index constant (PREFIX_KEY), the table PREFIX_COUNT,
and a record with a key field gets it filled and a NAME_find(key). A record
literal naming a field its record lacks is now an error everywhere; a global's
initializer is lowered as its own file's code (its errors, and what its module
may see, were whichever statement came last).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:34:51 +03:00
b0b0b62bce feat(lang): L7 memory is safe unless it says unsafe
The typed buffers are slices: words/floats/fixeds/doubles/pointers(n) make
zeroed, bounds-checked []int/[]float/... and the type names mean them. buffer(n)
is a []byte, with text_of, Fs.read_bytes/write_bytes and view(xs, start, n).
bytes(), indexing a raw pointer or bytes, free, resize, Memory.*, raw file calls,
data_of and C externs are refused outside unsafe { } / unsafe function, and a
project's own files may write unsafe only with --unsafe; the runtime and packages
are the platform. A slice passed to an extern goes as its data.

What the change found: Sync's atomics on a slice header, words(n) uninitialised,
input's fixed axes in ints, truetype's fixed outlines as ints, skin matrices
typed int, gl_shader's source table made from raw bytes. render3d gets safe
entry points (safe_api.ludic). Rendering is byte-identical; a frame costs the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:53:27 +03:00
9259808f80 feat(lang): L6 namespaces declared in Ludic - alias
`alias meth(labels) = target` in a namespace block makes Ns.meth a call to
target with those labels (the target's own parameter names without a list). The
engine's 41 table-driven namespaces - 438 methods: Http, Udp, Process, Json,
Value, Screen, Input, Audio, World, Tiled, ... - leave emit_ns_call for
runtime/native/namespaces.ludic, spliced into every program; 532 lines of
compiler go and the seed shrinks by 23k lines of IR. The game's IR is byte
identical. The checker checks an alias call's arguments against its target.
Still built in: the inline namespaces (Math, Text, List, Vector, Color, Time,
Date, ...) and the methods that pick a target by argument type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:40:57 +03:00
6a24b14f0e feat(lang): L5 generic records and functions
property Pool<T> { ... }, function first<T>(xs: []T) -> T, map<T, U> over fn
types; a type writes an instance as Pool<Thing>, nested as deep as needed. The
parser names an instance Pool$Thing and remembers its generic and arguments; the
checker takes the generic declarations out, infers a call's type arguments from
its arguments or its result's declared slot, and makes each instance once as an
ordinary record or function, checked like any other. Errors print Pool<Thing>.
An instance keeps its generic's module and export (L3). ludic-fmt keeps type
arguments together while spacing comparisons and shifts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:21:15 +03:00
57b66bdf47 feat(lang): L4 type checker between parse and emit
selfhost/check/ walks every function, the entry, tests, globals' initializers and
@On listeners with real scopes, and refuses mixed number kinds, text and numbers,
two record types, mismatched slices and fn types, wrong argument counts, wrong
returns and wrong push elements - every mix-up at once, each at its line.
LUDIC_CHECK_REPORT=1 lists them by category. pointer stays untyped (L7's).

What it found is fixed: render3d's HDR scan calling the float-bits extern f_lt
with floats; ludic.shooter's right-stick aim overflowing past half a push;
prof.ludic storing longs in []int; extern arguments now coerced to their
parameters. Text-returning runtime functions say string; Assets.ready says bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:34:49 +03:00