#!/usr/bin/python3
import hashlib
import json
import os
import re
import runpy
import subprocess
import sys
import tempfile
from pathlib import Path

GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")


def game_id(env):
    prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
    if not prefix:
        return None
    value = prefix.rsplit("/", 1)[-1]
    if not re.fullmatch(r"-?[0-9]+", value):
        return None
    number = int(value)
    if not -(2**31) <= number < 2**32 or number == 0:
        return None
    return str(number % 2**32)


DOTNET_FEX = {
    "FEX_TSOENABLED": "1",
    "FEX_VECTORTSOENABLED": "1",
    "FEX_MEMCPYSETTSOENABLED": "1",
    "FEX_HALFBARRIERTSOENABLED": "1",
    "FEX_X87REDUCEDPRECISION": "0",
    "FEX_MULTIBLOCK": "0",
}


def game_files(exe):
    """Lower-cased names of the files next to the game's executable and one directory below."""
    names = set()
    folder = os.path.dirname(exe)
    try:
        with os.scandir(folder) as top:
            for entry in top:
                names.add(entry.name.lower())
                if entry.is_dir(follow_symlinks=False):
                    try:
                        with os.scandir(entry.path) as inner:
                            names.update(e.name.lower() for e in inner)
                    except OSError:
                        pass
    except OSError:
        pass
    return names


class Game:
    """What a fix gets to look at: the executable, its launch arguments and the files beside it."""

    def __init__(self, exe, args):
        self.exe = exe
        self.args = args
        self.stem = os.path.splitext(os.path.basename(exe))[0].lower()
        self.files = game_files(exe)

    def has_arg(self, name):
        return any(a == name or a.startswith(name + "=") for a in self.args)


FIXES = []


def fix(function):
    FIXES.append(function)
    return function


def is_godot(game):
    return game.stem + ".pck" in game.files or "godotsharp.dll" in game.files


@fix
def dotnet_memory_ordering(game):
    if "coreclr.dll" in game.files:
        return DOTNET_FEX, [], ".NET runtime: FEX with full TSO and without multiblock"


@fix
def unity_memory_ordering(game):
    if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files:
        return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO"


@fix
def godot_vulkan(game):
    if is_godot(game) and not game.has_arg("--rendering-driver"):
        return {}, ["--rendering-driver", "vulkan"], "Godot: Vulkan renderer"


@fix
def godot_sentry(game):
    sentry = sorted(n[:-4] for n in game.files if n.startswith("libsentry.windows.") and n.endswith(".dll"))
    if is_godot(game) and sentry and ("coreclr.dll" in game.files or "godotsharp.dll" in game.files):
        return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"


@fix
def d3dgear_hook(game):
    """D3DGear's capture DLL left beside a game (The Crew 2) is loaded by it and hooks the DXGI swapchain;
    under Wine the hook faults over a thousand times within seconds and the game never presents again:
    black screen while the sound keeps playing."""
    if "d3dgear64.dll" in game.files:
        return {"WINEDLLOVERRIDES": "d3dgear64=d"}, [], "D3DGear capture hook off (it stops the game drawing)"


@fix
def deep_rock_gdk_raytracing(game):
    if game.stem == "fsd-wingdk-shipping" and not any(game.has_arg(arg) for arg in ("-raytracing", "-noraytracing")):
        return {}, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"], "Deep Rock Galactic GDK: ray tracing off (D3D12 pipeline creation fails on Thor)"


@fix
def arkham_city_x87(game):
    """Batman: Arkham City (BatmanAC.exe, also started through BmLauncher.exe beside it) leans on x87
    and stops with Unreal's "Fatal error!" right after start when FEX reduces its precision."""
    if "batmanac.exe" in game.files:
        return {"FEX_X87REDUCEDPRECISION": "0"}, [], "Batman: Arkham City: FEX with full x87 precision"



@fix
def protonfixes_off(game):
    if game.stem in ("blackops3", "darksoulsremastered"):
        return {"PROTONFIXES_DISABLE": "1"}, [], "umu-protonfixes off (its ALSA audio and VC++ installer steps fail here)"


@fix
def caravan_sandwitch_feature_level(game):
    if "caravansandwitch.exe" in game.files or game.stem == "caravansandwitch-win64-shipping":
        return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "Caravan SandWitch: D3D12 feature level 12_1 (12_2 hands it mesh shader pipelines vkd3d-proton cannot build)"


