Skip to content

Commit cbbd502

Browse files
committed
Release v4.4: raw mouse & docs
1 parent 7f48ae4 commit cbbd502

12 files changed

Lines changed: 417 additions & 347 deletions

File tree

.cursor/skills/new-release/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: new-release
3-
description: Bumps SoF Buddy version, updates VERSION and hdr/version.h, then git add/commit/push to trigger a GitHub Actions release. Use when creating a new release, cutting a build, or when the user asks to release, bump version, or push a new build. Agent must study commit history and changes to carefully create a changelog for the release.
3+
description: Bumps SoF Buddy version, updates VERSION and hdr/version.h, then git add/commit/push to trigger a GitHub Actions release. Use when creating a new release, cutting a build, or when the user asks to release, bump version, or push a new build. Agent must study commit history and changes to carefully create a changelog for the release. Do not skip over anything, if there is change in any feature, it must be documented in the changelog and change. This command is useful eg. `git diff v4.3-build143 --stat`
44
---
55

66
# New Release

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v4.4
4+
5+
- Raw mouse: reworked raw input pipeline to buffer and drain high-Hz `WM_INPUT` via `GetRawInputBuffer`, skip zero-delta packets, and keep the game's original mouse cvars/flow intact while sourcing movement from hardware deltas instead of OS cursor position.
6+
- Raw mouse: hardened window/foreground and clip handling (no cursor warping on clip refresh, proper unregister on failure/teardown, disabled-path hooks now pure passthrough with no side effects when `_sofbuddy_rawmouse` is 0).
7+
- Raw mouse docs: expanded feature README with callback/override details, disabled-path behavior, jitter/high-polling guidance, and configuration notes so users understand how and when to enable it.
8+
- Docs: README now surfaces a prominent “#1 thing you need to know” section and in-game command docs that emphasize `F12` / `sofbuddy_menu sof_buddy` as the primary entry point into SoF Buddy.
9+
310
## v4.3
411

512
- Docs: add http.png hero image to README.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4.3
1+
4.4

hdr/version.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
Increment version using: ./increment_version.sh
88
*/
99

10-
#define SOFBUDDY_VERSION "4.3"
10+
#define SOFBUDDY_VERSION "4.4"

src/features/raw_mouse/README.md

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,86 @@
11
# Raw Mouse Input
22

33
## Purpose
4-
Implements raw mouse input support for Soldier of Fortune using Windows Raw Input API instead of the default GetCursorPos/SetCursorPos cursor management. Provides smoother, more direct mouse response without Windows acceleration/smoothing while preserving all original SoF mouse cvars.
4+
Implements raw mouse input support for Soldier of Fortune using the Windows Raw Input API instead of the default GetCursorPos/SetCursorPos cursor management. Provides smoother, more direct mouse response without Windows acceleration/smoothing while preserving all original SoF mouse cvars.
55

66
## Callbacks
77
- **EarlyStartup** (Post, Priority: 70)
8-
- `raw_mouse_EarlyStartup()` - Patches SetCursorPos calls in exe to install hooks
8+
- `raw_mouse_EarlyStartup()` - Patches three `SetCursorPos` call sites in the exe (IN_Frame, IN_MouseMove, IN_MenuMouse) to redirect through `hkSetCursorPos`, and resolves the real `SetCursorPos` via `GetProcAddress` for passthrough use
99
- **RefDllLoaded** (Post, Priority: 70)
10-
- `raw_mouse_RefDllLoaded()` - Registers raw input device with Windows API
10+
- `raw_mouse_RefDllLoaded()` - Creates the `_sofbuddy_rawmouse` cvar with a change callback; if the archived value is non-zero, immediately registers raw input
1111

