The implementation plan. Steps run in order; within a step the boxes are
roughly ordered too. Finished items are deleted, not ticked — git history
and docs/STATUS.md are the record. Keep the file a plan, not a diary.
Everything here builds the specification in README.md, docs/ and the
prototype in design/. Where a step says device, it cannot run
on CI (see .claude/CLAUDE.md). Steps 1 and 3, 4.7, 4.9 and 5.1 are done.
Done apart from boxes that wait on other projects; none blocks anything.
java-kotlin analysis once the bundle supports Kotlin
2.4.20. The matrix entry is commented out in .github/workflows/codeql.yml
with its build steps kept. Bundle 2.27.0 (2026-09-16) still refused. A
v_2_4_20 shim in the github/codeql source tree does not mean a bundle
ships it: the check is to put the entry back on a branch and read the
Analyse java-kotlin jobDEPENDABOT_METADATA_TOKEN secret, so the metadata commit
starts the checks without the Approve workflows to run banner
(docs/build-setup.md)render/filament device test
that reads a frame back (AtlasTextureTest, PrintedNumbersDeviceTest, …)
kills the test process on the container’s emulator: Filament’s blitLow
program does not compile on SwiftShader (cannot convert from 'float' to
'out mediump 4-component vector'). The Pixel 10a passes them. Find which
change brought it (Filament 1.76.1, the emulator image, or the room light)
and either fix it or skip those tests on the emulator by name.VerifyDeviceTestResultsTask and ignoreFailures on
connectedDebugAndroidTest once AGP stops failing runs on devices whose
adb serial contains a colon (docs/build-setup.md)ReportingExtension.file(String) deprecation
from docs/build-setup.md once detekt stops calling it — Gradle 10
removes the method, so that is detekt’s deadlineEvery screen is built and connected; what is below is what is left. The design
option ids (1a, 9c, …) are the labels on the canvas in
design/. A change to a screen is state, UI, tests (unit,
Robolectric, accessibility) and the matching document, in one PR.
feature/rollDesign 1a–1j, 2a, 3a–3c, 4a, 4b, 6d, 6f, 9a, 9c, 1z.
Spec: docs/dice-notation.md, docs/tables.md,
docs/physics-and-rendering.md (“What is drawn over the table”).
min(2400, 950 + (n − 1) × 85) ms for the last landing. The prototype’s
collision shove is not ported: a settled die moved by code is the
invisible handImplementation notes recorded only here:
Modifier.draggable over an
Animatable, decisions in SheetSlide — not ui/common’s modal Sheet.Modifier.offset, or the semantics node
reports the unslid position and every position assertion quietly passes.Judgement on the phone:
SAVED ROLLS needed on the lower one?2a)?docs/dice-notation.md, “Emptying the field”):
open the drawer on a long formula and tap the × at the end of the
field — is it found without looking for it, is it far enough from the
text that a tap meant for the end of the formula does not empty it, and
does the keyboard stay up for the next one?1h)?1z). Instant, or
broken?Dice reads as “put away” rather than “gone”, and
whether opening Dice to reach the formula is one press too many.
Double-tap the felt: do the controls leave and come back the way you
expect, and is the double tap found at all without being told? Tap a
die: is the ~300 ms before its ring appears noticeable, or does a pick
still feel immediate?1d6! that
explodes) — is “Shake to re-throw 1 die” at the top impossible to miss
now, does it go the moment you shake, and does TalkBack read it once?Done when every example in docs/dice-notation.md can be typed, rolled
and read here, and a seed gives the same result with the renderer on and off.
feature/graph1m) and
stepped area (1l) are not v1feature/saveddocs/dice-notation.md)1r, 1o, 1p still mention favourites; fix in
the design project and re-importSavedEdgeScroll.ZONE_DP / TOP_SPEED_DP_PER_SECONDfeature/setssample-sets/ from a file (the
zips) and from a link (their GitHub folders). Does marble read as stone
with its numbers cut in, steel as metal, green resin as half see-through
and red glass as clear? Does each refusal or note say something useful?{halloween:d20} and
6{halloween:pumpkin} on a dark table (black felt, dark glass) and on
green felt. Does it read as glow-in-the-dark resin rather than a lit
green plastic, with the black numbers standing out? Do the three
pumpkin faces read as jack-o’-lanterns at arm’s length? Is glow = 70
right, or too bright to see the frosting?size_mm. It is the width across the
corners, so the built-in 16 mm d6 is a 9.2 mm cube weighing 0.9 g
against the design’s 4.2 g. Something near size_mm = 28 changes every
built-in die’s mass and how many fit a table — a decision, not a tweakfeature/tablesfeature/designerA Glow control on the Material step (decision 111). A set file can
make a die glow and the designer cannot: a die copied from a glowing
set comes over without its glow (docs/face-designer.md). Needs the
control drawn in design/ first, glow and glow_color in
DieFinish, the draft file and DiceSetToml
density (steel’s
is 7.8 g/cm³), the solver already uses it for mass, and the set details
show it as grams per die (“Weight, translucency and size, as a person
sets them”). What is missing is the die: the Material step’s six
materials set roughness, metalness and translucency, and a Metal die
drawn in the designer weighs what plastic does. Make weight one of a
die’s own material fields: each material brings its density (plastic
about 1.2, resin 1.2, glass 2.5, stone 2.7, metal 7.8), with a weight a
person can still change. Then check whether the weight is felt as
well as simulated: a heavy die bounces lower, and the knock and its
sound should say heavy (docs/TODO.md 5.6, haptics and sound). A
physics change for the designer’s dice, so fairness is re-run for a
metal dieClear face, in
words under the strip, where a thumb looks for it (decision 82)?feature/settingsNone of it can run on CI. A roll has to look like dice landing, not like
an animation of a random number. Runs again after every physics change, with
tools/harness.sh (docs/build-setup.md, “The physics harness”).
Latest harness run — Pixel 10a, 1,000 rolls of 20d20, 2026-10-03, after
the livelier tumble (60–120 rad/s of spin, die restitution 0.55; the before
column is main at #336):
| target | bar | before | measured |
|---|---|---|---|
| dice at rest on another die / corrections after rest | 0 / 0 | 0 / 0 | 0 / 0 |
| dice corrected | 0.5 % | 0.000 % | 0.000 % |
| dice re-thrown | 0.05 % | 2.65 % | 1.18 % |
| median / p99 settle | 2 s / 4 s | 0.83 s / 1.53 s | 0.96 s / 1.76 s |
| rolls that gave up / forced settles | 0 / 0 | 0 / 0 | 0 / 0 |
| deepest die-into-die overlap | 0.2 mm | 5.29 mm | 7.76 mm (p99 roll 4.67 → 5.68 mm) |
| turns after landing | ≥ 1.00 | 1.55 | 2.70 |
| p99 step time | 8.33 ms | 0.22 ms | 0.38 ms (0.20 ms on a cooler phone, same physics) |
Re-run at the stack’s tip on 2026-10-04 before trying collision sub-steps (5.4): the same within noise — re-throws 1.19 %, settle 0.96 s / 1.96 s, overlap 7.76 mm, 2.72 turns, p99 step 0.23 ms; over 5,000 rolls each on seeds 2 and 3, overlap 6.94 mm and 7.90 mm and re-throws 1.23 % and 1.21 %.
60d20 over 1,000 rolls: turns 1.16 → 2.06, median settle 1.28 s → 1.52 s,
re-throws 5.11 % → 2.64 %, none gave up, slowest roll 10.5 s (4.7 s before).
100d6 still gives up within its first 200 rolls, before and after.
Fairness is done on the Pixel 10a (the d18 held to the worst-face bound), and
ModesAgreeTest holds power-saving and drawn modes to the same faces.
Fairness at the roundest edges at full strength: 100,000 rolls with
edgeRounding=0.06 and 0.015 (20,000 a shape passed at both,
decision 100; docs/physics-and-rendering.md, “How round a die may
be”), and the harness with coins (-s d2), which the sweep did not
throw and which is what gave first
Identical outcomes for identical seeds across JVM, emulator and device at
ten thousand rolls and on a second phone. The golden suite already holds
for its ten cases on both ABIs; any divergence is a release blocker.
A linux-x86_64 build of the bridge, made outside the build for the coin
replay (docs/physics-and-rendering.md, “Why a die could rock for
ever”), matched all ten cases and the phone’s coin give-up. Building it
in Gradle would put real rolls in the JVM tier
The 1–100 d6 sweep under counting (Pixel 10a, 2026-10-04, at #349; 200 rolls each up to 20 dice, 60 above): no roll gave up at any count, no die read while standing on another, p99 step at most 1.23 ms (60 dice) against 8.33.
| dice | 1 | 2 | 5 | 10 | 20 | 40 | 60 | 80 | 100 |
|---|---|---|---|---|---|---|---|---|---|
| re-thrown | 0 % | 0.25 % | 0.10 % | 0.20 % | 1.10 % | 1.42 % | 1.83 % | 1.38 % | 1.22 % |
| settle median / p99 (s) | 0.68 / 0.92 | 0.75 / 0.96 | 0.81 / 0.98 | 0.83 / 1.54 | 0.86 / 1.70 | 0.88 / 1.64 | 1.45 / 1.73 | 1.40 / 1.68 | 1.43 / 1.64 |
| deepest overlap (mm) | 0 | 3.6 | 5.6 | 4.4 | 6.4 | 5.3 | 6.0 | 6.4 | 4.8 |
| turns after landing | 2.73 | 2.90 | 2.96 | 2.90 | 2.54 | 2.02 | 2.03 | 1.94 | 1.82 |
100d4 seeds in JoltBridgeTest settle in under two
seconds (five gave up with the tetrahedron), and no harness roll gave
up. But 3.68 % of the dice in 20d4 and 5.28 % in 100d4 are re-thrown,
against 2.35 % and 3.07 % before. Why, from the harness’s breakdown
(2026-10-24, 20d4, 3.675 % in all): 1.500 % came to rest on another
d4, 1.225 % cocked against one, 0.950 % cocked against a wall, and none
cocked on the floor alone — so a long d4 never rests on an end. A 20d6
run is 0.125 / 0.125 / 0.225 %. Stacking is the largest share: prevent
long dice from coming to rest on each other while they move, then
leaningCornerCasesTest bounds the heap at today’s worst, 29. The target is
zeroTYPE_GRAVITY; or a clamped tiltExtremeInputTest
covers what a machine can driveMIN_SCALE would not refuse before 241 dice,
and the engine stops at 100. TableCapacityTest notices if MAX_DICE
passes 241docs/physics-and-rendering.md has the table). Sub-steps
alone will not reach 0.2 mm. The dice already sweep their travel
(LinearCast), which does not sweep a spinning die’s corners — at
60–120 rad/s that is the likelier source, and the next thing to look at20d20 throws (or tools/harness.sh --capture
20 and step through it). If it is, eight collision steps is the measured
fix — one constant in World::Step, then the goldens re-recorded and
FairnessTest re-run at 20,000 a shapeNothing corrects a die and nothing throws one but the player: a roll counts the
dice that can be read, and the ones that cannot wait where they lie for the
shake that throws them again (docs/physics-and-rendering.md, “Avoiding
stacked and cocked dice”; decision 70). Figures in the table above, measured
while the roll still threw them again itself; the harness’s scripted hand
keeps them comparable.
docs/design-handover.md)?60d20 rolls in
10,000 that gave up were dice leaning on the round post that stood in
each corner; the corners are fillets now (decision 81) and over 50,000
rolls a count give-ups went from 9 to 2 at 60d20 and 1 to 2 at 20d20.
Every one left — and most from before — is one die with its centre still,
turning at 1–31 rad/s about the line through two single contacts (two
neighbours, or a neighbour and a wall), which friction cannot reach and
0.02 damping takes minutes to stop (docs/physics-and-rendering.md,
“Why a die could rock for ever”). Missing physics: drilling friction.
Options for the owner — a per-contact spin resistance in the contact
listener, or more angular damping — both change every roll’s tumble, so
each wants the turns-after-landing figure, the goldens and FairnessTest
with it. Replays: 20d20 seed 1 roll 9996, seed 2 roll 5193; 60d20
seed 2 roll 4518, seed 4 roll 8361main at #411; throws otherwise look right). First
find which lag it is — before the dice appear, or at the toss when the
hand stops — then whether it lines up with dropped steps
(LiveRoll.droppedSteps, below) or a material compiling, and the toss
log (log.tag.dinfinity.toss) shows the restRollPace.WATCHED is now
0.4 and a 0.96 s 20d20 throw takes 2.4 s to watch. Is the travel right
now, or too slow? Does 1d20 a dozen times still feel prompt; does the
speed change when the hand lets go read as intended; does a second shake
at tumbling dice still answer instantly?100d4 and 40d6 —
does the end of the roll speed up smoothly rather than jump, and is
the wait for the last few dice short enough now? Do 1d20 and 5d6
still feel unhurried? The numbers are RollPace.TAIL_FROM_DICE (10),
TAIL_STARTS (0.25) and TAIL_ENDS (0.10)2a)?1d20,
20d6, 100d6felt-green, oak, dark-glass and plain sound like
their surfaces with 5d6; does 2d20 against 20d6 read as size? Is
power-saving’s one-second replay a throw or an effect?DieNumbers.FACE_SHARE (with “0.78 or
0.78 squared”, Open questions), the long d4’s narrow faces, the d18’s
small numbersClearSpace is the number)? Does
a chain that fills the tray read its stop line as a rule or a bug?MAX_STEPS_PER_FRAME is 4). With developer tools on, throw 4d6!
three times from a cold start and read the overlay’s
dropped <roll> · visit <total> line after each8d6 on the table: can the eye follow the stream out of
the one spot, and does 40d6 read as a patter rather than a wait?
The numbers to turn are BoardDrops.DROP_SPOT (the middle of the tray),
SPOT_JITTER_MM (1.5), DROP_INTERVAL_SECONDS (0.1),
LONGEST_STREAM_SECONDS (4), DROP_HEIGHT_MM (60),
LEAST_SLIDE_MM_PER_SECOND–
MOST_SLIDE_MM_PER_SECOND (40–150), MOST_DOWNWARD_MM_PER_SECOND (150)
and LEAST_SPIN_RADIANS_PER_SECOND–MOST_SPIN_RADIANS_PER_SECOND
(9–18). Also run BoardSettlerTest and read its timings (dinfinity.board
in logcat)feature/realistic-rendering: physically based lighting from a real
room (decision 89), refracting resin for translucent dice (90 — do the
milky and glassy ends need Resin’s constants turned, and are the full
shadows of clear dice acceptable), rounded edges drawn with the
solver’s own convex radius (91 — does the bend’s glint, now spread by
specular anti-aliasing, read as a soft highlight), felt and oak drawn
from CC0 pictures (92 — TableTextureDeviceTest passes on the phone
with each look’s sheen taken out of its colour; in the gallery, does
the green felt read
as the old green, the black felt as black cloth rather than flat grey,
the oak as warm oak — a little darker than before — and its walls as
wood, is the felt’s crinkle too strong — its normal map can be
softened — and does it shimmer as the dice settle, and is plain
unchanged; do satin metal dice at 0.6 read as metal — decision 106).
Judged with tools/gallery.sh before and after each
step, and merged to main once the owner likes it on the phoneMemoryTest holds the native heap across 500 rolls (1,440 bytes left
behind).
Frame targets met on the Pixel 10a (tools/harness.sh --rendered, decision
80): 60.3 fps at 20d20 with p99 work 8.3 ms and GPU 12.6 ms; 57.3 fps at the
100-dice limit (docs/physics-and-rendering.md, “Performance, and how it is
measured”). Re-run after any change to the renderer or the physics step.
docs/physics-and-rendering.mdDone when every target is met on the Pixel 10a and the user agrees the dice look right. Wrong numbers become the new numbers in the docs.
Decision 105, adopted by the owner on 2026-10-20.
Release.GAIN, LEAST_MM_PER_SECOND, MOST_MM_PER_SECOND,
ShakeDriver.LET_GO_STEPS, PAUSE_MARGIN, MOST_LET_GO_STEPS and Toss’s lift and spreads against the owner’s
slow-motion recordings of real throws (every shape, from straight above,
with a scale in frame), and re-run tools/harness.sh --release.FairnessTest re-run).Light past white is bent by a pass of our own since decision 108: identity below 0.8, PBR Neutral’s shoulder above, about 1 ms of GPU a frame on the Pixel 10a.
ToneCurve.KNEE (0.8) and
DESATURATION (0.15)FilamentStageTest
passes) but reads as solid: its shadow is a solid die’s, and Filament’s
screen-space refraction sees only the opaque scene, never the die’s own
back. Candidates: Filament’s transparent shadows for the lighter
shadow; the die’s back faces drawn into what glass refracts; a
stronger scatter and a glow from within for resindocs/physics-and-rendering.md, “There is no ambient occlusion”); a
pass of our own could mask it to the dice. Each effect is judged in the
gallery and paid for against the frame budget, as the rest of the
renderer isdocs/architecture.md, “Accessibility”)ValidationMessage, core/collection’s reader,
NotationReference) keep its English words or become a typed reason the
screen phrases; and is percent typography ("0 %", "< 0.1 %", "—")
text?v1.0.0rhombic-triacontahedron (d30) firstdocs/dice-sets.md, “Shapes after v1”)Branch coverage is ~89.3 % against a floor of 80. Most of the rise from 75.8 %
is JaCoCo 0.8.16 filtering the Compose compiler’s generated branches
(docs/build-setup.md, “Coverage”); the last two points are about two hundred
tests of real behaviour. What is still missed is mostly screens’ decisions and
layout (DesignerScreen, RollScreen, DInfinityApp, SavedScreen,
StatsScreen), guards that cannot be reached, and filesystem failures. What helps:
extract decisions from draw lambdas, and test shared components on their own.
Figures go in every PR description.
Found by the test pass of 2026-10-05, each left as it was so no test locks a wrong answer in:
SafeExtractor lets a tar device or FIFO entry through as an empty
file: TarArchiveEntry.isFile is true for anything not a directory, so
the documented refusal of “a link or a device” never fires for them.
Not exploitable (the extractor writes the bytes itself), but the
validator should refuse anything but a regular file or a directoryInstallSource.gitea drops a src/branch/<ref>/<folder> subfolder and
installs from the repository root; GitHub and GitLab honour one. A
blob/… link on either becomes a subfolder named blob/…GraphMachine.redraw() rebuilds the distribution with
truncatedMass = 0, so an exploding formula loses its tail after a tap
or a mode switch (nothing shows the value yet)GraphMachine.redraw’s formula ?: return cannot be false: graphed and
formula are set together, and could be one nullable pairCorrectionLadder.mayTouch or bias, though
its KDoc says every correction goes through mayTouch, and
withinBudget has only tests too. Either a bridge should ask them, or the
ladder’s documentation should stop saying one does. A design question, so
they are left in place (.claude/CLAUDE.md, “Nothing touches a die”)Found by the test pass of 2026-11-06, again left as they were. “Run” means a probe showed it; “read” means it was only read in the code:
RollMachine.type does not clear earned: land 1d6! on a six, type 2d20,
and the next shake throws the old d6 instead, with the machine left in
RollingSafeExtractor does not count skipped entries against its caps (read).
An entry off the allowlist is decompressed and discarded without touching the
500 entries or 64 MiB, so a big gzip named .bin costs time without limit,
against docs/dice-sets.md. Nothing reaches the diskSavedStatsPresenter: deleting a roll with its chart open leaves selected
on the deleted roll (run)PackageFolder.swap: failing with no previous package throws
NoSuchFileException instead of returning false, and leaves .mine.writing
behind (run)StatsPresenter.filterBy and rollUp clear selected but not watching, so
the next throw reopens the die (read; the chips are hidden while a die is open)DesignerPresenter: shutting the sheet during New set reopens it when the
create succeeds (read)Breakdown: JSON null for notation reads as the text “null”, and a
non-list adjustments throws away readable groups (read)PackageRoot: endsWith has no path boundary, so skulls also matches
old-skulls (read)InstallSource: an archive link with surrounding whitespace is refused, and
forge archive/… links become subfolders, like the blob/… item above (read)RollProgress.complete ignores range.more, so an exploding six reads as
complete; nothing in production reads it (read)GroupRoller’s empty groups, RollLoop:363,467,516, RollMachine:366,694,798
and TablesPresenter:261,286. PlanSpot.onThePlan has no production callermaterial.clearCoat only when a die has a lacquer, and
Filament’s default is a full coat, so plain, dark glass, a flat felt and
every flat wall are drawn under a smooth coat that also takes away their
own sheen (docs/tables.md, “What the floors draw as”). Writing it
always changes every flat look and StudioLightDeviceTest’s felt
(its red would come out about 45, not 34) — so it wants the flat
looks put through SurfaceLight as the textured ones are, judged in
the galleryFACE_SHARE is
applied twice (LabelRoom.centred), so a numeral is ~0.61 of its room.
Once would make every number 28 % bigger; tray and designer move togetherdensity reaches the physics, not the
sound; it needs a hull volume the catalogue does not compute100d4 fairly held to the twenty-dice settle bars (HarnessTargets)?1v?8c)?docs/dice-notation.md1u)? It is drawn now (decision 92), stretched over the floor by
[1, 1] tilingAtlasDecoder’s messages are
read by nothing; the set’s details screen (6b) is the obvious homecore/probability hand-rolls its convolution and FFT — a second opinion
on taking a libraryNone blocks code (design decision log of 2026-09-17).
THORIN'S CAMPAIGN)?ModernistContrastTest holds today’s ratio)docs/design-handover.md1u is a grid of cards. Rebuild, or change
1udinfinity.robolectricSdk from 36 to 37 when Robolectric supports
it; move the container’s emulator above API 36 when an automated-test
image exists (decision 39)/dev/kvm unconditionally — is that the right
default (docs/build-setup.md)?docs/physics-and-rendering.md, “Which
Jolt, and when to look again”)