#!/usr/bin/python3
# Makes the ARM64 Proton the client already downloaded usable here, and sets it as the default tool
# for Windows titles (CompatToolMapping "0" in config.vdf) when the user has not chosen one. Run
# before the client starts; it rewrites config.vdf on exit.
#
#   bannerlator-steam-compat <steam root>
#
# Valve ships a native aarch64 Proton (arm64ec Wine with FEX as the x86 layer) as an ordinary depot,
# which is exactly what this runtime wants. Its toolmanifest declares require_tool_appid 4185400 -
# Steam Linux Runtime 4 for arm64 - so Steam stacks pressure-vessel underneath it, and
# pressure-vessel needs unprivileged user namespaces, which an Android app does not get. Steam then
# drops the launch on the floor: no window, no log, straight back to the library.
#
# So the depot is re-registered here as a compatibility tool of our own: every entry symlinked so
# nothing is copied and Steam still updates the real depot, but with a toolmanifest that leaves out
# require_tool_appid. Proton takes its base directory from dirname(sys.argv[0]) without resolving
# it, so it reads files/ through those symlinks and runs directly on this rootfs.
import os
import re
import shutil
import sys

TOOL = "bannerlator-proton-arm64"
# Valve's own ARM64 Proton, by the name the client registers it under. Used only to bootstrap a
# fresh install: naming it is what makes the client offer it and fetch the depot.
SEED_TOOL = "proton-experimental-arm64"
# The ARM64 Proton depot the client downloads for itself, as a compatibility-tool appid.
# The session asks the client to install it on the command line the first time (steam://install),
# so a new client has it before a Windows title is first launched. It is deliberately NOT named as
# this tool's dependency: a dependency is composed with its own dependencies, and this depot
# requires 4185400, the arm64 Steam Linux Runtime, whose container needs user namespaces Android
# does not give - the launch would be wrapped in it and die before this tool ever ran.
PROTON_ARM64_APPID = "4427310"
LAUNCHER = "bannerlator-proton"
# The extra Steam library the app mounts the installed games into (see bannerlator-steam-library).
# installed_apps() scans it too; it lives only in the sibling script, so define it here as well.
LIBRARY = "/mnt/bannerlator"  # the card library is LIBRARY + "-sd"; internal storage is steam_root itself
# The client lists its own "Proton Experimental (ARM64)" and "Proton 11.0 (ARM64)" in the same
# Compatibility dropdown, and those are the two that cannot start a game here. Ours has to be
# unmistakable at a glance, or a user picks a neighbour and gets a launch that fails silently.
# Newest first: whichever the client has downloaded is used.
SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)")
# Written by us; everything else in the depot is symlinked through.
OWN_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", "bannerlator-proton")
# The client assigns a Proton to a Windows title by itself, as a per-app CompatToolMapping entry at
# priority 250 - which outranks the "0" default, so setting that default alone changes nothing. Every
# Proton it can pick is one this runtime cannot start: the ARM64 ones want pressure-vessel (see
# above) and the x86_64 ones want pressure-vessel and FEX on top. Ours is the only one that runs
# here, so every Proton mapping is repointed at it. Tools that are not Proton are left alone.
COMPAT_TOOL_VDF = """"compatibilitytools"
{
	"compat_tools"
	{
		"%s"
		{
			"install_path" "."
			"display_name" "Bannerlator Proton (ARM64)"
			"from_oslist" "windows"
			"to_oslist" "linux"
		}
	}
}
"""
TOOL_MANIFEST_VDF = """"manifest"
{
	"version" "2"
	"commandline" "/%s %%verb%%"
	"use_sessions" "1"
	"compatmanager_layer_name" "proton"
}
""" % LAUNCHER


# Steam runs its own graphics probe through EVERY registered tool at once, ~40 s after logging in,
# all with STEAM_COMPAT_DATA_PATH=.../compatdata/0, and those concurrent Protons can write their
# builtin DLLs through each other's prefix links into each other's trees (seen: GE's FEX DLLs became
# Proton Experimental's). Moving those runs to a prefix per tool was tried and crashed the Steam
# client right after the probe started (segfault, rc 139, first ever in 25 sessions), so the client
# relies on compatdata/0 itself. The launch-time check below is the protection instead.

