Work on moving the D3DX9 effect framework (ID3DXEffect) statically linked into
NFSU2 to native code / short-circuiting it, step by step. Code:
runtime/d3dx_hle.c. General performance context: PROJECT.md → "Performance".
Measurement of 2026-09-23 (M1 Pro, highest settings, AUTOSEQ Quick Race,
mid-race, sample 8204 samples, guest thread self time):
| item | share |
|---|---|
D3DX9 effect library (33 functions with a mov edi,edi prologue, mostly 0x0066xxxx) |
47% |
| the game's own code | 32% |
our D3D9 layer (com_call, memmove, dispatch_indirect) |
~15% |
Including its subcalls, a single API entry, ID3DXEffect::BeginPass, is 48% of
the guest thread. The real D3D calls inside it are only 3.4%; the rest is D3DX
re-interpreting all state expressions (preshaders) on the CPU on every pass —
in our case through translated x86.
The mapping was verified with ret N signatures (every Set* 0xc/0x10, Begin
0xc, BeginPass 8, CommitChanges/EndPass/End 4 ...). The layout is the
transitional (2004) header that has both Set and Get for pixel/vertex shaders:
| slot | method | address | slot | method | address | |
|---|---|---|---|---|---|---|
| 3 | GetDesc | 0065cb66 | 58 | SetArrayRange | 0066c9cd | |
| 20 | SetValue | 0066c905 | 59 | GetPool | 0065cb08 | |
| 26 | SetInt | 0066dd7f | 60 | SetTechnique | 00661e8c | |
| 28 | SetIntArray | 0066e8a9 | 62 | ValidateTechnique | 0066074e | |
| 30 | SetFloat | 0066f501 | 65 | Begin | 00664792 | |
| 32 | SetFloatArray | 0066eec8 | 66 | BeginPass | 0066ace2 | |
| 34 | SetVector | 0066d303 | 67 | CommitChanges | 0066ac27 | |
| 36 | SetVectorArray | 0066ca38 | 68 | EndPass | 0065addb | |
| 38 | SetMatrix | 0066d13a | 69 | End | 0065acf5 | |
| 52 | SetTexture | 0066d7e9 | 70 | GetDevice | 0065cb37 | |
| 54–57 | Set/Get Pixel/VertexShader | 71/72 | OnLostDevice / OnResetDevice | 0065c89d / 0065e593 | ||
| 78 | CloneEffect | 0067065b |
48.1% BeginPass 0066ace2 (game: sub_005cf430 draw loop, 38.6%)
47.1% apply pass 006691b1
33.3% set state 00669296 type>>24 switch, 100-byte state record
26.0% get state value 00664a2f <- Stage 1 memoizes here
17.2% evaluate expr 00663a42 FPU CW setup + gather inputs + VM
5.3% preshader VM 00688e89 x87, double register file
4.8% gather inputs 00662edc (kind 1/2) / 00663275 (kind 0)
6.5% process result 00660a52
8.2% (other path) 00669f40 → 00668e0d → 00668cd0 → 00664a2f
| calls/frame | |
|---|---|
| Begin / End | 80 / 66 |
| BeginPass / CommitChanges / EndPass | 1398 |
set state (669296) |
28,338 (~20 per pass) |
| get state value / evaluate expression / VM | 11,838 (~8.5 per pass) |
| total Set* (Matrix 1731, Vector 1512, Texture 576, IntArray 501, Float 233, VectorArray 130) | ~4,700 (~3.4 per pass) |
The game calls BeginPass on every draw. While ~3 parameters change per pass, D3DX recomputes and applies all ~20 states.
Effect object (this, ecx):
| offset | meaning |
|---|---|
| +0x2c | array of parameter/pass records, 0x64 bytes/record |
| +0x3c | Begin state (0 = outside Begin → D3DERR_INVALIDCALL 0x8876086c) |
| +0x40 / +0x42 | technique indices |
| +0x44 | active pass (0xffff = none) |
| +0xa4 | technique array, 0xb4 bytes/technique; passes at [technique+0x38], 0x38 bytes, pass param index +0x34 |
| +0xb0 | object table |
| +0x114 | saved FPU control word |
| +0x118 | flags: bit0 = CW saved, 0x100 = inside a pass, 8 = … |
Parameter/pass record P = idx*0x64 + [this+0x2c]: +0 data block pointer,
+4 sub-record (state) array, +8 flags, +0x36 state count.
State record R = b*0x64 + [P+4]:
| offset | meaning |
|---|---|
| +0x08 | value buffer pointer |
| +0x30 | flags; &4 = constant value ([R+0x60] bytes are copied from [R+0x5c]) |
| +0x34 / +0x4e | input records (32 bytes) / count; valid if [R+0x3c] != 0 |
| +0x38 | program container (vtable 0x7b7124, slot 3 = getter returning [+0xc]) |
| +0x40 / +0x44 | VM program pointers |
| +0x48 | offset of the target within P.data |
| +0x52 | state type → table at 0x7b5834 (0x1c bytes/entry) |
Input record: +0 dirty flag (always set, re-set with [+0] = [+4], so it is
useless), +0xc → {data pointer, definition}, +0x10 → register definition
(+4 set 0/1/2, +6 index, +8 count). The gatherers convert float/int/bool
data into the VM's double registers (32 bytes per float4).
runtime/d3dx_hle.c: counter wrappers for 20 hot D3DX functions.tools/override_patch.py: appliesOVERRIDEto the existing shards without a full regeneration; only the affected shards are recompiled.CPUWIN: guest thread CPU time / frame (a measure independent of the GPU).
664a2f (get state value) depends, for expression states, only on the data of a
single input. D3DXANALYZE measurement: 111 distinct (effect, pass, state) keys
in a race, and in 95.8% of evaluations the input data is byte-for-byte the same
as in the previous evaluation of the same state.
Memo key (this, a, b). The record keeps a full copy of the input (this,
R, [R+0x38], [P], [R+0x48], [R+8] plus each input's dirty flag, data
pointer and data bytes; at most 256 bytes) and compares it with memcmp. If it
is the same as the previous copy, and the target dword and the first dword of the
value buffer haven't changed since the last real computation either, the call is
skipped. (The first version used a byte-wise FNV hash; in the profile it took 8%
by itself. Switching to a full copy made it both faster and removed the collision
risk.)
Verification (D3DXVERIFY=n): on 1/n of the predicted hits the original is
run anyway; ±256 KB of the effect object (mapped pages) and .data are compared
before/after.
- The only difference is
this+0x114/0x118: the saved FPU CW. Harmless, since the skipped evaluation never touches the CW and the end of BeginPass doesn't restore it either. Excluded from the comparison. - 965,299 checks with the memo off, 1,003,104 with the memo on (hashed version) and 501,888 (full-copy version): 0 differences, 0 guard aborts.
- Visual: car selection (frame 3050) exactly the same; in race frames only timing-dependent counters differ.
Result (four alternating runs, 2448x1836, race frames 5900–8990):
| guest CPU ms/frame | race mean | median | p95 | |
|---|---|---|---|---|
| memo off | 11.98 / 10.89 | 78.6 / 90.0 fps | 12.13 / 11.05 ms | 15.30 / 12.33 ms |
| memo on | 8.10 / 8.11 | 111.2 / 118.9 fps | 8.36 / 8.33 ms | 16.58 / 8.72 ms |
Guest CPU −29%. In the pair with an idle GPU the race hit the display's 120 Hz limit (median 8.33 ms).
D3DXMEMO=0 turns the memo off (on by default).
Measurement (COMSTAT, race, per frame): ~36,000 device calls come from
inside BeginPass/CommitChanges, and 96% of them are redundant (they rewrite
the value already on the device). Only ~7,000 come from outside.
| method | inside BeginPass | redundant | outside | redundant |
|---|---|---|---|---|
| SetRenderState | 12,598 | 97.9% | 1,578 | 85.0% |
| SetSamplerState | 11,745 | 97.4% | 1,212 | 75.2% |
| SetTextureStageState | 5,325 | 99.3% | 486 | 93.4% |
| SetTexture | 2,189 | 74.1% | 194 | 63.4% |
| SetVertexShader / SetPixelShader | 1,340 | 95.9% | 66 | 25.8% |
| SetVertexShaderConstantF | 1,317 | 50.4% | 57 | 10.5% |
| SetPixelShaderConstantF | 458 | 76.2% | 16 | 0% |
Implementation: a wrapper for sub_00669296 (set state) + runtime/dxrec.h.
- During a real run, the device calls the function makes (with arguments and
constant data) are recorded in
com.c(dxrec_begin/end). - The question "would this call have no effect if made now" is answered in
com.cby comparing the value the handler would write with the current state (dxrec_redundant). 8 supported methods: RenderState, SamplerState, TextureStageState, Texture (including the derived Metal id), Vertex/Pixel Shader (including the derived id), VS/PS ConstantF (data bytes). Any other device call, a nested669296, state block recording or a record overflow invalidates it. - Snapshot: 11 fixed fields (
thisflags, P), the whole state record (0x64 bytes), the target value (16), the value buffer (16), the object table entry, and for expression states the Stage 1 inputs. It is compared in place against the record slot's buffer and only copied after the first difference (8-byte inline word comparison). - Hit = same snapshot and every recorded call currently has no effect.
Verification (D3DXVERIFY2=n): on sampled hits the original is run with
recording. The memory window must be the same and every call it makes must have
no effect at that moment.
- In the first run two state types came out different. Type 9 reads indirectly from the texture table; types 14/15 select an object from an array with an expression and apply it nested. The snapshot doesn't cover these inputs. Only types 1–8 are memoized (86% of calls).
- After that, 3,286,996 + 3,304,796 checks, 0 memory / 0 device / 0 eax differences. Also clean with the Stage 1 verification on at the same time.
- Visual: car selection 0.07% pixel difference (the cursor), in races only the counters.
Result: 82% hits (92–99% for types 1–8).
| measurement | Stage 2 off | on |
|---|---|---|
guest CPU, PACE60=1 (fixed 60 fps) |
7.94 / 7.80 ms | 7.50 / 7.45 ms (−5%) |
| race fps (this machine, 120 Hz) | 119.8 | 119.9 (already at the limit) |
In the 60 fps profile BeginPass is ~26% of busy time. The wrapper's own check is ~12% (~34 ns per state, the original ~100 ns). That is why the gain stays small: ~300 bytes of input are read and compared for every state.
D3DXMEMO2=0 turns it off (on by default).
Measurement lesson: after Stage 1 the race is at the 120 Hz limit. There the
guest CPU/frame comes out at 8.15 ms in both configurations: the game's work
stretches a little with the frame rate (7.9 ms at 60 fps). Measure CPU changes
with PACE60=1 + CPUWIN. NOVSYNC=1 doesn't lift this limit (in windowed
mode the compositor runs at 120 Hz).
- Make the check cheaper: the 100 bytes of the state record are D3DX's
static structure. Settling for only the fields that are read (+0, +4, +8,
+0x1c, +0x30, +0x34, +0x38, +0x3c, +0x48, +0x4e, +0x52..+0x60) cuts the
snapshot by ~half. Must be verified with
D3DXVERIFY2first. The slot table (8192 × ~1.1 KB) may also not fit in the cache. - Types 9 and 14/15: add their indirect inputs to the snapshot (type 9: the
[this+0x18]texture table entry; type 14: the selected object's own states). Type 14 evaluates its own expression directly with663a42, so the Stage 1 memo doesn't cover it. - The
669f40 → 668e0d → 668cd0path (~7% of busy time at 60 fps): looks like a sampler object application; the same record/check can be done per sub-state.
Idea: make "did the inputs change" O(1) with a version counter per parameter and skip the whole pass with a single check per BeginPass.
Infrastructure built (still in the code, OFF by default):
- All
Set*slots (20–56 and CloneEffect, 20 call sites) call0x66056c, which in turn (for every effect in the effect pool) calls0x65ab24(effect, slot). The parameter block path (0x67054b) comes straight to65ab24. The65ab24hook writes a global counter to the 64-byte lines of the changed data. Since the effect pool shares data between effects, the key is the data address (the same data address was seen in different effects). - Parameter slot:
+0data,+4definition,+0x14parameter index. Definition:+0type (2 int, 3 float, 5 texture),+4class (0 scalar, 1 vector, 2 matrix, 4 object),+0x10elements,+0x14rows,+0x18columns. The+0xcfield of the expression input record points to the same slot structure. 6691b1pass wrapper: fingerprint (effect fields + pass record + technique+0x8c..+0xab), dependency lines, no-effect check of the recorded calls.dxrecnow supports a record kind (pass/state) anddxrec_append. The calls of states that Stage 2 skipped are appended to the open pass record.- Flag 8 path (
66404c/6641a7): compares the pass's shader constant register count with the technique's largest, and only does work when it grows (no calls after warm-up). Deemed acceptable by adding the technique fields to the fingerprint.
Result: 86% predicted hits, but D3DXVERIFY3 showed that in 78% of the
hits the pass actually produced different calls (713,519 device and 523,606
memory differences in 919,133 checks). Reasons:
- Textures don't come from parameter data. The sampler/texture state looks
at the parameter through an object index;
SetTexturewrites the new pointer into the effect's object table ([this+0x18] + index*24 + 4), and the parameter data (the index) doesn't change. - The game changes textures on almost every draw (576 SetTexture per frame). Even if the texture dependency were added correctly, cases where a whole pass stays the same are ~19%.
So in this game the right granularity is not the pass but the state
(Stage 2). D3DXMEMO3 is off by default; the version table is only maintained
when it is on. Stages 1–2 were re-verified after these changes: 3,541,437 +
81,950 checks, 0 differences.
Side finding, the identity of 668e0d: the shader-based pass application.
It binds the VS/PS (+0x170/+0x1ac), fills the constant blocks from the
parameters (6623ea/6627a5/662bab) and uploads them with
SetVertex/PixelShaderConstantF/I/B (+0x178/+0x1b4, +0x180/+0x1bc,
+0x188/+0x1c4). At the end it applies the sampler states with 668cd0
(type 3 → SetSamplerState, type 9 → SetTexture, from the texture object
table). The constant uploads really do change per draw. Savings exist only in the
shader/sampler part.
- A state-level memo for
668cd0(sampler sub-states): Stage 2's method, adding the object table entry ([this+0x18]) for textures to the snapshot. At 60 fps the668e0dpath is ~7.5% of busy time. - Make the Stage 2 check cheaper (the item in Stage 2b).
- Use parameter versions at the state level (instead of byte comparison of expression inputs). Expected gain small: most of the snapshot is the static state record.
- Native helpers (bit-identical C equivalents of the lifted x87/flag code):
67193eD3DXMatrixMultiply (a fast path without NaN checks if all 32 inputs are finite, FMA off; the original otherwise or on partial overlap),66056cpool notification (register and stack layout exact),65cf44SetMatrix transpose (types 3/1; type 2__ftolin the original).D3DXNVERIFY=n: ~1 million comparisons, 0 differences. D3DX self time −12.5% (profile), game thread ~−5%. - Narrowing the Stage 2 check (only the fields of the state record that are read, a hot/cold slot split): verification clean, but the time in the profile didn't change (1070 → 1065 samples) — the cost isn't in the byte comparison. Reverted.
Inventory (EFXSTAT=<frame>; the D3DXCreateEffectFromResourceA = 640c12
wrapper also logs creation):
- 23 effects, all at startup, with a single shared pool (
[0x870914]), from the compiled fx_2_0 binaries in the PE resources (RT_RCDATA,IDI_*_FX). - 25 of the 82 methods are used; per frame only 8 Set* + Begin / BeginPass / CommitChanges / EndPass / End. No string handles at all.
- 13 effects, 16 passes in a race; 33 across menu + race.
- Handle = bitwise NOT of the record address: parameter
~([this+0x9c] + i*0x50), technique~([this+0xa4] + i*0xb4), pass~([technique+0x38] + i*0x38).
Format (tools/efxdump.py, all 23 blobs are consumed byte for byte): 325
parameters, 35 techniques, 47 passes, 871 states; 92 shaders (CTAB is parsed),
224 expressions — all single-component MOVs (no constant/relative
addressing), and the PRES markers in the shaders are empty. The operation table
follows D3D9 order: 0..0x66 render state (WRAP0–15 together), 0x67.. texture
stage, 0x7a FVF, 0x7b–0x7e transform, 0x92/0x93 VS/PS, 0xa4 Texture, 0xa5..
sampler state. D3DX's own state table is at 0x7b5834 (28 bytes per operation).
D3DX mechanics (FXTRACE=<frame>,... device + effect call trace; for raw
D3DX use D3DXMEMO=0 D3DXMEMO2=0):
- Begin(0) captures ~5 state blocks every time, and End applies them (dozens of device calls even with no pass).
669296: if not dirty (R+0==0) the value isn't refreshed, but the device call is made on every BeginPass; its end is the0x66a1f7jump table (type 1 RS, 2 TSS, 3 SS, 9 texture, 14 shader object, 15 sampler object …).- Set* →
65ab24: outside Begin it marks the dependent states dirty; inside Begin it also adds them from all passes to the dirty list ([this+0x108]). CommitChanges applies the records from the list whose[P+0x3c](activity counter,65ae35±1, propagated to nested records) is greater than zero — which is why the PS constant block of an inactive pass can get uploaded too. EndPass clears the list without applying it. Since this ledger lives in D3DX memory, the port was done without disturbing it.
Ported (exactly, on D3DX's own memory structures; every unsupported path falls back to the original per call):
| step | function | scope | verification |
|---|---|---|---|
| 4a | 669296 types 1/2/3 (+ expression memo) |
86% of state calls; 664a2f if dirty, a small memo per state (input data + target/value buffer guard) |
2.9 M, 0 differences |
| 4b | 6691b1 pass loop |
prologue, flag 8 / P&8 / P&1 paths, two error rules | via 4a |
| 4c | 668cd0 samplers |
state + texture per sampler record | 4 thousand, 0 differences |
| 4d | 668e0d shader |
VS/PS + raw-copy constant blocks + upload; the fillers stay guest | 316 thousand, 0 differences |
| 4e | 66d7e9 SetTexture + 65ccd8 |
object table + pool propagation | run + image |
Common rules: if a device call matches the com.c shadow state it isn't made at
all (types 1–3 inline; always made during state block recording/replay, an open
dxrec record, or a trace frame). Texture AddRef/Release are skipped since they
are no-ops in com.c (~3900 dispatches per frame). The verifier (nat_verify):
the native code does a dry run (device calls are recorded), memory is rolled back,
the original runs with dxrec recording; the memory window + extra records, the
call sequence and eax/esp are compared. Paths that need a guest subcall can't be
verified dry.
Measurement (PACE60=1 CPUWIN=6200,8800, in the same session against the
baseline k21 binary, mean of two runs):
| state | baseline | new | difference |
|---|---|---|---|
| native helpers | 6.76 | 6.68 | −1.2% |
| + 4a | 6.68 | 6.11 | −8.5% |
| + expression memo, 4b | 6.70 | 5.64 | −15.8% |
| + 4c, 4d | 6.58 | 5.68 | −13.8% (within noise) |
| + 4e, inline check | 6.97 | 5.34 | −23.4% |
Image: race frame 6600 the same side by side (differences only from the timer /
frame moment). In the profile D3DX + native code are still ~29% of the game
thread; the biggest items are the expression memo (n4_eval), the state core,
expression evaluation misses (663a42/660a52/688e89), and the Set* path
(66056c, 66d13a, 66d303, 65ab24).
Next: a compiled state list per pass (for non-dirty states only a value +
shadow comparison), the Set* path (65ab24 mode A), a MOV-specific native
equivalent of expression evaluation, applying the Begin/End state blocks filtered
through the shadow.
Problem: Stage 4's −23% CPU gain didn't show up in watts at all at 120 fps
(powermetrics, race 3024x1964: baseline 8.33 W, new 8.31 W). The P0 cluster was
at 3.2 GHz with the same activity in both versions. Real per-thread CPU with
ps -M: the game thread was at 8.2 ms/frame in both versions (frame 8.34 ms →
98% busy). The time won was burning in a wait loop.
Source: QueryPerformanceCounter is called ~250 million times per run (~19
thousand per frame in a race; ~2 thousand with PACE60). The caller according to
QPCWHO=1: sub_0043bdf0 (GetTicks = QPC >> [0x8284e4]), called from
0x57eb79, i.e. sub_0057eae0 — NFSU2's own frame limiter:
X = [0x865558] = 1/120 s (frame target), dt = this frame's duration
if dt < X: start = GetTicks(); target = (X - dt) * [0x784550] (=1000, ms)
do { elapsed = Diff(start, GetTicks()); } while (elapsed < target);
Diff = sub_0043be20 = (now - start) * 2^S * [0x828028] (=0.001, ms)
The loop only looks at the clock; it spins without sleeping. Every ms won in D3DX (or anywhere else) went into this loop.
Fix (runtime/win32.c, qpc_spin_sleep): if the QPC call comes from this
loop (return 0x43bdfe, [esp+0xc] = 0x57eb79), the remaining time is
computed with the game's own formula: start = EBX, target = [esp+0x20] (float,
ms). Then it sleeps (remaining − margin) while releasing the big lock; the final
margin is still waited out by spinning. Since the exit condition and the clock
stay the same, timing and image don't change. SPINSLEEP=<margin µs> (default
200), SPINSLEEP=0 turns it off.
The sleep is not nanosleep but a kevent timer + NOTE_CRITICAL
(win32_ext.c, guest_sleep_ns). macOS wakes nanosleep with a timer
coalescing margin: in the game a 3 ms request is on average 447 µs late, 12% more
than 800 µs. Because of this, with a 400 µs margin the frame deviation rose from
0.44 → 0.50 ms, and with a 150 µs margin fps dropped to 116.6. With kevent the
mean latency is 41 µs (98% ≤ 200 µs), lock wait 2–6 µs; with 150 and 400 µs
margins fps is 119.9–120.0, deviation 0.43 ms (the same as the spinning version),
longest frame 9.5 ms. Independent measurement (QoS makes no difference):
nanosleep and mach_wait_until median ~770 µs late, kevent NOTE_CRITICAL
median ~45 µs late.
Measurement (120 fps race, frames 6400–11600, powermetrics, mean of two
runs):
| version | total | CPU | GPU | game thread (ps -M) |
fps | longest frame |
|---|---|---|---|---|---|---|
A baseline (k21) |
8.83 W | 5.18 W | 3.65 W | 8.26 ms/frame | 119.9 | 14.9 ms |
B Stage 4, SPINSLEEP=0 |
8.83 W | 5.10 W | 3.74 W | 8.22 ms/frame | 119.9 | 14.7 ms |
| C Stage 4 + sleep | 7.64 W | 3.96 W | 3.68 W | 5.25 ms/frame | 119.9 | 9.5 ms |
C is 1.19 W lower than A in both runs: total −13.5%, CPU −24%. Process total
1.40 → 1.05 cores, QPC calls 269 M → 4.2 M. (This measurement was with
nanosleep + a 400 µs margin; the kevent version also fixed the deviation, see
above.) The same in the HD build: game thread 8.11 → 5.32 ms/frame, process
1.43 → 1.17 cores, 119.8 fps.
Measurement lesson: at 120 fps the guest thread's CPU (including CPUWIN)
includes the frame limiter's spinning. Measure CPU gains with PACE60=1, and
power gains only with powermetrics. Real per-thread CPU: ps -M -p <pid> at the
start and end of the race window.
| variable | effect |
|---|---|
D3DXMEMO=0 |
turns off the Stage 1 memo (on by default) |
D3DXVERIFY=<n> |
on 1/n of the predicted hits runs the original and compares memory (slow) |
D3DXSTAT=<frame> |
counter/time report from that frame on (the timers add ~5 ms per frame; take times from the profile) |
D3DXANALYZE=<frame> |
change rate of expression evaluation inputs |
CPUWIN=<start>,<end> |
guest thread CPU ms/frame (together with PACE60=1 at the 120 Hz limit) |
D3DXMEMO2=0 |
turns off the Stage 2 memo (on by default) |
D3DXVERIFY2=<n> |
on 1/n of Stage 2 hits runs the original with recording and compares memory + device |
D3DXMEMO3=1 |
Stage 3 pass memo (research; OFF by default, came out wrong in verification) |
D3DXVERIFY3=<n> |
on 1/n of Stage 3 hits runs the original and compares, writes the effective calls |
COMSTAT=<frame> |
device method counts, inside/outside BeginPass, redundant ratio (together with D3DXSTAT) |
D3DXNATIVE=0 |
turns off the native helpers (67193e, 66056c, 65cf44) |
D3DXNVERIFY=<n> |
compares against the original on every nth call of the native helpers |
D3DXNATIVE4=0 |
turns off the native state path (4a) and the pass loop (4b) |
D3DXN4MEMO=0 |
turns off the per-state expression memo |
D3DXN4VERIFY=<n> |
dry native + original comparison on the 4a/4c/4d paths (with D3DXMEMO=0) |
D3DXNATIVE5/6/7/8=0 |
turn off the native pass loop, sampler, shader and SetTexture paths respectively |
EFXSTAT=<frame> |
ID3DXEffect vtable inventory + effect creation lines |
FXTRACE=<frame>[,…] |
every device call (argument/constant data) and effect method in order at those frames |
SPINSLEEP=<µs> |
sleep instead of spinning in the game's frame limiter; the final margin (default 200) is waited out by spinning. 0 turns it off |
QPCWHO=1 |
QueryPerformanceCounter callers (thread, return, parent frame) + a frame stamp every 1 M calls + a SPINSLEEP summary (sleep count, late wake-up distribution, lock wait) |
All of these are for measurement/diagnostics; run.sh doesn't clear them. The
recipe for measurements: AUTOSEQ Quick Race + QUITFRAMES=9000, window
5900–8990. See PROJECT.md "Measurement method".
Measurement trap: frame time is max(CPU, GPU). When the GPU is shared with
another application (on the night of 2026-09-23 the GPU was 20% busy with the
game closed, 5 → 18 ms per frame in game) all old and new runs slow down and the
CPU gain disappears. Measure CPU changes with CPUWIN, frame time with
NFSNATIVE=0.
Every wrapped function must be generated as sub_XXXX_orig. The full OVERRIDE
list (overrides.c + d3dx_hle.c; 35 addresses, must be the same as in
PROJECT.md → "Commands"). The first three are the rain / time-of-day wrappers
in overrides.c; if one is missing, sub_XXXX_orig isn't generated and the link
breaks:
60fab0,61e390,61f130,5c7ed0,50d510,65ae35,6623ea,66aa3f,66ace2,6691b1,669296,664a2f,663a42,688e89,663275,662edc,660a52,668e0d,664792,66ac27,65addb,65acf5,66d13a,66d303,66d7e9,66e8a9,66f501,66ca38,65ab24,67193e,66056c,65cf44,640c12,668cd0,65ccd8
To apply to the existing shards instead of a full generation:
.venv/bin/python tools/override_patch.py 66ace2,6691b1 # prints the names of the affected shards
# compile only those shards (same flags as build.sh), then ./link.sh| date | stage | change | guest CPU ms/frame (race) | note |
|---|---|---|---|---|
| 2026-09-23 | 0 | measurement infrastructure, vtable map | 10.9–12.3 | reference (memo off) |
| 2026-09-23 | 1 | state value memo (664a2f), hashed |
8.40–9.22 | −22–25%, 0 verification differences |
| 2026-09-24 | 1 | memo full copy + memcmp |
8.10–8.11 | −29% (reference the same night 10.9–12.0), race 118.9 fps |
| 2026-09-24 | 2 | state assignment memo (669296), call recording + no-effect check |
7.45–7.50 (PACE60) |
−5% (PACE60 reference 7.80–7.94); no difference at 120 Hz, already at the limit |
| 2026-09-24 | 3 | parameter versions + pass memo | — | negative: in verification 78% of the hits were wrong (texture object table, texture change per draw); off by default |
| 2026-10-15 | 3c | native helpers; cheaper Stage 2 check | 6.76 → 6.68 (PACE60) |
helpers permanent; Stage 2 narrowing gained nothing, reverted |
| 2026-10-15 | 4a–4e | 669296/6691b1/668cd0/668e0d/SetTexture native |
6.97 → 5.34 (PACE60, same session) |
−23%; all verifications 0 differences, image the same |
| 2026-10-16 | power | frame limiter (sub_0057eae0) spin → sleep |
8.26 → 5.25 (120 fps, ps -M) |
8.83 → 7.64 W (−13.5%), 119.9 fps kept; Stage 4 alone was 0 W |