Add x87 FPU and MXCSR support to Context32 (MidHook, x86-32). - #130
angelfor3v3r wants to merge 8 commits into
Conversation
Capture full x87 FPU state via FNSAVE (108 bytes: env + ST0-ST7 in
logical stack order) and MXCSR via stmxcsr; FRSTOR/ldmxcsr replay the
captured image verbatim on callback return, so writes to ctx.st[n] or
ctx.mxcsr take effect for the hooked code without disturbing other
slots or the FPU environment.
API:
- Fpu (10-byte 80-bit extended register) with as_f32/as_f64/set_f32/
set_f64. Conversions run through native FPU fld/fstp stubs (no
inline asm; MSVC ABI long double == 8 bytes gives no C++ way in).
- FpuEnv (28-byte packed FNSAVE environment: FCW, FSW, FTW, FOP,
FIP, FDP). Stored opaquely; top() accessor is informational and
may be stale on microarchitectures that reset the env as part of
FNSAVE.
- Context32::st_pop / st_push_f32 / st_push_f64 rotate slot bytes
in the buffer (cf. fstp/fld). Documented caveat: FpuEnv fields
(TOP, FTW) are NOT re-derived after rotation -- the bytes the
hooked code observes through FPU ops are correct, but reading
fpu_env.fsw / fpu_env.ftw afterward shows the pre-rotation state.
Internals:
- src/mid_hook.x86_32.asm: extended to save/restore FNSAVE image +
MXCSR around the destination callback. Destination reloc offset
updated from 0x59 to 0x65 (the new call site).
- src/mid_hook.cpp: regenerated 214-byte x86-32 asm_data array and
fixed the destination relocation to 0x65.
- src/context.cpp (new): four __cdecl asm converter stubs (48 bytes
total: fpu_to_float, float_to_fpu, fpu_to_double, double_to_fpu),
lazily vm_allocate'd as RWX and called through function pointers.
Tests (test/mid_hook.cpp, x86-32 only):
- ReadAndWriteAllStRegisters: round-trip ST0-ST7 via as_f32/as_f64
and set_f32, verified by the function's fstp sequence.
- ReadAndWriteMxcsr: read/flip MXCSR.RC and observe the effect via
cvtss2si(3.5f): truncate->3, round-to-nearest->4.
- StPopAndPushHookMutatesLiveStack: mirrors ThirteenAG's issue cursey#81
fmul/fmulp scenario -- callback pops the top then pushes 42; the
original fstp runs via the trampoline and observes 42.
All 21 tests pass (clang 21 targeting i686-pc-windows-msvc).
Refs: cursey#81
There was a problem hiding this comment.
Pull request overview
Adds x87 FPU register-stack (ST0–ST7) and MXCSR save/restore support to the x86-32 MidHook context, enabling callbacks to read and modify floating-point state and have those changes reflected when execution resumes (addresses #81).
Changes:
- Extend the x86-32 mid-hook stub to save/restore a full FNSAVE image plus MXCSR around the destination callback.
- Add
Fpu/FpuEnv+Context32helpers and converter stubs for 80-bit x87 values. - Add x86-32-only tests validating ST register round-trips, MXCSR mutation, and logical stack mutation via
st_pop/st_push_*.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
test/mid_hook.cpp |
Adds x86-32-only tests using Xbyak to validate x87 ST + MXCSR behavior through MidHook. |
src/mid_hook.x86_32.asm |
Updates the x86-32 stub to save/restore FNSAVE image + MXCSR and adjusts stack-frame offsets. |
src/mid_hook.cpp |
Regenerates the embedded x86-32 stub bytes and updates relocation offsets; minor cleanups. |
src/context.cpp |
Introduces runtime-emitted x87 conversion stubs and implements Fpu + Context32 stack helpers. |
src/CMakeLists.txt |
Adds context.cpp to the library build. |
include/safetyhook/context.hpp |
Extends Context32 API/types to expose x87 FPU state + MXCSR and adds stack helpers. |
.gitignore |
Ignores an additional build directory (build-x86). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
My test results so far:
void Find3rdPersonCamTargetVectorFMUL(SafetyHookContext& ctx)
{
float f = CDraw::GetAspectRatio();
ctx.st[0].set_f32(ctx.st[0].as_f32() * f); //_asm {fmul dword ptr[f]}
}void StretchX(SafetyHookContext& ctx)
{
float f = (CDraw::GetAspectRatio() / (4.0f / 3.0f));
ctx.st[0].set_f32(ctx.st[0].as_f32() / f); //_asm {fdiv dword ptr[f]}
}Works fine, didn't notice any regressions or crashes.
or |
|
Push and pop also works, and I think |
|
Good suggestions. I'll try some stuff out soonish. |
Replace `Fpu st[8]` with named members `Fpu st0, st1, st2, st3, st4,
st5, st6, st7;`, exposing each slot directly as `ctx.st0..st7`.
`st_pop` / `st_push_f32` / `st_push_f64` now operate on `&st0` base +
`memmove`/`memset` instead of array indexing.
Add ergonomic proxy types FpuF32 / FpuF64 / FpuF80 exposing each Fpu
slot as a read-modify-write handle: implicit `operator T()` read,
assignment, and compound-assignment (`+=`, `-=`, `*=`, `/=`). Add
`Fpu::f32()` / `f64()` / `f80()` factory methods.
Modeled on `std::atomic_ref<T>`:
- In-class member compound-assignment (no free binary operators -- a
prvalue proxy would dangle since it must alias a concrete slot).
- No `++`/`--` (atomic_ref provides those only for integral T; the
doc comment cites the precedent).
- Comparisons and streaming handled by implicit conversion.
f80 path (GCC/Clang, `__LDBL_MANT_DIG__ == 64`): `long double` is
80-bit, bit-identical to `Fpu::raw`, so `as_f80`/`set_f80` are lossless
`memcpy`s. f32/f64 route through the f80 path (compiler emits
`fld`/`fstp tbyte`); no JIT stubs on this path.
MSVC (`__LDBL_MANT_DIG__ == 53`): f80 absent. Renames the existing
two-`__cdecl`-stub pool types (`FpuToDoubleFn`/`DoubleToFpuFn`,
`ConverterCode`, `VmDeleter`, `CONVERTER_CODE` 24-byte array) already
in context.cpp; f32 routes through f64 via `static_cast`. No behavior
change vs. the prior stub implementation.
Tighten the doc comments on Fpu / FpuF32 / FpuF64 / FpuF80 / FpuEnv /
Context32::st_pop / st_push_f32 / st_push_f64 to be concise: state what
each does and the one caveat (bit-63 unsafe; fpu_env not re-derived
after slot rotation; callback runs at FINIT defaults). Pre-existing
top-level `@file`, `Context64`, and the original three `Context32` notes
(eip / esp / trampoline_esp) are left untouched.
Pin the full Context32 layout the hand-written x87 trampoline in
`src/mid_hook.x86_32.asm` encodes, via additional static_asserts:
offsetof(fpu_env)=0, st0=28, st7=98, mxcsr=108, xmm0=112,
eflags=240, eip=280; sizeof=284 (GCC i386 SysV) || 288 (MSVC tail
pad for Xmm alignment). asm uses literal offsets + movdqu and
allocates its own frame, so the MSVC size divergence is harmless.
Drop the "(may reflect post-save reset)" note from `FpuEnv::top()` --
FNSAVE stores the *captured* TOP, and post-rotation reads of fpu_env
return exactly what will be FRSTOR'd (the rotation never touches
fpu_env, which is the documented behavior).
Migrate existing MidHookX87 tests in `test/mid_hook.cpp` to
`ctx.st0..st7`. Add new tests:
- StProxyArithmeticOps: proxy `*=`, `/=`, `+=`, `-=`, `=`
- StF80LosslessRoundTrip (GCC/Clang only): verify as_f80/f80() are
bit-exact using 1e-30L as a probe that as_f64 cannot round-trip
Export Fpu / FpuEnv / FpuF32 / FpuF64 / FpuF80 (gated) from
`module/safetyhook.cppm`.
asm unchanged.
Breaking changes (x86-32 MidHook):
- `Context32::st[8]` replaced by named `Fpu st0, st1, st2, st3, st4, st5, st6, st7;`. Code referencing `ctx.st[n]`
must migrate to `ctx.st0..st7`.
- Inline asm in MidHook callbacks that operated on the live x87 FPU (e.g. `_asm { fmul dword ptr[f] }`) no longer
sees the program's register values: FNSAVE captures ST(0)..ST(7) into the `ctx.st0..st7` byte image and resets the
live FPU to FINIT defaults for the duration of the callback. The program's captured state is restored on return.
Replace inline asm with the `Fpu` accessors (`ctx.st0.as_f32()`, `ctx.st0.set_f32(...)`) or the
`FpuF32`/`FpuF64`/`FpuF80` proxies (`ctx.st0.f32() += x`, `ctx.st0.f32() = ...`). Proxy ops touch only the targeted
slot's 10 bytes and never disturb `fpu_env` or neighboring slots.
- Proxy arithmetic decays to the arithmetic type (`float`/`double`/`long double`), matching `std::atomic_ref<T>`:
`ctx.st0.f32() + x` yields a `float`, not a proxy. No free binary operators are provided (a prvalue proxy would dangle
since it must alias a concrete slot).
Verified: GCC 13.3 / Linux x86-32 (22/22) + x86-64 (20/20); Clang 21.1
/ Windows MSVC target x86-32 (22/22, f80 path absent as expected).
- Replace `std::memset(&st7, 0, sizeof(Fpu))` with `st7 = Fpu{};` to
silence GCC -Wclass-memaccess (Fpu's `uint8_t raw[10]{}` member-init
makes its default ctor non-trivial). Same zero bytes, no warning.
- Pin calling convention on the MSVC FpuToDoubleFn / DoubleToFpuFn
function-pointer types via SAFETYHOOK_CCALL so /Gz (stdcall) / /Gr
(fastcall) can't mismatch the __cdecl stubs' ABI.
- Replace magic `code + 13` offset with a named
`constexpr size_t FPU_TO_DOUBLE_LEN = 13;` so the dependency on the
first stub's size is auditable.
Verified: GCC 13.3 / Linux x86-32 (22/22) + x86-64 (20/20); Clang 21.1
/ Windows MSVC target x86-32 (22/22), all under -Werror / /WX.
|
@ThirteenAG Let me know if this is any nicer to use. You can do stuff like |
|
Checking the code I have, and encountered this: float temp = 0.0f;
_asm {fdiv st, st(2)}
_asm {fstp dword ptr[temp]}
*(float*)(regs.esp + 0x2C) = temp;
regs.st0.f32() /= regs.st2.f32();
float temp = regs.st0.f32();
regs.st_pop();
*(float*)(regs.esp + 0x2C) = temp;How about making regs.st0.f32() /= regs.st2.f32();
*(float*)(regs.esp + 0x2C) = regs.st_pop32(); |
|
Found a breakage: .text:004460D0 sub_4460D0 proc near ; CODE XREF: sub_41A9A0+5B5↑p
.text:004460D0 ; sub_41A9A0+602↑p ...
.text:004460D0
.text:004460D0 var_C = dword ptr -0Ch
.text:004460D0 arg_0 = dword ptr 4
.text:004460D0 arg_4 = dword ptr 8
.text:004460D0
.text:004460D0 53 push ebx
.text:004460D1 8B 5C 24 08 mov ebx, [esp+4+arg_0]
.text:004460D5 83 EC 08 sub esp, 8
.text:004460D8 8B 44 24 14 mov eax, [esp+0Ch+arg_4]
.text:004460DC 8B 10 mov edx, [eax]
.text:004460DE 89 53 68 mov [ebx+68h], edx
.text:004460E1 8B 40 04 mov eax, [eax+4]
.text:004460E4 89 43 6C mov [ebx+6Ch], eax
.text:004460E7 D9 05 A8 01 5B 00 fld ds:flt_5B01A8
.text:004460ED D8 73 68 fdiv dword ptr [ebx+68h]
.text:004460F0 D9 05 B0 01 5B 00 fld ds:flt_5B01B0
.text:004460F6 D8 73 6C fdiv dword ptr [ebx+6Ch]
.text:004460F9 D9 C9 fxch st(1)
.text:004460FB 8B 43 04 mov eax, [ebx+4]
.text:004460FE 85 C0 test eax, eax
.text:00446100 D9 5B 70 fstp dword ptr [ebx+70h]
.text:00446103 D9 5B 74 fstp dword ptr [ebx+74h]
.text:00446106 74 08 jz short loc_446110
.text:00446108 89 04 24 mov [esp+0Ch+var_C], eax
.text:0044610B E8 C0 DC FF FF call sub_443DD0
.text:00446110
.text:00446110 loc_446110: ; CODE XREF: sub_4460D0+36↑j
.text:00446110 83 C4 08 add esp, 8
.text:00446113 8B C3 mov eax, ebx
.text:00446115 5B pop ebx
.text:00446116 C3 retn
.text:00446116 sub_4460D0 endpflt_5B01A8 is 1.0 and affects scaling in a game Deer Avenger 4. I'm replacing with MakeNOP(0x4460E7, 6);
static auto _ = safetyhook::create_mid(0x4460E7, [](SafetyHookContext& regs)
{
regs.st_push_f32(1.0f);
});and all rendering breaks. I can see in the debugger that the value is pushed to st0, so perhaps it's some other things that break. static auto _ = safetyhook::create_mid(0x4460E7 + 6, [](SafetyHookContext& regs)
{
regs.st0.f32() /= 2.0f;
});this, however, works. Originally I had inline asm there and it also worked. |
|
What worries me is if the environment isn't set up right to reflect the changes or something, it's all very confusing to me. It probably breaks because when we push/pop we need to fix TOP/FTW and such. Maybe your push code would work if you recalculated the fpu enviornment and such. I'll try to poke around more in the intel docs. |
|
I asked copilot for a possible explanation:
void Context32::st_pop() noexcept {
const uint8_t old_top = fpu_env.top();
const uint8_t new_top = static_cast<uint8_t>((old_top + 1u) & 7u);
std::memmove(&st0, &st1, sizeof(Fpu) * 7);
st7 = Fpu{};
// TOP = new_top
fpu_env.fsw = static_cast<uint16_t>((fpu_env.fsw & ~(uint16_t(7u) << 11u)) | (uint16_t(new_top) << 11u));
// old physical ST(0) becomes empty
fpu_env.ftw = static_cast<uint16_t>(fpu_env.ftw | (uint16_t(0b11u) << (old_top * 2u)));
}
void Context32::st_push_f32(float value) noexcept {
const uint8_t old_top = fpu_env.top();
const uint8_t new_top = static_cast<uint8_t>((old_top - 1u) & 7u);
std::memmove(&st1, &st0, sizeof(Fpu) * 7);
st0.set_f32(value);
// TOP = new_top
fpu_env.fsw = static_cast<uint16_t>((fpu_env.fsw & ~(uint16_t(7u) << 11u)) | (uint16_t(new_top) << 11u));
// new physical ST(0) is now non-empty (valid)
fpu_env.ftw = static_cast<uint16_t>(fpu_env.ftw & ~(uint16_t(0b11u) << (new_top * 2u)));
}
void Context32::st_push_f64(double value) noexcept {
const uint8_t old_top = fpu_env.top();
const uint8_t new_top = static_cast<uint8_t>((old_top - 1u) & 7u);
std::memmove(&st1, &st0, sizeof(Fpu) * 7);
st0.set_f64(value);
// TOP = new_top
fpu_env.fsw = static_cast<uint16_t>((fpu_env.fsw & ~(uint16_t(7u) << 11u)) | (uint16_t(new_top) << 11u));
// new physical ST(0) is now non-empty (valid)
fpu_env.ftw = static_cast<uint16_t>(fpu_env.ftw & ~(uint16_t(0b11u) << (new_top * 2u)));
}I will compile with this edit and report back. |
|
UPD: Game still breaks with this code. |
|
Super odd, I don't know what causes it. x87 is really annoying and I feared things like this. |
|
I kept the original FLD instruction on top of having the hook, and it doesn't break rendering. So I guess there's something FLD does that's not happening with the current implementation of st_push_f32. |
|
Here's a conclusion deepseek made:
void Context32::st_push_f32(float value) noexcept {
__asm {
push eax
mov eax, ecx // ecx = this (MSVC thiscall)
frstor [eax] // restore game's FPU state to live FPU
fld dword ptr [value] // push value — updates FIP/FDP/FOP/FSW/FTW correctly
fnsave [eax] // save modified state back to the FNSAVE image
fwait
pop eax
}
}
void Context32::st_push_f64(double value) noexcept {
__asm {
push eax
mov eax, ecx
frstor [eax]
fld qword ptr [value]
fnsave [eax]
fwait
pop eax
}
}
void Context32::st_pop() noexcept {
__asm {
push eax
mov eax, ecx
frstor [eax]
fstp st(0) // pop ST(0) — updates FIP/FDP/FOP/FSW/FTW correctly
fnsave [eax]
fwait
pop eax
}
}With these indeed works, however I'm not sure if the conclusion is correct, just don't know enough about the subject. The game doesn't seem to use FSTENV instruction anywhere. |
|
I've made a lot of changes but I haven't gotten around to finishing it all up. Might be a while until I push my new changes, but I did implement Thanks for the help and stuff, I'll get around to this again soon hopefully. |
|
Alright, in any event I think allowing to modify st values without using inline asm should suffice, and not saving/restoring fpu context should still allow usage of inline asm where needed. If everything could be achieved without inline asm, that would be ideal of course. |
|
I'm hoping just having |
Capture full x87 FPU state via FNSAVE (108 bytes: env + ST0–ST7 in logical stack order) and MXCSR via stmxcsr.
FRSTOR/ldmxcsr replay the captured image verbatim on callback return, so writes to ctx.st0..st7 or ctx.mxcsr take
effect for the hooked code without disturbing other slots or the FPU environment.
FNSAVE also resets the live FPU to FINIT defaults — callback math runs there, not in the program's env; the program's
captured env is restored on return.
API (include/safetyhook/context.hpp, x86-32 only)
64).
std::atomic_ref. Enables ctx.st0.f32() /= f;.
captured value and is not re-derived after st_pop/st_push.
layout offsets the asm encodes.
is NOT re-derived; the values the hooked code sees are correct, but reading fpu_env.fsw/.ftw afterward reflects the
pre-rotation state.
Internals
callback.
double_to_fpu); f32 routes through f64. MSVC ABI pins calling convention with SAFETYHOOK_CCALL. Stubs are allocated
RW, copied, then vm_protect-ed to RX (W^X). GCC/Clang path uses 80-bit long double memcpy, no stubs. Functions lazily
allocated once via std::call_once, freed via vm_free.
Tests (test/mid_hook.cpp, x86-32 only)
pushes 42; original fstp via trampoline observes 42.
Refs: #81