1212
## Hooks
1313
- **IN_MouseMove** (Post, Priority: 100)
14-
- `in_mousemove_callback()` - Resets delta accumulators after each frame
14+
- `in_mousemove_callback()` - Polls buffered raw input to ensure minimum latency, then resets `raw_mouse_delta_x/y` to 0 after the game has consumed the frame's mouse input.
1515
- **IN_MenuMouse** (Post, Priority: 100)
16-
- `in_menumouse_callback()` - Consumes accumulated deltas after menu mouse handling (same as IN_MouseMove)
16+
- `in_menumouse_callback()` - Same as IN_MouseMove; polls and resets deltas.
1717

1818
## OverrideHooks
1919
- **GetCursorPos** (user32.dll)
20-
- `getcursorpos_override_callback()` - Returns virtual cursor position `(window_center + raw_mouse_delta_x/y)` when raw input enabled; never calls real GetCursorPos for look/menu
20+
- `getcursorpos_override_callback()` - When enabled, returns virtual cursor position `(window_center + delta_x/y)` instead of the real OS cursor position. On the first call (or after a reset), seeds `window_center` from the real cursor position via one call to the original `GetCursorPos`. When disabled, passes through to the original.
2121
- **DispatchMessageA** (user32.dll)
22-
- `dispatchmessagea_override_callback()` - On WM_INPUT, extracts relative raw deltas (ignores absolute) and accumulates via `raw_mouse_accumulate_delta()`
22+
- `dispatchmessagea_override_callback()` - When enabled: intercepts `WM_INPUT` messages, processes the current packet, and immediately polls `GetRawInputBuffer` to drain any queued input. This handles high-polling-rate mice efficiently. Also handles registration/clipping on window events.
2323

2424
## CustomDetours
25-
- **SetCursorPos** (user32.dll, via GetProcAddress)
26-
- `hkSetCursorPos()` - When raw input enabled: updates internal `window_center`, returns TRUE without calling the real SetCursorPos (OS cursor is never warped). Cursor clip is refreshed by DispatchMessageA on focus/move/size events and during registration.
27-
- Patched at exe addresses: `0x2004A0B2`, `0x2004A410`, `0x2004A579`
25+
- **SetCursorPos** (user32.dll, via binary patch + GetProcAddress)
26+
- `hkSetCursorPos()` - When enabled: updates internal `window_center` to the coordinates the game wants to recenter to, and returns TRUE **without** calling the real `SetCursorPos` (the OS cursor is never warped by the game). When disabled: calls the real `SetCursorPos` normally.
27+
- Patched at exe addresses: `0x2004A0B2` (IN_Frame), `0x2004A410` (IN_MouseMove), `0x2004A579` (IN_MenuMouse)
2828

2929
## Technical Details
3030

