#!/usr/bin/env bash
#
# Конвейер сборки Wine-тулчейна GameSphere.
#
# Одна команда на весь путь: загрузить закреплённые исходники CrossOver,
# сверить их контрольную сумму, наложить наши патчи по порядку, собрать,
# доложить свободные графические бекенды и упаковать в тот формат, который
# ожидает ядро продукта.
#
#     ./build.sh build      весь конвейер
#     ./build.sh check      офлайновая самопроверка (сеть и компилятор не нужны)
#
# Полный список команд — ./build.sh help
#
# Почему сборка своя, а не готовая чужая: см. spec.md, раздел «Движок запуска».
# Что мы обязаны опубликовать, изменив Wine: см. README.md, раздел про LGPL.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCES_JSON="$SCRIPT_DIR/sources.json"
PATCHES_DIR="$SCRIPT_DIR/patches"
BUILD_DIR="${GAMESPHERE_WINE_BUILD_DIR:-$SCRIPT_DIR/build}"

DOWNLOADS_DIR="$BUILD_DIR/downloads"
SRC_DIR="$BUILD_DIR/src"
STAGE_DIR="$BUILD_DIR/stage"
DIST_DIR="$BUILD_DIR/dist"

# Куда лягут пакеты, когда их выложат. Адрес попадает в манифест выпуска —
# именно по нему приложение потом качает тулчейн.
DIST_BASE="${GAMESPHERE_DIST_BASE:-https://gamesphere.dulin.tech/toolchains}"

# Время, которое проставляется всему содержимому пакета.
#
# Без него архив, собранный дважды, отличался бы временными метками, и обещание
# «одинаковый результат при повторном прогоне» проверить было бы нечем.
# Значение закреплено, а не взято из часов, ровно поэтому.
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1735689600}"

# Разбор JSON — питоном: он есть в системе, а вводить зависимость от jq ради
# пяти полей значит поставить сборку в зависимость от Homebrew.
PYTHON="${PYTHON:-python3}"

# Имя файла происхождения — того самого, которым выполняется третье
# обязательство LGPL: получатель двоичной сборки узнаёт из неё, где лежат
# исходники. Здесь только имя. Сам адрес не дублируется: он живёт ровно в одном
# месте — в патче, который этот файл создаёт, — и упаковщик берёт текст оттуда.
IDENTITY_FILE="gamesphere-build-identity.txt"

# ---------------------------------------------------------------------------
# Вывод
# ---------------------------------------------------------------------------

