Suppress DUID mismatch check resulting in create account hang (#3)

* test duuid mismatch failures

* add variables to trigger or suppress DUID mismatch

* update docs

* turn on by default
This commit is contained in:
devin
2026-07-14 13:36:52 -04:00
committed by GitHub
parent a6e4555a00
commit 6a62f0cc1d
8 changed files with 436 additions and 9 deletions
+77
View File
@@ -0,0 +1,77 @@
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Test tool: persist a genuinely corrupt STORED device id on this machine, matching the friend's
// condition (stored id truncated, current id healthy).
//
// We can't hand-craft the stored value: it lives in PlayerPrefs under an obfuscated key, encoded as a
// CodeStage ObscuredString, and both the key and the encode method are renamed per game build. So we
// let the game write it: WriteDUIDs() stores ObscuredString(SystemInfo.deviceUniqueIdentifier) under
// the right key. We temporarily spoof deviceUniqueIdentifier to a truncated value around that one
// call, so the game encrypts+stores a bad id with its own (unknown-to-us) key. Afterwards the spoof
// is off, so the current id reads healthy again -> stored != current -> real mismatch on next launch.
[HarmonyPatch]
public static class CorruptDUIDPatch
{
// Only true for the duration of the WriteDUIDs() call below, so the SystemInfo getter is spoofed
// exactly there and nowhere else.
private static bool _spoofActive;
private static string _spoofValue = "";
[HarmonyPrefix]
[HarmonyPatch(typeof(SystemInfo), "get_deviceUniqueIdentifier")]
private static bool DeviceIdGetterPrefix(ref string __result)
{
if (!_spoofActive)
return true;
__result = _spoofValue;
return false;
}
// Returns true if the corruption was written (so the caller marks it done and won't repeat).
public static bool CorruptStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing written");
return false;
}
var real = SystemInfo.deviceUniqueIdentifier; // spoof off -> real id
var bad = real is { Length: >= 7 } ? real.Substring(0, 7) : "badduid";
_spoofValue = bad;
_spoofActive = true;
try
{
cm.WriteDUIDs(); // encodes+stores ObscuredString(bad) under the real key
}
finally
{
_spoofActive = false;
}
Plugin.Log.LogWarning($"[CORRUPT] wrote truncated stored DUID = \"{bad}\" (real id = \"{real}\"). " +
"Set 'Corrupt Stored DUID' back to false and relaunch to drive the real mismatch path.");
return true;
}
// Undo: overwrite the stored value with the real id by calling WriteDUIDs with the spoof off.
public static bool RestoreStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing restored");
return false;
}
cm.WriteDUIDs(); // spoof off -> stores ObscuredString(real deviceUniqueIdentifier)
Plugin.Log.LogWarning($"[CORRUPT] restored stored DUID to real id = \"{SystemInfo.deviceUniqueIdentifier}\". " +
"Set 'Restore Stored DUID' back to false.");
return true;
}
}
+45
View File
@@ -0,0 +1,45 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
// Controls CheatManager.CheckForDUIDMismatch, which returns true when the machine's stored device id
// differs from the freshly-derived one. A true result sends the client down the migration path that
// POSTs PlayerReporting/v1/deviceId and then stalls on Create Account.
//
// Three modes, chosen by config:
// Simulate = true -> force TRUE (fake a mismatch to reproduce the hang without a corrupt value)
// Suppress = true -> force FALSE (the workaround fix: never migrate, never hang)
// both false -> pass through, let the REAL check run against the actual stored value
// (needed to observe a genuinely corrupt stored id, e.g. after Corrupt Stored DUID)
//
// Patch the concrete CheatManager method, NOT the abstract PGECJHKNIEN interface, or the prefix
// never runs.
[HarmonyPatch]
public static class DUIDMismatchPatch
{
private const string SimulatedStoredDeviceId = "491e8b9";
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "CheckForDUIDMismatch")]
private static bool Prefix(ref string ALOMDLLNIMD, ref bool __result)
{
if (Plugin.SimulateDUIDMismatch.Value)
{
ALOMDLLNIMD = SimulatedStoredDeviceId;
__result = true;
Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}");
return false;
}
if (Plugin.SuppressDUIDMismatch.Value)
{
ALOMDLLNIMD = string.Empty;
__result = false;
Plugin.Log.LogInfo("[DUID] mismatch check forced to false (suppressed)");
return false;
}
// Pass through to the real check.
return true;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Diagnostic only. Two jobs:
// 1. Show where the stored device id lives, by logging PlayerPrefs reads/writes.
// 2. Show how far the DUID migration branch gets, by logging CheatManager's other DUID methods.
// If WriteDUIDs() never fires after the deviceId POST, the flow stalls before it.
[HarmonyPatch]
public static class DUIDProbePatch
{
// PlayerPrefs.GetString is called constantly, so log each key only once — except device/DUID
// keys, which we always log so we can watch them change across the migration.
private static readonly HashSet<string> SeenKeys = new();
private static bool IsInteresting(string key) =>
key != null && (key.Contains("DUID") || key.Contains("Duid") || key.Contains("duid")
|| key.Contains("Device") || key.Contains("device")
|| key.Contains("Anon") || key.Contains("anon"));
private static void Note(string op, string key, string value)
{
if (IsInteresting(key))
Plugin.Log.LogWarning($"[DUID-PROBE] {op} {key} = \"{value}\"");
else if (SeenKeys.Add($"{op}:{key}"))
Plugin.Log.LogInfo($"[DUID-PROBE] {op} {key} = \"{value}\"");
}
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string)])]
private static void GetStringPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string), typeof(string)])]
private static void GetStringDefaultPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.SetString))]
private static void SetStringPrefix(string key, string value) => Note("SET", key, value);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.DeleteKey))]
private static void DeleteKeyPrefix(string key) => Note("DEL", key, "<deleted>");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() called");
[HarmonyPostfix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPostfix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() returned");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "ClearDUIDs")]
private static void ClearDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] ClearDUIDs() called");
}
+61
View File
@@ -0,0 +1,61 @@
using System;
using System.Text;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace RecNetPlugin.Patches;
// Experiment harness for the Create Account hang.
//
// On a device-id mismatch the client POSTs PlayerReporting/v1/deviceId, the server answers
// 200 {"success":true}, and then the client stops: CheatManager.WriteDUIDs() is never called, so the
// new id is never persisted and the flow never reaches create_account. That means the client can't
// proceed on what it got back.
//
// This rewrites that one response body before the game sees it, so response shapes can be tried
// without redeploying the server. WriteDUIDs() appearing in the log (see DUIDProbePatch) is the
// pass signal: it means the client accepted the response and resumed the migration.
[HarmonyPatch]
public static class DeviceIdResponsePatch
{
private const string Endpoint = "/PlayerReporting/v1/deviceId";
[HarmonyPrefix]
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
private static void Prefix(HTTPRequest request)
{
if (!request.Uri.AbsoluteUri.Contains(Endpoint, StringComparison.OrdinalIgnoreCase))
return;
var original = request.Callback;
// Whether the game attached a completion callback at all. If this logs False, the client is
// not waiting on this request through the callback API and the "stuck on the response" model
// is wrong -- that would be worth knowing before chasing response shapes any further.
Plugin.Log.LogWarning($"[DEVICEID] request seen; game callback attached = {original != null}");
var body = Plugin.DeviceIdResponseOverride.Value;
if (string.IsNullOrEmpty(body))
return;
var status = Plugin.DeviceIdResponseStatus.Value;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
{
if (resp != null)
{
// Set both: DataAsText is computed from Data but cached in dataAsText once read,
// and our own HTTP logger may already have read it.
resp.Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body));
resp.dataAsText = body;
resp.StatusCode = status;
Plugin.Log.LogWarning($"[DEVICEID] response overridden -> {status} {body}");
}
original?.Invoke(req, resp);
}));
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ public class SendRequestPatch
}
// Cap logged bodies so a large response/request doesn't flood the log.
private const int MaxLoggedBodyLength = 1000;
private const int MaxLoggedBodyLength = 10000;
private static string Truncate(string s)
{