Skip to content

Simulation and Scheduling

The current simulation integration layer lives under include/tess/sim/ and is exported by tess/tess.h. It provides the caller-driven bridge over storage, queued operations, path requests, movement validation, and render deltas.

Public Surface

Movement

  • MovementIntent records one adjacent tile move from from to to plus an optional MovementVersionCheck.
  • MovementVersionCheck carries optional expected from/to content versions and from/to topology versions. Unset fields are not checked.
  • MovementStatus reports Moved, invalid endpoints (InvalidFrom, InvalidTo, NotAdjacent), impassable endpoints (ImpassableFrom, ImpassableTo), an unavailable transition (Blocked), Occupied or Reserved destinations, and stale StaleContent / StaleTopology version guards.
  • MovementResult returns the status plus the echoed from/to coordinates.
  • is_transient_movement_failure(status) classifies failures: blocked, occupied, reserved, and stale statuses are transient (the world can legitimately change under a routed agent; re-search and retry), while invalid endpoints and non-adjacent steps indicate a caller bug and are terminal.
  • MovementFailureCounts aggregates failures into invalid, impassable, blocked, occupied, reserved, stale_content, and stale_topology buckets; record_movement_failure(counts, status) maps each non-Moved status into its bucket.
  • movement_versions_match(world, intent) checks only the optional version guards (chunk content versions first, then topology versions) and returns Moved when every set guard matches. It resolves both endpoints unchecked, so callers must validate coordinates first.
  • validate_movement_intent<World, ClassOrTag, OccupancyTag, ReservationTag>(world, intent) checks shape bounds and sparse residency, passability of both endpoints, resolved transition legality, destination occupancy, destination reservation, and the optional version guards, in that order, without mutating the world. Regular transitions come from the world's lattice and the movement class's step policy, so diagonal and axial-hex classes are not restricted to six-axis Manhattan adjacency. The second template argument is a movement class OR a raw passable tag, normalized exactly as in astar_path, so plan and commit share one vocabulary: every step A* accepted for a class validates for that same class. Validation checks both endpoints' passability predicates and rejects a zero-entry-cost destination before classifying either a regular or provider edge. This mirrors exact search's endpoint precheck: a cost field dropping to zero after planning blocks the already-planned step as ImpassableTo, so the agent can re-plan against the changed world. A blocked diagonal-clearance or provider transition reports Blocked because the destination tile itself remains passable. Missing provider topology reports StaleTopology, not a content-version failure. The from- and to-tiles may live on different pages; each endpoint's predicate is evaluated on its own resolved page.
  • commit_movement_intent<World, ClassOrTag, OccupancyTag, ReservationTag>( world, intent, dirty_mask) validates the same intent, clears source occupancy, sets destination occupancy, clears destination reservation, and marks source and destination tiles dirty when dirty_mask is nonzero.
  • Provider-aware validation and commit overloads use a legal regular transition as their fast path. When the regular transition is absent, blocked, or missing topology, they enumerate the supplied provider before mutation. A legal provider edge may deliberately parallel and override a blocked regular edge, keeping planning and commit transition sets aligned. Provider exceptions propagate whenever enumeration is required; the provider contract does not require enumeration to be noexcept.

Joint Movement

The per-agent commit validates each destination against current occupancy, so a move into a tile being vacated in the same tick is unreachable by construction: chains ("everyone steps forward together"), rotations (a cycle of agents shifts one place), and swaps (the two-agent cycle) all fail Occupied under it. Joint movement decides one tick's moves as a set.

  • SwapPolicy selects whether a mutually blocked pair may exchange tiles: Forbid (default; the standard multi-agent path finding constraint), Permit, or PermitOnDeadlock, which admits the exchange only after both members have been blocked for JointMoveOptions::deadlock_ticks consecutive ticks. Permitting a swap means both agents traverse the same edge in opposite directions for one tick — a semantic decision, not a tuning knob. Cycles of length three or more involve no shared edge and are admitted under every policy.
  • JointMoveOptions carries the swap policy and deadlock threshold; JointMoveStats reports the standard movement frame stats plus chained admissions, rotations, swaps, and denied swaps; JointMoveScratch is the caller-owned workspace whose reserve keeps the warm path allocation-free. reserve is its whole surface: the round buffers behind it are private and no longer reachable through the type. Like every other internal in a header-only library they remain spellable through tess::detail, which docs/style.md excludes from source-compatibility — the change is what the 1.0 promise covers, not what a determined consumer can name.
  • advance_path_agents_with_joint_movement<World, ClassOrTag, OccupancyTag, ReservationTag>(world, agents, routes, scratch, options, max_steps, dirty_mask) validates every eligible agent's next route step exactly as commit_movement_intent does, claims free destinations in span order, admits moves whose destinations are vacated this tick to a fixpoint, then admits the remaining wants-cycles by policy and applies the admitted set at once (sources clear before destinations set). A destination that is both occupied and reserved fails Reserved rather than joining admission, so a reservation cannot vanish behind a vacating occupant. Failure handling, route invalidation, arrival handling, reservation clearing on entry, and per-move dirty marking all match the per-agent advance; outcomes are deterministic given the caller's span order, and input-order invariance is a non-goal, exactly as for advance_path_agents_with_movement. An observer overload reports each committed move.
  • tick_weighted_path_agents_with_joint_movement<World, Class, MaxCost, OccupancyTag, ReservationTag> mirrors tick_weighted_path_agents_with_movement with the joint advance in place of the per-agent one and optionally reports the joint stats.

PIBT Movement

The joint commit only admits moves along retained routes: an agent whose route is blocked never considers stepping aside, so a head-on in a dead-end corridor under SwapPolicy::Forbid wedges forever even when a side pocket would let one agent yield. The PIBT tier (priority inheritance with backtracking, after Okumura et al.) closes that gap: each agent ranks staying put plus every legal transition of its movement class, the highest-priority agent decides first, an agent whose chosen tile is held by an undecided peer lends that peer its priority so the peer decides — and possibly yields off its route — immediately, and a peer that cannot place anywhere backtracks the chooser to its next candidate. It is an opt-in sibling of the joint advance, selected per population where contention justifies its cost, not a replacement.

Agents outside an active goal lifecycle are immovable. One that has arrived (no goal) or ended at Unreachable is never asked to yield — not by the priority loop, the apply pass, or inheritance. Its tile is claimed so later deciders are turned away, and an agent that wanted that tile backtracks to its next candidate, exactly as it would for a peer standing on impassable terrain. Without this, passing traffic would restart a terminal lifecycle as Blocked, and a second failure against one admitted goal would break the flow-accounting retention identity.

This is a throughput trade, and on some maps a visible one. A terminal agent is now a wall: a one-wide corridor that used to clear because a passing agent shoved an arrived agent aside stays blocked until the caller does something about it. That is deliberate — the shove corrupted a lifecycle the library documents as terminal — and it matches how an occupant outside the agent span is already treated. A caller that wants arrived agents to move again must give them a new goal, drop them from the span, or make their tile impassable to the movement class, which is the SettledTag recipe the tier's own tests use.

  • PibtPriorities is caller-owned adaptive priority state, index-paired with the agent span exactly like PathAgentRoutes: elapsed increments each tick an agent is unarrived and resets on arrival, and higher elapsed decides earlier (span index breaks ties). elapsed and reserve are the whole surface; the decision order and inheritance stack are rebuilt every pass and are private. Adaptive priorities are load- bearing: PIBT's reachability guarantee (every agent reaches its goal in finite time on graphs whose adjacent vertices share a cycle of length three or more) depends on them.
  • advance_path_agents_with_pibt<World, ClassOrTag, OccupancyTag, ReservationTag>(world, agents, routes, priorities, scratch, rank, options, dirty_mask) decides one step per agent and applies the decided configuration with the joint commit's semantics: sources clear before destinations set, reservations clear on entry, a move off the retained route drops it and clears last_result so scoped resubmission replans, and observer callbacks (overload) fire only after the whole configuration is applied. Edge conflicts follow the shared SwapPolicy from JointMoveOptions; vertex conflicts backtrack. The advance is dense-only (like the distance-field product family) and reuses JointMoveScratch.
  • The oracle's requirements are the PibtRanking concept: callable as rank(agent_index, coord) returning something convertible to std::uint32_t. It constrains the public entry points, so a caller whose lambda is rejected sees the constraint named in the diagnostic — which is why it is public rather than a detail name they could not spell.
  • The ranking oracle is the caller's, and it MUST share the agent's movement-class passability. rank(agent_index, coord) returns lower values for better tiles; a terrain-only oracle under a settled-aware class rates standing beside an obstruction above any detour and parks the agent there forever (proven in the tier's tests). DistanceFieldProduct::distance_at provides an exact per-tile oracle from build_distance_field_product over the same movement class; rebuild the product when the class's inputs (for example a settled set) change.
  • RouteAttachmentRanking is the shipped oracle for route-following populations: it scores a candidate by its best local attachment to the agent's retained A* route (attachment hop plus remaining route length), steering detached candidates back toward the corridor and falling back to goal distance for routeless agents. The attachment radius defaults to 1, the largest passability-safe radius — distance-1 tile pairs are edge-adjacent, while wider radii can attach across a one-tile wall and recreate exactly the wall-face parking the oracle exists to prevent (pinned by the tier's lure regression test). Distance fields are exact but per-goal; the route oracle serves populations with per-agent goals at planning cost already paid.
  • tick_weighted_path_agents_with_pibt<World, Class, MaxCost, OccupancyTag, ReservationTag> mirrors the joint tick driver with the PIBT advance in place of the joint one.

Selection guidance from the gate evidence: on thin cycle-rich maps the dominant stranding cause is sealing — settled arrivals cutting a live agent's goal off — which no movement tier can resolve; goal placement owns that hazard (see the settled recipe below). PIBT's measurable edge over the joint commit is live congestion: it eliminates most stranded-but-reachable residuals, resolves yield-requiring wedges under Forbid, and keeps populations moving so fewer seals form. Below that contention regime the joint commit is sufficient and cheaper to drive (no ranking oracle to maintain).

The Settled-Obstacle Recipe Is a Consumer Contract

Any consumer that turns idle agents into obstacles (the colony's settled marking) must follow all three steps, for every movement tier:

  1. Mark the obstacle field only when an agent's settled state changes (arrived or terminally unreachable), not every tick.
  2. Announce the content change with mark_content_changed, so route caches observe the version bump. Plain field writes bump nothing and stale routes reproduce the pre-settled deadlocks wholesale. This is content-only: use mark_dirty for dirty metadata, mark_topology_dirty for topology freshness, and the schedule's notification protocol for OnDirty tasks.
  3. Plan and rank with a movement class that excludes the obstacle field (settled-aware), never the terrain-only class.

Omitting any step reproduces the original colony deadlock; omitting step 3 in a PIBT ranking oracle parks agents permanently beside obstructions.

The browser colony scopes settled obstacles to one synchronized leg. A cheap terrain-graph precheck rejects obvious wall seals. Otherwise an exact search with the settled-aware class tests current reachability; if that fails, an exact terrain-only search distinguishes a durable wall failure from a goal blocked only by completed teammates. The latter outcome cancels the unfinished goal and is quiescent for the leg. Once every agent has arrived or reached that crowd-blocked outcome, the controller rearms the entire wave toward the opposite side. Wall edits on occupied tiles are rejected at admission, preserving occupied => standable.

Render Deltas

  • RenderTileDelta records a changed tile coordinate, chunk key, local tile id, matching dirty masks, and chunk content version.
  • collect_render_tile_deltas(out, world, dirty_mask) appends one delta per dirty tile in each matching chunk dirty bound. On a dense world it scans every chunk; on a sparse world it scans only the resident set (a non-resident chunk holds no data and cannot be dirty, so this misses no delta and never reads a non-resident slot or runs a full chunk_count scan).
  • render_tile_deltas(world, dirty_mask) returns an owning vector of render deltas for simple consumers.
  • clear_render_delta_dirty(world, dirty_mask) clears the render-relevant dirty bits after a presentation layer has consumed them; it iterates the resident set on a sparse world.

The RenderTileDelta family above is the legacy per-tile seam; new consumers should use the versioned DeltaFrame bridge below.

DeltaFrame Render Bridge

The versioned frame protocol in sim/delta_frame.h. Tile deltas are invalidation records, not value payloads: the consumer re-reads the current world for covered tiles at apply time, which is idempotent and convergent. Chunk dirty metadata is already a cross-tick coalescer (mask union, bounds union), so tiles are collected once per published frame through the lost-update-safe observe/clear-observed protocol.

stateDiagram-v2
  accTitle: DeltaFrame application and resynchronization
  accDescr: A consumer starts from a complete baseline, follows a continuous version chain, and returns to baseline recovery after a gap or truncation.

  [*] --> Uninitialized
  Uninitialized --> Synced: complete baseline, adopt to_version
  Uninitialized --> NeedsBaseline: delta or truncated frame
  Synced --> NeedsBaseline: version gap or truncation
  NeedsBaseline --> Synced: complete baseline, adopt to_version
  note right of Synced: Apply matching deltas and adopt to_version
  note right of NeedsBaseline: Reject deltas until a complete baseline arrives
  • RenderVersion is the monotonic frame-chain version. Collectors start at 1; value 0 is reserved for a consumer that has never applied a frame, so a fresh consumer can only start from a baseline.
  • TileChunkDelta is one chunk's record: its dirty_mask, the chunk-clipped bounds, and either tile_count per-tile entries starting at first_tile in frame.tiles, or tile_count == 0 meaning box-granular (repaint every tile in bounds). content_version is debugging only -- clears do not bump it and sparse rematerialization resets it. Chunk records are the only entry point; consumers never iterate frame.tiles directly.
  • TileDelta is one changed tile (coordinate, local tile id, dirty mask).
  • EntityDeltaKind / EntityDelta record entity motion and lifecycle: Moved (coalescible), and the barriers Teleported, Spawned, Despawned, Parked, Placed. from == to for spawns/places; parks and despawns carry the released tile. last_tick stamps the last coalesced commit so renderers can tell moved-this-frame from resting. Coalesced records are not a serializable per-record sequence: a coalesced move sits at its first commit's position, so consumers key presentation by entity and check tile exclusivity only at frame end.
  • DeltaFrameHeader carries from_version/to_version (equal on empty frames; +1 on state-carrying ones), the folded tick range and count, the union dirty_mask, and the baseline/truncated flags. Truncation (capacity overflow or a hard clear()) is a structural gap: entity loss is unrecoverable by the version chain.
  • DeltaFrame is an immutable view into collector-owned storage. Its spans are valid until the next publish() or reserve(), and until the collector is move-assigned to or moved from (it is not copyable) — not "until the next mutating collector call", which this page said until 2026-08-09: begin_tick, record_*, collect_* and clear() touch only the pending buffers, so the next frame is recorded while the current one is applied, and reserve() re-reserves the published vectors and was missing. header is a value copy and outlives all of it. Holding a frame across a publish() is outside the contract: the buffers it views become the pending accumulator and are cleared and refilled. empty() ignores overlays.
  • delta_frame_applicable(header, consumer) is the consumer's apply gate: truncated frames never apply -- not even baselines, because a baseline that overflowed chunk storage covers only part of the world (size baseline consumers' chunk capacity to the whole world); un-truncated baselines always apply (adopt to_version, re-snapshot entity presentation); otherwise the chain must match exactly with consumer.value != 0.
  • PathOverlayDelta is one agent's remaining route this frame (frame.overlay_nodes[first_node .. first_node + node_count)). Overlays are stateless, full-replacement decorations: every applied frame replaces the consumer's whole overlay set (possibly with the empty set), no create/update/remove lifecycle exists, they never affect version semantics or empty(), and overflowing overlay storage drops the overlay (counted in overlay_truncations), never the frame. Nodes are copies, valid for the frame's lifetime.
  • DeltaCollectorOptions sets the per-chunk sparse_tile_threshold (records per-tile up to it, box-granular above; 0 = always box) and coalesce_moves (fold consecutive moves last-writer-wins; disable for motion-interpolating renderers so each step spans one tile).
  • DeltaCollectorStats counts published frames, baselines, record kinds, coalesced moves, and truncations, cumulatively.
  • DeltaCollector accumulates records and publishes frames: reserve sizes every buffer once (steady state never allocates; records past capacity are dropped and flagged, never grown mid-frame); begin_tick stamps subsequent records; record_move/record_teleport/ record_spawn/record_despawn/record_park/record_place feed entity deltas (the ECS pipeline hook and lifecycle intents call these on success only); append_chunk_record/append_tile_record/ pending_tile_count/note_collected_mask/mark_baseline_pending are the collection seams; publish() seals the frame, bumping the version iff it carries state and dropping pending entity records on baselines (consumers re-snapshot entities on every baseline apply); clear() hard-resets pending state and poisons the stream -- the next publish is forced truncated unless it is a baseline, and a world swap is clear() followed by a full baseline collection. The collector must be the sole clearing owner of every dirty bit it collects; shared dirty_bounds across mask owners only widens boxes (conservative).
  • collect_baseline(collector, world, dirty_mask) is the full-scope resync: one box record covering every chunk (dense) or resident chunk (sparse), pending Dirty records dropped as superseded, the mask's dirty bits plainly cleared, and the pending frame marked baseline (which also drops pending entity records at publish). Scoped baselines deliberately do not exist -- a partial baseline that adopts the frame version would permanently lose out-of-scope invalidations from a gap.
  • collect_path_overlays(collector, runtime, agents, handles[, selection]) stages current runtime results. Its tickets must belong to the runtime's current generation, as they do immediately after an All-scope submission and processing pass. It is not valid after a NeedsOnly submission clears the runtime but skips agents that need no new request.
  • collect_path_overlays(collector, agents, routes, handles[, selection]) instead reads authoritative PathAgentRoutes. Use it with NeedsOnly submission and queue-produced routes: the copied ticket is identity/debug metadata only and may be stale or value-zero; it is never dereferenced. Both forms copy path.suffix(path_index), gate on has_goal && status == Found, and require selected indices to be in range; debug builds assert this precondition. Lifecycle intents run before the tick and overlays collect after it; an intent squeezed between tick and collection leaves that agent's overlay one frame stale while entity deltas stay correct.
  • collect_tile_deltas(collector, world, dirty_mask) observes, records, and clears (observed-generation-safe: a racing mark leaves the bits set for a harmless duplicate next frame) every dirty chunk under the mask; dense worlds scan chunk metadata, sparse worlds scan the resident set. Per chunk it emits per-tile records up to the threshold and a clipped box record otherwise, degrading to a box record when tile storage cannot hold a chunk. Sparse residency change records are deferred until a sparse render consumer exists; rematerialization resets metadata, so such consumers must treat them as baseline triggers.

