Files
m2dev-client-src/docs/high-fps-client-plan.md
server bfe52a81f9
Some checks failed
build / Windows Build (push) Has been cancelled
Add high-FPS render pacing and telemetry
2026-04-16 10:37:08 +02:00

7.8 KiB

High-FPS Client Plan

This document describes how to move the current client from a hard 60 FPS model to a safe high-FPS model without breaking gameplay timing.

Date of analysis: 2026-04-16

Executive Summary

The current client is not limited to 60 FPS by a single config value. It is limited by architecture.

Current hard constraints in the source:

  • src/UserInterface/PythonApplication.cpp
    • constructor switches CTimer into custom time mode
    • main loop advances by a fixed 16/17 ms
    • main loop sleeps until the next tick
  • src/EterBase/Timer.cpp
    • custom mode returns 16 + (m_index & 1) milliseconds per frame
  • src/GameLib/GameType.cpp
    • g_fGameFPS = 60.0f
  • src/GameLib/ActorInstanceMotion.cpp
    • motion frame math depends on g_fGameFPS
  • src/EterLib/GrpDevice.cpp
    • presentation interval is also involved, especially in fullscreen

Because of this, a simple FPS unlock would likely produce one or more of these problems:

  • broken combo timing
  • incorrect animation frame stepping
  • combat desync with the server
  • accelerated or jittery local effects
  • unstable camera or UI timing

The correct design is:

  1. Keep simulation on a fixed tick.
  2. Decouple rendering from simulation.
  3. Add interpolation for render state.
  4. Expose render cap and VSync as separate options.

Current Timing Model

Main loop

In src/UserInterface/PythonApplication.cpp:

  • CTimer::Instance().UseCustomTime() is enabled in the constructor.
  • rkTimer.Advance() advances simulated time.
  • GetElapsedMilliecond() returns a fixed 16/17 ms.
  • s_uiNextFrameTime += uiFrameTime schedules the next frame.
  • Sleep(rest) enforces the cap.

This means the current process loop is effectively built around a fixed 60 Hz clock.

Gameplay coupling

src/GameLib/GameType.cpp defines:

extern float g_fGameFPS = 60.0f;

This value is used by motion and attack frame math in:

  • src/GameLib/ActorInstanceMotion.cpp
  • src/GameLib/RaceMotionData.cpp

The result is that update timing and motion timing are coupled to 60 FPS.

Design Target

Target architecture:

  • simulation update: fixed 60 Hz
  • render: uncapped or capped to monitor / user value
  • interpolation: enabled between fixed simulation states
  • VSync: optional
  • gameplay legality: unchanged from the server point of view

This lets the client render at:

  • 120 FPS
  • 144 FPS
  • 165 FPS
  • 240 FPS
  • uncapped

while still keeping game logic deterministic.

Step 1: Replace the custom fixed timer for frame scheduling

Do not delete all timer code immediately. First isolate responsibilities.

Split timing into:

  • simulation accumulator time
  • render frame delta
  • presentation pacing

Recommended source touchpoints:

  • src/EterBase/Timer.cpp
  • src/EterBase/Timer.h
  • src/UserInterface/PythonApplication.cpp

Preferred clock source:

  • QueryPerformanceCounter / QueryPerformanceFrequency
  • or a StepTimer-style wrapper with high-resolution real time

Step 2: Convert the main loop to fixed update + variable render

Current model:

  • one process pass
  • one fixed pseudo-frame
  • optional sleep

Target model:

read real delta
accumulate delta
while accumulator >= fixedTick:
  run simulation update
  accumulator -= fixedTick
alpha = accumulator / fixedTick
render(alpha)
present
optional sleep for render cap

Recommended fixed tick:

  • 16.666666 ms

Recommended initial render caps:

  • 60
  • 120
  • 144
  • 165
  • 240
  • unlimited

Step 3: Keep gameplay update on fixed 60 Hz

Do not increase gameplay tick in the first rollout.

Keep these systems on the fixed update path:

  • network processing that depends on gameplay state
  • actor motion update
  • attack state transitions
  • player movement simulation
  • effect simulation when tied to gameplay
  • camera state that depends on actor state

