A bonus-roll expected-value tracker for World of Warcraft "Midnight." Paste an upgrade report and it ranks every raid boss and Mythic+ dungeon you can bonus-roll by the expected value of a single token, so you spend tokens where they pay off.
Everything runs client-side in your browser. Reports are fetched directly from
QuestionablyEpic / Raidbots; nothing is uploaded, and your state is saved to localStorage
(with JSON export/import for backups).
EV = ( Σ score of items you still want ÷ items still in the pool ) ÷ token cost
Item scores come straight from your report: QE Live upgrade reports for healers, in HPS, and Raidbots Droptimizer sims for everyone else, in DPS. Either can also be shown as a percentage of the character's own throughput from the Units control; raw is the default. See Reading a report's numbers for where each of those comes from.
Each item in a pool is in one of three states you can cycle by tapping it:
- Want — an upgrade still in the pool; its score counts toward EV.
- Own — you have it (or took it from the vault). It still dilutes the pool but is worth 0
to you. Anything you hold at ≥ the item level a roll here ends up at auto-marks Own, taken from
the gear the report was run in and from your
/simcexport if you pasted one. - Rolled — you already bonus-rolled it; removed from the pool for good. On a raid boss that's the pool of one difficulty, because each difficulty keeps its own: rolling the Heroic copy leaves the Mythic one where it was. So a raid mark is filed under the difficulty on screen.
The pool is the boss's whole loot table, not just what your report scored. A report only evaluates items worth simming, but a bonus roll draws from everything that boss can hand you, so the rest is filled in from the item database at zero value — it dilutes the odds without adding upside, which is exactly what it does in game.
What's in that pool depends on your loot spec. Blizzard only awards you drops your loot spec can receive, so items it can't — a plate helm for a monk, a caster trinket for a healer, an agility weapon for a Mistweaver — are left out of the math entirely and folded away with the reason attached.
That makes loot spec a lever, not just a filter, and it cuts both ways: switching can add items you want, but more often the win is dropping ones you don't. A Mistweaver at Twin Fangs is the standard case — looting as Windwalker sheds Preternatural Antivenom, which no agility spec can be given, and keeps every piece of leather. Same wanted value, one fewer item in the pool, better odds on each. So each encounter is costed as every spec of your class, and any that beats your current one is offered under the item list, named by what it dodges and what it gives up. Items only some of your specs can receive are badged as such. Note that your report's values still only describe the spec it was simmed as; the app says so when the two differ.
Icons and hover cards. Item names and icons link to Wowhead, and Wowhead's tooltip widget
(widgets/power.js, the same one QE Live and Raidbots use) renders its card on hover. Links carry
the same item level the row shows — the one its score was simmed at — so the card's stats and the
gain beside them describe one item rather than two. Icons come from Blizzard's icon CDN.
That widget is the app's only third-party script, and the only thing that tells anyone else what you're doing: hovering an item tells Wowhead which item you hovered. Nothing from your report is involved either way. Blocked or offline, the links stay links and the app is unaffected.
Items badged very rare (and flagged ✦ in the recommendation) are rare off a boss kill, but a bonus roll draws evenly from the pool — so they're weighted no differently here, and their EV is not discounted. That's intentional: the roll is the one place a very rare item costs the same as common filler, which is usually a reason to chase it rather than shy off it.
Optionally paste your in-game /simc addon export to fold in this week's Great Vault choices,
auto-mark owned gear, and import your logged bonus-roll history. Each vault option is priced as it
comes and finished at the top of its track, with the crests finishing it costs in that slot, and the
trade against the roll the vault's token would buy says when the answer turns on those crests rather
than inventing a rate to settle it.
Each logged roll carries the item context it was made in, Blizzard's Enum.ItemCreationContext
(5 for Heroic, 6 for Mythic, checked against a real log and the difficulty tag on the item it
gave). So a roll made in a raid comes out of that difficulty's pool only (applySimc). A /simc
saved before the app kept the context marks a roll at every difficulty until it's pasted again.
The vault half of that paste expires at the weekly reset, and only that half. Three options
appear at reset and are gone at the next one, so a /simc read before the last reset has no live
trade in it — the panel says which week it is describing and offers to clear it, rather than pricing
this week's decision against options that no longer exist. Owned gear, logged rolls and your loot
spec come from the same paste and don't expire that way, so they keep working. There's a clear
control on the live panel too: weeks where you have nothing pending are ordinary, and the only other
way out used to be pasting a fresh export.
Neither report format is quite what it looks like, and both quirks are load-bearing. The details
below are from QE Live's own source (UpgradeFinderEngine.js, UpgradeFinderFront.js) rather than
from inspecting payloads, so they name the fields as upstream writes them.
A QE result carries the same upgrade three times. rawDiff is the HPS gained, percDiff is
that same gain as a percentage, and score is whichever of the two the person running the report
had selected under QE's "Upgrade Finder metric" setting:
const rawDiff = Math.round(((newScore - baseScore) / baseScore) * baseHPS);
const percDiff = (newScore - baseScore) / baseScore;
if (getSetting(userSettings, "upgradeFinderMetric") === "Show HPS")
differential = rawDiff;
else differential = percDiff; // ← QE's defaultThat setting defaults to "Show % Upgrade", so score on a typical report is a bare ratio like
0.0234. Reading it as HPS understates every figure by a factor of the character's throughput, and
makes two reports saved under different settings incomparable. So scoreOf reads
rawDiff, and falls back to score only for a report old enough to predate it (QE has sent both
since April 2023).
That pair is also where the percentage toggle comes from. QE never sends baseHPS, but
rawDiff / percDiff × 100 recovers it, and it's fixed for the whole report.
baselineOf sums both fields across every result before dividing, because each is
rounded on the way out (rawDiff to an integer, percDiff to three decimals) and the small items
are where that hurts. A Droptimizer states its baseline outright, at
sim.players[0].collected_data.dps.mean.
A QE dropDifficulty is an index, not a rank — and 12.1 renumbered the list. It indexes QE's
own difficulty slider, and the 12.1 Upgrade Finder release cut that slider from eight values to
four: ["Raid Finder", "Normal", "Heroic", "Mythic"]. The old list interleaved each difficulty with
a (Max) twin meaning "that item at the top of its upgrade track", and 12.1 moved that idea onto
its own dropType field (below). Both lists live in src/data.js as
QE_RAID_DIFFICULTIES and QE_RAID_DIFFICULTIES_LEGACY, because reports are saved to localStorage
and outlive the patch that made them; qeIsModern in src/model.js picks between
them per board, on the presence of dropType.
Read the new list off itemLevels.raid in QE's ItemLevelsDB.ts, not off
convertRaidDifficultyToString in UpgradeFinderEngine.js — upstream left that function on the old
eight-entry list, where it now mislabels everything it is handed. Mirroring it is what made a Mythic
report read as Normal here, and then price the roll off the Hero track.
A 12.1 report describes each item three times. Every item at every source arrives as three rows,
tagged dropType:
dropType |
What it is |
|---|---|
drop |
the item as the boss or the end-of-run chest hands it over |
max |
that drop taken to the top of its own upgrade track, crests spent |
bonus |
what a bonus roll pays for that source, at the top of its track |
bonus is the row this app is about — QE simming the exact thing the ranking prices — so mergeRow
in src/model.js takes the score from it and the drop level from drop. Taking the
best of the three instead lands on bonus nearly every time, since scores climb with item level,
and nearly is not a rule: Top Gear re-optimises the whole set per item level, so nothing guarantees
monotonicity. It also leaves the item row claiming the boss "drops at" the promoted level.
One consequence worth stating plainly: for a 12.1 QE report the scores on the page are the payout
after the crests you would spend capping it, not the drop. That puts every source's scores at the
same finished item level and leaves the crests each roll saves as a separate figure
(Reward.crests) rather than hiding inside the EV. rollScored is the predicate, and the encounter
cards say which of the two they are showing — by name, since "Upgraded Bonus Rolls" is what QE's own
panel calls the figure and cross-checking the two should not require a guess. A Droptimizer, and any
QE report from before 12.1, still sims the drop.
The item level on each row follows the score rather than the payout, for the same reason: a Heroic
boss's roll hands you Myth 1/6 (ilvl 318) and QE prices it at 334, so a row printing 318 beside a
+11,053 HPS gain describes an item nobody is being offered — and hands Wowhead the wrong stats. The
row shows both, payout first — 318→334, the arrow being the crests — and Row.scoreIlvl (scoreIlvlOf in
src/model.js) is the one figure the whole card uses, so fillers the report never
scored don't sit at a different item level from the items above them. Where the report sims the drop
instead, nothing has been taken to a track top and the payout stays the number shown.
That same level decides what counts as a duplicate (rollTopFor). A Heroic roll hands the item
over at 318 and ends at 334, so a Hero 6/6 copy at 321 does not dupe it — the copy that roll leads to
is four steps better — even though 321 clears the hand-over. Measuring against the ceiling is also
what the rest of the card already assumes: the crest table credits a Heroic roll with zero saved
crests precisely because you pay for that 318→334 climb yourself. Only where the report makes the
claim: a Droptimizer sims the drop and has taken nothing to a track top, so there the payout stands
alone — which is why the last two Mythic bosses carry their own payout in the season data rather
than leaning on the report's level (under Seasons, with the end-of-raid tier).
The 12.1 Upgrade Finder shows a "Roll Expected Value" per boss. It is the same formula this app uses
— getRollExpectedValue in QE's ItemUtilities.ts filters a boss's bonus rows and returns
sum(percDiff) / count, the mean percentage upgrade over the pool. Two tools, arrived at
independently, same fraction. That makes QE a standing check on this app's arithmetic: where the
pools agree, the numbers agree to three decimals, and a divergence is a claim one of them has to
justify.
Every divergence has been run down against the in-game Encounter Journal, which is the only
source that settles them — read on 2026-08-17 across nine encounters, with the loot-spec filter both
on and off. tests/journal.test.js holds those readings as fixtures, and
the pools now match them item for item. Three real bugs came out of it that no amount of reading
upstream would have found:
-
Tier set pieces were pooled alongside the tier token for the same slot. Vashnikt drops the Venomcured Icon; you trade that for the Battle Gi of the Monkey King. QE's
ItemDB.jsonattributes the piece to the boss anyway, Raidbots files it under the catalyst sentinel-100, and the data build read QE first — so every tier boss was one item too big, for every class. The build now takes Raidbots' word where the two disagree. -
Spec restrictions were missing on all 54 tier-set items.
annotatetook eligibility only from Raidbots'specs, which is empty for tier pieces, so the armor-and-stat fallback let a Mistweaver's pool contain the Restoration Druid set. QE's ownclassRestrictionis now read as a fallback, widened to the whole class — QE is a healing tool and writes the Monk set as "Mistweaver Monk", which taken literally would take a Windwalker's own tier set away from them. -
Four items the journal lists but a roll cannot award. The Slumbering Coil Curio is a currency traded at a vendor for a tier piece — the Monk Discord confirmed it cannot be bonus rolled into by any class — and three cosmetic head pieces are transmog appearances.
isRollableinsrc/loot.jsdrops item class 5 and armor subclass 5. Small, but they were a third of Ula'tek's pool and two sevenths of Coiled Altar's, both Venomcursed bosses. -
The token had to carry the piece's value. De-duping the pool was only half the job: no tool scores a tier token, because a token has no stats, so the right item sat in the pool worth zero while the report's 4,904 for the Monk chest went nowhere. Raidbots'
containsnames the four class pieces a token can be traded for;applyTokeninsrc/model.jsresolves the one this loot spec would be handed and takes its value. The pool still holds one item — the token — and the row names the piece it becomes. For the same reason a copy you already hold is looked up by the piece: nobody holds a token, and holding the Myth 6/6 legs makes the legs token a dupe.
With all of that in place the two tools agree on 15 of 17 encounters, to three decimals. Both survivors are items QE's database can't see, and the journal lists them:
- Mindpiercer's Sigil (Voidscar Arena) and Sapling of the Dawnroot (The Blinding Vale) carry
sources: nullin QE'sItemDB.json, so QE cannot file them against a boss at all. This app has them because the data build backfills sources from Raidbots, and the journal confirms both.
One correction ran the other way: Knot of Writhing Serpents was in this app's healer pools, and
Blizzard's spec list for it is caster DPS with no healer in it. The journal agrees — it is absent
from a Mistweaver's Altar of Fangs list. That is exactly the distinction p exists to carry.
A dungeon row's dropDifficulty is the M+ key level, an index into MPLUS_KEY_REWARDS in QE's
Databases/MPlusKeyRewards.ts (mirrored as QE_MPLUS_KEYS). That is new in 12.1 and it is the
answer to a question the app used to have to duck: what a dungeon roll pays depends on the key, and
with no key to read the app quoted the +10 ceiling for every dungeon on the page. The season's M+
ladder now names those indices, rewardOf resolves the rung, and the ceiling is quoted only for a
report that genuinely doesn't say.
A QE report also ships the gear it was run in, as equippedItems. Reduced at ingest to
{ itemId: ilvl } and merged with any /simc data by taking the higher item level, so a healer who
pastes nothing but a report link still gets dupe detection. Each copy's bonus ids (bonusIDS, one
colon-separated string) are kept beside it, as the /simc item lines' are, because they're the only
thing that says which track a held copy is on: a 321 is Hero 6/6 or Myth 2/6, and a Myth 1/6 at 318
is the lower number and the better item against the first of those. Without them the level names the
track only where one track has that level (334 is Myth 6/6 and nothing else); in the overlap both
candidates are shown, never one picked. Boards saved before the ids were kept re-read their report
once at load for its gear alone (backfillEquipped) — a QE report is fixed once written, so nothing
else on the board moves.
Those item levels are whatever QE's import dialog produced, and it has two checkboxes that change
them (SimCraftDialog.js, applied in SimCImportEngine.ts):
| Checkbox | Default | Effect |
|---|---|---|
| Upgrade ALL to Max Level | off | every tracked item is reported at its track's item-level cap |
| Upgrade Vault to Max Level | on | vault items only are reported at their cap |
Taking the higher of report and /simc is deliberate, and it makes the app follow whichever
convention the report was made under. Many guilds ask raiders to import with Upgrade ALL on, so
that gear they intend to upgrade does not keep showing as a fresh upgrade and prompting a wasted
bonus roll or a NEED in RCLootCouncil. With that box ticked the report states the upgraded item
level, the merge keeps it, and the item marks Own. With the defaults it states the real one, and
a roll that pays out higher stays Want with a have N tag. Neither is overridden.
Two footnotes on that, both observed on real reports:
- Upgrade ALL only moves items that have an upgrade track. The guard is
if (protoItem.upgradeTrack && protoItem.upgradeTrack in itemLevelCaps), so gear from a previous expansion is left alone however the box is set. - The two sources can disagree without either being stale. For legacy scaling gear — a line
carrying
drop_level=and an oldcontent_tuning=— the addon reports an item level QE recomputes far lower (554 against 79 on one observed shoulder). Taking the higher value adopts the addon's, which for those items is the wrong one. It has no effect in practice because those items are not in current bonus-roll pools, and it is preferred to the alternative: a/simcis refreshed far more often than a report is re-simmed, so on current gear the higher value is normally the fresher one.
The loot spec lives in /simc, not in either report. The addon writes it commented out, as
# loot_spec=windwalker under spec=mistweaver, because SimulationCraft has no use for it. It is
the only place either format says what Blizzard will actually award against, and for a healer who
loots as a DPS spec to shed intellect trinkets it is not the report's spec. activeLootSpec in
src/model.js prefers an explicit choice in the dropdown, then /simc, then the
report's own spec, and ignores a loot spec belonging to another class.
A vault option is priced at the item level its own slot is offering. Each Great Vault slot pays
out at the level the activity behind it earned, and they differ within one vault: a Mythic raid slot
hands over Myth 6/6 (334), a Heroic one and every M+ slot hand over Myth 1/6 (318). A 12.1 report
scores each item at three levels — the drop, the drop capped, the bonus payout capped — and ranking
options by the last of those compares them all at a cap most slots never reach. valueAt in
src/model.js places each option on that curve instead, in three tiers:
| Tier | When | What it claims |
|---|---|---|
exact |
the report scored the item at that very level | nothing — the right row is picked |
between |
the level falls between two scored levels | the value on the line joining them |
outside |
the level sits beyond every scored level | nothing; the option carries no value |
Only between computes anything, and it computes it between two measurements ten item levels apart
— a quadratic through all three points differs from the chord by well under a percent. outside is
the tier that keeps the old rule honest: a number from beyond the report's own range would be
invention, so the option is left unpriced and cannot become the item the banner argues against.
Worth the space because the old behaviour was wrong by more than interpolation ever risks. On a real vault — two Mythic raid slots at 334, a Heroic slot and two M+ slots at 318 — the banner named Breeches of Deft Deals at 6,837 HPS, its value at a cap that slot doesn't pay. At the 318 it actually offers it is worth about 3,391, which loses to the roll. The right answer was Soulcoiler Ritual Vessel at 5,085, a number the report states outright.
And each option is priced a second time, finished. The capped figure wasn't wrong, only unpaid for: those breeches really are 6,837 once 80 Myth crests take them to Myth 6/6. Dropping it entirely was its own blind spot, found on a week-7 vault offering Alluring Bubbleband at 318 to a Mistweaver already wearing it at 321 — worth nothing as it comes, so the panel gave no sign it was worth +2,129 finished. Every option now reads both ways, and the second reading carries its price:
Worth 0 HPS as it comes — you hold one at 321 · 2,129 at Myth 6/6 for 80 Myth crests
The track comes off the option's own bonus ids, which the /simc vault block carries — 318 is Hero
5/6 and Myth 1/6 at once, and they top out five steps apart (trackStep in
src/season.js, over the Midnight tracks read off QE's BonusIDs.ts). The price is
the same climb arithmetic as every other crest figure (climbCost), against the watermark of the
slot the item goes in — see below for how those
are matched. The crests the paste says you hold ride along as context, never as an input.
Track steps on the panel — the option's, the finished one, and the copy you already hold — are
printed in the item-quality colours a character sheet paints item levels with (Myth legendary, Hero
epic, Champion rare, Veteran uncommon, Adventurer common), so a Myth 1/6 at 318 and the Hero 6/6 at
321 it would replace read as different things before you read the numbers. The same colours mark
the held copy's item level in each pool's have badge and the reward table's payouts, and nowhere
else: nearly every roll this season pays Myth, so colouring every mention would say nothing.
The trade banner then weighs the roll against both readings without converting crests into
score — there is no rate between them that doesn't depend on what else you'd spend the crests on.
The roll is already a finished figure (a 12.1 report sims it at the top of its track), and a roll
that hands its item over short of that — a Heroic boss, a +10 dungeon — carries the crests to finish
it too (rollFinish). vaultChoice asks whether one side comes out ahead whatever a crest is worth
to you (dominant): if the item does it's "take the item", if the roll does it's "take the token",
and otherwise the lead says It turns on 80 Myth crests and gives both numbers. Neither side is
ever worth less than nothing at any crest price, because nothing makes you finish what a roll hands
you, or wear what the vault does. Without that floor, a roll still needing 80 crests read as no roll at
all once crests were priceless. A vault option the report scored at 0 then "won" that end, and a
1,048 HPS roll against a one-hander worth nothing was said to turn on 80 Myth crests. A vault where
every option is worth 0 as it comes, with nothing worth raising finished, names none of them: the
banner says the roll is against nothing.
This replaced a line that credited the roll with the crests it "saves" over the vault item while pricing that item unfinished. That counted one 80 twice, in the roll's favour both times — the item kept at its unfinished value, and the roll handed a bonus for not needing finishing — and the finished value, the one number someone weighing an upgrade needs, appeared nowhere.
QE's dateCreated is "2026 - 7 - 29" — spaced separators and a one-digit month, which Date
refuses outright. shortDate in src/render.js reads the three numbers out and
constructs the date itself, rather than printing the raw string into the masthead.
Two things are season-dependent, and they're kept apart because they change at different times.
Which encounters are current lives in the generated database (data/qe-data.json), mirroring
QE Live's own constants. Regenerate it from a local QuestionablyEpic
checkout — nothing in it is hand-maintained:
npm run data # uses $QE_PATH, else ~/Projects/QuestionablyEpic
npm run data -- --qe=/path/to/QELive # explicit checkout
npm run data:check # report drift without writing.github/workflows/data-refresh.yml does this every evening (9:17 PM US Eastern) against QE's
master and opens (or updates) one PR when the result differs. QE merges a patch on patch Tuesday
evening (6:24 PM Eastern for 12.1.5) and keeps fixing loot for weeks after on any day, so a weekly
run — it used to be Monday mornings — sat on 12.1.5 for six days.
QE writes those databases in TypeScript and renames them between patches (InstanceDB.js became
InstanceDB.ts on the 12.1 branch), so the build tries both spellings and erases types with Node's
own stripper before evaluating them. A new season usually lands on a patch branch weeks before it
merges, which is the one case where the files have to be read out of a branch rather than a working
tree — git shown into a temp directory, so nobody's checkout gets moved off what they were doing.
Provenance is recorded either way: the build stamps _meta.source from the checkout's git history,
and --source= (or $QE_SOURCE) supplies it when there is no history to read, so an extracted
build says which branch it came from instead of unknown.
The build also downloads Blizzard's item and talent data from Raidbots
(equippable-items.json, talents.json) for the one thing QE Live's database can't supply: who is
allowed to loot what. QE's ItemDB is a healer database — every item in it is intellect and none of
it records spec eligibility — so it can't distinguish a healer trinket from the caster-DPS one on
the same boss, and a report will happily list both. Blizzard's spec lists, armor types and primary
stats settle it; see src/loot.js. Pass --items= / --talents= (or
$RAIDBOTS_ITEMS / $RAIDBOTS_TALENTS) to build from saved copies offline.
What a roll costs and what the season is called live in src/season.js —
QE Live doesn't publish those. Season 1 charges 2 tokens for a raid boss and 1 for a M+ dungeon;
Season 2 charges 1 for everything, which materially reorders the rankings. Moving seasons is
npm run data, then setting ACTIVE in that file. The app is on Season 2 as of 2026-08-11, the
day patch 12.1 went live; the raid itself opens on the 18th, so through pre-season week it ranks
content that is listed but not yet lootable, and the week copy says so by reporting week 0.
The build warns when the database has moved to a season the build doesn't know about, and it
watches the current raid list to do it, not QE's seasonID. Upstream went live on Season 2 with
every raid swapped and Season 1's season id (34) unchanged, and only moved the id a day later, so an
id check would have sat silent through the one rollover it existed to catch. Each season therefore
records the raid ids it expects in qeRaids.
The list also moves mid-season, when a patch adds a raid: 12.1.5 appended Unbinding of Kith'ix
(1324), a one-boss raid. That's not a new season, so the fix is adding the id to qeRaids rather
than bumping ACTIVE. Either way a test (tests/staledata.test.js) fails while the shipped
database and qeRaids disagree, so a refresh that needs either edit fails its checks, and can't
deploy, until it has it. The refresh PR says which of the two it needs.
Season 2 also promotes what a roll pays out — a Mythic boss hands back a fully upgraded Myth item
rather than the drop — so that season's table carries item levels as well as tracks. They're read
off the 12.1 PTR and pinned in tests/season.test.js, so a PTR revision
fails a test rather than quietly changing everyone's numbers. The M+ figure is the payout for a +10
key or higher; lower keys pay under it, and nothing in a QE report says which key you run.
A Mythic roll saves you up to 80 Myth crests, and the app says so as a saving. That promotion is not just a better item: it's the crests you'd have spent climbing to it. It's the one reward in the game you can't farm for by any other route, which makes it a real reason to roll a Mythic boss over a dungeon for the same single token — the comparison the EV, priced in score per token, cannot make.
The 80 is derived, not quoted, and the derivation is the interesting part. Midnight's tracks come off
QE's src/Retail/Engine/BonusIDs.ts (seasonId: 37), which carries every upgrade step with its item
level and price:
Hero 1/6 = 305 · 2/6 = 308 · 3/6 = 311 · 4/6 = 315 · 5/6 = 318 · 6/6 = 321
Myth 1/6 = 318 · 2/6 = 321 · 3/6 = 324 · 4/6 = 328 · 5/6 = 331 · 6/6 = 334
A flat 20 crests a step, in that track's own currency, with one cost block at mask_inv_type: 0 — one
price for every slot. A Mythic boss drops at 318 and a roll hands it over at 334, which looks like
five steps and 100 crests. It isn't, because of the two-step overlap. A step costs nothing when
the slot's high watermark already covers it (highWatermarkDiscounts, scaling: 0), and the
watermark is an item level, so
Hero 6/6 = 321 = Myth 2/6
Take a Heroic item in that slot to the top of its track and you've bought the 321 mark with Hero crests, which M+ hands out freely. The first Myth step then costs no Myth crests at all. Four paid steps, 80, which is exactly what the guides say — and their "1,280 to cap 16 slots" is 16 × 80.
That is also why 80 is a genuine maximum rather than a middle case. A slot below 321 would
arithmetically pay all five steps, but the fix for such a slot is Hero crests off a dungeon, not a
bonus roll — anyone spending Myth crests on that step has simply misplayed. Pricing the roll at 100
would credit the token with rescuing a mistake nobody should make, so crestSavingAt clamps the
watermark up to crestFreeTo and the figure only ever falls from 80:
| Slot high watermark | Myth crests a roll saves |
|---|---|
| anything up to 321 | 80 — the maximum |
| 324 (Myth 3/6) | 60 |
| 328 (Myth 4/6) | 40 |
| 331 (Myth 5/6) | 20 |
| 334 (Myth 6/6, capped) | 0 |
The Hero crests that clamp assumes are deliberately not modelled. They're cheap, farmable on demand, and a second currency on an encounter card is noise against the decision being made — so the copy says "don't spend Myth crests on that step" and leaves it there.
season.js records crestSteps, crestPerStep and crestFreeTo so all of this is re-derivable, and
the tests assert the arithmetic rather than pinning the number — including that no watermark can push
the figure above the maximum, and a cross-check that 80 × 16 reproduces the guide's 1,280.
This shipped as 100 for one commit, and the overlap is what was missing. Counting the climb naively gives five steps, and the guide's 80 looked like it must be answering a different question. It wasn't — it was accounting for a free step that a naive count doesn't see, and for the fact that no competent player ever pays for that step in Myth crests. Worth recording because the failure mode is seductive: game data beat a guide, the arithmetic checked out, and it was still wrong. When a well-maintained guide disagrees with your derivation, the derivation is missing a term.
The wording is load-bearing rather than stylistic. No roll pays crests out; described as a yield,
a reader goes looking for a currency drop that never arrives, or counts it a second time against the
crests they're already farming that week. So every surface says saves: the collapsed encounter card
(crestMeta, set off by an accent rule because it's the one thing beside the EV column the EV can't
account for), the expanded card's note (crestNote), and the reward pane, which leads its section
with the figure. Two of those are pinned by tests in tests/render.test.js,
which is otherwise deliberately shallow on wording.
The vault trade banner is the one place it does not appear. A saving is measured against the drop, and the alternative on that banner isn't a drop you'd have climbed — it's the vault item, whose own finishing cost is what differs between the two branches. So the banner prices that instead (see the vault section above), and never quotes the saving on top of it.
The figure stays quoted in crests everywhere, and out of the EV. Folding it in would need a crests-to-score rate, and that rate depends on which item you'd have spent them on — precisely the opportunity cost no report computes. It also can't reorder items within a pool: an item you want arrives already upgraded, and one you'd never wear still unlocks the slot so the piece you do wear upgrades free. Filler and upgrade save the same crests. Only a payout above a track's first step saves anything, which is why every non-Mythic row is zero rather than small.
A linked /simc makes this real rather than assumed. The addon writes a slot_high_watermarks
line — the best item level held in each slot — which is the only thing in a paste describing upgrade
state rather than possession. parseWatermarks reads it and crestSavingRange prices every slot, so
the app quotes a computed figure: one number where the slots agree, a range where they don't. With no
paste it quotes the maximum and says so ("saves up to 80"), along with what reaching it takes.
That rule is the game's, not an inference: highWatermarkDiscounts gives every crest currency
"scaling": 0 at or below the mark — free — with accountWide: false, so it's the character's own
mark that counts. Read the Midnight entries (seasonId: 37) for this, not the TWW ones that dominate
that file: TWW charged Valorstones alongside crests at "scaling": 0.5, and Valorstones don't exist
in Midnight, where a step costs crests only (plus gold, which QE doesn't model). TWW also varied
cost by slot mask, where Midnight has one price for every slot.
Each entry is slot:character:account — the two values C_ItemUpgrade.GetHighWatermarkForSlot
returns, in that order. The pair is not always equal — QE's own sample export carries 14:0:89 — and
that pair settles the order: an account's mark is the best across its characters, so it can never sit
below one of them, and the 0 has to be this character's. That is the mark a crest step is discounted
by (accountWide: false), so parseWatermarks keeps it. (It used to take the higher of the two, on
the theory that a higher mark could only undersell a roll. Once the same marks price a vault item's
finishing cost that stops being true — a higher mark makes the item look cheaper to finish — so the
guess had to become the answer.)
The index is the slot, and it is Blizzard's list, not SimC's. Seventeen entries is
Enum.ItemRedundancySlot:
0 Head · 1 Neck · 2 Shoulder · 3 Chest · 4 Waist · 5 Legs · 6 Feet · 7 Wrist · 8 Hand
9 Finger · 10 Trinket · 11 Cloak · 12 Twohand · 13 MainhandWeapon · 14 OnehandWeapon
15 OnehandWeaponSecond · 16 Offhand
This used to be read as SimC's slot order, which never fitted: marks landed under items held in the
same slot, and counting seventeen as one per equipment slot left a 12.1 export "over-subscribed by one"
— a puzzle this section used to end on. Read as the enum above, two independent exports (the user's,
and QE Live's SimCSampleDB) fit on every armor, jewelry and cloak index, with each mark at or above
the item held in that slot, where SimC's order puts a 308 mark under a 321 chest. WATERMARK_SLOT in
src/classes.js maps an item's inventory type (iv) to its entry.
Only what those exports pin down is mapped. Two-handers and held-in-off-hand items check out; shields share the latter's entry by name. One-handers and main-hand-only weapons don't: neither export's three weapon marks line up with the one-handers held clearly enough to say which is which. A slot the app can't name is priced at the season's assumption, and the copy says so rather than calling the whole figure computed.
With the slots named, an encounter's figure is read over the slots its own pool can land in
(poolSlots), not the whole character: a boss whose one live item is a pair of legs saves exactly
what the legs slot does, and the card names it. The reward pane still asks the character-wide question
and ranges over every slot.
Rings, trinkets and weapons come in pairs, and it's reasonable to wonder whether that doubles or halves anything. It doesn't change the app's figure, for a slightly boring reason: the figure is per roll, a roll hands you one item, and that item lands in one slot. Whether the other ring slot is capped has no bearing on what this ring saves you.
Paired slots share one mark, though — this section used to claim the opposite, off the misread
count above. Finger and Trinket are single entries, and QE's sample holds trinkets at 334 and 344
while reporting 334: the lower of the pair. So capping one ring does not discount the other, because
the mark both are discounted by is the worse of the two you wear. Replacing that worse ring with a
new one is the common case, and it's exactly the one a vault ring is priced for.
A two-hander doesn't cost double, and doesn't save double either. Every Midnight upgrade step
carries a single cost block at mask_inv_type: 0, so a two-hander climbs its track at the same 20 a
step as a ring — there is no per-slot rate to make a two-hander's climb worth 200. The intuition that
it might comes from the other half of the mechanic: a two-hander occupies main hand and off hand, so
if obtaining one raised both slots' marks it would unlock two slots' worth of free upgrades at once.
The enum answers that as far as the marks go: Twohand is an entry of its own, beside the main-hand,
one-hand and off-hand ones, so a two-hander is discounted by — and raises — its own mark only.
The last two bosses of the tier raid are a class apart — Venomcursed 9/6 items with cantrip effects — so those encounter cards carry a badge and a note the EV can't express: it prices this week only, and can't weigh a token banked for kill week against one spent now. Two things about that are easy to get wrong and are worth stating.
Which bosses are "last" comes from the raid's recorded pull order, carried through the data
build as order. It used to be derived by sorting encounter ids, on the theory that Blizzard hands
them out in roughly pull order. That held for The Voidspire and fails for Venomous Abyss, which
ends on Coiled Altar (2883) — sixth of eight by id. Sorting badged Lost Explorers instead, which is
the wrong boss to tell someone to bank a token for.
And the badge is gated on the season naming its tier raid (special.raid), because Season 2 ranks
more raids than that one: Tidebound Grotto, a one-boss flex world boss, and from 12.1.5 Kith'ix, a
one-boss raid. "The last two bosses" of a one-boss raid is that boss, so without the gate the world
boss wore the badge too.
The badge is also gated on difficulty, because "Venomcursed 9/6" quotes an item level and an
item level is a claim about the roll it's attached to. Those bosses carry their tier at Mythic only;
on Heroic the same encounter pays Myth 1/6 like any other Heroic boss, five upgrade steps below the
badge. What survives the difficulty change is the cantrips — the reason Larias' guide recommends
rolling exactly these bosses on Heroic week to week — so the card wears special.badgeAlt instead
and the note leads with the claim that applies. Both claims stay on the card either way: bank a
token for the Mythic kill, and spend one here on Heroic most weeks are both true, and which one
leads is decided by what the reader is being ranked at (specialAtTier in src/render.js).
On Mythic a roll there pays the tier itself: Venomcursed 9/6, ilvl 344. That is the drop, on no
upgrade track, so the roll promotes nothing — it buys a second shot at the drop. QE Live sims it the
same way (getSetItemLevel sets 344 for the drop, max and bonus rows of those two bosses). The
season records it as special.rollReward, merged over the Mythic row by rewardOf, and until
2026-10-14 the cards quoted the Mythic row's 334 instead: "pays Myth 6/6" in the head, 334→344 on
every row, and — on a Droptimizer, which has no report level for rollTopFor to fall back on — a
Myth 6/6 copy marked Own when the roll out-levels it by ten. The crest figure is the Mythic row's,
not zero. Against the boss's own drop the roll saves nothing, but the figure was never that
comparison: it is what a roll saves the slot it lands in, and an item past the top of the Myth track
spares that slot every Myth step exactly as one at the top does.
In Season 2 the roll token is itself a Great Vault reward from week 2, so taking it costs you the
item you'd otherwise have picked. From week 8 one is also handed out every week, and the vault
keeps offering its own on top — no source said so (Blizzard introduces the Voidcore as a vault
selection and never dates its removal), and it was confirmed in game at week 8's reset. So week 8
doesn't end the trade, it shrinks it: the weekly token takes your best roll, and the vault's buys
the next one, which is what the banner weighs the item against from then on (secondRoll in
src/model.js). A raid boss is rolled once a week — the roll is offered by the
kill — so "the next one" is the next encounter down; a dungeon can be run again, and a second run's
roll prices exactly as the first did, since before either is made each is as likely to land on any
given item. The banner names where the weekly token went, because otherwise its roll figure reads
as the best one on the board.
Those are rules stated in week numbers, which are only useful next to which week it is now — so the
season also carries week1, the reset that opens week 1 (August 18, off Larias' week-by-week
dates), and both places that describe the window answer it for today: the reward pane and the vault
trade banner. Before the season opens it names the date instead. The count is anchored to the US reset, which is
the reset those dates are written in; other regions reset later the same day, so at the boundary it
can name a week the reader hasn't reached yet.
All of that is legible in the app from the S2 rewards button in the masthead, which opens a
reward pane: what each source pays, at which track and item level, the full M+ ladder the ranking
quotes the top of, the crests a roll saves you, and what the rules change about the numbers on the
page.
The game shows none of this anywhere — in game a boss drops what it drops, and "your roll pays out
on your vault's track" is a rule you read a guide or install an addon for. The pane is rendered
from src/season.js, so the figures in it are the same ones the ranking prices
with; it's reachable from the legend and from the pays … chip on any encounter card too. It's
pinned to Season 2 rather than following ACTIVE: Season 1 has no reward scheme to document, and
Season 2's rules are wanted before the season starts. So it always names its season in the
heading, and says whether the ranking behind it is playing by those rules yet.
The app doesn't depend on either being up to date to stay useful. A Droptimizer carries its own
instance, encounter, difficulty and item level inline, so a next-season raid is rankable from the
report alone: unrecognised sources are ranked as normal, flagged in the UI, and named by id rather
than silently dropped. Sources that genuinely can't be bonus-rolled — crafted gear, reputation
vendors, timewalking, world bosses — are recorded at build time in ignoredInstances and filtered
out without a warning, as are a raid's non-encounter drops (QE files trash and catalyst loot under
encounter 999, world drops under a negative encounter id). So the warning only ever means "the
database is behind."
QE Live reports are the exception: they carry only item ids, and the loot table comes entirely from
data/qe-data.json. A new season's items won't resolve to any source until you regenerate. Those
items used to vanish without a word, which is how 12.1.5's Kith'ix went missing on launch day; the
banner now counts them instead (unplacedItems in src/model.js), limited to raid and dungeon rows
at the difficulty on screen.
The link button next to the report picker copies a share link. Opening it loads that report before anything is pasted — handy for handing a character's board to a guild officer. The link carries the report id and the Rolled/Own marks, so the recipient's pools match yours; they fetch the scores fresh from QE Live / Raidbots, while token overrides and the vault stay in each person's own browser. Raidbots links expire when the underlying report does (~30 days).
Pasted into Discord, a share link unfurls as the board: the top five rolls with their EV per
token, upgrades left in each pool, and the item carrying each one. That takes a server, because
Discord reads link previews without running any script and GitHub Pages serves the same page for
every link. So share links point at a small Cloudflare Worker (share/, address in SHARE_HOST
in src/share.js), which serves the preview — a Discord
component embed plus Open
Graph tags for everything else — and sends a person's browser straight on to the app.
The Worker never fetches a report or runs the model: the app writes the card's numbers into the
link when it makes it, and the Worker only looks up names and icons. That keeps it far inside the
free plan's 10 ms of CPU per request (a cold buildGroups alone costs about that), and a card
still draws after its Raidbots report has expired. The card is a snapshot, dated with a Discord
timestamp ("shared 2 hours ago"); the board behind the link is live.
All of that makes the link a few hundred characters long, so the Share button has the Worker
shorten it to three words, …workers.dev/CozyVenomGrell. The Worker keeps the long link in a
small Cloudflare D1 table (share/schema.sql) under words picked from its hash (share/words.js),
so the same link always gets the same words and sharing twice stores nothing new. If the Worker
can't be reached, Share copies the long link instead, which works on its own.
The app uses native ES modules, so it must be served over HTTP (not opened as a file://
URL). Any static server works — there is no build step, and the shipped site has no
runtime dependencies:
npm run dev # http://localhost:8000
npm run dev -- --port=3000 # or set $PORTThat's scripts/serve.mjs — a zero-dependency Node static server, so
Node is the only thing you need installed. It sends cache-control: no-store, because a
304 on a stale ES module is a confusing way to lose an edit.
Dev tooling (Biome, TypeScript, the test runner) lives in devDependencies — it never ships
to the deployed site. TypeScript runs in checkJs mode: the source stays plain .js,
type-checked through JSDoc annotations (see src/types.js), so you get editor
IntelliSense and CI safety without a compile step.
Biome is the linter and the formatter, on its recommended rule set
(biome.jsonc) — one binary covering the .js, .css and .html the site is
made of, where ESLint only ever saw the JavaScript. Its defaults are taken as they come, bar
2-space indentation: these files ship to the browser verbatim, so reformatting them to tabs
would rewrite every line of a buildless site for nothing.
npm install # one-time: install dev tooling
npm run lint # Biome — lint + format + import order
npm run fix # the same, applying every safe fix
npm run typecheck # tsc --noEmit (checkJs)
npm test # node:test
npm run check # lint + typecheck + test, as CI runs themCI runs lint + typecheck + test on every push and PR (.github/workflows/ci.yml).
Every name on the page is text somebody else wrote — item names from QE Live's database, character
and realm names from a pasted report. So markup is built with the html tagged template in
src/html.js, which escapes every interpolated value unless it is explicitly
marked as markup:
html`<div class="${cls}">${item.name}</div>`; // both escaped
html`<ul>
${rows.map(rowHTML)}
</ul>`; // arrays join; nested html`` passes through
html`${cond && html`<b>only sometimes</b>`}`; // false / null / undefined render as nothingSafety is the default rather than a discipline: there is no esc() to forget. The one way past it
is raw(), which is what to grep for when auditing. Interpolations are safe in text and in
double-quoted attributes — write class="${x}", never class='${x}'.
Element ids are declared once, in src/dom.js. $ is typed against that list, so a
rename in index.html is a type error everywhere the id is used rather than a null at runtime,
and a missing element throws with its own name attached. tests/dom.test.js checks the list
against the real markup in both directions — an id the app wants that the page lost, and an
element in the page that nothing renders into.
Tests run under node:test and live in tests/. The pure logic is covered directly (the
EV model, loot eligibility, report and simc parsing, the escaping contract). Rendering and event
wiring are covered against the real index.html, parsed with linkedom — so a test can fail
because an element id moved, because a fragment never reached the DOM, or because a hostile item
name arrived as markup. A stub DOM that answers every lookup with the same object would agree with
any markup at all, including markup that isn't there, which is why there isn't one.
index.html Thin HTML shell — markup only
src/
styles.css All styling (light/dark, theme tokens)
main.js Entry point: boots the UI and renders
data.js Re-exports the encounter database + difficulty vocabulary
season.js Season config: name + token costs (see "Seasons" above)
store.js Persistent state (localStorage), boards, save/load
model.js The EV model: encounter resolution, grouping, ranking, display scaling
loot.js Who can be awarded what — loot spec eligibility
classes.js Game constants: armor types, weapon training, class colours
reports.js Loading QE Live and Raidbots Droptimizer reports
simc.js Parsing the /simc addon export (vault, owned, roll history)
render.js All DOM rendering
ui.js Event wiring, theme toggle, export/import
share.js The share link's format, read by both the app and the share Worker
html.js The `html` tagged template — escaping by default
dom.js Element-id registry, typed `$`, and the mutations the app makes
wowhead.js Item links and the tooltip widget's payload
types.js JSDoc type definitions (no runtime code)
data/
qe-data.json Generated encounter + item database (see src/types.js QEData for its shape)
share/ The share Worker: link previews for share links (see "Sharing a report")
card.js What it serves; worker.js is its entry point, wrangler.jsonc its config
words.js The word lists short links are made of
schema.sql Its D1 table of short links, applied once by hand
scripts/
build-data.mjs Regenerates data/qe-data.json from a QuestionablyEpic checkout
serve.mjs Zero-dependency static server for local development
tests/ node:test suites; page.js supplies the real DOM
The site is fully static. Push to GitHub and enable Pages for the branch/root, or let the
included workflow at .github/workflows/pages.yml deploy on every push to main (set
Pages → Source to "GitHub Actions" in the repo settings).
The same workflow deploys the share Worker to Cloudflare's free Workers plan. It reads a
CLOUDFLARE_API_TOKEN secret from the repo's cloudflare environment, which only main can deploy
from, and the token can only edit Workers in the one Cloudflare account that holds this Worker.
The Worker bundles data/qe-data.json for item and boss names, so redeploying on every push to
main is also what keeps a merged data refresh from leaving new items nameless on cards. To deploy
it by hand: npx wrangler login once, then npm run share:deploy. Its short links live in a D1
database, slowyourroll, bound in share/wrangler.jsonc; a fresh account needs it created
(npx wrangler d1 create slowyourroll) and share/schema.sql applied once, as that file says.
Slow Your Roll is a fan-made, unofficial tool. It is not affiliated with, endorsed by, or sponsored by Blizzard Entertainment, QuestionablyEpic, or Raidbots. World of Warcraft and related marks are trademarks of Blizzard Entertainment, Inc.
MIT for the application code. Note that data/qe-data.json is derived from
QuestionablyEpic / Raidbots data, which carries its own terms — check those before
redistributing the database.