@fix
def robocop_rogue_city_feature_level(game):
    """vkd3d-proton turns mesh shaders off on Adreno, but 12_2 still advertises them, and UE5 then builds mesh
    pipelines with no pipeline layout that crash Turnip on launch."""
    if "robocop.exe" in game.files or game.stem == "robocop-win64-shipping":
        return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "RoboCop: Rogue City: D3D12 feature level 12_1"


@fix
def reskate_feature_level(game):
    if "skate.exe" in game.files and "reskate.dll" in game.files:
        return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "ReSkate (skate.): D3D12 feature level 12_1"


@fix
def cyberpunk_feature_level(game):
    if game.stem == "cyberpunk2077" or (
        game.stem == "redprelauncher"
        and os.path.isfile(os.path.join(os.path.dirname(game.exe), "bin", "x64", "Cyberpunk2077.exe"))
    ):
        return {"VKD3D_FEATURE_LEVEL": "12_1"}, [], "Cyberpunk 2077: D3D12 feature level 12_1"

def skip_red_launcher(command):
    """Cyberpunk 2077's prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the
    game half a minute later and then stays up beside it, well over a core busy for as long as the
    game runs. The game is started itself instead; the prelauncher's own --launcher-skip started it
    twice."""
    if len(command) > 2 and os.path.basename(command[2]).lower() == "redprelauncher.exe":
        game = os.path.join(os.path.dirname(command[2]), "bin", "x64", "Cyberpunk2077.exe")
        if os.path.isfile(game):
            print("droiddeck-game-env: REDlauncher skipped", file=sys.stderr)
            return command[:2] + [game] + command[3:]
    return command


UBISOFT_LAUNCHER = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"
UBISOFT_SETTINGS = "drive_c/users/steamuser/AppData/Local/Ubisoft Game Launcher/settings.yaml"


def overlay_off(text):
    lines = text.splitlines()
    try:
        start = next(i for i, line in enumerate(lines) if re.fullmatch(r"overlay:\s*(#.*)?", line))
    except StopIteration:
        if lines and not lines[-1].strip():
            lines.pop()
        return "\n".join(lines + ["overlay:", "  enabled: false", "  warning_enabled: false"]) + "\n"
    end = start + 1
    for index in range(start + 1, len(lines)):
        if lines[index].strip() and not lines[index].startswith("  "):
            break
        if lines[index].strip():
            end = index + 1
    block = lines[start + 1:end]
    for key in ("enabled", "warning_enabled"):
        entry = "  %s: false" % key
        found = [i for i, line in enumerate(block) if line.split(":", 1)[0] == "  " + key]
        if found:
            block[found[0]] = entry
        else:
            block.append(entry)
    return "\n".join(lines[:start + 1] + block + lines[end:]) + "\n"


def ubisoft_overlay_off(env):
    prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
    if not (prefix / UBISOFT_LAUNCHER).is_dir():
        return
    settings = prefix / UBISOFT_SETTINGS
    try:
        text = settings.read_text(encoding="utf-8") if settings.exists() else ""
        updated = overlay_off(text)
        if updated == text:
            return
        settings.parent.mkdir(parents=True, exist_ok=True)
        handle, staged = tempfile.mkstemp(prefix=".settings-", dir=settings.parent)
        try:
            with os.fdopen(handle, "w", encoding="utf-8") as target:
                target.write(updated)
            if settings.exists():
                os.chmod(staged, settings.stat().st_mode & 0o7777)
            os.replace(staged, settings)
        except BaseException:
            os.unlink(staged)
            raise
        print("droiddeck-game-env: Ubisoft Connect overlay turned off", file=sys.stderr)
    except (OSError, UnicodeDecodeError) as error:
        print("droiddeck-game-env: Ubisoft Connect overlay left as is (%s)" % error, file=sys.stderr)


