Compare commits

...

7 commits
v2.0.8 ... main

Author SHA1 Message Date
MiTHRAL
ecf1e680d6 v2.1.5: self-learning ownership — inject queried out-of-catalog defs (Supporter/DLC)
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 6s
Build DevourUnlocker / standalone (push) Successful in 5s
User session traces: only defs 7 & 51 queried all session, both OUTSIDE the
59-id store catalog -> those are DLC-granted items (Supporter flashlights).
Blind 1..256 sweep polluted userItems and broke pet display (reverted v2.1.4).

Now hUserHasItem/hPlayerHasItem remember every def the game asks about into a
global registry; fabricateOwnership() injects any remembered-but-missing id on
its next 15s cycle. Only game-validated ids enter the list — converges to
everything-owned without garbage.
2026-08-24 13:54:41 -04:00
MiTHRAL
817a2eff78 v2.1.4: remove def-id range sweep — polluting userItems with unknown ids broke pet display; restore exact v2.1.0 injection semantics, keep pass-through traces + catalog dump
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 7s
Build DevourUnlocker / linux (push) Failing after 7s
Build DevourUnlocker / standalone (push) Successful in 5s
2026-08-24 13:48:35 -04:00
MiTHRAL
fee1556da3 v2.1.1: PlayerHasItem -> true — emote validator gate (StoreEmotesOwnedOnSteam path)
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 8s
Build DevourUnlocker / standalone (push) Successful in 5s
2026-08-24 13:38:56 -04:00
MiTHRAL
f478a731d9 v2.1.0: Steam-proof loading — per-prefix Wine registry DllOverrides
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 7s
Build DevourUnlocker / standalone (push) Successful in 5s
Steam kept rewriting localconfig.vdf and dropping our WINEDLLOVERRIDES
LaunchOptions (account 919954036 lost it again -> user's session ran builtin
VERSION.dll, no unlocker, vanilla behavior).

scripts/prefix_override.py: writes version=native,builtin into
compatdata/1274570/pfx/user.reg [Software\Wine\DllOverrides]. Steam never
touches the prefix registry — override persists forever, zero env vars,
zero Launch Options. install.sh now applies registry (primary) + VDF (bonus).
Plain 'native' without fallback failed to boot; native,builtin verified.