3131
### How It Works
32-
The implementation works by **faking cursor position changes** so the original mouse processing code continues to work with all its cvars intact:
33-
34-
1. **Registration** (RefDllLoaded callback):
35-
- Registers for raw mouse input using `RegisterRawInputDevices()`
36-
- Sets `hwndTarget` to the active window handle
37-
38-
2. **Message Processing** (DispatchMessageA hook):
39-
- Intercepts WM_INPUT messages
40-
- Calls `ProcessRawInput()` to extract mouse delta from RAWINPUT structure
41-
- Ignores absolute mouse packets and accumulates only relative raw deltas
42-
43-
3. **Virtual Cursor Position** (GetCursorPos hook):
44-
- Returns `(window_center.x + raw_mouse_delta_x, window_center.y + raw_mouse_delta_y)` so the game sees movement (e.g. swipe left → negative delta_x → position left of center)
45-
- Game logic unchanged: it reads “cursor” position and uses it for look/menu; we never call the real GetCursorPos for that path
46-
47-
4. **No Cursor Warping** (SetCursorPos hook):
48-
- Game’s recenter calls are intercepted: we update `window_center` and return TRUE without calling the real SetCursorPos, so the OS cursor is never moved by the game
49-
50-
5. **Cursor Confinement** (ClipCursor):
51-
- While raw input is enabled and the game window is foregrounded, cursor is clipped to the game client area inset by 64px on all edges to reduce edge-of-screen issues
52-
- On each clip refresh (e.g. after alt-tab), if the OS cursor is outside the clip rect it is warped back inside via the real SetCursorPos so the cursor is always within bounds when regaining focus
53-
- Clip is automatically released when raw input is disabled or focus is lost
54-
55-
6. **Delta Consumption** (IN_MouseMove / IN_MenuMouse callbacks):
56-
- After the game processes the frame, `raw_mouse_consume_deltas()` resets `raw_mouse_delta_x/y` to 0, so the virtual cursor is effectively back at window_center for the next frame
32+
The game's original mouse pipeline is: `SetCursorPos` (center cursor) → game logic → `GetCursorPos` (read cursor) → compute delta → apply to view. This feature intercepts that pipeline so the game's own mouse code continues to work unchanged, but the underlying input comes from raw hardware deltas instead of OS cursor position:
33+
34+
1. **CVar Creation** (RefDllLoaded callback):
35+
- `create_raw_mouse_cvars()` registers `_sofbuddy_rawmouse` with a change callback (`raw_mouse_on_change`)
36+
- If the archived value is already non-zero (user previously enabled it), the change callback fires immediately
37+
38+
2. **Registration** (cvar change callback):
39+
- When the cvar is set to non-zero, `raw_mouse_on_change()` calls `raw_mouse_register_input()`
40+
- This registers for raw mouse input using `RegisterRawInputDevices()` with `hwndTarget` set to the active window
41+
- Deltas are reset and `window_center` is invalidated so the next `GetCursorPos` call seeds it fresh
42+
43+
3. **Message Processing** (DispatchMessageA hook, only when enabled):
44+
- Intercepts `WM_INPUT` messages
45+
- Processes the current message's data immediately via `GetRawInputData`.
46+
- Calls `GetRawInputBuffer` to drain any remaining input events in the queue in one batch. This prevents the message queue from being flooded by high-Hz mice (e.g. 1000Hz-8000Hz).
47+
- Absolute mouse packets are ignored; only relative deltas are accumulated via `raw_mouse_accumulate_delta()`
48+
- On window focus/move/size events, ensures registration is current and refreshes the cursor clip rect
49+
50+
4. **Virtual Cursor Position** (GetCursorPos hook):
51+
- Returns `(window_center.x + raw_mouse_delta_x, window_center.y + raw_mouse_delta_y)`
52+
- The game sees this as a normal cursor position offset from center, so its existing delta calculation works unchanged
53+
- On the very first call (or after a toggle), `window_center` is seeded from the real OS cursor position via one call to the original `GetCursorPos`
54+
55+
5. **No Cursor Warping** (SetCursorPos hook):
56+
- The game calls `SetCursorPos` every frame to recenter the cursor
57+
- The hook intercepts this: it records the coordinates as `window_center` and returns TRUE, but **does not call the real SetCursorPos**
58+
- This prevents the OS cursor from being physically moved, which would otherwise generate synthetic mouse events that conflict with raw input
59+
60+
6. **Cursor Confinement** (ClipCursor):
61+
- While raw input is enabled and the game window is foregrounded, the OS cursor is clipped to the game client area inset by 64px on all sides to prevent edge-of-screen issues
62+
- Clip is automatically released when raw input is disabled, focus is lost, or the window cannot be resolved
63+
- Clip refresh is only performed when raw mouse is enabled — when disabled, the DispatchMessageA hook has zero side effects
64+
65+
7. **Delta Consumption** (IN_MouseMove / IN_MenuMouse post-callbacks):
66+
- After the game processes the frame's mouse input, `raw_mouse_consume_deltas()` resets `raw_mouse_delta_x/y` to 0
67+
- This means the next `GetCursorPos` call returns exactly `window_center`, making the game see zero delta until new raw input arrives
68+
69+
### Disabled Path (Feature Compiled In, CVar Off)
70+
When `_sofbuddy_rawmouse` is 0 (the default), all hooks are pure passthroughs:
71+
- **GetCursorPos** → calls original
72+
- **SetCursorPos** → calls original `oSetCursorPos`
73+
- **DispatchMessageA** → calls original with no pre-processing
74+
- **IN_MouseMove / IN_MenuMouse** → returns immediately
75+
76+
No cursor clipping, delta accumulation, or registration occurs. The game behaves identically to an unhooked state.
77+
78+
### Jitter & high polling rate
79+
- **No cursor warp on clip refresh.** We do not call `SetCursorPos` to snap the OS cursor back inside the clip rect when refreshing. Warping generates synthetic mouse events that mix with raw deltas and cause spikey/jittery movement. Only `ClipCursor` is applied.
80+
- **Zero-delta packets skipped.** `ProcessRawInput` ignores `WM_INPUT` with `lLastX == 0 && lLastY == 0` so spurious (0,0) reports don’t add noise.
81+
- **Buffered Input.** Uses `GetRawInputBuffer` in both the message loop (draining the queue) and the frame loop (fetching latest data). This minimizes overhead for 1000Hz+ mice and ensures the lowest possible input latency.
82+
- **Legacy input left enabled.** We register with `dwFlags = 0` (no `RIDEV_NOLEGACY`) so window dragging and other system mouse behavior keep working.
83+
- **Windows 11.** KB5028185 and 24H2 improve behavior for high-polling-rate mice (throttling/coalescing for background listeners, USB polling optimizations). No code change required; up-to-date Win11 can help on high-Hz hardware.
5784