def offline_without_anticheat(command, env):
    """Start the configured game executable when the player opts into launching without EAC."""
    if (env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2
            or command[1] != "waitforexitandrun"
            or not re.fullmatch(r"start_protected_game(?:_?x64|_?64)?\.exe",
                                os.path.basename(command[2]), re.IGNORECASE)):
        return command
    folder = os.path.dirname(command[2])
    try:
        with open(os.path.join(folder, "EasyAntiCheat", "settings.json"), encoding="utf-8-sig") as source:
            executable = json.load(source).get("executable")
    except (OSError, ValueError, AttributeError):
        return command
    if not isinstance(executable, str) or not executable:
        return command
    relative = os.path.normpath(executable.replace("\\", "/"))
    game = os.path.join(folder, relative)
    if os.path.isabs(relative) or relative.startswith("..") or not os.path.isfile(game):
        return command
    # Resolve symlinks before accepting an executable inside the game directory.
    root = os.path.realpath(folder)
    if os.path.commonpath((root, os.path.realpath(game))) != root:
        return command
    if os.path.normcase(os.path.abspath(game)) == os.path.normcase(os.path.abspath(command[2])):
        return command
    print("droiddeck-game-env: started without Easy Anti-Cheat (offline only)", file=sys.stderr)
    return command[:2] + [game] + command[3:]


def engine_fixes(exe, args):
    """Environment and extra arguments a game needs because of what it is built with."""
    game = Game(exe, args)
    env, extra, notes = {}, [], []
    for check in FIXES:
        found = check(game)
        if found:
            for name, value in found[0].items():
                if name == "WINEDLLOVERRIDES" and env.get(name):
                    value = env[name] + ";" + value
                env[name] = value
            extra += found[1]
            notes.append(found[2])
    return env, extra, notes


def win_components(compat, app_id):
    """The game's Windows components put into its prefix; returns their WINEDLLOVERRIDES entries."""
    if not compat:
        return ""
    try:
        module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents"))
        names, notes = module["apply"](compat.rstrip("/"), app_id)
        for note in notes:
            print("droiddeck-game-env: " + note, file=sys.stderr)
        return module["overrides_value"](names)
    except Exception as error:
        print("droiddeck-game-env: Windows components not applied (%s)" % error, file=sys.stderr)
        return ""


def game_gpu(env):
    if GAME_TU_DEBUG not in env:
        return env
    result = env.copy()
    value = result.pop(GAME_TU_DEBUG)
    if value:
        result["TU_DEBUG"] = value
    else:
        result.pop("TU_DEBUG", None)
    return result


def vram_cap(env):
    """Puts the session's VRAM cap ahead of the game's own DXVK settings, so a game profile can still raise it."""
    value = env.get("BL_VRAM_MB", "")
    if not re.fullmatch(r"[1-9][0-9]{0,6}", value):
        return env
    result = env.copy()
    cap = "dxgi.maxDeviceMemory = %d" % int(value)
    own = result.get("DXVK_CONFIG", "").strip().strip(";").strip()
    result["DXVK_CONFIG"] = cap + "; " + own if own else cap
    return result


def parse_cores(text):
    """CPU numbers from a list such as "0-3,6"; None when the text is not one."""
    cores = set()
    for part in text.replace(" ", "").split(","):
        match = re.fullmatch(r"(\d{1,3})(?:-(\d{1,3}))?", part)
        if not match:
            return None
        first, last = int(match.group(1)), int(match.group(2) or match.group(1))
        if last < first:
            return None
        cores.update(range(first, last + 1))
    return cores or None


def online_cores():
    try:
        with open("/sys/devices/system/cpu/online") as f:
            cores = parse_cores(f.read().strip())
    except OSError:
        cores = None
    return cores or os.sched_getaffinity(0) | set(range(os.cpu_count() or 0))


def game_cores(env):
    """Pins the game to the cores its profile names, in place of the session's game cores."""
    text = env.get("DROIDDECK_GAME_CORES", "").strip()
    if not text:
        return
    cores = parse_cores(text)
    chosen = cores & online_cores() if cores else None
    if not chosen:
        print("droiddeck-game-env: ignoring game cores %r" % text, file=sys.stderr)
        return
    try:
        os.sched_setaffinity(0, chosen)
        print("droiddeck-game-env: game cores %s" % ",".join(map(str, sorted(chosen))), file=sys.stderr)
    except OSError as error:
        print("droiddeck-game-env: could not use game cores %r (%s)" % (text, error), file=sys.stderr)


def core_speeds(cores, root="/sys/devices/system/cpu"):
    """Each core's capacity, else its top clock; None when some core reports neither."""
    for name in ("cpu_capacity", "cpufreq/cpuinfo_max_freq"):
        speeds = {}
        for core in cores:
            try:
                with open("%s/cpu%d/%s" % (root, core, name)) as f:
                    speeds[core] = int(f.read().strip())
            except (OSError, ValueError):
                break
        else:
            return speeds
    return None


def fastest_first(cores):
    """The cores, fastest first; cores of equal or unknown speed keep their numeric order."""
    cores = sorted(cores)
    speeds = core_speeds(cores)
    return sorted(cores, key=lambda core: -speeds[core]) if speeds else cores


def proton_cpu_limit(command, env):
    """This game's entry in Proton's own default_cpu_limit list: a core count, another value such as "P" as text, or None."""
    app_id = env.get("SteamGameId", "")
    if not command or not app_id.isdigit():
        return None
    try:
        if os.path.getsize(command[0]) > 4 * 1024 * 1024:
            return None
        with open(command[0], encoding="utf-8", errors="replace") as f:
            text = f.read()
    except OSError:
        return None
    table = re.search(r"^default_cpu_limit = \{(.*?)^\}", text, re.M | re.S)
    match = table and re.search(r'^\s*"%s"\s*:\s*"?(\w+)' % app_id, table.group(1), re.M)
    if not match:
        return None
    value = match.group(1)
    return int(value) if value.isdigit() else value


def cpu_topology(env, command=()):
    """Windows sees the game's cores fastest first, so a thread a game pins to CPU 0 runs on the fastest core."""
    if "PROTON_CPU_TOPOLOGY" in env:
        return
    if "WINE_CPU_TOPOLOGY" in env:
        if env["WINE_CPU_TOPOLOGY"]:
            env["PROTON_CPU_TOPOLOGY"] = env["WINE_CPU_TOPOLOGY"]
        return
    picked = bool(env.get("DROIDDECK_GAME_CORES", "").strip() or env.get("BL_GAME_CPUS", "").strip())
    fastest = env.get("DROIDDECK_FASTEST_CORES", "").strip() != "0" and game_id(env) is not None
    if not (picked or fastest):
        return
    try:
        cores = sorted(os.sched_getaffinity(0))
    except OSError:
        return
    online = online_cores()
    if not cores or cores[-1] >= len(online):
        return
    picked = picked and not set(cores) >= online
    if fastest:
        limit = None if picked else proton_cpu_limit(command, env)
        if isinstance(limit, str):
            return
        cores = fastest_first(cores)[:limit or None]
    elif set(cores) >= online:
        return
    if cores == sorted(online):
        return
    env["WINE_CPU_TOPOLOGY"] = env["PROTON_CPU_TOPOLOGY"] = "%d:%s" % (len(cores), ",".join(map(str, cores)))
    print("droiddeck-game-env: Windows sees %d cores (%s)" % (len(cores), env["WINE_CPU_TOPOLOGY"]), file=sys.stderr)


def gl_vsync(env):
    """OpenGL stops waiting for each screen refresh; Steam's Frame Limit still holds the game through gamescope."""
    # Wine asks for interval 1 on every GL window, and WineD3D's front-buffer drawing (DirectDraw,
    # Direct3D 1-7) never lowers it, so those games ran at the refresh rate with no limit set.
    if "vblank_mode" in env or game_id(env) is None:
        return False
    env["vblank_mode"] = "0"
    return True


def apply_config(env, config, app_id, fixes=None):
    if config.get("version") != 1:
        raise ValueError("unsupported environment configuration")
    result = env.copy()
    entries = dict(config.get("shared", {}))
    for name, value in (fixes or {}).items():
        if name == "WINEDLLOVERRIDES" and entries.get(name, env.get(name)):
            value = entries.get(name, env.get(name)) + ";" + value
        entries[name] = value
    entries.update(config.get("games", {}).get(app_id, {}))
    for name, value in entries.items():
        if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
            raise ValueError("invalid environment name")
        if value is None:
            result.pop(name, None)
        elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
            result[name] = value
        else:
            raise ValueError("invalid environment value")
    game_dxvk = config.get("dxvkConfigGames", {})
    if not isinstance(game_dxvk, dict):
        raise ValueError("invalid game dxvk configuration")
    dxvk = game_dxvk.get(app_id, config.get("dxvkConfig"))
    if dxvk is not None:
        if not isinstance(dxvk, str) or "\0" in dxvk or len(dxvk) > 8192:
            raise ValueError("invalid dxvk configuration")
        if dxvk:
            own = result.get("DXVK_CONFIG", "").strip().rstrip(";").strip()
            result["DXVK_CONFIG"] = own + "; " + dxvk if own else dxvk
    return result


