Bypassing the WPS Presentation FPS Limit on Linux
A record of improving WPS Office PPT slideshow frame rates on Arch Linux by scaling QElapsedTimer and accelerating time with libfaketime.
Currently on Linux, due to the lack of hardware acceleration, animations in WPS Office Presentation are capped at around 24 FPS during slideshows. The effect is especially poor when using smooth transitions. In 2024, the official WPS community said that hardware acceleration on Linux would be improved soon (most likely Q3), but as of 2026, there has still been no further update. Since I use smooth transitions fairly often, and since they are also a decent way to create video animations, I had no choice but to come up with a temporary workaround.
Someone in the community proposed an approach: first, make QElapsedTimer::elapsed() and QElapsedTimer::restart() return smaller time values. This way, WPS still determines animation progress using its original logic, but the passage of time appears slower from its perspective. Then, use libfaketime to speed the process time back up. Combining these two operations makes WPS trigger frames more frequently while keeping the animation speed as close to the original as possible.
Problem Model
If WPS internally schedules slideshow animations at roughly one frame every 40 ms, the actual frame rate is:
1000 ms / 40 ms = 25 FPS
If we can make WPS think that only 20 ms has passed, it will not advance the animation to the next state as quickly. But if we simultaneously speed up process time by 2x, then 20 ms in the real world appears as 40 ms to the application again. This causes the scheduler to wake up more frequently while keeping the animation timeline advancing at roughly its original speed.
The default parameters are:
WPS_FAKE_SPEED=2.0
WPS_QELAPSED_SCALE=0.5
That is:
libfaketimespeeds up time by 2x;QElapsedTimerreturn values are multiplied by 0.5;- this brings the slideshow frame rate close to 48 FPS while maintaining the original animation speed.
If you want to try getting closer to 60 FPS, you can use:
WPS_FAKE_SPEED=2.5
WPS_QELAPSED_SCALE=0.4
Exporting Timer Symbols from WPS’s Bundled Qt
At first, it is easy to assume that intercepting the regular Qt5 symbols would be enough:
QElapsedTimer::elapsed()
QElapsedTimer::restart()
However, the AUR version of WPS does not link against the system Qt libraries. Instead, it ships with its own Qt build:
/usr/lib/office6/libQt5CoreKso.so.5.12.12
After inspecting the symbols, I found that WPS places its Qt classes inside the kso_qt namespace:
kso_qt::QElapsedTimer::elapsed() const@@Qt_5
kso_qt::QElapsedTimer::restart()@@Qt_5
kso_qt::QElapsedTimer::nsecsElapsed() const@@Qt_5
Therefore, the preload library must export these symbols with the kso_qt namespace and the Qt_5 symbol version. Otherwise, WPS will never call our implementation.
The corresponding mangled names are:
_ZNK6kso_qt13QElapsedTimer7elapsedEv
_ZN6kso_qt13QElapsedTimer7restartEv
_ZNK6kso_qt13QElapsedTimer12nsecsElapsedEv
Overriding Direct Timer Calls
LD_PRELOAD can intercept external symbol references during dynamic linking, but WPS’s bundled QtCore also contains many direct internal calls such as:
kso_qt::QUnifiedTimer -> kso_qt::QElapsedTimer::elapsed()
Because these calls occur within the same shared library, they do not necessarily go through the PLT and therefore may not be replaced by LD_PRELOAD.
The PPT animation scheduler is very likely located in QtCore’s QUnifiedTimer. Intercepting only calls to QElapsedTimer from other WPS .so files may make it look like the hook is working, while still having no real effect on animation scheduling.
The final implementation therefore consists of two layers:
- Export
QElapsedTimersymbols with the same names and symbol versions to handle external dynamic symbol references. - After the process starts, apply inline patches to
elapsed(),restart(), andnsecsElapsed()inside WPS QtCore itself, so that direct internal calls also jump to our scaling logic.
The basic inline patching process is:
- locate the original function addresses inside WPS QtCore;
- verify the machine-code prefix at the beginning of each function to avoid applying an incorrect patch after a WPS update;
- generate a trampoline that preserves the original function prefix and jumps back to the remaining part of the original function;
- replace the original function entry with a jump to our hook function;
- have the hook call the trampoline to obtain the original time value, multiply it by the scaling factor, and return the result.
Launch Script
The final launch script does several things:
export WPS_QELAPSED_SCALE=0.5
export FAKETIME="+0 x2.0"
export LD_PRELOAD="/path/to/libwps_qelapsed_scale.so:/usr/lib/faketime/libfaketimeMT.so.1"
exec /usr/lib/office6/wpp "$@"
In actual use, it can be launched like this:
bin/wpp-fps-unlock demo.pptx
You can also set the parameters manually:
WPS_FAKE_SPEED=2.5 bin/wpp-fps-unlock demo.pptx
If the inline patch stops working after a WPS update, you can disable it first for troubleshooting:
WPS_QELAPSED_INLINE=0 bin/wpp-fps-unlock demo.pptx
Verification
There are two probes for verifying that the implementation is actually taking effect.
The first one calls dlopen() on WPS QtCore at runtime and then invokes QElapsedTimer:
make verify-runtime
The results show that a normal 120 ms interval is scaled down to 60 ms:
elapsed_default_ms=120
elapsed_direct_ms=120
elapsed_default_ms=60
elapsed_direct_ms=60
The second probe loads WPS QtCore as a startup dependency, simulating the case where QtCore is already present when the WPS process starts:
make verify-startup-patch
The result likewise shows that even a direct call to elapsed() at QtCore’s own function address is affected by the inline patch:
startup_direct_ms=120
startup_direct_ms=60