step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf '    %s\n' "$*"; }
warn() { printf '\033[33m    ВНИМАНИЕ: %s\033[0m\n' "$*" >&2; }
fail() { printf '\033[31m    ОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Закреплённые версии
# ---------------------------------------------------------------------------

# Достаёт одно поле из sources.json. Точечный путь: wine.url, toolchainVersion.
#
# Отсутствующее поле — это пустая строка, а не падение: часть полей необязательна
# (юникс-сторона есть только у встроенных бекендов), и спрашивать про них
# приходится до того, как выяснилось, что их нет.
manifest() {
    "$PYTHON" - "$SOURCES_JSON" "$1" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
    node = json.load(handle)
for key in sys.argv[2].split("."):
    try:
        node = node[int(key)] if isinstance(node, list) else node[key]
    except (KeyError, IndexError, ValueError):
        print("")
        sys.exit(0)
print(node if not isinstance(node, (dict, list)) else json.dumps(node, ensure_ascii=False))
PY
}

# Сколько свободных бекендов перечислено.
graphics_count() {
    "$PYTHON" - "$SOURCES_JSON" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
    print(len(json.load(handle)["graphics"]))
PY
}

toolchain_version() { manifest toolchainVersion; }

# ---------------------------------------------------------------------------
# Проверка окружения сборки
# ---------------------------------------------------------------------------

require_tools() {
    local missing=()
    local tool
    for tool in "$@"; do
        command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
    done
    if [ ${#missing[@]} -gt 0 ]; then
        fail "не хватает инструментов: ${missing[*]}"
    fi
}

# Всё, без чего Wine не соберётся. Проверяется до загрузки гигабайтов, а не
# после: узнать про отсутствующий mingw через сорок минут — потерять сорок минут.
require_build_tools() {
    step "Проверка окружения сборки"

    require_tools curl shasum tar patch "$PYTHON"

    local missing=()
    local tool
    for tool in bison flex pkg-config; do
        command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
    done

    # PE-часть Wine собирается кросс-компилятором: без него получится тулчейн
    # без встроенных библиотек Windows, то есть неработающий.
    if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 \
        && ! command -v x86_64-w64-mingw32-clang >/dev/null 2>&1; then
        missing+=("mingw-w64")
    fi

    if [ ${#missing[@]} -gt 0 ]; then
        printf '\n' >&2
        fail "$(cat <<EOF
не хватает зависимостей сборки: ${missing[*]}

    Установить:  brew install bison flex pkg-config mingw-w64

    Учтите, что bison от Apple слишком стар для Wine — нужен именно из Homebrew,
    и его каталог должен стоять в PATH раньше системного:

        export PATH="\$(brew --prefix bison)/bin:\$PATH"
EOF
)"
    fi

    # Wine здесь собирается x86_64 и исполняется под Rosetta 2: это сегодняшний
    # слой трансляции продукта. Нативная ARM64-сборка — отдельная работа и явно
    # вынесена за рамки MVP.
    if [ "$(uname -m)" = "arm64" ]; then
        /usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null \
            || fail "нет Rosetta 2 — установите: softwareupdate --install-rosetta --agree-to-license"
    fi

    info "все зависимости на месте"
}

# ---------------------------------------------------------------------------
# Загрузка и сверка
# ---------------------------------------------------------------------------

sha256_of() { shasum -a 256 "$1" | awk '{print $1}'; }

# Загружает с докачкой и сверяет с закреплённой суммой.
#
# Незакреплённая сумма — это отказ, а не предупреждение: смысл закрепления
# в том, что собранное сегодня и собранное через год получены из одних и тех
# же байтов. Проставить сумму: ./build.sh pin
download_pinned() {
    local url="$1" expected="$2" target="$3"

    if [ -z "$expected" ]; then
        fail "$(cat <<EOF
контрольная сумма для $url не закреплена в sources.json.

    Закрепить (нужна сеть):   ./build.sh pin
    Затем сверьте результат с подписью, опубликованной поставщиком,
    и закоммитьте sources.json — именно этот коммит и делает сборку
    воспроизводимой.
EOF
)"
    fi

    if [ -f "$target" ] && [ "$(sha256_of "$target")" = "$expected" ]; then
        info "уже загружено и сверено: $(basename "$target")"
        return 0
    fi

    info "загрузка $url"
    # -C - продолжает прерванную загрузку с того места, где она оборвалась.
    curl --fail --location --retry 3 --retry-delay 2 -C - -o "$target" "$url" \
        || fail "не удалось загрузить $url"

    local actual
    actual="$(sha256_of "$target")"
    if [ "$actual" != "$expected" ]; then
        # Битый файл убирается сразу: оставленный, он превратил бы докачку
        # в бесконечную докачку заведомо неверных байтов.
        rm -f "$target"
        fail "контрольная сумма не совпала для $url
    ожидалась: $expected
    получена:  $actual"
    fi
    info "сверено: $(basename "$target")"
}

cmd_fetch() {
    require_tools curl shasum "$PYTHON"
    mkdir -p "$DOWNLOADS_DIR"

    step "Загрузка исходников Wine"
    local url expected
    url="$(manifest wine.url)"
    expected="$(manifest wine.sha256)"
    if [ "$(manifest wine.verified)" != "True" ]; then
        warn "версия CrossOver $(manifest wine.crossoverVersion) отмечена как неподтверждённая
    (sources.json → wine.verified=false). Убедитесь, что такой выпуск существует
    и адрес актуален, прежде чем закреплять сумму."
    fi
    download_pinned "$url" "$expected" "$DOWNLOADS_DIR/$(basename "$url")"

    step "Загрузка свободных графических бекендов"
    local index count
    count="$(graphics_count)"
    for ((index = 0; index < count; index++)); do
        url="$(manifest "graphics.$index.url")"
        expected="$(manifest "graphics.$index.sha256")"
        download_pinned "$url" "$expected" "$DOWNLOADS_DIR/$(basename "$url")"
    done
}

# Записывает контрольные суммы в sources.json. Нужна сеть.
cmd_pin() {
    require_tools curl shasum "$PYTHON"
    mkdir -p "$DOWNLOADS_DIR"

    step "Закрепление контрольных сумм"
    "$PYTHON" - "$SOURCES_JSON" "$DOWNLOADS_DIR" <<'PY'
import hashlib, json, subprocess, sys, os

path, downloads = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as handle:
    text = handle.read()
document = json.loads(text)


def pin(node, label):
    url = node["url"]
    target = os.path.join(downloads, os.path.basename(url))
    if not os.path.exists(target):
        print(f"    загрузка {url}")
        subprocess.run(
            ["curl", "--fail", "--location", "--retry", "3", "-C", "-", "-o", target, url],
            check=True,
        )
    digest = hashlib.sha256()
    with open(target, "rb") as handle:
        for chunk in iter(lambda: handle.read(1 << 20), b""):
            digest.update(chunk)
    value = digest.hexdigest()
    print(f"    {label}: {value}")
    return node["sha256"], value


replacements = []
replacements.append(pin(document["wine"], "wine"))
for entry in document["graphics"]:
    replacements.append(pin(entry, entry["backend"]))

# Точечная замена в тексте, а не перезапись json.dump: файл прокомментирован
# ключами "//", и переписывание целиком стёрло бы порядок и форматирование.
for old, new in replacements:
    if old == new:
        continue
    if old:
        text = text.replace(f'"sha256": "{old}"', f'"sha256": "{new}"', 1)
    else:
        text = text.replace('"sha256": ""', f'"sha256": "{new}"', 1)

with open(path, "w", encoding="utf-8") as handle:
    handle.write(text)
print("    sources.json обновлён — проверьте и закоммитьте его")
PY
}

# ---------------------------------------------------------------------------
# Распаковка и патчи
# ---------------------------------------------------------------------------

cmd_unpack() {
    step "Распаковка исходников"
    local url archive
    url="$(manifest wine.url)"
    archive="$DOWNLOADS_DIR/$(basename "$url")"
    [ -f "$archive" ] || fail "нет $archive — сначала ./build.sh fetch"

    # Дерево сносится целиком: наложенные патчи не должны накапливаться
    # от прогона к прогону, иначе второй запуск даст не то же, что первый.
    rm -rf "$SRC_DIR"
    mkdir -p "$SRC_DIR"
    tar -xf "$archive" -C "$SRC_DIR"

    local subdirectory
    subdirectory="$(manifest wine.sourceSubdirectory)"
    local root
    root="$(find "$SRC_DIR" -maxdepth 3 -type d -name "$subdirectory" | LC_ALL=C sort | head -1)"
    [ -n "$root" ] || fail "в архиве не найден каталог исходников Wine ($subdirectory)"

    echo "$root" > "$BUILD_DIR/wine-source-path"
    info "исходники: $root"
}

wine_source_path() {
    [ -f "$BUILD_DIR/wine-source-path" ] || fail "исходники не распакованы — ./build.sh unpack"
    cat "$BUILD_DIR/wine-source-path"
}

# Перечисляет серию патчей в порядке наложения.
#
# Порядок — лексикографический по имени файла, и потому имена начинаются
# с номера. Это не украшение: патч, добавляющий строку в файл, созданный
# соседним патчем, ляжет только после него, и порядок обязан быть виден
# в каталоге глазами, а не выясняться из скрипта.
patch_series() {
    local directory="${1:-$PATCHES_DIR}"
    [ -d "$directory" ] || return 0
    find "$directory" -maxdepth 1 -name '[0-9][0-9][0-9][0-9]-*.patch' \
        | LC_ALL=C sort
}

# Файлы, которых касается серия: берутся из самих патчей, из строк «+++».
#
# Нужны, чтобы сорвавшуюся серию можно было снять точно. Снимать её обратным
# наложением нельзя: patch в macOS на обратном ходу оставляет от созданного
# файла пустышку, и «дерево вернулось как было» оказывается неправдой.
patch_targets() {
    "$PYTHON" - "$@" <<'PY'
import sys

targets = []
for path in sys.argv[1:]:
    with open(path, encoding="utf-8", errors="replace") as handle:
        for line in handle:
            if not line.startswith("+++ "):
                continue
            name = line[4:].strip().split("\t")[0]
            if name == "/dev/null":
                continue
            if name.startswith(("a/", "b/")):
                name = name[2:]
            targets.append(name)

# dict.fromkeys, а не set: порядок должен быть устойчивым от прогона к прогону.
print("\n".join(dict.fromkeys(targets)))
PY
}

# Накладывает серию патчей на дерево. Возвращает ненулевой код, если серия
# не легла; решение, что делать дальше, принимает вызывающий.
#
# Патчи накладываются по одному в порядке номеров, а не проверяются все разом
# вхолостую: патч, правящий файл, который создал предыдущий, на чистом дереве
# заведомо не ляжет, и такая «проверка» браковала бы здоровую серию.
#
# Перед наложением снимается копия ровно тех файлов, которых серия касается,
# и сорвавшаяся серия восстанавливается из неё дословно — вместе с удалением
# файлов, которых до неё не было. Наполовину пропатченное дерево не собирается
# и не откатывается, а молча даёт не тот результат, который обещан.
apply_patches() {
    local tree="$1" directory="${2:-$PATCHES_DIR}"
    local patches=()
    local line
    while IFS= read -r line; do
        [ -n "$line" ] && patches+=("$line")
    done < <(patch_series "$directory")

    if [ ${#patches[@]} -eq 0 ]; then
        info "патчей нет"
        return 0
    fi

    local backup
    backup="$(mktemp -d)"
    mkdir -p "$backup/existing"
    : > "$backup/created"

    local target
    while IFS= read -r target; do
        [ -n "$target" ] || continue
        if [ -e "$tree/$target" ]; then
            mkdir -p "$backup/existing/$(dirname "$target")"
            cp -p "$tree/$target" "$backup/existing/$target"
        else
            printf '%s\n' "$target" >> "$backup/created"
        fi
    done < <(patch_targets "${patches[@]}")

    local file
    for file in "${patches[@]}"; do
        if patch -p1 --silent --directory="$tree" < "$file"; then
            info "наложен $(basename "$file")"
        else
            warn "патч не лёг: $(basename "$file")"
            restore_tree "$tree" "$backup"
            rm -rf "$backup"
            return 1
        fi
    done

    rm -rf "$backup"
    info "наложено патчей: ${#patches[@]}"
}

# Возвращает дерево к снимку: изменённое перезаписывается, созданное удаляется.
restore_tree() {
    local tree="$1" backup="$2"
    local target

    while IFS= read -r target; do
        [ -n "$target" ] || continue
        rm -f "$tree/$target"
    done < "$backup/created"

    if [ -d "$backup/existing" ]; then
        ( cd "$backup/existing" && find . -type f | sed 's|^\./||' ) | while IFS= read -r target; do
            [ -n "$target" ] || continue
            mkdir -p "$(dirname "$tree/$target")"
            cp -p "$backup/existing/$target" "$tree/$target"
        done
    fi

    # Следы неудачного наложения: patch оставляет отвергнутые куски рядом.
    find "$tree" \( -name '*.rej' -o -name '*.orig' \) -delete 2>/dev/null || true
}

cmd_patches() {
    step "Наложение патчей"
    apply_patches "$(wine_source_path)" || fail "$(cat <<'EOF'
серия патчей не легла на эти исходники.

    Дерево возвращено в исходное состояние — собирать из него нельзя,
    пока серия не починена. Как обновить патч под новую версию исходников,
    описано в Tools/wine/README.md, раздел «Свои патчи».
EOF
)"
}

# ---------------------------------------------------------------------------
# Сборка
# ---------------------------------------------------------------------------

cmd_compile() {
    require_build_tools
    step "Сборка Wine"

    local source prefix jobs
    source="$(wine_source_path)"
    prefix="$STAGE_DIR"
    jobs="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)"

    rm -rf "$STAGE_DIR"
    mkdir -p "$STAGE_DIR"

    local runner=()
    [ "$(uname -m)" = "arm64" ] && runner=(/usr/bin/arch -x86_64)

    # Сборка идёт вне дерева исходников: так «собрать заново» — это удалить
    # один каталог, а не выяснять, что из дерева осталось от прошлого раза.
    local object="$BUILD_DIR/obj"
    rm -rf "$object"
    mkdir -p "$object"

    info "конфигурация (x86_64, под Rosetta 2)"
    (
        cd "$object"
        "${runner[@]}" "$source/configure" \
            --prefix="$prefix" \
            --enable-win64 \
            --disable-tests \
            --without-x \
            --without-oss \
            --without-pcap \
            --without-v4l2
    ) || fail "configure завершился неудачей"

    info "компиляция в $jobs потоков — это надолго"
    ( cd "$object" && "${runner[@]}" make -j"$jobs" ) || fail "make завершился неудачей"

    info "установка в $prefix"
    ( cd "$object" && "${runner[@]}" make install ) || fail "make install завершился неудачей"

    [ -x "$prefix/bin/wine" ] || fail "сборка прошла, но $prefix/bin/wine не появился"
}

# ---------------------------------------------------------------------------
# Графические бекенды
# ---------------------------------------------------------------------------

# Раскладывает свободные бекенды туда, где их ищет ядро:
# lib/gamesphere/graphics/<бекенд>/{x64,x86}/*.dll
# и, для бекендов с юникс-стороной, lib/gamesphere/graphics/<бекенд>/unix/*.so
#
# Путь задан не здесь, а в WineToolchain.graphicsBackendDirectory — этот код
# обязан ему соответствовать, и check это проверяет.
#
# Про третий каталог. У DXMT есть половина, живущая на стороне юникса
# (winemetal.so): она часть самого Wine, а не подменяемая библиотека Windows,
# и в окружение не кладётся в принципе. Проверено запуском игры (тикет 29).
install_graphics() {
    local tree="$1"
    step "Раскладка свободных графических бекендов"

    local count index
    count="$(graphics_count)"
    for ((index = 0; index < count; index++)); do
        local backend version url archive unpacked
        backend="$(manifest "graphics.$index.backend")"
        version="$(manifest "graphics.$index.version")"
        url="$(manifest "graphics.$index.url")"
        archive="$DOWNLOADS_DIR/$(basename "$url")"
        [ -f "$archive" ] || fail "нет $archive — сначала ./build.sh fetch"

        unpacked="$BUILD_DIR/graphics/$backend"
        rm -rf "$unpacked"
        mkdir -p "$unpacked"
        tar -xf "$archive" -C "$unpacked"

        local architecture
        for architecture in x64 x86; do
            local from to source_name
            source_name="$(manifest "graphics.$index.layout.$architecture")"
            from="$(find "$unpacked" -maxdepth 3 -type d -name "$source_name" | LC_ALL=C sort | head -1)"
            [ -n "$from" ] || fail "в архиве $backend $version нет каталога $source_name"

            to="$tree/lib/gamesphere/graphics/$backend/$architecture"
            mkdir -p "$to"
            find "$from" -maxdepth 1 -name '*.dll' -exec cp {} "$to/" \;

            local placed
            placed="$(find "$to" -maxdepth 1 -name '*.dll' | wc -l | tr -d ' ')"
            [ "$placed" -gt 0 ] || fail "для $backend/$architecture не нашлось ни одной библиотеки"
            info "$backend $version → $architecture: $placed библиотек"
        done

        # Юникс-сторона — только у тех бекендов, которые её объявили.
        local unix_name
        unix_name="$(manifest "graphics.$index.layout.unix")"
        if [ -n "$unix_name" ] && [ "$unix_name" != "None" ]; then
            local from to placed
            from="$(find "$unpacked" -maxdepth 3 -type d -name "$unix_name" | LC_ALL=C sort | head -1)"
            [ -n "$from" ] || fail "в архиве $backend $version нет каталога $unix_name"
            to="$tree/lib/gamesphere/graphics/$backend/unix"
            mkdir -p "$to"
            find "$from" -maxdepth 1 -name '*.so' -exec cp {} "$to/" \;
            placed="$(find "$to" -maxdepth 1 -name '*.so' | wc -l | tr -d ' ')"
            [ "$placed" -gt 0 ] || fail "для $backend/unix не нашлось ни одной библиотеки"
            info "$backend $version → unix: $placed библиотек"
        fi
    done
}

# ---------------------------------------------------------------------------
# Упаковка
# ---------------------------------------------------------------------------

# Патч серии, который создаёт файл происхождения.
#
# Ищется по тому, что он делает, а не по номеру в имени: перенумеруют серию —
# упаковщик обязан продолжить работать, а не молча перестать класть файл.
identity_patch() {
    local file targets
    while IFS= read -r file; do
        [ -n "$file" ] || continue
        # Список целей забирается целиком, а не читается через `| grep -q`:
        # grep -q закрывает канал на первом совпадении, и при set -o pipefail
        # чтение упало бы на SIGPIPE ровно тогда, когда патч найден.
        targets="$(patch_targets "$file")"
        if grep -qx "$IDENTITY_FILE" <<<"$targets"; then
            printf '%s\n' "$file"
            return 0
        fi
    done < <(patch_series)
    return 1
}

# Текст файла происхождения — дословно из патча.
#
# Источник один, и это принципиально. Третья копия текста (в скрипте, в шаблоне,
# где угодно ещё) означала бы, что адрес в пакете и адрес в опубликованных
# исходниках однажды разойдутся, а разойдясь — соврут получателю сборки.
#
# Из дерева исходников текст не берётся намеренно: дерево живёт в build/, его
# сносят и чистят, а упаковку иногда перезапускают по готовому стейджу. Патч же
# лежит в репозитории и публикуется вместе с выпуском — он и есть канон.
identity_text() {
    local patch_file
    patch_file="$(identity_patch)" || fail "$(cat <<EOF
в patches/ нет патча, создающего $IDENTITY_FILE.

    Без него получатель двоичной сборки Wine не узнает, где лежат исходники,
    а это прямое нарушение LGPL. См. README.md, раздел «Обязательства LGPL».
EOF
)"

    "$PYTHON" - "$patch_file" "$IDENTITY_FILE" <<'PY'
import sys

path, wanted = sys.argv[1], sys.argv[2]
lines = []
inside = False
with open(path, encoding="utf-8") as handle:
    for line in handle:
        if line.startswith("+++ "):
            name = line[4:].strip().split("\t")[0]
            if name.startswith(("a/", "b/")):
                name = name[2:]
            inside = name == wanted
            continue
        if not inside:
            continue
        # Кусок кончился там, где начался следующий файл или следующий патч.
        if line.startswith(("diff ", "--- ", "index ")):
            inside = False
            continue
        if line.startswith("@@"):
            continue
        if line.startswith("+"):
            lines.append(line[1:])

if not lines:
    sys.exit("в патче не нашлось содержимого " + wanted)
sys.stdout.write("".join(lines))
PY
}

# Адрес публикации исходников — из текста файла происхождения.
identity_url() {
    local text urls
    text="$(identity_text)" || return 1
    urls="$(grep -oE 'https?://[^[:space:]]+' <<<"$text" || true)"
    printf '%s\n' "${urls%%$'\n'*}"
}

# Кладёт файл происхождения в дерево, которое уедет в пакет.
#
# Патч создаёт этот файл в корне ДЕРЕВА ИСХОДНИКОВ, а в пакет уезжает результат
# установки — то есть сам по себе файл в раздаваемый архив не попадает никогда.
# Ровно поэтому его кладёт сюда упаковщик, а не сборка.
write_build_identity() {
    local tree="$1"
    identity_text > "$tree/$IDENTITY_FILE"
    info "файл происхождения записан в $IDENTITY_FILE"
}

# Отметка версии внутри самого тулчейна.
#
# Приложению нужен ответ на вопрос «а какой тулчейн у меня стоит» без обращения
# к сети и без разбора имени каталога. Читается WineToolchain.version.
write_toolchain_manifest() {
    local tree="$1"
    "$PYTHON" - "$SOURCES_JSON" "$tree" "$SOURCE_DATE_EPOCH" "$PATCHES_DIR" <<'PY'
import datetime, json, os, sys

sources, tree, epoch, patches_dir = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
with open(sources, encoding="utf-8") as handle:
    document = json.load(handle)

patches = sorted(
    name for name in os.listdir(patches_dir)
    if name.endswith(".patch")
) if os.path.isdir(patches_dir) else []

built_at = datetime.datetime.fromtimestamp(epoch, datetime.timezone.utc)

manifest = {
    "formatVersion": 1,
    "version": document["toolchainVersion"],
    "crossoverVersion": document["wine"]["crossoverVersion"],
    "wineVersion": document["wine"]["wineVersion"],
    "builtAt": built_at.isoformat().replace("+00:00", "Z"),
    "patches": patches,
    "graphics": [
        {"backend": entry["backend"], "version": entry["version"]}
        for entry in document["graphics"]
    ],
}

with open(os.path.join(tree, "gamesphere-toolchain.json"), "w", encoding="utf-8") as handle:
    json.dump(manifest, handle, ensure_ascii=False, indent=2, sort_keys=True)
    handle.write("\n")
PY
    info "версия тулчейна записана в gamesphere-toolchain.json"
}

# Собирает дерево в архив побайтово одинаково от прогона к прогону.
#
# Три источника расхождений и три ответа на них: время файлов приводится
# к закреплённому, порядок записей — к лексикографическому, владелец —
# к нулевому. Плюс gzip -n, который иначе кладёт в заголовок текущее время.
package_tree() {
    local tree="$1" output="$2"
    local stamp
    stamp="$(TZ=UTC date -r "$SOURCE_DATE_EPOCH" +%Y%m%d%H%M.%S)"

    find "$tree" -exec touch -h -t "$stamp" {} +

    local list
    list="$(mktemp)"
    ( cd "$tree" && find . -mindepth 1 | LC_ALL=C sort | sed 's|^\./||' > "$list" )

    ( cd "$tree" && tar -cf - --no-recursion --uid 0 --gid 0 --uname '' --gname '' -T "$list" ) \
        | gzip -n -9 > "$output"

    rm -f "$list"
}

# Последний рубеж: пакет не выпускается, если в нём нет файла происхождения
# или в нём не тот адрес.
#
# Проверяется готовый архив, а не дерево, из которого он собран: получателю
# достаётся архив, и ровно между деревом и архивом файл и терялся. Проверка
# жёсткая (падение, а не предупреждение) намеренно — сборка Wine, из которой
# не добраться до исходников, нарушает LGPL, и случиться это должно не по
# невнимательности, а никогда.
verify_package_identity() {
    local archive="$1"

    local url
    url="$(identity_url)"
    [ -n "$url" ] || fail "$(cat <<EOF
в патче $IDENTITY_FILE не назван адрес публикации исходников.

    Патч обязан называть адрес, по которому получатель сборки найдёт исходники
    Wine и нашу серию патчей. См. README.md, раздел «Обязательства LGPL».
EOF
)"

    local content status=0
    content="$(tar -xzOf "$archive" "$IDENTITY_FILE" 2>/dev/null)" || status=$?
    if [ "$status" -ne 0 ] || [ -z "$content" ]; then
        fail "$(cat <<EOF
в пакете нет файла происхождения $IDENTITY_FILE.

    Раздавать сборку Wine, из которой не добраться до исходников, нельзя:
    это нарушение LGPL-2.1. Файл кладёт в дерево write_build_identity,
    текст берётся из патча $(basename "$(identity_patch)").
    Пакет: $archive
EOF
)"
    fi

    if ! grep -qF "$url" <<<"$content"; then
        local found
        found="$(grep -oE 'https?://[^[:space:]]+' <<<"$content" || true)"
        found="${found%%$'\n'*}"
        fail "$(cat <<EOF
в файле происхождения не тот адрес исходников.

    Ожидался (из патча):  $url
    В пакете лежит:       ${found:-—адреса нет вовсе—}

    Адрес обязан совпадать с тем, что вшит в исходники патчем, — иначе
    получатель сборки уходит в никуда. Пакет: $archive
EOF
)"
    fi

    info "файл происхождения на месте, адрес исходников: $url"
}

