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

 

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.