# WoW Lab > Simulation and theorycrafting tools for World of Warcraft # Documentation ## Roadmap _Dynamic page: content is rendered live at https://app.dev.wowlab.gg/dev/docs/overview/roadmap and is not included here._ ## Combat Mechanics The simulation engine implements World of Warcraft's combat mechanics with high fidelity to the live game. ## Damage Calculation Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ### Base Formula Damage calculation follows the standard formula: ``` Base Damage = Spell Power × Coefficient × Versatility × (1 + Mastery) Final Damage = Base Damage × (1 + Crit Bonus) × (1 + Target Modifiers) ``` ### Coefficient Sources | Source | Description | | ----------- | -------------------------------------------- | | Spell Data | Base coefficient from game data | | Talents | Multiplicative modifiers from talent effects | | Auras | Active buff/debuff modifications | | Set Bonuses | Tier set effect modifiers | ## Resource Systems Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Mana Energy Rage Mana regenerates based on Spirit and in-combat regeneration rules. Base regeneration is 2% of maximum mana per second out of combat. Energy regenerates at a fixed rate of 10 per second baseline. Haste affects energy regeneration rate linearly. Rage is generated through damage dealt and received. Generation rates vary by spec and ability. ## Aura System Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Check immunity, apply diminishing returns for CC effects. Execute aura effects: stat modifiers, periodic damage, absorbs. Track remaining duration, handle pandemic refresh rules. Remove aura, trigger on-expire effects, clean up state. ## Proc System Lorem ipsum dolor sit amet, consectetur adipiscing elit. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Real Procs Per Minute (RPPM) uses a bad luck protection system that increases proc chance based on time since last proc. ## Game Data API Public Supabase Edge Functions serving hydrated WoW game data. No authentication required. All endpoints support CORS. Base URL: `https://api.wowlab.gg/functions/v1/data` ## Endpoints ### GET /data/classes Returns all 13 playable classes with their specs nested inside. Cached for 24 hours. ``` https://api.wowlab.gg/functions/v1/data/classes ``` Response: ```json { "classes": [ { "id": 1, "name": "Warrior", "color": "#C69B6D", "fileName": "classicon_warrior", "iconUrl": "https://api.wowlab.gg/functions/v1/icons/large/classicon_warrior.jpg", "specs": [ { "id": 71, "name": "Arms", "role": 2, "orderIndex": 0, "fileName": "ability_warrior_savageblow", "iconUrl": "https://api.wowlab.gg/functions/v1/icons/large/ability_warrior_savageblow.jpg" } ] } ] } ``` Spec roles: `0` = Tank, `1` = Healer, `2` = DPS. ### GET /data/items?ids= Returns items by ID. Max 50 per request. Cached for 1 hour. ``` https://api.wowlab.gg/functions/v1/data/items?ids=19019,32837 ``` Response: ```json { "items": [ { "id": 19019, "name": "Thunderfury, Blessed Blade of the Windseeker", "description": "", "fileName": "inv_sword_39", "iconUrl": "https://api.wowlab.gg/functions/v1/icons/large/inv_sword_39.jpg", "itemLevel": 29, "quality": 5, "requiredLevel": 25, "binding": 1, "classId": 2, "subclassId": 7, "inventoryType": 13, "stats": [{ "type": 3, "value": 2000 }], "effects": [{ "spellId": 21992, "triggerType": 2 }], "setInfo": null, "speed": 2600, "dmgVariance": 0.5 } ] } ``` Item quality: `0` Poor, `1` Common, `2` Uncommon, `3` Rare, `4` Epic, `5` Legendary, `6` Artifact. ## Icons Every object includes an `iconUrl` pointing to a large icon. Swap the size segment in the URL for other sizes: - `/icons/large/` — 56px (default) - `/icons/medium/` — 36px - `/icons/small/` — 18px ## Filtering - Classes are filtered to IDs 1–13 (playable classes only, excludes pets/Adventurer/Traveler) - Specs exclude "Initial" placeholder specs (`order_index = 4`) ## Implementation The API is deployed independently from this repository. The web apps consume its public endpoints over HTTPS and load the full game-data snapshot into the browser cache. ## Content Style Guidelines for writing MDX content. ## Writing style - **Be concise.** Short sentences. No filler. - **Be accurate.** Verify claims against code. - **Be direct.** Tell the reader what to do. - **Use present tense.** "The engine compiles" not "The engine will compile". ## Prefer native markdown | Element | When to use | | --------------- | ----------------------------------------------- | | `## Heading` | Major sections. H2 for main, H3 for subsections | | `**bold**` | Key terms on first use | | `` `code` `` | Function names, variables, file paths | | `- list` | 3+ related items | | `[link](/path)` | Navigation, references | | Tables | Comparing 3+ items | | Code blocks | Examples (always include language) | ## Code blocks Always specify language: ```rust fn main() {} ``` Supported: `rust`, `typescript`, `json`, `bash`, `yaml`, `jsx`, `sql` ## Links - Internal: `[text](/dev/docs/section/page)` - External: `[text](https://example.com)` - Anchor: `[text](#heading)` ## Citations Use the `` component for academic-style references: ```mdx The WebSocket protocol enables real-time communication. ``` Add reference entries to `src/content/references.ts` before using a citation ID. ## Do NOT - Add placeholder content ("TODO", "Coming soon") - Make claims without verifying against code - Use components for decoration - Nest components excessively - Write walls of text without structure - Use em dashes - Use AI-sounding phrases ("It's important to note", "Furthermore") ## File naming - Docs: `{order}-{slug}.mdx` (e.g., `00-quickstart.mdx`) - Blog: `{date}-{slug}.mdx` (e.g., `2025-12-hello.mdx`) ## Frontmatter Required and optional fields for documentation pages: ```yaml --- title: Page Title # Required description: Brief summary # Optional, used for SEO nextSteps: # Optional, array of doc paths - 01-overview/00-architecture - 02-engine/00-simulation-core --- ``` ## Review checklist All code compiles and runs correctly. All internal and external links resolve. Technical claims verified against source code. Concise, direct, present tense. No AI artifacts. ## Branding Everything you need to use the WoW Lab brand in your own work. ## The mark A gear with a lightning-bolt arrow striking out of it, set inside a circular cutout. The gear and bolt share the same amber-to-orange gradient. The arrow exits the top-right of the gear, breaking the circle. The lockup uses no wordmark, the icon stands alone. ## Colors The mark is a two-stop gradient. The dark surface used in the social images and app icons is the same near-black navy used as the cutout fill in the SVG. | Token | Hex | Where it shows up | | ----------------- | --------- | ----------------------------------------------- | | Amber (gradient) | `#FDC20B` | Top-left stop of the gear and bolt gradient | | Orange (gradient) | `#F46B03` | Bottom-right stop of the gear and bolt gradient | | Surface | `#020611` | Cutout fill in the mark, social image backdrop | The amber and orange map to the app's `--primary` token, which sits in the same hue range across light and dark themes. ## Typography WoW Lab uses [Geist](https://fonts.google.com/specimen/Geist) by Vercel for body text and [Geist Mono](https://fonts.google.com/specimen/Geist+Mono) for code. Regular (400) and Bold (700) cover everything on the site. Grab the variable font if you want every weight in one file. ## Source files The `/branding` folder at the repo root holds three subfolders. | Folder | Contents | | ---------- | -------------------------------------------------------------- | | `source/` | Master logo in SVG, AI, EPS, PDF, PSD, and transparent PNG | | `favicon/` | Browser favicons, Apple touch icon, and PWA manifest icons | | `social/` | Profile and cover images for social media, on the dark surface | For most uses, grab `source/wowlab-logo.svg`. For a raster version on a transparent background, use `source/wowlab-icon-transparent.png`. ## Where to get it The full [`/branding` folder](https://wowlab.gg/go/github/tree/main/branding) lives on GitHub. Clone the repo or download the folder directly. # Bible ## Related Work The history of WoW combat simulation, the two fundamentally different approaches that emerged, and why it matters for understanding the design decisions behind WoW Lab. ## The Two Schools - Two fundamentally different approaches to answering "what gear should I wear": discrete event simulation and stat weighting - Both try to solve the same problem but make very different trade-offs in accuracy, speed, and complexity - Understanding the difference is essential context for why WoW Lab exists and the choices it makes ## Discrete Event Simulation - Simulate combat second by second (or more precisely, event by event), tracking every spell cast, buff tick, proc trigger, and cooldown - No shortcuts. Model the actual game loop. Roll the dice. Let mechanics interact naturally - Inherently accurate when modeled correctly because it mirrors what actually happens in-game - Downside: computationally expensive, requires thousands of iterations for statistical significance - This is what SimulationCraft pioneered and what WoW Lab does ### SimulationCraft - The gold standard for years. Open source C++ engine, community maintained - Action Priority Lists (APL) for rotation logic, same concept WoW Lab uses - Raidbots made it accessible by wrapping SimC in a web UI with cloud compute - Limitations: single-threaded C++ codebase, difficult to extend, no browser execution, aging architecture - SimC proved the approach works. The question was whether the tooling around it could be modernized ### Early Ask Mr. Robot - Early versions used a discrete simulation approach similar to SimC - Provided gear optimization on top of simulation results - Later pivoted away from discrete simulation entirely (covered below) ## Stat Weights and Weighted Engines - The alternative approach: instead of simulating combat, assign a numerical weight to each stat point - "1 point of Crit is worth 0.8 DPS, 1 point of Haste is worth 0.95 DPS" and so on - Score gear by multiplying each stat by its weight and summing. Higher score means better gear - Fast. Trivially fast. No simulation needed at all once you have the weights - The problem: weights are only accurate at the exact gear level they were computed for ### Where Stat Weights Break Down - Stat interactions are non-linear. Haste makes Crit better because you cast more spells. Crit makes Haste better because each spell hits harder on crit - At different gear levels the relative value of stats shifts, sometimes dramatically - Stat weights are a linear approximation of a non-linear system. Works okay near the measurement point, gets worse the further you move from it - Breakpoints, tier set interactions, trinket procs, and talent synergies make this even messier - You end up needing to re-simulate to get new weights anyway, which defeats the purpose ### QE Live - Stat weight based optimization tool for WoW - Fast results, no waiting for simulation runs - Trade-off: accuracy suffers in exactly the situations where players need the most help (comparing very different gear sets, evaluating tier pieces, trinkets with procs) ### Newer Ask Mr. Robot - Pivoted to a stat weight and analytical model approach - Faster than discrete simulation but inherits the fundamental accuracy limitations of the approach - Made the deliberate trade-off of speed over simulation fidelity ## Why Discrete Simulation Wins - When specs have 10+ interacting buffs, procs, and cooldowns, there is no closed-form solution - The only way to know for sure is to simulate it and let the mechanics play out - Stat weights can lie. Simulation results converge to truth given enough iterations - The real challenge is not whether to simulate but how to make simulation fast and accessible enough that players don't need to settle for approximations - That is the problem WoW Lab sets out to solve ## DBC Overview Everything the engine knows about a spell, an item, or a talent tree ultimately comes from World of Warcraft's own client database. The client ships its game data as a large set of tables, historically called DBC files, and every number I simulate (a cooldown, a coefficient, an aura duration) is a column in one of those tables. The simplest way to think about this whole section is: the game tells us the numbers, and the data layer's only job is to find them, reshape them, and hand them to the engine. I do not parse the binary client files directly. By the time data reaches this repository it has already been extracted to CSV, one file per table. The loader reads those CSVs into a single in-memory bundle, and the transforms turn that bundle into the flat types the engine consumes. This page covers that first hop: CSV to bundle. The pages that follow cover the reshaping, and [Data Resolution](/dev/bible/game-data/data-resolution) covers how the bundle, a Postgres mirror, and a browser cache all end up behind one trait. ## The bundle: `DbcData` The whole CSV side of the data layer collapses into one struct. `DbcData` is a flat record of roughly 130 lookup tables: `spell_name`, `spell`, `spell_misc`, `spell_effect`, `spell_power`, `chr_specialization`, `trait_node`, `item`, `item_sparse`, `item_bonus`, `curve`, `curve_point`, `rand_prop_points`, `power_type`, `expected_stat`, and so on. Each one is an `IntMap`, or a grouped `IntMap>` for one-to-many relations, keyed by the row's primary id or a foreign key. There is nothing clever about the bundle. It is deliberately a dumb container: load once, look up by id, never mutate. All of the interpretation, joining `spell` to `spell_misc` to `spell_effect`, deciding which effect carries the damage coefficient, happens later in the transform layer, never in the loader. ## Loading from CSV `DbcData::load_all` reads each table from `{data_dir}/data/tables/{Table}.csv`, so `Spell.csv`, `SpellName.csv`, and `ItemSparse.csv` all live side by side under one directory tree: {/* docref fn dbc-overview-load-all */} The function is one long sequence of per-table load calls. There is no schema registry and no reflection driving it, just an explicit list. The CSV directory is located through the `WOWLAB_DATA_DIR` environment variable. The engine CLI reads it and falls back to `{HOME}/Source/wowlab-data`, and `forge` does the same against its own `default_data_dir`. Rotations sit alongside the tables at `{data_dir}/rotations/{id}`. One design choice worth naming, because it is easy to misread as a bug: a missing CSV file is **not** an error. `read_csv_bytes` returns `None` for a file that is not present, and the loader turns that into an empty `IntMap`: {/* docref fn dbc-overview-read-csv-bytes */} So pointing the loader at an incomplete or empty data directory yields a bundle full of empty tables rather than a hard failure. I chose this because partial data sets are useful during development and because the transforms downstream already tolerate missing rows. The catch is that a typo in the data directory surfaces as "spell not found" much later, not as "directory missing" up front. Under the hood the loader uses three generic readers, parameterized by small marker traits derived on the row structs: | Loader | Keyed by | Shape | | ---------------- | -------------------- | ----------------------------------------------- | | `load_by_id` | primary `ID` column | `IntMap` | | `load_by_fk` | a foreign-key column | `IntMap>` (two-pass count + fill) | | `load_one_by_fk` | a foreign-key column | `IntMap` (first row wins) | The row structs themselves sit next to the loader, and their field names match the CSV column headers exactly, so deserialization is a straight serde mapping with no manual column indexing. ## Where the bundle goes `DbcData` is the input to the transform layer and, by extension, to the local resolver. `LocalCsvResolver` loads it lazily on first access and caches the `Arc`. The CSV path is the source of truth for the local resolver and, at snapshot time, for the rows that get written into Supabase. The remaining pages in this section follow that data forward: first the spell table, the largest and most important one, then talent trees, items and scaling, and the tooltip parser, before the two resolution pages tie the CSV path, the Postgres path, and the browser path together. ## Spell Data A spell in WoW is not one row. The client splits a single ability across a dozen tables: its name, its timing, its costs, its school, and a variable number of effects each with their own coefficients. The transform layer joins all of that back together into one struct, `SpellDataFlat`, so the rest of the system can treat a spell as a single value. The simplest mental model: `SpellDataFlat` is "everything the engine could ever want to know about one spell, flattened into one record." This is the largest and most consulted type in the data layer, so it is worth walking through its field groups rather than dumping the whole struct. Because `SpellDataFlat` derives serde snake_case, the exact same struct deserializes from a CSV-derived transform, a Supabase JSON row, or a JS-bridge value. That single-shape property is what lets three very different backends feed one engine; I return to it in [Data Resolution](/dev/bible/game-data/data-resolution). ## Field groups Rather than 80-odd fields in arbitrary order, the struct is organized into logical groups. These are the groups the engine actually reads during `resolve_game_data` (covered in [Codegen](/dev/bible/game-data/codegen)). | Group | Representative fields | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Identity / text | `id`, `name`, `description`, `aura_description`, `is_passive`, `knowledge_source` | | Timing / cost | `cast_time`, `recovery_time`, category IDs and recovery times, `start_recovery_time`, `power_costs`, `max_charges` | | Range / AoE | `range_max_0/1`, `range_min_0/1`, `cone_degrees`, `radius_max`, `radius_min` | | School / coefficient | `defense_type`, `school_mask`, `bonus_coefficient_from_ap`, `effect_bonus_coefficient`, `min_scaling_level`, `max_scaling_level` | | Interrupts | `interrupt_aura_0/1`, `interrupt_channel_0/1`, `interrupt_flags` | | Duration / empower | `duration`, `max_duration`, `can_empower`, `empower_stages` | | Vec columns | `attributes`, `effect_trigger_spell`, `implicit_target`, `learn_spells`, `effects` | | Aura props | `max_stacks`, `periodic_type`, `tick_period_ms`, `refresh_behavior`, `pandemic_refresh`, `tick_may_crit`, `tick_on_application` | | RPPM / labels | `rppm_base_rate`, `rppm_flags`, `rppm_mods`, `labels` | A couple of fields carry contracts that are not obvious from their names, and getting them wrong silently produces wrong cooldowns: - **Cooldowns are independent pools.** `recovery_time` is the spell's own timer, `category_id` and `category_recovery_time` describe a shared cooldown pool, and `charge_category_id` and `charge_recovery_time` describe a shared charge pool. A spell can participate in all of them at once. - **Start recovery is another category pool.** `start_recovery_category` identifies the pool and `start_recovery_time` is its lock duration. Category 133 is the ordinary player GCD; other categories serialize only their own members, while category 0 means no start-recovery lock. - **A non-zero `interrupt_channel_0` means the spell is a channel**, which changes how its "cast time" is interpreted. For channels the duration field is the channel length, not the cast bar. The resolver preserves these identities and durations separately. Runtime maps are keyed by the raw positive category ID, so newly added game-data categories work without adding enum variants or manifest overrides. ## Effects The payload of a spell lives in its effects. `effects` is a `Vec`, and each `SpellEffect` is one DBC effect row carrying its own type, aura sub-type, base points, and the two coefficients that drive damage: {/* docref struct spell-data-spell-effect */} A few of those fields carry conventions worth calling out. `index` is 0-based in the flat row even though the same effect is `$s1` (1-based) in descriptions. `effect` is the effect type id and `aura` is the aura sub-type id. `coefficient` and `variance` are the direct-damage roll, while `bonus_coefficient` is the spell-power coefficient and `bonus_coefficient_from_ap` is the attack-power coefficient. The coefficient naming is the one trap here. When the resolution pass builds the engine's damage view it reads `bonus_coefficient` as the SP coefficient and `bonus_coefficient_from_ap` as the AP coefficient. An effect whose direct coefficients are both zero but which redirects to a trigger spell is followed down a bounded chain to find the real payload. I cover that trigger-chain walk in [Codegen](/dev/bible/game-data/codegen). There is a subtle index mismatch to keep in your head. The flat `SpellEffect.index` is 0-based, but the resolver's `get_spell_effect(spell_id, effect_index)` takes a **1-based** index because that is the author-facing convention used in manifests and overrides. The local resolvers translate between the two via `validate_effect_index`, which subtracts one and treats index `0` as "not found": {/* docref fn spell-data-validate-effect-index */} Mixing the two conventions is the single easiest way to read the wrong effect. The remaining sub-types are small: `PowerCostEntry { power_type, cost, cost_pct, optional_cost }` describes one resource cost, `EmpowerStage { stage, duration_ms }` one empower tier, and `LearnSpell { learn_spell_id, overrides_spell_id }` the learn/override linkage. None of them need their own page; they exist only to keep the per-spell record self-contained. ## Codegen A spec, say Arcane Mage, is defined in two places, and the split is the whole point. The numbers (a cooldown, a coefficient, an aura duration) live in the game data and reach the engine through `ResolvedGameData`. The _behaviour_ is hand-written in a small TOML manifest: which spells exist, which auras they apply, which talents matter. Code generation stitches the two together: it reads the manifest and emits Rust that, at build time, pulls the omitted numbers out of `ResolvedGameData`. A manifest is therefore almost all structure and almost no numbers; anything it leaves out is resolved from data. This figure expands the **Engine** box of the system-context diagram in [Architecture](/dev/bible/overview/architecture), showing the build-time half of that picture: manifests in, generated Rust out.
```mermaid flowchart LR TOML[(manifests/*.toml)] ITEMS[(manifests/items.toml)] TOML -->|toml::from_str into Manifest| MAN["manifest-schema::Manifest"] ITEMS -->|toml::from_str into ItemsManifest| IMAN["manifest-schema::ItemsManifest"] MAN -->|generate_spec_file| SPECRS["specs/spec.rs source"] IMAN -->|generate_item_file| ITEMRS["items/item.rs source"] SPECRS -->|emits| BUILD["build_combat_system_draft + aura/spell fns"] SPECRS -->|emits| DESC["DeclaredSpecMetadata and SpecDescriptor"] DESC -->|collects in manifest order| BARREL["SPEC_DESCRIPTORS catalog slice"] ITEMRS -->|emits| IDEPS["typed item ResolveDependencies"] BUILD --> FMT["CodeWriter::finish - RA parse + pinned rustfmt"] DESC --> FMT ITEMRS --> FMT BARREL --> FMT IDEPS --> FMT FMT -->|fs::write output| GEN[(engine-content/src/generated)] GEN -->|reads while building the draft| RGD["ResolvedGameData require accessors"] ```
## The schema: `manifest-schema` `manifest-schema` is the canonical, serde-only definition of what a manifest may contain. It is shared by both the generator and forge's audit tooling, so there is exactly one description of the format. A per-spec manifest deserializes into `Manifest`; items deserialize into `ItemsManifest`. Every manifest carries a `schema_version`, checked against a `CURRENT_SCHEMA_VERSION` constant so a format change fails loudly rather than generating wrong code. The per-spec `Manifest` mirrors a spec's combat definition: {/* docref struct codegen-manifest */} Each section deserializes into its own struct. The recurring theme is that almost every field on a spell or aura is `Option`, and an absent value means "resolve from game data." | Section | Type | Holds | | --------------------------------- | ---------------------------------- | --------------------------------------------------------------------- | | `spec` | `SpecSection` | WoW spec id, pet flag, custom handler, precombat/stealth auras | | `resource` | `ResourceSection` | name, max, regen, starts_at, type id | | `secondary_resource` | `Option` | name, max, type id only, no regen | | `auras` | `IndexMap` | aura defs; declaration order = `LocalAuraIdx` | | `spells` | `IndexMap` | spell defs; declaration order = `LocalSpellIdx` | | `auto_attacks` | `IndexMap` | swing timers and AP coefficients | | `talents` / `hero_talents` | `BTreeMap` / `IndexMap` | talent name-to-id maps | | `effects` | `IndexMap` | named `(spell_id, effect)` pairs; generated into an `EFFECT::` module | | `metric_keys` / `rotation_schema` | optional sections | telemetry keys and rotation field schema | The `effects` section is worth singling out. An `EffectRef` is just a `(spell_id, effect)` pair given a name, and the generator stamps it into a per-spec `EFFECT::` module beside the `SPELL`/`AURA`/`TALENT` modules. That replaced a class of hand-maintained effect-index `const`s that used to live loose in the spec hooks: the index now sits in the manifest right next to the id it indexes, one source of truth that both the generated code and the hand-written hook read. A `SpellDef` carries `id` and then a long list of optional overrides: {/* docref struct codegen-spell-def */} Cooldown, cost, gain, damage, cast time, GCD, charges, the applied aura, AoE and channel sub-sections, cooldown-reduction rules, and a `hook` for custom behaviour are all here, and all optional. In a real manifest most of them are absent: the Arcane Mage manifest declares ten spells and fourteen auras where almost every spell gives only its `id` and lets the generator pull cooldown, cost, cast time, and damage from data. The manifest says _what the spec does_; the data says _by how much_. ## The generator: `codegen-cli` The `codegen` binary is invoked as `cargo codegen` and writes into `crates/engine-content/src/generated/`. Its driver, `generate_all`, reads every `*.toml` in the manifests directory, treats `items.toml` specially, parses each spec file into a `Manifest`, version-checks it, and calls `generate_spec_file`. It then emits the items, the barrel `mod.rs` files, and finally writes every `(filename, source)` pair to disk. A `--check` mode regenerates in memory and diffs against the committed files, so CI can prove the generated code is in sync with the manifests without a writable tree. `generate_spec_file` emits, in order: the id constants (the `SPELL`/`AURA`/`TALENT` and `EFFECT` modules), ordered complete spell and aura resolution-id slices, the local-index constants, one `aura_*` and one `spell_*` builder function per declaration, the `build_combat_system_draft` function, the handler factory, and one typed `DeclaredSpecMetadata` value in a `SpecDescriptor`. The generated barrel collects all 27 descriptors into one `SPEC_DESCRIPTORS` slice in manifest-repository order. Engine Content combines that slice with item and expansion-trait resolver dependencies into the single validated `ContentCatalog` used for lookup and iteration. ### How the source is emitted The generator accumulates source text through `CodeWriter`, while the small `Chain` helper builds method-call expressions and tracks which calls need `?`. When a file is done, `CodeWriter::finish` validates the accumulated source with `ra_ap_syntax`, normalizes numeric literals, formats it with the workspace toolchain's pinned `rustfmt`, and parses the formatted result again. Parse and formatter failures are returned as contextual errors without dumping the generated buffer, so invalid output aborts the complete in-memory generation pass before any files are written. Banner comments are retained across formatting and restored in their required position. Items go down the same chute. An item's on-use or proc spell is emitted by the very same `spell_chain` the spec generator uses, parameterised through a `SpellChainOpts` so the item path can reproduce its slightly leaner chain without a second copy of the logic. Per-item combat registration is a single `register_item!` macro call rather than a stamped-out block, so an item file is mostly its id, name, and the one chain. The item barrel separately emits typed `ResolveDependencies` in item-manifest order, making the resolver inputs part of the same catalog contract as spec metadata. ## The build-time override versus run-time accessor loop The connective idea is worth stating precisely, because it is the reason the two halves stay consistent. When a manifest field is present, the generator emits a literal. When it is absent, the generator emits a _call into `ResolvedGameData`_ instead. The altitude of those calls is higher than it once was. Rather than the generator inlining a sprawl of per-spell `data.cooldown_s(...)`, `data.cost(...)`, and target/multiplier lookups, the bulk now collapses to a handful of `*_from_data` builder methods that take the resolved data and the spell id and do the lookups themselves: `apply_base_from_data` pulls cooldown, cost, cast time, and the rest of the base profile; `apply_aoe_from_data` pulls the target count and chain multiplier; `damage_ap_from_data` / `damage_sp_from_data` pull the damage coefficient; and on the aura side `AuraBuilder::apply_base_from_data` mirrors the spell case. These live in `engine-combat`, so the per-spell ceremony moved out of the generated text and into a runtime helper called once per spell. The generated Arcane Mage code, accordingly, reads as one fluent line per spell, like `s.apply_base_from_data(data, 1449)?.apply_aoe_from_data(data, 1449, 0)`, rather than a paragraph of inlined field reads. That `?` is the second half of the consistency story. The accessors used to inline a full `MissingSpellData { spell_id, field }` literal at every call site, hundreds of times across the generated tree. They are now `ResolvedGameData::require_*` accessors that materialize that error once, internally, and return a `Result`. A spell whose data never resolved therefore fails the build of the combat system through one `?`, rather than silently simulating zeros, with none of the per-call-site error boilerplate. That closes the loop with [Data Resolution](/dev/bible/game-data/data-resolution). At build-combat-system time, the generated builder functions read coefficients, costs, cooldowns, and durations from `ResolvedGameData`; at resolution time, `resolve_game_data` populated that value from the catalog descriptor's generated `DeclaredSpecMetadata`. The manifest is the override layer on top, and the game data is the default underneath. Add a number to the manifest and it wins; omit it and the data fills in. Both halves share the same generated declaration, so the ids the resolver fetches are exactly the ids the builders use. This is the boundary between the data layer and the engine: from here on, every page assumes the engine is holding a fully resolved `ResolvedGameData` and a cataloged spec, and asks what it does with them. ## Event System The run loop is a `while` over a queue: pop the earliest event, advance the clock to it, call the handler method for its variant, drain whatever the handler scheduled, repeat. Every event carries a timestamp; the queue keeps them sorted; the loop never looks ahead. That is the entire executor. This page expands the **SimEngine.run** box of the [sim-pipeline figure](/dev/bible/engine/discrete-event-simulation). I cover the events themselves, the dispatch state machine, the queue that orders them, and the RNG that makes a run reproducible. ## The eight events `Event` is the central type the whole loop is built around. It is `Copy`, matched exhaustively in-workspace, and every variant carries a `t: SimTime` so the queue can sort on it without inspecting the payload. | Variant | Payload | Meaning | | --------------- | ------------------- | ----------------------------------------------------------------- | | `PlayerReady` | `t` | Rotation wake: ask the handler for the next action | | `OffGcdReady` | `t` | Off-GCD rotation wake; dispatched identically to `PlayerReady` | | `CastStart` | `t`, `spell_id` | Cast begins. The loop computes cast time and schedules completion | | `CastComplete` | `t`, `spell_id` | Cast lands. Damage, auras, cost, and cooldown all resolve here | | `AuraTick` | `t`, `aura_id` | Periodic DoT/HoT tick, or a channel tick (channels reuse the id) | | `AuraExpire` | `t`, `aura_id` | Aura expiry check (may be a no-op if the aura was refreshed) | | `CooldownReady` | `t`, `cooldown_key` | A cooldown or charge has recharged | | `AutoAttack` | `t` | A melee swing is due | Because `t` sits in every variant, the queue never has to know which variant it holds. `Event::timestamp` collapses all eight into one match and hands back the `t`: {/* docref fn event-system-timestamp */} One detail is worth flagging because the code contradicts its own doc comment. The doc on `CastStart` says cooldown and resource cost are "paid here". They are not. In the actual loop, the `CastStart` arm only asks the handler for the cast time and schedules a `CastComplete` at `t + cast_ms`: {/* docref code event-system-cast-start-arm */} All of the cost, cooldown, damage, and aura work runs at `CastComplete`, inside [`process_cast`](/dev/bible/engine/cast-pipeline). The doc comment is stale relative to the code, and the code wins. ## The dispatch loop The handler talks back to the loop through `SpecAction`, returned only from `on_player_ready`. It has two cases: {/* docref enum event-system-spec-action */} The loop turns a `Cast` into a `CastStart` event and a `Wait` into a future `PlayerReady`; everything else the handler wants to schedule it pushes through `flush_scheduled`, which the loop drains after every arm.
```mermaid stateDiagram-v2 [*] --> PlayerReady: "on_sim_start; push PlayerReady(0)" PlayerReady --> CastStart: "Cast{spell}; push CastStart" PlayerReady --> PlayerReady: "Wait{until}; push PlayerReady(until)" PlayerReady --> Idle: "action None" CastStart --> CastComplete: "cast_time_ms; push CastComplete(t+cast)" CastComplete --> PlayerReady: "process_cast schedules ready(gcd_end)" CastComplete --> CooldownReady: "start_cooldown" CastComplete --> AuraTick: "apply_aura schedules tick" CastComplete --> AuraExpire: "apply_aura schedules expire" AuraTick --> AuraTick: "reschedule next tick before expiry" AuraExpire --> Removed: "expire_aura_by_id" CooldownReady --> Recharged: "check_recharge" AutoAttack --> AutoAttack: "reschedule next swing" CastComplete --> [*]: "t >= encounter_end" PlayerReady --> Budget: "event count > 500000" Budget --> [*]: "EventBudgetExceeded" ```
This figure expands the **SimEngine.run** box of the [sim-pipeline figure](/dev/bible/engine/discrete-event-simulation). The states are the event variants; the transitions are real scheduling edges: - The loop seeds itself: after `on_sim_start`, it pushes `PlayerReady { t: 0 }` to kick off the rotation. - `PlayerReady` (and the identical `OffGcdReady`) call `on_player_ready`. A `Cast` becomes a `CastStart`; a `Wait` becomes a clamped future `PlayerReady`; `None` schedules nothing. - `CastStart` schedules `CastComplete` at `t + cast_ms`. - `CastComplete` runs `process_cast`, which is where the fan-out happens. It schedules the next `PlayerReady`, starts cooldowns (`CooldownReady`), and applies auras that schedule their own `AuraTick` and `AuraExpire` events. - `AuraTick` reschedules itself against live haste until the next tick would land past expiry; `AutoAttack` reschedules the next swing. There are two terminals. The normal one: a popped event whose `t >= encounter_end_ms` breaks the loop, the fight is over, stop. The failure one: a hard budget of `MAX_EVENTS = 500_000`. If a run processes that many events without finishing, the loop returns `SimRunError::EventBudgetExceeded`. That cap is a guard against a pathological rotation scheduling itself into a tight non-advancing loop; it does not normally fire. ## The timing wheel The queue is the part that earns its keep. With discrete events you push and pop constantly, and both have to respect time order. The obvious structure is a binary heap, O(log n) push and pop, simple to reason about. The engine uses a **timing wheel** instead, trading the heap's clean asymptotics for O(1) amortised push and pop. The standard reference for the structure is Varghese and Lauck.Hashed and Hierarchical Timing Wheels The shape is fixed: 32768 slots, each spanning 32 ms (`WHEEL_SHIFT = 5`, so `1 << 5 = 32`), for a wheel span of about 17.5 minutes. Each slot is the head of an arena-allocated linked list, kept sorted by `(time_ms, seq)`. A 512-word bitmap (one bit per slot) lets the pop path skip empty slots with a `trailing_zeros` scan instead of walking them one at a time.
```mermaid flowchart TB Push["push(event)"] -->|delta < span?| Decide{"in wheel span?"} Decide -->|yes| Slot["insert_into_wheel: slot = (time_ms >> 5) & mask"] Decide -->|no far future| Overflow[(overflow bucket)] Slot -->|alloc_node| Arena[(arena Vec + free list)] Slot -->|set bit| Bitmap["slot_bitmap (512 u64)"] Pop["pop()"] -->|head non-null?| Head{"current slot empty?"} Head -->|no| Emit["pop_from_slot; advance clock"] Head -->|yes| Scan["find_next_slot via bitmap scan"] Scan -->|found| Emit Scan -->|none + overflow non-empty| Rotate["rotate_wheel_base"] Rotate -->|reinsert in-span entries| Slot Rotate --> Overflow Scan -->|none + overflow empty| Done["return None"] ```
This figure expands the **EventQueue** box of the [sim-pipeline figure](/dev/bible/engine/discrete-event-simulation). The mechanics: - **push** computes the timestamp's slot. If the event lands beyond the wheel's span (`time_ms - wheel_base_ms >= WHEEL_SPAN_MS`), it goes into an overflow bucket instead. Otherwise it is inserted into the slot's linked list at the right sorted position, a fast tail append in the common case, a list walk otherwise. Nodes come from an arena with a free list, so steady-state pushing does not allocate. - **pop** reads the current slot's head; if empty it scans the bitmap for the next non-empty slot. If no slot has anything but overflow does, it rotates. - **rotate_wheel_base** advances the wheel base by one full span, resets the slot cursor, and drains the overflow bucket, reinserting every entry that now falls within the new span and leaving the rest in overflow. This is how a 30-minute DoT survives a 17.5-minute wheel: it sits in overflow until rotation brings it into range. Ordering is ascending `time_ms`, FIFO by an insertion `seq` on ties. The FIFO tiebreak is the only thing the wheel adds over a plain heap to make same-millisecond events deterministic, and it matters: two procs firing at the same instant must resolve in a fixed order or the run is not reproducible. The honest cost of this choice: the wheel is bigger and more code than a heap, and its O(1) is amortised, not worst-case. A burst of far-future events all landing in overflow, then a rotation, pays for itself across many ops rather than per-op. For a workload that is overwhelmingly near-future scheduling with the occasional long DoT, that trade is worth it. ## Deterministic RNG A simulation result has to be reproducible from its seed, or you cannot debug it and you cannot trust a regression. The RNG lives in its own leaf crate, `engine-rng`, with no dependencies, so any layer can pull it without dragging in the scheduler. It is `SimRng`, a `u64` xorshift64 generator (shifts 13/7/17), seeded by an FNV-1a hash over `seed_base || chunk_id || iteration_index`. The whole generator is one word of state: {/* docref struct sim-rng-struct */} The seed pipeline is split deliberately. `seed_prefix(seed_base, chunk_id)` pre-hashes the per-chunk part once: {/* docref fn sim-rng-seed-prefix */} and `from_prefix(prefix, iteration_index)` finishes it per iteration, so the chunk loop does not re-hash the whole key on every Monte-Carlo pass: {/* docref code chunk-seed-derivation */} On top of the raw generator sit the stochastic primitives in `crates/engine-rng/src/stochastic.rs`: `proc_chance` (a flat roll), `roll_tier` (cumulative thresholds), `shuffle_pick` (partial Fisher-Yates), and `roll_rppm`, real-procs-per-minute with same-time guarding, a 3.5-second elapsed cap, and bad-luck protection that ramps the chance up the longer a proc has gone without firing. [Procs](/dev/bible/engine/procs) covers the RPPM model in depth. One subtlety the crate split makes clean: the RNG is not the scheduler's concern. `SimEngine` holds no `SimRng` field at all, it only schedules and dispatches. The RNG that actually drives combat rolls is the handler's own `SimRng`, reseeded per iteration from the seed pipeline above. Keeping it in the handler rather than the loop is what makes a run reproducible from its seed regardless of event ordering. With the clock, the queue, and the RNG in place, the only remaining question is what the handler does when asked for the next action. That answer comes from the [rotation compiler](/dev/bible/engine/rotation-compiler). ## Cast Pipeline When a `CastComplete` event pops, the handler runs `process_cast`. This is the function that turns "the cast finished" into all of its consequences: the resource is spent, the cooldown starts, the damage is dealt, the aura is applied, post-cast hooks fire. It runs once per landed cast, top to bottom, no surprises. The very first thing it does is refuse to trust its input. An unknown spell id is logged and dropped, never a panic: {/* docref code cast-pipeline-lookup-guard */} Despite the doc comment on `CastStart` claiming cost and cooldown are "paid" at cast start, none of that happens until here at `CastComplete`. The `CastStart` arm of the loop only schedules this completion; this is where the spell actually does anything. ## The thirteen steps The pipeline is deliberately linear, a sequence of steps, not a graph, which makes it readable and makes the per-step attribution exact.
```mermaid flowchart TB Start["process_cast(spell_id)"] --> Lookup{"spell_data found?"} Lookup -->|no| Warn["warn UNKNOWN_SPELL_CAST; return"] Lookup -->|yes| Emit["emit_cast telemetry (gcd_ms)"] Emit --> Res["process_resources: spend + gain"] Res --> Chan{"is_channel?"} Chan -->|yes| Channel["process_channel_cast: schedule N AuraTicks + PlayerReady; return"] Chan -->|no| Ready["schedule PlayerReady(max(gcd_end, now))"] Ready --> Cd{"has_cooldown?"} Cd -->|yes| StartCd["start_cooldown + emit_cooldown_start"] Cd -->|no| Dmg StartCd --> Dmg["match damage: None / Flat / Ap / Sp -> deal_damage"] Dmg --> Aura{"applies_aura?"} Aura -->|yes| ApplyAura["apply_aura"] Aura -->|no| Cdr ApplyAura --> Cdr["process_cdr_effects"] Cdr --> Hook["fire_cast_hook"] Hook --> PlayerHook["fire_player_cast_hooks"] PlayerHook --> Stealth{"breaks_stealth?"} Stealth -->|yes| Break["break_stealth_if_active"] Stealth -->|no| Hist Break --> Hist["record last_used + update_history"] ```
This figure expands the **SimEngine.run** box of the [sim-pipeline figure](/dev/bible/engine/discrete-event-simulation). Specifically, it is the `on_cast_complete` callback the loop makes there. Step by step, with call sites: 1. **Lookup.** Fetch the spell's static `SpellData` by id. If it is missing, warn `UNKNOWN_SPELL_CAST` and return: a cast for an unknown spell is a no-op, not a panic. 2. **Cast telemetry.** Compute the effective GCD and emit a cast event carrying it. This is the GCD's only role in `process_cast`. It is reported, not enforced here, because the GCD gate already happened in `on_player_ready` before the cast was returned. 3. **Resources.** `process_resources` spends the primary and secondary cost and applies any energise gain, detailed below. 4. **Channel branch.** If the spell is a channel, `process_channel_cast` schedules the channel's ticks and the final rotation wake, then returns early. Channels do not run the rest of this pipeline the same way. 5. **Schedule the next wake.** Push a `PlayerReady` at `max(gcd_end, now)` so the rotation is asked for its next action when the GCD clears. 6. **Cooldown.** If the spell has a cooldown, start it and emit a cooldown-start event. 7. **Damage.** Match on the spell's `DamageDef`: `None` does nothing; `Flat` emits a fixed-amount damage event and fires impact procs; `ApCoefficient` and `SpCoefficient` route to `deal_damage_ap` / `deal_damage_sp`, which run the full [damage formula](/dev/bible/engine/combat-formulas). 8. **Aura.** If the spell applies an aura, `apply_aura` runs the [aura state machine](/dev/bible/engine/auras): fresh apply, pandemic refresh, or snapshot. 9. **Cooldown reduction.** `process_cdr_effects` walks the spell's CDR effects and hands each to `apply_cdr_effect`, which branches on the condition: `Always`, `ProcChance`, and `WhileAuraActive` gate a fixed-amount _reduction_, while `ResetWhileAura` fully _resets_ the target cooldown when its aura is active. Reduce-versus-reset is an explicit fork in `apply_cdr_effect`, not a "condition" that quietly mutates. 10. **Cast hook.** `fire_cast_hook` runs the spell-specific post-cast hook, if any. 11. **Player cast hooks.** `fire_player_cast_hooks` runs every registered global cast hook. 12. **Break stealth.** If the spell breaks stealth, expire the stealth aura. 13. **Record.** Stamp the spell's `last_used` and update the history slot's prev-GCD flags so the rotation can reason about what was cast last. The order is not arbitrary. Resources spend before damage so a starved cast still pays its cost. The cooldown starts before damage so a cooldown-reducing impact proc cannot reduce a cooldown that has not begun. Hooks fire after damage and auras so they observe the post-cast state. The history update is last so it reflects a completed cast. ## Resource accounting The resource step has more nuance than "subtract the cost." `process_resources` calls `process_single_resource` twice, once for the primary resource and once for the secondary. For each, if there is a cost it is spent and a `Spend` event emitted. If there is a gain it is granted and a `Gain { wasted }` event emitted, where `wasted` is the overflow past the resource cap. Two exceptions change the primary cost before the spend, not after. A cost-bypass aura zeroes the cost while it is active, the mechanic behind "your next spell is free" procs. And a channel pays per tick rather than up front, so its per-cast primary cost is zero. Both checks live at the top of `process_resources`: {/* docref fn cast-pipeline-process-resources */} The handler also keeps the resource current with the clock. Before evaluating the rotation, `on_player_ready` calls `sync_resource`, which regenerates the primary resource up to `now`, scaling regen by haste for resources that haste affects. Resource regeneration is continuous in the game but the sim only needs the value at decision points, so it lazily catches up the resource at each wake instead of scheduling a tick for every point of energy. This is the same idea as the discrete-event loop itself: compute state when it is read, not on a fixed grid. The remaining mechanics, the [damage multiplier chain](/dev/bible/engine/combat-formulas), the [aura lifecycle](/dev/bible/engine/auras), [procs](/dev/bible/engine/procs), and [resources](/dev/bible/engine/resources), are the subject of the following pages. What ties them together is covered under [spec handlers](/dev/bible/engine/spec-handlers): how a generated spec becomes the `SpecHandler` this pipeline lives inside. ## Combat Formulas Every damage number in the engine comes out of one function: `DamageCalc::calculate`. It takes the spell's base amount and a handful of stat inputs and walks them through a fixed multiplier chain: weapon roll, raw damage, crit, versatility, armor, then the situational multipliers. The order matters, and the engine commits to one. This is the [cast pipeline](/dev/bible/engine/cast-pipeline)'s damage step, zoomed in. The figure below expands the **SimEngine.run** box of the simulation pipeline (the Zoom-1 `sim-pipeline` figure); concretely it is what `process_cast` reaches when it hits the damage branch.
```mermaid flowchart TB Start["DamageCalc::calculate(rng)"] --> Weapon["weapon_roll = min + rng()*(max-min)"] Weapon -->|rng call 1 only if weapon_max>0| Raw["raw = base + weapon_roll*weapon_mult + coef*attack_power"] Raw --> Crit["is_crit = rng() < crit_chance"] Crit -->|rng call 2| AfterCrit["after_crit = raw * (is_crit ? crit_mult : 1.0)"] AfterCrit --> Vers["after_vers = after_crit * (1 + versatility/100)"] Vers --> Armor["after_armor = after_vers * armor_mitigation(armor, K)"] Armor --> Mult["final = (after_armor * damage_mult * mastery_mult).max(0)"] Mult --> Result["DamageResult#123;raw, is_crit, final_amount#125;"] ```
## The chain, step by step `DamageCalc` is a plain struct of inputs. Everything the formula needs is a field on it: {/* docref struct combat-formulas-damage-calc-struct */} `calculate(rng)` consumes them in this exact order, drawing from the RNG at most twice: 1. **Weapon roll.** `weapon_roll = weapon_min + rng()*(weapon_max - weapon_min)`, but only when `weapon_max > 0`. For a spell with no weapon component the roll is skipped entirely, including the RNG call, so the first random draw belongs to crit instead. 2. **Raw.** `raw = base + weapon_roll*weapon_multiplier + coefficient*attack_power`. This folds the flat base, the rolled weapon contribution, and the attack-power-scaled portion into one number before any multiplier touches it. 3. **Crit.** `is_crit = rng() < crit_chance.clamp(0,1)`; the factor is `crit_multiplier` on a crit, else `1.0`. The default crit multiplier is `2.0`. 4. **Crit applied.** `after_crit = raw * crit_factor`. 5. **Versatility.** `after_vers = after_crit * (1 + versatility/100)`. 6. **Armor.** `after_armor = after_vers * armor_mitigation(target_armor, armor_k)`. 7. **Multipliers.** `final_amount = (after_armor * damage_multiplier * mastery_mult).max(0)`, where the floor at zero is the only clamp on the result. The output is a `DamageResult { raw, is_crit, final_amount }`. Note that crit, versatility, and the late multipliers are all multiplicative against the same `raw`; there is no additive bucketing here. That is a simplification, since real WoW splits modifiers into additive and multiplicative buckets, but for the spells the engine models it keeps the formula auditable. ## Armor mitigation Armor only applies to physical damage, and it uses the standard ratio: ``` armor_mitigation(armor, K) = 1 - armor / (armor + K) ``` In code that ratio is clamped to `[0, 1]` and short-circuits to `1.0`, meaning no mitigation, when armor is non-positive: {/* docref fn combat-formulas-armor-mitigation */} The constant `K` is the armor coefficient for the target's level, supplied as `armor_k = game_data.armor_k()`, which is `armor_constant * armor_constant_mod` resolved from the expected-stats table. Non-physical schools skip the armor term: `prepare_damage_setup` only reads target armor for physical hits. ## Where the inputs come from `DamageCalc` is assembled in `prepare_damage_setup`, which reads the player's crit and versatility, the target armor for physical hits, the spell's base points, and then two aggregates: the buff totals and the mastery multiplier. ### Buff totals Finalization classifies each `BuffEffect` and binds only totals-relevant aura slots into the immutable effect plan. At runtime, `buff_totals` folds static contributions into actor-revision caches, evaluates only planned dynamic callbacks for the current target, and scales each contribution directly by its live stack count. The `BuffEffect` enum is the vocabulary of what an aura can change: | Variant | Effect | | ---------------------------------------- | ----------------------------------------- | | `Haste(f64)` | additive haste percent | | `Crit(f64)` | additive crit percent | | `Mastery(f64)` | additive mastery percent | | `Versatility(f64)` | additive versatility percent | | `PrimaryStat(f64)` | additive primary stat | | `DamageMult(f64)` | flat damage multiplier on all damage | | `DamageMultSchool(f64, DamageSchool)` | damage multiplier scoped to one school | | `DamageMultSpells(f64, &[u32])` | damage multiplier scoped to a spell list | | `Cleave(f64, u8)` | extra cleave hits at a fraction of damage | | `DamageMultStacking{initial, per_stack}` | multiplier that grows per stack | `BuffEffect` is `#[non_exhaustive]`, and its `scaled(stacks)` method is where the stack count actually applies. Additive stats scale linearly; the `DamageMult*` family compounds via `powf(stacks)` instead: {/* docref fn combat-formulas-buff-effect-scaled */} `BuffTotals` also keeps a per-school multiplier array `school_damage_mult: [f64; DAMAGE_SCHOOL_COUNT]` so school-scoped buffs land on the right hits; the length is `DamageSchool::COUNT` (derived by strum from the enum) rather than a hand-maintained literal, so adding a school can never silently index out of bounds. ### Mastery Mastery is not one formula. It is per-spec, so the finalized `CombatProgram` carries the immutable `mastery_category` definition and the runtime applies it in two shapes: - `MasteryCategory::UniformMult`: mastery multiplies all of the spec's damage equally. - `MasteryCategory::SchoolMult`: mastery multiplies only a specific school. The category is set at build time from the manifest (`mastery_category(params.mastery.category)`), and the resolved mastery percent comes from the stat recompute, scaled by the spec's mastery coefficient. This is deliberately coarse: most specs in WoW have a bespoke mastery, and the engine only models the two that fit a multiplier. Specs whose mastery does something structurally different (e.g. adds a proc, changes resource generation) need a hook, not a category. ## Dealing the hit `DamageCalc::calculate` is the arithmetic; `deal_damage` is the orchestration around it. For a single-target cast it builds one `DamageCalc`, calls `.calculate(rng)`, adds the result to `state.total_damage`, emits a `DamageEvent` to the [telemetry sink](/dev/bible/engine/metrics), and fires impact procs via `fire_impact_procs`. When the spell is flagged AoE it loops `run_single_hit` per target with a per-target multiplier (split, chain, or square-root falloff), and a single-target physical hit can still cleave extra hits when a `Cleave` buff is active. Snapshot DoTs are the exception: `deal_damage_with_snapshot` feeds `DamageCalc` the AP/SP/crit/vers/mastery captured when the DoT was applied instead of the live stats. That mechanism is the subject of the [auras](/dev/bible/engine/auras) page. ## Procs A proc is a random effect that fires off some trigger: a cast, a damage impact, a tick. The engine models two flavours: flat-chance rolls (a fixed probability per trigger) and RPPM (real procs per minute), where the chance scales with how long it has been since the last attempt so that, on average, the proc fires a target number of times per minute regardless of attack speed. All of the random sampling lives in one crate, `engine-rng` (`crates/engine-rng/src/stochastic.rs`), layered on the deterministic [`SimRng`](/dev/bible/engine/event-system) that lives next to it in the same leaf crate. Every primitive takes the RNG as `rng: &mut dyn FnMut() -> f64` so combat code can pass its own seeded generator. ## Flat-chance procs The simplest case is `proc_chance(rng, chance)`: one draw, `rng() < chance`. Every stochastic gate in the engine routes through this one primitive rather than rolling `rng() < chance` inline; a bare inline roll is a lint error (the `rust_raw_rng` Rulewright rule), so the rounding and clamp behaviour stays in exactly one place. There is also `roll_tier(rng, thresholds)` for tiered outcomes, which returns the index of the first ascending cumulative threshold the roll falls under, and `shuffle_pick`, a partial Fisher-Yates used to pick N random items. These are the building blocks. The interesting one is RPPM. ## RPPM Each RPPM source has an `RppmTracker` holding its rate (`rppm`), the time of the last attempt and last successful proc, an accumulator for bad-luck protection (`accumulated_blp`), and two flags: `haste_scales` and `blp_enabled`: {/* docref struct procs-rppm-tracker */} `roll_rppm(tracker, now, haste_pct, rng)` does the work, and it is worth reading in order because each piece corrects for a real failure mode: 1. **Same-time guard.** If this attempt is within `SAME_TIME_TOLERANCE_S = 0.001` of the last one, it returns `false` without rolling. Two events landing at the same instant must not double-roll the same proc. 2. **Elapsed, capped.** `elapsed = (now - last_attempt).max(0)`, then capped at `MAX_INTERVAL_S = 3.5`. The cap stops a long gap (the pull, say, or a movement break) from handing out a near-guaranteed proc on the next attempt. 3. **Haste scaling.** When `haste_scales` is set, `haste_factor = 1 + haste_pct/100`, otherwise `1.0`. This is what makes "per minute" hold as attack speed rises. Faster attacks mean more attempts, so each attempt's chance is scaled up by haste to keep the rate constant. 4. **Base chance.** `base_chance = rppm * haste_factor * (elapsed / 60)`, the rate per minute converted to a probability for this interval. 5. **Bad Luck Protection.** When enabled and the effective rate is positive, the longer you go without a proc, the higher the chance climbs. With `expected_interval = 60 / real_ppm` and `accumulated = min(accumulated_blp, MAX_BAD_LUCK_PROT_S)`: ``` factor = max(1, 1 + (accumulated/expected_interval - 1.5) * 3) chance = clamp(base_chance * factor, 0, 1) ``` The `1.5` and `3.0` constants match the established SimulationCraft BLP factor, per the in-code note. The BLP cap is `MAX_BAD_LUCK_PROT_S = 1000`. 6. **Roll and reset.** `success = rng() < chance`, `last_attempt_time` always advances, and on success `last_proc_time = now` and `accumulated_blp` resets to zero. The accumulator only grows between procs, so BLP ramps and then snaps back. A few candid notes. There is no explicit internal-cooldown (ICD) field on `RppmTracker`. The same-time guard plus the `MAX_INTERVAL_S` cap are the only time-based limiters, so an ICD'd proc would need to be modelled separately. And BLP here is the standard SimC formula, not Blizzard's exact (undocumented) implementation; it is a faithful reproduction of community-reverse-engineered behaviour, which is the best available reference. RPPM trackers are registered at build time. Item procs use `register_item_rppm`, which delegates to `rppm` to register the tracker, then indexes it by item id so the generated item code can look it up: {/* docref fn procs-register-item-rppm */} ## Impact procs Many procs trigger on a damage impact rather than a cast. Those go through `fire_impact_procs`, called at the end of every `deal_damage` and every periodic tick. An `ImpactProc` carries a `chance`, the function to run, and a set of filters that decide whether this particular hit is eligible: | Field | Meaning | | --------------- | ------------------------------------------------------------------ | | `chance` | flat per-eligible-impact proc probability | | `fire` | the `ImpactProcFn` run on a successful roll | | `spell_filter` | optional `fn(u32) -> bool` restricting which spells can trigger it | | `periodic_only` | only periodic (DoT/HoT) impacts are eligible | | `skip_periodic` | periodic impacts are ignored | | `crit_only` | only critical hits are eligible | Before doing any work, `fire_impact_procs` short-circuits on the cases that can never proc: no registered procs, a pet hit, or zero damage. {/* docref code procs-impact-short-circuit */} Pet damage explicitly does not trigger player impact procs, see [pets](/dev/bible/engine/pets). Past the guards it iterates the registered procs, applies each proc's filters, and rolls. To avoid cloning the proc vector on every damage event (the hot path), it uses a `mem::take` and restore against a reusable scratch buffer, the same allocation-avoidance pattern the cast hooks use. The proc function itself receives a `HookCtx`, the constrained post-event context that can apply or consume an aura, gain a resource, reduce or reset a cooldown, deal damage, schedule events, and roll RPPM, but cannot reach the raw event queue directly. That constraint is what keeps proc effects composable: a proc can only do things the engine knows how to schedule. ## Resources A resource is a pool with a current value, a maximum, and a regeneration rate: mana, energy, rage, combo points, and so on. The engine models each spec's primary and (optional) secondary resource as a `ResourceSlot` in the [DenseBuffer](/dev/bible/engine/rotation-compiler), and a cast spends from and gains into those slots as part of the [cast pipeline](/dev/bible/engine/cast-pipeline). ## The slot A `ResourceSlot` is three numbers, `current`, `max`, and `regen_per_sec`, with two mutating operations: - `spend(amount) -> bool`: if `current < amount` it returns `false` and changes nothing; otherwise it subtracts and returns `true`. Spending is all-or-nothing, never partial. - `gain(amount) -> f64`: adds, clamps to `max`, and returns the wasted overflow, the amount that would have pushed `current` past `max`. That `gain` return value is the whole reason the engine can report wasted resource generation. It hands back what it could not fit instead of silently clamping: {/* docref fn resources-slot-gain */} The slot also exposes derived reads for rotations, `deficit`, `pct`, `deficit_pct`, and `time_to_max`, so a script can ask "am I close to capping" or "how long until full" without the engine recomputing anything. ## Spending and gaining a cast When `process_cast` runs at `CastComplete`, it spends and gains resources through `process_resources`, called right after the cast telemetry is emitted. That handles the primary and secondary resource in one pass via `process_single_resource`, which does the same thing for each: - **Cost**: if `cost > 0`, spend it and emit a `Spend` event to the [telemetry sink](/dev/bible/engine/metrics). - **Gain**: if `gain > 0`, gain it and emit a `Gain { wasted }` event carrying the overflow from `ResourceSlot::gain`. The primary cost has two exceptions, both decided at the top of `process_resources`. The cost is zeroed when a cost-bypass aura is active, the mechanic behind "your next cast is free" buffs, or when the spell is a channel whose cost is paid per tick rather than up front. Otherwise it is the spell's flat `resource_cost`: {/* docref fn cast-pipeline-process-resources */} Whether a cast is even allowed to start is a separate, earlier check. `can_cast` runs at `on_player_ready` and includes a resource-cost gate. If the resource is short, the rotation evaluator computes a `resource_wait`, the time until enough primary resource regenerates, and the handler waits instead of casting. So `process_resources` at cast completion is the bookkeeping; the affordability decision already happened. ## Regeneration Energy-style resources regenerate continuously, and the engine does this lazily rather than on a tick. `sync_resource` is called at the start of `on_player_ready` and brings the resource up to the current time in one step. It opens with a guard that makes the call idempotent within a timestamp: if `now <= last_sync` it returns immediately, so repeated reads at the same instant don't double-regenerate. {/* docref fn resources-sync-resource */} Past the guard the math is a single catch-up step: ``` elapsed_s = (now - last_sync) / 1000 regen = regen_per_sec * haste_mult * elapsed_s current = min(current + regen, max) ``` Computing regeneration on demand, only when the rotation is about to make a decision, instead of scheduling a stream of tiny regen events keeps the [event queue](/dev/bible/engine/event-system) small. There is no benefit to ticking energy 10 times a second when nothing reads it in between. The `haste_mult` term is the resource-system equivalent of hasted attack speed. When the spec's resource is haste-scaled (`state.config.haste_regen`), the engine reads the live haste from the player's actor-revision `BuffTotals` cache and scales regeneration by `1 + haste_pct/100`. Aura dependency dispatch refreshes the effective per-second rate only when a relevant aura changes, so the rotation's `regen` and `time_to_max` reads stay current without rescanning unrelated auras. Specs whose regen tracks a specific buff (BM Hunter's Frenzy, say) reach the same math through one shared `systems::resources::set_regen_from_haste` helper instead of re-deriving `base_regen * (1 + haste/100)` inside each hook. ## Telemetry Every spend and gain pushes a `ResourceEvent` carrying the kind (`Spend` or `Gain { wasted }`), the resource type id, the amount, and the post-operation current and max. The telemetry accumulator folds those per-iteration events into per-resource totals, gained, spent, and wasted, which is how the results UI can show, for example, how much energy a rotation threw away by gaining at cap. The `wasted` figure is only meaningful because `ResourceSlot::gain` returns the overflow rather than silently clamping. ## Stats A character's gear gives ratings, a flat number of crit rating, haste rating, and so on. Combat math wants percentages. Converting one to the other is not a constant: WoW applies diminishing returns to secondary stats, so the hundredth percent of crit costs more rating than the first. The engine does not approximate that curve; it reads the game's own diminishing-returns curves out of the DBC data and interpolates them. ## Rating to percent The conversion is `rating_to_percent`: {/* docref fn stats-rating-to-percent */} The shape is two steps. The raw percent is `rating / divisor`, where `divisor` is the level-coupled rating-per-1% value pulled from the game's own CombatRatings GameTable (`ResolvedGameTables::combat_rating_divisor`) for this stat at this character level, the linear baseline before diminishing returns. The `interpolate_sorted` step then bends that raw percent through the relevant DBC curve to get the effective percent. The curve is keyed by rating type: - **Secondary** stats (crit, haste, mastery, versatility) use `SECONDARY_DR_CURVE_ID`, curve 21024. - **Tertiary** stats (leech, speed, avoidance) use `TERTIARY_DR_CURVE_ID`, curve 21025. `dr_curve_id` does that mapping. The curves themselves are `ResolvedCurves`, piecewise-linear point sets resolved from the game data scaling tables at bootstrap; `curve_points(curve_id)` hands back the point set and `interpolate_sorted` (a free function in `engine-domain::dbc`) walks it, clamped at both endpoints. This matters for honesty about the model: the engine is **not** applying a flat "30% cap" or any hand-tuned diminishing-returns approximation. It interpolates the same curve the game uses, so the DR behaviour is correct by construction as long as the curve data is current. There is no silent fallback. `rating_to_percent` returns a `Result`, and a missing curve (or a missing divisor row) is a hard error, `StatsError::MissingCurve` / `StatsError::MissingCombatRating`, not a degraded "DR off" mode. Because this runs in the gear-resolution setup path, a missing curve that silently produced an un-diminished, uncapped percent would mean wrong DPS; failing loud is the deliberate choice. ## Recompute `recompute` turns the raw primary stats and ratings into the combat-ready numbers the [damage formula](/dev/bible/engine/combat-formulas) reads: {/* docref fn stats-recompute */} Everything is in **percent points** (`25.0` means 25%, never a `0..=1` fraction), and `recompute` itself returns a `Result`: any missing curve or divisor surfaces as a `StatsError` rather than a silently-wrong stat. What it produces: - **Attack power and spell power** are both set to the spec's primary attribute value. The spec's primary attribute, strength, agility, or intellect, comes from `primary_stat_for_spec`. - **Crit** = `BASE_CRIT_CHANCE * 100 + crit_pct`, the innate 5% plus the rating-derived crit percent, both in percent points. - **Haste** = the rating-derived haste percent. - **Mastery** = `mastery_pct * mastery_coeff`. The per-spec coefficient is what translates "mastery percent" into the spec's actual mastery effect; the manifest supplies it. - **Versatility** = the rating-derived versatility percent. Every secondary above goes through `rating_to_percent`, so the DR curve is applied uniformly. Attack power and spell power being identical to the primary attribute is a simplification. It skips weapon DPS and the various AP-per-stat conversions, but the weapon contribution to physical hits is added separately in the damage chain via the weapon roll, so the AP value here is the stat-scaling portion only. `CombatStats` is the output struct, re-exported from `engine-ports`. Alongside the secondaries it carries a `crit_damage_bonus` field (percent points on top of the base 2x crit), so its `crit_multiplier()` accessor is `2.0 + crit_damage_bonus/100` rather than a hardcoded constant. Those resolved stats are what the damage chain reads when it computes a hit. There is also a `default_stats()` used for introspection and quick smoke tests, AP/SP 15000, crit 25, haste 15, mastery 40, versatility 5, so a spec can be built and introspected without any gear resolved. ## Where the curves come from The DR curves are part of [game data](/dev/bible/game-data/data-resolution): the `curve_points` scaling table is fetched alongside item scaling, folded into `ResolvedCurves` via `from_scaling` (which sorts each curve's points by x), and carried into the sim as part of the resolved data. Because the same curve data drives both gear scaling and stat conversion, the rating-to-percent numbers stay consistent with how the game would scale the gear that produced those ratings. ## Metrics A single run produces a DPS number, but a useful sim produces a distribution: a mean and its error, per-spell breakdowns, buff uptimes, resource accounting, and a representative timeline to look at. The engine collects all of that incrementally as it runs, never holding more than one iteration's worth of fine-grained events in memory at a time. The shape is two layers. Each iteration fills a `TelemetrySink` with raw events; after the iteration, those events are folded into a `TelemetryAccumulator` that carries running aggregates across iterations. The sink is cleared and reused; the accumulator grows. At the end, the accumulator encodes itself into protobuf bytes: the `ChunkTelemetry` that travels back to the rest of the platform. ## The two layers `TelemetrySink` is the per-iteration collector. It is a bundle of pre-allocated vectors, one per event category, that the combat functions emit into during a run: {/* docref struct metrics-telemetry-sink */} It is allocated once per chunk and cleared at the start of each iteration, so the hot path appends without allocating. `TelemetryAccumulator` is the cross-iteration aggregate. It holds the DPS statistics as streaming state, a count, a sum, min and max, and a running Welford pair (mean plus `M2`, the sum of squared deviations) alongside an auto-resizing HDR histogram that each iteration records into directly. There is no growing vector of per-iteration DPS values: the running pair and the histogram are both O(1) per iteration in memory. Around those sit the per-spell aggregates, aura uptimes, resource totals, per-second damage buckets, the direct/periodic/pet damage split, and the representative iteration's captured timeline: {/* docref struct metrics-telemetry-accumulator */} The handoff happens in the loop's post-iteration tail. After a run finishes, the loop computes the iteration's DPS and calls three accumulator methods in order: `record_sink_events` to fold this iteration's sink into the running totals, `record_iteration` to update the DPS statistics, and `maybe_capture_representative` to possibly snapshot this iteration's timeline.
```mermaid flowchart TB Iter["iteration: combat functions emit"] -->|emit_damage / emit_aura / ...| Sink[(TelemetrySink - per iteration)] Sink -->|record_sink_events| Acc["TelemetryAccumulator"] Iter -->|DPS| RecIter["record_iteration: Welford mean+M2, histogram.record, representative pick"] RecIter --> Acc Sink -->|maybe_capture_representative| Rep["representative timeline snapshot"] Rep --> Acc Acc -->|merge parallel chunks| Merge["combined accumulator: parallel-Welford, histogram.add, SIMD bucket sums"] Acc -->|encode| Encode["proto ChunkTelemetry"] Merge -->|encode| Encode Encode -->|serialize_histogram| Hist["HDR histogram bytes"] Encode -->|encode_to_vec| Bytes["telemetry_bytes"] ```
This figure expands the **telemetry** box of the [sim-pipeline figure](/dev/bible/engine/discrete-event-simulation). The flow: - During an iteration the combat functions push into the sink (`emit_damage`, `emit_aura`, `emit_resource`, and so on). - `record_sink_events` folds the iteration's events into the accumulator's running totals, and buckets damage into per-second slots. The timeline's resolution is one second (`TIMELINE_BUCKET_MS`). - `record_iteration` updates the Welford mean and `M2`, records the iteration's DPS into the histogram, and retains its lightweight deterministic identity as a representative candidate. - `merge` combines two accumulators when chunks run in parallel, via parallel-Welford on the running pair plus `histogram.add`, and joins their representative candidates. - `encode` produces the protobuf, serialising the already-built histogram and packing every aggregate. ## The representative iteration A run does thousands of iterations, but you can only show one timeline. Which one? After all chunks are merged, the accumulator picks the iteration whose DPS is **closest to the final aggregate mean**. Equal-distance candidates use their deterministic seed prefix and iteration index as a stable tie-break: {/* docref code metrics-representative-pick */} The execution layer then reruns exactly that deterministic iteration and installs its timeline (cast markers, damage markers, aura windows, cooldown windows) without changing the aggregate statistics. Each marker carries a typed `MarkerKind` enum (`Cast`, `Damage`, `Resource`, `Proc`) rather than a raw integer tag, so the kind survives the trip to the portal as a named value the consumer can match on. Picking the mean-nearest iteration rather than the best or worst is deliberate: a timeline shown to a user should be typical, not a lucky outlier. Selecting only after the final merge makes the result exact for the complete run instead of whichever chunk-local mean happened to choose it first. ## Statistics and convergence The DPS statistics accumulate incrementally with a streaming Welford update: each iteration adjusts the running mean and the running sum of squared deviations (`M2`) in constant time, from which the variance and standard deviation fall out as `M2 / n` without a second pass. The standard deviation feeds the adaptive early-exit in the [chunk loop](/dev/bible/distribution/orchestration): the loop periodically computes the relative standard error of the mean and stops once it drops below the requested `target_error`, so a chunk runs exactly as many iterations as it needs for the requested precision and no more. When chunks run in parallel (every sim now fans out through the application's parallel runner, with one thread as the degenerate single-chunk case), each thread builds its own accumulator and they are combined with `merge`. Merging combines the two Welford pairs with the parallel-Welford formula (`M2 = M2_a + M2_b + delta^2 * n_a * n_b / n`), which recovers the exact combined variance without ever holding the raw values, folds one chunk's histogram into the other with `histogram.add`, sums the per-spell and resource aggregates, adds the per-second bucket sums with a SIMD helper, and re-selects the representative against the combined mean. This is what lets a sim split across cores and still report one coherent distribution. ## The protobuf encoding The final step is `encode`, which consumes the accumulator and produces the `ChunkTelemetry` protobuf bytes. The HDR histogram is already built, recorded into one sample at a time during the run, so `encode` only serialises it to the `hdrhistogram` V2 byte format rather than scanning a value vector. It then emits the per-spell action rows, the aura and resource and cooldown rows, the execution and damage-profile data, the per-second bucket sums, and the representative timeline snapshot, then serialises the whole thing with prost's `encode_to_vec`. Two encoding details matter for fidelity. DPS and damage values are scaled by ten (`PROTO_DPS_SCALE`) and resource values by a hundred (`PROTO_RESOURCE_SCALE`) before being rounded into integers, so a decimal place of precision survives the integer wire format. And the histogram is HDR and auto-resizing rather than a fixed-bin histogram, so it records the DPS distribution across its full range at consistent relative precision without committing to bucket boundaries up front, which is exactly what makes recording it incrementally safe: there is no known-range problem to solve before the first iteration runs. Those bytes are the `telemetry_bytes` of a `ChunkReport`. From here the data leaves the engine entirely, decoded in the [portal](/dev/bible/portal/simulation-ui) for charts, or merged across chunks by the [orchestration](/dev/bible/distribution/orchestration) and [hosted-compute](/dev/bible/distribution/hosted-compute) layers for a full-job result. ## Pets I want to be candid here: pets are not a first-class actor in the engine. There is no separate pet event loop, no pet handler, no independent pet rotation. What exists is scaffolding: a handful of touch-points that let pet damage be attributed and let a rotation know a pet is present, sitting on top of the player's own [event loop](/dev/bible/engine/event-system). This page documents exactly that scaffolding and nothing more. ## What's actually there **A pet flag on the manifest.** A spec can declare `has_pet` in its `[spec]` section. That flows through the build into `BaseStats.has_pet`. **A pet buffer slot.** The [DenseBuffer](/dev/bible/engine/rotation-compiler) carries a singleton `PetSlot` with three fields: `is_active`, `count`, and `expires_at`, exposed to rotations as `is_active`, `count`, and `remaining`. At buffer initialization a spec flagged `has_pet` starts with the pet summoned, so `pet.is_active` reads true from `t = 0`: {/* docref code pets-seed-slot */} That is the whole lifecycle. A rotation can branch on whether a pet is up, but the engine does not itself summon, dismiss, or time a pet. Those fields are seeded once and otherwise driven only by whatever a spec's hooks choose to write. **A pet auto-attack.** `AutoAttackData` has an `is_pet` flag. In `phase_auto_attacks`, a pet auto-attack is the branch that gets no weapon slot. Pets have no weapon item, so it deals AP-only damage on its own swing timer. This is the one place a pet generates damage on its own schedule, and it does so by riding the same `AutoAttack` event the player uses. **A pet damage tag.** Every damage instance carries an `is_pet` flag, through `HitFlags::PET` and on the `DamageEvent` itself. The telemetry accumulator keeps a separate `pet_damage_total`, summed whenever a tagged event arrives, and reports it as its own slice of the damage profile alongside direct and periodic damage. That is why the results UI can show a pet's share of total damage. **Pet hits don't trigger player procs.** `fire_impact_procs` returns immediately on a pet hit, and the AoE path treats pet hits as single-target. Pet damage is accounted for but kept out of the player's [proc](/dev/bible/engine/procs) and cleave machinery. ## What this is not Taken together, the scaffolding lets a spec model a pet as a tagged source of auto-attack and ability damage that shares the player's clock and stats. What it does not provide: - No independent pet rotation or AI. A pet does not decide its own casts. - No pet stat sheet. Pet damage scales off the player's attack power via the auto-attack `ap_coef`, not a separate pet paperdoll. - No summon/despawn lifecycle beyond the seeded `PetSlot` fields; the `expires_at`/`count` fields exist but are not driven by a general pet-management system. - No pet-specific resources, cooldowns, or auras as first-class buffer domains. A proper pet implementation would mean a second actor: its own handler, its own slots in the buffer keyed per-pet, its own scheduled events, and a way to relate its stats to the owner's. That is a meaningful amount of structure, and the engine does not pretend to have it. The pet specs that exist today are approximated by folding pet output into the player's timeline as tagged damage, accurate enough for total throughput, but not a simulation of the pet as an entity. I would rather state that plainly than imply more. ## Content System This page documents the system that renders this page. The bible and the docs are MDX files in `packages/shared/src/content`, compiled at build time by [velite](https://velite.js.org) into a typed content collection, then rendered through a set of custom MDX components: the `
`, ``, ``, and `` you see throughout. The content is shared source: both the studio and landing apps point velite at the same tree. The mental model is three stages: velite parses the MDX and validates frontmatter; a content-collection helper turns the flat list of entries into a navigable, ordered tree; and the App Router renders each entry with the custom components. ## velite: parse and validate The studio's `velite.config.ts` declares two collections, `bible` and `docs`, both reading from the same shared content root. The `bible` collection's schema requires `title` and `description`, optionally `nextSteps`, and derives `body` (compiled MDX), `toc`, a git-commit `updatedAt`, and a `sortKey` from the file path: {/* docref code content-system-bible-collection */} The `updatedAt` field is computed by shelling out to `git log -1 --format=%cd` per file, so the "last updated" you see is the real commit date, not the build date. Code blocks are highlighted at build time with `rehype-pretty-code` and Shiki, and a small rehype plugin stashes the raw source on each `
` so the copy button has something to copy.

## The number-prefix rule

Files and folders are numbered, e.g. `05-portal/03-simulation-ui.mdx`. That prefix is load-bearing, not cosmetic. The content-collection builder parses the leading `NN-` to derive ordering, and it refuses anything without it:

{/* docref code content-system-extract-order-guard */}

The prefix is then stripped to form the public slug, so the URL is `/dev/bible/portal/simulation-ui`, not `.../05-portal/03-...`. This is why every cross-link in this bible uses the stripped path while `nextSteps` frontmatter uses the numbered one. They address the same page through two layers of the pipeline.

`createContentCollection` also builds the section index, resolves adjacent prev/next pages, and resolves the `nextSteps` numbered paths back to navigable items. The studio binds the velite output into this helper in `apps/studio/src/lib/content/bible.ts`.

## Rendering

The bible route is a dynamic catch-all, `[...slug]`, that prerenders every slug via `generateStaticParams` over `bible.slugs`. It fetches the page data and hands the compiled `body` to a shared `ArticleLayout` together with the custom MDX component map, `studioMdxComponents`.

That map is where the bible-specific components are registered:

{/* docref code content-system-mdx-components */}

Each tag maps to the component that renders it:



| MDX tag                                                         | Component       | What it renders                                        |
| --------------------------------------------------------------- | --------------- | ------------------------------------------------------ |
| `
` | `MdFigure` | A numbered, captioned figure (mermaid / table / image) | | `` | `MdBibleTable` | A numbered, captioned GFM table | | `` | `MdTerm` | A glossary hover-card link | | `` | `MdCite` | A numbered citation with a reference hover-card | | `` / `` / `` / `` | list components | The index pages that enumerate all of each kind | ## The figure / table / term / reference registries A deliberate choice: the metadata for figures, tables, glossary terms, and references is **not** in the MDX. It lives in four central TypeScript files, `apps/studio/src/content/{figures,tables,terms,references}.ts`, keyed by id. The MDX only references an id; the component looks the rest up. - `
` wraps a mermaid fence (or table/image). `MdFigure` looks `x` up in `figures` to get its number and caption. Captions are registered centrally, so the MDX does not pass one. - `` works the same way against `tables`. - `label` renders a hover card with the term's name, expansion, and description from `terms`, linking to the glossary anchor; an unknown id renders a visible `(?)` rather than crashing. - `` renders a numbered link with a reference hover-card and an optional locator; references support plain URLs, DOIs, and archived snapshots. The reason for the central registries is consistency: numbering, captions, and the index pages (`/dev/bible/figures`, `/dev/bible/glossary`, `/dev/bible/references`) all derive from one source, so a figure cannot have two different captions and the numbering cannot drift from the prose. The cost is that adding a figure touches two files, the MDX and the registry, which is a fair trade for never having a mislabeled or dangling reference. Mermaid diagrams are rendered client-side; velite does not validate them, so a malformed diagram fails in the browser, not at build. That is why every diagram in this bible is wrapped in `
` with the fence isolated by blank lines. ## Death Knight Work in progress. ## Demon Hunter Work in progress. ## Druid Work in progress. ## Evoker Work in progress. ## Hunter Work in progress. ## Mage Work in progress. ## Monk Work in progress. ## Paladin Work in progress. ## Priest Work in progress. ## Rogue Work in progress. ## Shaman Work in progress. ## Warlock Work in progress. ## Warrior Work in progress. ## Figures _Dynamic page: content is rendered live at https://app.dev.wowlab.gg/dev/bible/figures and is not included here._ ## Tables _Dynamic page: content is rendered live at https://app.dev.wowlab.gg/dev/bible/tables and is not included here._ ## Glossary ## References We include screenshots of all website sources, as well as Wayback Machine links to fight link rot. Please consider visitig the original websites if they are still reachable and have the revelant sources. # Blog ## Hello! Welcome to WoW Lab. This is our first blog post. ## What is this? WoW Lab is a combat simulator for World of Warcraft. Build rotations in the browser, then run simulations on WoW Lab's servers. Simulations require an account, while paid plans offer higher limits. This is **version 0.1.0**, very much a work in progress. Things will break, features are incomplete, and we're iterating fast. Building in public. ## What's next? More news soon. This blog covers updates, guides, and announcements as we go. In the meantime: - Check out the [About](/about) page to see what WoW Lab is and how it works - See which specs are ready to sim on the [Spec Coverage](/dev/docs/guides/01-spec-coverage) page - Join our [Discord](/go/discord) to chat, ask questions, or share feedback Thanks for stopping by.