GAME_DEBUG_CHANNELS = (
    "timestamp", "pid", "tid", "seh", "unwind", "threadname", "debugstr", "loaddll", "mscoree",
)
WINE_DEBUG_SELECTOR = re.compile(r"(?:(trace|warn|err|fixme))?([+-])([A-Za-z_][A-Za-z0-9_]*)")


def game_debug_channels(*values):
    """Normalize Wine selectors, retaining custom channels before required diagnostics."""
    selectors = {}
    for value in values:
        if not isinstance(value, str) or "\0" in value or len(value) > 8192:
            continue
        for part in value.split(","):
            part = part.strip()
            match = WINE_DEBUG_SELECTOR.fullmatch(part)
            if match is None:
                continue
            key = (match[1] or "", match[3])
            # Move the last value to the end: global +/-all and per-class selectors
            # have ordered semantics, so replacing a value in its old position is wrong.
            selectors.pop(key, None)
            selectors[key] = part
    for channel in GAME_DEBUG_CHANNELS:
        key = ("", channel)
        selectors.pop(key, None)
        selectors[key] = "+" + channel
    formatted = ",".join(selectors.values())
    if len(formatted) > 8192:
        formatted = ",".join("+" + channel for channel in GAME_DEBUG_CHANNELS)
    return formatted


def apply_game_debug_logs(env, config, debug_dir):
    """Apply app-owned diagnostics to a real game, without editing its launch command."""
    if not isinstance(config, dict) or config.get("version") != 1 or config.get("gameDebugLogs") is not True:
        return env
    if (not isinstance(debug_dir, str) or "\0" in debug_dir or len(debug_dir) > 8192
            or not os.path.isabs(debug_dir) or not os.path.isdir(debug_dir)):
        print("droiddeck-game-env: game debug logs skipped; session log directory unavailable", file=sys.stderr)
        return env
    result = dict(env)
    result.update({
        "PROTON_LOG": "1",
        "PROTON_LOG_DIR": debug_dir,
        "PROTON_CRASH_REPORT_DIR": debug_dir,
        "DXVK_LOG_PATH": debug_dir,
        "DXVK_LOG_LEVEL": "debug",
        "VKD3D_DEBUG": "info",
        "WINEDEBUG": game_debug_channels(env.get("WINEDEBUG"), env.get("PROTON_LOG")),
    })
    return result


def sync_config(config, app_id):
    games = config.get("games", {})
    return {
        "version": config.get("version"),
        "shared": {name: value for name, value in config.get("shared", {}).items() if name in SYNC_KEYS},
        "games": {app_id: {name: value for name, value in games.get(app_id, {}).items() if name in SYNC_KEYS}},
    }


def load_sync():
    try:
        return runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-esync"))
    except Exception as error:
        print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr)
        return None


def select_sync(command, env, sync=None):
    try:
        sync = sync if sync is not None else load_sync()
        if sync is None:
            return command, env
        chosen, chosen_env = sync["select"](command, env)
        if chosen and all(isinstance(value, str) for value in chosen) and isinstance(chosen_env, dict):
            return list(chosen), dict(chosen_env)
    except Exception as error:
        print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr)
    return command, env


def record_stock(sync, command, env):
    try:
        if sync is not None and "record" in sync:
            sync["record"](command, env)
    except Exception as error:
        print("droiddeck-game-env: stock launch not recorded (%s)" % error, file=sys.stderr)


def without_pack(chosen_env, env):
    result = dict(chosen_env)
    for name in PACK_KEYS:
        if name in env:
            result[name] = env[name]
        else:
            result.pop(name, None)
    return result


SSBS_LIB = "/usr/local/lib/libssbs.so"