# The Components page's choices, enforced in the one place every game launch passes: the app keeps
# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of
# each package in use. A file that no longer matches - whatever changed it - is put back before
# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build
# differs and the Proton's own new files are left alone until the user chooses again.
BL_COMPONENTS_SETUP = """
bl_components() {
  base=$1
  cm_dir="${HOME:-/root}/.local/share/bannerlator-components"
  cm_list="$cm_dir/desired.tsv"
  # A real game launch only: the verb the client starts a game with and the game's own prefix. Every
  # other run - above all the client's own probes right after login, which all share compatdata/0 -
  # is left exactly as it was: with this check running through them, the client segfaulted (rc 139)
  # within seconds of starting its probes, four sessions out of four. The prefix decides rather than
  # the app id because a non-Steam shortcut launches with app id 0 but still gets a prefix of its own.
  [ "${verb:-}" = waitforexitandrun ] || return 0
  case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac
  [ -f "$cm_list" ] || return 0
  cm_app=${STEAM_COMPAT_DATA_PATH%/}
  cm_app=${cm_app##*/}
  cm_tool=${base##*/}
  cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null)
  cm_fixed=0
  cm_checked=0
  while IFS=$'\\t' read -r t want comp rel sum src; do
    [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue
    cm_checked=$((cm_checked + 1))
    f="$base/$rel"
    if [ -f "$f" ] && [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ]; then continue; fi
    if [ ! -f "$src" ]; then
      echo "bannerlator-proton: components: $comp source missing ($src), left as it is" >&2
      continue
    fi
    mkdir -p "$(dirname "$f")"
    cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue
    case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac
    cm_fixed=$((cm_fixed + 1))
  done < "$cm_list"
  [ "$cm_checked" -gt 0 ] && echo "bannerlator-proton: components: checked $cm_checked file(s) in $cm_tool for app $cm_app" >&2
  if [ "$cm_fixed" -gt 0 ]; then
    echo "bannerlator-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2
    printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log"
  fi
}
"""

# Resolves the depot at launch time, so the tool can be registered before the depot exists and
# survives the client updating or replacing it. Proton takes its base directory from
# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself.
# Cores for a game the client launches, kept separate from the client's own. One mask suits the
# client's menus and another suits a game, and they are wanted at the same time - so the game's is
# applied here, in the launch path every game goes through, rather than to the session as a whole.
BL_GAME_CPUS_SETUP = """
bl_game_cpus() {
  [ -n "${BL_GAME_CPUS:-}" ] || return 0
  command -v taskset >/dev/null 2>&1 || return 0
  # exec'd through taskset rather than set afterwards: a mask applied to a pid races the process
  # into spawning its own threads, and this way every thread inherits it from the first instruction.
  BL_TASKSET="taskset -c $BL_GAME_CPUS"
  echo "bannerlator-proton: game cores $BL_GAME_CPUS" >&2
}
"""

# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under
# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the
# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these
# titles are told to stay windowed at the session's own size - unless the user's launch options
# already say something about the video mode, which are left as they are.
# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for
# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against
# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime
# container that path never reaches Proton either. (WinNative's winnative-proton-launch.)
BL_LIBPATH_SETUP = """
bl_libpath() {
  [ -n "${LD_LIBRARY_PATH:-}" ] || return 0
  local kept= dir old_ifs=$IFS
  IFS=:
  for dir in $LD_LIBRARY_PATH; do
    case $dir in
      "" | */steamrtarm64 | */steamrtarm64/*) ;;
      *) kept=${kept:+$kept:}$dir ;;
    esac
  done
  IFS=$old_ifs
  if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi
}
"""

