Jump to content

Recommended Posts

Posted

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.*


  • Submitter
  • Submitted
    07/21/2026
  • Category
  • K1R Compatible
    Yes

 

  • Like 1

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...

Important Information

By using this site, you agree to our Guidelines.