cmd_package() {
    require_tools tar gzip shasum "$PYTHON"
    step "Упаковка"

    [ -x "$STAGE_DIR/bin/wine" ] || fail "нечего упаковывать: нет $STAGE_DIR/bin/wine"

    write_build_identity "$STAGE_DIR"
    write_toolchain_manifest "$STAGE_DIR"

    local version archive manifest_file
    version="$(toolchain_version)"
    mkdir -p "$DIST_DIR"
    archive="$DIST_DIR/gamesphere-wine-$version.tar.gz"
    manifest_file="$DIST_DIR/gamesphere-wine-$version.json"

    package_tree "$STAGE_DIR" "$archive"
    verify_package_identity "$archive"

    local digest size
    digest="$(sha256_of "$archive")"
    size="$(wc -c < "$archive" | tr -d ' ')"

    # Манифест выпуска: ровно то, что приложение забирает перед загрузкой.
    "$PYTHON" - "$manifest_file" "$version" "$DIST_BASE/$(basename "$archive")" \
        "$digest" "$size" "$(manifest wine.wineVersion)" "$(manifest wine.crossoverVersion)" <<'PY'
import json, sys

path, version, url, digest, size, wine, crossover = sys.argv[1:8]
with open(path, "w", encoding="utf-8") as handle:
    json.dump(
        {
            "formatVersion": 1,
            "version": version,
            "url": url,
            "sha256": digest,
            "sizeBytes": int(size),
            "wineVersion": wine,
            "crossoverVersion": crossover,
        },
        handle,
        ensure_ascii=False,
        indent=2,
        sort_keys=True,
    )
    handle.write("\n")
PY

    info "пакет:   $archive"
    info "манифест: $manifest_file"
    info "sha256:  $digest"
}

