tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
schedule.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/sim/time.h>
5
6#include <cstddef>
7#include <cstdint>
8#include <string_view>
9#include <vector>
10
11// The M5 schedule: ordered phases of type-erased tasks driven by cadences
12// that are pure functions of the fixed-tick counter and per-task pending
13// dirty masks. The schedule itself never touches a world -- dirty bits are
14// FED to it (by task results and notify_dirty) -- so "no hidden full-world
15// scans" holds by construction. World-typed work lives in task objects the
16// caller owns and registers by reference; type erasure is a function
17// pointer plus a context pointer (no std::function, no allocation on
18// dispatch).
19//
20// Threading: a Schedule is externally synchronized like every tess scratch.
21// notify_dirty and request_run are frame-owner-thread calls and must never
22// be made from queued-operation callbacks (those may run on pool workers);
23// worker-produced dirty flows exclusively through the task-result mask.
24//
25// Reentrancy: task bodies may call notify_dirty, request_run, and
26// set_enabled (field writes on stable storage, with the documented
27// immediate-merge semantics). They must NOT call add_task or run_tick --
28// registration after seal() would reallocate the task array mid-iteration
29// and a nested tick would double-advance every cadence; both are asserted.
30namespace tess {
31
33enum class CadenceKind : std::uint8_t {
34 EveryTick,
35 EveryN,
36 OnDirty,
37 Background,
38 Manual,
39};
40
41// Deterministic background bound: a due background task is offered at most
42// max_items work units per run and reports how many it consumed plus
43// whether work remains. There is deliberately no wall-clock budget in the
44// current pre-1.0 release --
45// a time valve would make tick outcomes nondeterministic, and every
46// consumer bound is expressible in items; it returns with its first real
47// consumer.
50 std::uint32_t max_items = 1;
51};
52
54struct Cadence {
55 CadenceKind kind = CadenceKind::EveryTick;
56 std::uint32_t every_n = 1;
57 std::uint32_t dirty_mask = 0;
58 BackgroundBudget budget{};
59
60 [[nodiscard]] static constexpr auto every_tick() noexcept -> Cadence {
61 return Cadence{};
62 }
63
64 [[nodiscard]] static constexpr auto every_ticks(std::uint32_t n) noexcept
65 -> Cadence {
66 return Cadence{CadenceKind::EveryN, n == 0 ? 1u : n, 0, {}};
67 }
68
69 [[nodiscard]] static constexpr auto on_dirty(std::uint32_t mask) noexcept
70 -> Cadence {
71 return Cadence{CadenceKind::OnDirty, 1, mask, {}};
72 }
73
74 [[nodiscard]] static constexpr auto background(
75 BackgroundBudget budget) noexcept -> Cadence {
76 return Cadence{
77 CadenceKind::Background, 1, 0,
78 BackgroundBudget{budget.max_items == 0 ? 1u : budget.max_items}};
79 }
80
81 [[nodiscard]] static constexpr auto manual() noexcept -> Cadence {
82 return Cadence{CadenceKind::Manual, 1, 0, {}};
83 }
84};
85
86// Fixed phase list, executed in declaration order every tick. Tasks run in
87// registration order within a phase. The set matches the simulation TDD's
88// phase vocabulary; custom phase lists are deferred until a consumer needs
89// one.
91enum class SimPhase : std::uint8_t {
92 Input,
93 PreUpdate,
94 AI,
95 Pathing,
96 Movement,
97 Commit,
98 Topology,
99 Fields,
100 Background,
101 RenderDelta,
102 Diagnostics,
103 Count,
104};
105
108 SimClock clock{};
109 // OnDirty: the bits (within the task's own mask) that made it due; they
110 // are consumed before the task runs, so bits raised DURING the run re-arm
111 // it for the next tick.
112 std::uint32_t pending_dirty = 0;
113 // Background: the item budget for this run.
114 std::uint32_t budget_items = 0;
115};
116
119 // Dirty bits this run produced; the schedule merges them into every
120 // OnDirty task's pending mask immediately, so later-phase tasks can fire
121 // in the same tick and earlier-phase tasks fire next tick.
122 std::uint32_t dirty_mask = 0;
123 // Background: work units consumed (at most the offered budget).
124 std::uint32_t items_done = 0;
125 // Background: true keeps the task due next tick without a new trigger.
126 bool more_work = false;
127};
128
130using ScheduleTaskFn = ScheduleTaskResult (*)(void* ctx,
131 const ScheduleTaskContext&);
132
135 // Static-storage label (same rule as diagnostics trace labels).
136 std::string_view name;
137 SimPhase phase = SimPhase::PreUpdate;
138 Cadence cadence{};
139};
140
143 std::uint64_t runs = 0;
144 // Ticks on which the task was due but disabled.
145 std::uint64_t skipped = 0;
146 std::uint64_t background_items = 0;
147 std::uint64_t last_run_tick = 0;
148};
149
152 std::uint64_t tick = 0;
153 std::uint32_t tasks_due = 0;
154 std::uint32_t tasks_run = 0;
155 std::uint32_t tasks_skipped = 0;
156 std::uint32_t background_items = 0;
157 // Union of every task result's dirty mask this tick.
158 std::uint32_t dirty_mask_produced = 0;
159};
160
166class Schedule {
167 public:
168 using TaskId = std::uint32_t;
169
170 // Setup-time capacity; add_task within it never reallocates, and run_tick
171 // never allocates at all.
172 void reserve_tasks(std::size_t count) {
173 tasks_.reserve(count);
174 phase_order_.reserve(count);
175 dirty_task_ids_.reserve(count);
176 }
177
178 auto add_task(const ScheduleTaskDesc& desc, void* ctx, ScheduleTaskFn fn)
179 -> TaskId {
180 TESS_ASSERT(!sealed_);
181 TESS_ASSERT(fn != nullptr);
182 TESS_ASSERT(desc.phase != SimPhase::Count);
183 // Cadence is a public aggregate, so hand-built descriptors can bypass
184 // the factory clamps; re-clamp here or a zero EveryN would wrap its
185 // countdown to ~4.29B ticks and a zero background budget would spin
186 // in_progress forever with no progress.
187 TESS_ASSERT(desc.cadence.kind != CadenceKind::EveryN ||
188 desc.cadence.every_n != 0);
189 TESS_ASSERT(desc.cadence.kind != CadenceKind::Background ||
190 desc.cadence.budget.max_items != 0);
191 auto record = TaskRecord{};
192 record.desc = desc;
193 record.ctx = ctx;
194 record.fn = fn;
195 if (record.desc.cadence.every_n == 0) {
196 record.desc.cadence.every_n = 1;
197 }
198 if (record.desc.cadence.budget.max_items == 0) {
199 record.desc.cadence.budget.max_items = 1;
200 }
201 if (desc.cadence.kind == CadenceKind::EveryN) {
202 record.ticks_until_due = record.desc.cadence.every_n;
203 }
204 tasks_.push_back(record);
205 return static_cast<TaskId>(tasks_.size() - 1);
206 }
207
208 // Registers a task OBJECT the caller owns; `task` must outlive the
209 // schedule. T is any callable taking the context and returning a result.
210 template <typename T>
211 auto add_task(const ScheduleTaskDesc& desc, T& task) -> TaskId {
212 return add_task(
213 desc, static_cast<void*>(&task),
214 [](void* ctx, const ScheduleTaskContext& context)
215 -> ScheduleTaskResult { return (*static_cast<T*>(ctx))(context); });
216 }
217
218 // Freezes registration and builds the dispatch indexes: phase_order_
219 // (phase-major, registration-stable -- the order run_tick always had,
220 // now one pass instead of SimPhase::Count passes over every task) and
221 // dirty_task_ids_ (only OnDirty cadences consume pending_mask, so dirty
222 // merges stop writing tasks that never read the value; audit 2026-07-11
223 // M6). Storage is never reordered, so TaskIds stay stable. A contract-
224 // violating add_task after seal() asserts in debug builds; under NDEBUG
225 // the late task registers but never dispatches (it is absent from the
226 // frozen indexes).
227 void seal() {
228 // Idempotent: registration is frozen after the first seal, so there is
229 // nothing to rebuild -- and a redundant seal() from inside a task
230 // callback must not rebuild phase_order_ while run_tick iterates it
231 // (Codex review of the audit3 W3 change).
232 if (sealed_) {
233 return;
234 }
235 phase_order_.clear();
236 dirty_task_ids_.clear();
237 for (std::uint8_t phase = 0;
238 phase < static_cast<std::uint8_t>(SimPhase::Count); ++phase) {
239 for (std::size_t i = 0; i < tasks_.size(); ++i) {
240 if (static_cast<std::uint8_t>(tasks_[i].desc.phase) == phase) {
241 phase_order_.push_back(static_cast<TaskId>(i));
242 }
243 }
244 }
245 for (std::size_t i = 0; i < tasks_.size(); ++i) {
246 if (tasks_[i].desc.cadence.kind == CadenceKind::OnDirty) {
247 dirty_task_ids_.push_back(static_cast<TaskId>(i));
248 }
249 }
250 sealed_ = true;
251 }
252
253 [[nodiscard]] auto sealed() const noexcept -> bool { return sealed_; }
254
255 void set_enabled(TaskId id, bool enabled) noexcept {
256 TESS_ASSERT(id < tasks_.size());
257 if (id < tasks_.size()) {
258 tasks_[id].enabled = enabled;
259 }
260 }
261
262 // Arms the task to be due on the next run_tick regardless of cadence --
263 // the Manual trigger, and the initial trigger for Background tasks. An
264 // OnDirty task poked this way runs with pending_dirty == 0: treat a
265 // zero mask as a full-run request, not a no-op.
266 void request_run(TaskId id) noexcept {
267 TESS_ASSERT(id < tasks_.size());
268 if (id < tasks_.size()) {
269 tasks_[id].run_requested = true;
270 }
271 }
272
273 // Merges external dirty bits into the pending masks that can consume
274 // them (only OnDirty cadences read pending_mask; foreign bits within an
275 // OnDirty task's mask sit inert). Frame-owner thread only; never call
276 // from an op callback.
277 void notify_dirty(std::uint32_t mask) noexcept {
278 if (sealed_) {
279 for (const auto id : dirty_task_ids_) {
280 tasks_[id].pending_mask |= mask;
281 }
282 return;
283 }
284 for (auto& task : tasks_) {
285 task.pending_mask |= mask;
286 }
287 }
288
289 auto run_tick(SimClock& clock) -> ScheduleTickStats {
290 TESS_ASSERT(sealed_);
291 TESS_ASSERT(!in_run_);
292 // Scope guard rather than a trailing store: a throwing task callback
293 // must not leave the schedule latched "in run", or every subsequent
294 // tick would fail the reentrancy assert (audit 2026-07-11 C2).
295 struct InRunGuard {
296 bool& flag;
297 ~InRunGuard() { flag = false; }
298 };
299 in_run_ = true;
300 const InRunGuard guard{in_run_};
301 auto stats = ScheduleTickStats{};
302 stats.tick = advance_sim_tick(clock);
303
304 for (const auto id : phase_order_) {
305 run_task_if_due(tasks_[id], clock, stats);
306 }
307 return stats;
308 }
309
310 [[nodiscard]] auto task_stats(TaskId id) const noexcept -> ScheduleTaskStats {
311 TESS_ASSERT(id < tasks_.size());
312 if (id >= tasks_.size()) {
313 return ScheduleTaskStats{};
314 }
315 return tasks_[id].stats;
316 }
317
318 [[nodiscard]] auto task_count() const noexcept -> std::size_t {
319 return tasks_.size();
320 }
321
322 private:
323 struct TaskRecord {
324 ScheduleTaskDesc desc{};
325 void* ctx = nullptr;
326 ScheduleTaskFn fn = nullptr;
327 std::uint32_t pending_mask = 0;
328 std::uint32_t ticks_until_due = 0;
329 bool run_requested = false;
330 bool in_progress = false;
331 bool enabled = true;
332 ScheduleTaskStats stats{};
333 };
334
335 void run_task_if_due(TaskRecord& task, SimClock clock,
336 ScheduleTickStats& stats) {
337 // Cadence bookkeeping advances even while a task is disabled, so
338 // re-enabling never shifts the lockstep phase of EveryN tasks;
339 // OnDirty/Manual/Background triggers PERSIST across disablement and
340 // fire on the first enabled tick.
341 auto due = false;
342 auto fired_dirty = std::uint32_t{0};
343 auto budget = std::uint32_t{0};
344 switch (task.desc.cadence.kind) {
345 case CadenceKind::EveryTick:
346 due = true;
347 break;
348 case CadenceKind::EveryN: {
349 // The countdown advances independently of manual pokes, so a
350 // request_run never shifts the lockstep phase -- it just adds one
351 // extra run.
352 const auto counted = --task.ticks_until_due == 0;
353 if (counted) {
354 task.ticks_until_due = task.desc.cadence.every_n;
355 }
356 due = counted || task.run_requested;
357 break;
358 }
359 case CadenceKind::OnDirty:
360 fired_dirty = task.pending_mask & task.desc.cadence.dirty_mask;
361 due = fired_dirty != 0 || task.run_requested;
362 break;
363 case CadenceKind::Background:
364 due = task.in_progress || task.run_requested;
365 budget = task.desc.cadence.budget.max_items;
366 break;
367 case CadenceKind::Manual:
368 due = task.run_requested;
369 break;
370 }
371 if (!due) {
372 return;
373 }
374 ++stats.tasks_due;
375 if (!task.enabled) {
376 // EveryN consumed its countdown above (already reset); persistent
377 // triggers stay armed for the first enabled tick.
378 ++stats.tasks_skipped;
379 ++task.stats.skipped;
380 return;
381 }
382
383 // Consume triggers BEFORE invoking, so anything raised during the run
384 // re-arms the task for the next tick instead of being lost.
385 task.pending_mask &= ~fired_dirty;
386 task.run_requested = false;
387
388 auto context = ScheduleTaskContext{};
389 context.clock = clock;
390 context.pending_dirty = fired_dirty;
391 context.budget_items = budget;
392 const auto result = task.fn(task.ctx, context);
393 TESS_ASSERT(task.desc.cadence.kind != CadenceKind::Background ||
394 result.items_done <= budget);
395
396 task.in_progress =
397 task.desc.cadence.kind == CadenceKind::Background && result.more_work;
398 if (result.dirty_mask != 0) {
399 // Immediate merge: later-phase OnDirty tasks see it this tick,
400 // earlier-phase (and this) tasks next tick. Only OnDirty tasks
401 // consume pending_mask, so only they receive it.
402 for (const auto id : dirty_task_ids_) {
403 tasks_[id].pending_mask |= result.dirty_mask;
404 }
405 stats.dirty_mask_produced |= result.dirty_mask;
406 }
407
408 ++stats.tasks_run;
409 stats.background_items += result.items_done;
410 ++task.stats.runs;
411 task.stats.background_items += result.items_done;
412 task.stats.last_run_tick = clock.tick;
413 }
414
415 std::vector<TaskRecord> tasks_;
416 // Built at seal(): phase-major dispatch order and the OnDirty consumers
417 // of dirty-mask merges.
418 std::vector<TaskId> phase_order_;
419 std::vector<TaskId> dirty_task_ids_;
420 bool sealed_ = false;
421 bool in_run_ = false;
422};
423
424// Frame -> ticks bridge: consumes real frame time through the accumulator
425// (honoring SimSpeed and the per-frame tick cap) and runs the schedule once
426// per granted fixed tick. Cadences therefore count FIXED TICKS, never
427// frames: an EveryN task at 2x speed fires twice as often in real time and
428// exactly as often in sim time, and a backlogged frame that grants several
429// ticks advances every cadence through each of them.
432 std::size_t ticks = 0;
433 double alpha = 0.0;
434 double dropped_seconds = 0.0;
435 // Stats of the LAST tick this frame (zero ticks leaves it default).
436 ScheduleTickStats last_tick{};
437};
438
440inline auto run_schedule_frame(Schedule& schedule, SimClock& clock,
441 FixedStepAccumulator& accumulator,
442 double real_delta_seconds,
444 const auto frame = accumulator.consume(real_delta_seconds, control);
445 auto summary = ScheduleFrameSummary{};
446 summary.ticks = frame.ticks;
447 summary.alpha = frame.alpha;
448 summary.dropped_seconds = frame.dropped_seconds;
449 for (std::size_t i = 0; i < frame.ticks; ++i) {
450 summary.last_tick = schedule.run_tick(clock);
451 }
452 return summary;
453}
454
455} // namespace tess
Definition time.h:51
Definition schedule.h:166
Bounds background work in deterministic item units per task invocation.
Definition schedule.h:49
Configures when a task becomes due within the fixed-tick schedule.
Definition schedule.h:54
Summarizes all fixed ticks consumed during one rendered frame.
Definition schedule.h:431
Supplies a task with the current tick, trigger bits, and work allowance.
Definition schedule.h:107
Describes a task's static label, phase, and cadence.
Definition schedule.h:134
Returns produced dirty bits and bounded background progress to the schedule.
Definition schedule.h:118
Holds cumulative execution counters for one task.
Definition schedule.h:142
Summarizes task dispatch and dirty propagation for one fixed tick.
Definition schedule.h:151
Stores the authoritative monotonically increasing fixed-tick count.
Definition time.h:28
Supplies the time-control state consumed for one rendered frame.
Definition time.h:20