def force_ssbs(command, env):
    """DROIDDECK_FORCE_SSBS=1 (a game's or the shared environment): preload libssbs.so into this
    Proton's Wine, which then keeps PSTATE.SSBS set across signal returns - speculative store
    bypass stays allowed, as force_ssbs.diff does inside a rebuilt ntdll. ARM64 Protons only: the
    library is aarch64 and only arm64 Wine has the signal path it fixes."""
    if env.get("DROIDDECK_FORCE_SSBS", "").lower() not in ("1", "on", "true", "yes"):
        return env
    proton = os.path.dirname(command[0]) if command else ""
    if not os.path.isdir(os.path.join(proton, "files/lib/wine/aarch64-unix")):
        print("droiddeck-game-env: force SSBS asked for, but this Proton is not an ARM64 one; left off", file=sys.stderr)
        return env
    if not os.path.isfile(SSBS_LIB):
        print("droiddeck-game-env: force SSBS asked for, but %s is missing; left off" % SSBS_LIB, file=sys.stderr)
        return env
    parts = [p for p in re.split(r"[:\s]+", env.get("LD_PRELOAD", "")) if p]
    if SSBS_LIB not in parts:
        env = dict(env, LD_PRELOAD=":".join(parts + [SSBS_LIB]))
        print("droiddeck-game-env: force SSBS on (libssbs.so preloaded)", file=sys.stderr)
    return env


EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGNORECASE)


def mark_install_scripts(command, env):
    """The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process
    in them but a Vulkan runtime installer as already run first, so no redistributable installer
    starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++
    ones stop with an error."""
    if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command):
        return []
    root = env.get("BL_X64_STEAM_ROOT") or env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
    scripts = [str(Path(root) / arg.replace("\\", "/")) for arg in command if EVALUATOR_SCRIPT.search(arg)]
    scripts = [path for path in scripts if os.path.isfile(path)]
    if not scripts:
        return []
    try:
        subprocess.run([SEED_REDISTS, root], env=dict(env, DROIDDECK_SEED_SCRIPTS="\n".join(scripts)),
                       stdout=sys.stderr, timeout=60, check=False)
    except (OSError, subprocess.SubprocessError) as error:
        print("droiddeck-game-env: install scripts not marked (%s)" % error, file=sys.stderr)
    return scripts


INSTALL_STAMP = ".droiddeck-install-scripts"
VDF_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])')


def read_vdf(text):
    tokens = iter(VDF_TOKEN.findall(text))

    def block():
        items = []
        for word, brace in tokens:
            if brace == "}":
                return items
            if brace:
                raise ValueError("a block without a name")
            value, opens = next(tokens, ("", "}"))
            if opens == "}":
                raise ValueError("a name without a value")
            items.append((re.sub(r"\\(.)", r"\1", word), block() if opens else re.sub(r"\\(.)", r"\1", value)))
        return items

    return block()


