Command Palette

Search for a command to run...

Se connecter

Data Resolution

The DataResolver port and its six backends, the three-layer GameDataCache, and the introspection-driven resolve_game_data pass that builds the engine's ResolvedGameData

5 min de lecture

The engine never asks "where does this spell's data live?" It asks "give me spell 12345," and one trait answers, regardless of whether the bytes come from a CSV file, a Postgres mirror, a binary cache, or a JavaScript object in a browser tab. That trait is the resolver, and this page is the hinge of the whole data layer: it is where the four sources of the previous pages converge into one interface, and where that interface gets folded into the single read-only view the simulation runs on.

The figure below expands the Game Data box of the system-context diagram in Architecture. Read it left to right: sources on the left, the DataResolver trait in the middle erasing their differences, and the resolution pass on the right collapsing everything into ResolvedGameData.

Figure 8
Data Resolution Pipeline
Expands the Game Data box of the system context: the three sources (CSV, Supabase, JS bridge) behind the DataResolver port, the DBC-to-flat transform, and the resolve_game_data pass that folds them into ResolvedGameData.

The port: DataResolver

DataResolver is an async trait. Every backend implements the same handful of required methods, get_spell, get_spell_effect, get_item, get_scaling_data, get_power_types, get_spec, get_trait_tree, get_rotation_script, and inherits default bodies for the rest. get_spells batches over get_spell, decode_traits composes the loadout functions from Talent Trees, and so on.

Making an async trait usable as a trait object is otherwise impossible, so two attribute macros do the work:

rust
#[cfg_attr(target_arch = "wasm32", allow(async_fn_in_trait))]
#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send + Sync))]
#[dynosaur::dynosaur(pub DynDataResolver = dyn(box) DataResolver, bridge(dyn))]
pub trait DataResolver {

trait_variant::make(Send + Sync) produces a Send + Sync variant on native targets, while on wasm32 the futures are intentionally left !Send because the browser is single-threaded and demanding Send there buys nothing. Then dynosaur::dynosaur generates DynDataResolver, the boxed, dyn-compatible wrapper that the rest of the engine passes around. DynDataResolver::new_box, new_arc, and from_ref are the three ways a concrete resolver gets erased.

Failures flow through one #[non_exhaustive] ResolverError enum with a variant for each kind of miss, SpellNotFound, ItemNotFound, TraitTreeNotFound, and so on, plus transport errors for IO, the JS bridge, and Supabase. Because it is non-exhaustive, adding a new backend's error mode does not break existing matches.

The six backends

There are six implementations, and the choice among them is entirely a function of where the process runs, not of any runtime configuration the user sees.

Tableau 6
DataResolver Implementations
ImplSourceWhere it runsFile
SupabaseResolvergame.* over PostgREST, via GameDataCachenodes, server-sideremote/supabase.rs
LocalCsvResolverDbcData from CSV, transformed lazilyCLI, forge, devlocal/csv.rs
JsResolvera JS object's getSpell/getItem/… promisesbrowser (WASM)bridge/js.rs
OverlayResolver<R>decorates any base R with in-memory overrideseverywhere overrides are neededbridge/overlay.rs
InMemoryResolverhand-built mapstestsin_memory.rs
The five implementations of the DataResolver port — SupabaseResolver, LocalCsvResolver, JsResolver, OverlayResolver, InMemoryResolver — their source and where each runs.

OverlayResolver is the one that is not a source at all. It is a decorator that wraps another resolver and shadows specific spells, effects, or rotation scripts from in-memory maps before delegating the rest to the base. That is how a custom rotation or a tweaked spell gets injected without touching the underlying data, which the CLI, forge, and the WASM path all rely on. Its whole state is the base plus three override maps:

rust
pub struct OverlayResolver<R> {
    base: R,
    spell_overrides: IntMap<i32, SpellDataFlat>,
    effect_overrides: HashMap<(i32, u8), SpellEffect>,
    rotation_overrides: FastMap<String, String>,
}

Two honest naming and behaviour notes, because the code does not match every casual description of it:

