dInfinity

Dice sets

Design: the installed-set list (options 1s and 1t), the set details (6a, and 6b for a failed validation), the “My dice” details with its export (8c), the disable/remove dialog (5a) and the update flow (9h–9i) are in the clickable design (design/).

A dice set is a folder containing a diceset.toml file and, optionally, textures. The built-in dice are a dice set too; there is no privileged code path for them. Anyone can publish a set on a git forge or as a plain archive on any https server, and users can install it from inside the app.

The same package format also carries tables (the look of the dice tray); see docs/tables.md. A package may contain dice, tables, or both.

Design rules, in priority order:

  1. A set can never crash the app, hang it, fill its storage, or read anything outside its own folder.
  2. A set that fails validation is rejected entirely with a readable report. There is no “partially installed” state.
  3. A set that passes validation may still roll badly (e.g. an ugly, lopsided custom shape). That is the author’s problem, not a safety problem; the physics rules still apply and the result is still read honestly.

File layout

my-dice/
  diceset.toml
  textures/
    d20.png
    d6-face-1.png
    …
  README.md          (optional, shown in the app's set details)
  LICENSE            (optional, shown in the app's set details)

Only diceset.toml is required. Anything not referenced from it is ignored.

A worked example of exactly this layout is in the repository at examples/ — one die on every catalogue shape, a diceset.toml whose comments say what each field is for and what it is limited to, and a blank atlas per shape cut to the grid below. It is the place to start, and Authoring tips says why. Five finished sets to install and look at — marble with artwork on every face, steel, green resin, red glass, and Halloween, which glows and has a pumpkin die with artwork on half its faces — are in sample-sets/, each as a folder and as a zip.

diceset.toml

format = 1                       # schema version, required

[set]
id = "brass-and-bone"            # [a-z0-9-], 3..40 chars, unique per install
name = "Brass & Bone"
version = "1.2.0"                # semver, used for updates
author = "Ada Example"
license = "CC-BY-4.0"
description = "Brass numerals on bone-coloured resin."
homepage = "https://github.com/ada/brass-and-bone"

[defaults]                       # applied to every die unless overridden
color = "#e8dcc0"
number_color = "#8a6d1e"
roughness = 0.35
metallic = 0.0
size_mm = 16                     # clamped to 8..40
density = 1.2                    # g/cm³, clamped to 0.5..8
translucency = 0                 # %, 0 solid .. 100 glass, clamped
restitution = 0.55               # clamped to 0.0..0.8
friction = 0.5                   # clamped to 0.1..1.0
edge_rounding = 0.03             # share of size_mm, clamped to 0.015..0.06
glow = 0                         # %, 0 none .. 100 glow-in-the-dark at full charge
glow_color = "#e8dcc0"           # the colour it glows in; color when not given

# --- dice -----------------------------------------------------------

[[die]]
id = "d6"                        # what the notation "d6" resolves to
shape = "cube"                   # from the shape catalogue
faces = [1, 2, 3, 4, 5, 6]       # value per face, in the catalogue's face order
texture = "textures/d6.png"      # atlas; layout defined by the catalogue shape

[[die]]
id = "d20"
shape = "icosahedron"
faces = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
texture = "textures/d20.png"
color = "#2b2b2b"                # per-die override

[[die]]
id = "d4"
shape = "elongated-square-bipyramid"   # the long d4: read from the face on top
faces = [1, 2, 3, 4]             # one value per long face

[[die]]
id = "d10-tens"
shape = "pentagonal-trapezohedron"
faces = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
labels = ["00","10","20","30","40","50","60","70","80","90"]

[[die]]
id = "skull-d6"                  # any id; written 3{skull-d6} or tapped in the picker
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
labels = ["💀", "2", "3", "4", "5", "6"]   # what is printed if no texture

[[die]]
id = "d6"
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
color = "#f0e6d0"                # per-die override

# --- tables (optional, see docs/tables.md) ---------------------------

[[table]]
id = "bone-felt"
name = "Bone felt"
floor_texture = "tables/felt.png"
floor_normal = "tables/felt-normal.png"
floor_tile_mm = 80
color_mode = "average"
floor_color = "#d9cbb0"
wall_color = "#3a2a18"
sound = "felt"

Fields

Field Required Notes
format yes Integer. The app refuses formats newer than it knows.
set.id yes Slug of 3–40 characters ([a-z0-9-], starting and ending with a letter or digit). Used as the setref in notation and as the folder name, which is why it has a floor. Notation only reads a setref that starts with a letter (docs/dice-notation.md), so a set whose id starts with a digit installs and rolls from the picker, and a typed formula names it only inside braces, {3dice:d6}.
set.name, set.version yes  
set.author, license, description, homepage no Displayed only; nothing in the app enforces a licence. license is an SPDX identifier by convention — see “What a licence means”. homepage is shown as text, opened only on explicit tap, https only.
defaults.* no Material and physics defaults, all clamped.
die.id yes Slug of 1–40 characters, unique within the set — shorter than a set id, because d2, d4 and d6 are the ids plain notation resolves. Standard names (d2, d4, d6, d8, d10, d10-tens, d12, d18, d20, df) are what typed notation resolves — there is no d100 die, because d100/d% is always a d10-tens and d10 pair (docs/dice-notation.md, “d100 and d%”), optionally set-qualified as brass:2d20. A die with any other id is named in braces — 3{skull-d6}kh1, or 3{brass:skull-d6}kh1 from a named set — because a slug and a modifier are made of the same characters and only the } says where the id ends; the dice picker offers it too and writes the braces for you (docs/dice-notation.md, “A set’s own dice”).
die.shape yes A name from the shape catalogue below. v1 has no other option.
die.faces yes Integer values, one per face a value is read from. Length must match the shape. Range −9999..9999. Duplicates allowed (a d6 numbered 1–3 twice, say).
die.labels no Strings printed on faces when no texture. Defaults to faces as text. Max 4 characters each.
die.read no face-up, the default and the only value: every die is read from the face on top. vertex-up went with the tetrahedron (decision 109) and is refused.
die.texture no Path to a PNG/WebP atlas, relative, inside the set folder.
defaults.translucency, die.translucency no Per cent, 0 solid to 100 glass, clamped. Above 0 the die is drawn as resin: the felt and the dice behind it show through it, bent and tinted by the die’s own color — more of it the higher the value, and wholly at 100 (docs/physics-and-rendering.md, “A die you can see into”). How milky it is, how thick and how it tints what is behind it are worked out from this, color, size_mm and roughness — a low translucency is milky, a high one glassy, and a rough die is frosted — so there are no resin fields of their own. The numerals and any artwork stay fully opaque whatever it is, because a face you cannot read is not a die.
defaults.edge_rounding, die.edge_rounding no How round the die’s edges and corners are, as a share of size_mm: 0.03 (0.48 mm on a 16 mm die) when not given, clamped to 0.015–0.06 with a warning, refused when it is not a number. The top is measured, not chosen: past 6 % a coin’s rim starts to roll like a wheel and throws stop settling (docs/physics-and-rendering.md, “How round a die may be”; decision 100). It is a physical property, not a look: it is the solver’s convex radius, and the renderer draws the die with exactly the rounding the solver collides (docs/physics-and-rendering.md, “Dice bodies” and “Rounded edges”). Every catalogue solid gets the rounding it asks for, the long d4’s points included (decision 109). Rounder dice tumble a little longer and land a little differently; a fairness run can be asked about any value of it (FairnessTest’s edgeRounding) (docs/architecture.md, decision 94).
defaults.glow, die.glow no Per cent, 0 (the default) for a die that only reflects light to 100 for glow-in-the-dark at full charge, clamped with a warning. The die gives off light of its own in glow_color, added after the lamps, so the face turned away from them and the side in its own shadow show the glow rather than going dark (docs/physics-and-rendering.md, “A die that glows”). A look, not physics: nothing in the solver reads it. What is printed does not glow — numerals and artwork are paint on the outside of the body, as with translucency — so black ink on a glowing die stays black. A real glow-in-the-dark die is pale, translucent resin, and a set that wants one writes a translucency as well; the two are independent fields (sample-sets/halloween). Lights nothing around it: the felt beside a glowing die is lit by the tray’s lamps alone.
defaults.glow_color, die.glow_color no The colour a glowing die gives off, #rrggbb. When not given it is the die’s color. A glow-in-the-dark die is pale in daylight and green in the dark, so the two are separate. Refused when it is not a colour, like color.
die.color, number_color, roughness, metallic, size_mm, density, restitution, friction, glow, glow_color no Per-die overrides of defaults.
table.* no Table looks; fields and limits in docs/tables.md.

What a licence means

license is displayed and nothing else. The app does not read it, does not enforce it and does not refuse a package that has none: it is a statement by the author to whoever installs the package, and the app’s job is to carry that statement faithfully and show it where it will be seen (design 6a).

It should be an SPDX identifier — CC-BY-4.0, MIT, GPL-2.0-or-later — because the point of the field is that the reader recognises what it says. Where SPDX has no identifier for what the author means, its LicenseRef- form is the way to say so; the face designer writes LicenseRef-All-Rights-Reserved for a set somebody is keeping.

A package written by the app itself uses the word unspecified while nobody has chosen, which is written down rather than left out — a missing field cannot be told from one an older version never wrote. Nothing the app shares is ever left at unspecified: the face designer’s export is shut until a licence has been picked (docs/face-designer.md, “The licence, and why it is a gate”).

Packages the app writes

Some packages are generated on the device rather than downloaded. “My dice”, id mine, is built from the drawings in the face designer (docs/face-designer.md) and from the photographs somebody has made tables of (docs/tables.md, “Your own photo”). Beside it there can be any number of named personal sets, made with New set… in the designer’s Save sheet (docs/face-designer.md, “Save to set”; docs/architecture.md, decision 79):

Every one of them is a folder in dicesets/ like any other, and there is no privileged path for any of them:

Shape catalogue

The catalogue is closed in v1: these eight shapes and nothing else. Face count, face order and texture atlas layout are defined by the app, in code: the atlas grid in core/model (ShapeAtlas.kt) and each solid’s faces, their order and which way each cell is drawn up in simulation/api (SolidFaces.kt). examples/ carries a blank atlas for every shape to draw over.

Name Faces Typical use
coin 2 d2
elongated-square-bipyramid 4 d4: the long die (below)
cube 6 d6
octahedron 8 d8
pentagonal-trapezohedron 10 d10, d10-tens (together: d100 / d%)
dodecahedron 12 d12
enneagonal-trapezohedron 18 d18
icosahedron 20 d20

A set is free to define any number of dice on these shapes, with any face values and any artwork — a d6 of runes and a d20 of skulls are both just cube and icosahedron. What v1 does not accept is a new solid: a d30, or a shape supplied as a mesh. See “Shapes after v1” below.

The atlas layout for a catalogue shape is a fixed grid: face i occupies cell i of an N-cell grid, each cell square, drawn with the face’s “up” direction matching the catalogue’s reference orientation. The face designer produces exactly this layout, so hand-drawn and hand-authored sets are interchangeable.

Up is +z, in the tray, in the physics and in the renderer — one right-handed coordinate system shared by all three, so nothing has to be turned over on the way between them. “Up” on a face is that up flattened onto the face: the part of +z that lies in the face’s plane. A face pointing straight up or straight down has no such part, and those two are turned by +y instead. The face’s own circle fills the cell, so a triangle and a pentagon both touch its edges and a strip of cells is drawn at one size.

This is the file format’s rule and it does not move: every published set is painted to it, and changing it would repaint all of them. What does have to follow it is anything that writes an atlas, and the app’s own face designer did not — it copied its canvas into the cell square on, so a drawing came out turned by up to sixty degrees on a d20 and clipped where it ran past the real polygon. It turns each cell to this rule now (docs/face-designer.md, “Export details”).

The d4

The d4 is a long die: a square prism lying on one of its four long faces, with a short pyramid on each end (core/model’s LongDieShape). It is read like every other die, from the face on top, and its four long faces are its four cells. The ends carry no value and no cell; they are drawn in the die’s colour and it never rests on them.

flowchart LR
  end1["point"] --- body["four long faces, 1 to 4<br/>0.8 wide, 1.4 long"] --- end2["point"]

Face order. Faces are numbered from the top of the shape’s reference orientation downwards, and anticlockwise around each ring starting from the +x side; face 0 is the one that is up when the die has not been turned. That is a choice rather than a law, but it is a fixed one — it is the order faces is read in and the order the atlas fills its cells, so changing it would silently repaint every die of every set ever published. simulation/api owns it — and with it which corners make up which face (SolidFaces) — and the renderer, the physics hull and the face designer’s Solid tab are all built from that same arithmetic rather than from a model somebody exported.

Numbering

Opposite faces add up. A moulded die is numbered so that the pair across from each other sums to one more than the face count: 2 against 5 on a d6, 1 against 20 on a d20, 1 against 12 on a d12. The numbering therefore follows the pairing rather than the face order — the lowest number goes on the first face that has not been numbered, the highest on the face across from it, then the next lowest and the next highest — so the bundled package’s faces lists are not 1, 2, 3, … down the order.

Which face is across from which is geometry, not a convention: it is read off the same normals the die is scored from, in simulation/api’s ShapeGeometry.oppositesOf, and FaceNumbering lays a set of values out along them. Nothing hand-writes a table per shape, because a table would be a second description of the face order and the two would come apart the first time either was touched. The bundled diceset.toml carries the answer written out — a set file is data and cannot compute anything — and a test in dicesets/builtin holds it to what the solids say.

It is the same rule for values that are not 1..n. A Fudge die’s −1, −1, 0, 0, 1, 1 comes out a minus across from a plus and a blank across from a blank; a tens d10’s 0–90 comes out 0 across from 90.

Every shape in the catalogue has a face across from every face. The long d4’s four long faces are two pairs, so it is 1 across from 4 and 2 across from 3.

A set author is not obliged to follow any of this. faces is whatever the author writes and the app prints it where the order says; the rule is what the bundled set does and what the face designer’s “fill all with numbers” starts from (docs/face-designer.md).

A die that was rolled before this rule is not a die that was rolled after it. The same seed puts the same face up and that face now carries a different number, so a replayed roll and a per-face statistic taken before the change do not compare with ones taken after (docs/statistics.md).

Size

size_mm is how wide the die is: the diameter of the sphere its corners sit on, whatever solid it is. A 16 mm d6, a 16 mm d12 and a 16 mm d20 are all 16 mm across at their widest. Half of it is the bounding radius the table’s capacity rule sums (docs/tables.md).

It is deliberately not the dice maker’s nominal size — the edge length for a polyhedron — even though that is what a manufacturer quotes. A set author writing size_mm = 16 means “a 16 mm die”, and should not have to know that a dodecahedron’s edge is 0.36 of its width to get one. Read as an edge length, size_mm = 16 makes a d12 that is 45 mm across, and dice that size under ordinary gravity fall slowly enough to look weightless.

One consequence worth knowing: a d4 and a d20 of the same size_mm have the same bounding sphere, so the long d4 — as long as the d20 is wide, and slender — is the smaller solid inside it. That is how a real set looks — the dice are sized to sit together in a hand, not to enclose the same volume.

Weight, translucency and size, as a person sets them

A set author writes size_mm, density and translucency. A player does not think in any of those, and the design of 2026-09-17 gives the set detail screen a Physical block that says the same three things in the words a dice shop uses:

Shown Unit Built-in Brass & Bone Nordic runes
Weight g per die 4.2 6.8 9.1
Translucency % 0 18 0
Size % of average 100 108 115

Shapes after v1

Everything in this section is planned, not implemented. v1 rejects shape = "mesh" at validation with “unknown shape”; a set that uses it does not install. The design is written down here so the format does not have to change shape later, and so nobody has to rediscover the constraints.

Two things are wanted: more catalogue solids (a d30 as a rhombic-triacontahedron is the obvious first one) and author-supplied convex meshes. The catalogue is easy — it is one more entry per solid. The mesh path is the one that needs rules:

Plus die.mesh (relative path to the OBJ) and die.face_map (OBJ polygon index → entry in faces; coplanar polygons map to the same face) as extra fields.

A perfectly legal custom mesh may still be a terrible die — a very flat one lands on two faces and nothing else — and the geometry alone cannot say how unfair it is. So mesh support also needs a fairness preview: roll the die 1,000 times headless on import and show the face histogram, informational only. That preview is part of the same future work; v1 has no dice whose fairness is in question.

Why this is not in v1: every catalogue solid is a fair die whose behaviour is known and tested, and the physics, the atlas layout and the statistics all lean on that. Arbitrary hulls bring convexity checks, degenerate geometry, unfair dice and a fairness UI — a whole feature, not a field.

Textures

How an atlas reaches the tray

A die’s texture is a path relative to its own set’s folder, so the path alone names nothing — two packages may both ship textures/d20.png. What the renderer asks for is therefore a key: the package and the path, written mine::textures/d20.png (AtlasKey in render/filament). The separator is :: because a set id is a slug and cannot contain one, and because a path that arrives with no package in front of it is then refused rather than mistaken for a package called textures.

flowchart LR
  die["Die.texturePath<br/>textures/d20.png"] --> key["AtlasKey.of(setId, path)<br/>mine::textures/d20.png"]
  key --> stage["FilamentStage<br/>atlases: (String) -> Texture?"]
  stage --> cache["AtlasCache, on FilamentEngine<br/>one Texture per key and file version<br/>misses remembered"]
  cache --> app["DieArtwork, in :app<br/>splits the key"]
  app --> found["InstalledArtwork<br/>which folder, which file, how big"]
  found --> decode["AtlasDecoder<br/>bounds, then pixels, then cells"]
  decode --> image["AtlasImage<br/>RGBA8, straight alpha"]
  image --> cache

Four things about that path are rules rather than arrangement:

A table look’s pictures go through this too. The validator stamps every look with the package it read it out of, so a floor’s picture is keyed exactly like a die’s atlas — builtin::tables/felt-albedo.webp — and goes through the same path, size and decode checks on the way. Two things differ, both on the GPU side: a table’s pictures are uploaded with every mip level and sampled anisotropically, because a floor is looked at along it, and the colour picture is uploaded as sRGB (docs/tables.md, “Textures”). The bundled package is answered from its resources, the files it was validated from, because it is never installed to disk; its id is matched before any folder’s, as SetLibrary matches it.

Labels, and the artwork over them

A face carries its drawing or its label, never both. The labels go on in number_color on the body colour, in the same atlas grid an image fills, and the atlas is composited over that by its own alpha — but only the faces the atlas leaves clear are printed. A cell with anything drawn in it (any pixel above AtlasImage.CLEAR_ALPHA) carries the drawing on the body colour and no label; a cell left wholly clear carries the label. That is what “an atlas may leave cells transparent” above means, and it is settled per face, from the decoded pixels, by the renderer (render/filament’s AtlasCoverage and PrintedDice; docs/physics-and-rendering.md, “Rendering”; docs/architecture.md, decision 96). A die with no texture at all is simply the case where every cell is clear.

It was settled per pixel until v0.2.x — every face printed, the artwork laid over the printing — and that showed the label through every clear part of a drawing: a skull drawn in outline on a d6’s 1 had a 1 in the middle of it, and every face drawn in the face designer came to the tray with the set’s number over it. A number nobody drew is not part of anybody’s artwork. An author who wants the number and a border draws both, which the face designer’s “fill all with numbers” does in one tap (docs/face-designer.md).

The decoder says which face cells came out empty, as a warning, because the other way to arrive at an empty cell is an atlas saved in the wrong grid and the two look identical from outside (“Validation”, below).

Three rules decide what a face ends up carrying when it is printed:

The label What is printed Why
something the built-in font can draw — digits, +, −, ×, %, . the label it is what the author wrote
something it cannot, such as 💀 the face’s value a row of blanks would make the die unreadable, and a box would be a lie about what the author wrote. The value is the one thing about a face the app can always write down, and it is what the player is about to read off it anyway
empty nothing a blank side is a face an author asked for, and half a Fudge die is exactly that

A value printed as a label is written with a typographic minus, U+2212, and not with the hyphen on a keyboard. That is the character the bundled set labels its Fudge die with, the one the notation help writes −1, 0 or +1 with, and the one the built-in font draws at the width of the + on the next face of the same die; a hyphen is a word-joiner, drawn short, high and thin, and it does not read as the other half of a pair. It applies in the two places the app chooses the text itself — a faces entry with no labels beside it, and the fallback in the table above — and nowhere else. labels is printed exactly as the author wrote it, hyphen and all: the app does not correct somebody’s typography, and Face.printed is a default rather than a filter.

A number is marked when it could be read as another number on the same die. Turn the label about; if what comes out is a different label this die also carries, both are marked. That is why a d20’s 6 and 9 are and a d6’s 6 is not — a d6 has no 9 for its 6 to be mistaken for, which is exactly what a moulded d6 does. An 8 turns into itself and a 2 turns into nothing readable, so neither is ever marked.

The mark is a trailing dot, 6. and 9., which is the design’s (design/dInfinityPhone.dc.html, 2026-09-17) and which replaces the bar underneath this rule used to draw. A bar under a numeral is a second horizontal in a system whose dice already have edges; a dot is the one mark that cannot be confused with the die.

The derivation is what makes the percentile pair come out right without a rule of its own: a d%’s units digit is 6 against 9 and is dotted, while its tens die carries 00–90, none of which turns into another of them, so the tens are never dotted. A number set upright in the result sheet is not marked either, and for the same reason — nothing there is lying at an angle.

The font is not the set’s to choose. It is one built-in face, cut from Archivo (docs/assets/README.md), and a set that wants its own lettering draws it and ships it as a texture — which is what a texture is for.

Installing from a URL or file

Users paste a URL. Accepted sources:

Source Example How it is fetched
GitHub https://github.com/ada/brass-and-bone
…/tree/v1.2.0
…/tree/main/sets/skulls
Ref resolved to a commit SHA via the API, tarball for that SHA
GitLab (gitlab.com or self-hosted) https://gitlab.com/ada/brass-and-bone/-/tree/main Same, via the GitLab API
Codeberg / Gitea / Forgejo https://codeberg.org/ada/brass-and-bone Same, via the Gitea API
Any archive https://example.org/dice/brass.zip
https://example.org/dice/brass.tar.gz
Direct download; the SHA-256 of the archive is recorded in place of a commit SHA
Local file picked via the system file picker A single .zip, .tar.gz or .tgz file. A folder cannot be picked: the picker opens one document, so a set on the phone’s storage is zipped first.

The forge integrations exist for convenience (browse to a repo, paste the URL, get updates). They are not what makes an install safe — the validator is, and it runs identically for every source; an unknown host is treated as a plain archive and goes through the same extraction and the same checks.

Plain http:// is refused before a request is made. Redirects are followed by hand rather than by the HTTP client, so that a redirect from https to http — the oldest downgrade there is — is refused instead of taken; at most 5 hops. Downloads are capped at 64 MiB by the bytes that arrive, not by the Content-Length the server claims, and time out after 60 s.

Install flow:

  1. Recognise the source from the URL. Unknown hosts are treated as “any archive” and must end in .zip, .tar.gz or .tgz.
  2. Fetch the archive and record its identity (commit SHA or archive SHA-256) so updates are diffable and the install is reproducible.
  3. Stream-extract into a temporary folder with these checks:
    • Reject absolute paths and .., in either slash direction — an archive written on Windows carries ..\..\, and a check that only knew about / would wave it through to a filesystem that knows both.
    • Reject a tar entry that declares itself a symbolic link, a hard link or a device: tar puts that in the entry header, where a streaming reader sees it. A zip cannot be checked the same way — its unix modes live in the central directory at the end of the file, which a streaming reader never reads — and does not need to be: the extractor has no code path that creates a link, so a zip “symlink” extracts as an ordinary little file whose contents are a path. The loader then refuses to read anything whose canonical path leaves the folder anyway.
    • Reject total uncompressed size > 64 MiB or > 500 entries, counted as the archive is read. A thing that expands to a terabyte has to be refused at the megabyte where that becomes obvious.
    • Only extract files whose extensions are on the allowlist — for a dice set, toml, png, webp, obj, md, txt. Anything else is skipped rather than refused: a repository is entitled to contain a .gitignore. The allowlist, the size cap and the entry count are the caller’s to name, so that a saved-roll collection can come down this same path under bounds of its own (below).
  4. Locate diceset.toml (at the root or at the given subfolder). Which file marks the package is the caller’s to name as well; everything before this step is identical whatever is being unpacked.
  5. Run the validator (below). On failure: delete the temp folder, show the report.
  6. On success: move the folder atomically to dicesets/<set.id>/. If a set with that id exists it is replaced without asking — versions are not compared, so installing an older package over a newer one is allowed — and the result says so afterwards (“… was replaced with this one”). Replacing moves the old folder aside to <set.id>.replacing first and deletes it only once the new one is in place, so a failure halfway leaves the old set installed rather than neither. Every step of that is checked: if the old folder cannot be moved aside the install stops before writing anything, and if it cannot be put back after a failure the message says so rather than claiming the set is untouched. A .replacing folder left by an interrupted install is cleared by the next one.
  7. Write .meta.json with the source URL, SHA, timestamp and validator output.

The app never runs anything from the repository. No scripts, no build steps.

Updates: “Check for updates” re-resolves the ref and compares the commit with the one recorded at install. A set whose forge has moved on is badged on the list — the filled accent tag, which is the one of the three badges that says something to do rather than something that is — and updating it is a re-install from the source the install recorded — through the same validator, over the top of the folder that is there. An update that is refused costs nothing, because a package that fails validation leaves the one already installed alone.

A plain archive is checked too, by asking its server about the file. It has no commits to tell apart, so the install records what the server said — its ETag, and the Last-Modified beside it — and the check is a HEAD request comparing the two. A HEAD, because the whole point is to avoid pulling sixty megabytes to find out that nothing changed.

Neither header is parsed. An ETag is an opaque string by definition, and the date beside it is a header to compare rather than a time to reason about: deciding that one date is later than another would read a meaning into a server’s clock that nobody promised. The ETag is preferred where both ends have one, because it is the server’s own statement about the bytes, where a date changes when a file is rebuilt without changing.

Like is compared with like, and nothing is said otherwise. A set installed with an ETag, against a server that has since stopped sending one, is unanswerable rather than outdated — badging it would send somebody to re-download a set that has not changed. The same is true of a set installed before any of this was recorded: its note has a source and nothing to compare, and re-installing it is the only honest way to give it one.

Which of the two questions a set gets is decided by what its install recorded, not by reading the link again: a set with a commit against it came from a forge, and anything else is an archive. Re-parsing the URL would be a second place for “is this a forge” to be answered, and two answers to that is one too many.

A set installed from a file has no source at all and is never asked.

A download says how far it has got and can be stopped (design 9i). The bar is drawn from bytes that have actually arrived; the Content-Length beside them is what the server said and is only ever used to decide how full to draw it, so a server that sends none gets an indeterminate bar rather than a wrong one, and one that lies cannot push it past full. The cap is applied to what arrives, never to the claim.

The bar is there only while something is coming down the wire. An install from a file on the phone has nothing to show, and neither has the validation after a download — a bar that reached full and sat there would say the app had hung at the moment it was working hardest.

Cancel stops the download and nothing else. Once the archive is on disk the install is a validator over a folder and finishes in a moment, and a half-installed package is exactly what the install order above exists to make impossible — so there is nothing left to interrupt that would be safe to. A stopped download throws its half-written file away where it was being written, and the screen says nothing about it: the person who pressed Cancel knows what happened, and answering them with an error message is arguing with them.

The button is not drawn at all when nothing could be asked, and what a check found is said in a line rather than only on the rows: a check that found everything current and a check that could not reach anything look identical on a list where nothing is badged either way. A forge that cannot be reached is counted as unreachable rather than quietly read as up to date.

The button is still not drawn when nothing could be asked, and a set with nothing to compare is skipped rather than asked a question whose only possible answer is “no idea”.

Resolving a ref means asking the forge which commit it is at now — GitHub and Gitea call that hash sha, GitLab calls it id, and Gitea answers with a list because its endpoint is “the log from here”. The answer is recorded in .meta.json as commit, beside the archive’s own SHA-256.

Both are needed and neither replaces the other. The checksum is what makes an install reproducible: it is the bytes that actually arrived. It cannot answer “is there something newer”, because a tarball built twice from the same commit need not be byte-identical — the commit answers that.

A forge that cannot be asked, or that answers with something which is not a commit hash, does not stop an install. What comes back is checked for being forty or sixty-four hex digits and nothing else in the reply is read; if it is not one, the set still installs and only the update check is poorer for it. A forge is a stranger like any other host (SECURITY.md).

The same fetch and extraction path is used for saved-roll collections (docs/dice-notation.md), which are a single JSON file rather than a folder. A repository of them is recognised here, by the same table of forges above, and fetched and unpacked by the same code under the same refusals. Two things are named differently and nothing else is:

Named by the caller Dice set Saved-roll collection
What marks it diceset.toml, anywhere in the archive or at the named subfolder one *.dinfinity.json, at the repository root
What may be written toml, png, webp, obj, md, txt json
What it may expand to 64 MiB 1 MiB, the size a collection may itself be
What is kept afterwards the folder, under dicesets/<id>/ nothing; the rolls go into the database and the unpacked folder is deleted

The marker file is the same idea in both: a package says what it is by a name everybody agrees on, at a place everybody can find. A collection’s is at the root and there may be only one, because a link names a repository rather than a file — an import that quietly chose between two would be choosing for somebody.

Sets that come installed

Five finished sets come with the app and are installed on its first launch: marble, steel, green resin, red glass and Halloween, the archives in sample-sets/ (decision 107). Each defines the ten standard dice, so they show resin, glass, metal and glow on every shape from the first roll: steel:3d6, {red-glass:d20}. Halloween also shows what a set’s own die can be: pumpkin is a cube with artwork on three faces and the three opposite left blank, worth one and nought, so 6{halloween:pumpkin} counts pumpkins rather than adding pips. Its blank cells are clear on purpose, so it is the set that shows AtlasCellsEmpty meant.

They are installed, not bundled. The app carries their zips and installs each one through the same installer and validator as a file you pick (design 1s shows them among the installed sets). After that they are yours like any other set: switched off, or removed. Only Built-in is bundled and can’t be removed.

flowchart TD
  launch["first launch of this install or update"] --> seen{"installed by the app before?"}
  seen -- no --> have{"you already have it, same version or newer?"}
  have -- no --> install["installed through the validator"]
  have -- yes --> keep["left alone, noted as seen"]
  seen -- yes --> there{"still installed?"}
  there -- "no: you removed it" --> gone["stays removed"]
  there -- yes --> newer{"the app's version newer?"}
  newer -- yes --> install
  newer -- no --> keep2["left alone"]

Validation

The validator runs the same way for URL installs, local archive imports, face designer exports and the built-in set. It produces a report of error / warning lines with file and line references.

Errors (set is rejected):

Warnings (set installs, user sees them):

Two of the checks need a decoder, so they are not in either list above: they cannot be answered from bytes alone and are raised where a texture is first decoded, at load time, by AtlasDecoder in dicesets/install. They produce the same ValidationMessage lines as everything else.

Code Severity What it means
TextureWillNotDecode error The header check passed and the pixels are not there — a truncated download, a corrupt file, or a .png whose IHDR is the only sound thing about it. The die falls back to its printed labels and the package is otherwise untouched
AtlasCellsEmpty warning The atlas leaves one or more of a die’s face cells undrawn, so those faces are printed instead. Legal, and often meant; it is said because an atlas saved at the wrong size or in the wrong grid looks exactly the same from outside

Neither can refuse an install, and that is deliberate: a package is installed from its files and validated from its header, and a picture that goes bad on disk afterwards is a die that prints its labels rather than a set that stops working.

Every error is reported, not only the first: an author fixing a set wants the whole list, and the screen that shows a failed install has room for it (option 6b).

The whole check runs over an abstraction of the package’s files rather than over a folder, so the same code validates a temporary extraction during an install, a zip, the app’s own assets and a test’s memory. A validator that could only read one of those would only ever be tested on one of those.

Dimensions before decoding. A texture’s width and height are read out of its PNG IHDR or WebP header, in plain Kotlin, before any decoder sees the bytes — that is what makes refusing a 30,000-pixel image safe, since the decoder is the part with the attack surface. The same read is what rejects a truncated download or a .png that is not one. The full decode happens later, on the IO dispatcher at load time, inside these same caps; a file that survives the header check but cannot actually be decoded is reported then, and the die falls back to its label.

The parser is tomlj, a TOML 1.0 reader, chosen over a hand-written one because parsing TOML is exactly the sort of thing that should not be hand-rolled (.claude/CLAUDE.md) and over the alternatives because it reports the line and column of every key it read — without which the report above could not carry file:line at all. It is used through its document tree only: every field is asked for by name and checked by hand into a plain data class. No reflection, no polymorphic deserialization, no default-on-error, and no deserializer ever sees a downloaded file. Unknown keys are ignored with a warning to allow future extensions.

Runtime isolation

Authoring tips