Skip to content

Mask the game's thread-local slot operand in the gauge field templates - #184

Merged
Views2k merged 3 commits into
mainfrom
fix/gauge-tls-slot
Nov 8, 2026
Merged

Views2k merged 3 commits into
mainfrom
fix/gauge-tls-slot

Conversation

@Views2k

@Views2k Views2k commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Since the Forza Horizon 6 update of November 5 (Steam 6.483.970.0, Xbox app / Microsoft Store 3.483.970.0), the direct gauge reader is rejected at attach on every PC: 0 of 29 PCs with native HUD updates on the new build got a gauge observation, including sessions that stayed open across the update, so the needle angle and blur, EV digits, power and regen fill and headlight state fell back to Data Out values while Wisp still reported the build as supported.

Cause

Read-only captures of the running 6.483 (Steam) and 3.483 (Store) games show that the adaptive discovery resolves every gauge role, and that the field reader's type function and all 18 field initializers are instruction-for-instruction the same as on 6.461. The one operand that differs is the game's thread-local storage slot offset (mov edx, imm32 after the gs:[0x58] access): 0x420 through 6.461/3.461, 0x42C from 6.483/3.483. The type-body template and the 18 initializer templates compared that immediate as fixed code, so NativeAdaptiveFieldVerifier failed with type-body-template-changed. The 18 getters do not contain the operand and still match byte for byte, so the member offsets Wisp reads are unchanged.

On the Store build a second, smaller difference remained: one Tune guard now calls a shared helper through a 5-byte jump thunk while the gauge templates call the helper directly, and the shared-reference rule rejected the two targets as a disagreement.

Changes

  • A threadLocalSlot operand kind for field templates: masked like an address operand, no target, and every matched reader (type function and all 18 initializers) must carry the same value, 4-byte aligned and below 64 KB. Implemented in both verification paths: NativeAdaptiveFieldVerifier (Steam, which checks against the Store reference namespace) and NativeAdaptiveResolver.ValidateFields (Store).
  • The type-body template constant and the 18 initializer signatures in adaptive-store.json declare the operand (offset 34 and offset 31) with their normalized hashes recomputed from the retained 6.461 live bytes.
  • Both shared-reference checks accept exactly one 5-byte relative jump between two targets of the same reference and keep the helper itself; the thunk bytes join the proof. Any other disagreement is still rejected.
  • docs/adaptive-game-compatibility.md documents both rules.

Tests: NativeAdaptiveFieldVerifierTests (fixture carries the operand; a different value passes, a value that differs between readers is rejected with thread-slot-inconsistent, an unaligned or out-of-range value with thread-slot-bounds; a shared helper reached through one jump thunk passes and the thunk is part of the final proof), NativeAdaptiveCompatibilityTests.ThreadLocalSlotsMustAgreeAndStayBounded.

Verification

  • Offline replay of the production adaptive discovery (NativeAdaptiveImage + NativeAdaptiveResolver + CreatePack) against read-only live captures of the running games. Steam 6.483: before the change core and tune resolve and the gauge fails with type-body-template-changed; with the change all 115 roles resolve, tune and gauge enabled, and the generated pack keeps every one of the 76 gauge member offsets and constants of the 6.461 pack while only the 14 address roles change. Store 3.483: with the change all 126 roles resolve, tune and gauge enabled.
  • The same replay against the October 6.461 live-evidence image still passes, and the images without live gauge evidence still fail as before (no loosening beyond the two rules).
  • Adaptive test classes: 84 passed, including the new tests; the wider adaptive, catalog, process-memory and gauge resolver selection passed before the alias commit (404).
  • Not yet run: the in-game check of the rendered needle on 6.483 and 3.483 (planned with the 2.7.4 release candidate).

Since the Forza update of November 5 (6.483.970.0 / 3.483.970.0) the
direct gauge reader was rejected at attach on every PC and the needle
fell back to Data Out values. A live capture of the running game shows
that the field reader's type function and the 18 field initializers are
unchanged except for one operand: the game's thread-local storage slot
offset, 0x420 through 6.461 and 0x42C from 6.483. The templates compared
that immediate as fixed code.

Field templates can now declare a threadLocalSlot operand. It is masked
like an address operand, it has no target, and every matched reader must
carry the same 4-byte-aligned value below 64 KB. The type-body template
and the 18 initializer signatures declare it; their normalized hashes are
recomputed from the retained 6.461 live bytes. Getters still compare their
member displacements byte for byte.
On Microsoft Store 3.483.970.0 one Tune guard calls a helper through a
5-byte jump thunk while the gauge templates call the same helper directly.
The shared-reference rule saw two targets for one reference and rejected
the gauge ("Adaptive references disagree"), so the Store build kept its
gauge fallback after the thread-local slot fix.

Both shared-reference checks, in the resolver and in the field verifier,
now accept exactly one 5-byte relative jump between the two targets and
keep the helper itself. The thunk bytes join the proof so a changed jump
invalidates the accepted evidence. Any other disagreement is still
rejected.
@Views2k
Views2k merged commit c4659c6 into main Nov 8, 2026
10 checks passed
@Views2k
Views2k deleted the fix/gauge-tls-slot branch November 8, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant