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
MovementIntentrecords one adjacent tile move fromfromtotoplus an optionalMovementVersionCheck.MovementVersionCheckcarries optional expectedfrom/tocontent versions andfrom/totopology versions. Unset fields are not checked.MovementStatusreportsMoved, invalid endpoints (InvalidFrom,InvalidTo,NotAdjacent), impassable endpoints (ImpassableFrom,ImpassableTo), an unavailable transition (Blocked),OccupiedorReserveddestinations, and staleStaleContent/StaleTopologyversion guards.MovementResultreturns the status plus the echoedfrom/tocoordinates.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.MovementFailureCountsaggregates failures intoinvalid,impassable,blocked,occupied,reserved,stale_content, andstale_topologybuckets;record_movement_failure(counts, status)maps each non-Movedstatus into its bucket.movement_versions_match(world, intent)checks only the optional version guards (chunk content versions first, then topology versions) and returnsMovedwhen 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 inastar_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 asImpassableTo, so the agent can re-plan against the changed world. A blocked diagonal-clearance or provider transition reportsBlockedbecause the destination tile itself remains passable. Missing provider topology reportsStaleTopology, 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 whendirty_maskis 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.
SwapPolicyselects whether a mutually blocked pair may exchange tiles:Forbid(default; the standard multi-agent path finding constraint),Permit, orPermitOnDeadlock, which admits the exchange only after both members have been blocked forJointMoveOptions::deadlock_ticksconsecutive 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.JointMoveOptionscarries the swap policy and deadlock threshold;JointMoveStatsreports the standard movement frame stats plus chained admissions, rotations, swaps, and denied swaps;JointMoveScratchis the caller-owned workspace whosereservekeeps the warm path allocation-free.reserveis 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 throughtess::detail, whichdocs/style.mdexcludes 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 ascommit_movement_intentdoes, 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 failsReservedrather 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 foradvance_path_agents_with_movement. An observer overload reports each committed move.tick_weighted_path_agents_with_joint_movement<World, Class, MaxCost, OccupancyTag, ReservationTag>mirrorstick_weighted_path_agents_with_movementwith 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.
PibtPrioritiesis caller-owned adaptive priority state, index-paired with the agent span exactly likePathAgentRoutes:elapsedincrements each tick an agent is unarrived and resets on arrival, and higher elapsed decides earlier (span index breaks ties).elapsedandreserveare 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 clearslast_resultso scoped resubmission replans, and observer callbacks (overload) fire only after the whole configuration is applied. Edge conflicts follow the sharedSwapPolicyfromJointMoveOptions; vertex conflicts backtrack. The advance is dense-only (like the distance-field product family) and reusesJointMoveScratch.- The oracle's requirements are the
PibtRankingconcept: callable asrank(agent_index, coord)returning something convertible tostd::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 adetailname 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_atprovides an exact per-tile oracle frombuild_distance_field_productover the same movement class; rebuild the product when the class's inputs (for example a settled set) change. RouteAttachmentRankingis 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:
- Mark the obstacle field only when an agent's settled state changes (arrived or terminally unreachable), not every tick.
- 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: usemark_dirtyfor dirty metadata,mark_topology_dirtyfor topology freshness, and the schedule's notification protocol for OnDirty tasks. - 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
RenderTileDeltarecords 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 fullchunk_countscan).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
RenderVersionis 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.TileChunkDeltais one chunk's record: itsdirty_mask, the chunk-clipped bounds, and eithertile_countper-tile entries starting atfirst_tileinframe.tiles, ortile_count == 0meaning box-granular (repaint every tile inbounds).content_versionis debugging only -- clears do not bump it and sparse rematerialization resets it. Chunk records are the only entry point; consumers never iterateframe.tilesdirectly.TileDeltais one changed tile (coordinate, local tile id, dirty mask).EntityDeltaKind/EntityDeltarecord entity motion and lifecycle:Moved(coalescible), and the barriersTeleported,Spawned,Despawned,Parked,Placed.from == tofor spawns/places; parks and despawns carry the released tile.last_tickstamps 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.DeltaFrameHeadercarriesfrom_version/to_version(equal on empty frames; +1 on state-carrying ones), the folded tick range and count, the uniondirty_mask, and thebaseline/truncatedflags. Truncation (capacity overflow or a hardclear()) is a structural gap: entity loss is unrecoverable by the version chain.DeltaFrameis an immutable view into collector-owned storage. Its spans are valid until the nextpublish()orreserve(), 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_*andclear()touch only the pending buffers, so the next frame is recorded while the current one is applied, andreserve()re-reserves the published vectors and was missing.headeris a value copy and outlives all of it. Holding a frame across apublish()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 (adoptto_version, re-snapshot entity presentation); otherwise the chain must match exactly withconsumer.value != 0.PathOverlayDeltais 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 orempty(), and overflowing overlay storage drops the overlay (counted inoverlay_truncations), never the frame. Nodes are copies, valid for the frame's lifetime.DeltaCollectorOptionssets the per-chunksparse_tile_threshold(records per-tile up to it, box-granular above; 0 = always box) andcoalesce_moves(fold consecutive moves last-writer-wins; disable for motion-interpolating renderers so each step spans one tile).DeltaCollectorStatscounts published frames, baselines, record kinds, coalesced moves, and truncations, cumulatively.DeltaCollectoraccumulates records and publishes frames:reservesizes every buffer once (steady state never allocates; records past capacity are dropped and flagged, never grown mid-frame);begin_tickstamps subsequent records;record_move/record_teleport/record_spawn/record_despawn/record_park/record_placefeed 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_pendingare 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 isclear()followed by a full baseline collection. The collector must be the sole clearing owner of every dirty bit it collects; shareddirty_boundsacross 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 anAll-scope submission and processing pass. It is not valid after aNeedsOnlysubmission clears the runtime but skips agents that need no new request.collect_path_overlays(collector, agents, routes, handles[, selection])instead reads authoritativePathAgentRoutes. Use it withNeedsOnlysubmission 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 copypath.suffix(path_index), gate onhas_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
PathAgentStatestores an agent's position, goal,PathTicket, path index, optionallast_result,PathAgentPhase, active-goal flag, andblocked_retriescount.last_resultis absent before a search and after a route-invalidating movement failure;NoPathappears only after a search actually returns that result.PathAgentPhaseis the agent lifecycle, decoupled from the optional last search result:Idle(no goal or arrived),NeedsPath(goal assigned, no route yet),Following(walking aFoundroute),Blocked(transient failure; retained-step contention waits, while route-invalidating failures re-search until the shared retry budget and exhaustion policy take effect), andUnreachable(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 toIdle.PathAgentFrameStatscounts submitted and completed work; everyPathStatusoutcome (found,invalid_start,invalid_goal,no_path,indeterminate,cost_overflow,not_computed, andno_candidate);precheck_ruled_out;expanded_nodes; advanced steps; arrivals; blocked waits; and aMovementFailureCounts.expanded_nodestotals 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_outis the number of agents whose goal an optional topology precheck proved unreachable before A* (a subset ofno_path; see the path runtime'sprecheck_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, skippingUnreachableagents and clearing agents that already stand on their goal (counted as arrived).apply_path_agent_results(agents, runtime)copies ticketed results back:FoundentersFollowing. It resets the retry count for a new route, but preserves the count when re-planning an alreadyBlockedagent because an occupancy-blind planner may return the same contested step; movement progress is what resets that consecutive-block budget. Planner failures enterBlockedso the tick driver's retry budget governs them.advance_path_agents(agents, runtime, max_steps)walks agents with aFoundresult up tomax_stepsnodes along runtime-owned paths without touching world fields.advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag, ReservationTag>(world, agents, runtime, options)acceptsPathAgentAdvanceOptions{max_steps, movement_dirty_mask}and commits each step throughcommit_movement_intent(no version guards), validating with the same movement class the plan used. An occupied or reserved destination leaves theFoundroute intact so the retained step can be retried; other transient failures invalidate the route and request a re-plan. Either kind moves the agent toBlockedand counts a blocked wait. The failed movement does not itself consume a retry: each following movement-enabled tick consumes one bounded retry, whilemax_steps == 0pauses the budget. A structural failure is terminalUnreachable. Arrival clears the goal and counts an arrival. An observer overload appendson_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>(...)andprocess_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 trailingconst RegionGraphT<World::residency_type>*(defaultnullptr) that they forward to the runtime's precheck gate; when supplied, goals the region graph proves unreachable are resolved without A* and surfaced inPathAgentFrameStats::precheck_ruled_out.PathAgentReplanQueueis an opt-in, caller-owned FIFO for replans. Pending agent indices deduplicate, andcontains(index)reports that pending set — membership begins at an acceptedrequestand ends at the matchingpop_frontor atclear, so a caller can skip building a request that would only be refused.process_path_agent_replansinvokes 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_replansandprocess_weighted_path_agent_replansretain exact-search semantics and pass throughPathAgentReplanOptions. 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
SimClockholds the current tick;advance_sim_tick(clock)increments and returns it.PathAgentTickStateowns the clock, the WORLD-scopedpathing_dirtyflag, 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-argumentset_path_agent_goal(state, agent, goal)arms a goal as agent-scoped dirt -- only that agent replans (the drivers submit withPathSubmitScope::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.PathAgentTickOptionscarriesmax_stepsandmovement_dirty_maskper tick, the runtimePathRuntimeCachePolicy,max_blocked_retries(default 8), andblocked_exhaustion_policy.BlockedAgentExhaustionPolicydefaults toRemainBlocked, preserving the goal and optional last search result because elapsed retries do not proveNoPath;MarkUnreachableselects terminal exhaustion explicitly and clears the last result.PathAgentTickStatsreports the tick value, whether paths were processed, separate pathing and movementPathAgentFrameStats, and therepaths_requestedcount for actual searches plusrepath_exhaustedfor 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:NeedsPathagents request processing with no manual dirty mark.Blockedagents consume one retry on each following tick. Occupied/reserved destinations retainPathStatus::Foundand retry the retained step without a search; route-invalidating transient failures clear the obsolete result and request processing. At exhaustion the default leaves the agentBlockedwithout further automatic path processing;MarkUnreachableinstead terminalizes it and clears the optional last result.BlockedAgentRecoveryScheduleselects a deterministic, caller-bounded subset of persistently blocked agents for expensive recovery checks. Exponential delay with deterministic equal jitter spreads repeated checks across the fulluint32_tdelay range. Position changes restart recovery even when the agent remains blocked at the end of the tick; scheduling never decides reachability.BlockedAgentRecoveryOptionscontrols its delay, cap, and salt;BlockedAgentRecoveryStatsreports 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>(...), andtick_weighted_path_agents_with_movement<...>advance the clock, re-process paths whenpathing_dirtyis set or any agent requested processing, then advance agents — either freely or through movement commits with the suppliedmovement_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 trailingconst RegionGraphT<World::residency_type>*(defaultnullptr) 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"]
SimPhaseis the fixed phase list, executed in declaration order each tick; tasks run in registration order within a phase.SimClock(hoisted intotime.h; the path-agent tick shares it) is the authoritative fixed-tick counter every cadence derives from.Cadenceselectsevery_tick(),every_ticks(n)(exact: the countdown advances once perrun_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), andmanual().CadenceKindis the stored discriminator for those six cadence forms.ScheduleTaskDesccombines 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;ScheduleTaskContextsupplies the current clock, consumed dirty/event masks, and background budget toScheduleTaskFn;ScheduleTaskResultreturns produced dirty/event masks, completed items, and backlog state; andScheduleTaskStatsexposes cumulative run, skip, and item counts.BackgroundBudgetis deliberately items-only: a due background task is offeredmax_itemsunits per run and reportsitems_doneplusmore_workto 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; andpublish_eventstores an exact payload before notifying its mask;run_tick(clock)advances the clock and dispatches, returningScheduleTickStats;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 beforeseal(), and reentrant ticks. Background tasks may not report more items than their offered budget, and non-background tasks must report zeroitems_done. - In diagnostics builds, an active trace receives an inclusive
Scheduler/schedule_tickduration 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_maskmerges 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_tickpropagates 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 inTickStampedEvent<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>mapsScheduleTaskContext::budget_itemsto aResumableWorkQueue<T>and maps remaining pending tickets back tomore_work, retaining deterministic cooperative jobs across ticks. When the queue becomes empty the Background task disarms; a later queue submission must be paired withrequest_run(id)to re-arm it. Queue mutation and flow-tick observation duringadvance()fail fast in every build; a callback that overreports completed items instead settles its ticket asFailed, the queue's existing operation outcome.- Allocation contract:
reserve_tasks+ registration happen at setup;run_tick, trigger notification, andrequest_runnever allocate afterseal(). 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 theFixedStepAccumulator(honoringSimSpeedand the per-frame tick cap) and runs the schedule once per granted fixed tick, returning aScheduleFrameSummary(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::PolicyMismatchexecutes 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).
Policymust be ReadOnly or UniquePerChunk (the parallel phase planner's set) and the world dense (merge_planned_dirtyis 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
noexceptfunction-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
SimSchedulerStateowns the scheduler-adjacent state currently needed by the path-agent tick layer.SimSchedulerOptionsconfigures 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.SimSchedulerStatsreports one tick: the tick value, whether operations were planned (planned_ops) and executed (executed_ops), the queuedExecutionReportandPlannedExecutionResult, the variant'sPathAgentTickStats, 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 throughcommit_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
SimSpeedisPaused,Speed1x,Speed2x, orSpeed4x;SimTimeControlcarries the current speed.sim_speed_multiplier(speed)returns the integer multiplier (0/1/2/4) andeffective_tps(base_tps, speed)returns the multiplied tick rate, saturating at thestd::uint32_tmaximum.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 aFixedStepFrame.FixedStepFramereports theticksto run this frame, the interpolationalpha(fraction of one step still banked, clamped to [0, 1]), anddropped_seconds— sim-time seconds discarded because the frame hitmax_ticks_per_framewith 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 nonzerodropped_secondsmeans 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:
- Enqueue field edits in
OperationBatchwith accurateFieldAccessDescmasks. - Call a scheduler tick with a callback that applies each planned chunk view.
- Let the scheduler mark pathing dirty when executed operations dirtied configured movement-relevant fields.
- Let the path-agent tick submit/process active requests only when dirty.
- Consume render deltas from dirty chunk bounds instead of full snapshots.
- Commit accepted movement intents through
commit_movement_intentwhen 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.