luv

Workshop wiki

vocabularies.org

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.

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.

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.

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.

Baked coordinates freeze the atlas #4SIKJM

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 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 cost is a few multiply-adds per fragment and a flat interpolation qualifier. The vertex probably shrinks.

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.

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.

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.

defstruct (block-mesh-kind-tables (:constructor %make-block-mesh-kind-tables)) mesher.lisp:410

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.

defstruct
block-mesh-kind-tables
:constructor%make-block-mesh-kind-tables

Per-palette-index answers the mesher gathered before its dense loop.

solid
make-array0:element-type'bit
:type
simple-arraybit
emission
make-array0:element-type'single-float
:type
simple-arraysingle-float

Six tile offsets per palette index, in *BLOCK-FACES* order.

tiles
make-array0:element-type'
unsigned-byte16
:type
simple-array
unsigned-byte16

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.

defclass block-vocabulary world.lisp:680

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.

defclassblock-vocabulary
:documentation

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.

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.

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.