Jump to content

VexFlint

Registered
  • Posts

    2
  • Joined

  • Last visited

Files posted by VexFlint

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

    6 downloads

       (0 reviews)

    0 comments

    Submitted

×
×
  • Create New...

Important Information

By using this site, you agree to our Guidelines.