BL_IDTECH3_SETUP = """
bl_idtech3() {
  BL_EXTRA_ARGS=()
  case "${SteamAppId:-${STEAM_COMPAT_APP_ID:-}}" in
    # Quake III Arena, Quake III: Team Arena, Return to Castle Wolfenstein, Jedi Academy.
    2200|2350|9010|6020) ;;
    *) return 0 ;;
  esac
  case " $* " in
    *r_fullscreen*|*r_mode*|*r_customwidth*|*r_customheight*)
      echo "bannerlator-proton: Quake-engine title; its launch options set the video mode, left as they are" >&2
      return 0 ;;
  esac
  w=${BL_WIDTH:-1280}
  h=${BL_HEIGHT:-720}
  BL_EXTRA_ARGS=(+set r_fullscreen 0 +set r_mode -1 +set r_customwidth "$w" +set r_customheight "$h")
  echo "bannerlator-proton: Quake-engine title: windowed at ${w}x${h}" >&2
}
"""

BL_DIRECTAUDIO_SETUP = """
bl_directaudio() {
  base=$1
  [ -n "${BL_DIRECTAUDIO:-}" ] || return 0
  [ -d "$BL_DIRECTAUDIO/lib/wine" ] || return 0
  version=$("$base/files/bin-arm64/wine" --version 2>/dev/null)
  major=${version#wine-}
  major=${major%%.*}
  if [ "$major" != "11" ]; then
    echo "bannerlator-proton: DirectAudio left off - it is built for Wine 11 and this Proton is '${version:-unknown}'. Pairing them would be silent rather than broken, so it is not worth guessing." >&2
    return 0
  fi
  # Wine appends the per-architecture subdirectory to each entry, so the directory holding
  # aarch64-unix/ and aarch64-windows/ is what goes here.
  WINEDLLPATH="$BL_DIRECTAUDIO/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}"
  export WINEDLLPATH
  # mmdevapi only tries the drivers named in the registry; its built-in list is pulse, alsa, oss and
  # coreaudio and knows nothing about this one. The key is appended to the prefix's user.reg rather
  # than written with `wine reg`, which would start a wineserver here just to set one value.
  reg="${STEAM_COMPAT_DATA_PATH:-}/pfx/user.reg"
  if [ -f "$reg" ]; then
    if ! grep -q '"Audio"="directaudio"' "$reg" 2>/dev/null; then
      printf '\n[Software\\\\Wine\\\\Drivers] %s\n"Audio"="directaudio"\n' "$(date +%s)" >> "$reg"
      echo "bannerlator-proton: DirectAudio selected in the prefix" >&2
    fi
  else
    # A prefix that does not exist yet is created by Proton moments from now, after this runs, so
    # the key lands on the NEXT launch of that game. Said out loud because silent audio on a first
    # launch would otherwise look like a fault.
    echo "bannerlator-proton: no prefix yet - DirectAudio takes effect the next time this game starts" >&2
  fi
  echo "bannerlator-proton: DirectAudio ready (Wine $major)" >&2
}
"""

# Resolves the depot at launch time, so the tool can be registered before the depot exists and
# survives the client updating or replacing it. Proton takes its base directory from
# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself.
LAUNCHER_SH = """#!/bin/bash
# Bannerlator Proton (ARM64): runs the ARM64 Proton the Steam client downloaded, without the
# Steam Linux Runtime container its own manifest asks for (no user namespaces on Android).
verb=$1; shift
steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam}
# Steam's overlay is preloaded into every game Valve launches, and used to be dropped here: it was
# landing in front of open() and read() on /dev/input, letting Wine open the session's pad and
# answering every read of it with nothing - a controller that never reports, and Wine's HID thread
# spinning a core on it.
#
# Removing it was the wrong cure for an ordering problem. glibc processes LD_PRELOAD BEFORE
# /etc/ld.so.preload, and the interposer lives in the latter, so the overlay was simply ahead of it.
# Naming the interposer first in LD_PRELOAD puts it back in front and the overlay loads as Valve
# intends. That matters beyond the overlay itself: every deviation from the launch Valve ships is a
# difference this runtime would otherwise have to justify.
fake=/usr/local/lib/libfakeinput.so
if [ -f "$fake" ]; then
  case ":${LD_PRELOAD:-}:" in
    *":$fake:"*) ;;
    *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;;
  esac
fi
for name in %s; do
  depot="$steam_root/steamapps/common/$name"
  if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then
    bl_components "$depot"
    bl_directaudio "$depot"
    bl_game_cpus
    bl_idtech3 "$@"
    bl_libpath
    exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
  fi
done
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
exit 1
""" % " ".join('"%s"' % n for n in SOURCES)
# Spliced in after the formatting above, never through it: the function body contains ${major%%.*}
# and a % format would eat that into a single %, leaving a shell expansion that silently does
# something else.
LAUNCHER_SH = LAUNCHER_SH.replace(
    "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
    + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)

# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot
# is: its manifest declares require_tool_appid, and the client's overlay library has to come out of
# LD_PRELOAD. Its own entry point is left untouched and called through this wrapper.
EXTRA_WRAPPER = "bannerlator-proton-wrap"
EXTRA_MANIFEST_VDF = """"manifest"
{
	"version" "2"
	"commandline" "/%s %%verb%%"
	"use_sessions" "1"
	"compatmanager_layer_name" "proton"
}
""" % EXTRA_WRAPPER
# %s is the tool's own entry point, read out of the manifest it shipped with.
EXTRA_WRAPPER_SH = """#!/bin/bash
# Bannerlator: runs a Proton installed into compatibilitytools.d without the Steam Linux Runtime
# container its own manifest asks for - pressure-vessel needs unprivileged user namespaces, which
# an Android app does not get, and the launch dies before Proton is reached.
verb=$1; shift
here=$(dirname "$0")
# See bannerlator-proton: the interposer goes first in LD_PRELOAD rather than the overlay being
# removed, because glibc reads LD_PRELOAD before /etc/ld.so.preload and the overlay was simply
# ahead of it on /dev/input.
fake=/usr/local/lib/libfakeinput.so
if [ -f "$fake" ]; then
  case ":${LD_PRELOAD:-}:" in
    *":$fake:"*) ;;
    *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;;
  esac
fi
bl_components "$here"
bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
bl_libpath
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
"""
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
{
	"compat_tools"
	{
		"%s"
		{
			"install_path" "."
			"display_name" "%s (Bannerlator)"
			"from_oslist" "windows"
			"to_oslist" "linux"
		}
	}
}
"""


TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)')


def tokenize(text):
    tokens = []
    pos = 0
    while pos < len(text):
        m = TOKEN.match(text, pos)
        if not m or m.end() == pos:
            break
        pos = m.end()
        if m.group(2):
            tokens.append(m.group(2))
        elif m.group(1) is not None:
            tokens.append('"' + m.group(1) + '"')
    return tokens


def find_block(tokens, path):
    """Index of the '{' opening the block at path, or None."""
    depth, want, i = 0, 0, 0
    while i < len(tokens):
        t = tokens[i]
        if t == "{":
            depth += 1
        elif t == "}":
            depth -= 1
            if depth < want:
                return None
        elif depth == want and i + 1 < len(tokens) and tokens[i + 1] == "{":
            if t.lower() == '"%s"' % path[want].lower():
                if want == len(path) - 1:
                    return i + 1
                want += 1
        i += 1
    return None


def block_end(tokens, start):
    depth = 0
    for i in range(start, len(tokens)):
        if tokens[i] == "{":
            depth += 1
        elif tokens[i] == "}":
            depth -= 1
            if depth == 0:
                return i
    return len(tokens)


def serialize(tokens):
    out, depth, i = [], 0, 0
    while i < len(tokens):
        t = tokens[i]
        if t == "}":
            depth -= 1
            out.append("\t" * depth + "}")
            i += 1
        elif i + 1 < len(tokens) and tokens[i + 1] == "{":
            out.append("\t" * depth + t)
            out.append("\t" * depth + "{")
            depth += 1
            i += 2
        else:
            out.append("\t" * depth + t + "\t\t" + tokens[i + 1])
            i += 2
    return "\n".join(out) + "\n"


def write_changed(path, text, mode=None):
    """Writes `text` to `path` only when it differs, through a rename so a reader never sees half a
    file. The session runs this script every 15 s for as long as the client does; rewriting files
    that have not changed cost a python start's worth of writes each time, and config.vdf is the
    client's own, which it may be writing at that moment. Returns whether anything was written."""
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            same = f.read() == text
    except OSError:
        same = False
    if not same:
        tmp = path + ".bannerlator"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(text)
        if mode is not None:
            os.chmod(tmp, mode)
        os.replace(tmp, path)
    elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode:
        os.chmod(path, mode)
    return not same


def first_key(path, section):
    """The single key naming a block inside `section`, or None. Used to keep a third-party tool's
    own internal name, so a mapping the user already made against it keeps working."""
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            tokens = tokenize(f.read())
    except OSError:
        return None
    block = find_block(tokens, section)
    if block is None:
        return None
    for i in range(block + 1, block_end(tokens, block)):
        if tokens[i] == "}":
            break
        if i + 1 < len(tokens) and tokens[i + 1] == "{":
            return tokens[i].strip('"')
    return None


def manifest_entry_point(path):
    """The script a toolmanifest's commandline runs, relative to the tool directory."""
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            tokens = tokenize(f.read())
    except OSError:
        return None
    for i in range(len(tokens) - 1):
        if tokens[i].lower() == '"commandline"':
            command = tokens[i + 1].strip('"').split()
            if command:
                return command[0].lstrip("/")
    return None


def adopt_extras(tools):
    """Makes every other Proton in compatibilitytools.d launchable here, and returns the tool names
    to leave alone in CompatToolMapping - the whole point of installing one is to pick it per game,
    so register_default must not repoint it back at ours."""
    adopted = {}
    try:
        entries = sorted(os.listdir(tools))
    except OSError:
        return adopted
    for entry in entries:
        if entry == TOOL or entry.startswith("."):
            continue
        tool_dir = os.path.join(tools, entry)
        manifest = os.path.join(tool_dir, "toolmanifest.vdf")
        if not os.path.isdir(tool_dir) or not os.path.isfile(manifest):
            continue
        # The shipped manifest is kept, once, and stays the source of the entry point: re-running
        # must not read back our own rewritten commandline and wrap the wrapper.
        original = manifest + ".bannerlator-orig"
        if not os.path.isfile(original):
            try:
                shutil.copy2(manifest, original)
            except OSError:
                continue
        entry_point = manifest_entry_point(original)
        if not entry_point or entry_point == EXTRA_WRAPPER:
            entry_point = "proton"
        if not os.path.exists(os.path.join(tool_dir, entry_point)):
            print("bannerlator-steam-compat: %s has no %s; left alone" % (entry, entry_point))
            continue
        compat_vdf = os.path.join(tool_dir, "compatibilitytool.vdf")
        name = first_key(compat_vdf, ["compatibilitytools", "compat_tools"]) or entry
        wrapper = os.path.join(tool_dir, EXTRA_WRAPPER)
        try:
            # Formatted first, spliced second: the function carries a printf %s and a
            # ${major%%.*} of its own, and putting it through the format would eat both.
            body = (EXTRA_WRAPPER_SH % entry_point).replace(
                    'here=$(dirname "$0")',
                    'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
                    + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
            write_changed(wrapper, body, 0o755)
            write_changed(manifest, EXTRA_MANIFEST_VDF)
            write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry))
        except OSError as e:
            print("bannerlator-steam-compat: could not adopt %s (%s)" % (entry, e))
            continue
        adopted[name] = entry
    return adopted


# Steam's own tools and runtimes live in steamapps beside the games; they are not titles and must
# not be mapped to a compatibility tool.
NOT_GAMES = frozenset((
    "228980",    # Steamworks Common Redistributables
    "1070560",   # Steam Linux Runtime 1.0 (scout)
    "1391110",   # Steam Linux Runtime 2.0 (soldier)
    "1493710",   # Proton Experimental
    "3127680",   # FEX
    "4183110",   # Steam Linux Runtime 4.0
    "4185400",   # Steam Linux Runtime 4.0 - Arm64
    "4427310",   # Proton Experimental (ARM64)
    "4628740",   # Proton 11.0 (ARM64)
))


