One frame source trace

CNA snapshot 009d40f5  ·  Development › Runtime internals  ·  source links pinned to 009d40f5

✓

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:

ModeUpdateElapsedGameTime seen by UpdateTotalGameTime seen by Update
Fixed100
Fixed20.0166667 s (one target step)0
Fixed30.0166667 s0.0166667 s
Variable3 and 4 (as recorded)update 3: 0.0211 supdate 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:

  1. platform_->PollEvents(eventBatch_->events) fills the reused std::vector<PlatformEvent>; how a backend produces it is on the platform pages.
  2. 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).
  3. Then std::visit applies the game's own reaction (table below).
  4. After the whole batch: IPlatformKeyboard::Update(), then IPlatformMouse::Update(), each when the platform has that service; then, only if IsSubsystemInitialized(PlatformSubsystem::Gamepad), IPlatformGamepad::Update() and IPlatformJoystick::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).

EventGame::PollEvents reaction
QuitEventExit() (idempotent).
WindowEvent::CloseRequestedExit() 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, DisplayScaleChangedWindow_.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 / FocusLostSets IsActive true (raising Activated) and invalidates the surface / sets it false (raising Deactivated). The window id is not filtered.
Exposed, Minimized, Maximized, Restored, DisplayChangedOnSurfaceInvalidated only. Moved and any other kind do nothing.
KeyEventOnly 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.
DropEventWindow_.OnDropEXT(event), which assembles a drop and raises FileDropEXT and TextDropEXT.
AppLifecycleEventWillEnterBackground: 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 by DoInitialize through IGraphicsDeviceManager): BeginDraw() returns false when the manager has no device; otherwise it asks IGraphicsRenderer::CanBeginDrawEXT() (default true; EasyGL answers !metagl::IsContextLost(), so a browser WebGL context in its asynchronous lost/restored interval keeps ticking Update without 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 when Present throws.
  • Without a manager (the demo_2d shape): BeginDraw() is always true and EndDraw() calls getGraphicsDeviceProperty().Present() directly. There is no frame-long lease and no CanBeginDrawEXT gate.
  • GraphicsDevice::Present() checks disposal, throws InvalidOperationException if a render target is still bound, takes its own scoped lease that restores the previous binding, calls renderer_->Present() and then UpdateViewportFromWindow(). The overload with source and destination rectangles and an override window handle is the XNA Present(null, null, null) equivalent when nothing is specified; a renderer that cannot honour a region makes it throw NotSupportedException instead 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:

AspectDesktop TickEmscripten frame body
OrderWait, then PollEvents, then updatesPollEvents first, then time
Clock sourcePerformance counter and frequencyGetTicksMilliseconds() deltas in a double-millisecond accumulator, delta capped at 250 ms; the first callback seeds the last tick
Step modeFixed or variable per IsFixedTimeStepAlways fixed steps of TargetElapsedTime; IsFixedTimeStep = false is not honoured; nothing sleeps or yields
First updateZero elapsedA full step
TotalGameTimeAdvanced after UpdateAdvanced before Update
IsRunningSlowlyFrom accumulated frame lagAlways false
DrawOnce per tick unless suppressed or refusedOnly if at least one update ran and BeginDraw() is true; suppressDraw_ is not consulted
Time stategameTime_ memberA copy in s_emLoopState taken when RunLoop starts
ExitRunLoop sees RunApplication false, then raises OnExitingThe body raises OnExiting itself and clears the game pointer, ending the wait loop
ExceptionsPropagate out of Run() after a log lineCaught in the body, logged through CNA::Logger::Error, game pointer cleared: Run() then continues to EndRun() and returns normally
Mobile suspendHandled 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.

FileWhat 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.cppThe event-semantics transcript across every compiled platform (above).
GameTests.cppAComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame, 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

  1. Game.cpp — Run, RunLoop, Tick, AdvanceElapsedTime, UpdateEstimatedSleepPrecision, PollEvents, then Update and Draw; the Emscripten body sits between SortDrawable and BeforeLoop.
  2. GraphicsDeviceManager.cpp — BeginDraw and EndDraw, including lease cleanup on error.
  3. GraphicsDevice.cpp — Present, the render-target restriction and the two lease helpers.
  4. 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.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture