From 6c9feffb3a61cf82d4bd6a5fd828258391ff9d27 Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:25:48 -0600 Subject: [PATCH 1/2] Mask the game's thread-local slot operand in the gauge field templates 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. --- docs/adaptive-game-compatibility.md | 2 + .../NativeAdaptiveFieldVerifier.cs | 37 +++- .../NativeAdaptiveResolver.cs | 27 ++- .../NativeCompatibility/adaptive-store.json | 198 ++++++++++++++++-- .../NativeAdaptiveCompatibilityTests.cs | 10 + .../NativeAdaptiveFieldVerifierTests.cs | 54 ++++- 6 files changed, 295 insertions(+), 33 deletions(-) diff --git a/docs/adaptive-game-compatibility.md b/docs/adaptive-game-compatibility.md index 895fcb4d..99946416 100644 --- a/docs/adaptive-game-compatibility.md +++ b/docs/adaptive-game-compatibility.md @@ -18,6 +18,8 @@ Core HUD compatibility is required first. Tune and direct native gauge readers a Both platforms require typed code witnesses for all 18 direct-gauge fields. Steam uses the independently verified common field-reader code in an isolated Store reference namespace: complete initializer and getter code, exact property names, member offsets, type calls and the complete type function must all match. Only a verified single jump alias is accepted. This does not copy Store addresses into a Steam reader. +The field reader code carries the game's thread-local storage slot offset as an immediate operand (`mov edx, imm32` after the `gs:[0x58]` access). That offset is recompiled per build: 0x420 through 6.461.691.0 and 3.461.691.0, 0x42C from 6.483.970.0 and 3.483.970.0, where it rejected the direct gauge on every PC. The type function template and the 18 initializer templates declare it as 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. Getters do not contain it, and their member displacements are still compared byte for byte. + Steam's secondary HUD table is linked to the already verified main HUD table through the same complete-object RTTI type and hierarchy. Its subobject offset, full inheritance topology and slot code must still match, and the candidate must be unique. Table spacing and numeric RTTI labels are not treated as stable identities. This relationship was checked in both retained versions of both platforms. ## Drift angle and scoring guidance diff --git a/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs b/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs index 55ffe9e8..ee0e75a8 100644 --- a/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs +++ b/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs @@ -59,6 +59,7 @@ private static void ValidateCore(NativeAdaptiveImage image, JsonElement storePro var selected = new List(); var relationships = new Dictionary(); var typeReferences = new HashSet(); + var threadLocalSlots = new HashSet(); var typeSlot = checked(childVtableRva + 24); if (typeSlot % 8 != 0 || image.Kind(typeSlot, 8) != "readonly") throw new InvalidDataException("type-slot"); @@ -76,7 +77,7 @@ private static void ValidateCore(NativeAdaptiveImage image, JsonElement storePro } var identity = new TypeIdentity(typeEntry, canonicalType, hasAlias); if (!Match(image, typeBody, canonicalType)) throw new InvalidDataException("type-body-template-changed"); - BindSignature(image, typeBody, canonicalType, identity, typeReferences, relationships, selected); + BindSignature(image, typeBody, canonicalType, identity, typeReferences, relationships, selected, threadLocalSlots); image.Remember(typeSlot, image.Bytes(typeSlot, 8)); if (hasAlias) image.Remember(typeEntry, entry); image.Remember(canonicalType, image.Bytes(canonicalType, typeBody.Length)); @@ -120,8 +121,8 @@ private static void ValidateCore(NativeAdaptiveImage image, JsonElement storePro if (candidates.Count != 1) throw new InvalidDataException(candidates.Count == 0 ? "no-complete-typed-match" : "ambiguous-complete-typed-match"); var choice = candidates[0]; - BindSignature(image, initializer, choice.Initializer, identity, typeReferences, relationships, selected); - BindSignature(image, getter, choice.Getter, identity, typeReferences, relationships, selected); + BindSignature(image, initializer, choice.Initializer, identity, typeReferences, relationships, selected, threadLocalSlots); + BindSignature(image, getter, choice.Getter, identity, typeReferences, relationships, selected, threadLocalSlots); image.Remember(choice.Initializer, image.Bytes(choice.Initializer, initializer.Length)); image.Remember(choice.Getter, image.Bytes(choice.Getter, getter.Length)); image.Remember(choice.Name, image.Bytes(choice.Name, expectedName.Length)); @@ -131,13 +132,18 @@ private static void ValidateCore(NativeAdaptiveImage image, JsonElement storePro if (previous >= range.Signature.ReferenceRva && previous - range.Signature.ReferenceRva < range.Signature.Length && current != checked(range.Rva + previous - range.Signature.ReferenceRva)) throw new InvalidDataException("template-interior-reference-conflict"); + // The game's thread-local slot offset moves between builds; it must still be one value for the + // type function and every field initializer. + if (threadLocalSlots.Count > 1) throw new InvalidDataException("thread-slot-inconsistent"); + if (threadLocalSlots.Any(slot => slot % 4 != 0 || slot >= NativeAdaptiveResolver.ThreadLocalSlotLimit)) + throw new InvalidDataException("thread-slot-bounds"); image.CheckBudget(); } private static Relocation Operand(Signature signature, int offset) => signature.Relocations.Single(r => r.Offset == offset); private static void BindSignature(NativeAdaptiveImage image, Signature signature, uint rva, TypeIdentity identity, - HashSet typeReferences, Dictionary relationships, List selected) + HashSet typeReferences, Dictionary relationships, List selected, HashSet threadLocalSlots) { void Bind(uint previous, uint current) { @@ -151,7 +157,12 @@ void Bind(uint previous, uint current) relationships[previous] = current; } Bind(signature.ReferenceRva, rva); - foreach (var relocation in signature.Relocations) Bind(relocation.ReferenceTarget, Target(image, signature, rva, relocation)); + foreach (var relocation in signature.Relocations) + { + if (relocation.Kind == NativeAdaptiveResolver.ThreadLocalSlotKind) + threadLocalSlots.Add(BinaryPrimitives.ReadUInt32LittleEndian(image.Bytes(checked(rva + (uint)relocation.Offset), 4))); + else Bind(relocation.ReferenceTarget, Target(image, signature, rva, relocation)); + } selected.Add(new Selected(signature, rva)); } @@ -198,6 +209,11 @@ private static bool Match(NativeAdaptiveImage image, Signature signature, uint r var normalized = image.Bytes(rva, signature.Length).ToArray(); foreach (var relocation in signature.Relocations) { + if (relocation.Kind == NativeAdaptiveResolver.ThreadLocalSlotKind) + { + normalized.AsSpan(relocation.Offset, relocation.Width).Clear(); + continue; + } uint target; try { target = Target(image, signature, rva, relocation); } catch (Exception e) when (e is InvalidDataException or OverflowException) { return false; } @@ -241,8 +257,11 @@ internal Signature(JsonElement value) foreach (var r in Relocations) { if (r.Width is not (1 or 4 or 8) || r.Offset < 0 || r.Offset > Length - r.Width || - r.Kind is not ("relative" or "absolute" or "image") || r.Kind == "relative" && (r.Width == 8 || r.Next < r.Offset + r.Width || r.Next > Length) || - r.Kind == "absolute" && r.Width != 8 || r.Kind == "image" && r.Width != 4 || r.Section is not ("code" or "readonly" or "data" or "image")) + r.Kind is not ("relative" or "absolute" or "image" or NativeAdaptiveResolver.ThreadLocalSlotKind) || + r.Kind == "relative" && (r.Width == 8 || r.Next < r.Offset + r.Width || r.Next > Length) || + r.Kind == "absolute" && r.Width != 8 || r.Kind == "image" && r.Width != 4 || + r.Kind == NativeAdaptiveResolver.ThreadLocalSlotKind && (r.Width != 4 || r.Section != "none") || + r.Kind != NativeAdaptiveResolver.ThreadLocalSlotKind && r.Section is not ("code" or "readonly" or "data" or "image")) throw new InvalidDataException("template-relocation-bounds"); for (var i = r.Offset; i < r.Offset + r.Width; i++) { @@ -280,12 +299,14 @@ private static FieldSpec[] Specifications(NativeGaugeLayout g) // Complete old Store type body; independently matched both current Store // and captured Steam bodies after declared relocation normalization. // All field and type templates share this isolated OLD Store namespace. + // Offset 34 is the game's thread-local slot immediate (0x420 through 6.461/3.461, + // 0x42C from 6.483/3.483); it is masked and checked for consistency instead. private static JsonDocument ReadTypeBodyTemplate() { return JsonDocument.Parse(TypeBodyTemplate); } private const string TypeBodyTemplate = """ -{"referenceRva":58668368,"length":505,"normalizedSha256":"96C92ABE8C4B1E06321A753EAC44A2BD52E4112C7BB873572313D42D91918641","anchorHex":"48895C24104889742418574881EC80","anchorOffset":0,"section":"code","relocations":[{"offset":20,"width":4,"next":24,"kind":"relative","targetRva":180139504,"expectedTargetSection":"data","internalOffset":null},{"offset":49,"width":4,"next":53,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":55,"width":4,"next":59,"kind":"relative","targetRva":58668586,"expectedTargetSection":"code","internalOffset":218},{"offset":62,"width":4,"next":66,"kind":"relative","targetRva":177819824,"expectedTargetSection":"data","internalOffset":null},{"offset":91,"width":4,"next":95,"kind":"relative","targetRva":104645616,"expectedTargetSection":"readonly","internalOffset":null},{"offset":130,"width":4,"next":134,"kind":"relative","targetRva":98646732,"expectedTargetSection":"code","internalOffset":null},{"offset":146,"width":4,"next":150,"kind":"relative","targetRva":3864832,"expectedTargetSection":"code","internalOffset":null},{"offset":179,"width":4,"next":183,"kind":"relative","targetRva":177819824,"expectedTargetSection":"data","internalOffset":null},{"offset":184,"width":4,"next":188,"kind":"relative","targetRva":61564832,"expectedTargetSection":"code","internalOffset":null},{"offset":191,"width":4,"next":195,"kind":"relative","targetRva":103282304,"expectedTargetSection":"code","internalOffset":null},{"offset":196,"width":4,"next":200,"kind":"relative","targetRva":94177100,"expectedTargetSection":"code","internalOffset":null},{"offset":204,"width":4,"next":208,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":209,"width":4,"next":213,"kind":"relative","targetRva":94177304,"expectedTargetSection":"code","internalOffset":null},{"offset":214,"width":4,"next":218,"kind":"relative","targetRva":58668427,"expectedTargetSection":"code","internalOffset":59},{"offset":221,"width":4,"next":225,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":226,"width":4,"next":230,"kind":"relative","targetRva":94177412,"expectedTargetSection":"code","internalOffset":null},{"offset":232,"width":4,"next":237,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":239,"width":4,"next":243,"kind":"relative","targetRva":58668427,"expectedTargetSection":"code","internalOffset":59},{"offset":246,"width":4,"next":250,"kind":"relative","targetRva":58144224,"expectedTargetSection":"code","internalOffset":null},{"offset":276,"width":4,"next":280,"kind":"relative","targetRva":3864640,"expectedTargetSection":"code","internalOffset":null},{"offset":289,"width":4,"next":293,"kind":"relative","targetRva":104645664,"expectedTargetSection":"readonly","internalOffset":null},{"offset":302,"width":4,"next":306,"kind":"relative","targetRva":114154208,"expectedTargetSection":"readonly","internalOffset":null},{"offset":311,"width":4,"next":315,"kind":"relative","targetRva":114154224,"expectedTargetSection":"readonly","internalOffset":null},{"offset":325,"width":4,"next":329,"kind":"relative","targetRva":177825984,"expectedTargetSection":"data","internalOffset":null},{"offset":333,"width":4,"next":337,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":338,"width":1,"next":339,"kind":"relative","targetRva":58668766,"expectedTargetSection":"code","internalOffset":398},{"offset":342,"width":4,"next":346,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":347,"width":4,"next":351,"kind":"relative","targetRva":94177412,"expectedTargetSection":"code","internalOffset":null},{"offset":353,"width":4,"next":358,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":359,"width":1,"next":360,"kind":"relative","targetRva":58668766,"expectedTargetSection":"code","internalOffset":398},{"offset":369,"width":4,"next":373,"kind":"relative","targetRva":61733360,"expectedTargetSection":"code","internalOffset":null},{"offset":376,"width":4,"next":380,"kind":"relative","targetRva":103275760,"expectedTargetSection":"code","internalOffset":null},{"offset":381,"width":4,"next":385,"kind":"relative","targetRva":94177100,"expectedTargetSection":"code","internalOffset":null},{"offset":389,"width":4,"next":393,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":394,"width":4,"next":398,"kind":"relative","targetRva":94177304,"expectedTargetSection":"code","internalOffset":null},{"offset":401,"width":4,"next":406,"kind":"relative","targetRva":177826008,"expectedTargetSection":"data","internalOffset":null},{"offset":410,"width":4,"next":414,"kind":"relative","targetRva":177825984,"expectedTargetSection":"data","internalOffset":null},{"offset":425,"width":4,"next":429,"kind":"relative","targetRva":58668455,"expectedTargetSection":"code","internalOffset":87},{"offset":448,"width":1,"next":449,"kind":"relative","targetRva":58668862,"expectedTargetSection":"code","internalOffset":494},{"offset":469,"width":1,"next":470,"kind":"relative","targetRva":58668862,"expectedTargetSection":"code","internalOffset":494},{"offset":489,"width":4,"next":493,"kind":"relative","targetRva":104291080,"expectedTargetSection":"readonly","internalOffset":null},{"offset":495,"width":4,"next":499,"kind":"relative","targetRva":3870928,"expectedTargetSection":"code","internalOffset":null},{"offset":500,"width":4,"next":504,"kind":"relative","targetRva":58668455,"expectedTargetSection":"code","internalOffset":87}]} +{"referenceRva":58668368,"length":505,"normalizedSha256":"99CE202773165E54B18CEDB69D9E0626223AB757F86F308014D087E822D6E37E","anchorHex":"48895C24104889742418574881EC80","anchorOffset":0,"section":"code","relocations":[{"offset":20,"width":4,"next":24,"kind":"relative","targetRva":180139504,"expectedTargetSection":"data","internalOffset":null},{"offset":34,"width":4,"next":38,"kind":"threadLocalSlot","targetRva":0,"expectedTargetSection":"none","internalOffset":null},{"offset":49,"width":4,"next":53,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":55,"width":4,"next":59,"kind":"relative","targetRva":58668586,"expectedTargetSection":"code","internalOffset":218},{"offset":62,"width":4,"next":66,"kind":"relative","targetRva":177819824,"expectedTargetSection":"data","internalOffset":null},{"offset":91,"width":4,"next":95,"kind":"relative","targetRva":104645616,"expectedTargetSection":"readonly","internalOffset":null},{"offset":130,"width":4,"next":134,"kind":"relative","targetRva":98646732,"expectedTargetSection":"code","internalOffset":null},{"offset":146,"width":4,"next":150,"kind":"relative","targetRva":3864832,"expectedTargetSection":"code","internalOffset":null},{"offset":179,"width":4,"next":183,"kind":"relative","targetRva":177819824,"expectedTargetSection":"data","internalOffset":null},{"offset":184,"width":4,"next":188,"kind":"relative","targetRva":61564832,"expectedTargetSection":"code","internalOffset":null},{"offset":191,"width":4,"next":195,"kind":"relative","targetRva":103282304,"expectedTargetSection":"code","internalOffset":null},{"offset":196,"width":4,"next":200,"kind":"relative","targetRva":94177100,"expectedTargetSection":"code","internalOffset":null},{"offset":204,"width":4,"next":208,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":209,"width":4,"next":213,"kind":"relative","targetRva":94177304,"expectedTargetSection":"code","internalOffset":null},{"offset":214,"width":4,"next":218,"kind":"relative","targetRva":58668427,"expectedTargetSection":"code","internalOffset":59},{"offset":221,"width":4,"next":225,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":226,"width":4,"next":230,"kind":"relative","targetRva":94177412,"expectedTargetSection":"code","internalOffset":null},{"offset":232,"width":4,"next":237,"kind":"relative","targetRva":177820000,"expectedTargetSection":"data","internalOffset":null},{"offset":239,"width":4,"next":243,"kind":"relative","targetRva":58668427,"expectedTargetSection":"code","internalOffset":59},{"offset":246,"width":4,"next":250,"kind":"relative","targetRva":58144224,"expectedTargetSection":"code","internalOffset":null},{"offset":276,"width":4,"next":280,"kind":"relative","targetRva":3864640,"expectedTargetSection":"code","internalOffset":null},{"offset":289,"width":4,"next":293,"kind":"relative","targetRva":104645664,"expectedTargetSection":"readonly","internalOffset":null},{"offset":302,"width":4,"next":306,"kind":"relative","targetRva":114154208,"expectedTargetSection":"readonly","internalOffset":null},{"offset":311,"width":4,"next":315,"kind":"relative","targetRva":114154224,"expectedTargetSection":"readonly","internalOffset":null},{"offset":325,"width":4,"next":329,"kind":"relative","targetRva":177825984,"expectedTargetSection":"data","internalOffset":null},{"offset":333,"width":4,"next":337,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":338,"width":1,"next":339,"kind":"relative","targetRva":58668766,"expectedTargetSection":"code","internalOffset":398},{"offset":342,"width":4,"next":346,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":347,"width":4,"next":351,"kind":"relative","targetRva":94177412,"expectedTargetSection":"code","internalOffset":null},{"offset":353,"width":4,"next":358,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":359,"width":1,"next":360,"kind":"relative","targetRva":58668766,"expectedTargetSection":"code","internalOffset":398},{"offset":369,"width":4,"next":373,"kind":"relative","targetRva":61733360,"expectedTargetSection":"code","internalOffset":null},{"offset":376,"width":4,"next":380,"kind":"relative","targetRva":103275760,"expectedTargetSection":"code","internalOffset":null},{"offset":381,"width":4,"next":385,"kind":"relative","targetRva":94177100,"expectedTargetSection":"code","internalOffset":null},{"offset":389,"width":4,"next":393,"kind":"relative","targetRva":177826016,"expectedTargetSection":"data","internalOffset":null},{"offset":394,"width":4,"next":398,"kind":"relative","targetRva":94177304,"expectedTargetSection":"code","internalOffset":null},{"offset":401,"width":4,"next":406,"kind":"relative","targetRva":177826008,"expectedTargetSection":"data","internalOffset":null},{"offset":410,"width":4,"next":414,"kind":"relative","targetRva":177825984,"expectedTargetSection":"data","internalOffset":null},{"offset":425,"width":4,"next":429,"kind":"relative","targetRva":58668455,"expectedTargetSection":"code","internalOffset":87},{"offset":448,"width":1,"next":449,"kind":"relative","targetRva":58668862,"expectedTargetSection":"code","internalOffset":494},{"offset":469,"width":1,"next":470,"kind":"relative","targetRva":58668862,"expectedTargetSection":"code","internalOffset":494},{"offset":489,"width":4,"next":493,"kind":"relative","targetRva":104291080,"expectedTargetSection":"readonly","internalOffset":null},{"offset":495,"width":4,"next":499,"kind":"relative","targetRva":3870928,"expectedTargetSection":"code","internalOffset":null},{"offset":500,"width":4,"next":504,"kind":"relative","targetRva":58668455,"expectedTargetSection":"code","internalOffset":87}]} """; } diff --git a/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs b/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs index aa63b66b..f07ae4fa 100644 --- a/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs +++ b/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs @@ -10,6 +10,10 @@ namespace Wisp.App; // Only address operands may move; opcodes, member offsets and constants must match. internal sealed class NativeAdaptiveResolver { + // A thread-local storage slot offset the game recompiles into its field readers. It is masked + // like an address operand, but it has no target; every reader must carry the same value. + internal const string ThreadLocalSlotKind = "threadLocalSlot"; + internal const uint ThreadLocalSlotLimit = 0x10000; private readonly NativeAdaptiveImage _image; private readonly JsonElement _profile; private readonly Dictionary _signatures; @@ -265,6 +269,11 @@ private bool Match(Signature signature, uint rva, bool remember) var normalized = bytes.ToArray(); foreach (var relocation in signature.Relocations) { + if (relocation.Kind == ThreadLocalSlotKind) + { + normalized.AsSpan(relocation.Offset, relocation.Width).Clear(); + continue; + } uint target; try { target = Target(signature, rva, relocation); } catch (Exception e) when (e is InvalidDataException or OverflowException) { return false; } @@ -281,7 +290,7 @@ private bool Match(Signature signature, uint rva, bool remember) _matched[signature.Id] = rva; BindRelationship(signature.ReferenceRva, rva); foreach (var relocation in signature.Relocations) - BindRelationship(relocation.TargetRva, Target(signature, rva, relocation)); + if (relocation.Kind != ThreadLocalSlotKind) BindRelationship(relocation.TargetRva, Target(signature, rva, relocation)); } return true; } @@ -614,6 +623,7 @@ private void ValidateFields() throw new InvalidDataException("Adaptive native gauge fields could not be verified"); } var required = capability.GetProperty("requiredFieldWitnessPaths").EnumerateArray().Select(p => p.GetString()!).ToHashSet(StringComparer.Ordinal); + var threadLocalSlots = new HashSet(); foreach (var field in fields.EnumerateArray()) { var path = field.GetProperty("path").GetString()!; @@ -642,8 +652,20 @@ private void ValidateFields() Match(getter, Target(initializer, selected, getterOperand), remember: true); _image.Remember(Target(initializer, selected, nameOperand), name); _image.Pointer(typeSlot, proof: true); + threadLocalSlots.UnionWith(ThreadLocalSlots(initializer, selected)); } if (required.Count != 0) throw new InvalidDataException("Adaptive field witness is missing"); + CheckThreadLocalSlots(threadLocalSlots); + } + + private IEnumerable ThreadLocalSlots(Signature signature, uint rva) => + signature.Relocations.Where(r => r.Kind == ThreadLocalSlotKind) + .Select(r => BinaryPrimitives.ReadUInt32LittleEndian(_image.Bytes(checked(rva + (uint)r.Offset), 4))); + + internal static void CheckThreadLocalSlots(IReadOnlySet slots) + { + if (slots.Count > 1) throw new InvalidDataException("Adaptive thread-local slot differs between field readers"); + if (slots.Any(slot => slot % 4 != 0 || slot >= ThreadLocalSlotLimit)) throw new InvalidDataException("Adaptive thread-local slot bounds"); } private static void Set(JsonObject root, string path, JsonNode? value) @@ -675,7 +697,8 @@ internal Signature(JsonElement value) if (Length is < 1 or > 8192 || Hash.Length != 32 || AnchorOffset < 0 || AnchorOffset > Length - Anchor.Length || CanDiscover && Anchor.Length < 4 || Relocations.Any(r => r.Width is not (1 or 4 or 8) || r.Offset < 0 || r.Offset > Length - r.Width || r.Kind == "relative" && (r.Next < r.Offset + r.Width || r.Next > Length) || - r.Kind == "absolute" && r.Width != 8 || r.Kind == "image" && r.Width != 4 || r.Kind == "relative" && r.Width == 8)) + r.Kind == "absolute" && r.Width != 8 || r.Kind == "image" && r.Width != 4 || r.Kind == "relative" && r.Width == 8 || + r.Kind == ThreadLocalSlotKind && r.Width != 4)) throw new InvalidDataException("Adaptive signature bounds"); var occupied = new bool[Length]; foreach (var relocation in Relocations) diff --git a/src/Wisp.App/NativeCompatibility/adaptive-store.json b/src/Wisp.App/NativeCompatibility/adaptive-store.json index beecf725..6870bad2 100644 --- a/src/Wisp.App/NativeCompatibility/adaptive-store.json +++ b/src/Wisp.App/NativeCompatibility/adaptive-store.json @@ -63719,7 +63719,7 @@ "id": "code-0380A750-261", "referenceRva": 58763088, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -63733,6 +63733,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -64125,7 +64134,7 @@ "id": "code-0380B520-261", "referenceRva": 58766624, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -64139,6 +64148,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -64495,7 +64513,7 @@ "id": "code-0380B630-261", "referenceRva": 58766896, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -64509,6 +64527,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -64865,7 +64892,7 @@ "id": "code-0380B740-261", "referenceRva": 58767168, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -64879,6 +64906,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -65235,7 +65271,7 @@ "id": "code-0380B850-261", "referenceRva": 58767440, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -65249,6 +65285,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -65605,7 +65650,7 @@ "id": "code-0380D880-261", "referenceRva": 58775680, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -65619,6 +65664,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -65975,7 +66029,7 @@ "id": "code-0380D990-261", "referenceRva": 58775952, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -65989,6 +66043,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -66345,7 +66408,7 @@ "id": "code-0380DEE0-261", "referenceRva": 58777312, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -66359,6 +66422,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -66715,7 +66787,7 @@ "id": "code-0380E0F0-261", "referenceRva": 58777840, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -66729,6 +66801,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -67085,7 +67166,7 @@ "id": "code-0380E200-252", "referenceRva": 58778112, "length": 252, - "normalizedSha256": "4BF969725E2C6AF25E63F08E050946D25988532066FEA7BB77C490EFC7E9DA0A", + "normalizedSha256": "4F35BCDB6AFA548C5E06E0A08C3ED5E8C6994898FDD18E2221BFA7AE414CA142", "anchorOffset": 143, "anchorHex": "33C0488905", "section": "code", @@ -67099,6 +67180,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -67446,7 +67536,7 @@ "id": "code-0380F1B0-261", "referenceRva": 58782128, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -67460,6 +67550,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -67816,7 +67915,7 @@ "id": "code-0380F2C0-261", "referenceRva": 58782400, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -67830,6 +67929,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -68186,7 +68294,7 @@ "id": "code-0380F3D0-261", "referenceRva": 58782672, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -68200,6 +68308,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -68556,7 +68673,7 @@ "id": "code-0380F4E0-261", "referenceRva": 58782944, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -68570,6 +68687,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -68962,7 +69088,7 @@ "id": "code-0380F5F0-261", "referenceRva": 58783216, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -68976,6 +69102,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -69368,7 +69503,7 @@ "id": "code-0380F700-261", "referenceRva": 58783488, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -69382,6 +69517,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -69774,7 +69918,7 @@ "id": "code-0380F910-261", "referenceRva": 58784016, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -69788,6 +69932,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, @@ -70144,7 +70297,7 @@ "id": "code-03810B90-261", "referenceRva": 58788752, "length": 261, - "normalizedSha256": "37037C8FB2480AF44B36E6EC64DFFB56DBA25A2B8C86627922B7BE795A254352", + "normalizedSha256": "30F63D48BED9BDD718FDCAA910610F3C8A579C728DFCDD99808E2B1F7D0CB75B", "anchorOffset": 0, "anchorHex": "48895C24084889742410574883EC208B0D", "section": "code", @@ -70158,6 +70311,15 @@ "expectedTargetSection": "data", "internalOffset": null }, + { + "offset": 31, + "width": 4, + "next": 35, + "kind": "threadLocalSlot", + "targetRva": 0, + "expectedTargetSection": "none", + "internalOffset": null + }, { "offset": 44, "width": 4, diff --git a/tests/Wisp.App.Tests/NativeAdaptiveCompatibilityTests.cs b/tests/Wisp.App.Tests/NativeAdaptiveCompatibilityTests.cs index 62b97389..940b5d92 100644 --- a/tests/Wisp.App.Tests/NativeAdaptiveCompatibilityTests.cs +++ b/tests/Wisp.App.Tests/NativeAdaptiveCompatibilityTests.cs @@ -10,6 +10,16 @@ namespace Wisp.App.Tests; public sealed class NativeAdaptiveCompatibilityTests { + [Fact] + public void ThreadLocalSlotsMustAgreeAndStayBounded() + { + NativeAdaptiveResolver.CheckThreadLocalSlots(new HashSet()); + NativeAdaptiveResolver.CheckThreadLocalSlots(new HashSet { 0x42c }); + Assert.Throws(() => NativeAdaptiveResolver.CheckThreadLocalSlots(new HashSet { 0x420, 0x42c })); + Assert.Throws(() => NativeAdaptiveResolver.CheckThreadLocalSlots(new HashSet { 0x422 })); + Assert.Throws(() => NativeAdaptiveResolver.CheckThreadLocalSlots(new HashSet { 0x10000 })); + } + private static int _nextProcess = 600_000; [Theory] diff --git a/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs b/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs index fd72aeea..96641616 100644 --- a/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs +++ b/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs @@ -167,6 +167,40 @@ public void UnreadableFinalProofIsTransient() Assert.Throws(() => image.FinishProof()); } + [Theory] + [InlineData(0x420u)] + [InlineData(0x42cu)] + public void ThreadLocalSlotMayDifferFromTheReference(uint slot) + { + using var fixture = new Fixture(slotValue: slot); + var image = fixture.Image(TestContext.Current.CancellationToken); + fixture.Validate(image); + Assert.True(image.FinishProof().Matches(fixture.Read, Fixture.Module, + new NativeAdaptiveBudget(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken))); + } + + [Theory] + [InlineData("initializer")] + [InlineData("type-body")] + public void ThreadLocalSlotMustAgreeAcrossTheTypeFunctionAndEveryInitializer(string changed) + { + using var fixture = new Fixture(); + var offset = changed == "initializer" ? Fixture.Initializer(3) + 27 : Fixture.TypeBody + 9; + BinaryPrimitives.WriteUInt32LittleEndian(fixture.Bytes.AsSpan(offset), 0x42c); + var error = Assert.Throws(() => fixture.Validate(fixture.Image(TestContext.Current.CancellationToken))); + Assert.Equal("thread-slot-inconsistent", error.Message); + } + + [Theory] + [InlineData(0x422u)] + [InlineData(0x10000u)] + public void ThreadLocalSlotMustBeAlignedAndBelow64K(uint slot) + { + using var fixture = new Fixture(slotValue: slot); + var error = Assert.Throws(() => fixture.Validate(fixture.Image(TestContext.Current.CancellationToken))); + Assert.Equal("thread-slot-bounds", error.Message); + } + [Fact] public void CancellationIsNotConvertedIntoStructuralRejection() { @@ -178,7 +212,8 @@ public void CancellationIsNotConvertedIntoStructuralRejection() } private sealed record Field(string Name, string Path, ulong Offset, string Kind); - private sealed record Relocation(int Offset, int Width, int Next, uint ReferenceTarget, string Section, uint? Inner = null); + private sealed record Relocation(int Offset, int Width, int Next, uint ReferenceTarget, string Section, uint? Inner = null, + string Kind = "relative"); private sealed class Fixture : IDisposable { @@ -200,8 +235,11 @@ private sealed class Fixture : IDisposable internal static int Getter(int index) => Initializer(index) + 0x100; internal static int Name(int index) => 0x11400 + index * 0x40; - internal Fixture(bool alias = true, bool getterUsesBody = false) + internal uint SlotValue { get; } + + internal Fixture(bool alias = true, bool getterUsesBody = false, uint slotValue = 0x420) { + SlotValue = slotValue; Write16(0, 0x5a4d); Write32(0x3c, 0x80); Write32(0x80, 0x4550); @@ -217,10 +255,13 @@ internal Fixture(bool alias = true, bool getterUsesBody = false) Bytes[TypeEntry] = 0xe9; Relative(TypeEntry, 1, 5, TypeBody); Convert.FromHexString("4831C0909090909090909090909090C3").CopyTo(Bytes, TypeBody); + // mov edx, imm32: the game's thread-local slot offset, masked by the template. + Bytes[TypeBody + 8] = 0xba; + BinaryPrimitives.WriteUInt32LittleEndian(Bytes.AsSpan(TypeBody + 9), slotValue); Bytes[SharedHelper] = 0xc3; Bytes[0x1700] = 0xc3; TypeTemplate = JsonDocument.Parse(JsonSerializer.Serialize( - Signature("type", ReferenceBody, TypeBody, 16, []))); + Signature("type", ReferenceBody, TypeBody, 16, [new(9, 4, 13, 0, "none", null, "threadLocalSlot")]))); var signatures = new List(); var fields = new List(); @@ -246,6 +287,8 @@ internal Fixture(bool alias = true, bool getterUsesBody = false) Relative(initializer, 19, 23, name); Bytes[initializer + 24] = 0xeb; Bytes[initializer + 25] = 2; + Bytes[initializer + 26] = 0xba; + BinaryPrimitives.WriteUInt32LittleEndian(Bytes.AsSpan(initializer + 27), slotValue); Bytes[initializer + BodyLength - 1] = 0xc3; Convert.FromHexString("534889CB").CopyTo(Bytes, getter); Bytes[getter + 23] = 0xe8; @@ -265,7 +308,8 @@ internal Fixture(bool alias = true, bool getterUsesBody = false) new(5, 4, 9, ReferenceType, "code"), new(12, 4, 16, referenceGetter, "code"), new(19, 4, 23, referenceName, "readonly"), - new(25, 1, 26, referenceInitializer + 28, "code", 28) + new(25, 1, 26, referenceInitializer + 28, "code", 28), + new(27, 4, 31, 0, "none", null, "threadLocalSlot") ])); signatures.Add(Signature("getter-" + index, referenceGetter, getter, BodyLength, [ @@ -324,7 +368,7 @@ private object Signature(string id, uint referenceRva, int actualRva, int length offset = r.Offset, width = r.Width, next = r.Next, - kind = "relative", + kind = r.Kind, targetRva = r.ReferenceTarget, expectedTargetSection = r.Section, internalOffset = r.Inner From 94b52dff2d35635a585a7b901b8da9159d915a84 Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:36:25 -0600 Subject: [PATCH 2/2] Accept a single jump alias for a shared helper reference 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. --- docs/adaptive-game-compatibility.md | 2 ++ .../NativeAdaptiveFieldVerifier.cs | 5 +++++ .../NativeAdaptiveResolver.cs | 19 +++++++++++++++++++ .../NativeAdaptiveFieldVerifierTests.cs | 15 +++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/docs/adaptive-game-compatibility.md b/docs/adaptive-game-compatibility.md index 99946416..6ae70e17 100644 --- a/docs/adaptive-game-compatibility.md +++ b/docs/adaptive-game-compatibility.md @@ -20,6 +20,8 @@ Both platforms require typed code witnesses for all 18 direct-gauge fields. Stea The field reader code carries the game's thread-local storage slot offset as an immediate operand (`mov edx, imm32` after the `gs:[0x58]` access). That offset is recompiled per build: 0x420 through 6.461.691.0 and 3.461.691.0, 0x42C from 6.483.970.0 and 3.483.970.0, where it rejected the direct gauge on every PC. The type function template and the 18 initializer templates declare it as 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. Getters do not contain it, and their member displacements are still compared byte for byte. +A reference that two templates share may reach its target through a single 5-byte jump thunk on one side and directly on the other (Store 3.483.970.0 routes one Tune guard's call to a helper that the gauge templates call directly through such a thunk). Both shared-reference checks accept exactly that case and keep the helper itself; the thunk bytes join the proof. Any other disagreement between shared references is still rejected. + Steam's secondary HUD table is linked to the already verified main HUD table through the same complete-object RTTI type and hierarchy. Its subobject offset, full inheritance topology and slot code must still match, and the candidate must be unique. Table spacing and numeric RTTI labels are not treated as stable identities. This relationship was checked in both retained versions of both platforms. ## Drift angle and scoring guidance diff --git a/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs b/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs index ee0e75a8..67376e3b 100644 --- a/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs +++ b/src/Wisp.App/NativeCompatibility/NativeAdaptiveFieldVerifier.cs @@ -153,7 +153,12 @@ void Bind(uint previous, uint current) current = identity.Canonical; } if (relationships.TryGetValue(previous, out var existing) && existing != current) + { + // The same single-jump-alias rule as the resolver: one side may reach a shared helper through a thunk. + if (NativeAdaptiveResolver.JumpAliasTarget(image, existing) == current) { relationships[previous] = current; return; } + if (NativeAdaptiveResolver.JumpAliasTarget(image, current) == existing) return; throw new InvalidDataException("template-shared-reference-conflict"); + } relationships[previous] = current; } Bind(signature.ReferenceRva, rva); diff --git a/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs b/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs index f07ae4fa..dcd596a6 100644 --- a/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs +++ b/src/Wisp.App/NativeCompatibility/NativeAdaptiveResolver.cs @@ -298,10 +298,29 @@ private bool Match(Signature signature, uint rva, bool remember) private void BindRelationship(uint previous, uint current) { if (_relationships.TryGetValue(previous, out var existing) && existing != current) + { + // A build may route one call site of a shared helper through a single jump thunk. The + // reference then names the helper through the thunk on one side and directly on the + // other; keep the helper itself. Anything else is a disagreement. + if (JumpAliasTarget(_image, existing) == current) { _relationships[previous] = current; return; } + if (JumpAliasTarget(_image, current) == existing) return; throw new InvalidDataException("Adaptive references disagree"); + } _relationships[previous] = current; } + // The target of a 5-byte relative jump at rva, when both ends are code; the thunk bytes join the proof. + internal static uint? JumpAliasTarget(NativeAdaptiveImage image, uint rva) + { + if (image.Kind(rva, 5) != "code") return null; + var bytes = image.Bytes(rva, 5); + if (bytes[0] != 0xE9) return null; + var target = (long)rva + 5 + BinaryPrimitives.ReadInt32LittleEndian(bytes[1..]); + if (target <= 0 || target >= image.ImageSize || image.Kind((uint)target, 1) != "code") return null; + image.Remember(rva, bytes); + return (uint)target; + } + private void ValidateRelationships() { // References to the middle of a guarded instruction range must move with diff --git a/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs b/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs index 96641616..9045ebcb 100644 --- a/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs +++ b/tests/Wisp.App.Tests/NativeAdaptiveFieldVerifierTests.cs @@ -92,6 +92,21 @@ public void SharedTemplateReferencesCannotResolveToDifferentTargets() Assert.Equal("template-shared-reference-conflict", error.Message); } + [Fact] + public void ASharedHelperMayBeReachedThroughOneJumpThunk() + { + using var fixture = new Fixture(); + // Getter 1 calls the shared helper through a 5-byte jump at 0x1700 instead of directly. + fixture.Bytes[0x1700] = 0xe9; + fixture.Relative(0x1700, 1, 5, 0x1600); + fixture.Relative(Fixture.Getter(1), 83, 87, 0x1700); + var image = fixture.Image(TestContext.Current.CancellationToken); + fixture.Validate(image); + // The thunk is part of the proof: a changed jump invalidates the accepted evidence. + fixture.Bytes[0x1701] ^= 1; + Assert.Throws(() => image.FinishProof()); + } + [Fact] public void ReferencesIntoAnotherMatchedRangeMustKeepTheirInteriorOffset() {