Two-phase leveling built for an autoplay climb: a gentle journey to level 66 that opens the whole tower fast, then a steep geometric endgame that carries hundreds of hours.
| Quantity | Expression |
|---|---|
| XP required, levels 1–66 | 10 × L^1.75 |
| XP required, levels 67–100 | The rows of XPCurve.csv: per-level growth ramps from 3.1% at 67 to 16.0% at 99 (C1, 2026-10-23; the ramp’s end +12% → +16% by D’s T53 ruling, 2026-11-02) |
| XP required, player level 101+ | XPCurve.csv row 100 XP_Account_Flat = 354,245 — flat forever (player level only; creatures and classes cap at 100) |
| XP per kill | Not a formula — a table. XP_Kill_<tier> in codex/XPCurve.csv, indexed by the enemy’s level, on the game-wide value ladder C 1.0 / UC 1.5 / R 2.5 / SR 4.0 / SSR 7.0 (level 1 floored at 4 XP). Client xpKill() and server TABLES.xp_kill read the same 100 rows; no multiplier is applied on top |
| XP per battle | The sum over the battle’s 3 kills. Because encounter_weights.csv is tuned per floor against that same ladder, a full battle on floor L averages ≈ 10 × L — within ±0.8% from level 10 up (floor 100: 996.6 against 1,000). Below level 10 the 4-XP floor lifts it above the line |
| Battles per level | XP_To_Next(L) ÷ XP-per-battle(L): 1 at level 1, 24 at 66, 24 at 67, 308 at 99. Exact per-level values in the table below |
Computed from codex/XPCurve.csv and the on-level battle of tools/codex_tables.py (xp-formulas).
The two phases are continuous at level 67. Per-kill XP is not flat in the enemy's level — it is the authored table — but a whole battle tracks 10 × L closely enough that the battle counts follow the requirement curve almost exactly.
Log scale, plotted from codex/XPCurve.csv. Requirement for the next level and cumulative XP to reach the level. The knee at 67 is where 10 × L^1.75 hands off to a geometric phase whose per-level growth ramps smoothly from +2.7% (the polynomial's own rate at 66) to +16% at level 99 — no step anywhere; levels 90–100 are 60% of the total (C1, 2026-10-23, ramp end +12% → +16% by T53, 2026-11-02; before C1 it was a flat +17.5% from 67 and 78.5%).
The same curve divided by what a battle actually pays — battles spent at each level and cumulative battles to reach it, at the level-appropriate floor's own rarity mix over the −3…+3 spawn spread. Log scale. Level 100 has no bar of its own: it is the finish line, about 3,730 battles out (see Milestones).
| Level | Battles at that level | Cumulative battles | Cumulative time* |
|---|
*At ~109 battles/hour on autoplay — the economy simulator’s figure (dfmc-client tools/sim_econ.mjs BATTLES_PER_HOUR), about 33 s per battle. Generated by tools/codex_tables.py.
python tools/statmodel.py xp in dfmc-client. Before C1 (2026-10-23) the same climb was ~18,900 battles ≈ 173 h, 130 h of it in 90–100; the C1 curve (ramp ending at +12%) was ~2,900 battles ≈ 27 h until D’s T53 ruling (2026-11-02) ended the ramp at +16%. Reaching level 67 — where wild Stage 3 creatures appear — is unchanged at ~885 battles.Read live from codex/XPCurve.csv — the same 100 rows the client's xpLevel()/xpKill() and the server's TABLES.xp_cum/TABLES.xp_kill read. Nothing here is restated by hand. XP to next and cumulative are the requirement curve and apply identically to creatures, classes and the player level; the five per-kill columns are indexed by the enemy’s level, not the recipient's. XP/battle and battles are derived against that floor's own rarity mix in codex/encounter_weights.csv over 3 kills — a boss floor has no wild spawns, so it inherits the last wild floor's mix.
What actually sets the number. Per-kill XP is indexed by the enemy’s level, so XP per battle is set by the floor you farm and never by your own level — out-levelling a floor earns you nothing extra, it only costs. Three things are measured rather than assumed:
_ledK / _ledB from the server's own ledger). The assumption behind every battle count on this page.buildSpawn() rolls an offset of
−3…+3 at 3/7/12/56/12/7/3%, clamped 1–100, and the whole spread sits
inside the grace band, so on-level really is penalty-free on every roll. Averaging over it rather than reading the
floor's own row moves the number by under 0.35% above level 4 — one battle in 20,346 across the climb._ledC / _ledX is the retention the account actually got. On the reference account — class 31 on
floor 23, a gap of +8 — the formula predicts 0.796 and the ledger reads 0.807.| Gap | Level penalty | XP retained | Battles per level |
|---|---|---|---|
| On-level, or up to 3 above / 10 below the floor | 0 | 100% | ×1.00 (the table) |
| 5 levels above the floor | 0.082 | 91.8% | ×1.09 |
| 8 levels above the floor (measured, reference account) | 0.205 | 79.5% | ×1.26 |
| 14 levels above the floor | 0.450 | 55.0% | ×1.82 |
| 19 levels above the floor | 0.655 | 34.5% | ×2.89 |
| 25+ levels above the floor | 0.900 | 10.0% | ×10.0 |
| 19 levels below the floor (measured, floors 97–99) | 0.203 | 79.8% | ×1.25 |
| 50+ levels below the floor | 0.900 | 10.0% | ×10.0 |
The multiplier applies to class and creature levels only. Account (player) XP takes no penalty at all — it is the raw sum — so the account level tracks the on-level column exactly, provided the character is farming the floor that matches it. Full curve in Level Penalty.
Source: codex/XPCurve.csv · mix from codex/encounter_weights.csv · spawn spread from app.js buildSpawn(). Level 100 carries XP_To_Next = 0: creatures and classes are maxed there, and the player level continues at a flat XP_Account_Flat (354,245). Battle counts assume the enemy is level-appropriate, so the level-gap penalty is zero — see Level Penalty.
Separate from creature levels and class levels. The player (account) level is an account-wide marker of total time in the tower. It grants no stats and no unlocks, but since 2026-08-25 it has one job: it gates character gear — tier N needs account level ≥ (N−1)×10, T10 = 90 (D ruling: “Account level gates gear”; On release.; mechanics on Equipment). Tags are gated by the creature's own level, never by this one. It exists so that the endgame keeps counting, and so that a future system has a natural place to hook into. (This paragraph said “no gear requirements” until 2026-08-25; that was true only because nothing enforced them.)
| Property | Rule |
|---|---|
| XP source | Every point of XP the account earns, from every battle. Player XP is never split — it is the raw total. |
| Curve, levels 1–100 | Identical to the creature / class curve: the rows of XPCurve.csv (10 × L^1.75 to 66, then the C1 ramp). There is no formula fallback: with no curve loaded the account reads level 1, as a creature does. |
| Curve, levels 101+ | Flat. Every level past 100 costs XP_Account_Flat (row 100 of XPCurve.csv) = 354,245 XP: the C1 ramp continued one step past 99 (+16.40% on the 304,327 of 99→100), then held constant. No cap; the counter never stops. It is codex data so the vanity pace can be retuned without touching the leveling model; a DATA_PIN older than the column continues the curve’s own 98→99 growth one step instead (never a constant in the client). |
| Grants | Nothing. Deliberately inert. |
Pacing, past 100: at level-100 enemies (kill XP 118–827 by rarity, XPCurve.csv row 100 — roughly 1,000 XP per battle) each player level is ~355 battles ≈ 3¼ h of autoplay at the measured 109 battles/h — a level most evenings for a steady player, so the counter visibly ticks over instead of freezing. Reaching player level 100 itself takes 2,936,130 XP (the curve’s cumulative at 100, the same number playerLv() reads); every level after is the flat 354,245, climbing linearly forever instead of exploding. Continuing the ramp past 100 was rejected as too slow to feel alive.
The party occupies gear slots 8 and 9, and the battle units
a1/a2 are derived from whatever record sits in them. The credit used to be
written back the same way — straight into equipOv[8|9] at commit time. The Equipment
window can be opened during a battle, so that record is not guaranteed to still be the creature
that fought.
Reported by a player on 0.31 and reproduced on his own save: a level 28 Scorchscale fought a full battle, was swapped out mid-fight, and received nothing; a level 1 Vaettir swapped into the slot received 282 — the gain computed from the fighter's level and applied to the newcomer's base. That is also a power-levelling exploit: swap a fresh creature in before the killing blow and it earns at a veteran's rate.
uid of the record it was derived from
(monFromSlot / mkMon).partyRecFor(un, gi, st) resolves the fighter's record wherever it now sits: still in
its slot, moved to the other slot, or back in the bag. A fighter found in the bag is paid there.crXp species map,
so legacy saves keep working.commitSoulEvolution has carried since 2026-07-27.The advance check had the same defect, with a worse outcome.
advancingAlly also read the slot, so after a mid-battle swap it took the newcomer's level
and stamped the newcomer's uid — which meant commitPartyEvolution's own
guard passed and wrote the fighter's next form into the newcomer's record. That is the exact
cross-line corruption the guard exists to prevent, arriving through a different door. It now resolves
through partyRecFor as well.
tools/probe_partyswapxp.py — BEFORE 11/11 (the bug
reproduces on the reporter's save), AFTER 12/12, including an assertion that the unit carries a real
uid so the match is exact rather than a species-name guess.The server, since 2026-08-01. Class XP is the avatar's share of the same battle, and it is derived from the same authored tables the account total comes from — the client is not asked and is not believed.
| Quantity | Rule |
|---|---|
| Account XP (player level) | The raw sum of XP_Kill_<tier> over the kills the server derived from its own seeded slot. No penalty is applied — player XP is never split |
| Class XP | The same kills, each scaled by the level-gap penalty between the avatar's class level and that enemy: round(base × (1 − penalty(enemyLevel − classLevel))). The class level is read once, before the battle |
| Class level | Per class since 2026-10-01: each class line has its own row in ledger_cls and the battle credits the class it was fought as. Derived from that class's credited total against XP_Cumulative in XPCurve.csv — the server needs nothing from the client to know it |
| Ceiling | Classes cap at level 100, so 18,464,128 (the cumulative at 100) is an absolute ceiling on class XP. Below that, a save may only claim what the server has credited since the previous accepted save |
Two consequences fall straight out of the definitions. Class XP can never exceed account XP, because the penalty is never negative — an account where it does has a starved account total, not a rich class total. And at a level gap inside the grace window the penalty is zero, so the two totals are identical; that is the sharpest available check that both are being computed from the same table.
What this replaces. Until this landed the ledger held account XP, Ichor, kills and battles — and nothing at all for class XP. The save door capped it only on a newborn account, then merged it grow-only forever after, so on any established account a crafted save could set class XP to any number and keep it permanently. Class advancement was the last progression system still authored entirely by the client.
Grandfathering. The class ledger starts counting from the day it shipped while every live account's class XP is historical, so the ceiling is a delta: each accepted save is stamped with the server's class total at that moment, and the next save may claim what was credited since, plus the standard grace. An account with no stamp yet keeps what it holds. This is the same shape as the kill and battle counters, and for the same reason — a flat total would have confiscated real progress on the first save.
LIVE on release since 2026-08-04 (0.31). Owner ruling, standing: no client-credited battle
rewards. Account XP, class XP and Ichor are not moved when a battle ends. /api/battle
answers with the figures the server derived from its own kill list, and those are the only numbers that
ever reach the account or the screen.
What this replaced, stated precisely, because the end state was already correct. Since 2026-08-03 the client credited its own arithmetic and then reconciled to the server's number as a delta when the response landed — so the stored total was already the server's. What was wrong was the transient: for the 67–256 ms until the response arrived, the player's totals held a figure the server had never agreed to, and an uncredited battle (rate cap, spawn budget) had to be adjusted back down to zero afterwards. The local credit is now zero, which makes that same delta reconciliation an absolute apply with no second code path.
crXp is still client-side. It is a separate question from the account ledger.Why it matters beyond correctness. The two authorities disagreeing by design is what
made xp_over_ledger fire on every live account — hundreds of times each, on four of four
accounts measured. A guard that fires on every honest player describes the system, not the players, and a
real offender would have been invisible inside it. With one authority the flag becomes a signal again.
LIVE on release since 2026-08-04 (0.31). A creature, a socketed soul or the class still becomes eligible to advance the same way — by passing its line's advance level with a further stage to reach. What changed is who starts it. A victory no longer interrupts itself to play the scene. The win resolves, the rewards land, and the floor returns.
The advancement waits on the record that earned it. An upward arrow sits on that record's icon — the teammate's portrait, the equipment slot holding the soul, or the creature's tile in the soul bag (ON DEV since 2026-09-02, LIVE on release since 2026-09-02 (0.66).) — and clicking it plays the advancement scene. A creature in the bag can cross its advancement level through devour, and until 2026-09-02 nothing looked at it there: the arrow only ever reached equipped creatures, so a bag creature had to be equipped to advance.
The arrow is dead centre on the image in all three places it can appear: the equipped soul's tile, the party creature's card, and the class portrait. One shape, one size, one placement, so it means the same thing everywhere.
commitEvolution() runs at the end of the ~3s scene — so the arrow used to sit there, live and clickable, for the whole animation. advFired holds the advancements already set in motion so the derivation can answer honestly while the screen stops offering it.uid + toId), never the slot. A slot can be swapped while the scene plays — that is the cross-line corruption the commit guard exists for — and a slot-keyed suppression would hide the newcomer's arrow instead.The stale battle unit (report #41, measured 2026-08-07). advancingAlly() reads the creature's current stage off units[id].src — the battle unit — and commitEvolution() only ever rewrote equipOv. Measured against a real save: the slot became a Lich and units.a1.src stayed on DFMC-265, so the arrow was re-derived forever and every click replayed Reliquary → Lich on a creature that was already a Lich — word for word what the player reported. refreshPartyFromCsv() could not rescue it either: it rebuilds the party only while the phase is 'floor', and an advancement runs in phase 'evolution', so it fell through to a bare forceUpdate() and the stale unit survived even a return to the floor. The commit now moves the battle unit with the record.
The equipment arrow was clipped, not missing. It sat at top:-9px inside an overflow:hidden 72 px soul tile — measured 17×17 with its top cut off. It is now centred on the image and no longer clipped.
Two sizes, because one constant cannot serve both. The party card and the class portrait have room and take 44 px. The soul tile is a 72 px chip already carrying a creature image, a gender glyph, a grade glyph and a full-width multiplier band, so it takes 30 px and sits at top:32% — centred on the image area rather than on the whole tile, clear of the band below and the glyph in the corner. That is a real constraint, not a preference: at 44 px the arrow covered the tile’s own multiplier, which read ×1.?9 on screen. It can grow once that multiplier moves into the details panel.
How that was missed, and what now catches it. The probe measured size and centring, reported a clean pass, and never asked what else was in the box — the collision was found by taking a screenshot and looking at it. Every sibling carrying text is now checked with document.elementFromPoint at its own centre: is the arrow what is actually painted there? Rect-versus-rect was tried first and is the wrong test, because the multiplier is a full-width band whose box spans the tile even though its digits sit centred — it reported a collision that was not on screen.
Client: advKey(), advFiredHas(), advClearFired(), advReady(), advancingClassLive(), commitEvolution() in play/app.js. Probe: tools/probe_advarrow2.py (12/12).
The advancement arrows live in the equipment window, and the equipment window opens during a fight. Clicking one mid-battle used to drop the phase straight to the advancement scene and take the player out of a live battle — gear is frozen for the duration of a fight, and this was the one thing that could still change a fighter mid-round.
Every committed advancement appends a row to _advQ, which rides the next ordinary save and is then consumed and deleted. Same contract as the dismantle claim: the client claims, the server decides. The row names the creature (uid), the form it left, the form it reached and the level it held — and every one of those is re-derived server-side. They are sent so the server knows which record to look at, never so it can take their word for it. The claim is cleared once the save is accepted, keyed on uid+to rather than uid alone, because one creature can advance twice in a session.
The check is split, and the split is deliberate. creatures.csv lives behind a fetch and only the worker can read it; the previous save — the only thing that can say what that creature actually was and what level it held — lives in the player's object. So the worker answers “does this line take that step, and at what level?” and stamps the requirement; the object answers “is this that creature, and had it reached it?” The worker decides nothing about legality.
Four conditions must all hold, all of them against the server's own previous save:
creatures.csv;uid, wherever a creature can be held — bag, party slot or soul socket;lv is never read. That is the level check.A fifth is a consequence rather than a rule: the creature must actually be the new form in the save now arriving, or nothing advanced and there is nothing to record. Legal advancements are written to adv_ledger, one row per creature per form reached — the UNIQUE key is the idempotence, so a replayed claim writes nothing. Nothing is clamped or refused. An illegal claim is flagged advance_claim_bad and not written; the save still lands. This is a record of what legitimately happened, and a save must never be lost to it.
The class needs no claim. clsAdv is already in the save and the server already derives the class level from the same cumulative curve, so class legality is checkable from the save itself without a queue.
Client: deferAdvance(), drainAdvDefer(), advQPush() in play/app.js. Server: _advReq in the worker, adv_ledger and levelOfXp() in the object. Probes: tools/probe_advdefer.py (10/10), tools/probe_advledger.mjs (15/15, real SQLite).
LIVE on release since 2026-08-04 (0.31). Rarity still exists and still governs everything it always did — spawn tables, encounter weights, art, the codex. What it no longer does is announce itself. Owner ruling: players work it out by meeting a creature more or less often.
Removed from every in-game surface: the badge on the creature card, the badge on the creature sheet, the badges in the hatch and codex lists, and the tier baked into a creature's subtitle. The abbreviation function still exists and is still correct — nothing renders it.
The tier colour stays (owner ruling). A card still reads at a glance without naming what it is. Item grades are a separate system and are untouched.
Base attributes are drawn as the same three-axis radar the class pages use: ATK at the top, DEF lower right, SPD lower left, rings at 25/50/75/100%, values scaled out of ten. It replaced the numeric rows on the creature sheet and the bar charts on the class card and the class selector. HP is not charted — the class pages chart three axes and list HP nowhere, and the in-game chart follows that exactly. Utility went with the bars for the same reason.
The point is what a player reads off it. A grade of 9 reads as a quantity to be compared; a shape reads as an aptitude — what this creature is for. The geometry is shared with the codex so the two can never drift into two different triangles.
A move exists in three versions, and a creature moves up through them by level. Until 0.31 every one of the hundred lines upgraded its entire four-move kit on a single level: all four to V2 at once, then all four to V3 at once. Learn levels were already staggered, so only the upgrade columns were flat — a column filled per line rather than per move. The result was a cliff: a creature crossed one level and gained its whole next kit.
The rule now:
What found it. A player reported Hafgufa as overtuned. It was: Hafgufa is the second stage of the kraken line and advances at 69, but every move in that line upgraded to V3 at 54 — so for fifteen levels a mid-stage creature with the top attack grade held the full final kit, on the floors where it spawns. It was throwing Collapse of the Trench, a Kraken's move, at power 120. That move now cannot exist below level 80, and the line advances at 69, so a Hafgufa can never fire it.
XPCurve.csv (2,936,130 to 100; 354,245 a level ≈ 355 battles) and those two figures bind to the csv. “In every realm” (in the generated xp-player block, so the wording is fixed in tools/codex_tables.py gen_xp_player and the block regenerated) and the hardcore-death note left with the realms (2026-08-28). No game code or csv changed.src/tables.json (xp_cum, class_max_xp) must be regenerated with tools/gen_tables.py in the same release. What changed: the C1 generator, now written down as dfmc-client tools/gen_xpcurve.py (it reproduces the 2026-10-23 rows at --end 12), run at --end 16: the per-level growth of XP_To_Next ramps linearly from the polynomial’s own 2.7% at 66 to 16% at 99 (was 12%), cumulative recomputed; rows 1–66 and every kill-XP column untouched. Level 100 = 2,936,130 XP (was 2,126,757); 90–100 = 59.7% of it (was 50.0%); XP_Account_Flat = 354,245 (was 184,439; the ramp’s next step, +16.4%). A fresh on-level climb to 100: 34.2 h (was 27.0), 15.9 h of it in 90–100 (was 9.8); clearing only the Wins_To_Clear battles ends floor 120 at class 88 (was 89). The formula rows, the level table and the milestone note are regenerated (tools/codex_tables.py --write) and the bound spans refreshed; this page now loads csv-bind.js (it carried bound spans with no binder). An existing XP total between the old and the new cumulative of its level reads one level lower; a class clamped at the old cap (2,126,757) reads 95 and earns again to the new one. Gate: dfmc-client tools/probe_t53_xpcurve.py 4/12 → 12/12.tools/codex_tables.py, from XPCurve.csv and the on-level battle. Changed: growth at 67 2.7% → 3.0% (XP_To_Next 67 / 66); the ±0.7% band → ±0.8%; floor 100 999.4 → 996.6 XP per battle; the retired L^0.75 / 23.4 × 1.16^(L−67) battles formula → the computed counts (1 / 24 / 24 / 166 at 1 / 66 / 67 / 99); the milestone time column and its footnote ~30 → ~109 battles/h (the economy simulator, tools/sim_econ.mjs), and the “~98 h at the 30/h below” aside is gone. Player-level numbers unchanged.<!-- gen:NAME --> are generated by tools/codex_tables.py from the CSVs with the engine’s own formulas (each generator cites the engine line it ports); its --check is a dfmc-client gate, so a CSV edit without --write fails the gates.PLAYER_FLAT_XP = 639,723, hard-coded in play/app.js — the OLD level-90 step. On C1’s curve 99→100 costs 164,264, so 100→101 cost 3.9× the level before it; and playerCost(), the curve-missing fallback, still carried the retired 234 × L × 1.16^(L−67). The fix: XPCurve.csv gains the column XP_Account_Flat, filled on row 100 only: 184,439 (the 67–99 ramp continued one step, +12.28%). The client’s playerLv reads it; the constant, playerCost() and its formula are deleted. The server does not price account levels, so src/tables.json is unchanged. Gate tools/probe_acct_flat.py 2/7 → 7/7.XPCurve.csv (dfmc-client lane c1-balance, BACKLOG C1 §5.2/5.4, D’s balance card). Codex data; reaches the game with the next DATA_PIN advance, and the server’s src/tables.json (xp_cum, class_max_xp) must be regenerated with tools/gen_tables.py in the same release. What was wrong: XP_To_Next followed 10 × L^1.75 (+2.7%/level at 66) and then jumped to a flat +17.5% per level from 67, while a battle’s XP grows ~1% per level — so a level cost 0.2 h at 66, 3.5 h at 90 and 25 h at 99, and 78.5% of the XP to 100 sat in levels 90–100 (the live accounts 17–19 levels under their floor at 97–99). The fix: rows 1–66 untouched; from 67 the per-level growth ramps linearly from the polynomial’s own 2.7% to 12% at 99, cumulative recomputed. Level 100 = 2,126,757 XP (was 18,464,128); 90–100 = 50.0% of it. A fresh on-level climb to 100: 27.0 h (was 172.9 h), 90–100 9.8 h (was 129.9 h); clearing only the Wins_To_Clear battles now ends floor 120 at class 89 (was 79). Kill XP columns unchanged. Existing XP totals map to the same or higher level; a class above the new cap reads 100 and the server clamps it there. Two client constants are left as they were and are listed for a follow-up: PLAYER_FLAT_XP = 639,723 (the OLD level-90 requirement; the new 99→100 step is 164,264) and the playerCost() fallback. Research and targets: docs/balance-2026-10.md in dfmc-client.tools/probe_clslvup.py 4/5 → 5/5.classXp / clsXpBy with a grace or a clamp window: each class is set to the ledger’s view, a client figure above it is corrected and flagged (classxp_over_ledger), below it lifted, and the save is never refused. clsOwned is derived - the stored row’s class plus the class door’s wallet rows - not the stored list. A pre-ledger account keeps its stored figure until that class’s first credit folds it into the ledger; the wipe starts every class at 0. Gate tools/probe_sa_class.py 10/20 → 20/20.classlevel: “Every class is a different level. Only the account level stays shared.”). LIVE on release since 2026-10-23 (0.89). Since 2026-07-27 the build kept one classXp for every class, so switching class kept the number and every class shared one level, against what this design always said. Now classXp and clsAdv are the active class’s, and clsXpBy / clsAdvBy bank every other class. A newly bought class starts at level 1 and earns its own XP on the same curve (cap at level 100). Switching banks the class you leave and loads the class you take; its soul in the bag shows its own level. The server owns it: /api/battle credits class XP to the class the battle was fought as (if the account owns it), in a per-class ledger (ledger_cls), and the gap penalty uses that class’s level; the save door clamps each class’s XP to what that class was credited plus the usual grace, grow-only per class. Account XP and the account level are unchanged and shared. No migration: a save from before this has its classXp on its active class and every other class at level 1 until the wipe. Gates tools/probe_classlevel.py 1/9 → 9/9 and tools/probe_classlevel.mjs 6/19 → 19/19.tools/probe_review_lanep.mjs./api/battle read the avatar’s class level from the ledger’s class_total alone, which only counts credits since 2026-08-01, so a grandfathered account was priced as class level 1 and the server’s class figure (which overwrites the client estimate) disagreed with the rule. The class level is now the higher of the ledger and the stored save’s classXp, capped at class_max_xp as the save door caps it — the same level the client estimate reads.XPCurve.csv cumulative, what playerLv() uses); 21.6 M also counted the level-100 row’s own cost. Kill XP at level 100 is 118–827; ~1,000 is per battle. No rule changed.tools/probe_bagadv.py, 2/9 before the change, 14/14 after, driven through the real click and ritual on D's own save.creatures.csv and stamps the level it requires, and the player's object checks the creature, the form it left and the level it had banked in the previous save before writing adv_ledger. The claim's own level is never read. Nothing is clamped — an illegal claim is flagged and dropped, and the save still lands. New section above. D spec. Verified 10/10 in a browser and 15/15 against real SQLite.advancingAlly() reads the stage off the battle unit and the commit only wrote the record, while refreshPartyFromCsv() rebuilds only in phase 'floor' — so the stale unit survived even a return to the floor. The commit now moves both. The arrow also disappears on the click rather than at the end of the scene (advFired), is centred on the image and materially larger everywhere, and the class arrow moved from the name row onto the class portrait. The equipment arrow was not missing but clipped: top:-9px inside an overflow:hidden tile. Corrected the same day after a screenshot showed the enlarged arrow covering the soul tile’s own multiplier: two sizes now, and the probe gained an elementFromPoint occlusion check it had lacked. New section above. D report + #41. Verified 12/12.uid, and credit nobody rather than the wrong creature._ledK / _ledB is 3.00 kills per battle on 9 of 9 accounts that have fought, and
_ledC / _ledX on the reference account (class 31, floor 23, gap +8) reads 0.807
against a predicted 0.796 — the penalty model is confirmed against live data, the framing around it was
what was wrong. Account XP is unaffected: it is raw, so the account level still tracks the on-level column.≈10 × L.−3…+3 at 3/7/12/56/12/7/3% spawn-level spread that buildSpawn() actually
rolls. The whole spread lies inside the level-gap grace, so on a level-appropriate floor the penalty is zero on
every roll and account, class and creature XP are identical — which is what makes a single
“battles at Lv” number well defined at all. Measured effect of the averaging: under 0.35% above
level 4, one battle in 20,346 across the whole climb, and ~19% at level 1 where the 4-XP floor bites. The flat
floor-row figure was therefore already the average, and is documented as such rather than quietly kept.codex/XPCurve.csv now render here — XP to next, cumulative, and the five
per-kill columns — alongside derived XP-per-battle and battles-per-level against each floor's own
encounter_weights.csv mix. The milestone table and the chart were switched off their analytic
approximation onto the same data (the approximation survives only as a fetch-failure fallback).
Two Formulas rows were WRONG and are corrected: per-kill XP is not 10 × C — it is an
authored table on the C 1.0 / UC 1.5 / R 2.5 / SR 4.0 / SSR 7.0 ladder, and at level 100
a Common kill is 118 XP, not 1,000. What is ≈ 10 × L is a whole battle
(3 kills at the floor's rarity mix): 999.4 at floor 100, within ±0.7% of the line from level 10 up.
That identity is why every downstream figure on this page — ~20,300 battles to max, ~885 to level 67,
74.5% of the climb in levels 90–100 — verified against the real tables rather than needing a rewrite.
Client and server were checked against the CSV on both branches: xp_cum and xp_kill in
src/tables.json match all 100 rows exactly on main and dev, and
class_max_xp equals the level-100 cumulative, 18,464,128.