# ---------------------------------------------------------------------------
# Весь конвейер
# ---------------------------------------------------------------------------

cmd_build() {
    require_build_tools
    cmd_fetch
    cmd_unpack
    cmd_patches
    cmd_compile
    install_graphics "$STAGE_DIR"
    cmd_package
    step "Готово"
}

# Разложить одни только графические бекенды в указанное дерево тулчейна.
#
# Нужно затем, что бекенды проверяются запуском игры, а компиляция Wine стоит
# часа: без этой команды каждая проверка гипотезы про графику упиралась бы
# в полный конвейер. Дерево берётся готовое — своей сборки или чужой.
cmd_graphics() {
    local tree="${1:-}"
    [ -n "$tree" ] || fail "укажите каталог тулчейна: ./build.sh graphics <каталог>"
    [ -d "$tree" ] || fail "нет каталога $tree"
    require_tools curl shasum "$PYTHON"
    mkdir -p "$DOWNLOADS_DIR"

    step "Загрузка свободных графических бекендов"
    local index count url expected
    count="$(graphics_count)"
    for ((index = 0; index < count; index++)); do
        url="$(manifest "graphics.$index.url")"
        expected="$(manifest "graphics.$index.sha256")"
        download_pinned "$url" "$expected" "$DOWNLOADS_DIR/$(basename "$url")"
    done

    install_graphics "$tree"
    step "Готово"
    info "бекенды разложены в $tree/lib/gamesphere/graphics"
}

