This guide walks through building a Model in the Sim Engine: its lifecycle methods, how to organize states into controllers, how to configure rates and dependencies, and how to safely use streams, async tasks, and profiling in the hot path.
1. Understand What a Model Is
A Model is a logical unit that runs at a configured rate, may depend on other models, and contains state controllers that group related states (play/reset/pause). Models can run in parallel; declare dependencies explicitly so the scheduler can respect ordering. A model's primary responsibilities:
- Register and initialize state controllers and resources
- React to engine lifecycle events (Reset, Init, Run, Stop, Freeze, FirstRun)
- Execute time-critical logic inside
OnRun() - Trigger async actions and pass descriptors to background threads (lockless)
- Publish/subscribe to data streams and recorders
2. Implement the Lifecycle Methods
| Method | When it's called / what to do |
|---|---|
| Constructor | Called once when the model is created. Initialize one-time objects, configure CPU affinity, and set thread priority. |
OnInit() | Called once at creation/engine init. Initialize state controllers, set initial state statuses, configure stream triggers and send/receive callbacks, and call global initializers like OGIInit() (only from one model). |
OnFirstRun() | Called the first time the model enters running state. Use for one-time start-up actions tied to the engine actually running. |
OnRun() | Main per-frame execution callback (the hot path). Execute state controllers, send/enqueue outputs, start async tasks without waiting, and collect lightweight profiling. |
OnReset() | Called on simulation reset. Set the model's minor frame frequency via SetMinorFrame(), fetch shared resources (LRUs), and reset internal counters/state. |
OnStop() / OnFreeze() | Called when the model is stopped or frozen. Safely quiesce resources, stop background operations, or pause state controllers. |
OnStateChanged() | Called when the Sim Engine's global state changes (Run/Step/Stop). React to global transitions. |
Example constructor setup — one-time thread, affinity, and priority configuration:
const int numOfTasks = 10;
tasks = std::make_unique>CTasks<(numOfTasks);
hAsyncThread = tasks->AddThread("AsyncThreadName1",
[](void* pObj, void* param1, void* param2, void* param3) {
((CMyModel*)pObj)->AsyncCmd(nullptr, nullptr, nullptr);
},
this);
// Use Core 0 and Core 2
cores = { true, false, true };
// Highest thread priority
eThreadPriority = EThreadPriority::Highest;
3. Group States into State Controllers
Inside a model, states are grouped into logical State Controllers. Each controller manages a group of states and has its own play/pause/reset controls. Group states to get:
- Modularity — group related behaviors (communication, initialization, health checks)
- Control — pause or reset groups independently
- Sequencing — controllers allow deterministic ordering inside a model
// Add controller and set status
CStatesController::AddController<CCommCheck>("CommChecks");
CStatesController::SetStateStatus("CommChecks", CACState::EStateStatus::ePlay);
// Execute controllers in OnRun
CStatesController::ExecuteState("CommChecks");
4. Configure Rates and Dependencies
Each model executes at a configured minor frame rate:
-1— run every simulator minor frame0— disabled (does not run)- >0 — run every X simulator frames
Multiple models may run in parallel. If model A depends on model B, declare that dependency (via configuration or the scheduler) so execution is ordered correctly.
5. Connect Streams, IO, and Recording
Models can publish or subscribe to streams. Use stream triggers to run a model only when specific data arrives. There are three callback hooks around the raw send/receive path:
// Execute only when a block arrives on StreamName/BlockName
SetStreamTrigger("StreamName", "LRUName.BlockName");
// Called just before the raw block is sent out
SetStreamBlockPreSendCB("StreamName", "LRUName.BlockName", &Cmodel2::OnPreSendCB);
// Called just after the raw block is received, before it is accepted
SetStreamBlockPostReceiveAcceptCB("StreamName", "LRUName.BlockName", &Cmodel2::OnPostReceiveAcceptCB);
// Register receive callback
SetStreamBlockReceiveCB("StreamName", "LRUName.BlockName", &Cmodel2::OnReceiveCB);
All three callbacks share the same signature, which now includes the block name:
void Cmodel2::OnReceiveCB(const char* blockName, const uint8_t* raw, uint64_t size);
void Cmodel2::OnPreSendCB(const char* blockName, const uint8_t* raw, uint64_t size);
bool Cmodel2::OnPostReceiveAcceptCB(const char* blockName, const uint8_t* raw, uint64_t size);
- SetStreamBlockPreSendCB — called just before the raw data is sent out. Useful for calculating a CRC or otherwise finalizing the raw buffer before transmission.
- SetStreamBlockPostReceiveAcceptCB — called just after the raw data is received, before it is accepted. Useful for validating the raw message. If it returns
false, the message counter is not incremented and the registered stream trigger is not called. - SetStreamBlockReceiveCB — called after a received block has been accepted.
For recording, push small descriptors into a lockless SPSC ring in the hot path and let the recording thread serialize or write the large data off the critical path.
6. Add Async Commands and Profiling Safely
For long-running or blocking operations (file I/O, network, complex computation), use a thread pool or async task manager, and follow these rules:
- Do not block the real-time
OnRun()while waiting for async work. - Check task availability before starting to avoid duplicate runs (
tasks->IsTaskBusy()). - Prefer to enqueue and forget; use status flags or callbacks to detect completion.
Keep profiling lightweight in the hot path — use scope profilers or running averages, and avoid heavy logging:
// scope profiler
{
static CRunningAverage runningAverage;
CSimScopeProfiler simScopeProfiler("profilingTaskName", &runningAverage);
}
// manual timing
auto startT = std::chrono::high_resolution_clock::now();
// ... work ...
runningAverage.add(std::chrono::duration_cast(
std::chrono::high_resolution_clock::now() - startT).count());
7. Follow the Typical Model Flow
OnInit():
- Initialize states and controllers
- Set stream triggers and callbacks
- Pre-allocate buffers
OnReset():
- Set minor frame using SetMinorFrame()
- Reset state controllers and counters
OnFirstRun():
- One-time start tasks (timers, warm-up)
OnRun():
- Execute state controllers (fast, deterministic)
- Enqueue record descriptors into ring buffer
- Optionally start async tasks (non-blocking)
OnStop()/OnFreeze():
- Quiesce background threads, flush buffers
8. Apply Integration Best Practices
- Pre-allocate all model-local buffers and structures in
OnInit(). - Avoid blocking operations in
OnRun(); use lockless handoffs to background threads. - Set
SetMinorFrame()appropriately inOnReset()depending on how often the model should run. - Use state controllers to group related functionality and to enable/disable features at runtime.
- Prefer descriptive, small messages to the recorder (pointer or index) rather than raw blobs in the hot path.
- Document cross-model dependencies and ensure the scheduler respects ordering.
OnRun(), dynamic allocation inside the hot path, and uncontrolled async thread spawns. Tip: call OGIInit() from exactly one model if OGI is included, and let only one model enable global memory monitors.