def installed_apps(steam_root):
    """App ids of the titles the client has installed, tools and runtimes excluded."""
    apps = set()
    for library in (steam_root, LIBRARY + "-sd"):
        steamapps = os.path.join(library, "steamapps")
        try:
            names = os.listdir(steamapps)
        except OSError:
            continue
        for name in names:
            m = re.fullmatch(r"appmanifest_(\d+)\.acf", name)
            if m and m.group(1) not in NOT_GAMES:
                apps.add(m.group(1))
    return sorted(apps, key=int)


def register_default(config_path, apps=(), name=None, protect=()):
    """Writes the mapping. `name` defaults to our own tool; the first-run seed passes Valve's."""
    tool = name or TOOL
    # Tool names the user may deliberately have chosen (see adopt_extras); never repointed.
    keep = {n.lower() for n in protect}
    with open(config_path, encoding="utf-8", errors="replace") as f:
        original = f.read()
    tokens = tokenize(original)
    steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
    if steam is None:
        return
    mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"])
    entry = ['"0"', "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"75"', "}"]
    if mapping is None:
        end = block_end(tokens, steam)
        tokens[end:end] = ['"CompatToolMapping"', "{"] + entry + ["}"]
    else:
        mapping += steam + 1
        # Repoint every per-app entry the client aimed at a Proton it cannot actually start.
        for i in range(mapping, block_end(tokens, mapping)):
            named = tokens[i + 1].strip('"').lower()
            if (tokens[i] == '"name"' and named.startswith("proton")
                    and named != tool.lower() and named not in keep):
                tokens[i + 1] = '"%s"' % tool
        if find_block(tokens[mapping + 1:], ["0"]) is None:
            tokens[mapping + 1:mapping + 1] = entry
        # The "0" default only decides how a title is installed, so it settles new downloads and
        # leaves anything already installed as a Linux build running as one. An entry per installed
        # app is what the client's own Compatibility checkbox writes, and is what makes it switch.
        for app in apps:
            if find_block(tokens[mapping + 1:], [app]) is not None:
                continue
            tokens[mapping + 1:mapping + 1] = [
                '"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"]
    # Nothing to change is the usual case on the session's beat: the client's own file is left alone.
    text = serialize(tokens)
    if text != serialize(tokenize(original)):
        write_changed(config_path, text)


def find_source(steam_root):
    """The ARM64 Proton depot the client has downloaded, or None."""
    common = os.path.join(steam_root, "steamapps", "common")
    for name in SOURCES:
        depot = os.path.join(common, name)
        # bin-arm64 is what makes it the native build rather than the x86_64 one.
        if os.path.isdir(os.path.join(depot, "files", "bin-arm64")):
            return depot
    return None


def build_tool(tool_dir):
    """Our own manifests and the launcher. The depot is found at launch time, not here."""
    if os.path.islink(tool_dir):
        os.unlink(tool_dir)
    elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES):
        # Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding
        # only our own files is kept, and each file rewritten only when it changed.
        shutil.rmtree(tool_dir)
    os.makedirs(tool_dir, exist_ok=True)
    write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF)
    write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % TOOL)
    write_changed(os.path.join(tool_dir, LAUNCHER), LAUNCHER_SH, 0o755)


def main():
    steam_root = sys.argv[1]
    tools = os.path.join(steam_root, "compatibilitytools.d")
    os.makedirs(tools, exist_ok=True)
    build_tool(os.path.join(tools, TOOL))
    extras = adopt_extras(tools)
    for name, directory in sorted(extras.items()):
        print("bannerlator-steam-compat: adopted %s as %s" % (directory, name))
    depot = find_source(steam_root)
    print("bannerlator-steam-compat: registered %s (%s)" % (
        TOOL, "depot %s present" % os.path.basename(depot) if depot else
        "no ARM64 Proton depot yet; the session asks the client to fetch it"))
    config = os.path.join(steam_root, "config", "config.vdf")
    if os.path.isfile(config):
        apps = installed_apps(steam_root)
        register_default(config, apps, protect=extras)
        print("bannerlator-steam-compat: default and %d installed title(s) set to %s" % (len(apps), TOOL))


if __name__ == "__main__":
    main()
