dInfinity

Architecture

Design: every screen this module map has to serve exists as a live prototype — open the clickable design or design/. The menu (option 1q) and Settings (1y) show the navigation.

Goals that shape the design

  1. The physics result is the roll. There is no separate RNG path that decides the number. Rendering is a view of the simulation; turning it off must not change how rolls are produced.
  2. Dice sets are data, never code. Anything downloaded from the internet is parsed, validated and rejected on error. It cannot execute, cannot reach outside its own folder, and cannot break other dice sets or the app.
  3. Offline first. Everything except installing a dice set works with no network.
  4. Deterministic simulation. Given the same seed and inputs, the simulation produces the same result on every device. This is what makes power-saving mode honest and makes bugs reproducible.

Tech stack

Concern Choice Notes
Language Kotlin Native code (C++) only inside the physics/rendering bridge
UI Jetpack Compose Material 3
3D rendering Filament PBR, Vulkan/OpenGL ES, Android-first, Kotlin bindings
Physics Jolt Physics 5.3.0 via JNI Decided by a spike, not by reading: see decision 37. Built with CROSS_PLATFORM_DETERMINISTIC=ON, which is what goal 4 needs and what Bullet does not offer.
Persistence Room (SQLite), processed by KSP Statistics, saved rolls, installed set registry. Schemas exported and checked in; migrations from version 1 (docs/statistics.md)
Settings DataStore Preferences
Dice set parsing tomlj (TOML 1.0), read through its document tree Reports the line and column of every key, which is what a validation report is made of. No reflection-based deserialization of untrusted input
Network OkHttp Only for installing dice sets, tables and saved-roll collections from a URL (https only)
Images Android BitmapFactory with bounds check first Textures decoded with explicit size limits

The physics engine choice was the one most likely to change, and is now made (decision 37). The interface the rest of the app depends on (DiceSimulator) stays engine-agnostic anyway, so that swapping is contained to one module and so that everything above it can be tested without an engine at all.

Modules

build-logic/         Gradle convention plugins — every module's build config lives here, once
app/                 Application: single activity, theme, navigation graph
core/
  model/             Die, DiceSet, Face, the shape catalogue and its atlas layout, a decoded atlas and which of its cells are empty, RollPlan, RollResult, SavedRoll, and the colour arithmetic every picked colour goes through (Contrast, AccentRamp, Hsv, Hex) — pure Kotlin, no Android deps
  notation/          Formula parser + evaluator (docs/dice-notation.md)
  probability/       Exact PMF computation (docs/probability.md)
  stats/             Statistics aggregation logic
  collection/        The saved-roll collection format: read, written, validated (docs/dice-notation.md)
  glyphs/            The built-in font, typesetting, where a label goes on a face, and outlines into a signed distance field (docs/physics-and-rendering.md, docs/face-designer.md)
dicesets/
  format/            TOML schema, validator, table definitions (docs/dice-sets.md, docs/tables.md)
  install/           Fetch from git forges / https archives / local files, verification, extraction into sandboxed storage, reading back what is installed, decoding a die's artwork out of the package it was installed with (docs/dice-sets.md, "Textures"), and installing the sample sets the app carries (PreinstalledSets). The fetching and the extraction are shared with saved-roll collections (docs/dice-notation.md)
  builtin/           The bundled standard set and default tables as a normal package (eats its own dog food), and the sample sets' zips the app installs on first launch (SampleSets)
simulation/
  api/               DiceSimulator interface, the catalogue's solids — corners, face directions and which corners make up which face — table geometry + capacity check, settle/face-read logic, the frame clock, and the board before a throw: the BoardSettler interface, where an added die is let go (BoardDrops) and the recorded drop that is played back (BoardTrack)
  jolt/              Jolt JNI bridge (C++), the roll loop and the roll in progress, and the board's drop run in a world of its own (JoltBoardSettler)
  harness/           The Step 5 device harness off the device: what a run is asked for — a count of throws or a length of time — its JSON document, what it may say about frames, what a rendered run's drawn frames add up to, and the targets it is scored against (docs/build-setup.md, "The physics harness")
render/
  filament/          Scene setup, materials, camera, die meshes, tray
  headless/          The Renderer contract, and the renderer that draws nothing (power-saving mode)
input/
  shake/             Sensor fusion → throw impulses
feedback/            Impacts → haptic ticks and impact sounds (docs/physics-and-rendering.md)
designer/            The personal package, "My dice": the drawing model behind the face designer (marks, drafts on disk, cell outlines), the die turned over in the hand (the projection, the culling and the depth sort behind the Solid tab), the photographs somebody has made tables of, and the export that turns both into an installable package (docs/face-designer.md, docs/tables.md)
data/                Room database, DAOs, DataStore
ui/
  common/            The design system's tokens, and the screen furniture more than one screen needs: the formula field and its squiggle, the die silhouettes, the button, the rule, the segmented control, the sheet, the colour picker
feature/             One module per screen group; see docs/TODO.md Step 4
  roll/              Roll screen: tray, dice picker, formula field, result sheet, shake to roll
  graph/             Outcome graph
  saved/             Saved rolls: groups, list, editor, import/export
  sets/              Dice set browser, details, installer, and the "My dice" export behind a licence choice
  tables/            Table picker, and "use a photo"
  designer/          Face designer screen over the designer/ engine
  stats/             Statistics, history and sessions — the "Look back" screens
  settings/          Settings, the menu, the notation reference, and the developer screen (docs/physics-and-rendering.md)
test-fixtures/       Test data shared by every module: dice sets, collections, golden roll cases

Twelve screens in the menu — thirteen with the developer toggle on — and eight feature/ modules: statistics, history, sessions and saved-roll statistics are one module because they are one screen group over one set of data (design/dInfinity.dc.html, options 1w, 1x, 6c, 8b) and splitting them would only split the queries.

core/collection is the saved-roll collection format, and it lives in core/ rather than in feature/saved for the same reason dicesets/format is not in feature/sets: a file from a stranger is validated in one place, by code that cannot write anything. Its shape is the import rule made structural — CollectionReader hands back either a collection that is known to be sound or a list of reasons it is not, never something in between, so whatever imports it has no judgement left to make. That is how “an import can never damage what is already there” stops being something every screen must remember.

core/glyphs is the font a die with no artwork is printed with, and it is core/ for the same reason ShapeAtlas is: more than one thing has to agree about what a 6 looks like. The tray prints one, and the face designer stamps one from the same font (docs/face-designer.md); two fonts that were meant to be one would disagree, and the disagreement would be a die whose drawn faces do not match its printed ones. Nothing in it knows what a die is beyond a face and a label, and nothing in it draws — what comes out is a field of bytes, and the only thing that needs a GPU is uploading it.

It holds where the 6 goes as well as what it looks like, for exactly the same reason. LabelRoom solves the biggest box of a label’s proportions that fits inside a convex face and where on that face it sits, and FaceLabel says what a face is printed with and whether it needs a dot after it; both are asked by render/filament’s DieNumbers, about the polygon its mesh draws, and by designer’s FaceStamp, about the polygon the canvas is masked into. What is left on either side is the one thing only that side knows — which polygon it is. Two solves would eventually disagree, and the disagreement would be a die drawn from its own numbers that did not match the same die printed.

designer depends on :simulation:api, which looks like a drawing screen reaching for a physics module and is the same argument one more time. The designer’s Solid tab turns the real polyhedron over, and simulation/api is where a catalogue solid is — its corners, the direction of each readable position, the face order every set file is read in and which corners make up which face (SolidFaces). Nothing of the simulator comes with it: the dependency is on the closed forms, which are plain Kotlin with no engine behind them, and the alternative is a second account of a die’s geometry in the one module most likely to be looked at beside the first (decision 35).

ui/common is not a feature and is not a place for anything that is merely shared. Nothing in it knows what screen it is on, and it depends on core/notation and core/model and nothing else — so a piece of furniture cannot reach a database, a simulator or the navigation graph. It exists because three screens take a formula, validate it on every keystroke and have to say the same thing about the same mistake: the tray, the outcome graph and the saved-roll editor. Two copies of “the same thing” is one copy too many, and the disagreement would eventually be about whether somebody’s formula is valid.

The colour picker is the same argument, and it is the one that got away first. Three screens ask for a colour — Settings’ accent, the face designer’s ink, the saved-roll editor’s tag — and the sheet was written twice before it was written here, each copy private to its own feature module and each with its own slider row. ui/common may not depend on a feature’s engine, so what the picker is built on went the other way: the HSV arithmetic is core/model’s Hsv, beside AccentRamp and Contrast, where it is plain Kotlin under a JVM test and low enough in the graph for furniture to reach. Hex went with it, because the accent’s label, the designer’s readout and a saved roll’s tag each wrote the same six digits their own way.

The rule for putting something in it is the same rule: more than one screen needs it, and it needs no screen. A control that navigates does not go in — the menu button lives in feature/settings and is handed to each screen as a slot, because where it goes is the navigation graph’s business and the navigation graph is :app’s.

The design system’s tokens live here too, and the reason is the dependency arrow. Modernist — the palette, both ramps, the spacing scale, the type scale, the rule weights and the zero radius, transcribed from design/_ds/modernist-.../styles.css — used to sit beside the theme in app/. But :app depends on every feature module, so no feature could import it, and six of them each kept their own transcription of the same numbers. Six copies of one scale is six chances for it to drift from the stylesheet and from each other. ui/common is the only module every screen is above, so it is the only place the tokens can be. :app reads them from here as well, and Theme.kt keeps the job it always had: turning them into Material’s colour roles, type slots and shapes. ModernistTest reads styles.css at test time and fails if the transcription and the design system have come apart.

feedback is the other end of the wire input/shake is one end of, and it is shaped the same way: the thresholds, the layout in time, the pitch, the tick and the five waveforms are plain Kotlin, and only SystemBuzzer and PcmSpeaker touch an Android API. It is not in feature/roll because it is not about a screen — the tray plays through it, and the tray belongs to render/filament.

