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
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.
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:
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.
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:
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 aGameDataCache. - Its rotation queries use a different schema from everything else. Every game-data fetch goes through
get_json(path, "game"), setting anAccept-Profile: gameheader, butget_rotation_scriptandget_assisted_rotationuse plainclient().get(path)with no profile header, so they read therotationstable 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.
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:
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_dataissues onefutures::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 theItemScalingData::from_flatconstructor from Items and Scaling. The crate's own CLAUDE notes say four; the code does seven. The result is cached behind anRwLockbecause it is a single large bundle rather than per-id rows. - Power types are not cached at all.
get_power_typesissues 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.
Nächste Schritte