  • The Supabase backend is SupabaseResolver, not "RemoteSupabaseResolver". There is no type by the latter name. It is a thin wrapper whose only field is a GameDataCache.
  • Its rotation queries use a different schema from everything else. Every game-data fetch goes through get_json(path, "game"), setting an Accept-Profile: game header, but get_rotation_script and get_assisted_rotation use plain client().get(path) with no profile header, so they read the rotations table from the default (public) schema. The split is deliberate, rotations are user content, not game data, but it is easy to miss when reading the resolver as "all PostgREST."

The WASM backend is also deliberately partial. JsResolver's get_spec, get_trait_tree, and get_spell_effect are stubs that return Not Found rather than calling into JS, so spec- and trait-aware data is simply absent across the boundary. The browser side handles those concerns before invoking the engine; I trace the JS object protocol in WASM Boundary.

The three-layer cache

SupabaseResolver is fast only because it almost never hits the network. Its GameDataCache is a read-through cache with three layers, and the state machine below expands the GameDataCache box of the resolution figure above.

Figure 9
Game Data Cache Layers
Expands the GameDataCache box of the data-resolution pipeline: the L1 Moka memory, L2 disk JSON, and L3 PostgREST read-through path, plus patch-version invalidation.

Spells, traits, items, and specs all flow through one generic path, get_cached(mem_cache, disk_category, key, fetch). The body is the whole three-layer story in eleven lines:

rust
if let Some(v) = mem_cache.get(&key) {
    return Ok(v);
}
if let Some(v) = self.read_disk::<V>(disk_category, key) {
    mem_cache.insert(key, v.clone());
    return Ok(v);
}
let v: V = fetch(key).await?;
self.write_disk(disk_category, key, &v)?;
mem_cache.insert(key, v.clone());
Ok(v)

Try the L1 Moka in-memory cache, fall back to the L2 disk JSON file and promote any hit back into L1, and only on a double miss issue the L3 PostgREST request, then write the result to disk and L1. The Moka caches are capacity-bounded; spells cap at 50,000 entries.

Invalidation is by patch version. On construction the cache reads {cache_dir}/patch_version, and if it does not match the requested patch it clears the entire disk cache. A patch bump throws away everything stale in one step rather than trying to diff individual rows.

Two paths sidestep the generic machinery, and they are worth knowing:

  • Scaling data is fetched as seven parallel requests, not four. get_scaling_data issues one futures::try_join! over all seven scaling tables, item_bonuses, curves, curve_points, rand_prop_points, item_scaling_configs, item_offset_curves, item_squish_eras, and folds them with the ItemScalingData::from_flat constructor from Items and Scaling. The crate's own CLAUDE notes say four; the code does seven. The result is cached behind an RwLock because it is a single large bundle rather than per-id rows.
  • Power types are not cached at all. get_power_types issues a fresh request every call. There are few power types and they are read rarely, so the cache bookkeeping was not worth it, a small and deliberate exception.

Folding it all into ResolvedGameData

The resolver answers per-spell questions, but the engine wants one consolidated, immutable view of every number for the spec it is about to simulate. resolve_game_data builds it. The key idea is that it does not fetch a fixed list of spells. It asks the spec what it uses, then fetches exactly that.

It introspects the spec handler to discover every spell, aura, and auto-attack id it references, unions in the talent and item ids that bootstrap collected, and then walks that set: for each spell it fetches SpellDataFlat, derives costs and gains and the corrected cooldown, and records the props; for each effect it records the AP/SP coefficients; for each aura it records duration, stacks, and pandemic behaviour. The output is ResolvedGameData, an Arc-wrapped, cheap-to-clone, read-only value. Because the build is driven by what the spec actually needs, an unused spell never costs a fetch, a property that matters most over the network. The full pass, and how generated code reads back from this value, is the subject of Codegen.

Étapes suivantes