This reduces risk dramatically.

Step 4: Render from interpolated state

The renderer must not directly depend on only the last fixed update result if you want smooth motion at high refresh rates.

Add previous and current renderable state for:

  • actor transform
  • mount transform
  • camera transform
  • optionally important effect anchors

At render time:

  • interpolate position
  • slerp or interpolate rotation where needed
  • use alpha from the simulation accumulator

Without this step, high-FPS output will still look like 60 FPS motion with extra duplicate frames.

Step 5: Separate render cap from VSync

The current D3D present settings are not enough on their own.

Recommended behavior:

  • windowed:
    • allow immediate plus optional software frame cap
  • fullscreen:
    • keep VSync configurable
    • do not rely on fullscreen PresentationInterval as the only limiter

Source touchpoint:

  • src/EterLib/GrpDevice.cpp

Step 6: Make SetFPS() real or remove it

app.SetFPS() exists today but only stores m_iFPS. It does not drive the main loop.

Source touchpoints:

  • src/UserInterface/PythonApplication.cpp
  • src/UserInterface/PythonApplication.h
  • src/UserInterface/PythonApplicationModule.cpp

Required action:

  • wire m_iFPS into the render pacing code
  • or rename the API to reflect the real behavior

Step 7: Audit all 60 FPS assumptions

Search and review every system that assumes 60 FPS timing.

Known touchpoints:

  • src/GameLib/GameType.cpp
  • src/GameLib/ActorInstanceMotion.cpp
  • src/GameLib/RaceMotionData.cpp
  • src/AudioLib/MaSoundInstance.h
  • src/AudioLib/SoundEngine.cpp
  • src/AudioLib/Type.cpp

Expected rule:

  • gameplay timing stays fixed at 60 Hz for rollout 1
  • render-only timing becomes variable
  • audio smoothing constants may need review if they are implicitly tied to frame rate instead of elapsed time

Rollout Plan

Phase 1: Mechanical decoupling

  • introduce high-resolution real timer
  • add accumulator-based fixed update loop
  • keep simulation at 60 Hz
  • render once per outer loop

Deliverable:

  • no visible gameplay behavior change at 60 Hz cap

Phase 2: Interpolation

  • store previous and current render states
  • render with interpolation alpha
  • validate player, NPC, mount, and camera smoothness

Deliverable:

  • motion looks smoother above 60 Hz

Phase 3: Render settings

  • implement render_fps_limit
  • implement vsync toggle
  • expose settings to Python and config

Deliverable:

  • user-selectable 120 / 144 / 165 / 240 / unlimited

Phase 4: Validation

Test matrix:

  • 60 Hz monitor, VSync on and off
  • 144 Hz monitor, cap 60 / 144 / unlimited
  • minimized and alt-tab paths
  • crowded city combat
  • boss fight
  • mounted combat
  • loading transitions
  • packet-heavy scenes

Metrics:

  • stable attack cadence
  • no combo timing regression
  • no movement speed regression
  • no animation stalls
  • no camera jitter
  • no CPU runaway when capped

Risks

High risk

  • attack and combo logic tied to frame count
  • animation transitions tied to frame count
  • server-visible timing drift

Medium risk

  • effect systems that assume one render per update
  • audio systems with frame-based smoothing
  • UI code that assumes stable fixed frame cadence

Lower risk

  • D3D present configuration
  • config plumbing for user settings

Minimal Safe First Patch

If the goal is the fastest safe path, the first implementation should do only this:

  1. keep gameplay at fixed 60 Hz
  2. render more often
  3. interpolate actor and camera transforms
  4. expose render cap option

Do not attempt in the first patch:

  • 120 Hz gameplay simulation
  • rewriting all motion logic to time-based math
  • changing server combat timing

Success Criteria

  • client can render above 60 FPS on high refresh displays
  • gameplay remains server-compatible
  • no measurable change in combat legality
  • 60 FPS mode still behaves like the current client

Implement Phase 1 and Phase 2 together in a branch.

That is the smallest meaningful change set that can produce visibly smoother output without committing to a full time-based gameplay rewrite.