Orchestration
simulate_intent, bootstrap, and run_chunk. The single engine entry contract shared by the browser, the worker node, the CLI, and forge.
Every caller that wants a simulation result goes through one function: simulate_intent. That means the browser, a hosted compute node, the CLI, and the forge comparison tool. You hand it a TOML config, a chunk description, a seed, a data resolver, and a progress sink; it hands back protobuf telemetry bytes. Everything between is bootstrap (turn config + game data into a ready handler) followed by run_chunk (drive that handler through N iterations).
simulate_intent lives in the engine-application crate, the layer that sits between the host shells and the pure simulation core. Its job is orchestration, not simulation: parse and validate the intent, resolve all game data through the DataResolver port, build the per-spec SpecHandler, then loop the core engine and accumulate telemetry.
The pipeline
The figure below expands the Engine box of the system-context diagram. It is the Zoom-1 sim-pipeline figure; this page owns the Zoom-2 view of its run_chunk loop box, the chunk lifecycle state machine at the end.
There are four public entry functions, and they all funnel into the same bootstrap_chunk → run_chunk* spine:
The signature of the convenience entry is the contract every host meets:
simulate_intent wraps its arguments into a SimRequest with IntentOverrides::default() and delegates to simulate_intent_request, whose body is the whole layer in two lines: bootstrap_chunk then run_chunk.
bootstrap: config and game data become a handler
bootstrap is where the cost lives. It is async because game-data resolution is async. In order, it:
- Parses the TOML into a
SimConfigIntentand validatesintent_version == "v1", the spec id, a non-emptyrotation_id, and the fightduration_sagainst1.0..=MAX_DURATION_S(1800 s). - Resolves the buff toggles, bloodlust, flask (defaults on), augment rune (defaults on), tempered pre-pot, and race, all suppressed when
overrides.no_buffsis set. - Looks up the spec via
find_descriptor(spec), an inventory-registry lookup over the link-time-collectedSpecDescriptors. - Fetches the rotation JSON through the resolver.
- Turns gear into stats, either
compute_stats_from_gear(the real pipeline) orbench_gear_result(default stats), both yielding aGearResult. - Assembles the full list of extra spell ids to resolve, item-effect spells, set-bonus auras, the on-toggle buff spells, the racial, and the codegen-emitted item resolve ids, then decodes talents.
- Calls
resolve_game_data, the big async pass that introspects the spec to discover its spell/aura ids and resolves every one into aResolvedGameData(data resolution covers this in detail). - Builds the handler by calling the descriptor's
handler_factorywith a 15-fieldHandlerParams.
The output is a single Box<dyn SpecHandler>. bootstrap_chunk wraps it together with the parsed duration and the effective seed, seed_base.wrapping_add(chunk.seed_offset), into a ChunkBootstrap. The seed offset is how distinct chunks of the same job produce uncorrelated Monte-Carlo streams from one master seed.
run_chunk: one handler, N iterations
A chunk is the atomic unit of distribution. It declares how many iterations to run and, optionally, an early-exit error target. run_chunk builds the handler exactly once, allocates one EventQueue and one TelemetrySink, and reuses them across every iteration. Only handler.reset() and queue.clear() run between iterations:
That is a deliberate trade. It makes a chunk cheap to run a thousand times but means the handler must scrub all its per-iteration state in reset.
Each iteration i:
- Builds a deterministic RNG from
SimRng::from_prefix(seed_prefix, i), whereseed_prefixis an FNV-1a hash ofseed_baseand the chunk id, computed once. - Constructs a
SimEngineover the shared refs and callsengine.run(), one full combat run. - Records the iteration's DPS into the one
TelemetryAccumulator.
Every CHECK_INTERVAL = 100 iterations (and on the last), the loop computes a running relative standard error and, if the chunk carries a target_error and has passed min_iterations, breaks early once the error falls below the target. This adaptive early-exit is why a chunk's declared iterations is a ceiling, not a fixed count. When the loop ends, the accumulator encodes into a protobuf ChunkTelemetry, which becomes the telemetry_bytes of the returned ChunkReport.
ChunkAssignment and ChunkReport are defined in engine-ports, not here. The application layer only consumes them. The assignment is what the coordinator hands a node, and it carries everything the loop needs:
The permutation_index is set for tournament and factorial work and None for a uniform split; the min_iterations/max_iterations/target_error triple drives the adaptive early-exit above. ChunkReport is the small envelope back: job_id, chunk_id, chunk_index, and telemetry_bytes.
The chunk as a state machine
The Failed edge is the one failure the executor surfaces directly. If any iteration's event loop exceeds MAX_EVENTS = 500_000, engine.run() returns SimRunError::EventBudgetExceeded, which run_chunk maps to EngineError::SimulationRuntime and the whole chunk fails. There is no per-iteration recovery. One runaway iteration kills the chunk, and on the distributed path the chunk is later reclaimed and re-enqueued.
Native callers vs the WASM caller
The contract is the same everywhere, but how the resolver is built differs.
Native hosts construct a Rust DynDataResolver and call simulate_intent directly. The worker node's SimRunner::run builds a ChunkAssignment::single_indexed("", chunk_index, iterations) and calls simulate_intent over a SupabaseResolver. The CLI single-thread path does the same with a ConsoleProgress sink. The CLI multi-thread path is the one exception that does not call simulate_intent: it bootstraps once and then builds a per-rayon-thread handler factory around run_chunk_into_accumulator, merging accumulators across threads. Forge uses simulate_intent for its SimC comparison provider and build_handler for paperdoll.
The WASM host cannot pass a Rust resolver. Instead the browser hands in a JS object, which JsResolver adapts into a DataResolver, and the WASM export calls the same simulate_intent. The preview pane is the only caller of simulate_intent_with_trace, because attaching a decision-trace sink forces the interpreter rather than the JIT. That JS resolver and the worker plumbing around it are the subject of the next page, the WASM boundary.
Nächste Schritte
