Command Palette

Search for a command to run...

Se connecter

Orchestration

simulate_intent, bootstrap, and run_chunk. The single engine entry contract shared by the browser, the worker node, the CLI, and forge.

5 min de lecture

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_chunkrun_chunk* spine:

Tableau 12
Engine Entry Functions
FunctionPurposeDrives
simulate_intentConvenience entry. Wraps args into a SimRequest with default overrides, then calls simulate_intent_request.run_chunk
simulate_intent_requestCanonical entry taking a SimRequest<'_> envelope.run_chunk
simulate_intent_with_traceTrace-mode entry for the in-browser preview pane; attaches a DecisionTraceSink.run_chunk_with_trace
build_handlerBuilds a handler without running iterations (forge paperdoll). Runs on_sim_start once.none
The four engine-application entry functions — simulate_intent, simulate_intent_request, simulate_intent_with_trace, build_handler — and which runner each drives.

The signature of the convenience entry is the contract every host meets:

rust
pub async fn simulate_intent(
    sim_config: &str,
    chunk: &ChunkAssignment,
    seed_base: u64,
    resolver: &DynDataResolver<'_>,
    progress: &dyn ProgressSink,
) -> Result<ChunkReport, EngineError> {

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:

  1. Parses the TOML into a SimConfigIntent and validates intent_version == "v1", the spec id, a non-empty rotation_id, and the fight duration_s against 1.0..=MAX_DURATION_S (1800 s).
  2. Resolves the buff toggles, bloodlust, flask (defaults on), augment rune (defaults on), tempered pre-pot, and race, all suppressed when overrides.no_buffs is set.
  3. Looks up the spec via find_descriptor(spec), an inventory-registry lookup over the link-time-collected SpecDescriptors.
  4. Fetches the rotation JSON through the resolver.
  5. Turns gear into stats, either compute_stats_from_gear (the real pipeline) or bench_gear_result (default stats), both yielding a GearResult.
  6. 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.
  7. Calls resolve_game_data, the big async pass that introspects the spec to discover its spell/aura ids and resolves every one into a ResolvedGameData (data resolution covers this in detail).
  8. Builds the handler by calling the descriptor's handler_factory with a 15-field HandlerParams.

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:

rust
for i in 0..max_iters {
    handler.reset();
    queue.clear();

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:

  1. Builds a deterministic RNG from SimRng::from_prefix(seed_prefix, i), where seed_prefix is an FNV-1a hash of seed_base and the chunk id, computed once.
  2. Constructs a SimEngine over the shared refs and calls engine.run(), one full combat run.
  3. 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:

rust
#[derive(Debug)]
#[non_exhaustive]
pub struct ChunkAssignment {
    pub job_id: String,
    pub chunk_id: String,
    pub chunk_index: u32,
    pub chunk_count: u32,
    pub iterations: u32,
    pub seed_offset: u64,
    pub permutation_index: Option<u64>,
    pub min_iterations: Option<u32>,
    pub max_iterations: Option<u32>,
    pub target_error: Option<f64>,
}

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

Figure 20
Chunk Execution Lifecycle
Expands the run_chunk loop box of the simulation pipeline: a chunk moves Assigned to Bootstrapped to Running, then to ConvergedEarly (adaptive target_error) or Exhausted to Reported, or Failed on event-budget overflow.

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.

Étapes suivantes