Also verified this session: reverting UserHasItem/PlayerHasItem to pass-through
un-blocked real credit purchases (user bought an emote successfully); fabricator
injected remaining catalog ids on top of real ones (had=11 injected=52).
2026-08-24 13:34:37 -04:00
MiTHRAL
7632978bf3 v2.0.11: fabricate ownership at the source — inject catalog into SteamInventoryManager.userItems
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 8s
Build DevourUnlocker / standalone (push) Successful in 5s
Trace proved pet/outfit UI never consults UserHasItem/HasPerk for lock display
(0 HasPerk calls while browsing; buys no-op'd on 'already owned' guard) — display
reads the raw userItems list field directly.

fabricateOwnership(): resolves SteamInventoryManager.instance static, walks
steamItems (List<SteamInventoryItem>, itemID int @+0x10), and List<Add>s every
missing def id into userItems (List<SteamItemDef_t>).

Critical fix: SteamItemDef_t is a 4-byte int enum even though
il2cpp_class_array_element_size reports 8 — reading with 8-byte stride produced
composite garbage ids so dedup never matched and values looked corrupt.
All readers/writers now use 4-byte stride; add verified in-memory:
add sent=1 stored_last=1 ... injected=59, game stable.

Periodic refill every 15s x240 (game recreates the list on inventory refresh).
Inspector keeps container summaries only (element deref removed after hang).
GetCurrentTokens/HasRetrievedUserItems remain pass-through (spoofing broke
credit purchases); UserHasItem/PlayerHasItem stay true as belt-and-braces.
2026-08-24 13:21:46 -04:00
MiTHRAL
de4a4328ae v2.0.10: trace build — log ownership queries, unbreak token purchases
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 7s
Build DevourUnlocker / standalone (push) Successful in 5s
- GetCurrentTokens + HasRetrievedUserItems now pass through to original:
  spoofed tokens broke the credit-purchase modal; forced retrieved-flag with
  empty userItems made flows treat items as definitively not-owned
- UserHasItem/PlayerHasItem keep returning true but LOG itemDef on every call;
  IsCharacterUnlocked/HasPerk/HasOutfit log decoded string args
  -> next play session reveals whether pet/perk/outfit UI consults these at all
    (if silent while browsing locked pets, UI reads userItems list field directly)
- install.sh: replace fragile inline heredoc python with scripts/vdf_patch.py
  (chr(92)-built bytes; the old heredoc produced \\ corruption that silently
   killed LaunchOptions for account 919954036); warn when Steam is running
2026-08-24 01:40:39 -04:00
MiTHRAL
d9a50caa4f v2.0.9: full store unlock — hook the ownership validators themselves
Some checks failed
Build DevourUnlocker / windows-cross (push) Failing after 8s
Build DevourUnlocker / linux (push) Failing after 6s
Build DevourUnlocker / standalone (push) Successful in 5s
Metadata dig findings (Assembly-CSharp, 2349 types):
- SteamInventoryValidator.PlayerHasItem(connId, steamItemDefID): multiplayer/purchase
  validation gate used by WaitForPurchased{Pet,Emote,Flashlight,Outfit}Validation
  coroutines (<validator>5__4) -> hooked true
- SteamInventoryManager.HasRetrievedUserInventoryItems(): gates PopulateInventoryWhenAvailable;
  if Steam inventory query under Proton returns nothing, item views never populate ->
  hooked true so lobby tabs build with UserHasItem=true
- SteamInventoryManager.userItems cache read directly by UI in some paths — covered by
  UserHasItem+PlayerHasItem+HasRetrieved trio

Now 15 hooks: IsRobeUnlocked/IsCharacterUnlocked/HasPerk/HasOutfit->true,
UserHasItem/PlayerHasItem->owned, GetCurrentTokens->9999999,
HasRetrievedUserItems->true, UpdateLocked*(6)+WaitForPurchasedPetValidation call-through.
Pets/perks/flashlights/emotes/outfits route through the game's own grant path — no
purchase browser, no coin spoofing visible anywhere.
2026-08-24 01:23:36 -04:00
6 changed files with 495 additions and 64 deletions

View file

@ -138,65 +138,26 @@ python3 "$(dirname "$0")/../standalone/devour_unlocker.py" unlock --all 2>&1 | t
echo "[*] Installed to $GAME_DIR"
# Auto-set Steam launch options for Proton silently (so user never needs manual WINEDLLOVERRIDES)
# Uses scripts/vdf_patch.py — bash-escaped heredoc python caused \\\" corruption before.
if [[ "$OS" == "linux" ]]; then
patched_any=0
for lc in "$HOME/.local/share/Steam/userdata/"*/config/localconfig.vdf; do
if [[ -f "$lc" ]] && grep -q '"1274570"' "$lc"; then
cp "$lc" "$lc.bak.$(date +%s)" 2>/dev/null || true
python3 -c "
import pathlib, re, sys
lc=sys.argv[1]
LAUNCH='WINEDLLOVERRIDES=\\\\\"version=n,b\\\\\" %command%'
p=pathlib.Path(lc)
txt=p.read_text(errors='ignore')
lines=txt.splitlines(True)
patched=0
i=0
while i < len(lines)-1:
if lines[i].strip()=='\"1274570\"' and lines[i].startswith('\t'*5) and lines[i+1].strip()=='{' and lines[i+1].startswith('\t'*5):
start=i+1; depth=1; j=i+2; found=None; found_idx=None; end=None
while j < len(lines):
s=lines[j].strip()
if s=='{': depth+=1
elif s=='}':
depth-=1
if depth==0: end=j; break
if s.startswith('\"LaunchOptions\"') and lines[j].startswith('\t'*6):
found=lines[j]; found_idx=j
j+=1
if end is None: i=j+1; continue
if found_idx is not None:
if 'version=n,b' in found and 'WINEDLLOVERRIDES' in found:
if '\\\\\"version=n,b\\\\\"' not in found or '%command%' not in found:
lines[found_idx]='\t'*6+'\"LaunchOptions\"\t\t\"'+LAUNCH+'\"\n'; print(f'[*] {lc}: fixed escaping/truncated'); patched+=1
else:
import re as _re
m=_re.search(r'\"LaunchOptions\"\s+\"([^\"]*)\"', found)
ex=m.group(1) if m else ''
exu=ex.replace('\\\\\"','\"')
if 'WINEDLLOVERRIDES' in exu:
new=exu.replace('winhttp=n,b','version=n,b').replace('winhttp=n','version=n,b')
if 'version=n,b' not in new:
new=f'WINEDLLOVERRIDES=\"version=n,b\" {exu}' if '%command%' not in exu else exu.replace('%command%','WINEDLLOVERRIDES=\"version=n,b\" %command%') if 'WINEDLLOVERRIDES' not in exu.split('%command%')[0] else exu
newe=new.replace('\"','\\\\\"')
lines[found_idx]='\t'*6+'\"LaunchOptions\"\t\t\"'+newe+'\"\n'; print(f'[*] {lc}: updated winhttp -> version'); patched+=1
else:
if '%command%' in exu: combined=exu.replace('%command%','WINEDLLOVERRIDES=\"version=n,b\" %command%') if exu.strip() and 'WINEDLLOVERRIDES' not in exu else f'WINEDLLOVERRIDES=\"version=n,b\" %command%'
else: combined=f'WINEDLLOVERRIDES=\"version=n,b\" {exu}' if exu.strip() else 'WINEDLLOVERRIDES=\"version=n,b\" %command%'
combe=combined.replace('\"','\\\\\"')
lines[found_idx]='\t'*6+'\"LaunchOptions\"\t\t\"'+combe+'\"\n'; print(f'[*] {lc}: prepended version'); patched+=1
else:
lines.insert(end, '\t'*6+'\"LaunchOptions\"\t\t\"'+LAUNCH+'\"\n'); print(f'[*] {lc}: inserted LaunchOptions'); patched+=1
i=end+1+(1 if found is None else 0); continue
i+=1
if patched: p.write_text(''.join(lines)); print(f'[+] {lc}: patched {patched} block(s)')
else: print(f'[=] {lc}: no Apps 1274570 block')
" "$lc" 2>&1 | head -n 10
[[ $? -eq 0 ]] && patched_any=1
if pgrep -x steam >/dev/null 2>&1; then
echo "[!] Steam is RUNNING — it will overwrite localconfig.vdf on exit and drop our LaunchOptions."
echo " Close Steam first: steam -shutdown then re-run install.sh"
fi
# PRIMARY (Steam-proof): per-prefix Wine registry override — Steam never touches user.reg
if [[ -f "$(dirname "$0")/prefix_override.py" ]]; then
python3 "$(dirname "$0")/prefix_override.py" 2>&1 | head -n 10
else
echo "[!] prefix_override.py missing — registry override not applied"
fi
# SECONDARY: Steam LaunchOptions (harmless if Steam drops it later)
if [[ -f "$(dirname "$0")/vdf_patch.py" ]]; then
python3 "$(dirname "$0")/vdf_patch.py" 2>&1 | head -n 20 && patched_any=1
else
echo "[!] vdf_patch.py missing — cannot auto-set Launch Options"
fi
done
if [[ $patched_any -eq 0 ]]; then echo "[!] No localconfig.vdf with Apps->1274570 found — launch once from Steam, then re-run install.sh"; fi
echo "[*] Linux/Proton: version.dll is sole unlocker beside DEVOUR.exe — Steam Launch Options now auto-set, just press Play (restart Steam if it was running). No manual WINEDLLOVERRIDES needed."
echo "[*] Linux/Proton: version.dll is sole unlocker beside DEVOUR.exe — Steam Launch Options now auto-set, just press Play. If Steam was running during this install: steam -shutdown, re-run install.sh, reopen Steam."
echo "[*] Fallback: bash $(dirname "$0")/launch.sh (also auto-sets WINEDLLOVERRIDES)"
else
echo "[*] Windows: version.dll beside DEVOUR.exe is auto-loaded (UnityPlayer.dll imports VERSION.dll) — no launch option needed. Just launch Devour."

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Persist a Wine DllOverrides entry ("version"="native,builtin") inside the
Devour Proton prefix registry (user.reg).
Steam cannot overwrite this (it only manages its own LaunchOptions), so the
unlocker loads on every launch with zero user configuration.
Must run while the game/wineserver is NOT running (wineserver writes user.reg
back on clean exit).
"""
import pathlib, re, sys
APPID = "1274570"
SECTION = '[Software\\\\Wine\\\\DllOverrides]' # literal double backslash in reg file
def find_prefixes(steam_home: pathlib.Path):
out = []
direct = steam_home / f"steamapps/compatdata/{APPID}/pfx/user.reg"
if direct.exists():
out.append(direct)
lf = steam_home / "steamapps/libraryfolders.vdf"
if lf.exists():
for m in re.finditer(r'"path"\s+"([^"]+)"', lf.read_text(errors="ignore")):
cand = pathlib.Path(m.group(1)) / f"steamapps/compatdata/{APPID}/pfx/user.reg"
if cand.exists() and cand not in out:
out.append(cand)
return out
def patch(user_reg: pathlib.Path) -> bool:
t = user_reg.read_text(encoding="utf-8", errors="ignore")
changed = False
if SECTION in t:
i = t.index(SECTION)
j = t.find("\n[", i + 1)
end = j if j != -1 else len(t)
body = t[i:end]
if '"version"' in body:
new_body = re.sub(r'"version"="[^"]*"', '"version"="native,builtin"', body)
if new_body != body:
changed = True
else:
new_body = body.rstrip("\n") + '\n"version"="native,builtin"\n\n'
changed = True
if changed:
t = t[:i] + new_body + t[end:]
else:
t = t.rstrip("\n") + "\n\n" + SECTION + ' 1788000000\n"version"="native,builtin"\n\n'
changed = True
if changed:
bak = user_reg.with_suffix(".reg.bak.devour")
bak.write_text(t, encoding="utf-8")
user_reg.write_text(t, encoding="utf-8")
print(f"[+] {user_reg}: version=native,builtin set ({'updated' if 'updated' in locals() else 'added'})")
else:
print(f"[=] {user_reg}: already native,builtin")
return changed
if __name__ == "__main__":
home = pathlib.Path.home()
steam = home / ".local/share/Steam"
prefixes = find_prefixes(steam)
if not prefixes:
print("[!] No Proton prefix for Devour found (launch the game once via Steam first)")
sys.exit(0)
for p in prefixes:
patch(p)

76
scripts/vdf_patch.py Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Patch Steam localconfig.vdf LaunchOptions for Devour (1274570).
Sets WINEDLLOVERRIDES="version=n,b" %command% inside every userdata/*/config/
localconfig.vdf Apps->1274570 block. Must be run while Steam is CLOSED — Steam
rewrites localconfig.vdf from memory on exit and discards external edits.
VDF escaping note: quotes inside values are a single backslash + quote (\").
Getting that through bash heredocs is what broke account 919954036 before
(it ended up as \\\"). This module builds bytes explicitly via chr(92).
"""
import pathlib, sys
APPID = "1274570"
BQ = chr(92) + '"' # backslash + quote
TARGET_LINE = (
"\t" * 6 + '"LaunchOptions"' + "\t\t"
+ '"WINEDLLOVERRIDES=' + BQ + 'version=n,b' + BQ + ' %command%"' + "\n"
)
def patch(path: pathlib.Path) -> bool:
lines = path.read_text(errors="ignore").splitlines(True)
changed = False
i = 0
while i < len(lines) - 1:
if (
lines[i].strip() == f'"{APPID}"'
and lines[i].startswith("\t" * 5)
and lines[i + 1].strip() == "{"
and lines[i + 1].startswith("\t" * 5)
):
depth = 1
j = i + 2
lo_i = None
end = None
while j < len(lines):
s = lines[j].strip()
if s == "{":
depth += 1
elif s == "}":
depth -= 1
if depth == 0:
end = j
break
if s.startswith('"LaunchOptions"') and lines[j].startswith("\t" * 6):
lo_i = j
j += 1
if end is None:
i = j + 1
continue
if lo_i is None:
lines.insert(end, TARGET_LINE)
print(f"{path.parent.parent.name}: inserted LaunchOptions")
changed = True
elif lines[lo_i] != TARGET_LINE:
print(f"{path.parent.parent.name}: fixed {lines[lo_i].strip()[:70]!r}")
lines[lo_i] = TARGET_LINE
changed = True
else:
print(f"{path.parent.parent.name}: already exact")
i = end + 1
continue
i += 1
if changed:
bak = path.with_suffix(".vdf.bak.devour")
bak.write_text("".join(lines))
path.write_text("".join(lines))
return changed
if __name__ == "__main__":
paths = [pathlib.Path(a) for a in sys.argv[1:]] or list(
pathlib.Path.home().glob(".local/share/Steam/userdata/*/config/localconfig.vdf")
)
for p in paths:
if p.exists():
patch(p)

View file

@ -17,6 +17,10 @@
namespace devour {
static std::set<uint64_t> g_queriedDefs;
std::set<uint64_t>& QueriedDefs(){ return g_queriedDefs; }
void RememberQueriedDef(uint64_t id){ g_queriedDefs.insert(id); }
#ifdef _WIN32
static HMODULE g_gameAsm = nullptr;
@ -212,6 +216,8 @@ ResolvedMethod Il2CppResolver::updateLockedRobes() { return resolve("Menu", "Upd
ResolvedMethod Il2CppResolver::waitForPurchasedPet() { return resolve("Menu", "WaitForPurchasedPetValidation", 1); }
ResolvedMethod Il2CppResolver::userHasItem() { return resolve("SteamInventoryManager", "UserHasItem", 1); }
ResolvedMethod Il2CppResolver::getCurrentTokens() { return resolve("Menu", "GetCurrentTokens", 0); }
ResolvedMethod Il2CppResolver::playerHasItem() { return resolve("SteamInventoryValidator", "PlayerHasItem", 2); }
ResolvedMethod Il2CppResolver::hasRetrievedUserItems() { return resolve("SteamInventoryManager", "HasRetrievedUserInventoryItems", 0); }
void Il2CppResolver::dumpDiagnostics(){
#ifdef _WIN32
@ -359,6 +365,227 @@ void Il2CppResolver::dumpDiagnostics(){
#endif
}
void Il2CppResolver::inspectInventoryState(){
#ifdef _WIN32
try {
char exe[MAX_PATH]; GetModuleFileNameA(nullptr, exe, MAX_PATH);
char* slash = strrchr(exe, '\\'); if(!slash) slash = strrchr(exe, '/');
std::string logPath = slash ? std::string(exe, slash - exe) + "\\DevourUnlocker.log" : "DevourUnlocker.log";
std::ofstream f(logPath, std::ios::app);
f << "[inv] inspectInventoryState BEGIN\n" << std::unitbuf;
auto domain_get = reinterpret_cast<void*(*)()>(getProc("il2cpp_domain_get"));
auto klass_from_name = reinterpret_cast<void*(*)(void*, const char*, const char*)>(getProc("il2cpp_class_from_name"));
auto field_from_name = reinterpret_cast<void*(*)(void*, const char*)>(getProc("il2cpp_class_get_field_from_name"));
auto field_static_get = reinterpret_cast<void(*)(void*, void*)>(getProc("il2cpp_field_static_get_value")); // (field, out)
auto field_get_value = reinterpret_cast<void(*)(void*, void*, void*)>(getProc("il2cpp_field_get_value"));
auto obj_get_class = reinterpret_cast<void*(*)(void*)>(getProc("il2cpp_object_get_class"));
auto class_get_name_fn = reinterpret_cast<const char*(*)(void*)>(getProc("il2cpp_class_get_name"));
auto arr_elem_size_cls = reinterpret_cast<int(*)(void*)>(getProc("il2cpp_class_array_element_size"));
auto fields_iter = reinterpret_cast<void*(*)(void*, void**)>(getProc("il2cpp_class_get_fields"));
auto field_name_fn = reinterpret_cast<const char*(*)(void*)>(getProc("il2cpp_field_get_name"));
auto field_off = reinterpret_cast<size_t(*)(void*)>(getProc("il2cpp_field_get_offset"));
if(!domain_get || !klass_from_name || !field_from_name || !field_static_get || !field_get_value || !obj_get_class || !class_get_name_fn){
f << "[inv] missing exports\n"; return;
}
void* domain = domain_get();
if(!domain){ f << "[inv] no domain\n"; return; }
// find SteamInventoryManager class across assemblies
auto dom_asm = reinterpret_cast<const void**(*)(void*, size_t*)>(getProc("il2cpp_domain_get_assemblies"));
auto asm_img = reinterpret_cast<void*(*)(void*)>(getProc("il2cpp_assembly_get_image"));
if(!dom_asm || !asm_img){ f << "[inv] no asm exports\n"; return; }
size_t n=0; const void** asms = dom_asm(domain,&n);
void* simClass=nullptr;
for(size_t i=0;i<n && !simClass;++i){
void* img=asm_img((void*)asms[i]); if(!img) continue;
simClass = klass_from_name(img,"","SteamInventoryManager");
}
if(!simClass){ f << "[inv] SteamInventoryManager class not found\n"; return; }
f << "[inv] SIM class " << class_get_name_fn(simClass) << "\n";
void* fInst = field_from_name(simClass,"instance");
if(!fInst){ f << "[inv] no 'instance' field\n"; return; }
void* mgr=nullptr;
field_static_get(fInst,&mgr);
if(!mgr){ f << "[inv] manager instance null (not Awake yet)\n"; return; }
f << "[inv] manager=" << mgr << "\n";
const char* containers[] = {"userItems","steamItems","m_SteamItemDetails",nullptr};
for(int ci=0;containers[ci];++ci){
void* fld = field_from_name(simClass,containers[ci]);
if(!fld){ f << "[inv] field "<<containers[ci]<<" missing\n"; continue; }
void* val=nullptr;
field_get_value(mgr,fld,&val);
if(!val){ f << "[inv] "<<containers[ci]<<" = null\n"; continue; }
void* vcls=obj_get_class(val);
f << "[inv] "<<containers[ci]<<" obj="<<val<<" class="<<(vcls?class_get_name_fn(vcls):"?\n");
f << "\n";
// If List<T>: _items @0x10, _size @0x18
unsigned char* lst=(unsigned char*)val;
void* arr = *(void**)(lst+0x10);
int32_t lsize = *(int32_t*)(lst+0x18);
f << "[inv] _items="<<arr<<" _size="<<lsize<<"\n";
if(!arr){ continue; }
void* arrCls = *(void**)arr;
int esz = arrCls && arr_elem_size_cls ? arr_elem_size_cls(arrCls) : 0;
const char* aname = arrCls? class_get_name_fn(arrCls):"?";
uint64_t maxlen = *(uint64_t*)((unsigned char*)arr+0x18);
f << "[inv] array class="<<aname<<" elemSize="<<esz<<" capacity="<<maxlen<<"\n";
}
f << "[inv] inspect END\n"; f.flush();
} catch(...){
try{ char e2[MAX_PATH]; GetModuleFileNameA(nullptr,e2,MAX_PATH); char* s2=strrchr(e2,'\\'); if(!s2) s2=strrchr(e2,'/'); if(s2){ std::ofstream f2(std::string(e2,s2-e2)+"\\DevourUnlocker.log",std::ios::app); f2<<"[inv] EXCEPTION during inspect (guarded)\n"; } }catch(...){}
}
#endif
}
int Il2CppResolver::fabricateOwnership(){
#ifdef _WIN32
int injected = -1; // -1 = not ready / error
try{
auto domain_get = reinterpret_cast<void*(*)()>(getProc("il2cpp_domain_get"));
auto thread_attach = reinterpret_cast<void*(*)(void*)>(getProc("il2cpp_thread_attach"));
auto klass_from_name = reinterpret_cast<void*(*)(void*, const char*, const char*)>(getProc("il2cpp_class_from_name"));
auto field_from_name = reinterpret_cast<void*(*)(void*, const char*)>(getProc("il2cpp_class_get_field_from_name"));
auto field_static_get = reinterpret_cast<void(*)(void*, void*)>(getProc("il2cpp_field_static_get_value"));
auto field_get_value = reinterpret_cast<void(*)(void*, void*, void*)>(getProc("il2cpp_field_get_value"));
auto obj_get_class = reinterpret_cast<void*(*)(void*)>(getProc("il2cpp_object_get_class"));
auto class_get_name_fn = reinterpret_cast<const char*(*)(void*)>(getProc("il2cpp_class_get_name"));
auto get_method = reinterpret_cast<void*(*)(void*, const char*, int)>(getProc("il2cpp_class_get_method_from_name"));
auto invoke_fn = reinterpret_cast<void*(*)(void*, void*, void**, void**)>(getProc("il2cpp_runtime_invoke"));
if(!domain_get || !thread_attach || !klass_from_name || !field_from_name || !field_static_get ||
!field_get_value || !obj_get_class || !class_get_name_fn || !get_method || !invoke_fn) return -1;
void* domain = domain_get();
if(!domain) return -1;
if(thread_attach) thread_attach(domain);
auto dom_asm = reinterpret_cast<const void**(*)(void*, size_t*)>(getProc("il2cpp_domain_get_assemblies"));
auto asm_img = reinterpret_cast<void*(*)(void*)>(getProc("il2cpp_assembly_get_image"));
if(!dom_asm || !asm_img) return -1;
size_t n=0; const void** asms = dom_asm(domain,&n);
// SteamInventoryManager class + Steamworks.SteamItemDef_t enum class
void* simClass=nullptr; void* defEnumClass=nullptr;
for(size_t i=0;i<n;++i){
void* img=asm_img((void*)asms[i]); if(!img) continue;
if(!simClass) simClass = klass_from_name(img,"","SteamInventoryManager");
if(!defEnumClass) defEnumClass = klass_from_name(img,"Steamworks","SteamItemDef_t");
if(simClass && defEnumClass) break;
}
if(!simClass) return -1;
void* fInst = field_from_name(simClass,"instance");
if(!fInst) return -1;
void* mgr=nullptr;
field_static_get(fInst,&mgr);
if(!mgr) return -1;
void* fUser = field_from_name(simClass,"userItems");
void* fCat = field_from_name(simClass,"steamItems");
if(!fUser || !fCat) return -1;
void* userList=nullptr; void* catList=nullptr;
field_get_value(mgr,fUser,&userList);
field_get_value(mgr,fCat,&catList);
if(!userList || !catList) return -1;
unsigned char* ul=(unsigned char*)userList;
void* uArr=*(void**)(ul+0x10); int32_t uSize=*(int32_t*)(ul+0x18);
unsigned char* cl=(unsigned char*)catList;
void* cArr=*(void**)(cl+0x10); int32_t cSize=*(int32_t*)(cl+0x18);
if(!cArr || cSize<=0) return -1;
// existing owned ids (List<SteamItemDef_t>, enum is 4-byte int despite array_element_size saying 8)
std::vector<uint64_t> have;
if(uArr){
unsigned char* data=(unsigned char*)uArr+0x20;
for(int i=0;i<uSize;++i) have.push_back((uint64_t)*(uint32_t*)(data+(size_t)i*4));
}
auto hasId=[&](uint64_t id){ for(auto v:have) if(v==id) return true; return false; };
if(uSize >= cSize && cSize>0) return 0; // already fully populated
void* addMethod = get_method(obj_get_class(userList),"Add",1);
if(!addMethod) return -1;
unsigned char* cData=(unsigned char*)cArr+0x20;
// DIAGNOSTIC: hex-dump sample ids so mismatches are visible
auto dumpIds=[&](const char* tag){
try{
char exe[MAX_PATH]; GetModuleFileNameA(nullptr,exe,MAX_PATH);
char* s=strrchr(exe,'\\'); if(!s) s=strrchr(exe,'/'); if(!s) return;
std::ofstream f(std::string(exe,s-exe)+"\\DevourUnlocker.log",std::ios::app);
f<<"[dbg] "<<tag<<":";
if(strcmp(tag,"have")==0){ for(size_t i=0;i<have.size()&&i<8;++i) f<<" "<<std::hex<<(unsigned long long)have[i]; }
else { for(int i=0;i<cSize&&i<8;++i){ void* ip=*(void**)(cData+(size_t)i*8); if(!ip) continue; uint64_t d=(uint64_t)*(uint32_t*)((unsigned char*)ip+0x10); f<<" "<<std::hex<<(unsigned long long)d; } }
f<<std::dec<<"\n";
}catch(...){}
};
dumpIds("have");
dumpIds("catalog");
injected=0;
int verified=0;
for(int i=0;i<cSize;++i){
void* itemPtr=*(void**)(cData+(size_t)i*8);
if(!itemPtr) continue;
uint64_t defId=(uint64_t)*(uint32_t*)((unsigned char*)itemPtr+0x10); // SteamInventoryItem.itemID @+0x10 (int-backed)
if(defId==0 || hasId(defId)) continue;
void* exc=nullptr; void* arg=&defId;
invoke_fn(addMethod,userList,&arg,&exc);
if(exc){ continue; }
have.push_back(defId);
++injected;
// verify first 3 adds actually stored the right value
if(verified<3){
void* uArrNow=*(void**)(ul+0x10); int32_t szNow=*(int32_t*)(ul+0x18);
if(uArrNow && szNow>0){
unsigned char* dNow=(unsigned char*)uArrNow+0x20;
uint64_t last=(uint64_t)*(uint32_t*)(dNow+(size_t)(szNow-1)*4);
try{
char exe[MAX_PATH]; GetModuleFileNameA(nullptr,exe,MAX_PATH);
char* s=strrchr(exe,'\\'); if(!s) s=strrchr(exe,'/');
if(s){ std::ofstream f(std::string(exe,s-exe)+"\\DevourUnlocker.log",std::ios::app);
f<<std::hex<<"[dbg] add sent="<<(unsigned long long)defId<<" stored_last="<<(unsigned long long)last<<" size="<<std::dec<<szNow<<"\n"; }
}catch(...){}
}
++verified;
}
}
try{
char exe[MAX_PATH]; GetModuleFileNameA(nullptr,exe,MAX_PATH);
char* s=strrchr(exe,'\\'); if(!s) s=strrchr(exe,'/');
if(s){
std::ofstream f(std::string(exe,s-exe)+"\\DevourUnlocker.log",std::ios::app);
static bool loggedCatalog=false;
if(!loggedCatalog && cSize>0){
loggedCatalog=true;
f<<"[own] catalog ids:";
for(int i=0;i<cSize;++i){ void* ip=*(void**)(cData+(size_t)i*8); if(!ip) continue; f<<" "<<*(uint32_t*)((unsigned char*)ip+0x10); }
f<<"\n";
}
// Self-learning: inject any def the game queried but that lives outside the
// store catalog (Supporter/DLC flashlights etc). Valid ids only - no sweeps.
int learned=0;
for(uint64_t qid : g_queriedDefs){
if(qid==0 || hasId(qid)) continue;
void* exc=nullptr; void* arg=&qid;
invoke_fn(addMethod,userList,&arg,&exc);
if(exc) continue;
have.push_back(qid);
++learned;
}
f<<"[own] fabricate: catalog="<<cSize<<" had="<<(uSize>0?uSize:(int)(have.size()-injected))<<" injected="<<injected<<" learned="<<learned<<"\n";
}
}catch(...){}
} catch(...){ injected=-1; }
return injected;
#else
return -1;
#endif
}
#else // Linux stubs
uintptr_t Il2CppResolver::gameAssemblyBase() { return 0; }
@ -382,6 +609,8 @@ ResolvedMethod Il2CppResolver::updateLockedRobes() { return {"UpdateLockedRobes"
ResolvedMethod Il2CppResolver::waitForPurchasedPet() { return {"WaitForPurchasedPetValidation", nullptr}; }
ResolvedMethod Il2CppResolver::userHasItem() { return {"UserHasItem", nullptr}; }
ResolvedMethod Il2CppResolver::getCurrentTokens() { return {"GetCurrentTokens", nullptr}; }
ResolvedMethod Il2CppResolver::playerHasItem() { return {"PlayerHasItem", nullptr}; }
ResolvedMethod Il2CppResolver::hasRetrievedUserItems() { return {"HasRetrievedUserInventoryItems", nullptr}; }
#endif

View file

@ -2,6 +2,7 @@
#include <cstdint>
#include <string>
#include <vector>
#include <set>
// Minimal IL2CPP types — avoids needing il2cpp headers at compile time
struct Il2CppString { void* klass; void* monitor; int32_t length; wchar_t chars[1]; };
@ -9,6 +10,12 @@ struct MethodInfo;
namespace devour {
// Def ids the game itself asked UserHasItem/PlayerHasItem about (DLC/promo defs
// absent from the store catalog live here). Fabricator injects these too.
std::set<uint64_t>& QueriedDefs();
void RememberQueriedDef(uint64_t id);
// Resolves IL2CPP method pointers without hard offsets.
// Strategy 1: via il2cpp exports (class/method lookup)
// Strategy 2: xref string sigscan as fallback
@ -42,7 +49,18 @@ public:
static ResolvedMethod waitForPurchasedPet();
static ResolvedMethod userHasItem(); // SteamInventoryManager.UserHasItem(steamItemDefID)
static ResolvedMethod getCurrentTokens(); // Horror.Menu.GetCurrentTokens()
static ResolvedMethod playerHasItem(); // SteamInventoryValidator.PlayerHasItem(connId, steamItemDefID)
static ResolvedMethod hasRetrievedUserItems(); // SteamInventoryManager.HasRetrievedUserInventoryItems()
static void dumpDiagnostics();
// Raw introspection of SteamInventoryManager singleton containers (userItems etc).
// Logs element type/size/sample bytes so we can fabricate ownership entries safely.
static void inspectInventoryState();
// Injects every catalog def id from steamItems into userItems (List<SteamItemDef_t>::Add).
// Returns number newly injected. Safe to call repeatedly — the game recreates the
// list on inventory refresh, so run this periodically.
static int fabricateOwnership();
// Accessors used by the inspector/fabricator
static void* getProcPub(const char* name) { return getProc(name); }
static uintptr_t gameAssemblyBase();
static size_t gameAssemblySize();
};

View file

@ -29,6 +29,8 @@ using IsUnlockedFn1 = bool(__fastcall*)(void* thiz, void* str, void* method);
using UpdateLockedFn = void(__fastcall*)(void* thiz, void* method);
using UserHasItemFn = bool(__fastcall*)(void* thiz, unsigned long long itemDef, void* method);
using GetTokensFn = int(__fastcall*)(void* thiz, void* method);
using PlayerHasItemFn = bool(__fastcall*)(void* thiz, int connectionId, unsigned long long itemDef, void* method);
using HasRetrievedFn = bool(__fastcall*)(void* thiz, void* method);
static IsUnlockedFn oIsRobe = nullptr;
static IsUnlockedFn1 oIsChar = nullptr;
@ -43,6 +45,8 @@ static UpdateLockedFn oUpdRobes = nullptr;
static UpdateLockedFn oWaitPet = nullptr;
static UserHasItemFn oUserHasItem = nullptr;
static GetTokensFn oGetTokens = nullptr;
static PlayerHasItemFn oPlayerHasItem = nullptr;
static HasRetrievedFn oHasRetrieved = nullptr;
static bool g_installed = false;
static bool g_mhInit = false;
@ -62,9 +66,18 @@ static std::string il2cppStringToUtf8(void* ilStr) {
}
static bool __fastcall hIsRobe(void* thiz, void* robeStr, void* characterStr, void* method) { return true; }
static bool __fastcall hIsChar(void* thiz, void* charStr, void* method) { return true; }
static bool __fastcall hHasPerk(void* thiz, void* perkStr, void* method) { return true; }
static bool __fastcall hHasOutfit(void* thiz, void* outfitStr, void* characterStr, void* method) { return true; }
static bool __fastcall hIsChar(void* thiz, void* charStr, void* method) {
logPatch((std::string("[trace] IsCharacterUnlocked(\"") + il2cppStringToUtf8(charStr) + "\") -> true").c_str());
return true;
}
static bool __fastcall hHasPerk(void* thiz, void* perkStr, void* method) {
logPatch((std::string("[trace] HasPerk(\"") + il2cppStringToUtf8(perkStr) + "\") -> true").c_str());
return true;
}
static bool __fastcall hHasOutfit(void* thiz, void* outfitStr, void* characterStr, void* method) {
logPatch((std::string("[trace] HasOutfit(\"") + il2cppStringToUtf8(outfitStr) + "\",\"" + il2cppStringToUtf8(characterStr) + "\") -> true").c_str());
return true;
}
// UpdateLocked*: call the ORIGINAL so the UI runs its normal flow — ownership checks
// (UserHasItem→true) make it mark everything unlocked instead of leaving defaults locked
static void __fastcall hUpdateLockedPets(void* thiz, void* method) { if (oUpdPets) oUpdPets(thiz, method); }
@ -74,12 +87,32 @@ static void __fastcall hUpdateLockedFlashlights(void* thiz, void* method) { if (
static void __fastcall hUpdateLockedEmotes(void* thiz, void* method) { if (oUpdEmotes) oUpdEmotes(thiz, method); }
static void __fastcall hUpdateLockedOutfits(void* thiz, void* method) { if (oUpdOutfits) oUpdOutfits(thiz, method); }
static void __fastcall hUpdateLockedRobes(void* thiz, void* method) { if (oUpdRobes) oUpdRobes(thiz, method); }
// Ownership override: every Steam catalog item (pets/flashlights/emotes/outfits/promos) counts as owned
// Ownership override: every Steam catalog item (pets/flashlights/emotes/outfits/promos) counts as owned.
// LOGS every query — if the pet UI never appears in the log, it reads the userItems list field directly.
static bool __fastcall hUserHasItem(void* thiz, unsigned long long itemDef, void* method) {
(void)itemDef;
// pass-through + trace (v2.1.0 proved forcing true blocks purchases)
RememberQueriedDef((uint64_t)itemDef);
char b[96]; snprintf(b,sizeof(b),"[trace] UserHasItem(def=%llu)", (unsigned long long)itemDef); logPatch(b);
if (oUserHasItem) return oUserHasItem(thiz, itemDef, method);
return false;
}
// Multiplayer validator: every player counts as owning every item (lobby validation path)
static bool __fastcall hPlayerHasItem(void* thiz, int connectionId, unsigned long long itemDef, void* method) {
// MUST stay pass-through: forcing true desyncs lobby validation vs injected
// userItems and UNLOCKS REGRESS (pets locked again). See v2.1.1 regression.
RememberQueriedDef((uint64_t)itemDef);
char b[128]; snprintf(b,sizeof(b),"[trace] PlayerHasItem(conn=%d def=%llu)", connectionId, (unsigned long long)itemDef); logPatch(b);
if (oPlayerHasItem) return oPlayerHasItem(thiz, connectionId, itemDef, method);
return false;
}
// Inventory-retrieved flag: pass through — forcing true with an empty userItems made some
// flows treat items as definitively NOT owned; original behavior is safer now
static bool __fastcall hHasRetrievedUserItems(void* thiz, void* method) {
if (oHasRetrieved) return oHasRetrieved(thiz, method);
return true;
}
// Perks bought with Tokens: report a huge balance so every perk is purchasable/selectable
// Perks bought with Tokens: huge balance — perk modal deducts locally, so every perk
// is instantly affordable. Store items don't use tokens (they route through Steam).
static int __fastcall hGetCurrentTokens(void* thiz, void* method) { return 9999999; }
static bool hookUpdateLocked(void* addr, const char* name, bool shouldUnlock, void (__fastcall *hook)(void*, void*), UpdateLockedFn* origOut) {
@ -148,6 +181,11 @@ bool installPatches() {
char buf[1024];
snprintf(buf, sizeof(buf), "[DevourUnlocker] Resolved IsRobe=%p IsChar=%p HasPerk=%p HasOutfit=%p", robe.address, ch.address, hasPerk.address, hasOutfit.address);
logPatch(buf);
{
HMODULE ga = GetModuleHandleA("GameAssembly.dll");
char b3[160]; snprintf(b3,sizeof(b3),"[rva] GameAssembly base=%p", (void*)ga);
logPatch(b3);
}
snprintf(buf, sizeof(buf), "[DevourUnlocker] UpdateLocked Pets=%p Perks=%p Flash=%p Emotes=%p Outfits=%p Robes=%p WaitPet=%p", updPets.address, updPerks.address, updFlash.address, updEmotes.address, updOutfits.address, updRobes.address, waitPet.address);
logPatch(buf);
}
@ -196,8 +234,10 @@ bool installPatches() {
{
auto uhi = Il2CppResolver::userHasItem();
auto tok = Il2CppResolver::getCurrentTokens();
auto phi = Il2CppResolver::playerHasItem();
auto hri = Il2CppResolver::hasRetrievedUserItems();
char b2[512];
snprintf(b2, sizeof(b2), "[DevourUnlocker] Resolved UserHasItem=%p GetCurrentTokens=%p", uhi.address, tok.address);
snprintf(b2, sizeof(b2), "[DevourUnlocker] Resolved UserHasItem=%p GetCurrentTokens=%p PlayerHasItem=%p HasRetrievedUserItems=%p", uhi.address, tok.address, phi.address, hri.address);
logPatch(b2);
if (uhi.valid()) {
void* orig = nullptr;
@ -217,6 +257,24 @@ bool installPatches() {
any = true;
} else logPatch("[DevourUnlocker] Hook GetCurrentTokens failed");
} else logPatch("[DevourUnlocker] GetCurrentTokens not found");
if (phi.valid()) {
void* orig = nullptr;
if (MH_CreateHook(phi.address, (void*)hPlayerHasItem, &orig) == MH_OK) {
MH_EnableHook(phi.address);
oPlayerHasItem = reinterpret_cast<PlayerHasItemFn>(orig);
logPatch("[DevourUnlocker] Hooked PlayerHasItem -> always owned");
any = true;
} else logPatch("[DevourUnlocker] Hook PlayerHasItem failed");
} else logPatch("[DevourUnlocker] PlayerHasItem not found");
if (hri.valid()) {
void* orig = nullptr;
if (MH_CreateHook(hri.address, (void*)hHasRetrievedUserItems, &orig) == MH_OK) {
MH_EnableHook(hri.address);
oHasRetrieved = reinterpret_cast<HasRetrievedFn>(orig);
logPatch("[DevourUnlocker] Hooked HasRetrievedUserInventoryItems -> true");
any = true;
} else logPatch("[DevourUnlocker] Hook HasRetrievedUserInventoryItems failed");
} else logPatch("[DevourUnlocker] HasRetrievedUserInventoryItems not found");
}
any |= hookUpdateLocked(updPets.address, "UpdateLockedPets", Config::get().unlockPets, hUpdateLockedPets, &oUpdPets);
any |= hookUpdateLocked(updPerks.address, "UpdateLockedPerks", Config::get().unlockPerks, hUpdateLockedPerks, &oUpdPerks);
@ -245,6 +303,8 @@ bool installPatches() {
auto pw = Il2CppResolver::waitForPurchasedPet();
auto uh = Il2CppResolver::userHasItem();
auto tk = Il2CppResolver::getCurrentTokens();
auto ph = Il2CppResolver::playerHasItem();
auto hr = Il2CppResolver::hasRetrievedUserItems();
bool any2=false;
if(r.valid()){ if(MH_CreateHook(r.address,(void*)hIsRobe,(void**)&oIsRobe)==MH_OK){ MH_EnableHook(r.address); any2=true; logPatch("[DevourUnlocker] Retry hooked IsRobeUnlocked"); } }
if(c.valid()){ if(MH_CreateHook(c.address,(void*)hIsChar,(void**)&oIsChar)==MH_OK){ MH_EnableHook(c.address); any2=true; logPatch("[DevourUnlocker] Retry hooked IsCharacterUnlocked"); } }
@ -252,6 +312,8 @@ bool installPatches() {
if(ho.valid()){ if(MH_CreateHook(ho.address,(void*)hHasOutfit,(void**)&oHasOutfit)==MH_OK){ MH_EnableHook(ho.address); any2=true; logPatch("[DevourUnlocker] Retry hooked HasOutfit"); } }
if(uh.valid()){ void* og=nullptr; if(MH_CreateHook(uh.address,(void*)hUserHasItem,&og)==MH_OK){ MH_EnableHook(uh.address); oUserHasItem=reinterpret_cast<UserHasItemFn>(og); any2=true; logPatch("[DevourUnlocker] Retry hooked UserHasItem"); } }
if(tk.valid()){ void* og=nullptr; if(MH_CreateHook(tk.address,(void*)hGetCurrentTokens,&og)==MH_OK){ MH_EnableHook(tk.address); oGetTokens=reinterpret_cast<GetTokensFn>(og); any2=true; logPatch("[DevourUnlocker] Retry hooked GetCurrentTokens"); } }
if(ph.valid()){ void* og=nullptr; if(MH_CreateHook(ph.address,(void*)hPlayerHasItem,&og)==MH_OK){ MH_EnableHook(ph.address); oPlayerHasItem=reinterpret_cast<PlayerHasItemFn>(og); any2=true; logPatch("[DevourUnlocker] Retry hooked PlayerHasItem"); } }
if(hr.valid()){ void* og=nullptr; if(MH_CreateHook(hr.address,(void*)hHasRetrievedUserItems,&og)==MH_OK){ MH_EnableHook(hr.address); oHasRetrieved=reinterpret_cast<HasRetrievedFn>(og); any2=true; logPatch("[DevourUnlocker] Retry hooked HasRetrievedUserInventoryItems"); } }
if(p1.valid()) any2 |= hookUpdateLocked(p1.address, "UpdateLockedPets[retry]", Config::get().unlockPets, hUpdateLockedPets, &oUpdPets);
if(p2.valid()) any2 |= hookUpdateLocked(p2.address, "UpdateLockedPerks[retry]", Config::get().unlockPerks, hUpdateLockedPerks, &oUpdPerks);
if(p3.valid()) any2 |= hookUpdateLocked(p3.address, "UpdateLockedFlashlights[retry]", Config::get().unlockFlashlights, hUpdateLockedFlashlights, &oUpdFlash);
@ -271,6 +333,25 @@ bool installPatches() {
}
g_installed = any;
logPatch(any ? "[DevourUnlocker] Patches installed (some may retry)" : "[DevourUnlocker] No patches installed");
// Ownership fabricator + introspection thread:
// - every 15s inject missing catalog ids into userItems (list is recreated on refresh)
// - one layout dump at 30s for diagnostics
CreateThread(nullptr, 0, [](LPVOID)->DWORD{
Sleep(30000);
logPatch("[DevourUnlocker] inspectInventoryState @30s");
Il2CppResolver::inspectInventoryState();
int total=0;
for(int cycle=0; cycle<240; ++cycle){ // ~60 min of refills
Sleep(15000);
int n = Il2CppResolver::fabricateOwnership();
if(n>0){
total+=n;
char b[128]; snprintf(b,sizeof(b),"[DevourUnlocker] ownership injected +%d (session total %d)",n,total);
logPatch(b);
}
}
return 0;
}, nullptr, 0, nullptr);
return any;
}