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.
| 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.
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.
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.
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:
graph is a graph with
no formula, which says where a formula comes from, and a bare designer is
the designer on the die it always opens on.+ and / are characters a
formula is made of and a URI reserves; unencoded, 3d6 + 4 arrives as
3d6 4 and graphs a different roll.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”.
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.
| 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:
type on every keystroke, and its × calls
type("") — the same edge as deleting the text by hand. Its action key
says Done and shuts the drawer; it does not call roll (decision 66).
After a throw the tab is folded into the shut dice pull-down until that is
opened (decision 83);type or typeSaved. Neither decides anything
about the roll: a tap on a saved roll fills the field and waits for a shake
like every other way in;BottomEdge,
plain Kotlin with its own tests
(docs/physics-and-rendering.md, “Two pull-ups, one bottom edge”);Controls, plain Kotlin with its own tests (decision 83);add on a tap and remove on a long press,
and both are type underneath — a tap is an edit to the formula, so it
re-validates, re-checks the table’s capacity and abandons a throw in the air
exactly as a keystroke does (docs/dice-notation.md);roll, and nothing else does: there is no button, no
key and no accessibility action that throws, and no setting that switches
shaking off (decision 66). It is one shake for one throw — a settled roll
is put away by the presenter rather than by a separate act, and a roll that
is waiting on a hand is continued rather than restarted, whether it is
waiting for the dice a chain earned or for the ones a throw gave up on;round, which rescores from subtotals that
already landed and never moves a die;type with the formula the parser
suggested, so a suggestion taken is indistinguishable from the same
correction typed by hand;look, which moves the camera and is not
a state change at all — where a player is standing is not what the dice did;design/dInfinity.dc.html, option 7a),
the second carries the formula alone to the saved-roll editor (option 3b).
Both live at the foot of the result sheet, so both are offered in Settled
and in no other state — a device session asked for them to be part of the
result rather than plates standing on the felt. That is a narrowing: the
odds used to be offered in Ready and TooMany as well, and the refusal
was the case with the best argument, because a throw the table cannot hold
is exactly when “what would it have been” is the only answer there is. What
is left for that case is the menu, which reaches the graph from anywhere.
Neither callback knows where it goes, because feature/roll may not depend
on feature/graph or feature/saved.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.
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) |
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.
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.
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 |
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.
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).
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.
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 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.
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.
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.
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”).
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 |
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 |
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 |
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”).
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.
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.
| 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” |
Four surfaces have nothing under them for a screen reader to find, and each says what it contains rather than nothing:
AndroidExternalSurface) — how many dice, and what they came
to. Never which faces: those are on the result sheet, die by die, and
saying them twice makes every throw two announcements of the same thing.
It carries one action, Hide the controls / Show the controls, standing in
for the double tap that clears the table (decision 83), and none that
throws (decision 66);d100 has
a hundred, and a hundred spoken percentages is a minute nobody sits through;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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
Choreographer, and draws each frame
where it stands (render/filament’s TrayDriver). One thread rather than
two, which is a change from the original design (decision 49).DebugRelay), and only
while the developer toggle is on. Snapshots are built on the roll thread and
posted to the main one each frame; the frame times are summed up there too
(FrameMeter, the visit’s dropped steps in DroppedTally) and a frame-rate
reading is posted every 30 frames (decision 72).SensorShakeSource registers with SensorManager without a Handler, from
the roll screen’s LifecycleResumeEffect (ShakeToRoll, through
ShakeInput), so its callbacks
come in on the main looper. They do nothing heavy there — the shake detector
decides, and each ShakeSample is posted to the roll thread
(TrayDriver.shake), where it is applied at the step it is numbered for.HandlerThread per player, which holds a cue until
its moment and plays it there. It exists for two reasons. In normal mode every
cue is due now and the thread does nothing but keep AudioTrack.play and
Vibrator.vibrate off the roll thread, which is the thread stepping the
physics and drawing the frame. In power-saving mode it is what “played back
over about a second” is made of: the roll finished in eighty milliseconds and
the cues are posted forward across the second after it. Nothing on it can
reach the roll (docs/physics-and-rendering.md, “Impacts, haptics and
sound”).TrayDriver and shut down when the visit ends. It is where the
dice waiting to be thrown are dropped: each board change opens a headless
Jolt world, steps it until the dice stop, records the poses and closes it
(JoltBoardSettler), and the recording is posted back to the roll thread,
which decides whether it is still wanted. No world outlives the call, and a
board’s world shares nothing with a roll’s (decision 67).docs/tables.md,
“Thumbnails”).docs/dice-sets.md, “How an atlas reaches the tray”).StateFlow. A compile is a few seconds inside the roll thread’s
post, so nothing on that thread can say it is happening; FilamentEngine
tells ShaderProgress when it starts and ends — only on a miss in the
material cache — and that writes a ShaderWork into a StateFlow
(RollThread.shaders, handed on as Tray.shaders). The roll screen
collects it on the main thread and draws “Preparing the dice”, whose bar is
advanced by the main thread’s own frames against the estimate, so the plate
moves while the roll thread is busy (decision 95;
docs/physics-and-rendering.md, “Preparing the dice”). How long the compile
took is written to noBackupFilesDir from the roll thread.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)
<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.
| # | 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 | simulation/api rather than a second physics world |
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 | 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”) |
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”). |