cmd_clean() {
    step "Очистка"
    # Явным путём и только внутри своего каталога сборки.
    rm -rf "$BUILD_DIR"
    info "удалено: $BUILD_DIR"
}

# ---------------------------------------------------------------------------
# Самопроверка
# ---------------------------------------------------------------------------
#
# Проверяет всё, что можно проверить без сети и без многочасовой компиляции:
# закреплённость версий, порядок наложения патчей, раскладку пакета и то,
# что повторная упаковка даёт побайтово тот же архив.
#
# Это не замена настоящей сборке, а быстрый способ узнать, что конвейер цел.

check_failures=0
check_ok()   { printf '    \033[32m✓\033[0m %s\n' "$*"; }
check_fail() { printf '    \033[31m✗\033[0m %s\n' "$*" >&2; check_failures=$((check_failures + 1)); }

check_manifest() {
    step "Проверка: закреплённые версии"

    "$PYTHON" - "$SOURCES_JSON" <<'PY' && check_ok "sources.json разбирается и содержит все обязательные поля" \
        || check_fail "sources.json неполон или не разбирается"
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
    document = json.load(handle)
assert document["formatVersion"] == 1
assert document["toolchainVersion"]
wine = document["wine"]
for key in ("crossoverVersion", "wineVersion", "url", "sourceSubdirectory", "license"):
    assert wine[key], key
assert document["graphics"], "не перечислен ни один графический бекенд"
for entry in document["graphics"]:
    for key in ("backend", "version", "url", "license", "install"):
        assert entry[key], key
    # Способ установки — свойство самих библиотек, а не вкус: см. //graphics.install.
    assert entry["install"] in ("native", "builtin"), entry["backend"]
    assert {"x64", "x86"} <= set(entry["layout"]) <= {"x64", "x86", "unix"}, entry["backend"]
    # Юникс-сторона бывает только у встроенных: подменяемая библиотека Windows
    # по определению не может жить на стороне юникса.
    if "unix" in entry["layout"]:
        assert entry["install"] == "builtin", entry["backend"]
# Проприетарного компонента Apple в поставке нет и быть не может.
assert not any(
    entry["backend"] in ("gptk", "d3dmetal") for entry in document["graphics"]
), "в поставку попал проприетарный компонент Apple"
PY

    local version
    version="$(toolchain_version)"
    if [[ "$version" == *"+gs"* ]]; then
        check_ok "версия тулчейна закреплена: $version"
    else
        check_fail "версия тулчейна должна включать номер нашей серии патчей (+gsN): $version"
    fi

    local unpinned=0
    [ -z "$(manifest wine.sha256)" ] && unpinned=$((unpinned + 1))
    local count index
    count="$(graphics_count)"
    for ((index = 0; index < count; index++)); do
        [ -z "$(manifest "graphics.$index.sha256")" ] && unpinned=$((unpinned + 1))
    done

    if [ "$unpinned" -eq 0 ]; then
        check_ok "все контрольные суммы закреплены"
    else
        # Не провал проверки: незакреплённая сумма честно останавливает сборку,
        # и это проверяется отдельно ниже.
        warn "не закреплено контрольных сумм: $unpinned — сборка откажется идти, пока не выполнить ./build.sh pin"
    fi
}

