Command Palette

Search for a command to run...

Sign in

Talent Trees

The TraitTreeFlat graph, its node and edge types, and how a base64 loadout string decodes into the per-node selections the engine simulates

3 min read

A talent tree is a graph: nodes connected by edges, where each node grants one or more spells and may have several ranks. A loadout, the string you paste from the game or from Wowhead, is just a compressed list of which nodes you bought and how many ranks. The data layer's job is two halves: describe the tree once, and decode a loadout against it into a concrete set of selections the engine can turn into spell ids.

The tree: TraitTreeFlat

TraitTreeFlat is the flattened description of one spec's entire trait graph. Like every other flat type it deserializes identically from CSV-transform output or a Supabase row:

rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TraitTreeFlat {
    pub spec_id: i32,
    pub spec_name: String,
    pub class_name: String,
    pub tree_id: i32,
    pub all_node_ids: Vec<i32>,
    pub nodes: Vec<TraitNode>,
    pub edges: Vec<TraitEdge>,
    pub sub_trees: Vec<TraitSubTree>,
    pub point_limits: PointLimits,
}

The fields split into three groups. spec_id, spec_name, class_name, and tree_id are identity. all_node_ids is the full node membership, and nodes plus edges are the graph itself, with sub_trees holding the hero-talent subtrees. point_limits is the spend caps, defaulting to class 31, spec 30, and hero 10.

A TraitNode carries its grid position, max_ranks, a node_type, and a Vec<TraitNodeEntry>. The entries are the important part. Each one holds the data the engine ultimately cares about:

rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TraitNodeEntry {
    pub id: i32,
    pub definition_id: i32,
    pub spell_id: i32,
    pub name: String,
    pub description: String,
    pub icon_file_name: String,
}

A normal node has one entry; a choice node has several, and the loadout records which one you picked. Edges are minimal, just id, from_node_id, to_node_id, and visual_style, and exist mostly for the planner UI. The engine cares about purchased nodes, not the topology connecting them.

Decoding a loadout

A loadout string is a base64-encoded bitstream in the same format the game client and Wowhead use. Decoding it is a pure function over the bytes, with no game data required. decode_trait_loadout reads a version byte, a 16-bit spec id, a 16-byte tree hash, and then a per-node run of selection bits: selected, purchased, partially-ranked, a 6-bit rank count, and for choice nodes a 2-bit choice index. The result is a DecodedTraitLoadout, the header plus a Vec<DecodedTraitNode>, one entry per node in tree order.

That decoded list is positional. It says "node 0 has 2 ranks, node 1 is unselected", so it only becomes meaningful once paired with the tree that defines what each position is. That pairing happens in apply_decoded_traits, which walks the tree's nodes alongside the decoded selections and produces a TraitTreeWithSelections: the tree flattened together with a Vec<TraitSelection>. Each TraitSelection is { node_id, selected, ranks_purchased, choice_index }, the engine-facing form.

The resolver wires these two functions together. DataResolver::decode_traits has a default body that fetches the tree with get_trait_tree, decodes the string with decode_trait_loadout, and applies it with apply_decoded_traits. Because the body is default, every backend gets loadout decoding for free as long as it can return a TraitTreeFlat.

From selections to spell ids

The engine does not simulate selections directly; it simulates the spells those selections grant. get_trait_spell_ids bridges the gap:

rust
fn get_trait_spell_ids(
    &self,
    spec_id: i32,
    trait_string: &str,
) -> impl core::future::Future<Output = Result<Vec<SpellId>, ResolverError>> {
    async move {
        let tree_with_selections = self.decode_traits(spec_id, trait_string).await?;

It decodes the loadout, then for every selection with ranks_purchased > 0 it finds the matching node, reads the chosen entry (choice_index defaults to 0), and collects entry.spell_id. The result is sorted and deduplicated. Those ids are exactly what bootstrap feeds into the resolution pass as extra spells to fetch, so a talented spell ends up in the engine's game-data view the same way a baseline spell does, a thread I pick up in Codegen.

The honest limitation here is that the WASM resolver cannot decode loadouts at all: its get_trait_tree is a stub that returns TraitTreeNotFound, so the default decode_traits body fails before it begins. In the browser, talent decoding therefore happens JS-side before the engine is invoked, not across the WASM boundary. I cover that asymmetry in Data Resolution.