dInfinity

Dice sets

Design: the installed-set list (options 1s and 1t), the set details (6a, and 6b for a failed validation), 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.

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
restitution = 0.3                # clamped to 0.0..0.8
friction = 0.5                   # clamped to 0.1..1.0

# --- 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 = "tetrahedron"
read = "vertex-up"               # value at the top vertex, not the top face
faces = [1, 2, 3, 4]             # for vertex-up shapes: value per *vertex*

[[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; reached from the dice picker
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
labels = ["💀", "2", "3", "4", "5", "6"]   # what is printed if no texture

[[die]]
id = "d4"
shape = "tetrahedron"
read = "vertex-up"
faces = [1, 2, 3, 4]
color = "#f0e6d0"

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

[[table]]
id = "bone-felt"
name = "Bone felt"
floor_texture = "tables/felt.png"
floor_tiling = [3, 6]
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.
set.name, set.version yes  
set.author, license, description, homepage no Displayed only. 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 (d2d100, d10-tens, df) are what typed notation resolves, optionally set-qualified as brass:2d20. A die with any other id is rolled by tapping it in the dice picker — the grammar in docs/dice-notation.md has no unambiguous way to write skull-d6kh1, since a slug and a modifier are made of the same characters.
die.shape yes A name from the shape catalogue below. v1 has no other option.
die.faces yes Integer values, one per face (or vertex). Length must match the shape. Range −9999..9999. Duplicates allowed (d2-as-d6).
die.labels no Strings printed on faces when no texture. Defaults to faces as text. Max 4 characters each.
die.read no face-up (default) or vertex-up.
die.texture no Path to a PNG/WebP atlas, relative, inside the set folder.
die.color, number_color, roughness, metallic, size_mm, density, restitution, friction no Per-die overrides of defaults.
table.* no Table looks; fields and limits in docs/tables.md.

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 (documented in dicesets/format/shapes/ with reference images).

Name Faces Typical use
coin 2 d2
tetrahedron 4 d4
cube 6 d6, d2-as-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.

The d4

The one shape where a cell and a readable position are different things. A tetrahedron is read from the corner pointing up, so its four numbers belong to corners, while its four cells are painted on faces.

Cell i is the face opposite corner i — which is to say, the triangle whose three corners are the three that are not i.

A number is drawn at the corner it belongs to, on every face that meets that corner. So cell i carries three numbers, not one: the values of the three corners other than i, each at its own corner of the triangle. That is what a moulded d4 does, and it is what makes the die readable — when a corner points up, all three faces you can see carry that corner’s number at their apex.

It also means the two faces sharing an edge agree along it: both draw the same value at each end of that edge, because the value belongs to the corner rather than to either face. An author who draws them differently has drawn a die that reads as two different numbers depending on which way you look at it.

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 the renderer and the physics hull are built from the same arithmetic rather than from a model somebody exported.

size_mm is the die’s nominal size, quoted the way a dice maker quotes one: the edge length for a polyhedron, the diameter for the coin. Each shape has a fixed ratio from that to its bounding-sphere radius, which is what the table’s capacity rule sums — the ratios are listed in docs/tables.md.

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

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 .zip, .tar.gz or a folder

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 (toml, png, webp, obj, md, txt). Anything else is skipped rather than refused: a repository is entitled to contain a .gitignore.
  4. Locate diceset.toml (at the root or at the given subfolder).
  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, ask to replace (versions are compared). 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 (forges) or re-downloads the archive headers and compares checksums (plain URLs); if the identity differs and the new set.version is higher, offer to reinstall.

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.

Validation

The validator runs the same way for URL installs, local folder/zip 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):

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