Path-Agent Batch Helpers

  • PathAgentState stores an agent's position, goal, PathTicket, path index, optional last_result, PathAgentPhase, active-goal flag, and blocked_retries count. last_result is absent before a search and after a route-invalidating movement failure; NoPath appears only after a search actually returns that result.
  • PathAgentPhase is the agent lifecycle, decoupled from the optional last search result: Idle (no goal or arrived), NeedsPath (goal assigned, no route yet), Following (walking a Found route), Blocked (transient failure; retained-step contention waits, while route-invalidating failures re-search until the shared retry budget and exhaustion policy take effect), and Unreachable (structural failure or explicitly terminal exhaustion; terminal until a new goal is assigned).
  • set_path_agent_goal(agent, goal) arms the lifecycle (NeedsPath, retry count reset); clear_path_agent_goal(agent) returns the agent to Idle.
  • PathAgentFrameStats counts submitted and completed work; every PathStatus outcome (found, invalid_start, invalid_goal, no_path, indeterminate, cost_overflow, not_computed, and no_candidate); precheck_ruled_out; expanded_nodes; advanced steps; arrivals; blocked waits; and a MovementFailureCounts. expanded_nodes totals the search nodes expanded by the completed results a call applied. A replan budget still bounds the number of searches rather than the work inside one, so this counter is what a caller reads to bound planning by search effort instead: it is deterministic, so a budget derived from it replays exactly, which a wall-clock budget would not. precheck_ruled_out is the number of agents whose goal an optional topology precheck proved unreachable before A* (a subset of no_path; see the path runtime's precheck_ruled_out). add_path_agent_stats(lhs, rhs) accumulates two frames; record_path_agent_status(stats, status) buckets one path result.
  • submit_path_agents(agents, runtime) starts a new runtime request set and submits one request per agent with an active goal, skipping Unreachable agents and clearing agents that already stand on their goal (counted as arrived).
  • apply_path_agent_results(agents, runtime) copies ticketed results back: Found enters Following. It resets the retry count for a new route, but preserves the count when re-planning an already Blocked agent because an occupancy-blind planner may return the same contested step; movement progress is what resets that consecutive-block budget. Planner failures enter Blocked so the tick driver's retry budget governs them.
  • advance_path_agents(agents, runtime, max_steps) walks agents with a Found result up to max_steps nodes along runtime-owned paths without touching world fields.
  • advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag, ReservationTag>(world, agents, runtime, options) accepts PathAgentAdvanceOptions{max_steps, movement_dirty_mask} and commits each step through commit_movement_intent (no version guards), validating with the same movement class the plan used. An occupied or reserved destination leaves the Found route intact so the retained step can be retried; other transient failures invalidate the route and request a re-plan. Either kind moves the agent to Blocked and counts a blocked wait. The failed movement does not itself consume a retry: each following movement-enabled tick consumes one bounded retry, while max_steps == 0 pauses the budget. A structural failure is terminal Unreachable. Arrival clears the goal and counts an arrival. An observer overload appends on_commit(agent_index, from, to), invoked once per successful commit (after position/occupancy update, before arrival handling) and never on a failed validation, so external tile-to-entity mirrors updated inside the callback stay synchronized with the occupancy field by construction (the ECS adapter's hook point).
  • process_unit_path_agents<World, ClassOrTag>(...) and process_weighted_path_agents<World, Class, MaxCost>(...) run submit, runtime processing (cached unit or weighted batch), and result application as one synchronous pass. Both take an optional trailing const RegionGraphT<World::residency_type>* (default nullptr) that they forward to the runtime's precheck gate; when supplied, goals the region graph proves unreachable are resolved without A* and surfaced in PathAgentFrameStats::precheck_ruled_out.
  • PathAgentReplanQueue is an opt-in, caller-owned FIFO for replans. Pending agent indices deduplicate, and contains(index) reports that pending set — membership begins at an accepted request and ends at the matching pop_front or at clear, so a caller can skip building a request that would only be refused. process_path_agent_replans invokes a synchronous caller planner at most its supplied request count and copies each borrowed result into retained routes; the generic drain deliberately does not certify path legality or optimality. process_unit_path_agent_replans and process_weighted_path_agent_replans retain exact-search semantics and pass through PathAgentReplanOptions. This bounds request count, not one search's expansions or wall time. Queue, agents, routes, and planner scratch are externally synchronized; independent owners may run independently.

Path-Agent Tick

  • SimClock holds the current tick; advance_sim_tick(clock) increments and returns it.
  • PathAgentTickState owns the clock, the WORLD-scoped pathing_dirty flag, and the per-agent retained routes (PathAgentRoutes, index-paired with the agents span). mark_pathing_dirty(state) requests a full replan of every agent on the next tick (required after world edits); the three-argument set_path_agent_goal(state, agent, goal) arms a goal as agent-scoped dirt -- only that agent replans (the drivers submit with PathSubmitScope::NeedsOnly), everyone else keeps their retained route (per-agent pathing dirt; pre-split, one re-arm replanned the whole batch every tick). A NeedsOnly processing pass invalidates runtime tickets for agents it skips, so callers that need those agents' paths must use the tick-driver retained routes rather than reading old runtime tickets.
  • PathAgentTickOptions carries max_steps and movement_dirty_mask per tick, the runtime PathRuntimeCachePolicy, max_blocked_retries (default 8), and blocked_exhaustion_policy. BlockedAgentExhaustionPolicy defaults to RemainBlocked, preserving the goal and optional last search result because elapsed retries do not prove NoPath; MarkUnreachable selects terminal exhaustion explicitly and clears the last result.
  • PathAgentTickStats reports the tick value, whether paths were processed, separate pathing and movement PathAgentFrameStats, and the repaths_requested count for actual searches plus repath_exhausted for every exhausted blocked lifecycle (the historical field name also covers retained-step waits).
  • prepare_path_agent_processing(agents, options, stats) scans agents ahead of path processing: NeedsPath agents request processing with no manual dirty mark. Blocked agents consume one retry on each following tick. Occupied/reserved destinations retain PathStatus::Found and retry the retained step without a search; route-invalidating transient failures clear the obsolete result and request processing. At exhaustion the default leaves the agent Blocked without further automatic path processing; MarkUnreachable instead terminalizes it and clears the optional last result.
  • BlockedAgentRecoverySchedule selects a deterministic, caller-bounded subset of persistently blocked agents for expensive recovery checks. Exponential delay with deterministic equal jitter spreads repeated checks across the full uint32_t delay range. Position changes restart recovery even when the agent remains blocked at the end of the tick; scheduling never decides reachability. BlockedAgentRecoveryOptions controls its delay, cap, and salt; BlockedAgentRecoveryStats reports blocked, due, selected, and deferred counts. The caller owns exact search, sparse residency, movement-class, result, and synchronization semantics.
  • tick_unit_path_agents<World, ClassOrTag>(...), tick_weighted_path_agents<World, Class, MaxCost>(...), tick_unit_path_agents_with_movement<World, ClassOrTag, OccupancyTag, ReservationTag>(...), and tick_weighted_path_agents_with_movement<...> advance the clock, re-process paths when pathing_dirty is set or any agent requested processing, then advance agents — either freely or through movement commits with the supplied movement_dirty_mask. In the class forms one movement class drives pathing, the precheck, and commit validation, so plan and commit provably agree per class. Each accepts an optional trailing const RegionGraphT<World::residency_type>* (default nullptr) forwarded to the runtime precheck gate, so a caller that maintains a region graph can skip A* for goals proven unreachable.

Schedule

include/tess/sim/schedule.h is the schedule: ordered phases of type-erased tasks driven by cadences that are pure functions of the fixed SimClock tick counter and per-task pending dirty/event masks. The schedule never touches a world -- trigger bits are fed to it explicitly -- so the no-hidden-full-world-scans rule holds by construction. Type erasure is a function pointer plus a context pointer; world-typed work lives in task objects the caller owns and registers by reference. ScheduleTaskFn is the raw erased function-pointer form for callers that do not use the object-reference overload. ScheduleNoThrowTaskFn preserves an explicit noexcept contract through erasure; the object overload selects it automatically for a statically no-throw task.

flowchart TB
  accTitle: Fixed schedule phase order
  accDescr: Every simulation tick traverses the complete phase list in declaration order and tasks retain registration order within a phase.

  Early["Input → PreUpdate → AI"]
  Agent["Pathing → Movement → Commit"]
  Derived["Topology → Fields → Background"]
  Output["RenderDelta → Diagnostics"]
  Early --> Agent --> Derived --> Output

Dirty and event results are merged immediately into every matching subscriber.

flowchart TB
  accTitle: Same-tick and next-tick dirty propagation
  accDescr: A later matching task consumes dirty bits in the current tick, while an already-run task consumes them on the next tick.

  Produced["Task returns dirty_mask"] --> Remaining{"Target phase<br/>still ahead?"}
  Remaining -->|Yes| SameTick["Run later OnDirty task<br/>this tick"]
  Remaining -->|No| NextTick["Run matching task<br/>next tick"]
  • SimPhase is the fixed phase list, executed in declaration order each tick; tasks run in registration order within a phase. SimClock (hoisted into time.h; the path-agent tick shares it) is the authoritative fixed-tick counter every cadence derives from.
  • Cadence selects every_tick(), every_ticks(n) (exact: the countdown advances once per run_tick, even while the task is disabled, so re-enabling never shifts the lockstep phase; a due-while-disabled tick is counted as skipped), on_dirty(mask) (fires iff bits of the task's OWN mask are pending; firing consumes only those bits, so producers' same-tick marks re-arm it for the next tick), on_event(mask) with the same phase-aware coalescing rules, background(budget), and manual().
  • CadenceKind is the stored discriminator for those six cadence forms. ScheduleTaskDesc combines a name, a phase, and a cadence -- the name is required and must have static storage, the same rule diagnostics trace labels follow, because the schedule stores the view rather than the characters; ScheduleTaskContext supplies the current clock, consumed dirty/event masks, and background budget to ScheduleTaskFn; ScheduleTaskResult returns produced dirty/event masks, completed items, and backlog state; and ScheduleTaskStats exposes cumulative run, skip, and item counts.
  • BackgroundBudget is deliberately items-only: a due background task is offered max_items units per run and reports items_done plus more_work to continue next tick. A wall-clock valve would make tick outcomes nondeterministic; it returns with its first real consumer.
  • Schedule::add_task(desc, task) registers a caller-owned task object (or a raw fn-pointer + context); seal() freezes registration; request_run(id) arms any task for the next tick (the Manual trigger and the Background initial trigger); notify_dirty(mask) merges external dirty bits (frame-owner thread only; never from an op callback -- worker-side dirty flows exclusively through the task-result mask); notify_events(mask) coalesces event wakeups; and publish_event stores an exact payload before notifying its mask; run_tick(clock) advances the clock and dispatches, returning ScheduleTickStats; task_stats(id) reports per-task counters.
  • Lifecycle misuse that otherwise looks like an idle task fails fast in every build: registration or capacity reservation after seal(), null callbacks, invalid phase or cadence values, unknown task ids, running before seal(), and reentrant ticks. Background tasks may not report more items than their offered budget, and non-background tasks must report zero items_done.
  • In diagnostics builds, an active trace receives an inclusive Scheduler/schedule_tick duration and one nested duration named after each executed task. When allocation counters are active, those duration records also attribute inclusive allocation/free byte deltas. All instrumentation compiles out when diagnostics are disabled.
  • A task result's dirty_mask merges into every task's pending mask immediately: later-phase OnDirty tasks fire in the SAME tick, earlier-phase tasks the next tick.
  • If a task callback throws, run_tick propagates the exception after restoring the dirty bits, event bits, and explicit run request consumed for that invocation. Triggers raised during the failed callback are merged with the restored values. The simulation clock and cadence countdown still advance; task and world mutations are not rolled back.
  • EventStream<T> is caller-owned bounded storage for exact payloads with monotonic sequence and simulation-tick stamps in TickStampedEvent<T>. Overflow is rejected rather than overwritten. The scheduler mask is only a coalesced wakeup; an OnEvent task drains the separate stream according to application policy. Reserve and flow-accounting attachment are setup-only on an empty stream; assignment cannot overwrite attached outstanding inventory.
  • ResumableWorkTask<T> maps ScheduleTaskContext::budget_items to a ResumableWorkQueue<T> and maps remaining pending tickets back to more_work, retaining deterministic cooperative jobs across ticks. When the queue becomes empty the Background task disarms; a later queue submission must be paired with request_run(id) to re-arm it. Queue mutation and flow-tick observation during advance() fail fast in every build; a callback that overreports completed items instead settles its ticket as Failed, the queue's existing operation outcome.
  • Allocation contract: reserve_tasks + registration happen at setup; run_tick, trigger notification, and request_run never allocate after seal(). Reserved event streams and resumable queues also perform no container allocation on their warm paths; the stored payload type's own copy, move, and callback operations must be allocation-free for the complete operation to share that guarantee (pinned with allocation-free payloads).
  • run_schedule_frame(schedule, clock, accumulator, real_delta_seconds, control) is the frame-to-ticks bridge: it consumes real frame time through the FixedStepAccumulator (honoring SimSpeed and the per-frame tick cap) and runs the schedule once per granted fixed tick, returning a ScheduleFrameSummary (ticks, alpha, dropped seconds, last tick's stats). Cadences therefore count FIXED TICKS, never frames: an EveryN task at 4x fires four times as often in real time and exactly as often in sim time, and a backlogged frame advances every cadence through each granted tick.

Auto-Exec

include/tess/sim/auto_exec.h closes the auto-exec gap: AutoExecTask <World, Policy, Ack, ChunkFn> is one schedule task running the whole queued-ops pipeline -- plan, parallel phase planning, execution (serial or worker pool, chosen per phase by an operation-count threshold), per-phase dirty apply, and ack drain -- over a caller-owned OperationBatch queue. Both the queue and the task's result channel are cleared together at the end of every successful run (the paired-clear discipline), and the run's dirty_mask union feeds the schedule so OnDirty tasks in later phases fire the same tick. The worker pool is the production parallel backend (see the queued-operations note); the scoped-thread executor is the address-stable per-dispatch alternative. A planning or kernel exception preserves the caller-owned queue for inspection or replacement while the exception path clears transient result slots, so old completions cannot leak into a later run. Earlier writes may already have executed, so blindly retrying that queue is unsafe; chunk callbacks should not throw.

  • Policy uniformity is PRE-VALIDATED (AutoExecStatus::PolicyMismatch executes nothing; asserted in debug), which makes runtime aborts unreachable -- serial and pool execution therefore can never diverge on partially-applied plans, and the serial == pool golden compares whole worlds, chunk metadata, and drained ack sequences byte-for-byte.
  • Dirty records are merged after EACH phase: the partitioned scratch is re-prepared per phase, so a single post-loop merge would silently drop every phase's dirty but the last (pinned by a write-then-read phase-split test).
  • Policy must be ReadOnly or UniquePerChunk (the parallel phase planner's set) and the world dense (merge_planned_dirty is AlwaysResident-only).
  • Planning reuses a task-owned ExecutionReport, and the result channel and phase scratch retain capacity. After callers reserve or warm those buffers, the synchronous planning/execution path is allocation-free for payload types whose default construction and assignment are allocation-free.
  • Result hooks have a noexcept function-pointer contract. The queue is cleared before draining, so follow-up operations enqueued by a hook survive for the next run.
  • AutoExecRunStats (last_run()) reports status, planned/rejected ops, executed chunks, merged dirty chunks, drained acks, and phase/pool-phase counts between ticks.

Scheduler

  • SimSchedulerState owns the scheduler-adjacent state currently needed by the path-agent tick layer.
  • SimSchedulerOptions configures which dirty masks should trigger path replanning, which dirty masks should produce render deltas, path-agent tick options, whether render dirty bits should be cleared after collection, and which movement commits should mark dirty tiles.
  • SimSchedulerStats reports one tick: the tick value, whether operations were planned (planned_ops) and executed (executed_ops), the queued ExecutionReport and PlannedExecutionResult, the variant's PathAgentTickStats, and the number of render deltas appended this tick (render_delta_count).
  • run_queued_operations<World, Policy>(world, ops, fn) is the shared plan-then-execute step: it plans the frame's operations, returns without executing when validation fails, and otherwise executes the plan through the serial block bridge and reports whether execution completed.
  • tick_unit_scheduler<World, PassableTag, Policy>(...) executes planned queued operations through the existing serial block bridge, marks pathing dirty when planned work dirtied configured pathing fields, ticks unit-cost path agents, and emits render deltas.
  • tick_unit_movement_scheduler<World, PassableTag, OccupancyTag, ReservationTag, Policy>(...) runs the same sequence and commits agent movement through commit_movement_intent, marking moved-agent chunks dirty with the configured movement dirty mask.
  • tick_weighted_scheduler<World, Class, MaxCost, Policy>(...) runs the same sequence through the weighted path-agent batch tick.
  • tick_weighted_movement_scheduler<World, Class, MaxCost, OccupancyTag, ReservationTag, Policy>(...) combines the weighted batch tick with movement commits and the movement dirty mask.

All four scheduler variants share one internal tick sequence (detail::tick_scheduler_core); they differ only in the path-agent tick they run. When queued operations fail planning, the tick reports planned_ops without executed_ops, leaves the world untouched, and still ticks path agents.

Fixed-Step Time

  • SimSpeed is Paused, Speed1x, Speed2x, or Speed4x; SimTimeControl carries the current speed.
  • sim_speed_multiplier(speed) returns the integer multiplier (0/1/2/4) and effective_tps(base_tps, speed) returns the multiplied tick rate, saturating at the std::uint32_t maximum.
  • FixedStepAccumulator(base_tps, max_ticks_per_frame) converts variable real frame deltas into whole simulation ticks. consume(delta, control) banks speed-scaled time (paused, zero-tps, and zero-cap configurations produce no ticks; NaN and negative deltas contribute nothing) and returns a FixedStepFrame.
  • FixedStepFrame reports the ticks to run this frame, the interpolation alpha (fraction of one step still banked, clamped to [0, 1]), and dropped_seconds — sim-time seconds discarded because the frame hit max_ticks_per_frame with more than one step of backlog remaining. When the tick cap is hit, backlog beyond one step is dropped instead of banked: retained debt would force max-tick catch-up frames or an unrecoverable spiral, while one step of carry preserves alpha continuity. Sim time slows instead, and a nonzero dropped_seconds means the simulation is running behind real time.

The scheduler does not consume FixedStepAccumulator itself; callers use it to decide how many scheduler ticks to run in one rendered frame.

Behavior

The scheduler is deterministic and synchronous at its caller boundary. It does not own worker threads or an event loop. Cooperative async tickets, continuations, and exact event payload streams remain caller-owned; the schedule only advances and wakes them. Callers also own entity storage, game-specific job logic, AI decisions, UI state, and content rules.

The intended per-frame order for current consumers is:

  1. Enqueue field edits in OperationBatch with accurate FieldAccessDesc masks.
  2. Call a scheduler tick with a callback that applies each planned chunk view.
  3. Let the scheduler mark pathing dirty when executed operations dirtied configured movement-relevant fields.
  4. Let the path-agent tick submit/process active requests only when dirty.
  5. Consume render deltas from dirty chunk bounds instead of full snapshots.
  6. Commit accepted movement intents through commit_movement_intent when a game system needs occupancy and reservation validation.

The PathAgentPhase lifecycle ties the layers together. Assigning a goal arms NeedsPath, which requests path processing on the next tick even without a world edit. Planner failures and transient movement failures both land in Blocked; each following tick consumes one of max_blocked_retries when movement is enabled; max_steps == 0 pauses the budget. Occupancy and reservations retry the retained step, while route-invalidating failures re-search. Successful movement resets the consecutive-block count. Exhaustion remains Blocked by default; callers that need the historical terminal timeout select MarkUnreachable explicitly. Structural movement failures (invalid endpoints, non-adjacent steps) skip the retry budget entirely. A missing edge under a special-transition provider is StaleTopology, because a provider revision can legitimately remove it, and therefore requests a bounded re-search. Only a new goal re-arms an Unreachable agent. Clearing a goal returns any active lifecycle state to Idle; those equivalent edges are omitted from the diagram to keep the failure paths legible.

stateDiagram-v2
  accTitle: Path-agent lifecycle
  accDescr: Goals arm pathfinding; transient failures retry through Blocked, while structural failures become Unreachable and an explicit compatibility policy may terminalize exhausted retries.

  [*] --> Idle
  Idle --> NeedsPath: assign goal
  NeedsPath --> Following: path found
  NeedsPath --> Blocked: planning fails
  Following --> Idle: arrive
  Following --> Blocked: transient move failure
  Following --> Unreachable: structural move failure
  Blocked --> Following: route found or retained step moves
  Blocked --> Blocked: retry exhausted (default sleep/recovery)
  Blocked --> Unreachable: retry exhausted (compatibility policy)
  Unreachable --> NeedsPath: assign new goal

MovementIntent version guards are opt-in. They are useful when an external system collected path or move intents before queued world edits were applied. If a stored expected chunk content version or topology version no longer matches, the move fails with StaleContent or StaleTopology before occupancy changes are committed. The scheduler's own movement ticks submit intents without version guards; their steps are validated against live world state instead.

Render deltas are based on current chunk dirty bounds. If multiple dirty masks share a chunk, the current dirty bound is the union maintained by storage. A caller that needs per-field exact rectangles should keep its own field-level presentation data or drain deltas before broadening the chunk dirty bound with unrelated edits. Collection clips each chunk's dirty bounds to the chunk's own world-space box before visiting tiles, so bounds that span chunk borders or leave the shape emit deltas only for tiles the chunk owns.

Goal-Lifecycle Flow Accounting

The path-agent goal lifecycle participates in the diagnostics flow accounting: PathAgentTickState carries an optional caller-owned FlowAccounting, and every transition is counted where it happens. Arming a goal through the tick-state set_path_agent_goal is an admission; replacing a goal that path_agent_goal_outstanding still reports as live terminalizes it as superseded, while re-arming after a terminal outcome is a fresh admission. The tick-state clear_path_agent_goal cancels a live goal. arrive_path_agent completes the lifecycle at the arrival transition inside the advance helpers — including the joint-movement and PIBT tiers — and fail_path_agent_flow terminalizes it as failed at every structural failure and explicit MarkUnreachable exhaustion transition. observe_path_agent_flow_tick drives the per-tick inventory weighting and refreshes the oldest outstanding goal age from per-agent admission stamps. The bare state-only goal helpers perform no accounting and say so.

Deliberate Limits

The public schedule remains synchronous at its caller boundary even when an auto-exec phase uses the worker pool. It does not own arbitrary queued kernels, nondeterministic completion threads, local avoidance, multi-agent collision resolution, permission layers, general doors, or region-selective cache invalidation.

Movement validation currently uses a boolean-like passability field plus boolean-like occupancy and reservation fields. Weighted terrain remains part of path selection, not movement commit validation. Games with doors, factions, construction phases, vehicles, or multi-tile entities should layer those rules around this narrow helper until the movement vocabulary is expanded.