check_refuses_unpinned() {
    step "Проверка: сборка отказывается идти по незакреплённой сумме"
    if ( download_pinned "https://example.invalid/x.tar.gz" "" "/dev/null" ) >/dev/null 2>&1; then
        check_fail "незакреплённая сумма не остановила загрузку"
    else
        check_ok "незакреплённая сумма останавливает конвейер"
    fi
}

check_patch_series() {
    step "Проверка: порядок наложения патчей"

    local names=()
    while IFS= read -r line; do
        [ -n "$line" ] && names+=("$(basename "$line")")
    done < <(patch_series)

    local stray
    stray="$(find "$PATCHES_DIR" -maxdepth 1 -name '*.patch' 2>/dev/null | wc -l | tr -d ' ')"
    if [ "$stray" -eq "${#names[@]}" ]; then
        check_ok "все патчи названы по образцу NNNN-описание.patch (${#names[@]} шт.)"
    else
        check_fail "в patches/ есть файлы вне схемы NNNN-описание.patch"
    fi

    # Патчи ложатся на синтетическое дерево. Оно не Wine — оно содержит ровно
    # те файлы, которых наши патчи касаются, и потому проверка отвечает
    # на вопрос «серия цела и ложится по порядку», не требуя гигабайтов
    # исходников и часа компиляции.
    # Прибираем явно, а не ловушкой на возврате: ловушка RETURN в bash 3.2
    # переживает функцию, в которой поставлена, и срабатывает потом на чужом
    # возврате — уже с потерянной переменной.
    local sandbox
    sandbox="$(mktemp -d)"

    mkdir -p "$sandbox/tree"
    if apply_patches "$sandbox/tree" >/dev/null 2>&1; then
        check_ok "серия патчей продукта ложится на чистое дерево"
    else
        check_fail "серия патчей продукта не ложится — см. ./build.sh patches"
    fi

    # Отдельная серия-образец: 0001 создаёт файл, 0002 его дополняет. Она
    # существует ровно затем, чтобы порядок был проверен результатом, а не
    # верой в сортировку: наложи их наоборот — и второй патч не найдёт файла.
    local fixtures="$sandbox/fixtures"
    mkdir -p "$fixtures" "$sandbox/ordered"

    cat > "$fixtures/0001-create.patch" <<'PATCH'
diff --git a/order.txt b/order.txt
new file mode 100644
--- /dev/null
+++ b/order.txt
@@ -0,0 +1 @@
+первый
PATCH

    cat > "$fixtures/0002-append.patch" <<'PATCH'
diff --git a/order.txt b/order.txt
--- a/order.txt
+++ b/order.txt
@@ -1 +1,2 @@
 первый
+второй
PATCH

    if apply_patches "$sandbox/ordered" "$fixtures" >/dev/null 2>&1 \
        && [ "$(cat "$sandbox/ordered/order.txt")" = "$(printf 'первый\nвторой')" ]; then
        check_ok "патчи накладываются строго по возрастанию номера"
    else
        check_fail "порядок наложения патчей не соблюдается"
    fi

    # Битый патч обязан остановить конвейер, а не оставить дерево наполовину
    # пропатченным.
    local broken="$sandbox/broken"
    mkdir -p "$broken" "$sandbox/halfway"
    cp "$fixtures/0001-create.patch" "$broken/"
    cat > "$broken/0002-нет-такого-файла.patch" <<'PATCH'
diff --git a/отсутствует.txt b/отсутствует.txt
--- a/отсутствует.txt
+++ b/отсутствует.txt
@@ -1 +1,2 @@
 такой строки нет
+а этой тем более
PATCH

    if apply_patches "$sandbox/halfway" "$broken" >/dev/null 2>&1; then
        check_fail "битый патч не остановил наложение серии"
    elif [ -e "$sandbox/halfway/order.txt" ]; then
        check_fail "серия развалилась на середине и оставила дерево пропатченным наполовину"
    else
        check_ok "битый патч останавливает серию, не тронув дерево"
    fi

    rm -rf "$sandbox"
}

