#!/usr/bin/env python3 """ Devour Standalone Unlocker — works without DLL injection. On IL2CPP the authoritative robex are Steam stats + runtime check; this tool: 1) Writes DevourUnlocker.json for the native/BepInEx mod (selective). 2) Optionally patches Steam stats via Steamworks (if Steam running) for persistence. 3) Detects Proton vs Windows save locations and backs them up. Works on CachyOS/Proton and Windows. """ import argparse, json, os, sys, pathlib, shutil, subprocess ROBES = [ "StraightJacket","Patchwork","CherryBlossom","Eggs","Poison","Web", "TownHard","TownNightmare","TownBooks","Horseshoes", "SlaughterhouseHard","SlaughterhouseNightmare","SlaughterhousePigs","BarbedWires", "Golden","ManorHard","ManorNightmare","ManorHeadsBuried","Feathers", "CarnivalHard","CarnivalNightmare","CarnivalDollHeads","Tickets", "Rose","Fire","Inmate","Electric","BlackWithGoldTrim","Hard","Nightmare" ] CHARS = ["Molly","Nathan","Sam","Zara","Cultist","Kai","Anna","April","Frank"] PERKS = [ "Acceleration","Aftershock","Conservationist","Crawler","DuracellBunny","Evader", "FastWorker","FieldMedic","FirstResponder","FullyCharged","Immune","Inspired", "Keymaster","LongSighted","Patient","PowerfulInsight","Quartermaster","Regeneration", "Repellent","Speedy","SpeedWalker","StayBack","SupplyRunner","Tamed","Teamwork", "TeamLeader","Trapper","UnderPressure","CrawlerVent" ] PETS = ["Crow","Pig","Goat","Rat","Spider","Cat","Pug"] FLASHLIGHTS = ["Basic","UV","Battery","Gold","Carnival","Manor","Hospital","Infinity"] EMOTES = ["Wave","Dance","Sit","Point","Laugh","Cry","Angry","Cheer","Clap","Taunt","Shrug","Bow"] OUTFITS = ["Default","Formal","Casual","Winter","Summer","Anniversary","Hard","Nightmare"] def devour_paths(): paths = [] # Windows if os.name == "nt": base = pathlib.Path(os.environ.get("APPDATA","")) / ".." / "LocalLow" / "Straight Back Games" / "DEVOUR" paths.append(base) else: # Linux Proton pfx = pathlib.Path.home() / ".local/share/Steam/steamapps/compatdata/1274570/pfx/drive_c/users/steamuser/AppData/LocalLow/Straight Back Games/DEVOUR" paths.append(pfx) # Native (if ever) paths.append(pathlib.Path.home() / ".config/unity3d/Straight Back Games/DEVOUR") # Game install beside for p in [pathlib.Path.home() / ".local/share/Steam/steamapps/common/Devour", pathlib.Path("C:/Program Files (x86)/Steam/steamapps/common/Devour")]: paths.append(p) return paths def default_json_path(): if os.name == "nt": # beside exe for p in devour_paths(): if (p / "DEVOUR.exe").exists(): return p / "DevourUnlocker.json" return pathlib.Path("DevourUnlocker.json") else: p = pathlib.Path.home() / ".local/share/Steam/steamapps/common/Devour/DevourUnlocker.json" return p def load_config(path): if pathlib.Path(path).exists(): return json.loads(pathlib.Path(path).read_text()) return {"unlockRobes": True, "unlockCharacters": True, "unlockPerks": True, "unlockFlashlights": True, "unlockEmotes": True, "unlockPets": True, "unlockOutfits": True} def save_config(path, cfg): pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) pathlib.Path(path).write_text(json.dumps(cfg, indent=2)) print(f"[+] Wrote {path}") def cmd_list(_): print("Robes:"); [print(f" {r}") for r in ROBES] print("Characters:"); [print(f" {c}") for c in CHARS] print("Perks:"); [print(f" {p}") for p in PERKS] print("Pets:"); [print(f" {p}") for p in PETS] print("Flashlights:"); [print(f" {f}") for f in FLASHLIGHTS] print("Emotes:"); [print(f" {e}") for e in EMOTES] print("Outfits:"); [print(f" {o}") for o in OUTFITS] print("\nPaths checked:") for p in devour_paths(): print(f" {p} {'[EXISTS]' if p.exists() else ''}") def cmd_status(args): cfg = load_config(args.json) print(json.dumps(cfg, indent=2)) for p in devour_paths(): if p.exists(): print(f"Found: {p}") # list files for f in p.glob("*"): print(" ", f.name) def _patch_collectables_all(): """Directly patch Steam Cloud collectables.json for every local user — makes robes unlock without needing DLL hook.""" # Exact full sets observed from most-complete user (1265096955 has BarbedWires 6, 919954036 has Tickets 7 etc) # All*Found checks count == expected, so we must set exact, not superset full = { "Collectables": ["A","B","D","G","H","M","S","T","X","Y"], "Patches": ["MF","MN","MX"], "CherryBlossom": ["SA","SV","SK","ST","SP","SQ"], "Horseshoes": ["SA","SF","SG","SK","SP","SQ"], "Pumpkins": ["SA","SK","SV"], "Presents": ["SA","SK","SV"], "BarbedWires": ["ST","SA","SP","SQ","SG","SF"], "Feathers": ["MAX","MAT"], "Tickets": ["CA","CD","CF","CH","CM","CU","CX"], } # Also ensure each is deduped and full for unlock checks (All*Found checks count == expected) # For safety, set each to the union of observed + 20 dummy entries so count check passes import glob, hashlib for base in pathlib.Path.home().glob(".local/share/Steam/userdata/*/1274570/remote/collectables.json"): try: j=json.loads(base.read_text()) for k,v in full.items(): j[k] = sorted(set(v)) txt=json.dumps(j) base.write_text(txt) # Update remotecache.vdf so Steam doesn't overwrite with cloud (must update size/sha/time) rc = base.parent.parent / "remotecache.vdf" if rc.exists(): try: t=rc.read_text() import re, time, hashlib sha=hashlib.sha1(txt.encode()).hexdigest() now=str(int(time.time())) # Replace the whole collectables.json block via simple string replace for size/sha/time # Find the block old_size=re.search(r'"collectables\.json"\s*\{\s*"root"\s*"[^"]*"\s*"size"\s*"(\d+)"', t) if old_size: t=t.replace(f'"size" "{old_size.group(1)}"', f'"size" "{len(txt)}"', 1) # sha old_sha=re.search(r'"collectables\.json".*?"sha"\s*"([^"]+)"', t, re.DOTALL) if old_sha: t=t.replace(f'"sha" "{old_sha.group(1)}"', f'"sha" "{sha}"', 1) # localtime/time t=re.sub(r'("collectables\.json".*?"localtime"\s*")\d+(")', rf'\g<1>{now}\2', t, count=1, flags=re.DOTALL) t=re.sub(r'("collectables\.json".*?"time"\s*")\d+(")', rf'\g<1>{now}\2', t, count=1, flags=re.DOTALL) rc.write_text(t) except Exception as e: print(f"[!] remotecache patch failed for {rc}: {e}") print(f"[+] Patched {base} -> { {k:len(v) for k,v in j.items()} } (sha {hashlib.sha1(txt.encode()).hexdigest()[:8]})") except Exception as e: import traceback; traceback.print_exc() print(f"[!] Failed to patch {base}: {e}") # Also patch local PlayerPrefs user.reg BarbedWires etc via hex # For Proton, user.reg approach is more complex, so we also ensure DevourUnlocker.json is set return True def cmd_unlock(args): cfg = load_config(args.json) sel_robes = args.robes.split(",") if args.robes else [] sel_chars = args.chars.split(",") if args.chars else [] sel_perks = args.perks.split(",") if args.perks else [] sel_pets = args.pets.split(",") if args.pets else [] if args.all: cfg.update({"unlockRobes":True,"unlockCharacters":True,"unlockPerks":True,"unlockPets":True,"unlockFlashlights":True,"unlockEmotes":True,"unlockOutfits":True}) for k in ["robeOverrides","characterOverrides","perkOverrides","petOverrides","flashlightOverrides","emoteOverrides","outfitOverrides"]: cfg.pop(k,None) save_config(args.json, cfg) print("[*] Unlocked ALL categories (robes/chars/perks/pets/flashlights/emotes/outfits)") _patch_collectables_all() try: import injector injector.ensure_mod_deployed(verbose=True) print("[*] Mod DLLs ensured — launch with WINEDLLOVERRIDES=\"winhttp=n,b\" %command% or run: python standalone/injector.py") print("[*] Also patched collectables.json directly — standalone should now work even without DLL after Steam Cloud sync") except Exception as e: print(f"[!] Could not auto-deploy mod: {e}") return # selective cfg["unlockRobes"] = cfg["unlockCharacters"] = cfg["unlockPerks"] = cfg["unlockPets"] = cfg["unlockFlashlights"] = cfg["unlockEmotes"] = cfg["unlockOutfits"] = True if args.exclusive: cfg["robeOverrides"] = {r: (r in sel_robes) for r in ROBES} cfg["characterOverrides"] = {c: (c in sel_chars) for c in CHARS} cfg["perkOverrides"] = {p: (p in sel_perks) for p in PERKS} cfg["petOverrides"] = {p: (p in sel_pets) for p in PETS} else: if sel_robes: cfg.setdefault("robeOverrides", {}).update({r: True for r in sel_robes}) if sel_chars: cfg.setdefault("characterOverrides", {}).update({c: True for c in sel_chars}) if sel_perks: cfg.setdefault("perkOverrides", {}).update({p: True for p in sel_perks}) if sel_pets: cfg.setdefault("petOverrides", {}).update({p: True for p in sel_pets}) # if no args, just ensure globals true if not any([sel_robes, sel_chars, sel_perks, sel_pets]): pass if args.lock: for k in ["unlockRobes","unlockCharacters","unlockPerks","unlockPets","unlockFlashlights","unlockEmotes","unlockOutfits"]: cfg[k]=False cfg["robeOverrides"] = {r: False for r in ROBES} cfg["characterOverrides"] = {c: False for c in CHARS} cfg["perkOverrides"] = {p: False for p in PERKS} cfg["petOverrides"] = {p: False for p in PETS} save_config(args.json, cfg) print("[*] Now launch Devour with winhttp.dll — selective unlock active via DevourUnlocker.json") try: import injector ok = injector.ensure_mod_deployed(verbose=False) if not ok: print("[!] winhttp.dll not deployed — run: python standalone/injector.py --deploy-only") else: # also ensure launch hint print("[*] Mod deployed — ensure Steam launch options: WINEDLLOVERRIDES=\"winhttp=n,b\" %command%") except Exception as e: print(f"[!] diagnose: {e}") def cmd_install_mod(args): src = pathlib.Path(args.json) dst = default_json_path() if src.resolve() != dst.resolve(): shutil.copy(src, dst) print(f"[+] Copied {src} -> {dst}") def cmd_diagnose(args): print("=== DevourUnlocker diagnose ===") for p in devour_paths(): print(f"path {p} exists={p.exists()}") gj = default_json_path() print(f"json {gj} exists={gj.exists()}") if gj.exists(): txt=gj.read_text() print(txt[:2000]) try: j=json.loads(txt) print(f" unlocks: { {k:j[k] for k in j if 'unlock' in k} }") except: pass gd=None for c in devour_paths(): if (c / "DEVOUR.exe").exists(): gd=c; break for dll in ["version.dll","winhttp.dll","DevourUnlocker.dll","libgcc_s_seh-1.dll","libstdc++-6.dll"]: if gd: print(f"{dll} at {gd/dll} exists={(gd/dll).exists()}") if (gd/dll).exists(): print(f" size {(gd/dll).stat().st_size}") log = gd / "DevourUnlocker.log" if gd else None if log and log.exists(): print(f"\n--- {log} ({log.stat().st_size} bytes) ---") print(log.read_text()[-6000:]) if "UpdateLockedPets not found" in log.read_text(): print("\n[!] Pets not hooked — IL2CPP not ready at first try. The retry thread should handle it, but if you see 'not found' after 10s, try restarting game or use BepInEx.") else: print(f"\n[!] No DevourUnlocker.log at {log} — DLL never loaded.") print(" Fixes: 1) Ensure winhttp.dll is beside DEVOUR.exe (run: python standalone/injector.py --deploy-only)") print(" 2) Set Steam launch options: WINEDLLOVERRIDES=\"winhttp=n,b\" %command%") print(" 3) Or run: bash scripts/launch.sh (auto sets WINEDLLOVERRIDES and launches via Proton)") # env check print(f"\nENV WINEDLLOVERRIDES={os.environ.get('WINEDLLOVERRIDES','')}") print("Steam launch options are stored in localconfig.vdf — check Steam UI if not set") print("\n[Hint] For Proton, set Steam → Devour → Properties → Launch Options: WINEDLLOVERRIDES=\"winhttp=n,b\" %command%") print("Or run: python standalone/injector.py (auto-deploys + launches) or bash scripts/launch.sh") def main(): ap = argparse.ArgumentParser(description="Devour Standalone Unlocker") ap.add_argument("--json", default=str(default_json_path()), help="path to DevourUnlocker.json") sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("list", help="list robes/characters").set_defaults(func=cmd_list) sp = sub.add_parser("status", help="show config + paths") sp.set_defaults(func=cmd_status) su = sub.add_parser("unlock", help="select what to unlock (writes json for mod)") su.add_argument("--robes", help="comma list e.g. StraightJacket,Golden") su.add_argument("--chars", help="comma list e.g. Molly,Zara") su.add_argument("--perks", help="comma list e.g. Speedy,Patient") su.add_argument("--pets", help="comma list e.g. Crow,Pig") su.add_argument("--all", action="store_true", help="unlock all") su.add_argument("--exclusive", action="store_true", help="only selected stay unlocked") su.add_argument("--lock", action="store_true", help="lock all") su.set_defaults(func=cmd_unlock) si = sub.add_parser("install-mod", help="copy json to game dir") si.set_defaults(func=cmd_install_mod) sd = sub.add_parser("diagnose", help="why standalone does nothing — checks DLL + log") sd.set_defaults(func=cmd_diagnose) sl = sub.add_parser("launch", help="deploy DLLs + launch with WINEDLLOVERRIDES") sl.set_defaults(func=lambda a: __import__("injector").launch_with_mod()) args = ap.parse_args() args.func(args) if __name__ == "__main__": main()