VexFlint
-
Posts
2 -
Joined
-
Last visited
Content Type
Profiles
Forums
Blogs
Forum & Tracker Requests
Downloads
Gallery
Store
Events
Files posted by VexFlint
-
KOTOR_FPS_CAP
# KOTOR 1 has a built-in frame limiter — BioWare shipped it switched off
**TL;DR:** *Star Wars: Knights of the Old Republic* (2003, Odyssey engine) contains a
complete busy-wait frame limiter in its main loop. It is controlled by a single
`float` in the executable that defaults to `0.0`, which leaves the limiter
dormant, so the game free-runs at uncapped framerate. On high-refresh displays
this uncapped rate is what causes the long-standing **movement / "stuck after
combat turn" freeze** and related timing bugs, which the community has only ever
worked around with external FPS caps or by dropping the monitor to 60 Hz.
Writing `60.0` (or any target) to that one float **arms the engine's own
limiter** — a native, self-contained fix. No injected code, no wrappers, no
driver settings. It's a 4-byte data patch.
Verified: on a 144 Hz display with all external caps removed, the patched exe
holds a hard 60 FPS and the freeze does not occur.
---
## The bug
KOTOR's main loop (`WinMain`, `FUN_004041f0` in a Ghidra auto-analysis of the
standard PC exe) is a classic `PeekMessage` real-time loop:
```
while (quit_flag == 0) {
read timer (game clock)
if no pending message:
render scene (delta-time based)
SwapBuffers (present)
... optional limiter here ...
}
```
Gameplay logic — movement, and the combat round/turn state machine — advances by
frame **delta-time**. With no frame cap, delta-times on a fast display become
tiny and irregular, and the 2003-era state machine mis-steps: the controlled
character's action queue stalls until something rebuilds it (switching party
member, or a save/load clears it). That is the freeze players have reported for
20 years.
## The dormant limiter
Immediately after `SwapBuffers`, the loop contains this (Ghidra decompilation,
lightly annotated):
```c
if (DAT_007a3c58 != 0) // a separate, also-off coarse limiter
Sleep(DAT_0078d1e8);
if (0.0f < cap) { // cap = _DAT_007a3c64 (the frame-cap float)
target = 1000.0f / cap; // target frame time
// measure elapsed since frame start, then:
if (elapsed < target) {
do {
// busy-spin: re-read the game timer
} while (elapsed < target); // hold the frame until target reached
}
}
```
With real constant values read from the binary:
| Symbol | RVA | Value | Meaning |
|-------------------|-----------|----------|----------------------------------|
| `_DAT_007a3c64` | `0x7a3c64` | **0.0** | frame cap (FPS). **This is it.** |
| numerator const | `0x73d6fc` | 1000.0 | `target_ms = 1000.0 / cap` |
| zero const | `0x73d700` | 0.0 | used in the `0.0 < cap` gate |
| timer scale const | `0x73d708` | 0.001 | raw timer -> milliseconds |
The gate is literally **"if cap > 0, limit the frame."** With `cap = 60`, the
loop busy-waits until each frame has taken `1000/60 ≈ 16.67 ms`, i.e. it caps at
60 FPS. This is the *same* formula the engine uses elsewhere — the movie-playback
path caps to 30 with it — so the mechanism is proven, not speculative.
## Why it's off
`_DAT_007a3c64` starts at `0.0`. The **only** code that writes it is a
render-setup function (`FUN_004c5880`), and that write is guarded:
```c
if (DAT_00832904 != 0) {
...
_DAT_007a3c64 = (float)DAT_00832904; // arm the cap
}
```
`DAT_00832904` lives in **BSS** — zero-initialized, and it has **no writer
anywhere in the binary** (it's a config variable exposed by address, never set
by default). So the guard is never true, the cap value is never assigned, and
`_DAT_007a3c64` stays `0.0` for the entire session. The limiter is complete,
correct, and permanently asleep.
Because nothing ever writes the cap during normal play, **patching its initial
`.data` value is safe and permanent** — no code path overwrites it.
## The patch
Standard PC v1.03 / GOG / "editable" executable:
```
file offset 0x3A3C64 : 00 00 00 00 (float 0.0, limiter off)
-> 00 00 70 42 (float 60.0, 60 FPS cap)
```
Other caps if you prefer: `30.0` = `00 00 F0 41`, `72.0` = `00 00 90 42`,
`120.0` = `00 00 F0 42`.
A Python patcher (`kotor_fpscap_patch.py`) is included; it fingerprints the two
`.rdata` constants before writing, backs up the exe, and refuses to touch an
executable whose layout it doesn't recognize:
```
python kotor_fpscap_patch.py "path\to\swkotor.exe" 60
```
## Verification
- 144 Hz display, all external FPS caps and driver "wait for vertical refresh"
overrides removed.
- Patched exe launched directly.
- Framerate held a hard **60**; the movement/turn freeze did not reproduce on
transitions that previously triggered it.
## Caveats
- **Busy-wait, by design.** The limiter spins a CPU core while pacing each frame
(this is BioWare's own implementation — the movie path spins the same way). On
a modern machine, one pegged core for a single-threaded 2003 game is harmless,
but it is not a `Sleep`-based idle cap. A future refinement could redirect the
gate to the engine's own `Sleep(DAT_0078d1e8)` limiter instead.
- **Offset is per-exe-layout.** `0x3A3C64` is verified for the standard PC exe.
Other builds (Steam-with-different-packing, 4-CD, Mac) may differ; find
`_DAT_007a3c64` by locating the `1000.0 / cap` main-loop math.
- **Steam "Verify integrity" reverts it** (restores the stock exe). Re-apply
after any verify.
- Stacks cleanly with the 4GB LARGE_ADDRESS_AWARE patch, UniWS widescreen, and
the community mod builds — it only touches one unrelated float.
## Method
Static reverse engineering only (Ghidra). Chain: locate timing-API imports
(`QueryPerformanceCounter`, `GetTickCount`, `Sleep`) → their callers → the
`PeekMessage` message pump → its caller (`WinMain`) → the frame-limiter block in
the loop → the cap float and its dormant guard. Full decompiled C of the engine
was exported and searched locally to trace the call chain.
---
*Reverse-engineered from a legally-owned copy for interoperability and
bug-fixing. Not affiliated with BioWare/LucasArts/Aspyr.*
4 downloads
(0 reviews)0 comments
Submitted