check_packaging() {
    step "Проверка: формат пакета и воспроизводимость упаковки"

    local sandbox
    sandbox="$(mktemp -d)"

    # Дерево той же формы, что оставляет настоящая сборка. Содержимое файлов
    # неважно: проверяются раскладка и повторяемость упаковки, а не Wine.
    local tree="$sandbox/stage"
    mkdir -p "$tree/bin" "$tree/lib/wine/x86_64-windows"
    printf '#!/bin/sh\nexit 0\n' > "$tree/bin/wine"
    chmod 755 "$tree/bin/wine"
    printf 'MZ' > "$tree/lib/wine/x86_64-windows/cmd.exe"

    local count index backend architecture unix_name
    count="$(graphics_count)"
    for ((index = 0; index < count; index++)); do
        backend="$(manifest "graphics.$index.backend")"
        for architecture in x64 x86; do
            mkdir -p "$tree/lib/gamesphere/graphics/$backend/$architecture"
            printf 'DLL' > "$tree/lib/gamesphere/graphics/$backend/$architecture/d3d11.dll"
        done
        unix_name="$(manifest "graphics.$index.layout.unix")"
        if [ -n "$unix_name" ] && [ "$unix_name" != "None" ]; then
            mkdir -p "$tree/lib/gamesphere/graphics/$backend/unix"
            printf 'SO' > "$tree/lib/gamesphere/graphics/$backend/unix/winemetal.so"
        fi
    done

    write_build_identity "$tree" >/dev/null
    write_toolchain_manifest "$tree" >/dev/null

    if [ -f "$tree/gamesphere-toolchain.json" ] \
        && "$PYTHON" -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); assert d['version'] and d['wineVersion']" "$tree/gamesphere-toolchain.json"; then
        check_ok "версия тулчейна лежит внутри пакета и читается"
    else
        check_fail "в пакете нет читаемого gamesphere-toolchain.json"
    fi

    package_tree "$tree" "$sandbox/first.tar.gz"
    package_tree "$tree" "$sandbox/second.tar.gz"

    if [ "$(sha256_of "$sandbox/first.tar.gz")" = "$(sha256_of "$sandbox/second.tar.gz")" ]; then
        check_ok "повторная упаковка даёт побайтово тот же архив"
    else
        check_fail "упаковка невоспроизводима: два прогона дали разные архивы"
    fi

    # Раскладку проверяем по содержимому архива, а не по каталогу: пользователь
    # получит именно архив.
    local listing
    listing="$(tar -tzf "$sandbox/first.tar.gz")"
    local expected
    for expected in "bin/wine" "gamesphere-toolchain.json" "$IDENTITY_FILE"; do
        if grep -qx "$expected" <<<"$listing"; then
            check_ok "в пакете есть $expected"
        else
            check_fail "в пакете нет $expected"
        fi
    done

    for ((index = 0; index < count; index++)); do
        backend="$(manifest "graphics.$index.backend")"
        if grep -q "^lib/gamesphere/graphics/$backend/x64/" <<<"$listing" \
            && grep -q "^lib/gamesphere/graphics/$backend/x86/" <<<"$listing"; then
            check_ok "бекенд $backend разложен в x64 и x86"
        else
            check_fail "бекенд $backend разложен не туда, куда смотрит ядро"
        fi

        unix_name="$(manifest "graphics.$index.layout.unix")"
        if [ -n "$unix_name" ] && [ "$unix_name" != "None" ]; then
            # Без юникс-стороны встроенный бекенд не работает вовсе, а не
            # работает хуже: winemetal.dll без winemetal.so не грузится.
            if grep -q "^lib/gamesphere/graphics/$backend/unix/" <<<"$listing"; then
                check_ok "у бекенда $backend разложена юникс-сторона"
            else
                check_fail "у бекенда $backend потеряна юникс-сторона"
            fi
        fi
    done

    # Путь не выдуман здесь: он обязан совпасть с тем, что строит
    # WineToolchain.graphicsBackendDirectory. Разъедься они — продукт молча
    # перестанет находить бекенды.
    local swift_source="$SCRIPT_DIR/../../Sources/GameSphereWine/WineToolchain.swift"
    if [ -f "$swift_source" ] && grep -q '"gamesphere/graphics"' "$swift_source"; then
        check_ok "путь бекендов совпадает с тем, что ищет WineToolchain"
    else
        check_fail "WineToolchain ищет бекенды не там, где их кладёт сборка"
    fi

    rm -rf "$sandbox"
}