5885
### Preserved CVars
5986
All original SoF mouse cvars remain functional:
@@ -70,6 +97,7 @@ All original SoF mouse cvars remain functional:
7097
- **_sofbuddy_rawmouse** (default: 0, archived)
7198
- Set to 1 to enable raw mouse input
7299
- Set to 0 to use legacy cursor-based input
100+
- Changes take effect immediately via the cvar change callback
73101

74102
## Benefits
75103
- Smoother, more direct mouse response (no Windows acceleration/smoothing)

src/features/raw_mouse/hooks/dispatchmessagea.cpp

Lines changed: 82 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,131 +2,128 @@
22

33
#if FEATURE_RAW_MOUSE
44

5+
#include "../shared.h"
6+
#include "generated_detours.h"
57
#include "sof_compat.h"
68
#include "util.h"
7-
#include "generated_detours.h"
8-
#include "../shared.h"
9+
#include <vector>
910

1011
using detour_DispatchMessageA::tDispatchMessageA;
1112

1213
#if SOFBUDDY_RAWINPUT_API_AVAILABLE
13-
static bool ReadRawInputBuffer(HRAWINPUT input_handle, BYTE* buffer, UINT buffer_size)
14-
{
15-
UINT size = buffer_size;
16-
UINT copied = GetRawInputData(input_handle, RID_INPUT, buffer, &size, sizeof(RAWINPUTHEADER));
17-
if (copied == static_cast<UINT>(-1)) {
18-
return false;
19-
}
20-
return copied == size && size >= sizeof(RAWINPUTHEADER);
21-
}
14+
static std::vector<BYTE> g_dispatchBuffer;
2215

