Command Palette

Search for a command to run...

Se connecter

Codegen

How TOML manifests become Rust spec code through manifest-schema and codegen-cli, and the build-time override versus run-time data-accessor loop that ties generation to ResolvedGameData

5 min de lecture

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, showing the build-time half of that picture: manifests in, generated Rust out.

Figure 10
Spec Codegen Pipeline
Expands the build-time content facet of the Engine box: per-spec manifests/*.toml validated against manifest-schema, compiled by codegen-cli into engine-content/src/generated, whose builder functions read ResolvedGameData at bootstrap.

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:

rust
#[derive(Debug, Deserialize)]
pub struct Manifest {
    #[serde(default = "default_schema_version")]
    pub schema_version: u32,
    pub spec: SpecSection,
    pub resource: ResourceSection,
    #[serde(default)]
    pub secondary_resource: Option<SecondaryResourceSection>,
    #[serde(default)]
    pub auras: IndexMap<String, AuraDef>,
    #[serde(default)]
    pub spells: IndexMap<String, SpellDef>,
    #[serde(default)]
    pub auto_attacks: IndexMap<String, AutoAttackDef>,
    #[serde(default)]
    pub talents: BTreeMap<String, u32>,
    #[serde(default)]
    pub metric_keys: Option<MetricKeysSection>,
    #[serde(default)]
    pub rotation_schema: Option<RotationSchema>,
    #[serde(default)]
    pub hero_talents: IndexMap<String, HeroTalentTree>,
    #[serde(default)]
    pub spell_groups: Vec<SpellGroupDef>,
}

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."

Tableau 7
Spec Manifest Sections
SectionTypeHolds
specSpecSectionWoW spec id, pet flag, custom handler, precombat/stealth auras
resourceResourceSectionname, max, regen, starts_at, type id
secondary_resourceOption<SecondaryResourceSection>name, max, type id only, no regen
aurasIndexMap<String, AuraDef>aura defs; declaration order = LocalAuraIdx
spellsIndexMap<String, SpellDef>spell defs; declaration order = LocalSpellIdx
auto_attacksIndexMap<String, AutoAttackDef>swing timers and AP coefficients
talents / hero_talentsBTreeMap / IndexMaptalent name-to-id maps
metric_keys / rotation_schemaoptional sectionstelemetry keys and rotation field schema
The sections of a per-spec manifest-schema Manifest (spec, resource, auras, spells, auto_attacks, talents, hero_talents, metric_keys, rotation_schema) and their types.

A SpellDef carries id and then a long list of optional overrides:

rust
#[derive(Debug, Deserialize)]
pub struct SpellDef {
    pub id: u32,
    #[serde(default)]
    pub cooldown: Option<f64>,
    #[serde(default)]
    pub cost: Option<f64>,
    #[serde(default)]
    pub gain: Option<f64>,
    #[serde(default)]
    pub damage: Option<DamageDef>,
    #[serde(default)]
    pub off_gcd: bool,
    #[serde(default)]
    pub cast_time_ms: Option<u32>,
    #[serde(default)]
    pub gcd_ms: Option<u32>,
    #[serde(default)]
    pub charges: Option<u8>,
    #[serde(default)]
    pub charge_cd: Option<f64>,
    #[serde(default)]
    pub applies_aura: Option<String>,
    #[serde(default)]
    pub reduces_cd: Option<Vec<ReducesCd>>,
    #[serde(default)]
    pub reduces_cd_chance: Option<ReducesCdChance>,
    #[serde(default)]
    pub resets_cd_while: Option<ResetsCdWhile>,
    #[serde(default)]
    pub hook: Option<String>,
    #[serde(default = "default_breaks_stealth")]
    pub breaks_stealth: bool,
    #[serde(default)]
    pub aoe: Option<AoeDef>,
    #[serde(default)]
    pub channel: Option<ChannelDef>,
    #[serde(default)]
    pub requires_aura_id: Option<u32>,
    #[serde(default)]
    pub requires_aura_min_stacks: Option<u8>,
    #[serde(default)]
    pub cost_free_when_aura: Option<u32>,
}

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 modules), the local-index constants, one aura_* and one spell_* builder function per declaration, the build_combat_system function, the handler factory, and the define_spec_descriptor! invocation. The descriptor registers the spec at link time through the inventory crate, which is how find_descriptor later finds a spec by id without any central registry to maintain.

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 generated Arcane Mage code, for instance, fills a spell's cooldown with data.cooldown_s(...) and its damage by passing data.damage_def(...) into the builder's s.damage_auto(...), both reading the very same ResolvedGameData that the resolution pass built. cooldown_s and damage_def are ResolvedGameData accessors; damage_auto is the CombatSystemBuilder method that consumes the resolved DamageDef. Each accessor returns an Option, and the generated code converts a None into BuilderError::MissingSpellData, so a spell whose data never resolved fails the build of the combat system rather than silently simulating zeros.

That closes the loop with 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 by introspecting the spec the generated code defined. 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. And because both halves agree on the spell ids (the generated id constants are exactly the ids the resolver fetched), the override and the default always line up. 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 registered spec, and asks what it does with them.

Étapes suivantes