# Упаковка обязана падать, если файла происхождения в пакете нет или адрес
# в нём не тот. Это и есть смысл узла: выпустить сборку Wine, из которой не
# добраться до исходников, должно быть нельзя — не «не принято», а нельзя.
#
# Проверяется на настоящих архивах, а не на дереве: получатель имеет дело
# с архивом, и потерять файл можно ровно между деревом и архивом.
check_package_identity() {
    step "Проверка: пакет без файла происхождения не выпускается"

    local url
    url="$(identity_url)" || url=""
    if [ -n "$url" ]; then
        check_ok "адрес исходников берётся из патча и нигде не дублируется: $url"
    else
        check_fail "в серии патчей не нашлось адреса публикации исходников"
    fi

    local sandbox
    sandbox="$(mktemp -d)"
    mkdir -p "$sandbox/tree/bin"
    printf '#!/bin/sh\nexit 0\n' > "$sandbox/tree/bin/wine"

    write_build_identity "$sandbox/tree" >/dev/null
    package_tree "$sandbox/tree" "$sandbox/celiy.tar.gz"
    if ( verify_package_identity "$sandbox/celiy.tar.gz" ) >/dev/null 2>&1; then
        check_ok "пакет с файлом происхождения проходит упаковку"
    else
        check_fail "упаковка бракует пакет, в котором файл происхождения на месте"
    fi

    # Забыли положить — ровно то, что происходило до этой проверки.
    rm -f "$sandbox/tree/$IDENTITY_FILE"
    package_tree "$sandbox/tree" "$sandbox/bez-fajla.tar.gz"
    if ( verify_package_identity "$sandbox/bez-fajla.tar.gz" ) >/dev/null 2>&1; then
        check_fail "пакет без файла происхождения прошёл упаковку — LGPL нарушается молча"
    else
        check_ok "пакет без файла происхождения упаковку не проходит"
    fi

    # Файл есть, но адрес чужой: так выглядит переезд домена, о котором забыли.
    printf 'Исходники: https://source.example.invalid/wine\n' > "$sandbox/tree/$IDENTITY_FILE"
    package_tree "$sandbox/tree" "$sandbox/chuzhoj-adres.tar.gz"
    if ( verify_package_identity "$sandbox/chuzhoj-adres.tar.gz" ) >/dev/null 2>&1; then
        check_fail "пакет с чужим адресом исходников прошёл упаковку"
    else
        check_ok "пакет с чужим адресом исходников упаковку не проходит"
    fi

    rm -rf "$sandbox"
}

# Бекенд раскладывается в промежуточную область lib/gamesphere/graphics,
# откуда его берёт пер-бутылочная раскладка (WineRuntime, тикет 30). Builtin
# самого тулчейна (lib/wine/*-windows) при этом обязан оставаться родным Wine —
# иначе тулчейн начнёт диктовать бекенд глобально, поверх выбора рецепта, и
# пер-бутылочная модель будет бороться с глобальным дефолтом. Однажды это уже
# померещилось живьём (тикет 32) из-за того, что размер d3d11.dll приняли за
# признак DXMT, не сверив содержимое; проверка закрывает это раз и навсегда.
check_graphics_isolation() {
    step "Проверка: бекенды не подменяют builtin тулчейна"

    # Статически: install_graphics пишет только в lib/gamesphere/graphics.
    # Достаточно, что каждое присвоение `to=` внутри функции ведёт туда, и что
    # функция не пишет в lib/wine напрямую.
    local body
    body="$(awk '/^install_graphics\(\)/{f=1} f{print} /^}/{if(f)exit}' "$0")"
    local to_lines stray
    to_lines="$(grep -c 'to="\$tree/lib/gamesphere/graphics' <<<"$body")"
    stray="$(grep -E 'to="\$tree/lib/wine|cp .*"\$tree/lib/wine' <<<"$body" | grep -v gamesphere || true)"
    if [ "$to_lines" -ge 2 ] && [ -z "$stray" ]; then
        check_ok "install_graphics кладёт бекенды только в промежуточную область"
    else
        check_fail "install_graphics пишет в builtin тулчейна — бекенд станет глобальным"
    fi

    # Если под рукой есть собранный тулчейн — сверяем содержимым, а не размером:
    # ни один графический builtin lib/wine не должен нести символы DXMT.
    local built="$BUILD_DIR/stage/lib/wine/x86_64-windows"
    if [ -d "$built" ]; then
        local leaked=""
        local dll
        for dll in d3d9 d3d10core d3d11 d3d12 dxgi; do
            [ -f "$built/$dll.dll" ] || continue
            if strings "$built/$dll.dll" 2>/dev/null | grep -q '_ZN4dxmt'; then
                leaked="$leaked $dll"
            fi
        done
        if [ -z "$leaked" ]; then
            check_ok "builtin собранного тулчейна — родной Wine, а не DXMT"
        else
            check_fail "в builtin тулчейна просочился DXMT:$leaked"
        fi
    else
        info "собранного тулчейна нет — сверка содержимого builtin пропущена"
    fi
}

cmd_check() {
    require_tools tar gzip shasum patch "$PYTHON"

    check_manifest
    check_refuses_unpinned
    check_patch_series
    check_packaging
    check_package_identity
    check_graphics_isolation

    printf '\n'
    if [ "$check_failures" -eq 0 ]; then
        printf '\033[32m    Самопроверка пройдена.\033[0m\n'
        printf '    Не проверено здесь: загрузка исходников и компиляция — им нужны\n'
        printf '    сеть и зависимости сборки. Полный прогон: ./build.sh build\n'
    else
        printf '\033[31m    Самопроверка провалена: %s\033[0m\n' "$check_failures"
        exit 1
    fi
}

# ---------------------------------------------------------------------------

cmd_help() {
    cat <<EOF
Конвейер сборки Wine-тулчейна GameSphere.

    ./build.sh build      весь конвейер: загрузить, наложить патчи, собрать, упаковать
    ./build.sh check      самопроверка без сети и компиляции
    ./build.sh fetch      только загрузка и сверка контрольных сумм
    ./build.sh pin        записать контрольные суммы в sources.json (нужна сеть)
    ./build.sh unpack     распаковать исходники
    ./build.sh patches    наложить нашу серию патчей на распакованное дерево
    ./build.sh compile    собрать Wine
    ./build.sh package    упаковать собранное
    ./build.sh graphics <каталог>  разложить свободные бекенды в готовое дерево тулчейна
    ./build.sh clean      удалить каталог сборки

Переменные окружения:
    GAMESPHERE_WINE_BUILD_DIR   каталог сборки (по умолчанию Tools/wine/build)
    GAMESPHERE_DIST_BASE        база адреса выпуска для манифеста
    SOURCE_DATE_EPOCH           время в пакете; закреплено ради воспроизводимости

Версии закреплены в sources.json. Патчи — в patches/, накладываются
по возрастанию номера в имени. Порядок работы и обязательства LGPL — README.md.
EOF
}

main() {
    mkdir -p "$BUILD_DIR"
    case "${1:-build}" in
        build)   cmd_build ;;
        check)   cmd_check ;;
        fetch)   cmd_fetch ;;
        pin)     cmd_pin ;;
        unpack)  cmd_unpack ;;
        patches) cmd_patches ;;
        compile) cmd_compile ;;
        package) cmd_package ;;
        graphics) shift; cmd_graphics "$@" ;;
        clean)   cmd_clean ;;
        help|-h|--help) cmd_help ;;
        *) printf 'Неизвестная команда: %s\n\n' "$1" >&2; cmd_help >&2; exit 64 ;;
    esac
}

main "$@"
