More than 25 frames per second
25 fps wasn’t a display setting in the MU Online client, it was the clock. How I made it run faster in 2023 - and what a contributor found three years later about where the factor doesn’t belong.
The MU Online client was never made to run fast. It was made for 25 frames per second, and that wasn't about the monitor: 25 fps was the unit of time of the whole game. Every animation step, every movement, every rotation and every effect assumed that one frame is one fixed slice of time. The constant is still in the code:
constexpr double REFERENCE_FPS = 25.0;
If you just remove the frame limit, the game doesn't get smoother - it gets faster. Characters run too fast, animations race, effects are over before you see them.
One factor
The idea of the fix is simple. Instead of asking "how many frames have passed", every calculation asks "how much of a 25 fps frame has passed":
const double fpsRatio = (FPS <= 0.0) ? 0.0 : REFERENCE_FPS / FPS;
FPS_ANIMATION_FACTOR = std::clamp((float)fpsRatio, 0.f, 1.f);
At 25 fps the factor is 1, at 100 fps it's 0.25. Every per-frame change is multiplied by it, so the world moves at the same speed no matter how often it's drawn. The animation code, for example:
*AnimationFrame += Speed * FPS_ANIMATION_FACTOR;
Below 25 fps the factor stays at 1, so the game slows down instead of taking steps which are too big.
A week of regex
I did this in 2023, years before I used any AI agent on this code. Every place which changed something per frame had to be found and multiplied by the factor: movement, rotations, animation counters, particle velocities, fades, timers. I used regular expressions to find the candidates and to replace most of them. That mechanical part took about a week.
Today, FPS_ANIMATION_FACTOR is used about 3,050 times in 67 files.
The effects
The hard part were the effects. An effect in this client is often a chain: a skill creates an effect, which creates more effects while it lives, some attach to a bone of the model, some create particles. And all of them take positions and angles from whatever created them. Finding out where an effect comes from, and which of its values are per-frame changes, was the part no regex could do.
That's also why a lot of this work was cleanup. You can't follow effect code which checks if (Skill == 41), so the magic numbers became constants first:
AT_SKILL_TWISTING_SLASH = 41,
Twisting Slash was one of the hardest skills. It has several rotations at once: the character turns, the weapon follows, and the effect leaves a rotating trail behind. Each of these had its own per-frame step, and it took me several attempts to get them to work together. As it turned out later, I didn't fully get it right - more on that below.
More frames, more effects
Then there was a problem I didn't expect. A lot of effects are created randomly every frame, with a certain chance:
if (rand() % 8 == 0) { CreateParticle(BITMAP_SHINY, ...); }
At 25 fps that's about three sparks per second. At 100 fps it's twelve. So every torch, fire and glowing item suddenly created four times as many particles, and each of them has to be moved and drawn. The frame rate I just won was spent on particles again.
So the random checks had to consider the frame rate, too:
bool rand_fps_check(int reference_frames)
{
return Random::FpsCheck(reference_frames, FPS_ANIMATION_FACTOR);
}
rand_fps_check(8) means "once every 8 frames at 25 fps", and that stays the same per second at any frame rate. For effects which were created on every frame, there are CreateEffectFpsChecked and CreateParticleFpsChecked. Together, that's about 1,650 places in the code today.
Fractional frames
Some effects are not random, but happen at an exact frame of an animation:
int iAnimationFrame = (int)o->AnimationFrame;
if (iAnimationFrame == 2 || iAnimationFrame == 9)
{
// sparks at exactly these two frames of the swing
}
That works when the frame counter goes 0, 1, 2, 3. It doesn't work when it goes up by 0.25 per frame: then the counter is at "frame 2" for four rendered frames, and the sparks are created four times. Or the other way around, with a high animation speed, the counter jumps from 1.8 to 2.4 to 3.0, and the effect is never created.
So the animation code remembers the previous whole frame and reports when a frame boundary was actually crossed:
const auto priorAnimationFrame = (int)*AnimationFrame;
*AnimationFrame += Speed * FPS_ANIMATION_FACTOR;
if (priorAnimationFrame != (int)*AnimationFrame)
{
*PriorAction = CurrentAction;
*PriorAnimationFrame = (float)priorAnimationFrame;
}
When the frame rate decided your damage
Not everything was cosmetic. Some skills did less damage on a slow computer.
Evil Spirit is the one I remember. In the original design, the client told the server what the spirits hit, and that was tied to the effect running on the screen. Fewer frames meant fewer hits sent to the server, and less damage.
That's actually two problems. The frame rate dependency is one. The other, bigger one is that the client decided who got hit at all. A client shouldn't be asked that - a modified client can claim to hit as much as it wants.
So this wasn't fixed in the client, but in the OpenMU configuration. The skill is now an area skill where the server calculates the hits:
this.CreateSkill(SkillNumber.EvilSpirit, "Evil Spirit", ..., skillType: SkillType.AreaSkillAutomaticHits);
The hits are delayed by the distance to the target, a target is hit at most twice and not more often than once per second, and the hit chance gets lower with the distance. The client only says "I cast Evil Spirit here", and the frame rate has nothing to do with the damage anymore.
Where the factor doesn't belong
In 2023, I multiplied every step I found by the factor. Three years later, in October 2026, nitoygo went through the whole conversion and found the places where I applied it too much. The rule behind it is simple: the factor only keeps time for values which build up from frame to frame. For everything else, it just makes the value smaller at high frame rates.
Some of the cases he fixed:
- Values which are rebuilt every frame. And here's Twisting Slash again: its blade is drawn 100 units above the character, and the position is restored afterwards. With the factor, the blade was drawn 100 units up at 25 fps, 42 at 60 fps, 17 at 144 fps and 10 at 250 fps - so at high frame rates, it spun close to the ground.
- Values which are set once, when an effect is created. At 144 fps, the jet of the fire trap started 10 units in front of it instead of 60.
- Values which are multiplied every frame, like fades and friction. Multiplying
kby the factor is wrong there; it has to bepow(k, factor). Two mirror signs were even written aspowf(-1, factor), which is NaN above 25 fps. - One-time random picks which went through
rand_fps_check. At 250 fps, some effect variants and hit reactions became rare. - Frame-rate checks nested in other frame-rate checks, so they were scaled twice. Those effects came a tenth as often.
- Integer frame counters. At 250 fps, the wait before you can confirm a trade was 0.6 seconds instead of 6. These counters now step by
REFERENCE_FRAME_STEP, the number of whole 25 fps frames which have passed, with the remainder carried over to the next frame.
Zylkien fixed the same kind of problem in the flocks of birds, bats and fish, which turned too fast at high frame rates.
The rules are now written down in the repository, as an architecture decision record: multiply additive per-frame changes by the factor, use pow for multiplicative ones, use real milliseconds for timers, and never rely on VSync or a frame limit for the game to be correct.
Thank you both - that's the kind of work I'm really glad someone else did.
What it looks like now
The client runs with V-Sync and without an fps limit by default. If V-Sync isn't available, it's limited to 60 fps. The chat commands $fpscounter on and $details on show the frame rate, $fps and $vsync change the limits.
As far as I know, this was the first implementation of a higher frame rate for the MuMain client sources in the MU Online community. Other forks took over the approach quite quickly.
One more thing
All of this was done by hand in 2023, with regular expressions and a lot of patience.
The fixes three years later weren't: They were made with an AI agent. So the part which I thought really needs a human - going through every value and deciding whether the factor belongs there - was done by an agent, with the changed lines checked against the original game's source.
I find that a bit humbling, but mostly good news. This is the kind of work which nobody wanted to do for years, and now it finally gets done.