Frame pacing in Standard of Iron is measured from real gameplay runs and reported as a frame_pacing verdict. The gate is designed to answer a broader question than โwhat was the average FPS?โ It measures whether presented frames arrive consistently, whether CPU/GPU frame work fits the active graphics preset, whether uploads stay bounded, whether first-use asset work leaks into the playable window, and whether the measurement itself captured enough trustworthy evidence to qualify the run.
The current implementation is split across:
render/profiling/frame_pacing.h โ sample schema, preset budgets, statistics, verdict generation;ui/gl_view.* โ gameplay-frame sampling and report integration;scripts/check-frame-pacing.py โ repeat orchestration, fixture/replay handling, validation, artifact output;.github/workflows/frame-pacing.yml โ dedicated qualified-hardware workflow; andtests/render/profiling/frame_profile_test.cpp โ gate-logic tests.The numbers in this article describe the current code-defined gate, not aspirational targets.
A frame-pacing pass combines several independent measurements:
A run can therefore fail even if one headline number looks good. For example, low CPU p95 does not compensate for a repeated presentation hitch or a stream of texture creation after the battle becomes playable.
render/profiling/frame_pacing.h is the source of truth.
| Preset | CPU p95 | GPU p95 | Max upload/frame | Interval p95 | Interval p99 | Max interval |
| Low | 10 ms | 10 ms | 2 MiB | 18 ms | 25 ms | 50 ms |
| Medium | 12 ms | 12 ms | 4 MiB | 18 ms | 25 ms | 50 ms |
| High | 12 ms | 12 ms | 8 MiB | 18 ms | 25 ms | 50 ms |
| Ultra | 16.67 ms | 12 ms | 8 MiB | 18 ms | 25 ms | 50 ms |
All presets also enforce:
33.34 ms;2 frames per minute;1 frame;10%;120 valid frames; and30 seconds of measured presentation time.Unknown graphics presets fail the verdict rather than silently inheriting a default budget.
Frame time distributions are not well described by averages.
A renderer can average 8 ms while still producing a visible 70 ms stall every few seconds. The pacing gate therefore pays particular attention to:
The goal is to catch both sustained overload and short recurring stalls.
Render::Profiling::PacingSample records the evidence for one measured frame.
Current fields include:
interval_ms);cpu_ms);gpu_ms);The report then derives distributions and verdicts from the sequence of samples.
The accepted interval source is:
QQuickWindow.frameSwapped
This is a Qt presentation/queueing signal. It is not a claim to measure physical panel scanout or end-to-end input latency.
The distinction matters when interpreting the report: the gate measures application-level frame presentation continuity as observed through Qt, not display hardware telemetry.
scripts/check-frame-pacing.py rejects reports that use the wrong interval source.
A presentation frame without a valid presentation timestamp is not silently removed from the evidence.
The current gate allows zero untimed presentation frames. If timing disappears for part of the run, the measurement is considered invalid for qualification rather than producing optimistic percentiles from the subset that happened to be timestamped.
cpu_ms measures render-thread CPU work for the frame.
Per-phase CPU timing is also retained so a failed frame can be decomposed. Depending on the report, the worst sample can show which rendering phase dominated instead of forcing diagnosis from one aggregate number.
The preset CPU p95 budget is intended to capture recurring CPU pressure, while the presentation-interval/hitch checks catch visible stalls that may come from CPU, GPU, synchronization, resource creation, or other causes.
GPU timing is delayed because GPU queries do not necessarily resolve in the same frame that submitted the work.
The report associates available delayed GPU results with the pacing evidence while tracking the fraction of missing GPU samples.
A run fails if more than 10% of the expected GPU samples are missing. That prevents an apparently excellent GPU p95 from being accepted when most expensive frames simply lack GPU timing data.
The pacing gate records game-owned GL transfer traffic through the repository's GL resource tracking.
Tracked categories include:
gl_upload_bytes).The per-frame upload budget varies by graphics preset because the expected content/detail envelope differs.
These counters describe traffic issued through the game's wrappers. They are not a complete measurement of driver residency, internal Qt Quick allocations, or every byte moved inside the graphics stack.
post_playable_asset_work is deliberately separate from byte uploads.
The gate expects zero asset/resource construction after the battle enters the playable measurement window.
This catches a common class of hitch source: the steady-state frame is cheap, but the first arrow, creature, effect, building, shader variant, or other asset triggers expensive creation after gameplay has already been revealed.
A resource creation can therefore fail the pacing gate even when its transfer size is small.
For diagnosis, enable:
SOI_TRACE_PLAYABLE_GL=1
This records call sites for buffer, vertex-array, and texture creation after the first playable frame.
The resulting report stores raw addresses under playable_gl_creation_sites.
Resolve them against a symbol-preserving build with:
python3 scripts/symbolize-gl-sites.py REPORT.json \
--binary build/bin/standard_of_iron
This tracing mode is diagnostic. The acceptance verdict remains based on the normal pacing/report contract.
A presentation interval longer than 33.34 ms is considered a hitch for the current gate.
The gate then evaluates both frequency and clustering.
At most two hitch frames per measured minute are accepted.
At most one consecutive hitch is accepted.
A pair of back-to-back long frames can therefore fail even if the total count over a long run remains small. Consecutive stalls are visually more disruptive and can indicate a multi-frame resource or synchronization event.
The report keeps evidence for the worst pacing sample, including information such as:
This is important because the gate is meant to be actionable. A red verdict without evidence would only say that the run was bad, not which subsystem dominated the bad frame.
Reports also summarize ten-second windows.
Window-level evidence includes:
This makes it easier to distinguish a single startup-adjacent event from a recurring pacing problem spread throughout the scenario.
A report is not valid simply because the executable exited successfully.
The current gate requires:
A too-short or mostly-unmeasured run is rejected instead of treated as a pass with insufficient evidence.
scripts/check-frame-pacing.py also verifies that the run measured the intended gameplay state.
It rejects a report when, among other things:
frame_pacing is missing;QQuickWindow.frameSwapped;This prevents a trivially empty scene from โqualifyingโ a renderer that was never asked to draw the representative battle.
A performance run is more useful when it exercises known expensive gameplay actions instead of only orbiting an idle camera.
The repository includes:
assets/benchmarks/first_contact.mission.json;assets/benchmarks/first_contact.map.json; andassets/benchmarks/battle_coverage.action.json.The action fixture is versioned and declares required_coverage.
The runner validates that required behaviors were actually observed. Current coverage can include gameplay events from systems such as:
A fixture therefore proves more than โthe process stayed alive for 60 seconds.โ
The runner can drive repeated camera movement with --camera-cycle.
Camera motion exercises presentation paths that a static view may not stress in the same way, including visibility changes, terrain/scatter submission, scene transformations, and frame-to-frame QSG behavior.
Camera-cycle runs are still subject to the same sample and scene validity checks.
The runner can use an explicit replay:
--replay path/to/mission.soireplay
The replay is copied into the artifact directory and hashed.
This is useful when comparing presets or code revisions because the command stream can remain identical across runs.
The pacing gate is still measuring presentation performance, but the gameplay sequence is controlled by the deterministic replay system.
A representative camera-cycle run is:
python3 scripts/check-frame-pacing.py \
--binary build/bin/standard_of_iron \
--seconds 60 \
--repeats 3 \
--camera-cycle
A run using the authored mission/action coverage is:
python3 scripts/check-frame-pacing.py \
--binary build/bin/standard_of_iron \
--mission-file assets/benchmarks/first_contact.mission.json \
--action-fixture assets/benchmarks/battle_coverage.action.json \
--seconds 60 \
--repeats 3
Repeated runs are useful because a single performance sample can be influenced by transient host conditions even when the application is unchanged.
The output directory contains the evidence needed to reproduce and inspect the qualification run.
Depending on the invocation, artifacts include:
Keeping the exact fixture/replay and hashes with the report makes performance results reviewable instead of relying on a command copied into a chat or issue description.
The dedicated workflow uses:
.github/workflows/frame-pacing.yml
It runs on a self-hosted Linux x64 machine carrying the soi-performance label.
Before the build/measurement, it records host information with:
python3 scripts/check-perf-host.py \
--output artifacts/pacing-ci/reference-host.json
The qualification result is part of the artifact set.
The workflow is not intended to treat arbitrary shared hosted CI hardware as a stable performance reference.
The frame-pacing workflow is workflow_dispatch only.
Its current responsibilities include:
It does not provision a performance machine itself. The correctly labeled self-hosted runner is part of the test environment.
On Linux, check-frame-pacing.py looks for competing processes that can contaminate a run.
Examples include:
standard_of_iron process;arena_app;A contaminated run is not silently treated as a clean qualification result. Competing PID/executable information is retained for diagnosis.
Performance measurement is only meaningful if host contention is visible in the evidence.
The gate reports several dimensions because different failures imply different work.
Likely direction: recurring render-thread work is too expensive.
Inspect CPU phase timings and determine whether the pressure comes from scene walk, preparation, sorting/batching, UI/QSG interaction, or another measured phase.
Likely direction: the selected preset submits more GPU work than the current budget allows.
Inspect pass cost, draw/triangle load, shadow/post effects, overdraw, and shader/resource choices.
Likely direction: too much buffer/texture data is being transferred per frame.
Inspect dynamic buffers, orphaning patterns, repeated texture updates, or resources that should have been prepared earlier.
Likely direction: resource construction is occurring after the playable barrier.
Use SOI_TRACE_PLAYABLE_GL=1 and symbolization to find the creation sites.
Likely direction: intermittent stalls, synchronization, host contention, QSG/presentation delay, asset creation, or a smaller set of bad frames rather than sustained workload.
Inspect worst-frame and ten-second-window evidence.
Likely direction: a multi-frame event is blocking presentation, even if the total number of hitches is low.
Likely direction: the GPU measurement itself is incomplete. Fix the instrumentation/availability problem before drawing conclusions about GPU headroom.
Likely direction: the benchmark did not measure the intended battle. Correct the launch/fixture/load path before treating the numbers as performance evidence.
The pacing gate and graphics profile system describe different layers of the same performance contract.
render/graphics_settings.h defines what Low/Medium/High/Ultra render. frame_pacing.h defines the timing/upload limits those presets must meet during the measured workload.
A profile change that adds more expensive rendering work can therefore require either:
The documentation should not update one table without checking the other source.
Frame pacing begins after a playable measurement barrier.
Startup/map-load work has its own instrumentation and readiness contracts. Moving expensive work earlier can be a valid way to remove first-use hitches, but that work still needs to fit the startup/loading experience.
See MISSION_STARTUP.md for startup readiness and PERFORMANCE_INSTRUMENTATION.md for broader profiling.
A frame_pacing pass does not replace simulation-budget or deterministic replay checks.
Presentation can be smooth while the simulation itself is too slow, and simulation can be fast while rendering hitches.
The repository therefore keeps separate measurements for:
A performance change should be judged against the subsystem it actually affects.
The current gate depends on these invariants:
frame_pacing.h;QQuickWindow.frameSwapped;
| Concern | Source |
| Budgets/sample/verdict | render/profiling/frame_pacing.h |
| Runtime sampling | ui/gl_view.* |
| Runner/validation | scripts/check-frame-pacing.py |
| GL-site symbolization | scripts/symbolize-gl-sites.py |
| Host qualification | scripts/check-perf-host.py |
| CI workflow | .github/workflows/frame-pacing.yml |
| Gate tests | tests/render/profiling/frame_profile_test.cpp |
| Action fixture | assets/benchmarks/battle_coverage.action.json |
The gate documented here is the current executable performance contract. Historical investigation notes and proposed targets are not substitutes for the values enforced by the code and workflow.