Vocabularies as finite domains
A vocabulary is a finite domain #SICRZO
#B8R3KF asks four questions of a finite domain: how many sites exist, which index names one site, how an index maps to a dense offset, and how an offset maps back. A voxel chunk answers them. So does a set of block materials, and so does a set of atlas tiles. The sites are not places; they are members of a vocabulary. Nothing else about the definition changes.
Seeing this makes the machinery already built for space apply unchanged to naming. #J7A2KD divides the world into descriptions that dispatch and domains that compute; a vocabulary belongs on both sides of that line at once. A member need not be a singleton instance: an interned keyword can be the semantic identity, with its qualities supplied by EQL-specialized methods. The domain object owns the mapping from those identities to the small dense offsets a hot loop wants to index.
The luv block world already contains one worked example. block-palette-domain
in light.lisp declares the palette's entries as
a finite domain, and block-palette-light-properties materializes
propagation loss and emission over it, under a comment that states the whole
pattern: one generic dispatch per palette entry, then dense u8 lookups in
every hot loop.
The atlas originally received no such treatment. Its tile numbers were written by hand into method specializers and into each material's face list. #X3WRP1 is what that cost; #F2IOKG records the conversion to a named domain.
Mentioned in: A finite domain is more than dimensions
The tile that two branches both called thirty #X3WRP1
In August 2026 two checkouts of luvcraft added a material at the same time. One added the urbit block and took the next free atlas tile, 30. The other added a film reel and took the next free atlas tile, 30. Each was locally correct; neither could see the other.
Git merged them without a conflict, because the two definitions occupy
different regions of blocks.lisp and never
overlap textually. CLOS then accepted both (defmethod paint-block-atlas-tile
((tile (eql 30)) x y)) definitions and let the later one win, as it should.
No error was signalled at any layer. The urbit block would simply have been
painted as a film reel, and the defect would have been found by looking at a
wall.
The diagnosis is not that integers appeared in source. It is that the tile vocabulary had no object at all: it existed only implicitly, as whatever set of methods happened to be defined. There was nothing an index could be closed over, and so nothing for two branches to disagree against. #LRCB1X states the property that was missing.
Mentioned in: A vocabulary is a finite domain
An index is closed by the vocabulary that interprets it #LRCB1X
An index carries no meaning on its own. It means something exactly when it travels with the vocabulary that interprets it, or with a reference to one the reader is guaranteed to hold. Call this indexical closure.
This is a weaker and more useful requirement than "durable data must be symbolic". A save file that embeds its own material table is closed, and integers inside its columns are perfectly sound — indeed preferable, since they are dense, comparable, and cheap. What is unsound is an index whose vocabulary is implicit.
By this measure the per-chunk palette that
world.lisp carried until August 2026 was not
a mistake. A block-content-column held an adjustable palette of block
objects and a (unsigned-byte 16) index per site; the palette was an
embedded vocabulary, and it gave each chunk closure for free. The awkwardness
was granularity rather than principle: every chunk carried its own copy of a
vocabulary that was nearly always the same one, the palette was scanned
linearly on every placement, and #V6T1QS's demand that composition check domain
identity was met by accident rather than by construction.
#CPCZDB records hoisting that vocabulary to the world: one block-vocabulary
per block-world, append-only, with every chunk column closed by it. A
vocabulary embedded once per save is the same mechanism hoisted one level
further; it is only available once the material set closes, which is what
#KSWJZG is about.
Mentioned in: The tile that two branches both called thirty
Value changes and shape changes cost differently #8OP86P
A vocabulary that can be edited while the game runs faces two kinds of change, and keeping them apart is what makes live editing feel immediate.
A value change alters what a member means without altering the membership: a tile's paint function is edited, a material's emission is retuned. Cardinality and offsets are untouched, so every derived product that indexes by offset stays valid. Repaint one lane and republish.
A shape change alters the membership: a material or tile appears, or the domain is reordered. Cardinality moves and offsets may move with it, so every product that baked an offset is suspect. #A6X2RT's ownership rules decide who rebuilds what.
The useful consequence is that append-only assignment demotes most shape changes to value changes. If a new member always takes the next free offset and existing offsets never move, then adding a material leaves every mesh in the world correct and only grows the atlas prefix. This is the principled form of the rule that luvcraft currently keeps as a comment. It is not a taboo against renumbering; it is the property that stops a shape change from cascading through every product derived from the domain.
A domain that owns its offsets can own a revision alongside them, and a
materialization can record the revisions it was built from. That is the
discipline block-chunk already applies to content and boundary revisions,
one level up.
Vocabulary tables and population tables keep different time #BV6V5Y
Both are dense tables resolved from semantic objects, and they invite the same implementation, but their cadences differ enough that the mechanism should not be shared thoughtlessly.
A vocabulary table is small and changes rarely: materials, tiles, face directions, body kinds. It should be maintained in one place, revision checked, and rebuilt when a definition changes. A pass borrows the standing table and does no resolution work at all.
A population table changes every frame: which chunks are resident, which faces survive culling, which sites are on the lighting frontier. These are genuinely pass-local, and resolving them once per pass so the inner loop can run over small fixnum indices is exactly the amortization #Y0TPND describes for the packed frontier.
The mesher used to mix the two: it resolved its own population per pass, which
is right, and it also called block-face-tile once per visible face and
block-solid-p once per neighbour sample — vocabulary lookups performed at
population cadence, the smell #J7A2KD names, correct answers at the wrong
altitude. #FIGJ9R records the fix: the mesher now gathers the vocabulary
table once per job and its loop reads only dense columns.
A film is not a material #KSWJZG
tape.lisp defines film-block-kind as a
subclass of block-kind and makes one instance per downloaded video, each
carrying a video id, title, uploader, duration, and pathname. Its own
docstring gives the reason plainly: a film is its own block kind rather than
one entry in the palette, because the world's palette-by-identity storage then
keeps films apart for free.
The reason offered is a storage convenience, not a claim that a particular video is a kind of material. Three consequences follow, and all three are visible in the code today.
Films are never registered in *block-kinds*, which
define-block-kinds sets to exactly the
static list. So a film is an instance of the class whose purpose is to
enumerate the material vocabulary, living outside that enumeration and
unreachable through block-kind-named.
Persistence must special-case them. Its own comment says that carried blocks are the per-instance ones such as films, "which no palette would give back".
And the material set never closes, so no global material domain can exist and every per-material materialization must be rebuilt per chunk palette.
The same problem is solved the other way elsewhere in the same system. A
terminal wall carries a PTY, a surface, and a display, and does not mint a
material per terminal: there is one shared *terminal-block* and the state
hangs off coordinate-bearing surface objects. That is the block entity
#A6X2RT anticipates as sparse state outside dense site storage.
luv might therefore treat this as evidence about the representation choice rather than about films. Because materials were already heap objects with mutable slots, minting one more was a single line, and the door to per-particular materials stood open. Closing the material vocabulary means moving film state to entities first; the global material domain, the removal of per-chunk palettes, and the migration seam in #FTDOCV all follow from it.
Mentioned in: An index is closed by the vocabulary that interprets it, Make films block entities so the material vocabulary closes
Baked coordinates freeze the atlas #4SIKJM
The mesher writes an atlas coordinate into every vertex, computed from the tile number and the atlas width:
with C the tile capacity, currently 64. A block vertex is 16 single floats, six per face.
The divisor is the part worth noticing: C is baked into every vertex in the world. That is the real reason the atlas capacity must be fixed and can never grow — growing it would invalidate every mesh ever built. The capacity headroom in luvcraft, which allocates 64 tiles and paints a prefix of 37, is a workaround for a constant that a shader could have supplied as a uniform.
An alternative is to carry the tile index flat and the tile-local coordinate interpolated, and compute the atlas coordinate per fragment. Four things follow, and luv might weigh them together:
- the atlas becomes free to grow at runtime, because C is no longer baked;
- carrying a material and face identity instead of a tile index would let a material be retiled without re-meshing, with the vocabulary table itself living on the GPU;
- tile-local coordinates in [0,1] spend their whole mantissa on the fraction that matters; and
- greedy meshing becomes expressible, since merged faces need coordinates running past 1 with a wrap in the shader, which baked atlas coordinates cannot express without bleeding into a neighbouring tile.
The cost is a few multiply-adds per fragment and a flat interpolation qualifier. The vertex probably shrinks.
Mentioned in: Compute atlas coordinates in the shader
A save is a vocabulary and its columns #FTDOCV
luvcraft's saves are symbolic throughout: a block is written as
(:block :name :urbit) and restored through block-kind-named. This is why
renumbering an atlas tile cannot damage an existing world, and it is a good
property to have arrived at.
It is not the only closed shape available. A save may equally carry its material vocabulary as a table and its world as columns of indices into that table — denser, and closer to the materialization vocabulary the rest of the system already speaks. #V6T1QS's contract applies unchanged: a domain, a closed schema, one representation per logical field, exactly domain-size values in each.
The migration seam then becomes a single object rather than a decision at every site. Loading resolves the save's vocabulary against the running one and produces a remap from save index to runtime index, and every awkward case is one row in it: a renamed material is an alias, an unknown material is a placeholder that still round-trips the name it stood for, and a reordered runtime vocabulary is merely a different permutation. Nothing is decided per voxel, and the seam is an inspectable, printable, diffable table.
luvcraft's current format check is an equality on
+luvcraft-save-format-version+, which makes the first real bump reject every
existing file. A vocabulary-bearing save wants the opposite disposition:
accept anything up to the current version, migrate forward through one step per
version, and write only the newest, so files heal as they are saved.
Mentioned in: A film is not a material, The world owns one block vocabulary, Make films block entities so the material vocabulary closes, A save is a vocabulary table and its columns
DONE Name the atlas tiles and give the domain an owner #F2IOKG
Intent. Replace the hand-allocated atlas tile integers with keywords, and
make the tile vocabulary a finite domain object that assigns offsets.
Specializers become (eql :urbit) rather than (eql 36); a material's
face list names tiles rather than positions; the atlas materializes over the
domain, appending offsets and never moving one.
Consider the self-naming default at the same time. Of the twenty-eight tiles the blocks reference, twenty-four are used by exactly one material, so a default of "a material paints itself" would let sixteen of twenty-five materials say nothing about tiles at all, leaving only the interesting cases written down: grass split three ways, snow borrowing dirt underneath, the fountain and lava spring borrowing masonry.
Evidence. *block-atlas-tile-domain* now maps thirty-seven keyword identities
to the historical dense lane order. Paint methods and material face maps use
those identities; mesh, body, icon, and atlas materialization code resolve an
offset only at the representation boundary. The finite-domain tests reject
duplicate names and demonstrate that the same offset means something different
under another explicit mapping. The full test suite passes, and make smoke
reproduces the prior image byte for byte at
fa11abca777cb86907f660d180480f5476c68c315927e306cfe82b95bc59e785.
Done when. No atlas tile integer appears in
blocks.lisp,
balls.lisp, or the block face lists; a
duplicate tile name is an error at definition time rather than a silently
overridden method; and make smoke renders the same image byte for byte.
Mentioned in: A vocabulary is a finite domain
DONE Compute atlas coordinates in the shader #YLZYQJ
Intent. Carry a flat tile identity and an interpolated tile-local coordinate in the block vertex, and move the atlas arithmetic of #4SIKJM into the block world shaders, with the atlas width as a uniform.
Evidence. The block vertex now carries tile-local UV, the dense tile offset under the atlas mapping, and one base-three scalar for its four edge classes. That packing shrinks the vertex from sixteen floats to fourteen. The vertex shader reconstructs atlas UV from the inset tile-local coordinate, the tile offset, and the actual renderer-owned texture width in the frame uniform; the tile identity also crosses the stage boundary with explicit flat interpolation.
Atlas capacity is a live materialization policy rather than a constant.
refresh-block-atlas republishes paint in place when the extent agrees; after
a capacity change it transactionally creates wider colour and normal textures,
discards bind groups naming the old views, and publishes the replacements.
Existing chunk meshes are untouched. Both SPIR-V and Metal lower the new flat
interface explicitly, all generated SPIR-V validates, and the full test suite
passes. make smoke remains byte-identical at
fa11abca777cb86907f660d180480f5476c68c315927e306cfe82b95bc59e785.
Done when. The atlas capacity can be raised in a running image and existing chunks keep rendering correctly; the vertex is no larger than the sixteen floats it is today; the smoke render is unchanged.
DONE The mesher gathers first and then runs one dense loop #FIGJ9R
Intent. Stop the mesher from asking a CLOS block object, through a generic
function, what it is — once per neighbour sample, some twenty times per face.
Gather instead: project the (small, closed) block vocabulary through
block-solid-p, block-surface-emission and block-face-tile into flat
per-offset tables once per mesh job, and mesh straight out of the snapshot's
u16 index and u8 light columns with hoisted strides.
Evidence. A statistical profile of the old loop showed about thirty percent
of time in PCL dispatch, twenty percent in generic FLOOR from per-sample
coordinate decomposition, and nothing at all in the tile vocabulary lookup
that had been suspected. gather-block-mesh-kind-tables now builds
the tables and mesh-block-halo is the loop; owner-side meshing goes
through the same snapshot as the workers, and make-block-mesh-snapshot
copies its halo as slabs from the neighbouring chunks. The output is
bit-identical to the old mesher across every resident chunk of a played
world; for sixty chunks, meshing fell from 396 ms to about 10 ms and snapshot
capture from 118 ms to about 2 ms.
Done when. The loop touches no block or face object and no generic
function per sample; sb-sprof shows no PCL frames under mesh-block-halo;
meshes are unchanged.
Mentioned in: Vocabulary tables and population tables keep different time
Referenced from code: Per-palette-index answers the mesher gathered before its dense loop. Six tile offsets per palette index, in defstruct (block-mesh-kind-tables (:constructor %make-block-mesh-kind-tables)) mesher.lisp:410 ↗
*BLOCK-FACES* order.
DONE The world owns one block vocabulary #CPCZDB
Intent. Replace the per-chunk palette with one append-only vocabulary per
block-world, so that every chunk's u16 column means the same thing, a
mesh snapshot needs no palette translation, a block placement is a hash
lookup rather than a linear scan, and per-kind tables can in time be
materialized once per vocabulary revision rather than once per chunk.
Evidence. block-vocabulary in world.lisp: NIL (air) is always offset
zero, members only append, and a revision counts the appends. A
block-content-column binds to a vocabulary; block-content-column-palette
still answers the member vector, so whole-domain borrowers in light, physics
and the mesher read it unchanged. A column produced under another world's
vocabulary — as the streaming worker's throwaway world produces them — is
translated in place by block-vocabulary-translation when it is
installed, which is #FTDOCV's remap at chunk granularity. The mesh snapshot
freezes the member vector by copying and uses its length as the index of an
absent halo sample, so a vocabulary that grows after capture cannot change
what a captured index means. Films still take an offset per video, which is
what #R7MAN5 would close.
Done when. All chunk columns of a world share one vocabulary object; a transferred column installs with its members appended and its indices remapped; the world and mesh tests pass and the played world renders as before.
Mentioned in: An index is closed by the vocabulary that interprets it, Make films block entities so the material vocabulary closes
Referenced from code: Block content is a narrow semantic field. The presentable value is a
shared Lisp object (or NIL for air); the physical column is a dense u16
offset under the world's block vocabulary. A site is an offset in a
domain, not an object with an identity or allocation of its own.
Computational code should borrow the aggregate storage below and dispatch
once per chunk. Single-site world access still resolves through
coordinate descriptors and a chunk key; it is for inspectors, sparse
interaction, and other genuinely row-shaped work. The block vocabulary is the finite domain a content column's indices are
closed by: an append-only, world-wide ordering of block objects with NIL
(air) at offset zero. One world has one vocabulary, so every chunk's u16
columns mean the same thing, snapshots need no translation, and per-kind
tables can be materialized once per vocabulary revision rather than once
per chunk. Membership grows only when a never-seen block is placed;
offsets never move, so a shape change leaves every existing column and
derived product valid. See #CPCZDB. An append-only finite domain of block objects; NIL is always offset 0. MEMBERS is the adjustable vector interpreting every dense offset; borrowing
it as a palette is the ordinary way whole-domain code reads block identity.defclass block-vocabulary world.lisp:680 ↗
TODO Make films block entities so the material vocabulary closes #R7MAN5
Intent. Give films the representation terminal walls already have: one static
:film material, with per-film state in sparse coordinate-keyed entities
outside dense site storage. The material vocabulary then closes, which admits
a global material domain, a single materialization of per-material properties
rather than one per chunk palette, and the removal of the per-chunk palette
in favour of a global material index stored directly in the site column.
Evidence. #KSWJZG: films are block-kind instances deliberately absent from
*block-kinds*, and persistence special-cases them for that reason. Since
#CPCZDB the world vocabulary is already global and append-only, so a film
costs one offset per video rather than a palette entry per chunk; what
remains open is that the vocabulary cannot be enumerated from *block-kinds*
alone, and block-palette-light-properties still rebuilds its table per
capture rather than once per vocabulary revision.
Done when. *block-kinds* enumerates every material in the running image;
per-material properties are materialized once per vocabulary revision; an
existing saved world containing films loads with its films intact through the
remap of #FTDOCV.
Mentioned in: The world owns one block vocabulary, A save is a vocabulary table and its columns
IDEA A save is a vocabulary table and its columns #OQDNC5
Intent. Reshape the save format as a vocabulary table plus columnar world data, per #FTDOCV, and make loading a vocabulary resolution that produces an index remap. Aliases, unknown materials, and reordering all become rows in that remap.
Evidence. The format version check currently rejects any file it does not recognize exactly, which makes the first migration expensive for a game with two players. The remap is the natural home for the compatibility rules that would otherwise be scattered through per-block restoration.
Note. This should not move before #R7MAN5, since the vocabulary a save would embed is the one that does not yet close.
Multiplication and scalar scaling.
An append-only finite domain of block objects; NIL is always offset 0. MEMBERS is the adjustable vector interpreting every dense offset; borrowing it as a palette is the ordinary way whole-domain code reads block identity.
An append-only finite domain whose semantic members have EQ identity. MEMBERS is the adjustable vector interpreting every dense offset. Appending a new identity increments REVISION without moving any old offset, so retained dense columns stay meaningful. Clients borrow MEMBERS rather than copying it; snapshots which…
At minimum a finite domain answers: – how many sites exist; – which index type names one site; – how an index maps to a dense storage offset; and – how a dense offset maps back to an index. Additional capabilities should be explicit rather than presumed: – a neighborhood relation; – metric spacing or cell extent; –…
The intended division is not "CLOS outside, low-level code inside." It is more precise: – CLOS objects describe worlds, domains, field meanings, representations, policies, algorithms, and presentable categorical values; – generic dispatch selects an operation and storage representation once at an aggregate boundary;…
In August 2026 two checkouts of luvcraft added a material at the same time. One added the urbit block and took the next free atlas tile, 30. The other added a film reel and took the next free atlas tile, 30. Each was locally correct; neither could see the other. Git merged them without a conflict, because the two…
Intent. Replace the hand-allocated atlas tile integers with keywords, and make the tile vocabulary a finite domain object that assigns offsets. Specializers become (eql :urbit) rather than (eql 36); a material's face list names tiles rather than positions; the atlas materializes over the domain, appending offsets and…
An index carries no meaning on its own. It means something exactly when it travels with the vocabulary that interprets it, or with a reference to one the reader is guaranteed to hold. Call this indexical closure. This is a weaker and more useful requirement than "durable data must be symbolic". A save file that…
A bundle is an eager answer to a finite question: for every site in this domain, what are the values of these fields? It is neither the entire world nor a universal database. A useful tentative contract for luv is: The schema should be closed by a class or other inspectable definition. A hash table into which…
tape.lisp defines film-block-kind as a subclass of block-kind and makes one instance per downloaded video, each carrying a video id, title, uploader, duration, and pathname. Its own docstring gives the reason plainly: a film is its own block kind rather than one entry in the palette, because the world's…
One universal voxel bundle would combine values with very different causes and invalidation rules. A more legible arrangement might eventually include: – a mandatory block-content bundle; – a derived or incrementally maintained light bundle; – an optional fluid bundle; – ecological or geological readings over their…
The packed lighting frontier in #QS1ERH is the first small dynamic relative of a bundle. Its current sites are the ordinal domain [0, length); pushing and popping changes membership, while three parallel arrays materialize one entry, offset, and level reading for every member. The level buckets decide which buffer…
luvcraft's saves are symbolic throughout: a block is written as (:block :name :urbit) and restored through block-kind-named. This is why renumbering an atlas tile cannot damage an existing world, and it is a good property to have arrived at. It is not the only closed shape available. A save may equally carry…
The mesher writes an atlas coordinate into every vertex, computed from the tile number and the atlas width: u = \frac{16\,t + \tfrac{1}{2} + 15\,u_{\text{local}}}{16 \cdot C} with C the tile capacity, currently 64. A block vertex is 16 single floats, six per face. The divisor is the part worth noticing: C is baked…
Intent. Give films the representation terminal walls already have: one static :film material, with per-film state in sparse coordinate-keyed entities outside dense site storage. The material vocabulary then closes, which admits a global material domain, a single materialization of per-material properties rather than…
Intent. Stop the mesher from asking a CLOS block object, through a generic function, what it is — once per neighbour sample, some twenty times per face. Gather instead: project the (small, closed) block vocabulary through block-solid-p, block-surface-emission and block-face-tile into flat per-offset tables once per…
Intent. Replace the per-chunk palette with one append-only vocabulary per block-world, so that every chunk's u16 column means the same thing, a mesh snapshot needs no palette translation, a block placement is a hash lookup rather than a linear scan, and per-kind tables can in time be materialized once per vocabulary…
Gathering.
The mesher starts by collecting everything it will ask of the block vocabulary, once per job, into a few flat tables indexed by palette position: occupancy, surface emission, and one atlas tile offset per face. The loop below then reads u16 palette indices and u8 light levels straight out of the halo columns and never consults a block object, a face object, or a generic function per sample. The vocabulary is small and closed -- it changes only when someone redefines a kind at the REPL -- so the gathering is cheap and the protocol generics still decide what each kind means. See #FIGJ9R.