One frame source trace
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read from Game.cpp, GraphicsDeviceManager.cpp and GraphicsDevice.cpp at this snapshot; the named tests exist and were not executed. The Emscripten body and the mobile suspend path were read but not built or run, and the runtime tests do not drive them.
A frame is one Game::Tick. One tick can run several fixed-step updates but at most one draw; platform time and native events are consumed before any user callback, and presentation happens only after the draw path allows it. This page traces the desktop tick function by function, the clock rules that deliberately follow XNA 4.0 rather than FNA, the ordering of native events against the input snapshots, the draw and present path with its renderer-context lease, and the separate browser frame body whose timing is not the desktop one. It is for anyone changing timing, event handling or the draw path.
Desktop execution sequence
Game::Run
→ AssertNotDisposed → DoInitialize (once) → BeginRun → BeforeLoop (IsActive = true, raises Activated)
→ previousPerformanceCounter_ = platform_->GetPerformanceCounter()
→ RunLoop while (RunApplication)
mobile target && isSuspended_ → WaitWhileSuspended (Delay(16), PollEvents), no Tick
otherwise → Tick
→ Tick
→ AdvanceElapsedTime counter delta / frequency → accumulator
→ fixed step only: Delay(1) while accumulator + worst sleep precision < target,
then yield() until accumulator >= target
→ PollEvents
platform_->PollEvents(reused vector)
→ PlatformInputBridge::ProcessEvent(each event), then Game's own handling
→ keyboard.Update, mouse.Update, gamepad/joystick.Update only if the Gamepad subsystem is up
→ accumulator = min(accumulator, MaxElapsedTime = 500 ms)
→ Update: fixed: while (accumulator >= target) { Update } variable: exactly one Update
→ Game::Update: snapshot ordered components under the lock, run enabled ones,
FrameworkDispatcher::Update
→ suppressDraw_ ? clear it, no draw
: BeginDraw → Draw → EndDraw (GraphicsDevice::Present)
→ after the loop: OnExiting (raises Exiting) → EndRun → AfterLoop
Three properties of this shape matter to anyone editing it. First, RunApplication is read only by RunLoop, between ticks: Exit() sets RunApplication to false and suppressDraw_ to true, but a fixed-step tick that already entered its update loop runs its remaining catch-up updates, and only the draw of that tick is skipped (the flag is then cleared). Second, OnExiting is raised by RunLoop after the loop ends, not by Exit(), and Run() returning does not dispose anything (see Ownership and shutdown). Third, Tick() is public: RunOneFrame() is DoInitialize (once) plus the counter seed plus one Tick(), and skips BeginRun, BeforeLoop, OnExiting and EndRun. Hosts and most runtime tests drive frames that way, so their IsActive stays false until a focus or lifecycle event says otherwise.
With diagnostics compiled in, Tick opens a frame scope and Game/Tick, Game/Update and Game/Draw profiler zones, and bumps the frame counters Runtime/UpdateCount and Runtime/DrawCount (Instrumentation.hpp: counters and frame scope from level 1, profiler zones from level 2; at level 0 the arguments are type-checked and nothing runs). See Diagnostics internals.
Clock decisions
AdvanceElapsedTime() reads IPlatform::GetPerformanceCounter(), converts the delta to milliseconds with GetPerformanceFrequency(), stores the new counter and adds the resulting TimeSpan to accumulatedElapsedTime_. A previous counter of zero is the “not seeded” sentinel: the call stores the current counter and contributes zero. Run seeds the counter just before RunLoop and RunOneFrame seeds it after its first DoInitialize; the mobile foreground handler writes zero back on purpose (below). The platform timing contract this depends on — a monotonic counter and a stable non-zero frequency — is what GamePlatformOwnershipTests.cpp and GamePlatformTimingTests.cpp check.
Fixed step waits before it polls. While accumulated + worstCaseSleepPrecision_ < TargetElapsedTime, the tick calls platform_->Delay(1), advances the clock and feeds the measured duration to UpdateEstimatedSleepPrecision. That estimator caps each sample at 4 ms; a sample at or above the current worst case becomes the new worst case, and otherwise, if the sample about to be overwritten in the 128-entry ring equals the worst case, the worst case is recomputed as the maximum of the ring; the ring starts at 1 ms and so does the estimate. The last gap is closed with std::this_thread::yield() (the comment names FNA's Thread.SpinWait(1) as the model). If the platform counter never advances, both loops spin forever; nothing on this path guards against it, which is why the counter contract is tested. Variable step has no wait at all: the loop runs as fast as Present and the swap interval allow.
Events are polled after the wait, so every update of a tick sees the same keyboard and mouse snapshot, including the second and third update of a catch-up burst. After polling, the accumulator is clamped to MaxElapsedTime (500 ms) so a stalled process does not run an unbounded catch-up burst.
Fixed-step bookkeeping. Each pass of while (accumulated >= TargetElapsedTime) sets ElapsedGameTime, subtracts one target span, counts the step, asserts the game is not disposed, runs Update, then advances TotalGameTime. After the loop updateFrameLag_ grows by max(0, stepCount - 1); IsRunningSlowly turns true when the lag reaches five, turns false again only when the lag is back to zero, and a tick with exactly one step decrements a positive lag. The GameTime that Draw receives has ElapsedGameTime = TargetElapsedTime x stepCount (computed from integer ticks, so there is no float error), not the last update's value. Variable step puts the whole accumulator into one Update, or zero if ResetElapsedTime() asked for it, then clears the accumulator; ResetElapsedTime() does nothing in fixed-step mode.
Correction to the older reading — a desktop fixed-step tick always updates. The wait loops do not return until a full target span has accumulated, so on the desktop path a fixed-step tick performs at least one Update before its draw; the browser body is the one that can draw without updating (it never draws unless it updated). The single exception found by reading the code is a TargetElapsedTime above MaxElapsedTime: the loops wait for more than 500 ms, the clamp then leaves less than one span, no step runs, and Draw still happens every tick. No test in the runtime group targets that value (checked by reading the test names; not executed).
XNA clock semantics that must not drift
Two rules are measured XNA 4.0 behaviour that CNA adopted over FNA on the project owner's decision, as recorded in the header of GameClockFirstUpdateTests.cpp: the game's first update runs with a zero ElapsedGameTime, and TotalGameTime is the time before the step, so it advances only after Update returns. FNA sets ElapsedGameTime to the target for every update including the first and advances the total before calling Update, which the comment says leaves a game two fixed steps ahead of XNA's by the same update index. The file's header (fixed step) and the comment on its variable-step case record these values as measured on the real XNA runtime under Wine:
| Mode | Update | ElapsedGameTime seen by Update | TotalGameTime seen by Update |
|---|---|---|---|
| Fixed | 1 | 0 | 0 |
| Fixed | 2 | 0.0166667 s (one target step) | 0 |
| Fixed | 3 | 0.0166667 s | 0.0166667 s |
| Variable | 3 and 4 (as recorded) | update 3: 0.0211 s | update 3: still 0; update 4: 0.0211 s |
The three cases in that file assert exactly this on the desktop path: TheFirstFixedStepUpdateSeesZeroElapsedTime, TotalGameTimeIsTheTimeBeforeTheStep and VariableStepTotalAlsoLagsItsOwnUpdate, each by recording the clock inside Update and calling Exit() after four updates. Advancing the total before the callback would look like a harmless tidy-up and would change every simulation whose state accumulates. The rule is the desktop rule: the browser body below keeps its own arithmetic. GameTime in the user guide states the same contract for game authors.
Native events and public input
Game::PollEvents is the only place the runtime consumes native events, and there is no separate input-update step: the keyboard and mouse snapshots that Keyboard::GetState and Mouse::GetState read are published at the end of this function (a method called Game::UpdateInput does not exist). The order is fixed:
platform_->PollEvents(eventBatch_->events)fills the reusedstd::vector<PlatformEvent>; how a backend produces it is on the platform pages.- For every event,
PlatformInputBridge::ProcessEvent(event)runs first. The source comment records why:Exit()in the handling below must not stop the rest of the batch from reaching the input state machine (the source cites PLAT-6 finding 4). - Then
std::visitapplies the game's own reaction (table below). - After the whole batch:
IPlatformKeyboard::Update(), thenIPlatformMouse::Update(), each when the platform has that service; then, only ifIsSubsystemInitialized(PlatformSubsystem::Gamepad),IPlatformGamepad::Update()andIPlatformJoystick::Update().
The gate in step 4 is deliberate. Reaching GetGamepad() or GetJoystick() is itself what asks the platform to start the controller subsystem, so an unconditional pump here would put the udev device enumeration back at frame one (the source measures about 1.9 s on its Linux reference machine); a game that reads GamePad or Joysticks initializes it, and the pump runs from that frame on (the startup page's controller note). Relative mouse motion stays consume-on-read inside its service. The bridge-versus-snapshot split, and what each backend does in its Update, is on Input internals.
PlatformEvent is a std::variant of sixteen alternatives (PlatformEvent.hpp). Game itself acts on five of them. The bridge acts on nine: key, the three text events (committed text, editing, candidates), mouse motion, button and wheel, touch, and device events. Sensor, controller-axis and controller-button events have no branch in Game::PollEvents or in the bridge (SdlInputBridge.cpp, ProcessEvent).
| Event | Game::PollEvents reaction |
|---|---|
QuitEvent | Exit() (idempotent). |
WindowEvent::CloseRequested | Exit() as well. FNA acts only on quit and relies on the windowing layer to synthesize one; the source comment notes that synthesis is conditional in every backend that offers it, so CNA acts on the request itself and lets the usual synthesized quit in the same batch cost nothing. |
Resized, PixelSizeChanged, DisplayScaleChanged | Window_.updateFromPlatform() (re-query bounds, display name, orientation; raise the window events only for real changes), GraphicsDevice_.UpdateViewportFromWindow(), then IGraphicsRenderer::OnSurfaceInvalidated(window). The event's size payload is ignored and the window id is not filtered. |
FocusGained / FocusLost | Sets IsActive true (raising Activated) and invalidates the surface / sets it false (raising Deactivated). The window id is not filtered. |
Exposed, Minimized, Maximized, Restored, DisplayChanged | OnSurfaceInvalidated only. Moved and any other kind do nothing. |
KeyEvent | Only a non-repeat press of F9 or F10 matters: F9 calls DebugSimulateContextLoss() and F10 DebugRestoreContext() on the renderer. Both default to no-ops in IGraphicsRenderer; renderers that model context or device loss (EasyGL among them) override them. The runtime reserves the two keys in every Game. |
DropEvent | Window_.OnDropEXT(event), which assembles a drop and raises FileDropEXT and TextDropEXT. |
AppLifecycleEvent | WillEnterBackground: isSuspended_ true, IsActive false. DidEnterForeground: isSuspended_ false, counter and accumulator reset, ResetElapsedTime(), IsActive true, OnSurfaceInvalidated(0). LowMemory: a Logger::Warn line. Terminating: isSuspended_ false and Exit(). |
The lifecycle handling is a deliberate deviation from FNA, which tracks only IsActive: an iOS process that submits GPU work in the background is terminated and Android destroys the surface, so the loop must actually stop between the two events. The flag is set on every platform but acted on only where CNA::isMobilePlatform() (a compile-time constant, true for Android and iOS in TargetPlatform.hpp) is true. While suspended, RunLoop parks the thread with Delay(16) plus PollEvents() instead of ticking, because the platform contract has no blocking event wait. On resume the counter is zeroed so the first frame measures only itself; without that the whole background period, clamped to 500 ms, would run as a burst of catch-up updates before the first visible frame. Terminating clears the suspended flag first so Run() can leave the wait loop and reach OnExiting.
What the golden transcript pins. GameEventSemanticsGoldenTests.cpp feeds one platform-neutral script per scenario (26 of them) through a real Game::RunOneFrame() on every platform in PlatformFactory::GetAvailable() and compares an observable-state transcript (IsActive, RunApplication, whether ClientBounds changed, wheel delta) with platform-event-semantics.txt. Its header names four load-bearing findings: resize payloads are ignored and the window is re-queried (a 12345x6789 payload must not become ClientBounds); the loop does not filter by window id (focus loss from an unrelated id still deactivates); minimize, maximize, restore, move and display changes alter no captured public state; and Exit() does not stop the batch draining (a wheel event after a quit still lands). A reading caveat on the last one: the wheel value in that test comes from a scripted snapshot mouse that is published at the end of the frame, so it pins that publication runs after Exit(); the guarantee that later events still reach the bridge is the loop body itself, where Exit() only sets two flags. The transcript is regenerated with CNA_UPDATE_EVENT_GOLDEN=1 and reviewed as a diff; a platform that cannot back the build's renderer is skipped.
Update, Draw and Present
Game::Update copies the ordered IUpdateable* list under componentListsMutex_, releases the lock, calls Update on each non-null, enabled entry, clears the snapshot and then calls FrameworkDispatcher::Update(). That dispatcher (implemented in cna_audio) drives dynamic sound streams, microphone buffer checks, MediaPlayer::Update, the queued active-song and media-state notifications and, when a touch device exists, TouchPanel::Update; a derived Update that skips the base call skips all of it (extension points, user guide). Game::Draw uses the same snapshot pattern for visible IDrawable*. A component removed while a snapshot is in flight is set to null in it, so no dangling pointer is invoked later in that frame.
After the updates, Tick either clears suppressDraw_ and skips drawing, or asks BeginDraw(); when that returns true it runs Draw(gameTime_) then EndDraw(), and a false answer skips both.
- With a
GraphicsDeviceManager(found byDoInitializethroughIGraphicsDeviceManager):BeginDraw()returns false when the manager has no device; otherwise it asksIGraphicsRenderer::CanBeginDrawEXT()(default true; EasyGL answers!metagl::IsContextLost(), so a browser WebGL context in its asynchronous lost/restored interval keeps tickingUpdatewithout touching invalid GL entry points), then holds a frame-long renderer-thread context lease (AcquireRendererThreadContextLeaseForFrame, which releases the renderer's binding on release) and marks the draw begun.EndDraw()presents only if a draw was begun, and releases the lease even whenPresentthrows. - Without a manager (the demo_2d shape):
BeginDraw()is always true andEndDraw()callsgetGraphicsDeviceProperty().Present()directly. There is no frame-long lease and noCanBeginDrawEXTgate. GraphicsDevice::Present()checks disposal, throwsInvalidOperationExceptionif a render target is still bound, takes its own scoped lease that restores the previous binding, callsrenderer_->Present()and thenUpdateViewportFromWindow(). The overload with source and destination rectangles and an override window handle is the XNAPresent(null, null, null)equivalent when nothing is specified; a renderer that cannot honour a region makes it throwNotSupportedExceptioninstead of presenting the whole frame.
The two-lease structure is why a game that disposes the device from inside Draw is survivable: the manager subscribed to the device's Disposing event and drops its frame lease and drawBegun_ there, while the renderer and its context still exist, so EndDraw neither presents nor releases a dangling lease (device disposal).
Emscripten is a separate path
On Emscripten, RunLoop does not use Tick. It publishes this in s_emLoopState and loops EmscriptenMainLoopCallback() followed by CNA_WaitForAnimationFrame(), an EM_ASYNC_JS function that awaits requestAnimationFrame. Asyncify suspends and resumes that same Wasm stack between frames, which keeps Run()'s blocking, stack-local Game lifetime semantics; the source comment explains that a separately registered browser callback could not re-enter the instance while the Run() stack is suspended. The frame body differs from the desktop tick in every timing detail:
| Aspect | Desktop Tick | Emscripten frame body |
|---|---|---|
| Order | Wait, then PollEvents, then updates | PollEvents first, then time |
| Clock source | Performance counter and frequency | GetTicksMilliseconds() deltas in a double-millisecond accumulator, delta capped at 250 ms; the first callback seeds the last tick |
| Step mode | Fixed or variable per IsFixedTimeStep | Always fixed steps of TargetElapsedTime; IsFixedTimeStep = false is not honoured; nothing sleeps or yields |
| First update | Zero elapsed | A full step |
TotalGameTime | Advanced after Update | Advanced before Update |
IsRunningSlowly | From accumulated frame lag | Always false |
| Draw | Once per tick unless suppressed or refused | Only if at least one update ran and BeginDraw() is true; suppressDraw_ is not consulted |
| Time state | gameTime_ member | A copy in s_emLoopState taken when RunLoop starts |
| Exit | RunLoop sees RunApplication false, then raises OnExiting | The body raises OnExiting itself and clears the game pointer, ending the wait loop |
| Exceptions | Propagate out of Run() after a log line | Caught in the body, logged through CNA::Logger::Error, game pointer cleared: Run() then continues to EndRun() and returns normally |
| Mobile suspend | Handled in RunLoop (mobile targets) | Not handled here |
Destroying the game that owns the browser loop while Run() is active clears the loop pointer and logs an error so the loop stops before the platform and graphics resources go. The user guide already records the clock differences for game authors under fixed versus variable timestep. Do not assume a desktop timing detail holds here, and compare both bodies when changing timing or lifecycle logic. Nothing in this trace was built for or run in a browser.
Test evidence and debugging probes
Every file below is in modules/runtime/tests/Microsoft/Xna/Framework; they exist at this snapshot and none was executed for this page.
| File | What it targets |
|---|---|
GameClockFirstUpdateTests.cpp (3 cases) | The two XNA clock rules in fixed and variable mode. |
GamePlatformTimingTests.cpp (9) | Counter monotonic, frequency non-zero and stable, counter and millisecond ticks advancing over a real delay; AFixedTimestepFrameAdvancesGameTime (target 1 ms, two RunOneFrame calls) and AVariableTimestepFrameAlsoRuns; three cursor-visibility cases. |
GameEventSemanticsGoldenTests.cpp | The event-semantics transcript across every compiled platform (above). |
GameTests.cpp | AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame, ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem and MobileLifecycleEventsSuspendResumeAndTerminateTheLoop, which scripts lifecycle events through a decorator around a real platform and calls PollEvents through a friend peer. |
Timing and window-dependent cases skip when the configured platform cannot create a window. These tests do not prove any native event pump, and none drives the Emscripten body or a real mobile host.
Localizing a missing frame. Probe in the order the tick runs: RunApplication (an early Exit(), a quit or close request), AdvanceElapsedTime (a counter that does not advance), PollEvents (what the batch contained), the step count (the Runtime/UpdateCount frame counter, or a counter of your own in Update), BeginDraw (no device, CanBeginDrawEXT() false), Draw (a suppressed draw) and Present (a bound render target throws). On a mobile target also check isSuspended_. A frame that updates but never draws points at suppressDraw_ or BeginDraw; a frame that draws but never updates points at a fixed-step target above 500 ms.
Read the implementation in this order
Game.cpp—Run,RunLoop,Tick,AdvanceElapsedTime,UpdateEstimatedSleepPrecision,PollEvents, thenUpdateandDraw; the Emscripten body sits betweenSortDrawableandBeforeLoop.GraphicsDeviceManager.cpp—BeginDrawandEndDraw, including lease cleanup on error.GraphicsDevice.cpp—Present, the render-target restriction and the two lease helpers.GameClockFirstUpdateTests.cpp— why time cannot be “simplified” without changing compatibility.
Continue with Ownership and shutdown; the maintainer view of the same objects across threads is the thread and callback map.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- GameTime and the timestep: exact clock semantics — What CNA's game clock reports: GameTime protection, fixed-step catch-up with worked numbers, IsRunningSlowly hysteresis versus XNA 4.0, ResetElapsedTime, vsync versus timestep, and the browser clock.
- The Game class: contract, run modes and extension points — Exact semantics of CNA's Game base class: event types, property guards, override points, Run versus RunOneFrame versus Tick, the browser loop, reserved debug keys and the exception boundary.
- The web target: Emscripten build contract, browser loop, storage and renderer evidence — CNA's Emscripten build contract (exception ABI, Asyncify, threads), the Asyncify browser loop, content and save storage in the virtual file system, web networking, and the evidence per browser renderer.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-078: Game::PollEvents fires the renderer's test-only context-loss hooks on F9/F10 in every build — A non-repeated F9 or F10 press in any CNA game calls the renderer's DebugSimulateContextLoss() or DebugRestoreContext(), a channel CNA itself describes as a test seam, with no build, option or opt-out guard.
- CNA-BUG-080: The Emscripten Game loop (EmscriptenMainLoopCallback) does not implement Game's loop contract — In the browser, Game ignores IsFixedTimeStep = false, SuppressDraw and ResetElapsedTime, uses FNA's clock order instead of the XNA rule the desktop loop implements, and turns a frame exception into a normal return from R
- CNA-BUG-085: GameTime.IsRunningSlowly follows FNA's lag counter, not XNA 4.0's rule — CNA sets IsRunningSlowly after five accumulated extra steps and clears it when the lag counter returns to zero; XNA 4.0 reports it when two multi-step ticks occur within 20 ticks and assigns it before each Update, so the
- CNA-BUG-208: On Emscripten a diagnostics frame excludes the requestAnimationFrame wait, so FrameSample.framesPerSecond is a work rate there — The web loop opens the frame scope inside EmscriptenMainLoopCallback and waits for the next animation frame after it returns, so web frame durations and FPS exclude pacing, unlike the native Tick the documentation descri
- CNA-BUG-223: docs/input-backend.md names a SdlInputBridge ProcessEvent overload and src/Input/... paths that do not exist at TARGET — docs/input-backend.md describes a raw SdlInputBridge::ProcessEvent overload that the SDL-shaped test-double retirement (PLAT-90) removed, and cites src/Input/Internal/ and src/Input/Xna/ paths; the input sources live und
- CNA-BUG-244: README.md's Usage Example does not compile (nonexistent Graphics/GraphicsDeviceManager.hpp include, unqualified CornflowerBlue) and presents every frame twice — The README's game skeleton includes a nonexistent header path, writes CornflowerBlue without Color::, and calls device.Present() in Draw although EndDraw() already presents, so the copied program fails to compile and, on
- CNA-BUG-262: CnaTests is linked with both -sJSPI=1 and -sASYNCIFY=1 on Emscripten; emcc lets the later flag win, so the JSPI its CMake comment relies on is silently replaced by Asyncify — UnitTests.cmake adds -sJSPI=1 to CnaTests, but BuildPerformance.cmake then adds -sASYNCIFY=1 through cna_emscripten_asyncify; emcc keeps the later flag, so CnaTests is Asyncify-linked and the comment's reason (no Asyncif
- CNA-GAP-063: A fixed-step TargetElapsedTime above 500 ms never produces an Update from Game::Tick, essentially the same ceiling XNA 4.0 has — With IsFixedTimeStep true, Game::Tick clamps the accumulator to MaxElapsedTime (500 ms) before its update loop, so a TargetElapsedTime above 500 ms is never reached: Update does not run while Draw keeps running, at about
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Game loop: fixed vs variable timestep · Game loop: GameTime · Game loop: Update() · Game loop: Draw()
- Architecture
- Runtime lifecycle · Audio and input architecture
- Internals
- Runtime module internals · Startup source trace · Ownership and shutdown · Input internals · GraphicsDevice internals
- Maintainer workflow
- Thread and callback map · Ownership and lifetime master map · Change public XNA behavior
- Tests and validation
- Test architecture
- Reference
- Test target index