LOG IN
Docs
By GitHub Copilot

Logic Frame Pacing

How FramePacer, GameEngine, and game-mode startup code decouple render FPS from the simulation tick while preserving interpolation.

Generals Remastered no longer treats render FPS and simulation FPS as the same thing. The split is implemented in three places: FramePacer decides how much wall-clock time passed, GameEngine decides whether that is enough to advance logic this frame, and game startup decides what the target logic rate should be for the current mode.

The base timing object is FramePacer in Core/GameEngine/Source/Common/FramePacer.cpp. It still owns the Windows timer-resolution tweak and the render-frame limiter, but the important modern behavior is elsewhere. update measures the frame interval through m_frameRateLimit.wait, getActualFramesPerSecondLimit floors the render cap so it can never throttle below the effective logic rate, and the logic-time helpers compute a simulation step relative to the current render cadence.

That render-cap floor fixes a subtle failure mode. The comments in getActualFramesPerSecondLimit spell it out: both single-player and multiplayer advance at most one logic tick per render iteration, so if render is capped below the chosen logic rate, the simulation is silently dragged down with it. The fix is to raise the render cap to at least the network-selected or logic-time-scale-selected tick rate.

The second half of FramePacer is the logic-rate query API. getActualLogicTimeScaleFps returns zero when time is frozen or the game is halted, returns TheNetwork->getFrameRate() in multiplayer, returns the chosen logic-time-scale FPS when that mode is enabled, and otherwise reports the uncapped path that mimics original behavior. getActualLogicTimeScaleOverFpsRatio then turns that into a normalized step size against the current render FPS, and helpers such as getLogicTimeStepMilliseconds expose it to systems like camera motion and water animation.

GameEngine consumes those values through an accumulator. canUpdateRegularGameLogic in GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp is the simple case. If fast mode is active or logic-time scaling is disabled, the engine updates logic immediately just like the original game. If scaling is enabled, it adds the smaller of render delta and target-frame time into m_logicTimeAccumulator, advances logic only when the accumulator crosses one simulation step, and leaves the remainder behind for the next frame.

That leftover time is what powers interpolation. getInterpolationFraction divides the accumulator by the target logic-frame time and clamps the result into [0, 1]. Drawables can then lerp between previous and current transforms instead of jumping only on simulation boundaries.

The multiplayer path uses the same idea for a different reason. canUpdateNetworkGameLogic does not decide tick timing itself; the network layer is still authoritative about when lockstep data is ready. But the function still grows m_logicTimeAccumulator while the client is waiting for the next network frame:

if (TheNetwork->isFrameDataReady())
{
    TheFramePacer->setGameHalted(false);
    m_logicTimeAccumulator -= targetFrameTime;
    if (m_logicTimeAccumulator < 0.0f) m_logicTimeAccumulator = 0.0f;
    return true;  // advance logic one tick
}

// Network not ready — accumulate so the next render uses a larger
// interpolation fraction. Cap at one step; beyond that we'd be
// extrapolating future state we don't have.
m_logicTimeAccumulator += min(TheFramePacer->getUpdateTime(), targetFrameTime);
if (m_logicTimeAccumulator > targetFrameTime)
    m_logicTimeAccumulator = targetFrameTime;
return false;

Rendering continues to interpolate smoothly between the previous and current logic snapshots instead of freezing visually until the packet arrives. When frame data finally becomes ready, the accumulator is reduced by one step and logic advances once. The cap at targetFrameTime matters: without it, a long stall would push the interpolation fraction past 1 and the renderer would extrapolate future state that hasn't been simulated yet.

The chosen target rate is set during game startup. GameLogic.cpp keeps campaign and challenge at the original 30 Hz, but skirmish and multiplayer now honor the host or user pick from GameInfo, falling back to the historical 70 Hz skirmish behavior only when the configured value is absent or invalid. That same code enables logic time scaling only when the final rate differs from the classic 30 Hz baseline.

Multiplayer has one extra handoff. LANAPICallbacks.cpp pushes the host-selected game FPS into TheNetwork during game start. Without that step, the network layer would keep its 30 Hz default even if the rest of the engine thought the session was running faster, which would break lockstep pacing and any code that queries the network-owned simulation rate.

Why It Matters

This is the timing layer that makes higher skirmish rates, host-selected multiplayer speeds, and smooth interpolation coexist. Without it, every attempt to raise simulation FPS either gets pinned by the render limiter or produces visibly choppy movement between lockstep ticks.

Quirks

  • One logic tick per render iteration is a hard rule. Render caps and simulation caps are coupled unless the frame-pacer floor is enforced. Bypassing the floor "to limit CPU" silently caps the simulation to whatever render ran at — a subtle way to slow down MP matches.
  • Multiplayer interpolation is accumulator-driven even while no logic update happens. That is intentional, not a bug. Removing the accumulator grow when !isFrameDataReady produces a choppy-looking network stall even though the underlying simulation is fine.
  • Campaign and challenge stay at 30 Hz on purpose. Mission scripting and balance assume the original cadence — a lot of scripted timers, recoil values, and cooldown frame counts are tuned against 30 Hz exactly. Skirmish/MP honor host GFPS because balance in those modes is emergent rather than authored.
  • Systems that query logic-scaled time inherit freeze and halt behavior through FramePacer flags. Camera motion and water animation call getLogicTimeStepMilliseconds with flags that determine whether frozen-time or halted-game state zeroes the result. A new system needs to pick the right flag, or it will animate during cutscenes (frozen-time) or after the game has halted.
  • scaleFrames treated the 1,000,000 "uncapped" sentinel as a real FPS. Fixed in get30HzFrameScale and get30HzPerFrameScale — the previous bug multiplied all INI frame counts by ~33,000× when fps was uncapped. See bug_scaleframes_uncapped_sentinel.md.
  • Render FPS cap in MP also caps logic. Always pins logic down to the render ceiling unless the floor kicks in. See bug_mp_render_cap_locks_logic.md — a specific reproducible bug that the floor in getActualFramesPerSecondLimit now prevents.
  • LANAPICallbacks.cpp is the handoff point for host-selected MP FPS. Missing that call leaves TheNetwork at 30 Hz default even when the rest of the engine believes the session is running faster. If MP pacing feels wrong on a specific host, this is the first thing to verify.