23-
static void ProcessRawInput(LPARAM lParam)
24-
{
16+
static bool ReadRawInputBufferInternal(HRAWINPUT input_handle, std::vector<BYTE>& buffer) {
2517
UINT dwSize = 0;
26-
if (GetRawInputData(reinterpret_cast<HRAWINPUT>(lParam), RID_INPUT, NULL, &dwSize, sizeof(RAWINPUTHEADER)) == static_cast<UINT>(-1)) {
27-
return;
18+
if (GetRawInputData(input_handle, RID_INPUT, NULL, &dwSize, sizeof(RAWINPUTHEADER)) == (UINT)-1) {
19+
return false;
2820
}
21+
2922
if (dwSize < sizeof(RAWINPUTHEADER)) {
30-
return;
23+
return false;
3124
}
3225

33-
BYTE stackBuffer[256];
34-
RAWINPUT* raw = nullptr;
35-
36-
if (dwSize <= sizeof(stackBuffer)) {
37-
if (!ReadRawInputBuffer(reinterpret_cast<HRAWINPUT>(lParam), stackBuffer, dwSize)) {
38-
return;
39-
}
40-
raw = reinterpret_cast<RAWINPUT*>(stackBuffer);
41-
} else {
42-
g_heapBuffer.resize(dwSize);
43-
if (!ReadRawInputBuffer(reinterpret_cast<HRAWINPUT>(lParam), g_heapBuffer.data(), dwSize)) {
44-
return;
45-
}
46-
raw = reinterpret_cast<RAWINPUT*>(g_heapBuffer.data());
26+
if (buffer.size() < dwSize) {
27+
buffer.resize(dwSize);
4728
}
4829

49-
if (!raw || raw->header.dwType != RIM_TYPEMOUSE) {
50-
return;
30+
if (GetRawInputData(input_handle, RID_INPUT, buffer.data(), &dwSize, sizeof(RAWINPUTHEADER)) != dwSize) {
31+
return false;
5132
}
33+
34+
return true;
35+
}
5236

53-
const RAWMOUSE& mouse = raw->data.mouse;
54-
if ((mouse.usFlags & MOUSE_MOVE_ABSOLUTE) != 0) {
37+
static void ProcessRawInputMessage(LPARAM lParam) {
38+
// Process the specific message that triggered WM_INPUT
39+
if (!ReadRawInputBufferInternal(reinterpret_cast<HRAWINPUT>(lParam), g_dispatchBuffer)) {
5540
return;
5641
}
5742

58-
if (mouse.lLastX == 0 && mouse.lLastY == 0) {
43+
RAWINPUT* raw = reinterpret_cast<RAWINPUT*>(g_dispatchBuffer.data());
44+
if (raw->header.dwType != RIM_TYPEMOUSE) {
5945
return;
6046
}
6147

62-
raw_mouse_accumulate_delta(mouse.lLastX, mouse.lLastY);
63-
}
64-
#else
65-
static void ProcessRawInput(LPARAM lParam)
66-
{
67-
(void)lParam;
68-
}
69-
#endif
70-
71-
static void EnsureRawMouseRegistered(HWND hwnd_hint)
72-
{
73-
if (!raw_mouse_is_enabled() || !raw_mouse_api_supported()) {
48+
const RAWMOUSE &mouse = raw->data.mouse;
49+
if ((mouse.usFlags & MOUSE_MOVE_ABSOLUTE) != 0) {
7450
return;
7551
}
76-
77-
HWND hwnd = raw_mouse_hwnd_target;
78-
if (hwnd && !IsWindow(hwnd)) {
79-
raw_mouse_registered = false;
80-
raw_mouse_hwnd_target = nullptr;
81-
hwnd = nullptr;
52+
53+
if (mouse.lLastX != 0 || mouse.lLastY != 0) {
54+
raw_mouse_accumulate_delta(mouse.lLastX, mouse.lLastY);
8255
}
8356

84-
if (!hwnd) hwnd = hwnd_hint;
85-
if (!hwnd) hwnd = GetActiveWindow();
86-
if (!hwnd) hwnd = GetForegroundWindow();
87-
if (!hwnd) {
88-
return;
89-
}
57+
// Now drain any other pending input to prevent queue buildup and latency
58+
raw_mouse_poll();
59+
}
60+
#else
61+
static void ProcessRawInputMessage(LPARAM lParam) { (void)lParam; }
62+
#endif
9063

91-
if (!raw_mouse_registered || raw_mouse_hwnd_target != hwnd) {
92-
raw_mouse_register_input(hwnd, false);
93-
}
64+
static void EnsureRawMouseRegistered(HWND hwnd_hint) {
65+
if (!raw_mouse_is_enabled() || !raw_mouse_api_supported()) {
66+
return;
67+
}
68+
69+
HWND hwnd = raw_mouse_hwnd_target;
70+
if (hwnd && !IsWindow(hwnd)) {
71+
raw_mouse_registered = false;
72+
raw_mouse_hwnd_target = nullptr;
73+
hwnd = nullptr;
74+
}
75+
76+
if (!hwnd)
77+
hwnd = hwnd_hint;
78+
if (!hwnd)
79+
hwnd = GetActiveWindow();
80+
if (!hwnd)
81+
hwnd = GetForegroundWindow();
82+
if (!hwnd) {
83+
return;
84+
}
85+
86+
if (!raw_mouse_registered || raw_mouse_hwnd_target != hwnd) {
87+
raw_mouse_register_input(hwnd, false);
88+
}
9489
}
9590

9691
static bool msg_affects_cursor_clip(UINT msg) {
97-
switch (msg) {
98-
case WM_SIZE:
99-
case WM_MOVE:
100-
case WM_ACTIVATE:
92+
switch (msg) {
93+
case WM_SIZE:
94+
case WM_MOVE:
95+
case WM_ACTIVATE:
10196
#ifndef WM_ENTERSIZEMOVE
10297
#define WM_ENTERSIZEMOVE 0x0231
10398
#endif
10499
#ifndef WM_EXITSIZEMOVE
105-
#define WM_EXITSIZEMOVE 0x0232
100+
#define WM_EXITSIZEMOVE 0x0232
106101
#endif
107-
case WM_ENTERSIZEMOVE:
108-
case WM_EXITSIZEMOVE:
102+
case WM_ENTERSIZEMOVE:
103+
case WM_EXITSIZEMOVE:
109104
#ifndef WM_DISPLAYCHANGE
110105
#define WM_DISPLAYCHANGE 0x007E
111106
#endif
112-
case WM_DISPLAYCHANGE:
113-
return true;
114-
default:
115-
return false;
116-
}
107+
case WM_DISPLAYCHANGE:
108+
return true;
109+
default:
110+
return false;
111+
}
117112
}
118113

119-
LRESULT dispatchmessagea_override_callback(const MSG* msg, detour_DispatchMessageA::tDispatchMessageA original) {
120-
if (msg && raw_mouse_is_enabled() && raw_mouse_api_supported()) {
121-
if (msg_affects_cursor_clip(msg->message))
122-
EnsureRawMouseRegistered(msg->hwnd);
123-
if (msg->message == WM_INPUT)
124-
ProcessRawInput(msg->lParam);
114+
LRESULT dispatchmessagea_override_callback(
115+
const MSG *msg, detour_DispatchMessageA::tDispatchMessageA original) {
116+
if (msg && raw_mouse_is_enabled() && raw_mouse_api_supported()) {
117+
if (msg_affects_cursor_clip(msg->message)) {
118+
EnsureRawMouseRegistered(msg->hwnd);
119+
raw_mouse_refresh_cursor_clip(msg->hwnd);
120+
}
121+
if (msg->message == WM_INPUT) {
122+
ProcessRawInputMessage(msg->lParam);
125123
}
126-
if (msg && msg_affects_cursor_clip(msg->message))
127-
raw_mouse_refresh_cursor_clip(msg->hwnd);
128-
SOFBUDDY_ASSERT(original != nullptr);
129-
return original(msg);
124+
}
125+
SOFBUDDY_ASSERT(original != nullptr);
126+
return original(msg);
130127
}
131128

132-
#endif // FEATURE_RAW_MOUSE
129+
#endif // FEATURE_RAW_MOUSE

0 commit comments

Comments
 (0)