Design: the notation field (option 2a), the three dice pickers (1h–1j), the parse-error state (9c) and the saved-roll screens (1n–1p, editor 1r, import 9f–9g) are in the clickable design (design/).
dInfinity accepts the tabletop notation most players already know, e.g.
3d6 + 1d20 - 4. This document defines exactly what is accepted and how it
is evaluated.
| Input | Meaning |
|---|---|
d20 |
one twenty-sided die |
3d6 |
three six-sided dice, summed |
3d6 + 1d20 - 4 |
sum of both groups minus 4 |
2d20kh1 |
roll two d20, keep the highest (advantage) |
2d20kl1 |
keep the lowest (disadvantage) |
4d6dl1 |
roll four d6, drop the lowest (stat generation) |
d100 or d% |
percentile: two d10 as tens and units (d% is an alias for d100) |
8d6! |
exploding d6: each 6 rolls an additional d6 |
2 * (1d8 + 3) |
arithmetic and grouping |
1d6 + 1d4 [Fire] |
trailing label, ignored for math, shown in breakdown |
brass:1d20 |
use the d20 from the installed dice set with id brass |
3{skull-d6}kh1 |
three of the die whose own id is skull-d6, keep the highest |
3{brass:skull-d6}kh1 |
the same, from the set brass |
3{skull:d6}kh1 |
three of the d6 of the set skull |
The app carries this reference too. Menu → Notation lists everything
below in the same words, with an example on every line that you can tap to put
in the tray’s field — because somebody with a phone in their hand at a table is
not reading a README. The screen is built from NotationReference in
core/notation, beside the parser, and a test puts every one of its examples
through that parser: an example the app would refuse fails the build rather
than the player (docs/architecture.md).
formula := expr label?
expr := term (("+" | "-") term)*
term := factor (("*" | "/") factor)*
factor := ("-")? atom
atom := dice | integer | "(" expr ")"
dice := (setref ":")? count? "d" sides modifier*
| count? braced modifier* ; a die named by its own id
count := integer ; default 1, max 1000 (see Limits)
sides := integer | "%" | "F" ; "%" is an alias for 100 (as 2d10), "F" = fudge/fate die
setref := identifier ; installed dice set id
braced := "{" (slug ":")? slug "}" ; {die} or {set:die}, no spaces inside
slug := [a-z0-9][a-z0-9-]* ; at most 40 characters
modifier := "kh" integer ; keep highest n
| "kl" integer ; keep lowest n
| "dh" integer ; drop highest n
| "dl" integer ; drop lowest n
| "!" ; explode on max face
| "r" integer ; reroll once on value <= n
| "min" integer ; clamp each die to at least n
label := "[" text "]"
integer := [0-9]+
identifier:= [a-z][a-z0-9_-]*
Whitespace is ignored between tokens. Most of the notation is
case-insensitive: D, F, kh, kl, dh, dl and min may be written in
either case, so 4D6KH3 and 4d6kh3 are one formula. Two things are not:
r must be lower-case. The reroll modifier is matched exactly, so
4d6r1 rerolls and 4D6R1 is a parse error.setref is scanned as [a-z][a-z0-9_-]* — it
has to start with a letter. A set id may start with a digit
(docs/dice-sets.md), but such a set cannot be named in a formula: 3dice:1d6
does not parse as a setref. Give a set you want to reference by name an id
that starts with a letter — or name it inside braces, where {3dice:d6}
reads, because the } says where the id ends.{Skull-d6} is an error, not the same die.A dice set may define dice under any id — skull-d6, a d100 that really has
a hundred faces (docs/dice-sets.md). Plain notation cannot name them: a die
id and a modifier are made of the same characters, so brass:skull-d6kh1 has
no reading that does not ask the installed sets where the id stops
(docs/architecture.md, decision 31). Braces close the id before the
modifiers start, so a set’s own die is written braced:
3{skull-d6}kh1 — three of the die whose own id is skull-d6, keep the
highest. Resolved like a plain 3d6: the default set’s, falling back to the
built-in set’s per die.3{brass:skull-d6}kh1 — the same, from the set brass, with no fallback,
as for any setref.3{skull:d6}kh1 — the d6 of the set skull.The set goes inside the braces and only there: brass:3{skull-d6} is an
error that says where to put it, so each die has one spelling and the picker’s
badges can read it back. A braced id is the die with that id and nothing
else: {d100} is a set’s real hundred-face die, never the percentile pair,
and {d10-tens} is the tens die thrown on its own. Everything else is as for
any group — the count, the modifiers, the limits, the breakdown, which quotes
the group as written.
The parser lexes what is between the braces without looking at any set,
which is what lets the field keep validating on every keystroke; whether a set
has the die is decided when the formula is planned (DieResolver), and a die
nobody has is the usual “no skull-d6 in set “builtin”” under the group. A
formula without braces means exactly what it always did, so every saved roll,
history entry and collection file written before is read the same way.
To keep the simulation and the probability graph tractable:
| Limit | Value | Behaviour when exceeded |
|---|---|---|
| Dice per formula (parse) | 1,000 | Parse error, shown inline. This bound exists so the outcome graph stays cheap. |
| Dice per roll | table capacity (docs/tables.md), hard cap 100 |
A shake throws nothing and the screen says how many would fit; the graph still works |
| Sides per die | must exist in a set | Parse error naming the missing die |
| Explosion depth | 20 | Further explosions ignored, noted in breakdown |
| Dice in the tray, including the ones explosions add | table capacity, hard cap 100 | The chain stops there, noted in the breakdown. An added die is dropped into clear floor, and a tray with none left cannot take one (docs/tables.md) |
| Nested parentheses | 8 | Parse error |
| Characters in a braced set or die id | 40 | Parse error; no dice set can define a longer id |
| Result magnitude | fits in 64-bit | Overflow is a parse-time error via the PMF bound |
A formula like 500d6 can be typed and graphed but cannot be rolled: the
dice would not fit on the table with room to move, and a physics engine with
that many bodies crammed in a box produces garbage rather than a roll. The
capacity check happens before any body is created and the UI explains it
(“500 dice don’t fit on the table; up to 72 do”).
Resolve each dice node to concrete Die definitions: setref if given,
otherwise the default set — chosen on that set’s own details screen
(docs/dice-sets.md, design 6a) and remembered with the settings —
falling back to the built-in set per die when the default set lacks that
one (so a set with no d12 still rolls 1d20 + 1d12). The breakdown names
the set each die came from and says when it fell back.
A default that is not installed, switched off, or no longer valid is not
a default: the built-in set stands in for the whole catalogue rather than
per die, because a catalogue pointing at a set nobody has cannot resolve a
plain d20 at all. The setting is left as it was — a set switched off for
an evening is still the one the player chose.
docs/physics-and-rendering.md, “Avoiding stacked and cocked dice”). So
an explosion is never decided, and a chain never asks for its shake, while a
die of the throw is still unread.docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”).
A chain stops at the depth limit, or sooner if the tray has no room left for
another die. In power-saving mode the same throws happen with nothing drawn.Produce a RollResult with the total and a per-die breakdown including
dropped dice (shown struck through). The result screen shows the total
large, then each group’s subtotal and the individual dice, then the
modifiers — the user never has to add anything up.
A chain that stopped says so, under its group. Everything else a die
has to say about itself is already legible on the row above — a dropped die
is struck through, a rerolled one stands beside its replacement, an exploded
one is simply another die in the row. The two ways a chain ends are the
exception, because what they describe is a die that is not there: an
8d6! that reached the depth limit and an 8d6! that ran out of table look
identical on the sheet, and both read as an explosion that never happened.
So the group carries a line saying which it was — “Exploding stopped at 20
dice.” or “The tray had no room for another die.” A reroll the tray had no
room for reaches the second line too, because it is the same fact about the
tray. Each line appears at most once per group, whatever the number of dice
that hit the limit, and in a fixed order rather than the order they happened
in.
The modifiers are the plain numbers the formula adds or takes away, each on
a row of its own and in the order they were written. They are the
top-level sum only: the 3 in (2d6 + 3) * 2 is multiplied along with
the dice, so listing it as “+ 3” would be adding up to the wrong number in
front of the player. A formula like that gets no such rows, and its total is
the formula’s own arithmetic — RollResult.itemised is what says which of
the two a result is, and it is checked rather than assumed, because “the
rows add up” is the one claim on that screen a reader cannot verify at a
glance. It is the same rule the picker’s badges follow (see “Picking dice
without typing”): edit or itemise only what can be read back.
Modifiers take effect in this order whatever order they were written in, so
4d6r1dl1 and 4d6dl1r1 mean the same thing:
r n — a die showing n or less is thrown once more. Once: the
replacement stands however low it is. Both dice stay in the breakdown, the
first struck through — which is also how a die a player picked up and threw
again is shown, every earlier face struck through in front of the one that
replaced it (physics-and-rendering.md, “Picking
a die up and throwing it again”). No die of a group with r n or ! can be
picked up: in a chain, which die counts depends on what was thrown because
of what. A reroll the tray has no room for does not happen and the die
stands as it fell, marked in the breakdown — the alternative being a die
dropped onto dice that have already been read.! — a die showing its highest face earns another throw of the same
die. The new die joins that die’s chain rather than the group at large, so
2d6!kh1 keeps the better of two chains, which is what a player means by
it. A chain stops after the explosion depth limit, or when the tray has no
clear floor left for another die; either way the die that would have exploded
again is marked in the breakdown.
The app does not throw the earned dice. The player shakes again. An
exploding six earns a throw, and a throw is something a hand does — so the
dice that are down stay down, the screen says how many a shake is owed, and
the next shake throws them. There is no button for it: a shake is the only
way anything is thrown (docs/physics-and-rendering.md, “Starting a roll”).
A chain cannot be stopped short. There was a Stop the chain option
beside it, and it put the roll away with no total — a roll thrown away
rather than a roll finished, which is not what the words promised. Scoring
what is on the table instead would need a reason a chain ended that is not
the tray’s, so rather than invent one the option was deleted. A chain that
has earned a throw is finished by throwing it.
Advantage earns nothing. 2d20kh1 is one throw of two dice followed by
a selection, so there is no second throw to wait for and no shake is owed.
Only ! and r n earn a later throw.
Every chain that earned a die is owed one at the same moment, and one
shake throws the lot. Three sixes in 8d6! are three dice, thrown together,
because that is what a hand does with three dice; asking for a shake each
would be asking three times for one act. Depth is the part that stays
sequential — a chain cannot know it needs a third die until the second has
landed — so a long chain is several rounds and a wide one is a single
handful. A chain that threw itself finished a roll the
player had not finished asking for, and it made the one moment worth
watching happen without them. Everything else about it is unchanged: the
earned die is thrown into the same tray, among the dice still down, on
a seed derived from the throw that started the chain — so a chain still
replays to itself however long the player takes between shakes. Still
down, because a throw that had to throw one of its own dice again took the
dice it had already read off the table to make the room, and those have
left: they keep their faces and the next link of the chain is neither
drawn over them nor aimed around them
(docs/physics-and-rendering.md, “The dice an explosion or a reroll
adds”).
min n — a die below n counts as n, per die. The face it actually
landed on is still what the breakdown shows; only its contribution changes.kh / kl / dh / dl — whole chains are kept or dropped, ranked by
what each chain came to together. A percentile pair counts as one unit, so
2d%kh1 keeps the better of two 1–100 results.Every roll shows what it is expected to come to: the lowest, the highest and the average. It is on the plate over the tray before the shake, where the Roll button’s label used to be the only thing saying what a throw was worth, and again under the breakdown on the result sheet, so the total is a number in a range rather than a number on its own.
The ends are the same reckoning the live range uses, asked of a throw that has
read no dice at all — so the figures a player reads before the throw and the
figures they watch close during it are one calculation seen twice. The average
is the exact mean of the distribution (docs/probability.md), and a formula
too large to graph exactly keeps its range and loses only its average.
A roll counts each die and takes it off the table the moment it can be read, so
by the time the last one lands most of the answer has been known for a while and
the dice that carried it are gone (docs/physics-and-rendering.md). What the
screen follows instead is how many dice have been read and how high and low
the finished roll can still come out, which is one line whether the throw was
four dice or a hundred.
The floor is exact: the lowest faces set off no explosion, so it is a total the
roll could really come to. kh, kl, dh, dl, min, arithmetic and
percentile pairs are exact at both ends — forcing the dice that have not landed
to their lowest or highest faces is a real assignment, and a group’s subtotal
only goes up as its dice do. Subtraction turns a group’s range over rather than
carrying it through, so 20 - 1d6 reads 14 to 19 and not the other way about.
A formula that can explode shows its ceiling with a +. A maximum face
earns another throw, so there is no useful highest number — counting every die a
forced chain could earn gave 8d6! a ceiling of 1008, attainable and useless.
The ceiling is instead every die at its highest, and every throw they earn at
its lowest. 3d6! is three sixes, and the three throws those earn coming up
one apiece: 3 to 21+. The + is the honest part — those three could have been
sixes too, and earned three more.
A lowest face explodes nowhere, so the chain stops itself rather than being cut off, and the number is a total the roll could really come to.
The mark comes from the ceiling’s reckoning alone. The floor draws extra dice as
well — a die forced low is what sets off r n — and a reroll is not an
explosion: its replacement stands however it lands, so 1d6r3 really does top
out at six and marking it would promise a number that cannot come.
A group may carry each modifier at most once, and may keep or drop, not
both: 4d6dl1dl1 and 4d6kh1dl1 are refused rather than quietly meaning
something. Keeping or dropping more dice than the group rolls is refused too.
Everything that can be known without rolling is checked while the formula is still text, so a roll in front of a player never fails halfway through:
kh/kl/dh/dl count the group cannot satisfy;! on a die whose every face is its highest, which would never stop;1d6 / 1dF is refused, 1d6 / 1d4
is not;Division rounds down by default (7/2 = 3), which is what most game
rules say. The default is a setting, and the result sheet offers Down /
Nearest / Up for the throw in front of you — the total is recomputed from the
same dice, which stay as they landed. Nearest rounds .5 up and anything
below .5 down.
The per-throw override is not remembered: the next roll uses the setting again. The outcome graph always uses the setting, since it is computed before the throw exists.
The roll screen also has a dice picker: tap a d6 three times and a d20
once and you have 3d6 + 1d20. A tap writes into the formula field, so what
comes out is a formula somebody could have typed — which is what makes the
outcome graph, the breakdown and statistics identical either way. A picked
roll can be turned into a saved roll with one tap.
It is a pull-down at the top of the table, headed Dice and carrying the
count of dice the formula asks for, so a menu that is shut still says what is
in the throw. The row inside scrolls sideways: ten dice at a touch target
worth pressing do not fit across a 360 dp phone
(docs/physics-and-rendering.md, “What is drawn over the table”).
Which set the row offers is chosen inside that same pull-down, under the
row, once there is a second set installed — one entry is furniture, so the
chooser is not drawn until it has something to choose between. It is not the same question as the default set:
which set a bare d20 means is a preference chosen where the sets are
(docs/dice-sets.md, design 6a), and somebody whose default is their own set
still reaches for a borrowed d20. A die taken from a set that is not the
default is written with it in front — brass:1d20 — so the row can only write
a formula that rolls what it showed; taken from the default set it is written
bare, because that is what a person would type. Changing the chooser leaves the
formula exactly as it is: what is already written was written on purpose.
The rules the picker follows, all of which fall out of “a tap writes a formula and never throws one away”:
1d6 into 2d6 rather than writing 1d6 + 1d6.3d6 - 4 gives 3d6 + 1d20 - 4, because a formula reads as dice and then
arithmetic.4d6dl1 shows no badge: the picker could
not take a die away from it without silently changing what dl1 drops.
Dice inside brackets and dice being subtracted are likewise not the
picker’s to change.docs/physics-and-rendering.md, “The dice
waiting to be thrown”). The board under the formula is what the throw will
be, so adding to the formula is adding to the board — and the new die falls
in and tumbles to a stop rather than appearing, because that is what putting
a die on a table looks like. It is not a roll and nothing about it is read.docs/face-designer.md, “Quick mode”).[label] come back spelled the way
they were typed. Only the spacing around the top-level + and - is
normalised, because that is the part being cut into.The row offers every die the selected set defines: the standard dice first
— dN, d% and dF, in their usual order — then the set’s own dice in the
order its file lists them, each captioned with its id and drawn as the solid it
is. A tap on a set’s own die writes it braced (see “A set’s own dice”):
1{skull-d6} from the default set, 1{brass:skull-d6} from another, counted
up to 2{brass:skull-d6} by the next tap like any group. A set’s own die whose
id plain notation already names exactly — a d3 — is written plainly, 1d3,
because that is what somebody would type; a d100 of its own is braced,
because plain d100 is the percentile pair (docs/architecture.md,
decision 75). The badges follow the same rule as ever: 2{d6} is not what the
d6 button writes, so it is not the d6 button’s to count.
d% is an alias for d100 everywhere: in typed formulas, saved rolls,
imported collections and the breakdown (which always prints d100).
Both are always resolved to a tens d10 (faces 00–90) and a units
d10 (0–9) from the same set, marked as a pair. Result = tens + units, with
00+0 = 100. If the set has a d10-tens die it is used; otherwise the normal
d10 is used with its face values multiplied by ten in the breakdown. Sets can
also define a true 100-face die but it is never chosen by d100 implicitly.
d2 is an ordinary die id: it uses the set’s d2 (a coin) if it has one.
Without a setref, a set that lacks one falls back to the built-in set’s coin,
like any other missing die, and the breakdown says which set it came from.
With a setref, a missing d2 is an error. Nothing stands in for a coin — no
d6 is relabelled 1,2,1,2,1,2.
A Fudge die’s faces are a minus, a blank and a plus — worth −1, 0 and +1 —
and the bundled df is labelled − − 0 + 0 + (docs/dice-sets.md,
“Numbering”). The faces and the breakdown print those symbols, and so does
the statistics histogram, which names each bar by the die’s own label
(docs/statistics.md, “Screens”).
A total of Fudge dice is written with its sign: +2, −1, 0. That is
how a Fate result is written, and it is the only spelling that says the same
thing about one die as about four. The minus is U+2212, the one the faces
carry, rather than a hyphen — a total set beside the dice that made it should
use the same glyph they do.
It applies when every die in the roll is a Fudge die, and to a group’s
subtotal on the same rule. 1dF + 1d6 is a count, because a d6 is a count and
a count plus a sign is a count. Exports are unaffected: a file is read by a
machine (docs/statistics.md).
The alternative was to print the die’s own label — −, +, 0 — for a roll
of exactly one die. It was refused because it only works for one: 4dF has no
single face to quote, and a d10, whose tenth face is printed 0 and is worth
ten, would read 0 for its best roll.
A saved roll is a named formula with an icon, living in a group:
SavedRollGroup {
id, name ("Curse of Strahd" / "Thorin"), icon,
parentId? // one level of nesting: game → character
sortOrder
}
SavedRoll {
id, groupId,
name ("Fireball"), icon (from icon pack or emoji),
formula ("8d6 [Fire]"),
colour tag,
sortOrder // the order the player dragged it into
createdAt, lastUsedAt, useCount
}
D&D / Thorin, Pathfinder / Ezren); deeper trees are not worth the UI.
One level is checked from both ends: a group cannot be put inside one that
is already inside another, and a group that has groups inside it cannot be
put inside anything.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. Which roll the formula came from
is carried across the wait, so the throw is still recorded as that roll’s
(docs/statistics.md). Switching the active group is one tap in the top bar,
and the active group also sets the default statistics session.The order is the player’s, and nothing else’s. There is no pinning and no favourites: each entry carries a grip, and dragging it moves it. The design took the favourite flag out in the pass of 2026-09-17, and the reason is that the two were solving the same problem twice — a favourite is a roll somebody wants at the top, and so is a roll somebody dragged to the top, except that the second one also says which of the favourites comes first. Recency is gone with it: a list that reorders itself between two fights is a list nobody can point at.
It reorders live under the finger rather than on release, and the row being dragged is the one under the pointer rather than the one the drag started on — which is what makes it work when a list is longer than a thumb. The list is its own scroll box, so it scrolls under a header and a group picker that stay put. The order is written down when the finger lifts, in one go, rather than at every step of the drag.
A row held near the list’s top or bottom edge scrolls the list towards
that edge, so a row can travel further than the list shows in one drag. The
band along each edge is 64 dp tall, and the speed grows with how deep into it
the finger is, from nothing at its inner edge to 640 dp a second at the
list’s own edge and beyond — tuning constants in SavedEdgeScroll, not
limits. A row picked up inside a band does not set the list moving by
being picked up: the band starts where the drag started, so the list only
scrolls once the finger has moved towards the edge (decision 78 in
docs/architecture.md). Letting go drops the row where it is, as any other
drag does.
A new roll lands at the bottom of its group’s list. It is a thing somebody has just made and not yet placed; putting it at the top would move everything they had placed down by one. The grip also carries move up and move down as accessibility actions, because a list that can only be ordered by dragging is a list somebody using a screen reader cannot order at all.
docs/architecture.md, “Settings”), because a roll’s icon is drawn in its
colour and a pale yellow on paper is an icon nobody can see. The clamp moves
a colour towards the ground’s own text colour until it can be seen and stops
there, so a tag that is already legible is drawn exactly as it was chosen —
and ink at night and bone on paper, each invisible as written, are not.
A colour beyond the twelve is picked, on a thirteenth swatch that opens
the app’s one colour picker — hue, depth and brightness, the same sheet the
face designer and the accent open (docs/architecture.md, “Modules”). It
used to be a hex code typed into a field, which is what a phone found
awkward. The hex is still printed under the swatches, because a hex code
is what somebody copying a colour onto a character sheet has in front of
them and a swatch is a picture. A tag is stored as the colour itself, so one
of the twelve and one somebody picked are the same kind of thing in
storage.docs/statistics.md) — opens blank
rather than guessing. “Save as roll” on the outcome graph still carries the
formula that was being read; a formula somebody chose is not replaced by one
they happened to throw.setref: gets
none, because somebody who wrote brass:1d20 asked for brass and quietly
handing them a different d20 would be changing their dice without saying so.
The roll is left exactly as written: the set may be re-installed tomorrow.Saved rolls travel as a collection: a JSON file describing groups and rolls.
{
"format": 1,
"name": "Thorin, level 5 fighter",
"groups": [
{ "id": "thorin", "name": "Thorin", "icon": "⚔️", "parent": null }
],
"rolls": [
{ "group": "thorin", "name": "Longsword", "icon": "🗡️", "formula": "1d20 + 7 [Attack]" },
{ "group": "thorin", "name": "Longsword damage", "icon": "💥", "formula": "1d8 + 4 [Slashing]" }
]
}
curse-of-strahd.dinfinity.json —
and is a copy in the cache, handed over through a content URI granted for
one use. Nothing the app holds is made readable to do it.docs/dice-sets.md), recognised in the same
place, so a community can keep a repo of “stat blocks for monster manual X”
and a player can paste its URL. The file picker offers every file rather than
only application/json: a collection mailed through three apps arrives as
text/plain as often as not, and a picker that hides the file somebody is
looking at is worse than one that lets them choose the wrong thing and be
told so.https only, a redirect that would leave
https refused, and the bytes that actually arrive counted rather than the
Content-Length believed — capped at the same megabyte a file is, so a
server cannot spend somebody’s data allowance proving that it should not
have. What arrives is then read by exactly the rules below, because there is
one validator and no path around it. A download that does not arrive is said
differently from a collection that does not read: “no collection came back
from that link” and “this is not a collection” are different things to be
told, and only one of them is worth going and fixing the file over. A
repository that arrives and holds no collection, or holds two, is said the
first way for the same reason: there is no file there to go and fix a line
of, and what is wrong is named in the line beneath.
android.permission.INTERNET has been in the merged manifest all along,
contributed by okhttp’s own manifest, so nothing about this asks the player
anything new.A repository holds one collection, at its root, named the way the app
names one. A dice set is a folder and a collection is a single file, so
something has to say which file in a repository is meant: it is the one whose
name ends in .dinfinity.json, at the root. That is the same shape
docs/dice-sets.md gives a package — a well-known name marking the thing —
and it is a rule that can be followed without reading this: export from the
app, commit the file the app wrote, push.
| What is in the repository | What happens |
|---|---|
one *.dinfinity.json at the root |
it is imported |
| none at the root, one deeper | refused, naming where it found one |
| more than one at the root | refused, naming them all |
| none anywhere | refused, saying what a collection is called |
At the root rather than anywhere, because a repository of stat blocks is
full of JSON and “anywhere” would mean guessing; one rather than several,
because a link names a repository and not a file, and an import that quietly
picked one of two would be picking for somebody. A forge’s tarball wraps
everything one folder deep in repo-<sha>/, and so does anybody who zips a
directory rather than its contents, so that one wrapper is seen through —
but only that one, because “the root” has to mean something definite.
https
link to a .zip or .tar.gz, with the branch or tag the URL named. The
tarball is fetched by the same downloader under the same one-megabyte cap
as the collection itself, and unpacked by the same hardened extractor a dice
set goes through: absolute paths and .. refused in either slash direction,
links and devices refused, the entry count and the expanded size counted as
the archive is read. Two things are narrowed for a collection: only .json
is written at all, and the whole archive may expand to no more than the
megabyte the collection itself is allowed. The app is here for one page of
JSON, and a repository that expands to more than the file it carries is
asking the phone to unpack a library to read a page.- and _ — not UUIDs,
because a collection is a file people edit by hand: "group": "thorin" is
something a person can type where a UUID is something a person mistypes.
Exporting re-derives them from the group names, so exporting the same
collection twice gives the same file.format this version does not know is refused, not read as best it
can. Reading a newer file anyway is how a format silently drops whatever the
newer version added.rolls[3].formula).Every formula field — the tray’s drawer, the saved-roll editor, the outcome
graph — carries a × at its end while it has anything in it. One tap empties
it and leaves the cursor in it, so the keyboard stays up for the next
formula (design/dInfinity.dc.html, options 2a, 6f and 9c). A phone has no
select-all worth the name, and the alternative was holding backspace through
8d6 [Fire] + 2d4kh1. An empty field has no ×, because there is nothing for
it to do. To a screen reader it is one button, “Clear the formula”, with the
48 dp target of every other control.
It is not a second way to empty a formula: the × hands the screen the same
empty text the last backspace would, so each screen does exactly what it
already does with an empty field. On the tray that is the Empty state —
the waiting dice come off the board and nothing is left to throw
(docs/architecture.md, “The roll screen”, the clear the field edges); the
editor has a roll with no formula, and no odds under it, until one is typed;
the graph goes back to the note it shows with nothing to draw. It throws
nothing, and nothing that has been saved is changed until the editor saves.
Parse errors point at the offending character range and are shown inline
under the formula field: the formula is printed again with a squiggle under
the characters that are wrong, and the message after it
(design/dInfinity.dc.html, options 6f and 9c).
3d6 + 1d7 - 4 — this set has no d7
~~~
Printed again rather than marked up in the field itself. The field is where somebody is typing, and a squiggle that moves under the cursor as they type is a squiggle that fights them. A wave rather than a straight underline, because a straight underline under text reads as a link.
The blame is clamped to a character that exists: a formula that stops in the
middle of something (3d6 +) is blamed on a position one past its end, and
there is nothing there to underline.
Suggestions are offered when the intent is obvious (d7 → nearest available,
3 d 6 → 3d6), as one tap under the message. A braced {d7} is offered the
nearest dN still braced; a braced {skull-d7} is offered nothing, because
there is no honest nearest to a skull. Empty braces ({}), a { with no },
and a character that cannot be in an id ({Skull}, a space) are each an error
of their own, pointing at the brace or at the character. Where there is no honest
reading there is no suggestion: a guess that is wrong is one tap away from
replacing a formula somebody meant.