def run_keys(items, appid=""):
    appid = next((value for name, value in items if name.lower() == "appid" and isinstance(value, str)), appid)
    found = set()
    for name, value in items:
        if not isinstance(value, list):
            continue
        if any(isinstance(v, str) and re.fullmatch(r"process [0-9]+", n.lower()) for n, v in value):
            key = next((v for n, v in value if n.lower() == "hasrunkey" and isinstance(v, str)),
                       "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\" + appid)
            found.add((key.lower(), name.lower()))
        found |= run_keys(value, appid)
    return found


def registry_values(path):
    found, section = set(), None
    with open(path, encoding="utf-8", errors="replace") as reg:
        for line in reg:
            if line.startswith("["):
                section = line[1:line.find("]")].replace("\\\\", "\\").lower() if "]" in line else None
            elif section and line.startswith('"') and '"=' in line:
                found.add((section, line[1:line.index('"=')].replace("\\\\", "\\").lower()))
    return found


def has_run(key, value, values):
    path = key[len("hkey_local_machine\\"):] if key.startswith("hkey_local_machine\\") else None
    if path is None:
        return False
    views = [path]
    if path.startswith("software\\"):
        views.append("software\\wow6432node\\" + path[len("software\\"):])
    return any((view, value) in values for view in views)


def scripts_digest(scripts):
    digest = hashlib.sha256()
    for path in sorted(scripts):
        digest.update(os.path.basename(path).encode() + b"\0")
        digest.update(Path(path).read_bytes())
    return digest.hexdigest()


def install_scripts_done(scripts, env):
    prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
    try:
        if (prefix / INSTALL_STAMP).read_text().strip() != scripts_digest(scripts):
            return False
        keys = set()
        for path in scripts:
            keys |= run_keys(read_vdf(Path(path).read_text(encoding="utf-8", errors="replace")))
        values = registry_values(prefix / "system.reg")
    except (OSError, ValueError):
        return False
    return bool(keys) and all(has_run(key, value, values) for key, value in keys)


def record_install_scripts(digest, env):
    prefix = Path(env.get("STEAM_COMPAT_DATA_PATH", "/nonexistent")) / "pfx"
    try:
        if prefix.is_dir():
            staged = prefix / (INSTALL_STAMP + ".tmp")
            staged.write_text(digest + "\n")
            staged.replace(prefix / INSTALL_STAMP)
    except OSError as error:
        print("droiddeck-game-env: install scripts not recorded (%s)" % error, file=sys.stderr)


def start(launch, launch_env, digest):
    if digest:
        code = subprocess.run(launch, env=launch_env, check=False).returncode
        if code == 0:
            record_install_scripts(digest, launch_env)
        return code
    return os.execvpe(launch[0], launch, launch_env)


def main():
    command = sys.argv[1:]
    if not command:
        return 2
    env = game_gpu(os.environ.copy())
    mic_enabled = env.get("BL_MIC_ENABLED")
    debug_dir = env.get("BL_DEBUG_DIR", "")
    scripts = mark_install_scripts(command, env)
    if scripts and install_scripts_done(scripts, env):
        print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr)
        return 0
    try:
        digest = scripts_digest(scripts) if scripts else None
    except OSError:
        digest = None
    app_id = game_id(env)
    if app_id:
        gl_default = gl_vsync(env)
        waiting = len(command) > 1 and command[1] == "waitforexitandrun"
        fixes = {}
        if waiting:
            command = skip_red_launcher(command)
            ubisoft_overlay_off(env)
            fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], [])
            for note in notes:
                print("droiddeck-game-env: " + note, file=sys.stderr)
            command += extra
            components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id)
            if components:
                own = fixes.get("WINEDLLOVERRIDES")
                fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components
        path = Path.home() / ".config/droiddeck/game-environment.json"
        config = {}
        try:
            with path.open(encoding="utf-8") as source:
                config = json.load(source)
            if waiting:
                env = apply_config(env, config, app_id, fixes)
            else:
                env = apply_config(env, sync_config(config, app_id), app_id)
        except FileNotFoundError:
            if waiting:
                env = apply_config(env, {"version": 1}, app_id, fixes)
        except (OSError, ValueError, TypeError, AttributeError):
            print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
        if waiting:
            before_debug = env
            env = apply_game_debug_logs(env, config, debug_dir)
            if env is not before_debug:
                print("droiddeck-game-env: game debug logs enabled for this launch", file=sys.stderr)
        if gl_default and env.get("vblank_mode") == "0":
            print("droiddeck-game-env: OpenGL does not wait for the screen refresh (vblank_mode=0)", file=sys.stderr)
    if app_id and len(command) > 1 and command[1] == "waitforexitandrun":
        command = offline_without_anticheat(command, env)
    if mic_enabled == "0":
        env["BANNER_AUDIO_DIRECT_MIC"] = "0"
    env = vram_cap(env)
    env = force_ssbs(command, env)
    game_cores(env)
    cpu_topology(env, command)
    try:
        xbox = runpy.run_path(str(Path(__file__).with_name("droiddeck-xbox")))
    except Exception as error:
        print("droiddeck-game-env: Xbox support unavailable (%s); stock launch" % error, file=sys.stderr)
        xbox = {"launch": lambda command, env: None}
    try:
        xbox_result = xbox["launch"](command, env)
    except (OSError, ValueError, KeyError, RuntimeError) as error:
        print("droiddeck-game-env: Xbox launch could not start (%s)" % error, file=sys.stderr)
        return 1
    if xbox_result is not None:
        return xbox_result
    sync = load_sync()
    launch, launch_env = select_sync(command, env, sync)
    if launch[0] != command[0]:
        try:
            return start(launch, launch_env, digest)
        except OSError as error:
            print("droiddeck-game-env: %s could not start (%s); stock launch" % (launch[0], error), file=sys.stderr)
            launch, launch_env = command, without_pack(launch_env, env)
            record_stock(sync, launch, launch_env)
    return start(launch, launch_env, digest)


if __name__ == "__main__":
    sys.exit(main())