Rule: core/*, dicesets/format, simulation/api, simulation/harness, render/headless and test-fixtures are plain Kotlin modules with no Android dependency, so they run on the JVM and stay fast; everything else is an Android library. simulation/jolt carries native code of its own (C++ built by the NDK) and render/filament drives the prebuilt native Filament library; both are tested with instrumented tests on a device. simulation/harness ships in nothing: it is on the test classpath of simulation/jolt and of render/filament and nowhere else, and the arrow only goes that way — it knows about a SimulationOutcome and about no engine at all (decision 53). render/filament’s device tests also take simulation/jolt, for the rendered harness, which throws the real physics through the tray onto a real surface; its own code still knows no engine (decision 80). The golden determinism suite spans both tiers from a source set they share, simulation/jolt/src/sharedTest (decision 44). Most of simulation/jolt is not device-only, though, and that is deliberate: everything it decides about a roll is Kotlin over an interface, and only the two files that talk to the engine need a phone (decision 40).

Build configuration is not repeated per module: build-logic provides six convention plugins. Four are the ones a module applies — dinfinity.kotlin-jvm, dinfinity.android-library, dinfinity.android-feature (a library with Compose) and dinfinity.android-app — and every module’s build script is one of those plus its dependencies. The other two are never applied by a module directly: dinfinity.quality (ktlint, detekt and the project’s own checks) is applied by dinfinity.kotlin-jvm, dinfinity.android-library and dinfinity.android-app — and so by dinfinity.android-feature, which builds on the library plugin — and dinfinity.quality in turn applies dinfinity.coverage (JaCoCo), so no module can opt out of either by accident.

Screens and the states behind them

Two state machines, and they are deliberately not the same one. Which screen is on is navigation, owned by the NavHost and keyed by Destination. What a screen is doing is that screen’s own state, owned by its presenter and never by the navigation graph. A screen left and returned to is built again from scratch; nothing about a roll survives the trip, and that is the point — a presenter owns a physics world and a scene, and leaving the screen gives both back (decision 49). The roll thread and the Filament engine on it are the exception, and belong to the application rather than to a visit (decision 50).

Every screen is wired in one place. Presenters names a factory per screen, with no optional fields and no defaults, so a destination that has been added but not wired stops the activity compiling rather than drawing a placeholder nobody notices — which is what happened to the sessions screen for a whole step. DInfinityApp takes it as nullable, because drawing placeholders for everything is a real mode: a Robolectric test of the navigation graph has no GPU and no physics engine. What that mode may not be is partial.

A visit is the back-stack entry, and nothing else. A presenter is remembered against that entry, never against the lambda that builds it: such a lambda is built afresh on every recomposition, so remembering against one makes a new presenter — and on the roll screen a new tray — every time any preference changes. On a cold launch one always does, because the defaults stand in until the settings file has been read. The surface is handed to the tray that is there when the surface is created and to no other, so the discarded tray kept it and the dice rolled where nobody could see them.

Every screen is a Destination, and the graph has had every one of them from the start so that adding one is a change in a single place. Roll is home. The menu is a destination too: it lists the other eleven and is not in the list itself (design/dInfinity.dc.html, option 1q).

One destination is listed conditionally. Developer is in the App section beside Settings and appears only while AppSettings.developerTools is on, which it is on no install until somebody turns it on. The route exists either way — a route that came and went would be a back stack that could not be restored — so what the toggle governs is whether anything offers it (docs/physics-and-rendering.md, “Debug tooling”).

stateDiagram-v2
    [*] --> Roll
    Roll: Roll (home)
    Menu: Menu
    Graph: Outcome graph
    Screen: Saved · Stats · History · Sessions<br/>Sets · Tables · Designer · Settings · Notation
    Editor: Saved roll editor
    Developer: Developer<br/>(only while the toggle is on)

    Roll --> Graph: See the odds, off the result sheet
    Roll --> Editor: Save as roll, off the result sheet
    Roll --> Menu: the menu button
    Screen --> Roll: Roll it, carrying the die being drawn
    Roll --> Screen: back to the designer, on that die
    Screen --> Editor: a saved roll, or New
    Editor --> Screen: saved, deleted, or the chevron
    Editor --> Roll: Roll now
    Graph --> Menu: the menu button
    Screen --> Menu: the menu button
    Developer --> Menu: the menu button
    Menu --> Roll: choose Roll
    Menu --> Graph: choose the graph
    Menu --> Screen: choose any of them
    Menu --> Developer: choose Developer
    Graph --> Roll: system back
    Screen --> Roll: system back
    Developer --> Roll: system back
    Menu --> Roll: system back
    Roll --> [*]: system back twice leaves the app

Every screen is now reachable, and every one of them by the same control. The menu button sits in the top corner of each, so wherever a player is, every other screen is two presses away. Choosing a row takes the menu off the back stack with it, so back from what it opened goes where the menu was opened from — a menu you have to press back through twice reads as a detour. Choosing the screen you are already on does not stack a second copy of it.

One control climbs to somewhere the menu does not go, and it is the one exception worth naming. A throw asked for by the face designer’s Roll it carries the die it was drawing on the tray’s own route, and a tray opened that way draws a banner going back to the designer on that die (docs/face-designer.md, “The way back”). It is a banner rather than a chevron because a chevron climbs to a destination’s up — the same screen every time — and the tray is home and has none; a control that appeared on some visits and not on others could not be that. The press itself uses the same climbTo a chevron does, so what it leaves behind is the tray with the designer on it rather than a pile of both twice.

Nothing is undefined, and nothing is unreachable. NavHost answers back on every destination, Destination.home is where the app opens, and the graph registers one route for every entry of Destination — its pattern, which carries the query arguments — so there is no screen without a route and no route without a screen. Destination.ofRoute goes the other way, from a concrete route to the entry it names or null for one nothing serves; the app itself never needs it, and the tests use it to ask which screen is on.

Back off the roll screen is two presses, not one. From anywhere else, back goes to Roll and clears the stack, which is what the diagram has always said. On Roll itself it arms: the first press raises a toast reading “Back again to leave dInfinity”, and a second press within two seconds leaves. After that the arming lapses, so a stray swipe cannot close the app a minute later. The design asked for it (2026-09-17) and the reason is the screen it guards — the roll screen is where the app opens and where a result sits while somebody is still reading it, and it is also the screen with the largest gesture surface in the app.

The rule is LeavingTheApp, a state machine over “back was pressed at time t” with the clock handed in, and it knows nothing about Compose. A timer inside a composable would make the behaviour something only a phone could observe, and the case that matters most — the press that arrives a millisecond too late — would be a two-second sleep in a suite that runs on every commit. What the screen adds is the press, the words and finish(); the handler is in the composition only while the tray is, so it cannot fire on a screen it was not written for. There is no “closed app” screen: the prototype draws one because a web page cannot close, and an app that closes is closed.

The toast is ui/common’s ModernistToast — the design’s inverted plate, --color-text ground and --color-bg letters, 8/14 dp of padding and --shadow-md — and it takes itself away after 2.6 s. That is six tenths of a second longer than the window it names, so a press in the gap arms again rather than leaving. It is the failure worth having: an app that stays open when it was asked twice is one press from closing, and one that closes when it was asked once is gone.

A chevron in a header always goes up, never back. It takes the player to the hub the screen was opened from, and from the hub to Roll, whatever path they took to get there. It is not a second spelling of the system’s back button: one of them retraces steps and the other climbs, and a control that sometimes does each is a control nobody can predict.

Where up is is Destination.up, and it is total: the menu for everything the menu lists, the list it belongs to for the three screens that are about one of something — the editor and an import go to Saved rolls, a set’s details to Dice sets — the tray from the menu, and nowhere from the tray, where leaving is back twice. NavHostController.climb is the only way it is used, and it never pops: it navigates, leaving the tray at the bottom of the stack and the screen climbed to on top of it. So the editor opened from the tray’s strip and the same editor opened from the list leave by the same chevron to the same place, which a popBackStack could not do.

Today one screen draws the chevron: the saved-roll editor, which is the only screen with no menu button — it is about a roll rather than about a subject — so without it the only ways out were saving, deleting and the system’s own back. Saving and deleting climb the same way. The ← on Statistics and the Back on saved-roll statistics are not chevrons in this sense and do not navigate: they close a detail on the screen they are drawn on.

One safe area, applied once

The window goes edge to edge, and what the system covers — the status bar, the gesture bar, a cutout — is applied by the navigation graph, once, around whatever it is about to draw.

It used to be each screen’s own job, which works exactly as long as nobody forgets. Settings forgot: on the Pixel 10a its title and menu button sat under the clock, 58 dp above where the tray’s menu button sat, and nothing could have caught it because every screen’s own test draws that screen in a window with no status bar, where inset and not-inset look identical. A screen should not have to know the window has edges.

Destination.fullBleed is the one way out and the tray is the one destination that takes it: it is a single full-bleed table with everything floating on it, and felt inset by 58 dp would be a grey stripe across the top of the screen. The tray insets its controls instead — the menu button, the debug overlay, the stack of controls along the bottom. Window-inset padding consumes what it applies, so a screen that still insets something inside itself gets nothing twice.

The menu button is handed to each screen rather than built by it. A screen that knew what the menu was would be one feature module depending on another, and the navigation graph belongs to :app. Each screen takes a menu composable slot and draws it where it has room.

Four destinations are not in the menu (their group is null), and for the same reason: they are about something rather than about a subject. The menu is the list; the saved-roll editor is about one roll, reached from that roll; importing a collection is about saved rolls, reached from their screen; and a set’s details are about one set, reached from its row on the dice-set screen. A menu row for the import would be a row that means nothing until somebody has a file.

Five destinations are opened with arguments. The outcome graph is about a formula, and after a roll it also marks the total that came up, so its route is graph?formula={formula}&total={total}. The tray takes a formula too — roll?formula={formula} — which is what tapping a saved roll does, and the editor takes the roll it is editing, or nothing for a new one: it puts the formula in the field and leaves the throw to the player, because a saved roll is a formula with a name rather than a roll waiting to happen. A set’s details screen takes the set. The face designer takes the die to draw on — designer?die={die} — which is what “Doodle this die” carries out of the breakdown: quick mode is the same screen on a different die rather than a screen of its own (docs/face-designer.md, “Quick mode”). Two rules keep arguments from spreading trouble:

A screen built from its arguments is a screen that comes back the same from a restored back stack, which is the other half of “a screen left and returned to is built again from scratch”.

The roll screen

RollState is what the roll screen is doing. It is a sealed interface, so the screen’s when over it is exhaustive by the compiler rather than by inspection: a state nobody drew would not compile.

stateDiagram-v2
    [*] --> Empty
    Empty: Empty<br/>nothing typed
    Invalid: Invalid<br/>error + range to squiggle
    TooMany: TooMany<br/>asked for N, M fit
    Ready: Ready<br/>diceCount, scale
    Rolling: Rolling<br/>dice in the air
    ShakeAgain: ShakeAgain<br/>a chain earned throws
    ThrowAgain: ThrowAgain<br/>dice nobody could read
    Stalled: Stalled<br/>dice that never stopped
    Settled: Settled<br/>result, divides

    Empty --> Ready: type a formula that reads and fits
    Empty --> Invalid: type a formula that does not read
    Empty --> TooMany: type more dice than the table holds

    Invalid --> Ready: type
    Invalid --> TooMany: type
    Invalid --> Empty: clear the field
    TooMany --> Ready: type
    TooMany --> Invalid: type
    TooMany --> Empty: clear the field

    Ready --> Empty: clear the field
    Ready --> Invalid: type
    Ready --> TooMany: type
    Ready --> Rolling: a shake

    Rolling --> Rolling: an explosion or a reroll adds a die<br/>(thrown once the last has landed)
    Rolling --> ShakeAgain: a chain earned a throw
    Rolling --> ThrowAgain: dice came to rest cocked or stacked
    Rolling --> Stalled: the roll gave up on some dice
    Rolling --> Settled: the last die comes to rest
    Rolling --> Ready: type<br/>(abandons the throw)
    Rolling --> Invalid: type
    Rolling --> TooMany: type
    Rolling --> Empty: clear the field

    ShakeAgain --> Rolling: a shake<br/>(throws the dice it earned)
    ShakeAgain --> Ready: type<br/>(abandons the roll)
    ThrowAgain --> Rolling: a shake<br/>(throws only the unread dice)
    ThrowAgain --> Ready: type<br/>(abandons the roll)
    Stalled --> Rolling: a shake<br/>(throws the ones that never stopped)
    Stalled --> Ready: Cancel the roll, or type

    Settled --> Settled: Down / Nearest / Up<br/>(rescored, dice never move)
    Settled --> Rolling: a shake
    Settled --> Ready: type
    Settled --> Invalid: type
    Settled --> TooMany: type
    Settled --> Empty: clear the field

The self-loop on Rolling is a roll adding dice to itself. 8d6! is not eight dice: it is eight dice and then, for each six, another — and how many that is cannot be known until the first eight have landed. So settled hands back either the finished throw or the next throw to make (Landed), and the screen goes on saying “Rolling…” while each added die is dropped into the tray among the dice that set it off (docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”). Every one of those throws is an ordinary throw down the ordinary path; there is no second way to get a number.

ThrowAgain comes before ShakeAgain, never beside it. A throw that leaves dice it cannot read — cocked, or standing on another die — stops and asks for a shake rather than throwing them again itself (decision 70). Its dice are scored only once every one of them has a face, and a chain earns its next die only when a throw is scored, so the two waits cannot be owed at once: the unread dice are thrown first, and the chain asks for the shake after. The bookkeeping is simulation/api’s Passes — which die of the throw each die of a later pass is, the throw a shake makes for the unread ones, and the one outcome they add up to — and the machine keeps one per throw, so a die a chain added that lands cocked comes back as the chain’s. A throw that gave up waits in the same way (Stalled) and is thrown by the same call; only the words differ.

A die falling onto the board is not a state. Adding a die to the formula drops one onto the table and it tumbles to a stop under real physics, which takes well under a second and is worth watching — but RollState says what the roll is doing, and while the dice are being put out the roll is doing nothing at all. The screen stays in Ready throughout and says the same thing before, during and after; the drop is worked out on the tray’s board thread and played back by the tray, entirely below feature/roll (render/filament, simulation/api and the BoardSettler app hands the tray), and nothing above it can tell whether it has finished (docs/physics-and-rendering.md, “The dice waiting to be thrown”). It has to be that way round: a state for it would be a state a shake could arrive in, and a shake that had to wait for an animation would be a shake the app ignored.

Typing is the one input every state accepts, which is why it reaches every state in the diagram: the field is live on every keystroke and is never disabled, including while the dice are in the air. That last edge is the one worth reading twice. Typing during Rolling abandons the throw — the record of it is dropped, so when the physics finishes, settled finds nothing waiting for the outcome and discards it. The dice keep tumbling on screen, because nothing touches a roll that is under way; what has gone is anybody’s interest in the answer. A result that arrived for a throw nobody is waiting for is ignored rather than shown, and that is deliberate: a screen one stray callback away from a total with no roll behind it would not be worth the rest of the file.

What each state puts on screen

State Total Message Sheet A shake Formula drawer
Empty — nothing at all — throws nothing live
Invalid — the squiggle and why, inside the drawer; the tab is red — throws nothing live, in error
TooMany — how many were asked for and how many fit; the tab is red — throws nothing live, in error
Ready — that a shake rolls, and what the throw is expected to come to — throws the formula live
Rolling — how many dice have been read, and the range they can still come to — joins the roll in the air live
ShakeAgain — how many dice the chain earned, and what is still to come — throws them live
ThrowAgain — how many dice landed where they cannot be read, how many were read, and what is still to come — throws those again, and only those live
Stalled — how many never stopped, what is still to come, and Cancel the roll — throws them again live
Settled the total, in the sheet’s grip — the grip carries the total and what the throw was expected to come to; the body carries the breakdown and rounding if the formula divides throws the formula again live

Empty says nothing, and that is deliberate. Type a formula, or open Dice at the top. stood in that row and the second device session asked for it to go (docs/physics-and-rendering.md, “What is drawn over the table”). An empty tray is now an empty tray with two doors along the top, and a fresh install still gets the first-launch screen.

There is no Roll button column any more, because there is no Roll button: a shake is the throw (docs/physics-and-rendering.md, “Starting a roll”). The column that replaces it is the only one a player can act on, which is why the message column carries so much — with nothing to press, the words are the whole of the affordance, and shaking is the one input nobody would guess at.

Over all of it, once, a new install shows the first-launch screen (design/dInfinity.dc.html, option 9a). It is not a state of RollState: the machine underneath is Empty like any other new screen, and the welcome is a sheet on top with four ways out, all of them forward. Its “put a d20 on the table” types 1d20 into the field and gets out of the way; the throw is the shake the player makes, because a welcome that rolled for them would be teaching the one thing this app does not do. There is no demonstration path and no canned number. That it has been seen is remembered on disk, and also in the composition, so the screen changes when the button is pressed rather than when a write comes back.

While it is up, the tray does not listen for a shake (decision 74). It covers the whole tray, so a roll started under it would be dice nobody saw land and a result sheet drawn under its buttons. Whether it has been pressed past is therefore held by RollScreen (rememberWelcome) rather than by the welcome, because it decides two things: whether the welcome is drawn, and whether ShakeToRoll registers a hand. A formula that arrives while it is up is typed and waits; pressing past it leaves an ordinary Empty or Ready tray with the hand registered, and the next shake throws.

Two of the four go and fetch something — saved rolls from a file or a link, dice sets from either — and neither dismisses it: coming back to a tray that had forgotten it ever said hello would leave somebody wondering what to do next, and the count line above the buttons has something new to say when they return. That line counts dice sets, saved rolls and sessions. The first is the roll screen’s own; the other two arrive as a WhatIsThere from :app, because feature/roll does not know what a saved roll or a session is — the same rule the saved-roll strip follows, which is a slot rather than a screen. It is a flow, watched only while the welcome is up, and it is built from the repositories rather than from presenters: a sessions presenter would make the default session as a side effect, and saying hello is not a reason to write to a database.

The formula is not on the tray at all until it is asked for. What is on the tray is a tab at the right-hand edge — the word Formula and a chevron — and pressing it slides the drawer in from the side: the field with its × to empty it (docs/dice-notation.md, “Emptying the field”), the squiggle, the one-tap fix and the keyboard, whose action key says Done and puts the drawer away again — it throws nothing (decision 66; design/dInfinity.dc.html, option 2a). It used to be a line of type with a dashed rule under it, on screen in every state; the second device session asked for it to be put away and to arrive from the side, and both halves are about the felt. The tab is red when the formula does not read, which is the one thing it still says about a formula it no longer prints — what is wrong is said inside, under the squiggle, because that is where somebody can fix it. Whether the drawer is in is the screen’s, remembered across a rotation, and RollMachine knows nothing about it.

Two controls hang off the top edge, and only one of them can be open. The formula is one of them, on the right under the menu button; the dice picker is the other, on the left. The picker pushes what is under it down and the formula comes in over the table, so two open at once would be the top half of the table covered — which is the thing the layout exists to stop (docs/physics-and-rendering.md, “What is drawn over the table”). Which is open is two booleans on the screen, remembered across a rotation, and opening either shuts the other in one place rather than in each control.

The picker row is not in that table because it is reachable, and live, in every state — for the same reason the formula is, and in fact for exactly that reason: it is the formula edited with a thumb. It is put away rather than absent: the pull-down’s head carries the count of dice the formula asks for, so a shut menu still says what is in the throw.

Every control on the screen is connected to exactly one of those transitions, and none of them decides anything itself:

In power-saving mode the tray is not there at all, and the list is otherwise unchanged: the dice are thrown by the same roll, stepped by the same loop, and the total arrives in the same Settled. Only pinch and pan have nothing to move (design/dInfinity.dc.html, option 1z).

The tray is not in that list at all, except for the camera. It draws what the roll is doing and has no way to change it: Renderer has no method that returns anything (decision 48), so drawing a roll cannot alter one, and a one-finger tap on the tray never rolls — under a total it picks a die up for the next shake, or puts one back, and nothing moves (decisions 68 and 76; docs/physics-and-rendering.md, “Picking a die up and throwing it again”). It carries a spoken description of what is on it, including how many dice are picked, and no action, custom or otherwise.

While the phone is being shaken

RollState is what the roll is doing, and it is not everything the screen knows. There is one more piece of state, deliberately outside the sealed interface: whether a shake is going on right now. It is owned by ShakeToRoll and read by nothing that scores a roll.

ShakeToRoll does not name the sensors itself. It listens to ShakeInput.current, which is ShakeInput.SENSORS — SensorShakeSource over the accelerometer and gyroscope — in every build. A test sets a TestHand there instead, because a shake is the only way to roll and neither Robolectric nor a phone on a desk can be shaken on cue; the hand calls the same onStarted, onSample and onEnded the sensors do, so a test throws by the one path a player does (decision 66). ShakeInputTest drives the sensor half with real SensorEvents, so the stand-in is known to stand in for something that works.

It is separate because it is not about the dice. A shake begins, the dice are thrown, RollState goes to Rolling — and the hand carries on moving through all of that, and after the dice have settled too. What this state drives is the phone, not the roll.

stateDiagram-v2
    [*] --> Still
    Still: Still<br/>the back gesture works as it always does
    Shaking: Shaking<br/>edges claimed, samples fed to the roll

    Still --> Shaking: the shake source says one began<br/>(and calls roll)
    Shaking --> Still: the shake ended
    Shaking --> Still: the screen was paused or left

While it is Shaking, HoldTheEdges keeps the back gesture off a band down each side, because a hand around a phone being shaken is a hand on both edges of it. The edges are given straight back afterwards, and leaving the screen mid-shake counts as afterwards — an app that kept the back gesture because it never saw the shake end would be a worse citizen than the problem it solves.

Three more things hang off the screen’s lifecycle rather than off any state, and none of them is a control anybody presses:

  Held while Given back
the accelerometer the screen is resumed on pause — a sensor left running behind a backgrounded app is a battery bill for nothing
the screen staying awake the screen is on screen on leaving it; a tray is something a table looks at between turns, and a phone that blanks after fifteen seconds has to be poked to read a roll
the orientation lock the same the same. The tray is the screen (docs/tables.md), so a quarter turn rebuilds the table — the right answer for a player who meant it, a surprise for one who is shaking it. A half turn gives the same table back and is allowed: pinning the display to one rotation also freezes what PhoneAxes is told about which way the hand went (docs/physics-and-rendering.md)

What the tray is drawing

The tray has a small state machine of its own, in TrayLoop, and it is worth drawing because it is the one place where a transition nobody thought about strands a roll — which it did, for real, on the phone.

stateDiagram-v2
    [*] --> Nothing
    Nothing: Nothing yet<br/>opened, told nothing
    Table: An empty table
    Throw: A throw<br/>in the air, or landed where it stopped

    Nothing --> Table: table(geometry, look)
    Table --> Throw: roll
    Throw --> Throw: roll<br/>(a second throw replaces the first,<br/>and so does a die an explosion adds)
    Throw --> Table: clear

A die an explosion adds is a roll like any other, and it replaces the throw before it like any other. What keeps the tray from emptying is that the throw carries the dice already down (ThrowSpec.among): the scene is rebuilt with each of them back where the simulation left it, and only the new die moves after that. The tray has no state of its own for “a roll that is still adding to itself”, and deliberately — that is the roll screen’s question, one level up.

The dice waiting to be thrown are not on it either. A board is asked for from Table and played there: TrayLoop.waiting builds a numbered request from what is on screen (null while a roll is running, so a formula edited mid-throw is for the throw after), the board thread works the drop out, and TrayLoop.boardSettled plays it only if it is still the pending board. A newer request makes an older drop stale, and roll, table and clear forget the pending board altogether, so a drop that comes back late is thrown away rather than painted over a throw. While a drop plays it wants a frame every vsync, and only with a surface — it is a recording, not a simulation, and nothing is owed a drop nobody can see.

A surface arriving or going is not on that diagram, and that is the point: it is not a state of the tray but of where the tray draws. The two are crossed, not merged, and one rule decides who asks for the next frame:

  A roll is in the air Nothing is moving
a surface a frame every vsync one frame, and only until a frame actually lands
no surface a frame every vsync anyway none — nothing to draw on, nothing owed that could be paid

The top-right cell is the subtle one. The frame callback is what steps the simulation, so a roll that stops being asked for frames is a roll that stops: never read, never reported, never over, with the screen on “Rolling…” for good. Drawing is the part that needs a surface; the physics is not, and must not wait for anybody to be looking.

The bottom-right is the other half. A still picture — an empty table, a pinch, a roll that has already landed — has nothing coming after it to cover for a skipped frame, so it is owed one and goes on asking until one lands. With no surface there is nothing to pay it with, so the debt is simply carried until a surface arrives.

The outcome graph

GraphState is a smaller machine of the same shape, and it is smaller for a reason: the graph has no thread, no engine and nothing to wait for. A formula goes in and a distribution comes out.

stateDiagram-v2
    [*] --> Empty
    Empty: Empty<br/>no formula
    Invalid: Invalid<br/>error + range
    TooLarge: TooLarge<br/>past what is exact
    Graphed: Graphed<br/>bars, statistics, mode, pick, mark

    Empty --> Graphed: opened with a formula that graphs
    Empty --> Invalid: opened with one that does not read
    Empty --> TooLarge: opened with one past the limit
    Graphed --> Graphed: ask the other question, or tap a bar
    Graphed --> Invalid: retype
    Graphed --> TooLarge: retype
    Graphed --> Empty: clear
    Invalid --> Graphed: retype
    TooLarge --> Graphed: retype

TooLarge is the state worth reading twice. The formula is legal and may even be rollable; what cannot be done is working out its distribution exactly, and the screen says so rather than drawing an approximation. An approximated curve presented as the odds is a number somebody bets on (docs/probability.md).

Tapping a bar and changing the question both stay in Graphed and redraw the same distribution — neither is a new computation. Retyping drops the tapped bar with it, because a bar at 14 on one distribution is not the same bar at 14 on the next.

Saved rolls

SavedState is the third of these machines and the first that watches rather than holds. Its groups and rolls come from the database as flows, so a roll saved in the editor or arriving in an import appears without anybody asking. The two flows are combined rather than collected apart: a list of rolls and the groups they belong to arriving a frame apart is a list that flickers through a state that was never true.

   
loaded = false the database has not answered yet — not the same as empty
loaded && rolls.isEmpty() nothing saved in this group, which is a thing to say
switching the group switcher is open over the list

The first row is the one worth having a field for. “Nothing saved yet” drawn under a list that has simply not arrived is the app telling a player their rolls are gone.

Two things the screen does not decide. Whether a formula still resolves is re-checked every time the list is drawn rather than stored, because the set it names can be uninstalled between one drawing and the next; and the order — the one the player dragged the list into, sort_order — is SQL’s, because it is what the list is (docs/dice-notation.md, “Saved rolls”).

A drag is the one thing the screen holds that the database does not yet. The list reorders live under the finger and is written down when the finger lifts, so between those two moments the presenter is showing an order the database has not been told about; move keeps it and settle writes it, in one transaction. Until the database reports that order back, an emission arriving for any other reason — a use count, an import — is drawn in the order the drag left, because a row that snapped back under the finger moving it would be the screen arguing with the player. What a reorder comes to is SavedOrder, plain Kotlin with no Compose in it: the list, the row being dragged and where the finger is, to the list to draw. The gesture and the drawing are the screen’s, and the row that moves is the one under the pointer rather than the one the drag began on.

A row held near an edge scrolls the list. How fast is SavedEdgeScroll, plain Kotlin again: where the finger is, where the drag began, the list’s visible height and the band size, to a speed; and a speed and a frame’s length to a distance. The screen runs one effect per drag that sleeps on a snapshotFlow until the finger is in a band, then scrolls by that distance once a frame and moves the dragged row to whatever is now under the finger. Every move pins the scroll position by index (requestScrollToItem, issued after the move is composed and before it is measured): left to itself a lazy list keeps its first row’s key in place, and when that row is the dragged one the list would follow it out of sight and end the gesture.

The list is its own scroll box. The title bar, the group switcher and the line above the list stay where they are; only the rolls move. A list that scrolled the whole screen would take the group name away exactly when somebody is looking for it.

Reordering is offered twice, because a drag is not available to everybody: the grip carries Move up and Move down as custom accessibility actions, so a list that can be ordered with a finger can also be ordered with TalkBack (“Accessibility”, below).

Control Calls What changes
the group name showGroups whether the switcher is open
a group in the switcher open which group’s rolls are listed, and the stored active group
a group’s …, or a long press on it GroupPresenter.edit the group sheet opens on that group
New group, at the foot of the switcher GroupPresenter.create the group sheet opens on a group that does not exist yet
⤴ the collections sheet the two ways out — this group with its subgroups, or everything — and the one way in
a row, tapped used, then navigation one more use, and the tray with that formula in its field
a row, long-pressed the editor which screen is on, opened on that roll
New the editor the same, opened on a roll that does not exist yet

The group sheet

GroupDraft is a machine of its own but not a screen: a group is a name, a mark, which group it sits in and the table its rolls land on, and four fields do not deserve a destination — nor a place in the navigation graph that the back button would then have to mean something on. It is a dialog over whichever screen opened it, and both the saved-rolls list and the editor open the same one, so a group made while writing a roll is made the same way and refused for the same reasons.

It watches the same two flows the list does, which is what lets both of its rules be answered while the player types rather than when they press Save. The two flows come from two repositories: SavedRollRepository for the rolls and SavedRollGroupRepository for the folders, joined by SavedRollLibrary — the one place the two halves meet, in the sense SetLibrary is for dice sets. A screen takes the library rather than the pair, and a rule that spans both (deleting a group moves its rolls; a roll’s table falls back to its group’s) is written there once rather than in each screen that needs it.

Rule Answered by Why it is not only checked at import
a group’s name is its own the group list, ignoring case an import refuses a collection whose group name is taken (decision 15); a name the app itself let you duplicate would make that refusal arbitrary
groups nest exactly one level parents, and nestable checked from both ends — a group cannot go inside one that is already inside another, and a group with groups inside it cannot go inside anything

The second is the one that was wrong until this sheet existed. Checking only the parent lets a three-deep tree be built from the bottom: make the child, then move its parent. SavedRollGroupRepository.save refuses both, because an import writes without ever passing through the sheet.

Control Calls What changes
the name field name the name, and whether another group already has it — named, not merely reported
a mark choose that mark, or none when the chosen one is tapped again
Inside choose which group it sits in; the chooser is absent, with its reason, for a group that has children
Table for this group choose the table every roll in it lands on, unless the roll pins its own. “Default” means the app’s (docs/tables.md)
Save group save the group is written, the sheet closes, and whoever opened it is handed the id
Delete delete the group goes, its rolls move to Unfiled and its child groups are lifted to the top level. Nothing a player wrote is deleted, and the sheet says how many rolls will move before it is pressed
Cancel dismiss the draft is thrown away

Unfiled is the one group with no Delete: it is where a deleted group’s rolls go, so it has to be there to go to.

Which table a throw lands on

The same shape rule as below, for the same reason: the roll screen does not know what a saved roll is, so it cannot ask which table one is pinned to.

RollMachine takes a (TablePin?) -> TableLook rather than one fixed look, and asks it with the pin the throw came with. Resolving a pin needs the installed sets, which is :app’s to know: RollWiring turns a pin into a look, falls back to the app default when the throw pins nothing, and falls back again to the bundled package’s first look when the chosen table’s package is no longer installed — leaving the setting alone, because the package may come back.

Where the pin comes from is the other half. Precedence is most specific first: the saved roll’s pin, then the pin of the group it lives in (tablePinFor in core/model, one function so two screens cannot come to disagree). It is answered where both halves are already known — the saved-rolls list and the strip each watch rolls and groups as one flow — and the answer travels on SavedRollSource beside which roll and which group a throw was made from. So a tap costs no query, and the pin drops when the attribution does: typing over Fireball’s formula puts the app’s table back.

The tray is built when the screen opens, so a pinned table reaches it afterwards. RollPresenter remembers the look it last announced and calls Tray.table again only when it actually changes — a scene is rebuilt on that call, and rebuilding one per keystroke is not a thing to do by accident.

The picker itself now draws, which nothing but the roll screen used to. TablesPresenter takes a TableThumbnails, an interface with no graphics type in it; :app’s RenderedTableThumbnails joins it to render/filament’s TrayThumbnails, which posts onto the roll thread and draws with the roll screen’s engine. A row asks as it comes on screen, so a LazyColumn pays for what fits rather than for everything installed, and a look that has no picture — not yet, or not on this device, or not in power-saving mode — keeps the swatch (decision 60).

Making a table out of a photograph

The one screen that adds to a package rather than reading one (docs/tables.md, “Your own photo”). Four things have to meet, and they are deliberately in four places:

  Where Why there
the picker :app (DInfinityApp) a content URI is reached through a ContentResolver; the same reason the dice-set screen’s file picker is there
the arithmetic designer’s PhotoScaling, plain Kotlin what size to aim at, which power of two to subsample by, and how far the ladder goes are decisions a JVM test can assert on
the pixels designer’s BitmapPhoto the seam AtlasPainter draws, in the other direction: a decode, a scale and an encoder, which can fail but cannot be wrong (decision 55)
the writing designer’s MineSets, PhotoStore, PackageFolder the personal package is already written and validated here, and a photo is one more thing it is built from

What crosses into feature/tables is a TablePhotos — the seam feature/roll’s ThrowRecorder draws, and for the same reason: a screen that lists tables has no business decoding a JPEG or running the validator. What crosses into it is a way of opening a stream and the file’s display name; never a Uri, and never bytes, because the photo is opened twice — header first, pixels second — and the second open must not have to rewind the first.

:app’s TablePhotoLibrary joins the four up, and does one thing besides: after a photo lands it calls SetLibrary.all(), because the catalogue the picker lists from is only re-read there. A table written and not re-read is a table that is on disk and in no list.

The photos are kept outside dicesets/, in filesDir/table-photos/, for a structural reason: everything in dicesets/ is scanned as a package, so a folder of loose pictures in it would be listed as a dice set that does not validate.

feature/tables depends on :designer, which is the same dependency feature/sets already takes and for the same reason — “My dice” is an ordinary installed package built by that module, and a screen that adds to it needs its rules rather than a second copy of them.

Writing a roll down

The statistics tables have existed since database version 1 and nothing wrote to them. This is the seam that does, and it has one shape rule: the roll screen cannot see a database.

RollMachine.settled hands out a FinishedThrow — the result, the plan it came from, and the throw itself, which is the ThrowSpec the dice were spawned from with the shake that actually arrived written back into it. feature/roll declares a ThrowRecorder interface and :app implements it over data’s RollRecording. A roll screen that could reach a database is a roll screen that will eventually query one mid-throw.

The throw stops there. What crosses the ThrowRecorder seam is the result, the plan, the seed and where the roll came from; RollRecording.record has no parameter a spec or a shake could be passed as, and nothing below it — FinishedRoll, RollHistoryRow, HistoryEntry, the exports — has a field that could hold one. That is asserted rather than remembered, in :app, which is the one module that can see both ends of the seam. Reproducing a roll is a developer action about a roll still on screen, not something a history row offers (decision 13, docs/statistics.md).

The plan travels with the result because the two know different things: the result knows which face came up, and only the plan knows which die it was and which set supplied it — and the statistics are kept per die. A breakdown line with no plan entry is not counted rather than counted wrongly, which keeps a bug upstream visible instead of hiding it in a histogram.

The write is launched, not waited for. A roll is finished when the dice stop, not when SQLite says so; a failure to record is a missing statistic, which is much better than a roll that appears to hang.

Re-rounding a throw does not come back through settled, so a roll is recorded once rather than once per rounding somebody tries. A history with a row per button pressed is a history of the buttons.

The breakdown is stored whole, as JSON, rather than normalised into rows — and not for convenience. A roll’s breakdown means what it meant then. Normalising it would let a set uninstalled last week quietly rewrite last week’s rolls, so everything the history screen draws is in the text: the labels the faces carried, which dice were dropped, which came from an explosion, which showed a natural maximum. Nothing has to be looked up to draw a past roll, which also means nothing can be looked up wrong. Reading one back is deliberately lenient: a breakdown written by an older version is still a record of a roll somebody made, and the total is in its own column either way.

Every roll carries a session id: the active session’s, read per roll (see Sessions). The first session’s id is default rather than an empty string, because "" in a history is a value somebody will one day have to guess the meaning of — and because it is what let the rolls filed before sessions existed become a real session that can be renamed rather than a gap to migrate.

The saved-roll strip on the tray

The active group’s rolls, as tiles, behind a pull-up on the bottom edge: parked by default so the whole table is visible, pulled up when somebody wants one (docs/physics-and-rendering.md, “Two pull-ups, one bottom edge”). It was a plate standing across the felt in every state, which is one more place a die could land and not be seen.

A tap here fills the formula field, exactly as a tap on the saved-rolls list does, and the throw is the shake that follows (docs/physics-and-rendering.md, “Starting a roll”). It used to throw, which made the strip the one control in the app that rolled without a hand: a saved roll brushed by a thumb was dice already on the table, and the throw nobody watched was the throw that counted.

What the strip hands back is the formula and which saved roll put it there, and that survives the wait for a hand — so the throw is still recorded as that roll’s, and the saved-roll statistics still have something to count (docs/statistics.md).

It is handed to the roll screen as a slot, the same way the menu button is, and for the same reason: a roll screen that knew what a saved roll was would be one feature module depending on another. The slot is given the callback that fills the field, so the strip hands back text and the roll screen does the rest — through the same type a keystroke goes through.

The invitation tile is last and is the only thing there when the group is empty, which is what design option 9a means by “the strip invites the first save”. A strip that vanished when there was nothing in it would never tell anybody saved rolls exist.

Exporting

Two halves, split where Android begins. CollectionExport decides what goes into the file and what it is called — a group with its subgroups, or the lot; the name slugged, because a group called D&D / 5e? is a fine group and a poor path. CollectionSharing, in app/, hands that file to another application.

It is in app/ rather than in feature/saved because the FileProvider it needs is declared in the application’s manifest and its authority is the application’s id. The screen hands its file up exactly the way it hands up a request to navigate; what the app does with it is the app’s.

The share sheet rather than a file picker, because “export” is not one action: it is mailing a stat block to a player, saving it into Files, putting it in a chat, or pushing it to a repository the group keeps. One sheet offers all of them and the app does not have to have an opinion.

The copy goes into cacheDir/collections, which is emptied first — the sheet offers one file, and a directory that only grows is a directory of everything anybody ever exported. res/xml/collection_paths.xml lets the provider see that directory and nothing else: a file-sharing provider that can reach the database is one that will eventually be asked for it.

Importing

Two steps, and the order of them is the whole of it. A file is read first, by core/collection, which cannot write anything; only a file that came back sound is offered to CollectionImporter, which writes it in one transaction. Every way an import can fail has therefore already happened before anything is at risk.

A collection arrives three ways, and only the first stretch of the journey differs. Everything after “the bytes, as text” is one path, because a collection is a collection however it travelled.

flowchart TD
  picked["A file the picker chose"] --> text
  pasted["A pasted https link"] --> known{"a link the<br/>installer recognises?"}
  known -- "no: the file itself" --> fetch
  known -- "yes: a repository" --> source["InstallSource:<br/>which forge, which ref,<br/>which tarball"]
  source --> fetch["PackageFetcher:<br/>https only, redirects by hand,<br/>1 MiB of bytes that arrive"]
  fetch -- "the file" --> text
  fetch -- "the tarball" --> extract["SafeExtractor:<br/>into a folder it cannot leave"]
  extract --> which["CollectionInRepository:<br/>one .dinfinity.json at the root"]
  which --> text["The bytes, as text"]
  text --> reader["CollectionReader:<br/>writes nothing"]
  reader -- "sound" --> importer["CollectionImporter:<br/>one transaction"]
  reader -- "not sound" --> refused["Unreadable:<br/>every line wrong with it"]

The repository half is deliberately not a second path. InstallSource decides which repository a URL means — the same code, and the same forges, a dice set’s link goes through (docs/dice-sets.md) — and what it names is fetched by the same PackageFetcher under the collection’s own one-megabyte cap and unpacked by the same SafeExtractor. Two things about that extractor were parameters waiting to be named: ArchiveLimits, which is what may be written and how much of it, and PackageRoot, which is which folder inside the archive counts. A dice set asks for diceset.toml under 64 MiB and six extensions; a collection asks for one *.dinfinity.json at the repository’s root, under the megabyte it is itself allowed, with json the only extension written at all. Everything hostile about an archive — a path that climbs out, a link, an entry count, a bomb — is refused by the same lines for both, which is the point: a second extractor would be a second answer to “is this path safe”, and two answers to that is one too many.

Nothing a repository carries is kept. The archive and everything unpacked from it live in a folder of that one fetch’s own, deleted whether the import succeeded or not, and the database is written only after the reader has passed the collection — the same ordering as a file, one layer further out.

CollectionDownload and CollectionInRepository are in :app for the reason everything else here is: a cache directory and an HTTP client are the platform’s, and feature/saved takes a suspend (String) -> Fetched and never learns which kind of link it was. What a collection file is called is neither of theirs — it is core/collection’s CollectionFiles, because the name the app exports under and the name a repository is searched for have to be one rule.

The importer’s own rule is a refusal. A collection whose group name is already taken is turned away outright, naming the clash, with nothing merged and nothing deleted (decision 15). It is checked ignoring case, because two groups a capital apart are one group to a person — the same rule the group sheet keeps when somebody types a name by hand.

The file’s own ids are not reused. A slug is stable inside a file, which is what lets somebody edit one by hand; it says nothing about what this database already uses, and an id taken from a stranger is an id that can collide with one made here.

State What it means
Waiting nothing chosen; what an import will and will not do is on screen
Reading brief, but not instant for five hundred rolls
Fetching a link is being followed, which is the one wait that is somebody else’s speed
Unreachable no collection came back: the server refused, or the repository held none, or held two. Apart from Unreadable on purpose — there is no file here to go and fix a line of
Unopenable the file could not be opened at all — moved, or the permission withdrawn
Unreadable it is not a collection, and every line wrong with it is listed, each saying where in the file it is
Clash a group name is taken. Its own state, not another kind of problem: the file is fine and so is what is saved, and one of the two names has to change
Imported it is in, with the counts and any roll whose dice are not installed
Control Calls What changes
Choose a file the picker, in :app a content URI arrives, is read bounded, and becomes text
Fetch a link fetch, then CollectionDownload in :app a file or a repository is downloaded, and becomes text or a refusal
(not a control) the file’s text offer the state, to one of the four above
Choose another file again, then the picker back to Waiting
See the rolls (navigation) climbs to the saved-rolls list, leaving the import behind

The picker is in :app rather than on the screen, because a content URI is the application’s business. The screen takes text; the permission, the bounded read — one byte past the limit and no further, which is what tells a file at the limit from one over it — and the failure to open all happen on that side of the seam.

The saved-roll editor

EditorState is the fourth machine and the only one that can refuse to finish. Saving is disabled while the formula does not read, because a saved roll is a button somebody presses in the middle of a game and one that fails then is worse than one that was never made.

When the formula does read, the editor says what it is worth — the exact mean and range from core/probability, which is the thing a player is choosing between when they write 2d6 + 3 or 1d12 + 2, and which costs nothing because nothing has to be thrown to know it (docs/probability.md). A formula too large to graph exactly is still worth saving; it simply has no numbers beside it.

Control Calls What changes
the name field name what it will be called; blank means the formula is its name
the formula field formula the formula, its error and its odds, all from one plan
icon, colour, group, table choose that one field and nothing else — none of them needs re-validating
the thirteenth swatch (opens ui/common’s ColourPicker) nothing until Use it, which reports a colour into choose like one of the twelve
New group GroupPresenter.create the group sheet opens; the group it writes becomes this roll’s
Save roll save the roll is written down, and the editor leaves
Roll now (navigation) the tray, with this formula, without saving
Delete delete the roll is taken away, and the editor leaves

A new roll opened from nothing starts on the last formula that was thrown. There are three ways in and they carry different things. The outcome graph’s “Save as roll” carries the formula that was being read, and that one wins — somebody chose it. The tray’s strip “+” and the saved list’s New carry nothing, and an empty field there asks for what the app watched somebody type a moment earlier, because “add a roll” is pressed just after throwing the thing worth keeping. Editing a roll that already exists is untouched by this: it opens on its own formula, and Editing makes any other answer unsayable rather than merely forbidden.

What fills the gap is the history, read once — HistoryRepository.recent(1) in SavedWiring, handed to the presenter as a suspending lambda. Not a new AppSettings field: “the formula used for the last roll” is literally what a history row is, and a second copy in the settings file would be a second answer to the same question, one that needs a migration to add and that would go on answering after somebody cleared their history (docs/statistics.md). The read happens on the editor’s own scope beside the groups, so nothing waits on the database before the screen draws, and a player who beats it to the first keystroke keeps what they typed. The formula that arrives is validated like any other, so one whose dice set has since gone says so instead of being saved.

The editor leaves by climbing, not by going back: saving, deleting and the chevron in its header all land on the saved-rolls list. It is a detour from that list however it was opened — from the list, from the tray’s strip, from the outcome graph’s “Save as roll” — and finishing one is arriving at it (“Navigation”).

Sessions

A session is a label somebody puts on a stretch of rolls, not a thing that happens. Nothing here starts one and nothing stops one: the app files what is thrown under whichever is active, so there is no button to forget to press and a session left running overnight is not a state that exists.

Database version 3 adds the table, and the migration inserts the first row rather than leaving that to whichever screen first wants one. roll_history has carried a session_id since version 1 and every row already has a value — RollRecording files rolls under default — so the rolls made before sessions existed belong to a session that can now be renamed, rather than to a gap that had to be migrated. A history full of rows pointing at a session that does not exist is a join that quietly drops them.

The same rule groups follow: deleting a session moves its rolls to the first one rather than deleting them, in one transaction, and the first session has no Delete at all because it is where they go.

Each row carries two numbers, both counted in SQL: how many rolls are in the session, and how many of those had a die showing its highest face. The second is read out of the stored breakdown rather than by joining anything, because a breakdown means what it meant then and the set that threw it may be long uninstalled.

The active session is a preference, like the active group: it outlives the screen that chose it, and the roll screen reads it on every throw. RollRecording asks for it per roll rather than capturing it, so an evening’s rolls do not all land in whichever session was current when the screen opened.

Control Calls What changes
a session in the list activate which session new rolls are filed under, and the stored preference
New session edit(SessionDraft()) the naming sheet, on one that does not exist yet
Rename edit(draft) the same sheet, on one that does. The id does not move, so the rolls filed under it stay filed under it
Save save it is written, and a new one becomes active — making a session and then having to tap it is two acts where the player meant one
Delete delete the session goes, its rolls move to the first one, and if it was the active one the rolling moves too. Otherwise the next throw would be filed under a session that is gone

Statistics

Two screens in one destination: every die ever thrown, and one die opened. Opened rather than pushed, because going back from a histogram to the list is the same gesture as closing it, and a second destination for “the same screen about one row” is a back-stack entry nobody wanted.

The list is ordered most recently used first, which is not a preference: a player comes here about a die they have just been rolling.

A die’s values come from the installed set, not from its face count. A die labelled 1,2,3,1,2,3 is a d3, and a histogram drawn against a sixth would show it as twice as lucky as it is on every value. FaceHistogram therefore takes the values with repeats and weights the fair line by them; the arithmetic is in core/stats rather than in a draw lambda, so it can be tested directly.

When the set has been uninstalled since, the record is still the player’s and is still shown — but the values are taken from what has actually come up, and the screen says the fair line is a guess. The alternatives were hiding somebody’s record or drawing it against a line that is wrong without saying so.

A value that has never come up is a bar of zero rather than a gap: “this d20 has never rolled a 20” is the single most interesting thing a histogram can say, and a missing bar does not say it.

A bar is named by the die’s label, looked up when it is drawn (decision 73). FaceLabels (core/stats) maps each value to the one label the installed die prints it with; DieRow carries it and DieDetail.labelOf is what the bars and the natural-high and natural-low tiles ask. A value printed two ways, a value of a die whose set is gone, and a value two pooled sets print differently all fall back to the number (Face.printed).

Control Calls What changes
a die in the list select that die opens, and its face counts start being watched
← close back to the list, and the watching stops
Forget this die’s record confirm the confirmation, not the deletion
Forget everything confirm the same, for the lot
Forget it reset it happens. Nothing is forgotten without passing through here, and nothing reaches here without a confirmation
Keep it confirm(null) nothing

History

HistoryState watches, like the saved rolls do, so a throw made on the tray appears here without anybody asking — which also means the screen has nothing to refresh and no way to be stale.

A past roll is a record, not something to re-run. There is no replay action and no seed anywhere on the screen, and that is not enforced by remembering it: HistoryEntry has no seed on it to show. The row in the table does; the type the screens are given does not. HistoryRepository is the line between them, and it is a separate class from the one that writes rolls because they are different jobs with different shapes — one transaction across three tables going in, a flow of one table coming out.

Which row is open is held in the state rather than in the list, because it has to survive the list being rebuilt when a roll lands: an expanded breakdown that closed itself every time somebody rolled would be a breakdown nobody could read. One at a time — fifty open breakdowns is not a list.

Session headings are drawn only when the list spans more than one session. A heading repeated down a whole list says nothing, which is what a fresh install would see.

Control Calls What changes
a row with a breakdown open that breakdown opens, and any other closes
the same row again open it closes
(not a control) a roll landing on the tray — the list, by itself

Settings, and the screens with no presenter

Every screen has a factory in Presenters except three: Settings, Notation and the menu. Settings is built the other way round: no presenter, no state of its own. It takes an AppSettings and a lambda. What it is showing is held above the navigation graph, in the activity and backed by DataStore, because a preference outlives the screen that changed it — which is the opposite of what a roll does, and why the two are not built the same way (decision 49).

Notation goes further and has no state at all: it is NotationReference — which lives in core/notation, beside the parser it describes — with a layout on it. Every example on it is a button that puts that formula in the tray’s field, and NotationReferenceTest parses all of them, so the screen cannot offer a formula the app would refuse (docs/dice-notation.md).

Every setting is one row. Its name and the sentence under it on the left, the control on the right and centred against the text, a hairline between rows and a 2 dp rule between blocks — which is what the design draws (design/dInfinityPhone.dc.html, the Settings screen: a flex row of a min-width: 0 text column and a flex: none segmented control). The app used to stack the three, so every setting was three blocks tall and the screen ran to twice the length of the drawing. Where the app and the design disagree about how something looks, the design wins. SettingRow is that layout — a two-slot Layout rather than a Row, because the decision it makes cannot be made with weights.

The decision the drawing does not make is what happens when the control will not fit beside the text. A browser can let flex: none overflow the viewport and a phone cannot, so the control goes under the text, at the left edge, and the row grows. The threshold is the text column’s own floor, 120 dp: the control is measured first, at its natural width, because it is the half that cannot be squeezed, and if what is left is narrower than that floor the row stacks. The two alternatives are both worse — a sentence set in 60 dp is a column of single words, and a squeezed control either clips an option’s label or drops it under the 48 dp touch target. The consequence worth knowing: below roughly 300 dp of room every row stacks, so the screen degrades to what it used to be rather than to something broken. That is narrower than any phone the app ships against and is exactly what a split-screen pane is.

Two things stay blocks rather than rows. The accent grid is four columns of swatches and would have nothing left of itself in half a row, and About is not a control at all. Haptics and sound is the third shape — a section heading and sentence with two rows under it — because the sentence is about both switches, and because the design has no sound switch at all (docs/TODO.md, “Does the sound go?”).

A boolean setting is a row like any other, with the Off / On segmented read-out where a picker has its options. The whole row is toggleable, so the tap covers the sentence too. Its heading is its label now, which cost the three switches that stood alone a second label each: “Power saving” above “Do not draw the dice” was one row saying the same thing twice, and a screen reader read both. What is left of that in the code is SettingRow’s readAsOne: the name and the sentence are merged into one stop for a screen reader, except where the row itself already merges, because a merging node inside a merging node is withheld from it rather than joined to it — which made the power switch announce its state without ever saying which setting it was. That is a thing a test can see, and one does.

Control Calls What changes
System / Light / Dark onAppearanceSelected which palette every screen draws in, immediately. Three choices and no fourth: “automatic at sunset” would change colour halfway through somebody’s game
one of the six accent presets, or any colour from the app’s own picker (decision 63) onAccentSelected the stored accent, and with it every screen at once. The six are laid out four across, so six presets and a custom swatch come out 4 + 3 with nothing orphaned
Straight down / Angled onTableViewSelected how far the camera leans over the table, from the next visit to the roll screen. Straight down is the default (docs/physics-and-rendering.md, “Rendering (normal mode)”)
the haptics switch onHapticsChanged whether a die landing ticks in the hand, from the next visit to the roll screen. The system’s own touch-feedback setting still governs it: the effects go out under VibrationAttributes.USAGE_TOUCH and the app never asks whether that is on
the sound switch onSoundChanged whether a die landing makes a noise, on the same terms. Which noise is the table’s (docs/tables.md)
Down / Nearest / Up onRoundingSelected which way division rounds on the next throw, and on every outcome graph. The per-throw override on the result sheet is still not remembered
the power-saving switch onPowerSavingChanged whether the next visit to the roll screen draws the dice at all
the developer-tools switch onDeveloperToolsChanged whether the menu offers the Developer screen, at once, and whether the next visit to the roll screen draws the debug overlay, with its frame rate and dropped steps (decision 72). Off on every install, and it changes nothing else: the history still has no replay and still never shows a seed (decisions 13 and 53)
Source code and issues onRepository a browser. The app’s only outward link
(not a control) the first-launch screen onWelcomeSeen that it has been seen, so it is shown once
the menu button, on every screen navigate(Menu) which screen is on

Six of those take effect when the roll screen next opens rather than where they are pressed — power saving, haptics, sound, the default rounding, the table view and the debug overlay half of the developer toggle. A renderer appearing under a roll in progress, a roll that starts buzzing half way down, an overlay appearing over a throw, a camera leaning over while the dice are still moving, or a total changing its arithmetic while the dice are in the air are not settings taking effect; they are bugs (decision 16). The camera is the clearest of them: the tray a visit is given is built with one answer and a rotation rebuilds the picture with the same one, so the lean is fixed for as long as the screen is. The developer toggle’s other half — the menu row — appears at once, because a menu is not a roll.

The accent is no longer a closed palette, and the clamp is what makes that safe. AccentColor was six entries checked against both grounds by a test, and its KDoc said why a free picker was refused: a slider that offers a pale yellow produces an app whose most important control is invisible. The design of 2026-09-17 keeps six as presets — Light blue #38a8dc by default, then Modernist red #ec3013, Magenta #c2186f, Cobalt #1d5fd4, Pine #0f7a50 and Amber #c07000 — and adds a colour of the player’s own behind a contrast clamp. AccentRamp.clamp pushes whatever arrives away from the ground it will be read against, in ten steps towards black on paper or white on a dark page, and stops at the first that clears 3:1; that clamped value is what feeds --color-accent and the ramp mixed from it. The swatch and the hex label still show the colour the player actually chose, because a picker that silently shows something else is a picker nobody believes. What used to be a test over six fixed entries is a test over the clamp — the stronger statement, since it holds for every colour rather than for six (AccentRampTest).

The clamp lives in the theme rather than in Settings, so there is no second path to the screen: a preset and a picked colour arrive the same way and the one that is too pale is deepened either way. Light blue itself is 2.41:1 on paper, so the presets are not exempt and are not meant to be — they are the design’s colours, not six colours chosen for passing a test.

An accent is therefore not one colour but a ramp on a ground: color-mix(accent 16 %, bg) and 28 % at the pale end, 86 / 58 / 40 % towards --color-text at the deep one. Both ends are mixed from the accent the player picked, against the ground’s own page and ink rather than against black and white, which is what makes one rule resolve on both grounds — and what finally makes the filled accent tag drawable for an accent the design system ships no ramp for (ui/common’s TagKind.Accent). The consequence worth knowing: Modernist red’s pressed step is now the mix rather than the stylesheet’s #ae1800. One rule with an exception in it for the one accent that has a published ramp is a rule no test can hold, and the difference is a shade.

The stored ids changed, and four of the six are gone. An unknown id falls back to the default, which would have repainted every phone that had chosen one of those four — in the same release that changed the default, with nothing on screen to say why. So AccentColor carries a map from each retired id to the surviving preset nearest it in CIE Lab: coral → Modernist red, sky → Light blue, moss → Pine, violet → Cobalt. It is applied once, on read, and the next write stores the survivor. The two ids that survived, vermilion and amber, kept their ids while changing their names — an id is storage and a name is language, and re-labelling a colour must not move anybody’s choice.

Android has no colour picker to send anybody to. The prototype’s <input type="color"> is the browser’s, and there is no platform equivalent to borrow, so the app draws its own: ui/common’s ColourPicker, a sheet with a patch of colour and three sliders — hue, depth and brightness rather than red, green and blue, because dragging one of three colour channels changes all three of the things somebody is looking at.

There is one of it, and three screens open it: Settings’ accent, the face designer’s ink and the saved-roll editor’s colour tag. It was written twice before it was written once — each copy private to its screen, each with its own slider row — which is the duplication this module layout exists to prevent, and which both copies’ own comments had already noticed. The arithmetic under it is core/model’s Hsv, beside AccentRamp and Contrast: plain Kotlin, so a JVM test holds what a hue is, and low enough in the graph that ui/common can reach it without depending on a feature’s engine. Hex is there too, and is the one way a colour is written #RRGGBB — the accent’s label, the designer’s ink readout and a saved roll’s tag each had their own before, and two of them took the default locale with them.

feature/settings no longer depends on :designer at all; it took that dependency only for the picker’s arithmetic.

There is no longer a sound switch in the design. The prototype’s Settings has Appearance, Table view, Power-saving mode, Haptics, Division and Accent colour, and nothing else; haptics is the only feedback toggle it offers. The app has a feedback/ module that generates an impact sound per table material and pitches it by the die’s size. Removing that is a product decision rather than a drawing, so the row below stays until it is taken, and the decision is in docs/TODO.md.

Haptics and sound are read there rather than per throw because both ends of them are built with the screen: the thing that listens is the roll, which is opened with listening = haptics || sound, and the thing that plays holds an actuator, a handful of audio buffers and a thread. Reading them later would mean a roll that recorded impacts nobody asked for, or a player rebuilt mid-throw. The pair is also what the saving is measured against: with both off nothing is measured, rather than measured and then thrown away.

SettingsRepository has one write, not one setter per setting. The list of settings is still growing, and an interface with a method for each is an interface that changes every time somebody adds a checkbox. update takes what changed and the named operations are extensions beside it, so call sites still read like English. The store writes every key on each change: edit is one transaction either way, and writing the whole of what was decided means a setting can never be half-applied.

The version comes from the installed package rather than a generated constant, so it is what is on the phone rather than what some build thought it was compiling.

The power-saving row is the one setting that does not take effect where it is pressed. It is read when the roll screen opens and not watched, because a renderer appearing or vanishing under a roll in progress is not a setting taking effect — it is a bug (docs/physics-and-rendering.md, “Power-saving mode”).

Accessibility

The rule the whole interface follows: nothing is said by a colour alone, and nothing that is drawn is silent. It is the same rule the result sheet already made about dice — “anything else a die has to say about itself is a note in the breakdown, not a colour nobody can decode” — applied to every screen.

The decision, then the drawing

What a screen reader says is worked out in plain Kotlin and only then looked up, for the same reason the bar heights are: a Canvas draw lambda and a semantics block are places a test cannot read, and the part that can be wrong is the words, not the call that attaches them.

What it decides Where
which of three a landed die is — kept, highest face, dropped feature/roll’s DieReading
what the tray has on it, per RollState feature/roll’s TrayReading
what the next shake will throw, and how many feature/roll’s ShakePrompt.of
how many dice are in each state on the debug plan feature/roll’s TrayPlan.tally
the shape of the distribution: how many totals, their range, the likeliest, what is marked feature/graph’s ChartReading
what the observed-against-expected chart claims feature/stats’ TotalsReading
whether two colours can be told apart, in WCAG’s arithmetic core/model’s Contrast

The colour and the words then come from one answer rather than from two whens that could drift: ResultSheet asks DieReading for both the tint and the label, so a die cannot be painted as a natural maximum and announced as an ordinary one.

What was carrying meaning in colour alone, and what it says now

Where The colour The second channel
the result sheet a natural maximum in the accent, a dropped die struck through “18, highest face”, “1, dropped”
the outcome graph the rolled total marked in the accent the chart’s own description, and the line under it that names the total
the statistics histogram the observed bar over the fair line “Face 2 came up 3 times, 75.0 %; a fair die, 50.0 %” — the face by the die’s own label, a blank one as “blank”
the saved-roll chart the exact distribution in the error colour across the ink bars how many totals, their range, and how many ran ahead of the distribution
the history a total with a natural maximum in the accent “20, with a natural maximum”; the dropped line says it is dropped
the cuts, orders and table rows the chosen one in the accent and in bold selected in the semantics tree
the set details a source you can open printed in the accent a click label, “Open in a browser”
the menu section names small, capitalised and in the accent heading(), so the menu is jumped through by section
the debug overlay’s tray plan three tints, two of them red “Tray plan: 3 dice, 1 at rest, 1 moving, 1 stacked”

Things that are drawn

Four surfaces have nothing under them for a screen reader to find, and each says what it contains rather than nothing:

A control drawn as one glyph is labelled and given a target: the menu button, the export mark, a group’s …, the back arrow out of a die, and every tool of the face designer. Rows that are one fact are merged with mergeDescendants so they arrive as one announcement rather than three — a face of the designer’s strip is a thumbnail and a label and arrives as “Face crit”, not as a picture followed by a word.

The designer is where the rule cost the most and paid the most. Twenty-one controls lost their words at once (docs/face-designer.md, “The tools are pictures”), and not one string resource was deleted: each became the name its control is announced by, so the words moved from the face of the button to the contentDescription and a translator still reaches every one of them. A two-state tool says its state in the semantics as well as in the paint — a nib is selected, the guide is a Role.Checkbox because a tap turns it back off — and a tool that merely does something is a Role.Button with no chosen-ness to report at all, rather than one reporting that it is not chosen.

A gesture is not an affordance — and rolling is one anyway

Shaking the phone is the only way to throw dice (docs/physics-and- rendering.md, “Starting a roll”), and a shake is not a gesture every hand can make. Until decision 66 two things stood in for it: a custom accessibility action on the table, Throw the dice, and the formula editor’s action key. Both are gone, and nothing replaces them. There is no button, no key, no accessibility action and no switch in Settings that throws or that turns shaking off; a tap on the table does not roll. Under a total it picks a die up for the next shake (decision 76), and that too is a sighted gesture with no accessibility action of its own: a die is a target only an eye can aim at. What a screen reader gets is the count — the table’s description says how many dice are picked and that a shake throws them, and the prompt over the tray says it aloud as it changes (decision 84).

This is a decided limitation, and it is the one place the rule above is broken on purpose. Somebody using TalkBack can hear everything on the roll screen — what is on the table, what the dice came to, what a roll is waiting for — and can type a formula, but cannot start a roll unless they can also shake the phone. The same is true of anybody who cannot shake one: a phone on a stand, a hand that cannot hold it, a tablet on a desk. The owner chose it, knowing that, because the throw is the product: a roll that can be started without the hand is a random-number generator with dice drawn on it (decision 66).

What survives of the old affordances is the description. The table still says what is on it, the power-saving panel still says why there is no table, and the shake prompt below still announces how many dice the next shake will throw — so a player who can shake is never left guessing what a shake will do.

A roll that is waiting on a hand announces itself. A chain that earned a throw, a throw that left dice nobody could read, a throw that gave up on dice that never stopped, and a landed roll with dice picked all put a shake prompt over the tray — “Shake to re-throw 2 dice”, “Shake to throw the 2 picked dice” — which is a polite live region, so it is read when it arrives and again when its count changes, rather than when somebody swipes onto it (decision 84). It stays until the shake that answers it. A notice nobody is told about is a notice that did not happen.

Touch targets

48 dp, which is Android’s own figure and WCAG 2.2’s success criterion 2.5.8 at level AA, held once as TOUCH_TARGET in ui/common (TouchTarget.kt). The dice picker row was built to it from the start (PickerRow sizes its chips with it); the controls that needed saying so afterwards are the ones whose label is a single character, because a button sized to its text is a button the size of one glyph.

Contrast

Measured rather than looked at. Contrast is WCAG 2.2’s arithmetic — relative luminance, the ratio between two colours, and compositing a translucent one over its ground — and it is core/model’s because it is arithmetic and nothing else: no Android type, no composition, no screen. AccentColorTest and ModernistContrastTest both measure with it, so the palette’s two halves cannot come to disagree about the same colour.

The bars are 4.5:1 for body copy, 3:1 for large text and for a control’s own boundary.

Pair Light Dark
text on background 14.86:1 14.86:1
text on surface 13.70:1 12.60:1
the accent on background 3.76:1 3.95:1
the accent on surface 3.47:1 3.35:1
accent body copy (accentOnLightText) on background 6.41:1 —
a filled button’s label on its own accent 3.76:1 3.95:1
the divider at 40 % of the text colour 2.41:1 3.51:1

The two in bold are short of their bar, and both would need the palette itself to change — which is a design decision and not a test’s to make. They are written down in docs/TODO.md under “Open questions”, and ModernistContrastTest holds them at the measured value so a palette edit cannot deepen the shortfall without failing.

The accent’s own claim is a property rather than a table, because the accent is whatever the player handed the app. AccentRampTest asserts it over five hundred random colours on both grounds: what is painted clears 3:1 against its ground, the pressed step clears 4.5:1 because that step is what body copy in the accent uses, and the two ends of the ramp clear 4.5:1 against each other because that pairing is what a filled accent tag is made of. The measured worst cases are 5.00:1 and 6.56:1, both on #D60000 over the dark ground. Clamping twice is clamping once, so any layer may do it and the theme always does.

What a test cannot answer

The labels, the sizes and the ratios are asserted in Robolectric and on the JVM. What is left is a person with a phone: whether the reading order through a screen is sensible, whether the announcements are the right length arriving one after another, and whether the tray is comprehensible with the screen curtain on. That is the one line left in docs/TODO.md, Step 6, and it is left there honestly rather than ticked.

Text a person reads

Every word the app says is a string resource, in the res/values/strings.xml of the module that says it. v1 ships English and only English, and that is a decision about what is in the APK rather than about what the app could speak: nothing in the code stands between here and a values-de/ that somebody writes. A caption typed into Kotlin is exactly such a thing, so there is a check that stops the next one.

Where the line is drawn

Not every string is text. The rule is words a person reads, and the three questions that settle it are: does it reach the screen or TalkBack, does it have words in it, and are those words the app’s own?

A resource Stays in the code
a caption, a heading, a button’s label a test tag — "saved:new" is a handle, and no one reads it
a contentDescription or stateDescription — TalkBack reads it out loud a require/check/error message, which reaches a crash report and never a screen
a menu row’s name and the line under it a TOML or JSON key, a file name, an extension, a MIME type, a URL
a sentence saying why something was refused, where the app wrote that sentence a navigation route or a query argument: "savedstats" is an address
a count followed by a noun, as <plurals> — never as a string with a number in it a formula, a die id, "d20": dice notation is the same in every language
  a glyph with no words in it — "←", "…", "★", "●", an emoji somebody picked as an icon. Where a mark carries meaning it is given a spoken label, and that is a resource
  the shape a number is printed in — "%.1f", "%.2f", "0 %", the "—" that stands for no value. String.format already follows the device’s locale for the decimal point

A string that borrows its words rather than saying them is not text either: "$groupName ▾" is a marker after a name the player typed.

What holds it

Android Lint has the rule already — HardcodedText — and it cannot help here. It reads layout XML, and this app has no layouts: every screen is Compose, so Text("Roll") is an ordinary function call no resource-aware check ever sees. It is switched on all the same, by name in both convention plugins, for the XML there is.

The check that covers Kotlin is verifyTextIsAResource, registered by dinfinity.quality and so applied to every module, wired into check. It scans each module’s src/main/kotlin for a string literal handed to Text(, text =, contentDescription =, stateDescription =, placeholder =, label =, supportingText = or title =, and fails the build when that literal has words of its own in it. Its scan is TextIsAResource in build-logic, and it has unit tests of its own — a check nobody tested is a check that passes everything.

flowchart LR
  subgraph resources["Modules that can hold resources"]
    app["app/"]
    ui["ui/common"]
    feature["feature/*"]
  end
  subgraph plain["No res/, by design"]
    notation["core/notation (plain Kotlin)<br/>NotationReference.kt"]
    install["dicesets/install (Android library)<br/>AtlasDecoder.kt, InstalledSets.kt"]
  end
  format["dicesets/format (plain Kotlin)<br/>ValidationMessage"]
  format --> install
  strings[("res/values/strings.xml<br/>tools:locale=en")]
  app --> strings
  ui --> strings
  feature --> strings
  notation -. "English in code,<br/>exempted by name" .-> screen
  install -. "English in code,<br/>exempted by name" .-> screen
  strings --> screen["What the player reads"]

The two dotted arrows are the gap, and it is named rather than hidden. core/notation is plain Kotlin on purpose — the notation reference sits beside the parser so one can be tested against the other — so it cannot hold a resource. A validation message is a ValidationMessage, a type in dicesets/format, which is plain Kotlin for the same reason the rule above gives: a validator that reads a stranger’s file may not depend on Android. dicesets/install is an Android library (it decodes atlases with BitmapFactory), but it has no resources either, and the messages its AtlasDecoder and InstalledSets write are ValidationMessages like the validator’s own. Both modules write English that ends up on a screen. The files that do so — NotationReference.kt, AtlasDecoder.kt and InstalledSets.kt — are exempted by name in their own build scripts with the reason beside them, the same way the coverage exclusions are, so the gap is a line in a diff. Closing it means giving each message a typed reason the screen phrases, which is a design change rather than a string move; it is recorded in docs/TODO.md under Step 6, “Localisation”.

The same is true of the sentences app/’s download and file-reading helpers write — PackageFileReading, CollectionFileReading, PackageDownload, CollectionDownload, CollectionInRepository, TablePhotoLibrary — and of the two presenters that say a build cannot reach the network. They are halves of the same pipeline: the other half of every one of those sentences comes from dicesets/install or core/collection, and half a translated message reads worse than none.

The default locale

values/ is English and says so: every strings.xml carries tools:locale="en" on its <resources>, which is what tells Lint and any translation tool what they would be translating from. The application module sets localeFilters += "en", so the seventy-odd languages AndroidX and Material ship translations for do not travel in an APK that speaks one. Adding a language is the same line as adding the folder.

Data flow of a roll

flowchart TD
    F["Formula<br/>3d6 + 1d20 - 4"] -->|notation.parse| P["RollPlan<br/>terms: 3×d6, 1×d20<br/>modifier: -4, set: builtin"]
    P -->|resolve dice from installed sets| C{Table capacity check<br/>docs/tables.md}
    C -->|does not fit| R["Roll refused<br/>'up to N dice fit'"]
    C -->|fits| T["ThrowSpec<br/>dice, dieScale, seed, table,<br/>initial impulse (shake or default)"]
    T -->|"DiceSimulator.start / run"| L["LiveRoll<br/>one fixed step at a time"]
    L -->|"every step, while shaking"| L
    L --> S["SimulationOutcome<br/>per-die face index, steps, rethrows,<br/>where each die came to rest,<br/>the dice it could not read"]
    S -->|"dice left unread:<br/>wait for a shake"| U["Passes.next<br/>only those dice, a seed of their own,<br/>among: the dice already down"]
    U -->|"thrown by the next shake"| L
    L --> D["drivenBy<br/>the shake as it actually arrived"]
    L -.->|body transforms, optional| V[Renderer]
    L -.->|"impacts, optional"| I["Impacts<br/>ticks and sounds, now or over ~1 s"]
    S -->|face index → value<br/>keep/drop/explode, modifier| O["RollResult<br/>total, per-die breakdown,<br/>formula, timestamp"]
    S -->|"a 6 on an exploding die,<br/>or a reroll"| X["One more ThrowSpec<br/>one die, derived seed,<br/>among: the dice already at rest"]
    X -->|"dropped into clear floor"| L
    O -->|"under a total: dice a finger picked,<br/>thrown by the next shake"| H["ThrowSpec of the picked dice<br/>Seeds.byHand,<br/>among: every die down, the picked ones too"]
    H -->|"dropped into clear floor"| L
    D -->|"spec.copy(shake = drivenBy)"| FT["FinishedThrow.thrown<br/>the ThrowSpec that replays this roll<br/>goes no further than this screen"]
    O --> FT
    O --> UI[UI]
    O --> ST["stats.record<br/>or, after a throw by hand,<br/>stats.amend of the same row"]

The DiceSimulator runs at a fixed timestep. In normal mode it is stepped in lockstep with the frame clock and the renderer interpolates. In power-saving mode it is stepped as fast as the CPU allows on a background thread and only the outcome is delivered. Same code path, same result for the same seed — and “same code path” is literal: both are a LiveRoll, and the difference is who calls it (decision 48).

Both dotted lines are watchers and neither has a way back: Renderer has no method that returns anything and Impacts has none either, so drawing a roll and hearing one are alike in being unable to change it (decisions 48 and 52). The impacts are recorded only when something is going to play them, which is what the two feedback settings decide when the screen opens.

The loop through Passes.next is a throw that left dice it could not read. The throw stops and the roll waits on the screen’s thread for a shake — no world is open and no step is taken while it waits, so the twelve-second backstop is a pass’s and a roll can wait as long as the player likes. The shake throws the unread dice, and only those, through the same Rolls.start every throw takes; the dice the last pass read are lifted by that throw rather than drawn among it. Headless callers have no shake to wait for, so JoltDiceSimulator.run follows each such pass with the next at once (Passes.scripted) — the same passes and seeds, minus the wait (decision 70).

The loop through One more ThrowSpec is an explosion or a reroll. A roll is not always one throw, and how many it is cannot be known before the dice land, so scoring says either “here is the total” or “throw one more of these first”. The added die goes round the same path — same simulator, same renderer, same tray — carrying the dice already at rest so that it can be dropped clear of them and drawn among them. No body is created for any of those: a die that has come to rest is finished (docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”).

The loop through ThrowSpec of the picked dice is a player’s hand. Once a roll has a total, a finger picks dice up (RollMachine.pick, through TrayPick and PickUp), and the next shake throws those and only those — the same path, with every die already down carried as among and the picked die among them, never moved. The new faces replace the old ones, the roll is scored again from the beginning, and the old faces stay in the breakdown, struck through (PickUp.withEarlierThrows). The history row the roll was first written as is amended rather than joined by a second (RollRecording.amend), and only the dice thrown again are counted again (decisions 68 and 76; docs/physics-and-rendering.md, “Picking a die up and throwing it again”).

The loop back into LiveRoll is a shake. The dice are spawned when the shake is confirmed, so most of one arrives while they are already in the air; a sample is filed on the step the world is about to take and rewritten to it, so the roll is reproducible from the record afterwards whatever the sensor’s own clock said — which matters, because a watched roll is paced and the two clocks do not run together (docs/physics-and-rendering.md, “Shake input”).

The roll accumulates those samples as it takes them and reports them beside the outcome, so a throw that has landed can be described by the spec that would replay it rather than by the empty one it began with. That join happens once, on the roll screen’s own thread, and it is where the record ends: nothing below stats.record has a field to put it in. A past roll is a record, not something to re-run (decision 13).

Threading

sequenceDiagram
    participant Screen as Roll screen (main thread)
    participant Roll as Roll thread
    participant Engine as FilamentEngine
    participant Progress as ShaderProgress
    Screen->>Roll: surfaceAvailable (posted by TrayDriver)
    Roll->>Engine: first use: made, materials loaded
    Note over Engine: MaterialCache hit: nothing is reported
    Engine->>Progress: started(variant), on a miss only
    Progress-->>Screen: shaders = Compiling (StateFlow)
    Note over Screen: plate up, bar follows the main thread's frames
    Engine->>Engine: libfilamat compiles
    Engine->>Progress: finished(variant, compiled)
    Progress-->>Screen: shaders = Idle
    Progress->>Progress: keep the time taken (noBackupFilesDir)

Storage layout

<filesDir>/
  dicesets/
    <set-id>/                 one folder per installed package (dice and/or tables), id is a sanitised slug
      diceset.toml
      textures/…
      .meta.json              source URL, commit hash / archive checksum + ETag, install time, validation report
    mine/                     the same again, generated from the drafts rather than downloaded (docs/face-designer.md)
    <named-id>/               a personal set named in the face designer, generated from its own records (decision 79)
    .mine.writing/            it being rebuilt (or .<named-id>.writing/); renamed into place, never a package (the dot)
    .mine.previous/           the one it replaced, held until the swap is done
  drafts/…                    in-progress face drawings, one file per die — the canvas, and what mine/ is built from
  table-photos/…              photographs made into tables: <id>.webp and <id>.name, two files each
                              (deliberately not inside dicesets/, where a loose folder would be scanned as a package)
  preinstalled-sets.txt       the sample sets the app has installed, `id=version` per line, and the app version it last
                              ran for (`@app=`); what tells a set never installed from one the player removed (decision 107)
  mine-physical.txt           what "My dice" is made of: size_mm, density, translucency, one per line
                              (the third record mine/ is built from; docs/dice-sets.md)
  personal-sets/              created when the first set is named, never before (decision 79)
    <named-id>/
      name.txt                what the set is called, one line
      drafts/…                its own copy of each drawing saved into it, one file per die
      physical.txt            what its dice are made of, as mine-physical.txt
    .<named-id>.writing/      a record being made; renamed into place, never a set (the dot)
<cacheDir>/
  collections/                one exported saved-roll collection, emptied before each share
  exports/                    one exported file of numbers, emptied before each share
  packages/                   one exported dice-set zip, emptied before each share
  preinstall/                 a sample set's zip while it is installed, deleted again (PreinstalledSets)
<codeCacheDir>/                emptied by Android on every app update
  materials/                  the dice materials compiled for this phone's driver (MaterialCache)
  studio/                     the studio panorama folded into a cube (StudioCache)
<noBackupFilesDir>/            survives an update, never backed up
  shader-timings.txt          how long each material took to compile here, `key=millis` per line (decision 95)
<databases>/dinfinity.db       Room: stats, saved rolls and their groups, roll history, sessions, set registry

An imported saved-roll collection is not kept as a file. Its rolls go straight into the database as saved rolls, and the file it came from is read once and let go.

Dice set folders are treated as read-only after installation, with one exception the app owns end to end: the personal sets. dicesets/mine/ is rewritten from the drafts and the photo tables whenever the folder is read and either has changed, and a named set’s dicesets/<id>/ from its records under personal-sets/<id>/ in the same way (decision 79). Removing a named set removes its records too; removing mine removes the package alone. Uninstall deletes the folder and the registry row; statistics referencing that set are kept (they are keyed by set id and die id, not by file path).

The three <cacheDir> directories are the only paths the app’s FileProvider can see (app/src/main/res/xml/collection_paths.xml). Each kind of export gets one of its own rather than sharing a wider path, because the cache also holds the archives an install is working through, and those came from a stranger.

Key decisions log

# Decision Reason
1 Result comes from physics, always Core value proposition; avoids “is the animation just theatre?”
2 Fixed-timestep, seeded, deterministic sim Power-saving mode must be provably the same roll; reproducible bugs
3 TOML for dice sets Human-editable, no code execution, comments allowed, simple to validate
4 v1’s shape catalogue is closed: eight convex solids, no author-supplied meshes Convex-convex collision is fast and robust, and eight known-fair solids need no fairness UI; sets vary values and artwork, not geometry
5 Dice sets installed into sandboxed per-set folders Containment; a set cannot reference files outside its folder
6 Exact PMF via convolution, not a normal approximation It is cheap for realistic formulas and correct for small dice counts
7 d100 is two d10s (tens + units) Matches table convention; a 100-sided ball does not roll honestly in a tray
8 Stats keyed by (set id, die id), not by shape A custom d20 with a skull on the 1 is still a d20 for statistics — but users may also want per-set stats
9 Table mesh is fixed; only its look is exchangeable The tray is the screen; a fixed box keeps physics predictable and the capacity limit meaningful
10 Rolls that exceed table capacity are refused, not batched Too many bodies in a small box produces tunnelling and jitter — that is not a roll, it is a bug generator
11 Downloads from any https archive URL, with first-class support for git forges Not everyone is on GitHub; the safety comes from the validator, not from the host
12 GPL-2.0-or-later Author’s choice; user content (sets, tables, saved rolls) is explicitly not covered
13 Seeds and inputs are recorded but never surfaced; no replay in the app Determinism is for testing and bug reports, not a feature; a past roll is a record, not something to re-run
14 Tables are global; a dice set never overrides the selected table One tray on the screen, whatever mix of sets is in the throw
15 Saved-roll import refuses a duplicate group name instead of merging No conflict UI to get wrong, and an import can never damage existing rolls
16 Power-saving is manual only A roll that silently stops rendering because the battery dipped is a surprise
17 minSdk 36, compileSdk/targetSdk 37 Robolectric cannot start API 37 and cannot run below minSdk; a minSdk of 37 would cost the whole Robolectric test tier for one API level of reach
18 Nothing touches a die at rest: prevention, then corrections while a die is still moving, then a visible re-throw of that one die (made by the player’s shake since decision 70) A settled die that twitches shows the player the result being arranged rather than rolled — worse than the stacked die it fixes. Re-throwing a cocked die is fair, and it is what a player does at a real table
19 The verdict on an instrumented run comes from its JUnit XML, not from AGP’s own pass/fail AGP 9.4.0 cannot pass a run on a device whose adb serial contains a colon — which is every device attached over WiFi debugging — so its verdict is unusable here (docs/build-setup.md)
20 Release APKs are signed v2+v3, not v1 or v4 v3 carries the proof-of-rotation record, so a lost or compromised release key can be replaced without breaking updates for anyone who already installed the app; v1 is unread above API 24 and v4 only speeds up incremental adb install
21 The submitted dependency graph covers the runtime classpaths only A graph of every configuration also carries the build’s own toolchain, producing vulnerability alerts for transitives no file in this repository declares and that Dependabot therefore cannot patch; build-tool advisories ride in on the weekly AGP and Kotlin bumps instead
22 The accent is six presets and any colour the player likes, with a contrast clamp between the choice and the paint The Modernist system spends colour in one place and relies on that colour carrying meaning, and a free picker lets somebody choose an accent that vanishes against the ground — which is why this used to be a closed palette of six asserted at 3:1 by a test. The clamp answers the same worry better: AccentRamp.clamp pushes any colour off the ground it is read against until it clears 3:1, so the guarantee becomes a property of every colour rather than a list of six, and a property is what a test can hold. It is in the theme, so a preset and a picked colour cannot take different paths. What is shown stays what was chosen — a swatch that answers a tap with a different colour is a control nobody can aim — and the deepening is explained in words above the grid instead. The same answer now covers a saved roll’s colour tag, which is the other place in the app where a player picks a colour the app will print words in: twelve presets, any colour beyond them, and AccentRamp.clamp between the choice and the mark. What it did not extend to is a die’s face, and deliberately — the ground behind a die’s artwork belongs to the dice set, so there is no ratio to clamp against (core/model’s Hsv)
23 The identity’s blue is fixed and does not follow the accent The mark is the app’s name, not its chrome, and a launcher icon cannot follow a runtime setting in any case (docs/assets/README.md)
24 SonarQube runs as the scanner in CI, not as automatic analysis Automatic analysis cannot ingest a coverage report at all, and it ignores sonar.issue.ignore.*, so a reviewed finding could only be accepted by clicking it away in the web UI. It also reads a different file, so the repository had to carry two configurations that could silently disagree — and did (docs/build-setup.md)
25 Coverage is reported per module, not merged into one file Each module has exactly one JVM test task, and SonarQube merges a list of reports itself. The Android modules’ reports are built by AGP rather than by a hand-written JacocoReport task, so nothing depends on the paths of AGP’s intermediate class directories, which are not API and have moved between versions
26 The coverage rule is a floor in gradle.properties, not a comparison against main A floor fails the same way on a developer’s machine as on CI, needs nothing cached or recomputed, and turns both directions into something a reader sees: raising it is a line in the diff, lowering it is an argument in the pull request
27 build-logic is linted by the ktlint CLI rather than its Gradle plugin The plugin lints whole source sets, and Gradle generates its plugin accessors into that build’s main source set — tens of thousands of violations in code nobody wrote, which no path filter would suppress. The CLI takes explicit patterns
28 Every dependency is pinned by SHA-256, not only by version A version says which artifact was asked for; a checksum says which one arrived. The app installs downloaded dice sets, so a build that cannot tell the difference is the wrong foundation for one that must. The cost is that a dependency bump has to regenerate the metadata (docs/build-setup.md)
29 A release is refused unless the APK carries the expected certificate fingerprint A signature that verifies is not the same as our signature. Without keystore.properties the build produces an unsigned APK rather than failing, and an APK signed with the debug key or a regenerated one installs as a different app and can never update anyone — a mistake that cannot be taken back once published (SECURITY.md)
30 Only one JaCoCo report is written at a time JaCoCo’s HTML formatter copies its static resources out of a jar reached through the class loader, and two reports running at once in the same daemon share that open archive — the first to finish closes it under the other (ZipException: ZipFile closed). It failed a build on main with nothing changed to explain it. Writing a report is milliseconds, so serialising costs nothing worth measuring
31 Typed notation names standard dice (dN, d%, dF), set-qualified or not; a set’s own die ids are reached from the dice picker. Extended by 75: a set’s own die is typed in braces, 3{brass:skull-d6}kh1 A die id and a modifier are made of the same characters, so brass:skull-d6kh1 has no unambiguous reading — the parser would have to ask the installed sets where the id ends, and the formula field re-validates on every keystroke on a thread that has never seen storage. Picked dice build the same RollPlan as typed ones, so nothing else in the app knows the difference (docs/dice-sets.md)
32 Everything a roll can fail on that does not need dice is decided at plan time A roll is watched. A formula that turns out mid-throw to divide by zero, keep four of two dice or explode for ever would have to fail with dice on the table and nothing to show. ResultBounds proves the 64-bit promise the same way, which is also what lets the evaluator add in plain Long with no overflow checks
33 A dice set is parsed by tomlj and read field by field into plain data classes Parsing TOML is the kind of thing that should not be hand-rolled, and this parser is the one that carries the line and column of every key — without which the validation report could not say file:line at all (docs/dice-sets.md). Its deserializer is never used: a downloaded file reaches a document tree and nothing else
34 A texture’s dimensions are read from its own header, in plain Kotlin, before any decoder sees it Refusing a 30,000-pixel image is only safe if the refusal happens before the decode, because the decoder is the part with the attack surface. It also means dicesets/format stays a JVM module and can be tested without an emulator
35 Every catalogue solid is computed from its closed form in simulation/api, and the hull, the mesh, the face reading and the face designer’s Solid tab all come from that one place Three descriptions of the same solid are three chances to be a hundredth of a degree apart, and the one that would show is a die whose printed face and scored face disagree. Face 0 is the face that is up in the reference orientation, which is what both the atlas and a settled reading expect. Which corners make up which face is part of that one place rather than of whoever needs it: SolidFaces groups the corners onto the face planes once, and both render/filament’s DieMesh and designer’s SolidStage are built from it — the grouping used to live in the mesh, and a second copy of it in the designer would have been decision 35 broken in the one place it is easiest to break it, since the two pictures of face 7 would look right until somebody changed a solid. Which corner of a face is which readable position is part of it too (SolidFace.cornerReads): a d4’s numbers belong to its corners, and the renderer derived that from the mesh while the face designer derived it from the face order, which agrees with the tetrahedron on one edge in six — so a printed d4 and a drawn one numbered the same corner differently until the derivation moved here
36 size_mm is a die’s nominal size — the edge length for a polyhedron, the diameter for the coin — not its bounding diameter It is what a dice maker quotes, so “a d6 of 16 mm” means the same thing to an author as to the app. It is also the reading the capacity rule in docs/tables.md was worked out under: a 16 mm d6 covers 6.03 cm², and eighty of them are exactly what a phone-sized tray holds
37 Jolt Physics 5.3.0, not Bullet — decided by building both against the toolchain the app actually uses Goal 4 is determinism, and Jolt offers cross-platform determinism as a supported build mode (CROSS_PLATFORM_DETERMINISTIC=ON) while Bullet offers no such guarantee at all. The spike settled the rest on evidence: Jolt configures and builds clean with the SDK’s CMake 4.1.2 and NDK 30’s Clang 21 in about three seconds, and a real slice of it — a convex-hull die, a box tray and fixed 1/120 s stepping — links to a 2.0 MB stripped arm64-v8a library. Bullet 3.25 does not configure at all: its cmake_minimum_required(VERSION 2.4.3) is below what CMake 4 still supports. An engine the build cannot even configure is not a fallback (docs/build-setup.md)
38 The Renderer contract lives in render/headless, and render/filament depends on it rather than the other way round “No Filament engine is created at all” in power-saving mode is a claim about a whole dependency, and it is only true if the headless path can be built without that dependency present. A headless mode made out of the real renderer with the drawing switched off would still hold a GPU context and would quietly stop being free the first time somebody allocated in the wrong place. The contract also returns nothing anywhere, so a renderer cannot act on the simulation it is watching
39 The container’s emulator is an automated-test image at the newest API that has one, even when that is a release below targetSdk The same trade as decision 17, for the same reason and with the same floor: API 36 is the app’s own minSdk, so it is a device the app must work on regardless, and the API it targets is covered by the phone. It is also not much of a choice — API 37’s full image crashes surfaceflinger under headless software rendering and takes the framework with it, which surfaces as an install failing with “Can’t find service: package” (docs/build-setup.md)
40 The physics engine is native; every decision about a roll is Kotlin. The bridge is a PhysicsWorld interface, and the native side only creates bodies, steps them and reports what they are doing A roll is almost entirely judgement — where the dice start, which way the shake loads them, when a die has stopped, whether it may be touched, what it read, whether it has to be thrown again — and none of that is physics. Written in C++ it could only be checked by running it on a phone and looking; written in Kotlin over an interface, a test hands the loop a die that has stopped dead in the worst state a die can be in and asserts that nothing reaches it. A device can show that ten thousand rolls contained no post-rest correction; it cannot show that none is possible. The cost is a JNI call per step, which against 1,440 steps of a solver is nothing
41 The simulation runs in centimetres and grams, not metres and kilograms Jolt’s tolerances are absolute numbers tuned for objects about a metre across, and a die is sixteen millimetres. Most of them can be scaled by hand and some must be — its default penetration slop is 20 mm, larger than the die. One cannot: MotionProperties::SetMassProperties tests the inertia tensor for being “near zero” against a hard-coded 10⁻¹² on its squared length, and a 16 mm die’s 2·10⁻⁷ falls under it, so Jolt silently substitutes the inertia of a sphere a metre across — nine thousand times too much to turn. Friction then cannot take the spin out of a die and rolls never end, which looks exactly like broken friction and is not. At one centimetre to the unit a die is 1.6 units across and Jolt’s defaults mean what they were written to mean. Density needs no conversion either, which is the sign the unit is right: a set file quotes grams per cubic centimetre because that is what a dice maker quotes
42 Jolt is compiled optimised in every variant, the debug build included An unoptimised solver is not a slower version of the same roll — it is too slow to step 120 Hz on a phone, so the emulator and device tiers would be judging something the release build never does. Nobody steps into Jolt with a debugger; the bridge’s own bugs are in the Kotlin above it, which is built normally
43 Everything upstream of the engine does its trigonometry with StrictMath, through simulation/api’s Exact Math.sin is only required to land within one ulp of the true result and is free to be a hardware intrinsic, so two runtimes may both be right and disagree in the last bit. That bit is a die’s starting quaternion or a corner of the hull the engine collides, and a hundred steps of contacts later it is a different face — which would make the golden suite’s recorded outcomes true of the machine that recorded them and nothing else. StrictMath is fdlibm and has no such freedom. No divergence was observed between the two on the JVM or on either ABI; what was removed is the licence to diverge, which is not something a test can be written against after the fact. sqrt and the four operators are correctly rounded by IEEE 754 and are left alone
44 The golden determinism suite is split where the engine begins, and the two halves compile the same code for turning a case into a throw What CI can run and what only a device can run are different questions about the same roll: everything the engine is handed is Kotlin and belongs on the JVM, everything it did needs an ABI. Splitting it there means the part most likely to be changed by accident — a tuning constant, an extra random draw, a solid’s closed form — is caught on every pull request rather than on whoever next runs a phone. Both halves assert the same digest of the throw, which is what makes the JVM half evidence about a real roll rather than about itself; the moment the two disagree about what a case even is, that is the failure, and it is a louder one than a wrong face. The shared source set exists because a JVM copy and a device copy of that definition would be two suites, and their first divergence would look exactly like a physics bug
45 A die’s mesh is grouped onto simulation/api’s own face directions, and lives beside the shape catalogue’s atlas layout in core/model The mesh is the third description of a solid, after the hull the solver collides and the directions the reader reads, and decision 35 already says all three come from one construction. This is that rule carried out: a face of the mesh is not matched to a catalogue face afterwards, it is built by asking which corners lie on that face’s plane, so face i of the picture is face i of the roll by construction. A die whose printed face and scored face disagree looks exactly like the physics cheating, and it is the one accusation this app cannot answer. The atlas grid moved out of dicesets/format for the same reason: both the validator that checks an author’s image and the renderer that samples it have to mean the same grid, and a renderer that depended on a package validator to find out would be the wrong way round
46 Filament’s materials are compiled on the device with filamat-android, not by matc at build time Filament ships no default material: every surface needs one compiled from .mat source, and the two ways to get there are a host tool or the runtime compiler. matc would mean the devcontainer image and the CI action both gaining another pinned download, and the app build depending on a host binary — for a project whose whole build story is “it works in the container”, that is a real cost. filamat-android is one dependency line, supports Vulkan as well as OpenGL ES and optimises what it compiles. It is paid for in APK size, because it bundles a shader compiler, and in some work at launch. If either turns out to matter on the Pixel 10a, the material source does not change — only who compiles it. It also leaves the door open to a dice set bringing its own material rather than only its own parameters, which matc at build time would have closed for good — but that door stays shut in v1, because a shader is code and docs/dice-sets.md says the app never runs anything from a package (docs/TODO.md, After v1). Launch time did turn out to matter — 9.9 s of black tray on every cold start — and was answered without moving the compiler: it compiles for the engine’s own backend only, keeps the packet in codeCacheDir across launches, and makes the resin material for translucent dice on first use (decision 90; 0.8 s, 3.2 s after an update; docs/physics-and-rendering.md, “Rendering (normal mode)”)
47 render/filament draws through a Stage interface, and one file implements it The same line decision 40 draws through the physics, for the same reason and with the same shape. Which meshes a throw needs, how big each die is at the capacity rule’s scale, which numbers its material takes, when the camera stops framing the tray and starts framing the dice — all judgement, and none of it physics or GPU. Behind the seam a JVM test can say the dice were the right size, that the camera moved when they settled and that a second roll did not land on top of the first; in front of it a device can only say a frame was drawn. FilamentStage and FilamentEngine are the files that hold a context, and — with RollThread, the thread they are made on and the lifetime they are kept for (decision 50), and TrayDriver, which posts the roll onto that thread — the files sonar-project.properties excludes from SonarQube’s coverage figure, by name. The build’s own gate, verifyCoverage, draws the line coarser: it leaves the whole of render/filament (and of simulation/jolt) out, because those modules’ tests cannot run where it runs, and excludes by name only the device-only stragglers in modules it does measure (DiceTray.kt, RollWiring.kt). They are split along what a surface owns: a swap chain and a viewport die with the surface they were made from, while the engine and the material compiled on the device do not — rebuilding those for every rotation is a recompile the player watches as a black tray
48 A roll in progress is a LiveRoll: the loop steps one step at a time, and a FrameClock decides when. Power-saving mode is the same object with nobody calling the clock The loop used to run to completion in one call, which meant a rendered roll could only be a second implementation of it — and two implementations of “the physics result is the roll” is one too many (goal 1). Splitting the loop at the step it was already taking costs nothing and buys the claim outright: normal mode asks for the time since the last frame, power-saving asks for the lot, and underneath it is one loop over one world taking the same steps in the same order. The clock is the other half. Handing a frame time to a solver would make the roll depend on the panel, the thermal state and whether the app was backgrounded, so the frame time stops at the clock: it is cut into whole fixed steps and the remainder becomes the moment a renderer interpolates at. That is also why a slow frame drops simulated time and never a step — the roll is unchanged, it simply arrives later. The dependency runs simulation/jolt → render/headless, the direction the data-flow diagram already showed: a renderer is handed frames and has no way back
49 The physics and the Filament engine share one thread, driven by that thread’s own Choreographer The design started with a simulation thread publishing transforms to a render thread through a lock-free double-buffer. Written down, the render side turns out to have exactly one thing it can do with a transform, which is draw it — so the buffer would be eighty entries copied across a boundary neither side wanted, and a class of bug (torn reads, a frame drawn from two different steps, a stage closed while the other thread is mid-draw) bought in exchange for overlapping a copy with a draw. Filament also insists every engine call comes from the thread that made the engine, and the physics world is single-threaded for determinism, so both halves already wanted one owner each; giving them the same owner removes the hand-off rather than synchronising it. The thread is still not the main one — eighty convex bodies at 120 Hz does not belong where the UI is drawn. What it costs is that a long physics step delays that frame, which is the same trade the frame clock’s four-step catch-up cap already makes visible
50 The roll thread and the Filament engine on it outlive a visit to the roll screen; the physics world and the scene do not FilamentEngine already keeps the engine and the compiled material across every surface made from it, because compiling the dice material happens on the device for the driver that is actually there (decision 46) and costs long enough that rebuilding it per rotation was the black tray. A driver per visit put that cost straight back: leaving the roll screen for the menu and returning compiled the material again, and the player watched it happen. So the line is drawn one level further out — what a visit owns is a roll, and a roll the player walked away from never landed, so the world and the scene still go. The thread is kept with the engine rather than instead of it, because Filament only takes calls from the thread that made the engine, and an engine outliving its thread is an engine nothing may touch. What it costs is an idle thread and one engine held while the player is on another screen, against a black tray every time they come back
51 A die’s printed numbers are a signed distance field built on the phone, from outlines generated at build time from a real typeface Three ways to get a number onto a face, and only one of them survives being looked at closely. Live text renders in whatever font the device happens to have, which makes a die a different die on a different phone. A rasterised atlas is a picture of a digit at one size, and the whole point of the pinch is that the player chooses the size — four times in, a 64-pixel cell is a blur. A distance field is the shape rather than a picture of it: one byte per pixel saying how far that pixel is from the edge of the ink, and a smoothstep across one fragment’s worth of it recovers a crisp edge at any magnification. It costs one extra sampler and a build-time step that runs about once in the life of the project (tools/generate-font.py, the same generator the mark uses). The outlines are flattened to polygons there rather than kept as curves, because the field is built once per die and a cubic on the phone would buy arithmetic nobody can see. Which faces are printed, how big each number is on the face it is on, where it sits and which ones need a dot after them are all Kotlin over plain polygons, so all of it is tested on a JVM — the same line decisions 40 and 47 draw, in the same place and for the same reason. DieNumbers says what a die carries and core/glyphs’ LabelRoom says how much room a face has for it — asked about the polygon the mesh draws, which FaceRoom reads off the same texture coordinates the renderer samples, and asked again by the face designer about the polygon its canvas is masked into, so that a drawn die and a printed one put a 6 in the same place (docs/face-designer.md). The test that matters is the one that says no number, on any solid in the catalogue, reaches past the edge of the face it is printed on
52 Impacts are derived in Kotlin from the change in a die’s speed, not reported by the bridge; and there is one player over them with the clock as a parameter The same line decisions 40 and 47 draw, for the third time. What an impact is — how hard is worth feeling, what counts as a hit rather than a slide, how many of a hundred simultaneous ones a phone can play — is judgement, and none of it is physics. Deriving it from the scalar speed the bridge already reports also keeps the wire format still: a velocity vector per die per step is three more floats crossing JNI a hundred and forty thousand times a roll, for something the scalar says. The subtraction is what makes it honest — gravity can change a free die’s speed by one step’s worth of its own acceleration and no more, so what it does not explain is what something else did, and a die sliding or at rest therefore reports nothing without a rule saying so. The second half is the same argument as decision 48 one level up: normal mode and power-saving mode are one list of impacts with a different spread over it, not two players, so “the recorded impacts are played back over about a second” cannot drift from what a watched tray does. It costs one branch per die per step when nothing is listening, and nothing at all when something is
53 The device harness’s arithmetic — what a run was asked for, what its rolls added up to, and whether they met Step 5’s targets — is a plain Kotlin module, and the instrumented test only rolls, times and writes It is decision 40 applied to the thing that judges the physics rather than to the physics. A harness whose own percentile, whose own share of dice corrected and whose own pass/fail comparison can be checked only by running it on a phone is a harness nobody can trust: when it says a run failed, the first question is whether the run failed or the harness did, and there would be no way to answer it. Split here and the answer is a JVM test — including the boundary cases a phone would have to misbehave to produce, like a correction landing on a die at rest. The same split is what lets the shell script print a table it did not render: the device writes the table the Kotlin produced, and a second copy of the comparison written in awk cannot drift from the one the tests hold
54 A die an explosion or a reroll adds is thrown into a world of its own, into the clear floor the settled dice leave, and drawn among them Three rules meet here and only one arrangement keeps all three. The result must be the physics, so the added die is really simulated. Nothing may touch a die that has come to rest, so the settled dice cannot be bodies in that throw — a die dropped onto them would shove them, and a face the player has already read would change, which is the failure this project cares most about. And the player has to see it happen, so it cannot stay in the tray nobody is looking at. Putting the settled dice in as immovable furniture would need the native side to grow a second kind of body, untestable on the JVM and unverifiable without a phone; leaving them out entirely costs nothing and makes the rule true by construction rather than by tuning — there is no body in that world to shove. What is left is the picture, and ClearSpace answers it above the bridge, where a test can reach it: the point of the tray furthest from every die already down, on a fixed grid so the same roll replays to itself. The residue is honest and small — a die that rolls a long way could still be drawn crossing a settled one, which is why it is dropped rather than thrown, and why the drop needs eyes on a phone (docs/TODO.md, Step 5.6)
55 A drawn face becomes an atlas in two halves: plain Kotlin decides what goes where, and one file puts the pixels down The same line decisions 40, 47 and 51 draw, in the same place and for the same reason. How big the image is, which cell a face occupies, where every point of every mark lands in it and which cells are left out so they stay transparent are all arithmetic, and all of it can be wrong; Bitmap, Canvas and the PNG encoder cannot be wrong, only unavailable. So Atlas is a plan a JVM test asserts on — including that no catalogue shape passes the 2048-pixel texture limit and that every cell comes out exactly square — and AtlasPainter is an interface with one file behind it, which Robolectric’s native graphics still exercises a tier below a device. It buys the failure mode too: a painter that cannot allocate answers with nothing, the die loses its artwork and keeps its labels, and the package still installs. The other half of the decision is that the package the app writes goes through DiceSetValidator before it is put in dicesets/ and again before its zip is offered to anybody. The app’s own output is not a privileged path, exactly as the bundled set is not — and it means dicesets/format is tested against a second writer rather than only against its own fixtures
56 The debug overlay is a Compose plan of the tray, not lines drawn behind Stage; and the anomaly log lives in memory and shares by itself The overlay’s whole job is to say what the rendered picture cannot — which die is standing on another, which is against a wall, which has not stopped — and none of that is a projection problem. Registering a wireframe to the dice would mean reproducing the perspective camera, the pinch and the pan on the far side of Stage, where no JVM test can reach, in order to draw outlines over pictures that already show where the dice are. Drawn as a plan in Compose it costs no GPU code at all, and the one piece of judgement in it — millimetres to a place on the plan — is TrayPlan, plain Kotlin with a unit test, the same line decisions 40, 47 and 52 draw. The log is the same argument about storage: an anomaly carries the seed that reproduces it, so a stored one is a replay waiting to be written into a screen a player can reach, which is exactly what decision 13 forbids. In memory it is bounded, it goes when the app does, and it is shared as text from the developer screen — never with the statistics export, whose files are built from HistoryEntry and have no seed to leak
57 A harness target that nothing was measured for is a third outcome — not measured — rather than a pass, and the figure behind it is absent from the document rather than zero Step 5.7’s bar is about drawing, and a headless run draws nothing. The two ways to score it with two outcomes are both wrong: “pass” is the harness claiming a frame rate nobody saw, and “FAIL” stops every run that was never about frames. The third outcome is also what makes the partial measurement sayable — a paced run times the simulation half of a frame, prints it, and says it is a half — and it keeps the verdict line honest, because a run that passed with a gap in it says how big the gap was. Zero would be worse than either: it is not a missing measurement but the best one there could be, and it would score as such
58 A die’s artwork is addressed by a key of package and path, decoded outside the renderer, and cached on the Filament engine Three separate things forced the shape. A texture is relative to its own set’s folder, so the path alone names nothing — two packages may both ship textures/d20.png — and the renderer therefore needs the set id, which is why DieAtRest now carries one and why it is not defaulted: a die drawn against the wrong package wears somebody else’s picture, and that is not a thing to get by forgetting an argument. render/filament may not read a disk, so the key crosses (String) -> Texture? and :app joins it to dicesets/install — the same seam, and the same reason, as decisions 40 and 47. And a Texture is a native handle, so where it is cached is the whole of whether it leaks: a decoded atlas belongs to a package, which outlives every surface and every visit, so it is held beside the material compiled on the device (decision 50) and given back with it. The key’s separator is :: rather than / so that a path arriving with no package in front of it — which is what a table look’s floor texture was until decision 92 — is refused instead of being read as a package called textures
59 Every die is printed, and its artwork is composited over the printing by alpha (which faces are printed is now per face — decision 96) docs/dice-sets.md has always promised that an atlas may leave a cell transparent and the label shows through, and the old material could not keep it: baseColor *= atlas over a transparent pixel is black, not the die, and the printed field was suppressed for any die with a texture at all. Deciding it per cell instead would mean the renderer knowing which cells came out empty, which is a fact about pixels that live on the far side of Stage — so it is decided per pixel, in the material, where the alpha already is. The cost is a distance field built for dice that may not need one, which is cached per die and is eighty kilobytes; what it buys is that a die with no artwork and a die whose artwork covers every face are the same code path with different alpha, rather than two. Where the artwork is opaque the result is the old multiply exactly, which is what keeps a table’s floor tinted by its floor colour
60 The table picker’s thumbnails are drawn by the roll screen’s renderer, on the roll screen’s thread and engine; everything that decides what one is a picture of is plain Kotlin It is the first thing in the app to want the renderer somewhere that is not the tray, and there were three ways to get it and only one that keeps the promises already made. A private engine on the main thread is simply wrong — Filament takes calls only from the thread that made the engine (decision 49). A second engine on a second thread works and pays, again, the cost decision 50 exists to avoid: the dice material is compiled on the device for the driver that is actually there (decision 46) and takes long enough to watch, so a second one is that compile twice over and two graphics contexts held for one app. RollThread already outlives every visit to every screen and already has one engine on it, and a handler serialises what reaches it, so the picker posts. What a thumbnail owns is a swap chain and a scene, and both are given back before the post returns. The other half is decision 40 and 47 applied again: how big a picture is, what tray it is a tray of, where the camera stands, which face of the die is up, how high that leaves the die sitting, which looks are kept and which are dropped are all arithmetic and judgement, and all of them fail as a slightly odd picture rather than as an error — so all of them are ThumbnailPlan and ThumbnailCache, with JVM tests, and only the draw call and the buffer of pixels are behind Stage. The seam back out is the same one TablePhotos uses: feature/tables asks for a picture of a look and cannot name an engine, render/filament draws a frame and cannot name a bitmap, and :app joins them. The fallback is the screen’s existing swatch, kept for exactly that — an engine that will not open, a driver that will not read a frame back, and power-saving mode, which promises that no engine is created at all
61 Every word a screen says is a string resource, and a check of the project’s own — not Android Lint’s — is what keeps it that way Lint has the rule and cannot apply it: HardcodedText reads layout XML, and there is no layout in this app to read. Left at that, “nothing prevents a translation” would be a claim maintained by whoever last remembered it, which is the kind of rule that decays quietly — a caption typed into a Text( is invisible in review and invisible in CI. So the rule is enforced by verifyTextIsAResource, which reads what a composable is handed. It is a heuristic over source text rather than a type-resolved analysis, and that shapes what it asks: only the handful of call sites that put words on a screen, and only literals with words of their own in them, so that a test tag, a route, a require message and "%.1f" are all left alone. The gaps are the two plain-Kotlin modules that write English on purpose — the notation reference beside its parser, and the validator that may not depend on Android — and they are exempted by file name in their own build scripts rather than by a directory nothing looks in, so the hole stays visible and small
62 Which die a finger is on is arithmetic in render/filament, the inverse of the camera; whether that die may be thrown again is arithmetic in core/notation, over the breakdown The same line decisions 40 and 47 draw, applied to the only gesture the tray had left. A touch point becomes a die by a ray through the frustum TrayCamera framed, against the ball around each die at the scale the capacity rule threw it — so it belongs beside that camera, where a JVM test can project a die through the picture it was drawn in and ask for it back, and not inside a pointerInput lambda where the only test is a person tapping a phone and the only symptom is a die they did not touch. The second half is a different question and deliberately not in the same place: may a die be thrown again is about the formula, not about the physics or the picture. A die that another die was thrown because of is spent — 8d6! threw a seventh die because the sixth came up six — and throwing it again would leave the roll holding a die nothing asks for, which can only be resolved by taking a die off the table or keeping one whose reason has gone. Both are the app moving dice behind the player, which is what the whole stacking ladder exists to avoid. So the rule sits beside GroupRoller, which is what builds the chains, and it is coarse on purpose: a group carrying ! or r n offers nothing at all rather than a per-die guess reconstructed from a flat list the chains were flattened out of. A die it refuses is a die the player throws again by shaking the whole roll again; a die it wrongly allowed would be a roll the app had rearranged
63 There is one colour picker, in ui/common, and the arithmetic under it is in core/model Three screens ask for a colour and the sheet had been written twice, each copy private to its feature module, each with its own slider row and its own duplicate of what a hue is — and the second copy’s own comment already said that two pickers disagreeing about a hue would be one too many, which is a note somebody wrote instead of fixing it. The rule for ui/common answers it exactly: more than one screen needs it, and it needs no screen. What forced the second half is the dependency arrow — ui/common may not depend on a feature’s engine, so the arithmetic could not stay in :designer and moving it up was the only direction left. core/model is where it belongs anyway: it sits beside AccentRamp and Contrast, which are the other things every picked colour goes through, it is plain Kotlin so a JVM test holds what a hue is rather than a Robolectric one, and it is low enough that nothing has to take a screen’s dependency to write a colour down. feature/settings had taken :designer for the arithmetic alone and no longer depends on it; feature/designer’s alias of ui/common’s Ink went with the name clash that forced it. The tags are derived from one string rather than declared seven times per screen, because twenty-one constants is twenty-one chances to spell a suffix two ways
64 The dice waiting to be thrown fall onto the board, and the fall is closed-form arithmetic in simulation/api rather than a second physics world Superseded by 67. The player asked to see a die land when they add one, and the obvious way to get that is to simulate it — which is the one thing this app must not do twice. A real world for the pre-throw board would be a second place a face could come from, kept out of the score by a promise rather than by construction, and it would want a thread, a lifecycle and a device to test on, with eight of them queued behind somebody tapping out 8d6. FallingIn has none of that: free fall, three bounces and an eased turn, evaluated wherever the frame callback asks, with every fall ending at Quaternion.Identity — the resting orientation is fixed before the die is let go, so there is no face to read even in principle. It ends exactly where RestingPlaces always put the die, so the board is the board it always was; its randomness comes through Seeds.WAITING, a purpose no throw uses, so the golden fixture does not move; and it is all on the near side of Stage, so it is tested on a JVM (docs/physics-and-rendering.md, “The dice waiting to be thrown”)
65 A watched roll is paced: RollPace scales a frame’s real time before FrameClock turns it into steps, and the factor is one constant applied in TrayLoop.frame The dice are too fast to watch and the physics cannot fix it — friction moves the median settle of 20d20 on the Pixel 10a only from 0.73 s to 0.85 s, and the top of that range pushes the dice into one another. Two seconds of tumbling is energy a hand does not put into dice. So the same roll is shown over more wall clock: same seed, same steps, same order, same faces, nothing reaching the solver. It lives at the one line where real time becomes simulated time, which is also the line power-saving mode never crosses — that mode has no screen, so it must not be paced, and a place it cannot reach is a better guarantee than a flag it must clear. It is off while WatchedRoll.driven, which is ShakeDriver.stillShaking, so the part of a roll a hand is steering is exactly the part that is not slowed; reusing the roll’s own predicate is what stops the two ever disagreeing. Placing it here rather than inside FrameClock also keeps the clock’s contract untouched: it still says nothing about wall clocks and still reports dropped steps and interpolation in the time it was actually given. The factor is RollPace.WATCHED = 0.4: it started at 0.5, and the owner, judging the livelier tumble on the Pixel 10a, found the spin right and the travel still a little fast — 0.4 shows the median 20d20 over 2.4 s rather than 1.9 s and keeps 1d20 at about two
66 A shake is the only way to start a roll. No key, no accessibility action, no tap and no setting to turn it off The owner’s call: “only a shake starts the roll, not a button or tap on the screen.” Two non-shake throws had survived the Roll button — the formula editor’s action key and the table’s custom accessibility action, Throw the dice — and both are gone: the key says Done and only closes the editor, and the table keeps its spoken description and carries no action. A tap on the table, already inert, is recorded as decided: no, which leaves the one-finger touch free for picking a die up. The Shake to roll switch in Settings went with them, because with shake the only way in, switching the sensors off would leave an app that cannot roll; AppSettings.shakeToRoll is gone and an old install’s shake_to_roll key is left on disk, unread. The consequence is accepted, not overlooked: a TalkBack user who cannot also shake the phone, and anybody else who cannot shake one, has no way to start a roll (“Accessibility”). Tests still need to shake, so feature/roll’s ShakeInput is where ShakeToRoll gets its shakes — the sensors in every build — and a test installs a TestHand there, which reaches the presenter by the same onStarted → roll and onSample → shaking calls the sensors make. It is process-wide because the device tests launch MainActivity as it ships and cannot hand a composable anything; nothing a player can reach sets it
67 The dice waiting to be thrown drop under real physics, simulated once off the roll thread and played back; this supersedes decision 64 The scripted fall of decision 64 still put every die in a laid-out spot, square on, and a player said what that looks like: dice set out with equal space, not dropped. Only a simulation makes a die come down somewhere, tumble and knock its neighbour. What decision 64 feared is kept out by structure instead of by refusing the engine: BoardSettler returns a BoardTrack, which is poses and nothing else, the board code never touches FaceReader, nothing on the board is scored, and the next shake throws every die from its own spawn exactly as before — so the golden fixture and every throw are unchanged. The board’s numbers come from Seeds.waiting(board, die) under Seeds.WAITING, a purpose no throw uses. Each board change opens a short-lived headless Jolt world on a per-visit board thread, steps it to rest (or three seconds), records every pose and closes the world before returning, so no world outlives the call and none is shared with a roll; playback is arithmetic over the recording, frame-rate independent, and wants frames only while it plays. A board die nothing touched is recorded exactly where it stood, which is a drawing decision, not a force. The sequencing — numbered requests built from what is on screen, stale drops dropped, a throw cancelling the pending board — lives in TrayLoop and TrayRenderer, so it is tested on a JVM; the drop itself (settleOn) is plain Kotlin over PhysicsWorld and tested against a fake world, and the device suite measures what a real one does (docs/physics-and-rendering.md, “The dice waiting to be thrown”)
68 A die picked up by hand is thrown by a shake. A finger on a die only marks it picked; the next shake throws the picked dice, and nothing else, through ThrowSpec.among The owner’s call: “only the shake throws the die”. A finger that threw would be the one throw no shake started, an exception to decision 66 in the one place a player is most tempted to throw on a whim. Picking with the finger keeps the gesture the camera leaves free (one finger, unclaimed) and keeps every throw a hand moving the phone; a shake with nothing picked throws the whole roll again, as it always has. How a picked die is drawn and how a pick is undone are left to the change that wires it (docs/TODO.md, 4.1)
69 Dice added to the board leave one spot, one after another. Every new die is let go over the middle of the tray (BoardDrops.DROP_SPOT, within 1.5 mm), and several added at once are let go 0.1 s apart (DROP_INTERVAL_SECONDS, at most 4 s for the whole stream), inside the one precomputed board world: a die is a body from the start, parked under the tray until its step, then put over the spot once with respawn and drawn from that step on (BoardTrack.inPlay, Stage.put) A player said random spots made the added dice hard to keep track of. One spot is what the eye can follow, and a stream from it spreads out by bouncing off what is already down, which is physics (decision 67). Parking rather than adding a body mid-run keeps the native bridge as it is — nativeAddDie before nativeFinish, nativeRespawn already there — so no bridge change needs a device to prove it; the drop stays one deterministic recording and the renderer keeps playing arithmetic over it. A column of dice stacked over the spot was the other cheap way to stagger arrivals, and the 200 mm lid would have cut it at a handful. Where a die starts is decided at its step against the dice in play then, in three dimensions, lifted over anything in the way — a die is never skipped, and only goes elsewhere (CrowdedFloor.spot) when the lift would reach the lid. The board’s cap counts from the last die let go (MOST_BOARD_STEPS), and LONGEST_STREAM_SECONDS keeps a hundred dice from being a ten-second wait and a ten-second simulation before anything moves (docs/physics-and-rendering.md, “The dice waiting to be thrown”)
70 Dice that cannot be read wait for the player’s shake. A throw that leaves dice cocked or standing on another stops: the read dice stay read, the unread ones lie where they fell, and the next shake throws those and only those, through ThrowSpec.among; the 12-second backstop is per pass The owner’s call, from the Pixel 10a: “the re-roll of stuck dice still happens automatically, but should be initiated by the user’s shake event.” The automatic re-throw was honest — visible, physical, never a nudge — but it put dice in the air that no hand had thrown, which is decision 66’s rule broken by the app rather than by a button. The re-throw is therefore the throw an explosion already makes: a world of its own, seeded from the throw’s seed and the pass number (Seeds.again, a purpose of its own so it never shares a stream with an explosion), with the dice still down carried as among and the dice the last pass read lifted by the throw rather than before it — so the player looks at the heap as it lies until they shake. The bookkeeping is one plain-Kotlin class in simulation/api, Passes, shared by the roll screen and by every headless caller, which follows each pass at once with no shake (Passes.scripted, at most 16 passes) — so the harness still measures the share of dice that needed another throw, and the golden cases, none of which re-throws, did not move. A throw that leaves dice unread is scored, and a chain earns its next die, only once every die has a face, which orders the two waits without a rule of its own. The cost: a cocked die is a second shake, at about 3 % of dice on the Pixel 10a; the overlay’s re-throw count is nought within a throw; and SpawnLayout.rethrowPlacement and PhysicsWorld.respawn/remove have no caller left in a roll
72 The frame-rate readout is a line on the debug overlay, behind the developer toggle, not a Settings row of its own. It shows frames per second and the p99 frame time over the last 120 frames the tray drew back to back, measured as the time between two consecutive Choreographer frame callbacks in TrayLoop and summed up by FrameMeter in simulation/api; the overlay also shows the roll’s and the visit’s dropped steps Step 5.7 asked for “a setting that shows the frame rate” and left where open. The conservative answer is the switch that already exists: a frame rate is a figure for whoever is checking the 60 fps target, not a choice a player makes, and a Settings row would be one more thing every player scrolls past for a number they cannot act on. Behind the toggle it inherits the toggle’s promise — off on every install and free when off, because DebugWatch.watching is asked before a frame is timed — and its relay, so nothing new crosses a thread. Frame intervals rather than the work inside a frame, because the interval is what a person holding the phone sees and includes Filament’s half, which the headless harness cannot time (FrameTimes.drawn); only frames that followed a frame asking for them are counted, so a still tray does not read as a stutter. A count of frames rather than a stretch of time, so the p99 is over the same 120 samples at 60 Hz and 120 Hz. The visit’s dropped steps are told apart by the snapshots alone (DroppedTally: a roll’s steps and dropped steps only grow), which keeps the overlay a watcher the tray does not have to tell when a roll begins. The instrumented harness that puts drawn frames in a report is decision 80
73 The face histogram names a face by the die’s own label; the files keep the value. The bars, the natural-high and natural-low tiles and what TalkBack says use the label the installed die prints a value with, looked up at read time (FaceLabels); a blank label is said as “blank” (a string resource). A value printed two ways, a die whose set is uninstalled, and a value pooled sets print differently fall back to the number. The statistics CSV and JSON exports keep the numeric value and gain no label column A dF read -1, 0, 1 and TalkBack said “Face -1” (docs/TODO.md, 4.8). Looking the label up rather than storing it keeps the database as it is and shows a relabelled set’s labels over its old counts; nothing else could be said of a set that is gone. The files are read by machines: a value is what adds up and stays stable across relabelling and uninstalling, and a label taken from the installed set at export time could be a different print from the one the die was thrown with — so a label column would be neither trivial nor true. The history export already carries each die’s label as recorded with the roll. The most conservative choice for a value with two prints is the number both faces score, rather than picking one of them
74 While the first-launch welcome is up, the shake is not listened to. ShakeToRoll registers no hand at all while the welcome covers the tray, and registers it the moment the welcome is pressed past; nothing else about the tray behind it changes A roll could start under the welcome — a formula reaches the field while it is up (a saved roll opened on the way back from an import, which does not dismiss it) and a shake threw it — and the result sheet was then drawn under the welcome’s buttons. There were two ways out, and the other one, drawing no result behind it, is worse: the dice would still be thrown, read and written into the history by a roll nobody saw land, and the first thing the tray showed after the welcome would be a total for a throw the player never watched. Not listening is the conservative half — no roll, no history entry, no result to hide, nothing to reconcile when the welcome goes — and it costs a shake made while reading a screen whose buttons are the way on. Not ignored but not registered, so the sensors are not running behind it either, and what is typed while it is up stays typed: the first shake after it throws that formula, as on any other ready tray (docs/physics-and-rendering.md, “What is drawn over the table”)
75 A set’s own die is named in braces, with its set inside them, and the picker row offers it. 3{skull-d6}kh1, 3{brass:skull-d6}kh1, 3{skull:d6}kh1 (docs/dice-notation.md, “A set’s own dice”). The set goes inside the braces and only there — brass:3{skull-d6} is refused with a message saying where it goes. A braced id is exact: {d100} is a die called d100, never the percentile pair, and {d6} is not what the d6 button counts. Unqualified, it takes the default set’s per-die fallback like any plain die; qualified, none. Ids are capped at 40 characters, the longest a set may define. The picker row offers the standard dice and then the set’s own, a set’s own die written braced — 1{skull-d6}, 1{brass:skull-d6} — unless plain notation already names exactly that die (d3 → 1d3; never d100), and drawn by its solid’s face count; the face designer’s Roll it writes the same spelling Decision 31’s gap, closed the way docs/TODO.md had decided: the } ends the id, so the parser lexes it without asking any set and the field still validates on a thread with no storage, while DieResolver decides whether the die exists. The rest are the small calls building it needed, each the narrower option. One place for the set gives each die one spelling, which is what lets the badges read a tap back (DicePicker); exact ids keep d100 meaning one thing in plain notation and another only when braced, never by guessing; the fallback rule is the existing one rather than a third. Writing d3 plainly is what a player would type, and the plain spelling resolves to exactly that die, so there is nothing for braces to disambiguate. Nothing already written changes: braces were a parse error before, so every stored formula, history entry and collection file reads as it did
76 A picked die is ringed over the picture, and a second tap on it puts it back. Under a total, a one-finger tap on a die PickUp allows marks it picked; a tap on a picked die un-picks it. The mark is a ring drawn in Compose over the tray, round the ball TrayPick reads a finger against (TrayPick.markOf), in two inks — a broad ring in the plates’ ground with a narrow one in their ink — and the tray’s spoken description says how many are picked and that a shake throws them, announced while any are. A pick that would need more clear floor than the tray has is refused. The roll is then written down once: a throw by hand amends the row it was first recorded as, counting only the dice thrown again Decision 68 left both open, and the owner was not asked: these are the conservative answers. A ring over the surface draws nothing into the scene, so the renderer still draws what the simulation says and nothing else, and it is placed by the same frustum as the finger, so what is ringed is exactly what a second touch reaches; tinting the die in the scene would be a material change only a phone can check. Two inks rather than the accent because accent never touches felt (docs/physics-and-rendering.md, “What is drawn over the table”) and one ink is a colour some table swallows — superseded by decision 84, which rings it in the accent on a halo of the ground and announces a pick through the shake prompt rather than the tray’s description. A second tap is the undo a player tries first and needs no control of its own; putting the result away or editing the formula drops every pick too. Refusing a pick the tray has no floor for is TrayFull’s rule, met before the shake rather than after it. Amending rather than adding a row keeps “one total for the roll” and “every throw of the die” both true. The cost: a TalkBack user cannot aim a tap at a die, so picking is a sighted gesture, as the shake is a physical one (decision 66); and whether the ring reads on every felt is the owner’s to judge (docs/TODO.md, 4.1)
77 The solver keeps two collision steps per 1/120 s step; four and eight were measured and not adopted. World::Step passes 2 to PhysicsSystem::Update, as before Step 5.4 asked whether more collision sub-steps fix die-into-die overlap now that the throw is livelier (60–120 rad/s, restitution 0.55). On the Pixel 10a, 20d20 over seeds 1–3 (11,000 rolls a count) and 60d20 over 1,000: eight steps take the deepest overlap from 6.9–7.9 mm to 2.5–2.7 mm (four: 4.7–5.0 mm), leave the shaken-throw heap where it was (7 of 16 seeds clustered at 2, 4 and 8, so the packing the old ladder showed is gone) and the turns after landing where they were (2.7 → 2.8), and keep the p99 step at 0.4 ms (1.6 ms at sixty dice) against 8.33. But on every seed they settle a little later — median 0.95–0.96 s → 0.98 s, p99 1.94–1.96 s → 2.00–2.03 s, and 1.67 s → 1.75 s median at sixty dice — and at sixty dice re-throw 2.87 % against 2.62 %, so settle regresses beyond run-to-run noise, which was the rule for adopting. Eight still misses the 0.2 mm bar by twelve times, and changing the count moves every golden case and wants the 20,000-a-shape fairness run again; the conservative call is to keep what is recorded and validated. Whether the overlap is visible on the tray at all is the owner’s judgement (docs/TODO.md, 5.4); if it is, eight is the measured candidate (docs/physics-and-rendering.md, “What a shake’s spread currently rests on”)
78 A row picked up inside the saved-rolls list’s edge band does not scroll the list until the finger moves towards that edge. The band’s inner boundary for a drag is wherever the drag began, if that is nearer the edge, and the speed ramps up from there (SavedEdgeScroll.speed) The rows a thumb most often picks up are the first and last ones shown, and both sit inside a band. Scrolling the moment one is picked up would move the list under a finger that has not asked for it, and a player who picked a row up to look at it, or to move it one place, would lose their place. Waiting for a move towards the edge is the conservative reading: it never scrolls when nobody meant it to, and costs a few millimetres of travel when they did. Taken while the owner was away; the tuning constants (band 64 dp, 640 dp/s) are a judgement item in docs/TODO.md, not part of this decision
79 More than one personal set: “My dice” stays where it is, and a named set is new records beside it. The Save sheet’s New set… makes a set from a name of 1–40 characters; its id is the name with accents dropped, lower-cased, every run of non-letters-or-digits one hyphen, trimmed and cut to 40 (PersonalSetId), and must pass the set.id slug rule (3–40). An id the bundled set, mine, an installed package or another named set has is refused with the id named, never merged. A named set keeps its name, its own drafts and its own weight under filesDir/personal-sets/<id>/, builds dicesets/<id>/ through the validator like mine, starts at the default (average) weight, translucency and size, has no photo tables, stays listed when empty, exports as <id>.zip, and takes its records with it when removed. The canvas keeps saving into filesDir/drafts/, which is still what mine is built from; Save into a named set writes a copy of the canvas’s drawing into that set’s drafts The most conservative step that gives each set an id, a folder, drafts, an export and a weight of its own: nothing of an existing install moves, is renamed or is rewritten, so an update cannot lose a drawing or a weight, and a phone that never names a set never gets the folder. Ids from names, refused on a clash, follow decision 15 (refuse rather than merge) and keep notation’s setref a word the player chose. Keeping the canvas on one drafts folder leaves the designer exactly as it was and “My dice” as everything drawn on the phone; whether a drawing saved into a named set should also stay in “My dice” is left to the owner (docs/TODO.md, 4.6)
80 The rendered harness scores the work of a frame — simulation and draw on the roll thread — and, as a row of its own, the GPU’s time from Filament’s frame history; the frame rate is reported, not scored. It runs the shipping path onto a screen-sized ImageReader surface, in render/filament’s device tests, and scores the frame rows only. RenderedHarnessTest throws through TrayDriver on a RollThread with the roll screen’s engine, so TrayLoop paces each frame by RollPace as the screen does; two wrappers time LiveRoll.advance (which steps, places and calls Filament’s beginFrame/render/endFrame) and read FilamentStage.gpuFrames after each draw. RenderedFrames and HarnessTargets.scoreFrames in :simulation:harness hold every figure and bar. Unasked, it runs one unscored roll; harness.rolls asks for a scored run, which tools/harness.sh --rendered makes, twenty rolls by default Step 5.7’s bar is a frame time, and a frame on the phone is three things: the roll thread’s work, the GPU’s, and the vsync both wait for. The interval between frame callbacks is what a person sees, but on a 60 Hz panel it is 16.7 ms by construction — over a 16.6 ms bar whatever the phone does — so it cannot be the scored figure; it is read through the overlay’s FrameMeter and printed. The work is what the app controls and is the same figure the paced headless run measures, now with the draw in it, so the two runs compare. The GPU works a frame behind, so its time is scored separately against the same budget rather than added; a driver without timer queries reports none, which is not measured (decision 57), never zero. A surface the size of the whole display rather than the tray’s share is the most pixels the tray could ask the GPU for, so a pass there is a pass on the screen. Only the frame rows are scored because twenty watched rolls are too few for the physics bars, and the plain harness already scores them. Not measured, and said so: the Filament driver thread’s CPU time, the compositor and display latency (an ImageReader is not composited), playing impacts, and building the scene when a throw starts, which happens between frames. The cost of the layout: render/filament’s test APK carries Jolt, a test-only edge that goes one way
81 The tray’s rounded corners are fillets — six flat convex wedges to the quarter, at the points TrayMesh draws — and not a cylinder; the drilling friction a die pinched at two points lacks is left open. World::AddCornerFillet builds, in each corner, the solid between the two walls and a 12 mm quarter circle tangent to both, as a fan of kCornerSegments (6, the renderer’s CORNER_SEGMENTS) triangular prisms from the tray’s corner to the arc, floor to ceiling The two 60d20 rolls in 10,000 that never settled (docs/TODO.md, 5.5) were each one die leaning on the corner cylinder and a neighbour, rocking or trembling to the cap while 59 dice had stopped dead — and the cylinder was not the rounded corner docs/tables.md describes but a post 24 mm across on floor the mesh draws as open, so a die could lean on nothing visible. Three ways to stop it were tried on the frozen scene of roll 8091 on the Pixel 10a: restitution only above 50 mm/s and doubled solver iterations did not, a flat-faced post did, in eight steps. Of the two flat shapes the fillet is the one that is the tray as drawn and as specified; a faceted post would have kept an invisible obstacle. The settle rule was not loosened (the 7196 die swung 6 rad/s — it was moving) and nothing touches a die at rest: the change is the shape dice collide with, before and while they move. Over 50,000 rolls a count (seeds 1–5) give-ups at 60d20 went from 9 to 2 and re-throws from 2.64 % to 2.21 % (20d20: 1 to 2 give-ups, 1.21 % to 1.04 %), settle and turns unchanged; it moves every golden case, which were re-recorded, and FairnessTest was re-run at 20,000 a shape. What is left is a different mechanism, seen before and after: a die held at two single points spinning about the line through them, which Jolt’s point contacts cannot resist. Drilling friction, or more damping, would change the tumble of every roll, so it is a decision for the owner and is recorded rather than taken (docs/physics-and-rendering.md, “Why a die could rock for ever”)
82 The face designer clears the face in front of the player, in words, and never the whole die. Clear face is a lettered button in the row under the face strip, for every die, dead on a blank face; it takes everything the face holds — strokes, fills, stamped numerals, pips — off in one step that one press of undo returns, and leaves every other face untouched (Step.Clear, FaceDrawing.clear). Clear eyes (pips only, every face of a d6) is gone, and so is the cross (#ic-x) in the tool row that cleared the face before The owner’s feedback: the designer could clear the eyes but not the numbers, and a clear for the current face was what was wanted. The face clear already existed as the cross among the pens and was not found — a cross there reads as “close” or “cancel”, not “take everything off this face” — so the action is moved to words beside the other actions about faces, and one control replaces two rather than adding a third. Per face, because that is how undo works and so the clear can always be taken back; no “clear the die”, because leaving the designer or choosing another base die already starts again, and a twenty-face wipe in one press is the one control that could lose an evening to a stray thumb. Pips need no clear of their own once the face has one: undo takes off what Fill all with eyes put on (docs/face-designer.md, “Clear face”)
83 A throw clears the top, and a double tap clears the table. A shake that throws dice shuts both menus along the top and folds the formula’s tab into the shut dice pull-down, so the Dice head is the only control left along the top while the dice roll and after they land; the tab comes back only when the player opens the pull-down, and stays once it has. A double tap anywhere on the tray slides every pull-down, pull-up and edge tab out through its own edge, and the next double tap brings each back in the state it was left in; the plates that say what the roll is waiting for, the toast and the pick rings stay. A shake that throws ends a cleared table, with the top folded. A single tap is reported only once the platform’s double-tap timeout has passed without a second, so neither half of a double tap picks a die; a tap followed by a pinch is a tap and a pinch. The tray carries one custom accessibility action, Hide the controls / Show the controls. All four facts are one plain-Kotlin value, feature/roll’s Controls The owner’s two asks from the Pixel 10a: “after shaking, move the dice and formula buttons into a pull down menu and give an unblocked view to the table”, and “a double tap on the table should move all pull-up/pull-down slides away for a full view of the table; another double tap returns the sliders”. When the top comes back was left open; only when asked is the conservative answer, because a tab that slid back in when a total landed would be one more thing moving over the dice the player is looking at — and the folded head says so to TalkBack (“Open the dice and the formula”). Clearing is a flag over the other three rather than a reset of them, which is what “returns the sliders” asks for. The waiting plates stay because they say what the next shake will do and are not slides; a throw ends the clear view because a result that arrived behind it would be a total nobody saw, and a result never leaving the screen is the sheet’s own rule. Waiting out the timeout rather than picking at once and taking the pick back on the second tap is what Android’s own double-tap detection does: picking first would ring a die, announce it and un-ring it inside a third of a second. The cost is about 300 ms before a pick shows, which is the owner’s to judge (docs/TODO.md, 4.1). The action is not a throw and does not reopen decision 66; it exists because a double tap is a gesture a screen reader takes for itself. Power-saving mode has no table to clear and takes no double tap (docs/physics-and-rendering.md, “Clearing the table”)
84 What the next shake throws is a prompt over the tray, in the accent, for as long as it is owed; a picked die is ringed in the accent on a halo of the ground. ShakePrompt.of maps the roll’s state and the pick count to one of three prompts — dice nobody could read or that never stopped (“Shake to re-throw N dice”), dice a chain earned (“Shake to throw the N earned dice”), dice a finger picked (“Shake to throw the N picked dice”, with “Tap … again to put it back” under it) — and ShakePromptBanner draws it centred under the controls along the top: an accent fill, a drawn shaken phone and the words at titleLarge, behind --shadow-md, a polite live region, gone with the shake that answers it. It replaces the 2.6-second waiting toast, and the tray’s description is no longer a live region. PickRings draws an 8 dp ring in --color-bg with a 4 dp ring in the theme’s primary — the accent chosen in Settings — inside it The owner, from the Pixel 10a: the request to shake for cocked dice was “easy to miss”, the ring “should be the accent colour to make it pop out more”, and “I don’t see what I can do with a picked die”. One missing sentence answers both — the next shake throws these — so it is one component, said where the eye is when the dice stop rather than as a line along the bottom edge that left after 2.6 s. Filled in the accent like a primary button because it is the one thing to do next; the words are large text (20 sp, 800), which the accent as chosen clears at 3:1, and the smaller “put it back” line sits on the ground in the ink. The accent still never meets the felt directly: the banner is its own opaque fill, and the ring’s accent sits inside a band of ground either side, so the pairing to check is still accent-on---color-bg. The prompt, not the tray, is the live region, so a pick is announced once rather than twice. The cost: a band over the top of the felt while a shake is owed, which a die can land behind — the felt under it is the part the pull-down already crosses. Whether it reads, and whether the ring reads on every felt, is the owner’s to judge (docs/TODO.md, 4.1)
85 Dice of a later pass are not marked on the tray (withdrawn after building it) The design pass of 2026-09-17 outlines a later pass’s dice in accent-700 with a pass 2 label; it was built (#366) and taken out at the owner’s request after a session on the Pixel 10a. A re-throw lifts the dice the last pass read, so the table only ever holds the newest throw’s dice and the mark explained nothing the player could not see; and in the accent it competed with the shake prompt and the pick ring (decision 84), which are the marks that ask for something. The prototype’s mark is not ported
86 The face designer masks every cell into its own face’s polygon — the d10 and the d18 into two different kites, measured off simulation/api’s SolidFaces — and draft format 2 records that; a format-1 drawing on a kite die is carried onto its die’s kite when it is read. FaceOutline.Kite is split into PentagonalKite and EnneagonalKite, whose corners FaceShapes.kiteOf takes from the face itself: turned short tip up, long point down, 0.96 of the canvas long, centred. DraftFile.FORMAT is 2; format 1 is still read, and on a d10 or d18 its marks go through SharedKite.carried — the shared kite’s old cell fit followed by the inverse of the new one, a uniform size and shift (1.31× on a d10, 1.33× on a d18, stroke widths with it) One hand-drawn kite served both trapezohedra and was neither’s face, so FaceOnSolid.cellFitOf had to grow it until it covered the real one — 1.20× the best fit on a d10, 1.37× on a d18 — and a drawing reached the tray larger than drawn, spilling past every edge of the face and clipped at the tip. With the face’s own polygon the covering size is the best fit, the Solid tab and the exporter agree by construction, and the stamp and guide solve a number on the polygon the tray prints on. Changing what the canvas fractions mean on two dice is a change of meaning, which is what the format number is for (docs/face-designer.md, “The draft file”); re-masking old drafts in place would have cut a border drawn along the old kite’s edge off the narrower d18 face, and mapping kite onto kite corner for corner would have squashed every numeral. Carrying the drawing to where the old exporter put it is a similarity, so nothing is distorted and the die shows exactly what it showed. The cost: an older build opens a format-2 draft blank (and would overwrite it if somebody drew on it there), which is a downgrade nobody is offered; and the editor shows an old kite drawing larger than it was drawn, because that is the size the die always had it at
87 The pull of the wrist’s swing is taken out of a shake before it is recorded; nothing else about the shake is. SwingCorrection in input/shake fits, per shake from its first hard moment, where the sensor sits from the pivot it is swung about — least squares of a ≈ (ωωᵀ − ‖ω‖²I)·p over the shake so far, ω from the gyroscope, each sample weighted by the time it stands for and the answer damped towards no pivot (DAMPING, 20 (rad/s)⁴·s) and held within 300 mm — and ShakeSession subtracts ω × (ω × p) for the rate of turn now from every acceleration it records. The detector still hears the raw shake The owner, from the Pixel 10a: the dice gathered at the top of the phone whichever way it was shaken. Off the phone, through the real bridge built for the host, a shake that only carries the phone leaves the dice spread in every direction, and a swung one does not: a pivot below the sensor pulls it towards the hand by the square of the rate of turn, so every stroke both ways adds a pull towards the bottom of the phone, which inverted is a push up the tray — 45–55 % of the dice in the top third over 300 synthetic hands a direction, 74 % swung hard. The tray does not turn (ShakeDriver), so that pull is the one part of the motion it has no business feeling. Estimating the pivot rather than assuming one, because a grip is not a constant; tying the correction to the rate of turn rather than high-passing the acceleration, because a filter cannot tell a pull that ended from one that never was and would kick the dice the other way as the hand stops — this one takes nothing from a phone that has stopped turning. It is input shaping before the record, not a hand on the dice: the physics still decides everything and a roll replays from its record exactly as before, and no golden case moves because none goes through ShakeSession. After: 33–36 % in the top third, the mean position within ±0.04 of the middle in every direction. The cost: the first strokes of a shake, before the fit has seen enough turning, keep some of the pull, and the hands it was tuned on are synthetic — whether a real one spreads the dice is the owner’s to judge (docs/TODO.md, 5.3; docs/physics-and-rendering.md, “Shake input”)
88 A big roll’s tail is shown faster: for ten dice or more, the watched pace climbs to real speed as the dice are read — 0.4 while a quarter or more are still moving, 1.0 once a tenth or fewer are, a straight line between (RollPace.paceFor) The owner found 100d4 slow to settle. On the Pixel 10a it settles in a median 1.29 s and a p99 3.09 s of simulated time, which at 0.4 is 3.2 s and 7.7 s to watch, nearly all of the tail a handful of dice rocking among ninety still ones. Speeding up the tail changes only when steps are taken, so faces, fairness and goldens are untouched — unlike changing the physics. It replaces decision 65’s “one constant, no easing”: the climb is gradual so it does not read as a dropped frame, it stops at real speed so the last dice still move like dice, and small rolls (under ten dice) keep 0.4 to the end because there the last die is the roll
89 The tray is lit by a photographed room and photographed like a product. The image-based light is Poly Haven’s Brown Photostudio 02 (CC0, 1k equirectangular .hdr, 1.6 MB), shipped as a resource of render/filament, decoded by the module’s own Radiance reader (Radiance), folded into a 256-pixel cube on the panorama’s own mapping (StudioCube.faces) — on the first launch of a version only, then read back from codeCacheDir/studio (StudioCache) — and prefiltered by Filament’s Texture.generatePrefilterMipmap, once per engine, on the roll thread’s first draw (FilamentEngine.room); its three-band irradiance is nine numbers projected from the same file and written into StudioLight, where a JVM test re-projects the shipped file and checks them. The room is turned so its up is the tray’s +z and its window stands behind the key light, and its intensity is set so the felt receives from it exactly what it received from the gradient. There is one directional light, the key; the fill is added to the room’s irradiance as the three-band harmonics of a 25,000 lux lamp (FillLight). The view uses a linear tone mapper, an exposure that puts a white floor facing up at 0.95, set as f/16, 1/125 s and ISO 80 (TrayLighting), 4× MSAA instead of FXAA, and PCF shadows. No contact shadows and no ambient occlusion; the gradient (RoomLight) stays as the fallback when the panorama is missing or will not decode A die is lacquered, and lacquer mirrors whatever is around it: a sky-to-ground gradient has nothing in it to mirror, so the dice read as matte plastic under a grey sky in the baseline gallery. A real room puts a window and lamps in every polished face. The prefilter is borrowed — it is the genuinely complex part, and Filament’s CPU one is already in the engine the app ships — and the decode and the fold are owned. The first build used filament-utils-android for all three, on the GPU; its native library links against gltfio’s (a DT_NEEDED, so gltfio cannot be packaged out), and the two added about 4.5 MB of uncompressed native code per ABI, 17.5 MB over the four the APK carries, for a header, a run-length code and a texture lookup. The owned half is about 150 lines, tested on the JVM against hand-made files and against TwelveMonkeys’ reading of the shipped one, and reads the panorama as untrusted input. Decode and fold took most of the 558 ms the Pixel 10a spent building the room on every cold start (the GPU path had measured 223 ms), so the folded faces are kept on disk like the compiled material: 4.7 MB with a CRC-32, keyed by a hash of the panorama, the face size, a fold version and the byte order, and anything wrong with the file folds again. StudioLightDeviceTest holds the first launch under a second and every later one — which must have read the cube — under half a second. The panorama adds 1.3 MB. The camera is given an aperture, shutter and ISO rather than Camera.setExposure(float), because that overload takes its argument as something other than the exposure factor: it sets f/1, 1.2 s and ISO 100 / e, which Filament clamps to 204,800, and the first build rendered twenty-six stops over — a white frame, with or without the post pass. The irradiance is precomputed rather than worked out on the device because nine numbers need no decoder and no time, and the test makes a stale set impossible to ship. The fill is irradiance because Filament shades exactly one directional light, the brightest, and silently drops the rest: the fill had never been drawn, which the device showed as felt the key did not reach at 0.40 of the lit felt, where key and room alone predict 0.43 and key, fill and room 0.50. Linear rather than ACES (Filament’s default) or AgX because a set’s colours are part of its design and ACES shifts saturated hues and AgX greys them; and rather than PBR Neutral, which was tried, because its toe subtracts nearly all of a colour’s smallest channel on the promise of a four-per-cent white reflection that a lamp-lit scene does not deliver, in Rec. 2020 where a saturated green’s smallest channel is three times its sRGB one — the phone drew #1f5e3a as (0, 70, 22). A linear mapper clips rather than rolls highlights off, so the exposure leaves the headroom: only the lamp’s reflection in the lacquer and a pure white face turned to the key and the window (1.13) reach white (the lacquer no longer does since decision 103). MSAA rather than FXAA because the aliasing is geometry, and rather than TAA because a settled die must not shimmer while a history converges. PCF rather than PCSS, which was tried for contact-hardened shadows: Filament 1.76’s PCSS samples a variance shadow map, which wants every receiver to cast, and it put the rim’s shadow back on the felt as a hard band 25 to 40 mm in from the walls — the shadow the tray’s castShadows(false) had removed; StudioLightDeviceTest now measures the felt along every wall against the middle of the floor. Screen-space contact shadows would darken the wall–floor join the way ambient occlusion did (docs/physics-and-rendering.md, “Rendering (normal mode)”)
90 A translucent die is drawn as refracting resin — Filament’s screen-space refraction, solid, LIT with its clear coat — in a second material compiled on first use; every resin input is derived from fields a set already writes, so the format does not grow. Opaque surfaces keep the opaque material; the blended (TRANSPARENT) variant is gone. transmission is 1 − (1 − translucency)², the blur of what is seen through is max(roughness, 0.6 × (1 − translucency)), ior 1.5, thickness 0.7 of the drawn size, and what is seen through is tinted once, by the base colour moved towards the square root of color — no absorption (Resin.of; docs/physics-and-rendering.md, “A die you can see into”) A blended die drew a dusty, faded opaque one: nothing behind it bent and nothing in it deepened, which is not what resin looks like. Refraction is what does — the felt and the neighbouring dice seen through a die, displaced and tinted. The tint is applied once: Filament already multiplies what is seen through by the base colour, and the first version added an absorption from the colour on top, which turned green felt under a glassy amber die black on the Pixel 10a (a sphere-model ray crosses ¾ to all of the thickness, so the absorption was a second uniform tint, not a depth). One pass is the colour’s square root, because a clear die looks its colour over a pale table after crossing it twice. Transmission grows as a square because the body is lit by the key light while the felt it shows lies in the die’s own shadow: weighed in a straight line, 0.6 read as opaque. Screen space rather than the cubemap, because a cubemap refraction looks into the lighting environment while a die sits on felt; the price is one copy of the opaque frame with its blur levels on frames that have a resin die in them, and that one translucent die cannot be seen through another. Not the SUBSURFACE model: in Filament it is a wrap of direct light round a thin object, cannot be combined with refraction, and has no clear coat, so it would have bought a glow at the cost of both the see-through and the lacquer — milkiness comes instead from the refraction’s own roughness blur under a smooth coat. Opaque blending, because Filament draws a refracting surface after the opaque pass and shows what is behind it by sampling, not by blending; the transparent variant then had no die left to draw and was removed rather than kept as dead code. No new format fields: milky-to-glassy, thickness and tint all follow from translucency, color, size_mm and roughness, which is everything an author can see in a resin, and a field added now would have to be honoured forever; if the phone shows a resin the derivation cannot reach, an optional field can still be added through the validator then. The first resin die after an install pays one more compile, in the place the blended one paid its two seconds; how long a refracting material takes is for the device test to report
91 A die is drawn rounded by exactly the radius the solver collides it with — its flat faces on their own planes, its edges cylinders and its corners spheres cut from the sharp solid — and the radius is simulation/api’s HullMargin, repeated through Jolt’s own limits by render/filament’s RoundedEdges, not chosen for the picture The solver has always rounded a die: Jolt pulls a hull’s planes in by its convex radius and grows the result back out, so what meets the felt is the sharp solid with 0.48 mm fillets (0.25 mm on a d4). Drawing the sharp solid put every corner outside the die that collides — up to half a millimetre into the felt — and choosing a smaller, “safe” radius for the picture would only have shrunk that error, not removed it. Repeating the construction removes it: the drawn surface is the colliding one, a die on a face is drawn where it was because no face plane moves, and the face read is the face shown. The share moved from JoltWorld to simulation/api unchanged in value, because the renderer cannot see the Jolt module and two copies of the number would come apart; Jolt’s 0.5 mm error limit is named beside it because the solver leaves it at Jolt’s default. The physics is untouched — no golden, fairness or harness figure can move. The label room is still measured on the sharp face, so the tray and the face designer still put a 6 in the same place, and the flat part of a face samples its cell exactly as before; the bends continue their face’s cell rather than going blank, so artwork runs round an edge instead of stopping short of it — no bend reaches a printed numeral’s ink but the d4’s, whose tips are painted round by design. A bend’s lacquer glint is thinner than a pixel, so both dice materials are compiled with Filament’s geometric specular anti-aliasing, which spreads it over the bend instead of drawing it as a broken white line, and leaves every flat surface as it was. It costs triangles — 204 for a d6 where 12 did — which a hundred dice at the capacity limit can afford (docs/physics-and-rendering.md, “Rounded edges”)
92 A table look’s pictures are keyed like a die’s atlas — package::path, the package being the one the validator read the look out of (TableLook.packageId, stamped, never read from the file) — and the bundled package’s are answered from its APK resources by InstalledArtwork ahead of any folder. A look may name a colour picture, a normal map and a roughness map per surface, laid at real size (floor_tile_mm, wall_tile_mm), coloured by color_mode (multiply, the old meaning and the default, or average, under which the colour picture is scaled to average out at the look’s colour as drawn — the dielectric sheen the lamps put on top of it taken out first, by SurfaceLight, and a colour darker than that sheen lifted by the least grey that reaches it — and the roughness map is moved to average out at the look’s roughness); they are drawn with a material of their own, DiceMaterial.TABLE_SOURCE, compiled on first use and cached like the others, uploaded mipmapped (sRGB for colour) and sampled trilinearly with 8× anisotropy. The built-in felts and oak ship 1.5 MB of CC0 WebP; plain stays a flat colour through the dice’s material and dark glass through the glass floor’s (decision 93; **through the dice’s material like plain since decision 104). A look that is both glossy and drawn from pictures is drawn from its pictures and shows no dice in it (DiceMaterial.variantOf). The picture material has no specular anti-aliasing: Filament’s filter reads the mesh’s normal, which on the tray is flat, not the normal map, whose shimmer the mip chain answers** The owner asked for realistic felt and oak, with plain left as the plain, power-saving look. The open question was whose package a path names: stamping the id at validation answers it with no new key, keeps a table from reaching another package’s files, and lets a photo table in mine draw its picture the same way. The bundled set is never on disk, so reading its pictures through the very files it was validated from keeps the rule that the bundled set goes through the validator like any download (BuiltinDiceSet) rather than special-casing a path. A separate material keeps every die’s source, packet and pixels unchanged and costs nothing on a phone that never draws a textured table. Real size rather than whole repeats per side, because a repeat count stretches with the screen’s aspect and felt is a fixed weave; the old *_tiling stays for photographs, which want stretching. average exists because multiply makes the file’s colour the tint rather than the colour of the floor, which would have put a wrong swatch in every power-saving picker row and asked authors for arithmetic; multiply stays the default so every photo table already on a phone draws as it was taken. The roughness follows the colour because a photograph’s roughness map is its finish, not the look’s: oak’s averages 0.44, and under the studio’s key (decision 89) that drew a white sheen across the boards that turned them pinkish grey; the look says oiled oak, 0.75. The sheen is taken out because it is not small: felt at 0.9 gives back 1.4 % of white straight down, as much as green felt’s red, and the Pixel 10a drew #1f5e3a as (44, 94, 65) — exactly that sheen on top of the colour, not the normal map (modelled, a normal-mapped felt’s sheen is a flat one’s to a ten-thousandth). It is worked out from the lamps, the exposure and the roughness, not measured and subtracted, so it follows them if they change; it is for the Table view’s straight-down look, and a tilted view sees a little more. The cost: three new keys per surface and one per look (validated like texture and like sound), about 50 MB of GPU memory with mips when both bundled pictures are in use, and an APK larger by the pictures
93 A glossy table shows the dice in it by a planar reflection of the dice alone — a second Filament view, from the camera turned over in the floor, drawn a quarter of the screen each way with no shadows and no post pass into a RGBA16F target the floor samples — and whether a table is glossy is its roughness, under 0.3, not a new field. The floor is a material of its own (DiceMaterial.Variant.GLASS, compiled on first use) that adds the picture times its own Fresnel term and the table’s strength (1 − roughness / 0.3; dark glass two-thirds) and lowers its reflectance by as much where a die is; the tray is on a layer that view does not see (Reflection; docs/physics-and-rendering.md, “The dice in a glossy table”) Withdrawn by 104. The owner asked for dark glass to reflect the dice “a bit, not perfectly”. Filament’s screen-space reflections were the obvious candidate and were read against 1.76.1’s shaders before anything was built: they reflect everything on screen, so the walls would lay a dark band along the foot of each one — the band that switching ambient occlusion off had just removed from the felt; the blur they derive from roughness is in world units and this scene is in millimetres, so it comes out sharp; and they fade any ray that turns back towards the camera, which from the default straight-down view is nearly every ray off the table. They also need the previous frame and the post pass, and cost a depth pass of the whole scene and a ray march over the full-screen floor every frame. A planar reflection is exact for a flat floor, shows only what it is given — the dice — and its softness is the size of its picture, which is also what makes it cheap: the dice drawn again at a sixteenth of the pixels, culled and sorted once more on the CPU, and nothing on a table that is not glossy. Roughness rather than a field, because how glossy a surface is is how much it reflects, and a format field would let a look say “rough, but a mirror”, which nobody can make; felt and oak stay on the picture material and plain on the opaque one, and draw exactly as before. Its known shortcuts: a reflection does not soften with height above the glass, a real glass table would show its walls too, and a resin die in the picture is drawn without the post pass its refraction may want — all at four per cent, where the owner judges whether they show. The cost at the capacity limit is for tools/harness.sh --rendered --table dark-glass to say on the Pixel 10a
94 How round a die is, is each die’s own: an optional edge_rounding in a set file ([defaults] or [[die]]), a share of size_mm clamped to 0.015–0.12 (0.06 since decision 100), default 0.03 — carried as DieMaterial.edgeRounding to HullMargin, which asks for that radius and lets Jolt cut a sharp corner back by an error in proportion to it (maxErrorMm, Jolt’s own 0.5 mm at the default) — so the solver (HullRounding, mMaxErrorConvexRadius per hull) and the picture (RoundedEdges, meshes cached per shape and radius) follow it alike. The face designer sets it with four steps (Sharp, Standard, Rounded, Very round: 1.5, 3, 6, 12 %; **a slider since decision 98) and sets what a die is made of with a menu of six presets over the existing roughness, metallic and translucency (Plastic, Pearl, Resin, Glass, Metal, Stone; Custom for anything else); both are a finish in the die’s draft and per-die keys in the package** The owner could not see the new resin and rounding anywhere: nothing let a person choose either. Material needed no new fields — the renderer already derives resin from translucency and roughness (decision 90) — so the menu is names for values a set can already write, tuned against Resin.of. Rounding did: decision 91 made the drawn radius the solver’s, so it is a physical property and has to travel the way friction does, through the validator and both clamps, and reach Jolt. Held at Jolt’s default corner error, a rounder die would have been cut back to 0.68 mm on a d6 and not rounded any further on a d4, so the error scales with the share and every shape keeps the proportions it has at the default; Jolt’s other limit, half the thinnest width, is not reached by any catalogue solid at 16 mm and full scale. Every default is untouched and proved so: a die that says nothing asks for the same radius bit for bit and hands Jolt exactly 0.05f (HullMarginTest, HullRoundingTest), and RoundedEdges gives the same radius on the default path (RoundedEdgesRoundnessTest), so the built-in set and the goldens are the same bodies. The range is bounded both ways: below half the default a d4 catches on its corners again, and at 12 % a d20 has already lost about half of each face to the bends and rolls further from anything a fairness run has measured — rounding is symmetric, so it should not load a die, but a rounder die rolls and cocks differently, so FairnessTest and the harness take an edgeRounding argument to ask. Steps rather than a slider for the same reason: four named dice are four that can be thrown and checked. A die whose material was chosen writes all four keys whole, so a die made plastic on purpose stays plastic when the details screen makes the set translucent; one copied from a set with a finish of its own carries it, so the menu and the tray agree.
95 While a dice material compiles, the roll screen says so: “Preparing the dice”, a plate over the tray with what is being prepared, why, a determinate bar and the time left. The bar is an estimate — elapsed time against what this phone took last time, kept in noBackupFilesDir, the Pixel 10a’s figures until then — capped at 95 % until the compile ends. FilamentEngine reports a compile (cache misses only) to ShaderProgress, which publishes a ShaderWork on a StateFlow (RollThread.shaders → Tray.shaders, default “never”) that the screen collects (docs/physics-and-rendering.md, “Preparing the dice”) The owner asked for a loading screen on the first launch after an install or update, which spent about 2.4 s on a black tray compiling the opaque material, and for a progress bar if one was possible. libfilamat’s builder reports nothing until it returns, so a true fraction is not available; a spinner says nothing about how long, and the owner asked for how long. An estimate against a measured figure is honest if it can only err on the slow side, so it is capped short of full and the plate goes only when the compile does. The figure is kept apart from the material cache because that cache is emptied by exactly the update that needs it. Reporting from inside the cache’s miss path, and nowhere else, means a launch that compiled nothing shows nothing; the same plate covers the lazy mid-roll compiles (resin, table; glass until decision 104) because they stop the tray just as visibly. All of the arithmetic is plain Kotlin under JVM tests; FilamentEngine only calls two methods around the compile. A StateFlow because the state is written on the roll thread and read on the main one, and it is the one thread-safe observable Compose collects without glue; the Tray default means no fake and no power-saving tray had to change. The alternative of compiling every variant up front behind the screen was rejected: it would turn about 2.4 s into about 8 on every update, for materials most phones never draw
96 A face the artwork draws on is not printed; a face it leaves clear is. The decoded atlas says which face cells hold anything (AtlasImage.drawnCells), once per upload and for every catalogue face count (AtlasCoverage, held beside the texture on FilamentEngine); the renderer asks the stage before adding a die (Stage.drawnCells, empty for a stage that cannot read pixels) and leaves those cells out of the number field (DieNumbers.fieldOf(drawn), PrintedDice keyed by die and drawn faces). The material still lays the artwork over the body by its alpha The owner rolled a die from the face designer and found the set’s numbers on every drawn face: decision 59 printed every face and let the artwork’s alpha decide per pixel, so every clear part of a drawing showed the label through it — a number nobody drew. Per face is what a person drawing a face means, and it is the face designer’s rule too: undrawn faces keep their numbers so a die drawn on one face is still readable, and a number wanted on a drawn face is stamped into the drawing. Decision 59 chose per pixel because the renderer did not know which cells were empty; the decoder already worked that out for the validator’s AtlasCellsEmpty warning, so the fact was one function away. It changes how a third-party set whose artwork leaves gaps around a printed number looks — such a set now shows the artwork alone on those faces — which is the same complaint from the other side, and the cure for both is to draw the number
97 A die drawn in the face designer has a body colour of its own, chosen beside its material: the pen’s twelve colours and ui/common’s ColourPicker past them. It is part of the die’s finish (DieFinish.colorArgb, with numberColorArgb set to whatever reads on it by PaperInk — the built-in near-black or white), written to the draft as two optional integers and to the package as the existing per-die color and number_color keys; the canvas, the strip and the turning die are all drawn on it The owner asked for a configurable base colour: with rounded edges a face no longer fills its side, so a die “coloured” by bucket-filling every face kept bone-coloured edges and corners. The format already had per-die color and number_color, so nothing new reaches the validator; the draft’s finish gains two optional fields, because a finish written before them described a bone die and should still read as one. The numbers follow the body automatically rather than being a second control, because their one job is to be read on it — a black die printed black is not a choice anybody makes on purpose, and a drawn face is not printed at all (decision 96). Drawing on the body colour rather than on white is what makes the canvas the die: the eraser paints the body, and the guide is drawn in the ink that reads on it
98 The face designer’s Edges is a slider over the set file’s whole edge_rounding range, 1.5–12 % (6 % since decision 100), in half per cents (EdgeRounding.snapped, so a move lands on a value a set file writes exactly), said under it as the share and as the millimetres the solver gives this die; the turning die is drawn rounded by that radius (RoundedSolid: flat parts, edge bands, corner patches, the outline round the rounded corners). Amends decision 94’s four steps; the range is decision 94’s until the Pixel 10a runs in docs/physics-and-rendering.md, “How round a die may be”, are made The owner asked for a slider and found that the four steps “only made sense for the d4”. The root cause was not the solver — every shape but the d4 gets the full radius at every value — but that the designer drew the sharp polyhedron whatever the step, and that how much rounding shows is the solid’s: a corner stands in by r(\|m\|−1) and a band is r·tan(θ/2) wide, so a d20 (42° between faces) moves half a millimetre across the whole range where a d4 (109°) moves two. Steps were chosen as “four dice that can be thrown and checked”, but rounding is symmetric and its effects on settling and fairness are smooth, so the ends of the range answer for what is between them and the steps bought nothing a slider loses. The bounds could not be measured on the phone in the change that made the slider; the range stays the one decision 94 set on geometry, and the runs that would move it are written down
99 The face designer is three steps — Shape, Material, Faces (DesignerStep) — in one screen with one presenter: a segmented step bar under the title that goes to any step, Back and Next in the footer (Next the filled button on the first two, Roll it on the last), the turning die on the first two steps and on the third’s Solid tab. The step is presenter state like the tab is; moving between steps changes nothing but the step and writes the draft. The menu opens on Shape, a route that names a die on Faces (DesignerStep.openingFor); the phone’s Back leaves the designer from any step The owner asked for the design process in steps — geometry, then material and base colour, then faces — instead of everything on one screen. One screen and one presenter rather than three destinations, because the three are views of one draft that is being edited throughout, and three routes would mean handing the draft between screens and three entries on the back stack for one task; the tab bar this screen already had is the idiom, grown by one. The phone’s Back is left to leave the screen because the app has no BackHandler anywhere and a Back that sometimes climbs and sometimes steps is the unpredictable control the navigation rule avoids (“Navigation”; docs/face-designer.md, “The way back”). Quick mode and the way back from a test throw know the die, so stepping them through the chooser again would be a detour
100 A die may be rounded by 1.5 % to 6 % of its size, not 12 % (DieMaterial.EdgeRoundingRange), for a set file and the face designer’s slider alike: measured on the Pixel 10a at 20,000 throws a shape The owner asked for a slider “within reasonable ranges, no too round dice that never settle”, and decision 98 left the set file’s range unmeasured. At 12 % every solid was still fair but the coin’s rounded rim rolled like a wheel: 42 of 20,000 throws ran out the backstop against a bar of 2, and the d18’s worst face drifted to 0.98 % against its 1 % bound (0.39 % at the default). At 8 % the coin was on its bar exactly; at 6 % and at 1.5 % nothing gave up and every shape passed with room. 6 % is twice the default and the last value with a margin. The harness, which throws no coins, saw nothing wrong anywhere in 1.5–12 % — rounder dice tumbled longer and were re-thrown less — which is why the fairness run, not the settle run, set the bound (docs/physics-and-rendering.md, “How round a die may be”). A set file asking for more is clamped with a warning, as for every other physical value; the harness and FairnessTest refuse values outside the range, so measuring beyond it means widening it on a branch first
101 A die’s artwork is drawn in its own colour over the body, not tinted by it, and the atlas is uploaded as sRGB. An opaque atlas pixel is art.rgb (mix(body, art.rgb, art.a) in the dice material), and FilamentEngine.uploadAtlas uploads SRGB8_A8; AtlasTextureTest holds a black die wearing an opaque red atlas to the frame a red die draws, on a device The owner’s report: the colour given to eyes and numbers in the face designer was not seen when the dice were rolled. Two faults made it. The material multiplied the artwork by the body colour — kept from when a table’s floor picture went through it, which tables no longer do (decision 92) — so ink on a black die was black and on any dark die nearly so; decision 97 gave designed dice a body colour of their own and made that the common case. And the atlas was uploaded as linear RGBA8 while its pixels are sRGB, so every ink came out paler than drawn (#C0 as 75 % rather than 52 %). docs/dice-sets.md already said a drawing sits on the body colour; the code now does. On the Pixel 10a the new test fails by 127 levels with the multiply and by 54 with only the linear upload. A published set that relied on the multiply — white artwork tinted by color — draws white now; none of the bundled or sample sets did
102 The face designer’s Material step has an Opacity slider, glass (0 %) to solid (100 %) in steps of five per cent, between Colour and Edges. It is the set file’s translucency the other way round (DieFinish.opaque, Opacity.translucencyOf) and adds no field; the Material menu sets it and follows it (MaterialPreset.of) “The face designer needs a slider for the opaqueness” — the owner, who chose the die’s body over the pen’s ink when asked. Ink stays opaque (docs/face-designer.md, “A colour beyond the twelve”): a half-clear mark would be a colour that depends on a body the designer only approximates. Opacity rather than translucency because “more solid” is the direction somebody asks in. Five per cent because every name’s translucency (0, 25, 60, 100 %) is on a step and a finer one is a difference the tray does not show; the slider keeps surface, metal, colour and rounding, so 75 % plastic is Pearl and a value between the names is Custom, exactly as a die copied from somebody else’s set is
103 A die’s lacquer has a roughness of 0.4, not 0.12 (DiceMaterial.DIE_COAT_ROUGHNESS), so a face turning through the key light’s mirror angle brightens without going white; the key stays a sizeless directional light The owner saw the dice “almost blink” at some angles while they rolled. A frame of a screen recording showed a d6 face drawn pure white. The key light has no size and the camera looks straight down, so a flat face mirrors it everywhere at once; at 0.12 the lacquer did so within a few degrees of the mirror angle (13° for this key), and a tumbling die turns through that in one frame. DieGlintDeviceTest measures it on the Pixel 10a: turned half a degree at a time, half a d6’s face and a third of a dark d6’s blew out at 0.12, most of the face at 0.25, nearly half at 0.3, and none at 0.35 or 0.4 — where the highlight swells and fades over about twenty degrees and peaks under white. 0.4 rather than 0.35 because 0.35 peaked close to white with no margin. A SUN light with an angular radius was tried first and changed nothing: Filament compiles its disc only at the HIGH shader quality, and inside the disc it moves the reflection onto the peak, which would make the white patch larger, not dimmer. The coat alone matters: flooring the body’s roughness at the same value measured identically. The price is a softer reflection of the room in the lacquer; whether the dice still read as varnished is the owner’s call on the phone
104 The dice are not reflected in a glossy table: decision 93’s planar reflection is removed. Dark glass stays a table look, its floor drawn by the dice’s opaque material like plain, at its own roughness and colours; its physics are unchanged. With it go the glass material (DiceMaterial.Variant.GLASS), the second view and its target, the camera under the floor and the tray’s layer From the top-down camera — which stays: the owner has ruled out oblique angles, and the angled Table view leans only 22° — a die’s reflection lies directly beneath the die and is hidden by it, and glass looked at square on reflects about 4 % (2.7 % at the strength dark glass’s roughness gave it). On the Pixel 10a nothing of it is visible: the owner looked and saw none, and the render gallery shows none. It cost a second view of the dice and a material of its own to compile on first use. It saved no GPU time: drawn by the opaque material, dark glass’s floor costs more than the glass material did — 13.20 ms GPU p99 at 20d20 against 11.87 ms with the reflection, the same as plain’s 13.0 ms (tools/harness.sh --rendered, Pixel 10a, 2026-10-19 and 2026-10-20); every table still draws at 60 fps. A phone that timed the glass material keeps a glass= line in its shader timings, which the reader skips as an unknown variant; its compiled packet in codeCacheDir is never asked for again and goes with the next update (docs/physics-and-rendering.md, “A glossy table”)
105 The shake is as it was, and the hand tosses the dice when it stops. The dice are spawned when the shake begins and driven by it in sight; once per throw, a moment after the last hard stroke (ShakeDriver.lettingGo, 8 steps after the last hard moment, or half as long again as the longest pause the shake has made between its strokes, up to 0.3 s — a slow shake goes quiet mid-stroke and was let go of there — never in the shake’s first 0.3 s) every die is tossed from where it lies (Toss, through PhysicsWorld.respawn at its own pose), one after another within 0.1 s (Toss.Handful): up 15–60 mm and onward along its own motion or, barely moving, along the hand’s last stroke (Release.of: acceleration integrated, drift removed, the last stroke’s peak, ×1.5, 600–1,600 mm/s), with a fresh spin. The hand drives nothing for the rest of the throw after it lets go and the roll stops counting as driven; the watched pace eases in over 0.3 s of the roll (RollPace.eased) rather than dropping in one frame. A throw thrown by a shake says so (ThrowSpec.toss); one recorded before there was a toss replays as it did The owner compared the tray with a slow-motion film of real dice: shaken in the tray, four d6 were still about 0.3 s after the hand stopped, because the drive is a box being shaken and its last stroke brakes the dice. A first version kept the dice in the hand, off the table, while the phone was shaken and threw them from a clump when it stopped; it tumbled well, and the owner rejected it because nothing on the table showed the shake reaching the dice. Tossing from where the dice lie keeps the shake in sight and adds the throw. It is an impulse the code applies, which is why it is a decision: it is the hand’s, read off the hand’s own record, applied once, at the moment the hand stops, to dice that are all still in it — none is read until the table has settled and the hand is off it, so nothing touches a die that has come to rest. Twenty d20 tossed one way pile against the far wall and are thrown again 3.9 % of the time against 1.2 %, so a crowded toss needs less speed; the constants are first guesses, to be set from the owner’s recordings of real throws (docs/TODO.md, Step 5.8). Tried on its own branch first; the owner, comparing it on the Pixel 10a with the line without it, adopted it on 2026-10-20 — without the toss the dice “almost immediately settle after stopping the shaking”
106 A metal die is not lacquered, and the designer’s Metal and the steel sample set are satin, at a roughness of 0.6, not 0.3. The dice material sets its coat on every surface, nought included The owner found the top faces of metal dice in the gallery overly bright. A metal reflects the key light in its own colour, about twenty times as much as a lacquered plastic die does, and from the leaning camera most flat faces sit near the key’s mirror angle. Over the whole throw, 8,144 pixels clipped against none for the same dice in plastic. Two things made 0.3 worse than it looked. Filament never draws a body smoother than its coat, so under the 0.4 lacquer a 0.3 metal drew exactly as a 0.4 one. And the shader set the coat only when there was one, while Filament starts every pixel of a coated material with a full coat as smooth as a mirror (clearCoat = 1.0, clearCoatRoughness = 0.0, found in Filament 1.76.1’s shader library). So asking for no coat gave a mirror coat, and a bare metal at 0.8 still burnt a fifth of a face at the mirror angle. On the Pixel 10a, with the coat set every time and none on metal, DieGlintDeviceTest blew out over two thirds of an ivory metal face at 0.5 and none at 0.6, and two thirds of the steel set’s grey at 0.4 and none at 0.5. The coat scales with what is not metal (DiceMaterial.coatOver). The flat floors — plain, dark glass, any look without pictures — had been drawn under that mirror coat too, and under a coat Filament reflects next to nothing off the body, so the coat had been doing SurfaceLight’s job: without it StudioLightDeviceTest drew a flat #1f5e3a as (44, 94, 64). A flat floor now has the lamps’ sheen taken out of its colour, as a pictured floor in average does (DiceMaterial.floorOf); its walls, a few levels lighter without the coat, are left as written. What is left: ivory metal at 0.6 still reaches 255 in red on some faces in the leaning view, cream rather than white, which the device test, counting only pixels white in all three channels, does not see. Whether that reads right is the owner’s call
107 The sample sets come installed (four when this was decided; Halloween made it five): the app carries sample-sets/*.zip and installs each through PackageInstaller on the first launch of an install or update (PreinstalledSets), rather than bundling them like Built-in The owner wanted resin, glass and metal dice for every shape in the app without installing them by hand. They come installed rather than bundled. A bundled set can’t be removed and is read from the app’s resources on every launch, and “the bundled set” is one set throughout: the set library, the artwork reader, the designer and the notation fallback all take exactly one. An installed one is the player’s to switch off or remove, and every module already handles it. The zips are copied into dicesets/builtin’s resources at build time, so the repository holds one copy, and SampleSetsTest holds the list to the committed files. Each still goes through the validator, as a download does. preinstalled-sets.txt records what was installed and at which version. That record is what tells a set never installed from one the player removed, which stays removed. A newer shipped version replaces an older installed copy, never a newer one the player installed. A version that is not three numbers is never newer. The record also holds the app version it ran for, so the archives (870 KB of marble among them) are unpacked and validated only once per install or update
108 Light past white is bent, not cut: Filament’s grading runs two stops darker into a half-float frame, and a tone pass of our own brings it back, rolls off everything above 0.8 with PBR Neutral’s shoulder (not its toe), encodes it and dithers it (ToneCurve, TonePass) The linear mapper (decision 89) keeps every colour as its package wrote it, and stopped dead at one. After metal dice lost their lacquer (decision 106), ivory metal still reached 255 in red on some faces in the leaning view. Filament’s Java API takes no custom tone mapper, and every built-in one bends colours below white: ACES, AgX, and PBR Neutral, whose toe drew green felt as (0, 70, 22). So the grading stays linear at −2 EV, its own cut lands at four times white, and one full-screen pass of our own applies a curve that is the identity below 0.8. The tray’s view keeps its multisampling, shadows and grading; it only renders into the frame instead of the screen and no longer dithers. On the Pixel 10a every colour and orientation device test passes unchanged, and so did the first measurement, which showed the grading writes sRGB into the frame. A polished metal highlight is kept at 4.00 of white with none of it at 255. The gallery’s metal throw went from 5,462 clipped pixels to none. The pass costs about 1 ms of GPU time per frame (p50 4.26 → 5.26 ms, p99 6.91 → 7.77 ms, at 20d20) and 60 fps holds. The tone material is compiled, cached and announced like the surface materials (DiceMaterial.Variant.TONE)
109 The d4 is a long die, shape = "elongated-square-bipyramid": a square prism lying on one of four long faces with a short pyramid on each end, read from the face on top like every die. The tetrahedron and its read = "vertex-up" are gone, and a set file naming either is refused — a breaking change to the set format, shipped in v0.5.0: before 1.0 the owner bumps the minor version for it, and keeps 1.0.0 for the v1 release of Step 6 (LongDieShape) The owner, 2026-10-23: get rid of the d4’s problems and its special rules. A tetrahedron has no face on top, so it was read from its top corner. That one shape carried a reading of its own (FaceRead.VertexUp), three numbers per face turned to their corners (LabelRoom.cornered), a per-corner account of the solid (SolidFace.cornerReads, decision 35), the face designer’s three corner guides and a prototype panel for them, and the worst settling in the catalogue: 100d4 cocked on itself. All of that is deleted rather than kept for old sets: the owner chose refusing over reading old files as the new shape. Proportions, in units of the bounding radius (half size_mm): 0.8 wide, a 1.4 prism and two 0.3 ends, 2.5 to 1, with the points on the bounding sphere so size, capacity and weight work as before. The prism’s corners are inside the sphere, the one solid whose corners are not all on it. The ends cannot be rested on: the point under the middle of the die is a fifth past an end triangle’s base. Ends of 0.18, which would have put every corner on one sphere, are short enough to balance on. The ends are faces with no cell (SolidFaces.bareOf), drawn and rounded with the die. The long faces are long rectangles in square cells, and the designer’s canvas has a matching outline that turns in halves (FaceOutline.LongRectangle). Every shape now gets the full rounding it asks for; the tetrahedron got half (decisions 91, 94). The four sample sets go to 2.0.0. A copy installed before this no longer loads, so it is counted at the version it was installed at and replaced by the new one on the first launch after the update (PreinstalledSets.versionsOf). The capacity rule needs nothing new: it counts every die by its bounding sphere, so a long d4 takes a disc as wide as it is long, more floor than it covers (docs/tables.md). The validator tells an author exactly what to change
110 The d18 stays an enneagonal trapezohedron, held to the worst-face bound rather than chi-squared (docs/physics-and-rendering.md, “The bar the d18 is held to”); no other shape is adopted to pass chi-squared The owner, 2026-10-24, after the release record put it at χ² 267 against 40.8 with its worst face 0.609 % off, inside the 1 % bound. The bias is single-precision hull asymmetry amplified by narrow basins (docs/physics-and-rendering.md, “Where the d18’s bias actually sits”), so only wider basins would cure it. The enneagonal trapezohedron and bipyramid are the only convex solids whose eighteen faces are all alike, and both have nine round one axis — the bipyramid would gain a few degrees, not the margin. The rhombicuboctahedron’s eighteen squares are 45° apart but fall into orbits of six and twelve, so it would be fair only once tuned against this engine, and re-tuned whenever the physics changed — giving up the fairness-by-symmetry every other die has, for a test that detects bias far below anything a player could notice. Resolves the open question “a true enneagonal trapezohedron, or a rounded barrel?”
111 A die may glow: glow (per cent) and glow_color (default color) in [defaults] or a [[die]], drawn as Filament’s emissive colour in the surface both die materials share, in the screen’s units (emissive.w = 0), at up to 0.6 of white (Glow.FULL), with nothing printed glowing; and a halo from a glow mask of its own — the glowing dice drawn again in their glow alone, everything else black, at half size, blurred by its mip chain and added by the tone pass (GlowPass). Glow and translucency are independent fields; a glow-in-the-dark die writes both. No light is cast on the tray The owner asked for a glowing Halloween set, partly see-through, from photographs of real glow-in-the-dark dice. An emissive term needs no new material — opaque and resin dice both glow, and the cache key moves with the source — and added after the lighting it is what makes a glow read on a tray that is always lit: the shaded side is green, not dark. The screen’s units keep it independent of the 80,000-lux key and the exposure worked out from it (TrayLighting). A light per glowing die (up to a hundred in Filament’s froxels) was weighed and left for later. On the phone the glow alone read as mint plastic and the owner asked for more. Filament’s bloom was tried and dropped: thresholded at white it showed nothing, unthresholded it hazed the felt and bloomed every highlight. The owner suggested the glow as a screen-space pass of its own, which spreads the glow and nothing else (97.5 % of the felt untouched round a glowing die on the phone) (docs/physics-and-rendering.md, “A die that glows”). The face designer has no Glow control yet (docs/TODO.md)
112 Jolt stays at 5.3.0: 5.6.0 was measured and offered dice no advantage The owner, after a trial on the Pixel 10a on 2026-10-31. 5.6 was no faster for dice and re-threw more of them, nearly all cocked: 0.99 % against 0.76 % of 20d20 with each version at its best friction and restitution, so re-tuning did not close the gap. Its dice also tumbled less after landing, 2.73 turns against 2.97. Both passed FairnessTest. Its headline feature, GPU compute, cannot decide a roll, because GPU floating point is not the same on every driver (decision 37), and moving would have cost new golden cases for nothing a player would gain. The likely cause, not proved, is 5.6 applying friction at the average contact point. Look again when a release changes friction or contact resolution, or makes convex-hull stepping faster on the CPU (docs/physics-and-rendering.md, “Which Jolt, and when to look again”; docs/build-setup.md, “Moving to a new Jolt”).