The calibration ledger¶
data/calibration.json holds every physics constant the engine runs on, and each one carries how
well it is known. Read this page before you rely on one of those constants or change one. It tells
you what an entry means, how far to trust it, and which keys are still open. Nothing in the Rust
core or in the Python layer may hardcode a number that appears in this file.
Why it exists¶
This project's predecessor spent weeks trying to make pathfinding match the real game. The cause was not difficulty. Plausible numbers and measured numbers were stored the same way, so a wrong guess was indistinguishable from a fact. Later tuning then absorbed the error instead of revealing it. A constant fitted to the one case anybody checked makes a wrong law read as right, on every board at once.
The ledger's job is to keep those two kinds of number apart, permanently and visibly.
What an entry looks like¶
"PATH_SEARCH": {
"value": "client16402",
"status": "measured",
"confidence": "HIGH",
"candidates": ["client16402", "trace_fitted_astar"],
"provenance": "...the client version, the captures, and what was compared...",
"promotion_rules": "...the observation that would settle or overturn it...",
"engine_contract": "...which code reads it, and what it changes..."
}
| Field | Meaning |
|---|---|
value |
what the engine runs on. Compiled into the crate; changing it needs a rebuild |
status |
how the value is known, from the vocabulary below |
confidence |
HIGH / MEDIUM / LOW, and which part of a key is which when a source settles only part of it |
candidates |
the other values the engine implements. state.rs::pick refuses a candidate string with no implementation, so a candidate is always runnable |
provenance |
the evidence: the client version, the trace or capture, the counts |
promotion_rules |
the observation that would raise the status, written before it is made |
engine_contract |
which code reads the key and what changes when it changes |
refuted_for |
the part of the space this entry's own shipped value is known to be wrong for, or * for all of it. It sits beside the confidence that says so |
supersedes_globals |
present when a measured value departs from a shipped table ON PURPOSE: the key, the value that table holds, and why. tools/extract_globals.py reports a declared divergence and fails an undeclared one, so the two can differ without breaking the build and cannot differ silently |
Status vocabulary, in increasing order of trust¶
| Status | Meaning |
|---|---|
guess |
nobody has evidence; a placeholder so the engine runs |
disputed_existence |
the key names something no shipped data or recording shows exists; ranked with guess, because nobody has evidence either way |
hypothesis |
an argument from the shape of the data, not an observation |
community |
multiple independent third parties agree, with no primary source |
third_party_measured |
one outside measurement, its source, method and sample named, not reproduced by this project's instruments; ranked with community |
datamined |
taken from shipped game data. State the file and the vintage |
measured |
observed in the real client by this project's own instruments |
owner_ruling |
a maintainer's direct observation of the live client, quoted verbatim and dated |
third_party_measured ranks with community, and unlike measured it is never overwrite-protected: this project's own measurement replaces it without --supersede. It was added on 2026-09-24, when two outside readings were found filed as measured, a status that would have resisted the very correction that should replace them.
owner_ruling ranks with measured, not below it: a direct observation of the live client is
a primary source, and the live client is the target. A ruling may settle only part of a key, the
sign of a push but not its vector, say. In that case confidence names which half is which and
a promotion_rule stays open for the rest. No entry holds this status today. The only one did:
knockback.DIRECTION_ROLLING, a ruling on the sign of the Log's push. A measurement overturned
it on 2026-09-25, and the ruling is kept in that entry's supersedes.
How far to trust the provenance¶
The status vocabulary is one claim and the provenance prose is another, and they are not equally well checked.
A re-read on 2026-09-22 went through 24 entries against the corpus, of the 148 the ledger held that day. The ledger has grown since. Counted against it today, the re-read went through 24 of the 397 entries. It moved no status and no value. What it turned up was in the evidence the statuses rest on: 42 places where a cited number, recording name or piece of arithmetic does not hold. Take that as a reason to re-derive, not as 42 established defects. Only a handful of the 42 have since been recomputed by hand, and one of those did not survive the recomputation. The supported claim is that the set needs re-reading.
formation.GROUND_Y_CLAMP is the worked example. Its status of measured was defensible and
four of its statements were wrong, including a capture whose real numbers are 31053/31057
where the entry said 31000. It has been rewritten. What is true of that key now: the clamp is
pinned by 34 members over 17 clean groups, the two seats' back-edge bounds are a full row apart
rather than half a row, side 1's river bound is one native unit looser than the rotation rather
than tighter, and side 0's range is pinned by nothing at all, which the entry now says.
373 entries have not been re-read. So, concretely (every count on this page comes from
python tools/ledger_census.py, and tests/test_ledger_census.py fails when the page and the
ledger disagree, because these figures went stale twice in one afternoon before that gate existed):
- The status on a key is worth trusting. No status moved in the re-read.
- The shape of an entry is worth trusting where a judgement was made, but it is not
universal, so check rather than assume. All 397 carry a status. That 397 counts TOP-LEVEL
entries; one further entry,
pathfinding.PATHFINDING_COSTS.application, is nested inside another and carries its ownmeasuredstatus, so counting every status in the file gives 398 and 285 measured. The tools agree on 397 by convention, and the convention undercounts by one. 338 name the rivals the value was chosen against and 352 state what would move it, and 331 do both. The gap is mostly the 30dataminedkeys, where the number was read out of a shipped table and no choice was made, so a candidate list would be a category error; those carry a vintage and an engine contract instead, which is the right shape for them. But the gap is not only those. 26 of the 284measuredentries name no rival at all, and 13 of those state no promotion criterion either. This file's rule is that evidence is discrimination and never origin. A measured key with no candidate list has therefore recorded nothing that it was discriminated against. Some are harmless (time.TICK_MShas no plausible rival).pathfinding.PATH_GOAL_RULEandmovement.CONTACT_DOMAINare exactly the kind of rule that should say what it beat. They are on the re-read list. - Any specific number inside a provenance string is worth re-deriving before you build on
it, and that includes a rival's score and a promotion criterion. Those are not safer than
the rest. Of the 24 entries re-read, 10 findings land on a candidate list and 7 on a
promotion rule.
GROUND_Y_CLAMP's fourth error was in itspromotion_rules, which called side 1's river bound undiscriminated while a clean group stood two Goblins exactly on it.targeting.ATTACK_RANGE_RULEstates a losing candidate's sum as 8500, which is Range plus the tower's own radius, the very term that arm drops; the real sum is 8100, so the observation still discriminates, but eight ticks later than the entry implies.
The ledger is checked against its own corpus, and that check is not finished.
Changing a value¶
Change a value only with new evidence, and write the evidence into the entry: what was measured, on which client version, and in which trace or capture.
oracle/calibrate.py enforces the ordering. A value at measured or owner_ruling cannot be
overwritten with a different value without --supersede, so a later result that disagrees with an
earlier measurement is refused rather than applied quietly. Superseded readings stay in the
entry's history instead of being deleted. An argument that loses to a measurement belongs beside
what overturned it.
After changing any value, rebuild: ..\.venv\Scripts\maturin develop --release. Prose-only
edits need no rebuild (see contributing.md).
Ground truth, and which source wins¶
Three bodies of evidence sit behind the measured entries:
| Source | Client | Where |
|---|---|---|
| Offline traces | 15.535.29 | data/oracle-native/ (gitignored, large) |
| Live captures | 16.402 | recorded from the real game by the client instrument; each entry names the capture it rests on, and the recordings are not distributed |
| Shipped game data | 2016-2018 vendored, 2023 cross-reference | data/raw/ |
Where the two clients disagree, the live 16.402 client wins and the entry says so: the target is the live game, not a frozen build. Shipped data from 2016-2018 is evidence, not spec. Ground movement was rewritten on 2025-03-31, so anything pre-2025 about movement is archaeology until somebody re-measures it.
tools/oracle_diff.py diffs the engine against a trace tick by tick;
crates/royalesim/tests/oracle2026.rs gates the recorded first paths.
Open keys and what would settle them¶
Everything below is at guess, hypothesis, community or datamined-but-unverified. The
engine runs on a placeholder for these, so the behaviour it produces is not evidence about the
real game. Each row names what a recording would have to show. The ledger is the authority; this
table is a reading guide over it, and a key's own status and promotion_rules win where the two
disagree. Of the 397 top-level keys with a status, 284 are measured; one more entry, nested inside another, is measured too (398 in all).
| Key | Value today | Status | Settled by |
|---|---|---|---|
collision.PUSH_MODEL |
mass_weighted |
guess, LOW | a mass-ladder recording: units of known Mass pushing each other |
collision.BUILDING_FOOTPRINT_MODEL |
collision_radius_circle |
guess, LOW | a walk past a building; note that circle and 2x2 box differ by 0.044 tile at best, so only 3x3-vs-not is separable |
collision.SEPARATION_ITERATIONS |
1 | guess, LOW | a crowd recording with per-tick positions |
pathfinding.TIE_BREAK |
ortho_first_placeholder |
guess, LOW | read only by the trace-fitted arm (path2026.rs); the selected arm reproduces the published node lists outright, so the key no longer gates it |
combat.DAMAGE_ARITHMETIC |
integer |
guess, LOW | hit counts to kill a tower at known levels |
targeting.LOGIC_RANGE_EXTENSION_TO_KEEP_TARGET |
25 | datamined, LOW | vendor the modern data, or measure a target held past its range |
targeting.LOGIC_XPOS_BASED_TOWER_TARGETING |
true | datamined, LOW | a centre-column deploy: which tower it walks at |
match.LOGIC_BATTLE_START_COOLDOWN_MS |
4500 | datamined, LOW | any recording of a match start |
arena.ARENA_SOURCE_VINTAGE |
~2018 tilemap | datamined, MEDIUM | a calibrated screenshot of a live arena; bridge width varied by arena even in 2018 |
knockback (5 of 13 keys) |
the measured ladder, with its duration, water, stacking, zero-vector and deploying-unit edges unfixed | guess / hypothesis, LOW-MEDIUM | each key's promotion_rules names the capture it needs. DISPLACEMENT_LAW, ATTACK_RESET, PUSH_LOAD_TIMER, DIRECTION_ROLLING, ROLLING_CONTACT_RADIUS, ATTACK_PUSHBACK and DEATH_PUSHBACK are measured |
spells.* (10 of 41 keys) |
AOE_HIT_TEST and SPAWNING_SPELL_WATER_RULE are in spell-spec.md; the other eight are not, so read their ledger entries |
guess / hypothesis / community | each key's own promotion_rules in the ledger names its deciding observation |
status.* (13 of 26 keys: stun and buff timing) |
see spell-spec.md |
community / hypothesis / guess | likewise |
economy (2 of 7 keys) |
the elixir a death pays the opponent, the starting-hand rule | guess / community, LOW-MEDIUM | each key's promotion_rules names the 15.535.29 scenario it needs. MANA_ON_DEATH_FOR_OPPONENT_UNIT rests on the tables' pattern. The other five are measured: the Elixir Collector's payout at the cap, its overflow, its step in double elixir, its stun and the elixir its death pays its owner |
spawner.INTERVAL_START_ORIGIN |
placement_counter_first_frame_counts |
hypothesis, MEDIUM | an interval spawner whose DeployTime is not StartCounterAt - 950: the tick of its first unit |
rng.GENERATOR |
pcg32 |
guess, LOW | not settleable, and not a goal. See architecture.md, Determinism |
enchant (5 of 17 keys) |
the Rune Giant's places, cooldown origin, stun at the pick, crown-tower bonus and arrival reach | hypothesis / guess, LOW-MEDIUM | each key's promotion_rules names the run it needs; the other 12 are measured on client 15.535.29 |
transform.HEALTH_TRIGGER_COMPARE |
at_or_below |
guess, LOW | a hit that brings a Goblin Demolisher to exactly 650 of its 1300 hp |
parry.SAME_TICK_PICK |
first_created_attacker |
guess, LOW | two melee hits landing on one tick on a ready Ronin (a swarm's first contact) |
parry.READY_AT |
spawn |
guess, LOW | a melee hit on a Ronin within its first 20 ticks, while it deploys |
The keys that carry the measured 2026 movement and pathfinding model are at measured, most of
them at HIGH; each entry's confidence names the ones that are not. They are time.TICK_MS,
time.SPEED_TO_SUBTILES_PER_TICK, time.PROJECTILE_SPEED_TO_SUBTILES_PER_TICK,
pathfinding.PATH_SEARCH, collision.CONTACT_LAW, the movement.* section except
BUFF_SPEED_COMPOSITION, SPAWN_PATHFIND_STATES, SPAWN_PATHFIND_START and JUMP_LANDING_CONTACT (hypotheses), and the cost, goal and replan
keys. Their evidence is in pathfinding.md and movement-measurements.md.
These keys were measured later, on the 16.402 corpus or client 15.535.29, and are measured too:
| Key | What it settles |
|---|---|
match.TICK_ORDER |
attack updates before move updates, the move pass in creation order |
match.KING_ACTIVATE_TIME_MS |
the king's activation delay, 3550 ms |
combat.CROWN_TOWER_DAMAGE_ROUNDING |
how a crown tower's reduced share of a spell's damage rounds (ceil_kept_share) |
movement.JUMP_WATER_HOP |
a JumpEnabled troop's river hop |
movement.DYING_UNIT_VISIBILITY |
whether a dying neighbour is still an obstacle this tick |
combat.STAT_BASE_LEVEL, combat.TOWER_HITPOINT_LADDER |
level scaling and the crown-tower ladder |
combat.ATTACK_CYCLE, combat.PROJECTILE_LAUNCH, combat.KAMIKAZE_DEATH |
the attack cycle, the launch point, the kamikaze death |
lifetime.HP_DECAY |
a building's hit-point drain over its lifetime |
formation.LAYOUT, DEPLOY_STAGGER, GROUND_Y_CLAMP |
where a card's summons stand, and when each appears |
spawner (34 of 41 keys) |
emission timing, the first wave, the start-time origin, the two deploy-time defaults, the death-spawn layout, an emission's water turn, and more. The Goblin Hut's wake reach, wake targets and spawn speed (LIFE_STATE_WAKE_REACH, LIFE_STATE_WAKE_TARGETS, ACTION_SPAWNER_SPAWN_SPEED) were measured on client 15.535.29 only |
knockback.DISPLACEMENT_LAW, ATTACK_RESET |
the push ladder and what a landed push does to the attack |
charge.CHARGE_RANGE_UNIT, CHARGED_HIT_TIMING |
the run-up's unit and when the charged hit lands |
The hide.* section is mostly community (5 of its 7 keys). Half of the status.* section is
community, hypothesis and guess (13 of its 26 keys). hide.RISE_LAW,
hide.TARGETABLE_WHILE_RISING, status.ATTRACT_LAW, status.ATTRACT_WHILE_HELD,
status.FULL_STOP_BUFF_IS_STUN, status.BUFF_PULSE_AMOUNT, status.AREA_BUFF_SOURCE_BINDING,
status.APPLY_BUFF_BEFORE_DAMAGE, status.BUFF_DEATH_SPAWN_DEPLOY_TIME,
status.CROWN_TOWER_DAMAGE_PER_HIT_SCALING, status.DAMAGE_REDUCTION, status.IDLE_BUFF,
status.STUN_CLEARS_TARGET, status.RESUME_RETARGET_WINDUP and status.WAITED_PRESS_CAST are measured.
status.DAMAGE_REDUCTION is measured at reductions of 100 and 65 on client 15.535.29 and at 15 and 60
on 16.402.
Each open key carries the observation that would settle it.