Jump to content

VexFlint

Registered
  • Posts

    2
  • Joined

  • Last visited

Recent Profile Visitors

87 profile views

VexFlint's Achievements

Scout

Scout (1/15)

  • Conversation Starter Rare
  • One Month Later Rare
  • One Year In Rare
  • Week One Done Rare

Recent Badges

1

Reputation

  1. View File 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 VexFlint Submitted 07/21/2026 Category Mods K1R Compatible Yes  
  2. VexFlint

    KOTOR_FPS_CAP

    Version 1.0.0

    6 downloads

    # 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.*
×
×
  • Create New...

Important Information

By using this site, you agree to our Guidelines.