#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
渣迪 · 游戏厅 —— 本地暂存库生成脚本（不放进 App 仓库）

这个脚本把 /tmp 里已经下载/构建好的 ROM、截图和许可文件，
整理成「一个机种一个目录、一个游戏一个目录」的暂存库：

    zhadi-games/
      nes/<slug>/  game.nes  cover.png  screenshot.png
                   description.txt  description.md  meta.json
                   SOURCE.txt  LICENSE.txt  [NOTICE.txt]
      gba/...  gbc/...  snes/...
      catalog.json          # 只收录 status=ready 的游戏
      placeholders.json     # 商业作品占位索引（不计入 catalog）
      LICENSES/             # 许可全文
      sources/              # GPL / ShareAlike 对应源码包
      SHA256SUMS            # 所有 ROM 的哈希
      MANIFEST.sha256       # 本目录每个文件的哈希

★ 仓库和 APK 永远不放 ROM；这个目录在仓库外面。
★ 商业 ROM 一律不下载、不收录，只生成占位目录。
"""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import unicodedata
from pathlib import Path

from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps

ROOT = Path(__file__).resolve().parent.parent
CJK = "/System/Library/Fonts/Hiragino Sans GB.ttc"
CJK_BOLD = "/System/Library/Fonts/Hiragino Sans GB.ttc"
LATIN_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"

PLATFORMS = {
    "nes": {
        "label": "FC / NES",
        "short": "FC",
        "top": (92, 20, 32),
        "bottom": (40, 8, 18),
        "accent": (255, 92, 92),
        "native": (256, 224),
    },
    "gba": {
        "label": "GBA",
        "short": "GBA",
        "top": (54, 26, 96),
        "bottom": (20, 10, 42),
        "accent": (180, 130, 255),
        "native": (240, 160),
    },
    "gbc": {
        "label": "GBC",
        "short": "GBC",
        "top": (10, 72, 82),
        "bottom": (4, 28, 38),
        "accent": (90, 220, 210),
        "native": (160, 144),
    },
    "snes": {
        "label": "SFC / SNES",
        "short": "SFC",
        "top": (30, 46, 108),
        "bottom": (10, 16, 44),
        "accent": (120, 170, 255),
        "native": (256, 224),
    },
}

# ---------------------------------------------------------------- 实际收录（17）
GAMES = [
    dict(
        slug="nova-the-squirrel",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-dl/nova.nes",
        title_zh="松鼠诺瓦",
        title_en="Nova the Squirrel",
        genre="横版平台",
        players=1,
        tags=["平台", "冒险", "单人"],
        license="GPL-3.0-or-later + CC-BY-NC-SA-4.0",
        author="NovaSquirrel",
        version="v1.0.6a",
        source="https://github.com/NovaSquirrel/NovaTheSquirrel",
        source_ref="v1.0.6a",
        source_commit="02a3913f72713ec2e3d1c34fbd39541cd45179a0",
        source_archive="sources/nova-the-squirrel-v1.0.6a.tar.gz",
        license_file="/tmp/zhadi-lic/repos/NovaTheSquirrel/LICENSE.txt",
        screenshot="/tmp/zhadi-shots/nes/nova.png",
        desc_short="松鼠诺瓦为了找回被偷的魔法书踏上冒险。跳跃、奔跑、攻击，闯过一关关明快的像素世界。",
        desc_long=(
            "《松鼠诺瓦》是一款完成度很高的 NES homebrew 横版平台游戏。"
            "诺瓦可以奔跑、跳跃，并用尾巴攻击敌人；关卡里有金币、道具和隐藏路线。"
            "画面、音乐和容量都按 NES 实机限制制作。"
        ),
        controls="方向键：移动 / 蹲下\nA：跳跃\nB：奔跑 / 攻击\nStart：暂停",
        notice=(
            "游戏代码以 GPL-3.0-or-later 授权；图像、关卡、音乐等素材以 "
            "CC BY-NC-SA 4.0 授权，不得用于商业用途。作者明确保留 Nova 角色与设计，"
            "不得把该角色用于其他作品。来源：https://github.com/NovaSquirrel/NovaTheSquirrel"
        ),
    ),
    dict(
        slug="falling",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-dl/falling.nes",
        title_zh="坠落",
        title_en="Falling",
        genre="街机 / 反应",
        players=1,
        tags=["街机", "反应", "单人"],
        license="MIT",
        author="tragicmuffin (xram64)",
        version="master@52dcb8a",
        source="https://github.com/xram64/falling-nes",
        source_ref="master",
        source_commit="52dcb8a951200562e696dfc2aba5d4d14edd0078",
        source_archive=None,
        license_file="/tmp/zhadi-lic/falling.txt",
        screenshot="/tmp/zhadi-shots/nes/falling.png",
        desc_short="左右移动躲开不断冒出的障碍，捡金币加分；三条命，难度会一路走高。",
        desc_long=(
            "一款极简的反应类 NES 小游戏：障碍从画面下方不断向上冒出来，你要左右移动，"
            "让角色躲开它们；沿途捡金币加分，偶尔还能捡到加命道具。被撞掉一条命，"
            "并有短暂无敌时间。难度约在四分钟内分档拉满。"
            "标题画面可选白天、黄昏、夜晚三种配色和配乐。"
        ),
        controls="← / →：移动\n↑ / ↓：标题画面选模式\nStart：开始 / 暂停",
        notice=None,
    ),
    dict(
        slug="roborun",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-dl/roborun.nes",
        title_zh="机器跑酷",
        title_en="RoboRun-NES",
        genre="横版跑酷",
        players=1,
        tags=["跑酷", "平台", "单人", "PAL"],
        license="GPL-3.0",
        author="heaven-hm",
        version="v1.1",
        source="https://github.com/heaven-hm/roborun-nes",
        source_ref="main",
        source_commit="9a515a62321938ab25063e1efdc330ae74f962ba",
        source_archive="sources/roborun-main.tar.gz",
        license_file="/tmp/zhadi-lic/roborun.txt",
        screenshot="/tmp/zhadi-shots/nes/roborun.png",
        # 上游截图是一张 1920×969 的浅蓝底板，真正的游戏画面在中间这块。
        screenshot_crop=(450, 28, 1470, 932),
        desc_short="机器人在工厂关卡里奔跑、跳跃，收齐金币过关，还要躲开不同敌人。NES 上用 C 写的横版平台游戏。",
        desc_long=(
            "《RoboRun》是一款用 C 和 cc65 开发的 NES 横版平台游戏。"
            "左右移动、按 A 跳跃，收齐当关金币即可过关；途中还有星星（短暂无敌）和加命道具。"
            "共 7 关，敌人和地形逐关变化，血量见底时会出现倒计时。\n\n"
            "**注意：作者说明本 ROM 只支持 PAL / Dendy 制式。** 在 NTSC 时序下游戏速度可能偏快，"
            "手感会和实机 PAL 版本不同。"
        ),
        controls="← / →：移动\nA：跳跃\nStart：暂停",
        notice="ROM 只支持 PAL / Dendy 制式；模拟器按 NTSC 运行时速度会偏快。",
    ),
    dict(
        slug="fast-tanks",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-tanks/JD3_NES_FAST_TANKS/TANKS.nes",
        title_zh="快速坦克",
        title_en="FAST TANKS",
        genre="双人坦克对战",
        players=2,
        tags=["对战", "双人", "坦克"],
        license="MIT",
        author="JD3",
        version="master@0498ff9",
        source="https://github.com/JD3/NES_FAST_TANKS",
        source_ref="main",
        source_commit="0498ff904201bdf0906117d23165abf3fc3a0017",
        source_archive=None,
        license_file="/tmp/zhadi-tanks/JD3_NES_FAST_TANKS/LICENSE",
        screenshot=None,
        cover_scene="tanks",
        desc_short="双人坦克对战：在迷宫里转向、开火，先击毁对手的坦克获胜。经典坦克大战的合法开源替代。",
        desc_long=(
            "《FAST TANKS》是 JD3 用 6502 汇编写的双人 NES 坦克对战游戏。"
            "两台坦克在障碍场地里互相射击，子弹会被墙挡住，需要绕位和预判。"
            "支持同机双人，需要两只手柄。\n\n"
            "它是经典《坦克大战》的自由软件替代品：MIT 授权，代码和素材都可以再分发。"
        ),
        controls="方向键：转向 / 移动\nA：前进\nB：开火\n1P Start：重置场地",
        notice=None,
    ),
    dict(
        slug="opennes-snake",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-clones/sebastiandine_openNES-Snake/build/snake.nes",
        title_zh="贪吃蛇",
        title_en="openNES Snake",
        genre="休闲 / 贪吃蛇",
        players=1,
        tags=["休闲", "益智", "单人"],
        license="Zlib",
        author="Sebastian Dine",
        version="built from source@66cc333",
        source="https://github.com/sebastiandine/openNES-Snake",
        source_ref="master",
        source_commit="66cc333b9df91c2b27965083c48dd0e6e48cb044",
        source_archive=None,
        license_file="/tmp/zhadi-clones/sebastiandine_openNES-Snake/LICENSE",
        screenshot="/tmp/zhadi-shots/nes/snake2.png",
        desc_short="经典贪吃蛇的 NES 重制：吃果实变长，别撞墙、别咬到自己。",
        desc_long=(
            "openNES Snake 用 C 和 cc65 编写的 NES 贪吃蛇，目标是重现经典玩法。"
            "吃下果实身体变长，撞墙或撞到自己即结束。作为开源示例项目，代码也适合学习 NES C 开发。"
        ),
        controls="方向键：转向\nStart：开始 / 暂停",
        notice="本 ROM 由本地 cc65 从仓库源码编译，源码提交见 SOURCE.txt。",
    ),
    dict(
        slug="8bit-table-tennis",
        platform="nes",
        ext="nes",
        src="/tmp/zhadi-clones/mike42_8bit-table-tennis/build/table_tennis.nes",
        title_zh="8 位乒乓球",
        title_en="8-Bit Table Tennis",
        genre="体育",
        players=2,
        tags=["体育", "双人", "乒乓"],
        license="MIT",
        author="Michael Billington (mike42)",
        version="built from source@21dae94",
        source="https://github.com/mike42/8bit-table-tennis",
        source_ref="main",
        source_commit="21dae94d845ee527ebe40a42e41f8a0a0c8f25dd",
        source_archive=None,
        license_file="/tmp/zhadi-clones/mike42_8bit-table-tennis/LICENSE",
        screenshot="/tmp/zhadi-shots/nes/tennis1.png",
        desc_short="极简 8 位乒乓球，先得 11 分者胜。支持同机双人对战。",
        desc_long=(
            "《8-Bit Table Tennis》是一款双人 NES 乒乓球游戏。发球、对拉、抢分，先到 11 分获胜。"
            "双人模式需要两只手柄；单人无法对战 AI。"
        ),
        controls="Start：开始\n↑ / ↓：移动\nA：发球\n先得 11 分获胜",
        notice="双人模式需要两个手柄。本 ROM 由本地 cc65 从仓库源码编译。",
    ),
    dict(
        slug="pong",
        platform="gba",
        ext="gba",
        src="/tmp/zhadi-dl/pong.gba",
        title_zh="乒乓 Pong",
        title_en="Pong (Homebrew GBA)",
        genre="街机 / 体育",
        players=1,
        tags=["街机", "体育", "单人"],
        license="MIT",
        author="ZeroDayArcade",
        version="main@c784b60",
        source="https://github.com/ZeroDayArcade/Pong-Homebrew-GBA",
        source_ref="main",
        source_commit="c784b6036a4f188c50932b411e98126bfcbd07d6",
        source_archive=None,
        license_file="/tmp/zhadi-lic/pong.txt",
        screenshot=None,
        cover_scene="pong",
        desc_short="经典 Pong 的 GBA 重制：移动挡板接球，让 AI 对手失分。先得 10 分获胜。",
        desc_long=(
            "ZeroDayArcade 的 Pong homebrew，用 GBA Mode 3 位图模式实现。"
            "玩家对战电脑，移动挡板接住球并让对手漏球，先到 10 分获胜。"
            "代码写得很直白，也适合作为 GBA 入门示例。"
        ),
        controls="↑ / ↓：移动挡板\nA / Start：菜单",
        notice=None,
    ),
    dict(
        slug="solar-guard",
        platform="gba",
        ext="gba",
        src="/tmp/zhadi-dl/solarguard.gba",
        title_zh="太阳守卫",
        title_en="Solar Guard",
        genre="太空射击",
        players=1,
        tags=["射击", "太空", "单人"],
        license="GPL-3.0 + third-party art licenses",
        author="Deft Spade",
        version="GBA-JAM-2021",
        source="https://github.com/Deft-Spade/Solar-Guard",
        source_ref="GBA-JAM-2021",
        source_commit="7418bdbeccbc62366e1abd57526402990973d9a8",
        source_archive="sources/solar-guard-GBA-JAM-2021.tar.gz",
        license_file="/tmp/zhadi-lic/solar.txt",
        screenshot="/tmp/zhadi-shots/gba/solar-2.png",
        desc_short="在太阳系执行任务的太空射击：转向、推进、开火，清理轨道上的太空垃圾。",
        desc_long=(
            "《Solar Guard》是 GBA Jam 2021 参赛作品，使用 Butano 引擎开发。"
            "玩家驾驶飞船在行星轨道上完成护航、巡逻、救援等任务，管理燃料、能量和热量，"
            "击毁太空垃圾与敌方目标。"
        ),
        controls="← / →：转向\nR：推进\nL：刹车\nL + R：倒退\nA：开火\nStart：暂停",
        notice=(
            "源代码和原创素材以 GPL-3.0 授权。部分美术来自 Open Game Art，"
            "分别使用 CC BY 3.0、CC BY-SA 3.0、OGA-BY 3.0、CC0 / 公有领域授权。"
            "完整署名与来源见本目录 NOTICE.txt。"
        ),
        notic_extra=True,
    ),
    dict(
        slug="notebook-adventure",
        platform="gba",
        ext="gba",
        src="/tmp/zhadi-dl/notebook.gba",
        title_zh="笔记本大冒险",
        title_en="Notebook Adventure",
        genre="横版平台",
        players=1,
        tags=["平台", "冒险", "单人"],
        license="GPL-3.0",
        author="NotImplementedLife",
        version="v1.3",
        source="https://github.com/NotImplementedLife/NotebookAdventure",
        source_ref="v1.3",
        source_commit="a554578b067b7734fe35ea3b1b91b7119638cd6e",
        source_archive="sources/notebook-adventure-v1.3.tar.gz",
        license_file="/tmp/zhadi-lic/notebook.txt",
        screenshot="/tmp/zhadi-shots/nes/notebook2.png",
        desc_short="在笔记本纸页之间跳跃冒险，切换角色、探索隐藏区域。",
        desc_long=(
            "《Notebook Adventure》是 RetroPlatformJam #4 的 GBA 平台游戏。"
            "画面像画在笔记本上的涂鸦，玩家可以切换不同角色，利用各自能力探索关卡。"
        ),
        controls="方向键：移动\nA：跳跃\nL：切换角色\nR：探险 / 互动\nStart：暂停",
        notice=None,
    ),
    dict(
        slug="tobu-tobu-girl-dx",
        platform="gbc",
        ext="gb",
        src="/tmp/zhadi-dl/tobu.gb",
        title_zh="飞跃少女 DX",
        title_en="Tobu Tobu Girl Deluxe",
        genre="平台动作",
        players=1,
        tags=["平台", "动作", "单人"],
        license="MIT + CC-BY-4.0",
        author="Tangram Games",
        version="master@4985d4d",
        source="https://github.com/SimonLarsen/tobutobugirl-dx",
        source_ref="master",
        source_commit="4985d4d282aa43229f250ffa3b0d86998ebd55d3",
        source_archive=None,
        license_file="/tmp/zhadi-lic/repos/tobutobugirl-dx/LICENSE",
        screenshot="/tmp/zhadi-shots/gb/tobu-3.png",
        desc_short="为了救回被拐走的猫，少女在天空与城市间八方向冲刺。轻快的 GBC 平台动作。",
        desc_long=(
            "《Tobu Tobu Girl Deluxe》是 Tangram Games 开发的 Game Boy Color 平台动作游戏。"
            "少女可以在八个方向上冲刺，收集道具、躲开敌人，救回被拐走的猫。"
            "画面明快，音乐轻快，GBC 彩色版比原版更完整。"
        ),
        controls="方向键：移动 / 选择冲刺方向\nA：冲刺\nB：使用道具 / 加速\nStart：暂停",
        notice=(
            "源代码以 MIT 授权；图像、文字、声音和音乐等全部素材以 CC BY 4.0 授权。"
            "再分发时请保留作者署名。来源：https://github.com/SimonLarsen/tobutobugirl-dx"
        ),
    ),
    dict(
        slug="petris",
        platform="gbc",
        ext="gbc",
        src="/tmp/zhadi-dl/petris.gbc",
        title_zh="宠物俄罗斯方块",
        title_en="Petris",
        genre="益智 / 方块",
        players=1,
        tags=["益智", "方块", "单人"],
        license="CC-BY-NC-SA-4.0",
        author="bbbbbr",
        version="release_1.1_gbdk2020@8e702a9",
        source="https://github.com/bbbbbr/Petris",
        source_ref="release_1.1_gbdk2020",
        source_commit="8e702a9d331f4b87419ace041a82f42706f30ad1",
        source_archive="sources/petris-release_1.1_gbdk2020.tar.gz",
        license_file="/tmp/zhadi-lic/repos/Petris/LICENSE",
        screenshot="/tmp/zhadi-shots/gb/petris-3.png",
        desc_short="把可爱的宠物拼块堆叠消除，玩法类似俄罗斯方块，但每一块都是小动物。",
        desc_long=(
            "《Petris》把俄罗斯方块的方块换成了各种形状的宠物：拼出完整的长宠物、清理尾巴、"
            "在屏幕向上挤压前活下去。支持多种游戏类型和难度，画面是明快的 GBC 彩色。\n\n"
            "原作支持联机线双人对战；本 App 只能单人游玩。"
        ),
        controls="← / →：左右移动\n↓：加速下落\nB / A：左右旋转\nStart：暂停\nSelect：查看宠物长度",
        notice=(
            "整体以 CC BY-NC-SA 4.0 授权，不得用于商业用途。作者对 ShareAlike 的定义"
            "包含源代码与修改的分发。音乐素材来自 GB Studio Community Assets（Krümel / crumb）。"
        ),
    ),
    dict(
        slug="ucity",
        platform="gbc",
        ext="gbc",
        src="/tmp/zhadi-dl/ucity.gbc",
        title_zh="微缩城市",
        title_en="uCity",
        genre="模拟经营",
        players=1,
        tags=["模拟", "经营", "单人"],
        license="GPL-3.0-or-later + CC-BY-SA-4.0",
        author="AntonioND",
        version="master@71fb08e",
        source="https://github.com/AntonioND/ucity",
        source_ref="master",
        source_commit="71fb08e7ca53fe4228e66316968d9c6bea3439ba",
        source_archive="sources/ucity-master.tar.gz",
        license_file="/tmp/zhadi-lic/repos/ucity/gpl-3.0.txt",
        screenshot="/tmp/zhadi-shots/gb/ucity-1.png",
        desc_short="在 GBC 上规划道路、住宅、商业和工业，慢慢把小城发展起来。",
        desc_long=(
            "《uCity》是 GBC 上的城市建造模拟游戏。规划道路、电力、住宅、商业和工业，"
            "吸引居民、平衡预算，逐步扩大城市规模。界面为掌机做了紧凑的适配。"
        ),
        controls="Select：打开建造菜单\nStart：暂停菜单\n按住 B：自由移动地图\nA：查看 / 确认",
        notice=(
            "带 GPL 头的源代码以 GPL-3.0-or-later 授权；图像和音乐以 CC BY-SA 4.0 授权。"
            "仓库中还包含其他许可的第三方代码（如 GBT Player，BSD-2-Clause），"
            "完整说明见上游 readme.rst。"
        ),
    ),
    dict(
        slug="2048gb",
        platform="gbc",
        ext="gb",
        src="/tmp/zhadi-dl/2048.gb",
        title_zh="2048 GB",
        title_en="2048gb",
        genre="益智 / 数字",
        players=1,
        tags=["益智", "数字", "单人"],
        license="Zlib",
        author="Sanqui",
        version="master@e44f94f",
        source="https://github.com/Sanqui/2048-gb",
        source_ref="master",
        source_commit="e44f94f898c1e0de7caa1160c82c0da36ff5eb05",
        source_archive=None,
        license_file="/tmp/zhadi-lic/2048.txt",
        screenshot="/tmp/zhadi-shots/gb/2048-2.png",
        desc_short="把相同数字方块合并，目标是拼出 2048。GBC 上的经典 2048，随开随玩。",
        desc_long=(
            "《2048gb》是经典 2048 的 Game Boy 移植：滑动合并相同数字，目标拼出 2048。"
            "随开随玩，适合在车上打发几分钟。\n\n"
            "原版 2048 由 Gabriele Cirulli 创作；GB 移植为 Sanqui（Sanky）；"
            "方块图形由 beware（BGB 作者）制作。"
        ),
        controls="方向键：滑动合并",
        notice="原版 2048 由 Gabriele Cirulli 创作；GB 移植 Sanqui；tile graphics by beware of BGB fame。",
    ),
    dict(
        slug="flooder",
        platform="gbc",
        ext="gb",
        src="/tmp/zhadi-dl/flooder.gb",
        title_zh="色彩洪流",
        title_en="Flooder",
        genre="益智",
        players=1,
        tags=["益智", "色彩", "单人"],
        license="MIT",
        author="Obalfour",
        version="main@29991e4",
        source="https://github.com/Obalfour/Flooder",
        source_ref="main",
        source_commit="29991e4306e6b8da4d7750f9818d2c30dcf8b78a",
        source_archive=None,
        license_file="/tmp/zhadi-lic/flooder.txt",
        screenshot="/tmp/zhadi-shots/gb/flooder-2.png",
        desc_short="从左上角开始染色，在 25 步内把 14×14 的棋盘变成同一种颜色。",
        desc_long=(
            "《Flooder》是 Flood It 类型益智游戏：棋盘 14×14、共 6 种颜色，"
            "左上角是你掌控的色块。每次挑一种颜色填充，与它相连的同色区域会并入你的地盘；"
            "25 步内把整块棋盘染成同一色就算过关，通关后自动生成新棋盘。"
        ),
        controls="↑ / ←：向前切换颜色\n↓ / →：向后切换颜色\nA / B：选定颜色并填充\nStart：开始 / 继续下一局",
        notice=None,
    ),
    dict(
        slug="crossconnect",
        platform="gbc",
        ext="gbc",
        src="/tmp/zhadi-dl/crossconnect.gbc",
        title_zh="交叉连线",
        title_en="CrossConnect",
        genre="益智 / 连线",
        players=1,
        tags=["益智", "连线", "单人"],
        license="MIT",
        author="Quinn Painter",
        version="main@7813ec4",
        source="https://github.com/QuinnPainter/CrossConnect",
        source_ref="main",
        source_commit="7813ec4efc329381b94f93fe0ca526053f62c16f",
        source_archive=None,
        license_file="/tmp/zhadi-lic/crossconnect.txt",
        screenshot="/tmp/zhadi-shots/gb/crossconnect-2.png",
        desc_short="在网格上画线连接相同颜色的端点，线不能交叉。规则简单但关卡烧脑。",
        desc_long=(
            "《CrossConnect》是连线谜题：把相同颜色的端点用线连起来，线不能交叉、不能重叠。"
            "随着关卡推进，网格会越来越大，颜色也越来越多。"
        ),
        controls="方向键：移动光标\n按住 A：画线\nB：删除",
        notice=None,
    ),
    dict(
        slug="space-rescue-squad-demo",
        platform="snes",
        ext="sfc",
        src="/tmp/zhadi-dl/srs/Space Rescue Squad Demo v1.1.sfc",
        title_zh="太空救援小队（试玩）",
        title_en="Space Rescue Squad Demo",
        genre="动作 / 平台",
        players=1,
        tags=["动作", "平台", "单人"],
        license="Zlib + game-specific asset license",
        author="Marcus Rowe (undisbeliever)",
        version="game-jam-v1.1",
        source="https://github.com/undisbeliever/space-rescue-squad",
        source_ref="game-jam-v1.1",
        source_commit="9f263c2fa92d2c984ded91ed4ede23fdcb5e2b31",
        source_archive=None,
        license_file="/tmp/zhadi-clones/undisbeliever_space-rescue-squad/game/LICENSE",
        screenshot="/tmp/zhadi-shots/nes/srs.jpeg",
        desc_short="在太空站里奔跑、跳跃、近战和射击，救出受困的队友。2025 年游戏开发活动试玩版。",
        desc_long=(
            "《Space Rescue Squad》是一款 SNES 动作平台游戏，"
            "由 Marcus Rowe（undisbeliever）为游戏开发活动制作的试玩版。"
            "在太空站里奔跑、跳跃、近战和射击，打开舱门，救出受困的队友。"
        ),
        controls="B：跳跃\nY：奔跑\nA：近战\nX：射击\n↑：进门 / 互动\nStart：暂停",
        notice=(
            "汇编代码以 zlib 授权；`game/resources` 目录中的游戏资源仅允许用于本游戏，"
            "不得用于其他项目。音乐由 KungFuFurby 创作。"
        ),
    ),
    dict(
        slug="super-apocalux",
        platform="snes",
        ext="sfc",
        src="/tmp/zhadi-clones/DanielTheSilly_Super-Apocalux/super-apocalux.smc",
        title_zh="超级末日",
        title_en="Super Apocalux",
        genre="街机 / 跑酷",
        players=1,
        tags=["街机", "跑酷", "单人"],
        license="GPL-3.0",
        author="DanielTheSilly",
        version="v1.0 (master@8dc8f28)",
        source="https://github.com/DanielTheSilly/Super-Apocalux",
        source_ref="master",
        source_commit="8dc8f280854e491fe4659d6a67088af866724474",
        source_archive="sources/super-apocalux-master.tar.gz",
        license_file="/tmp/zhadi-clones/DanielTheSilly_Super-Apocalux/LICENSE",
        screenshot=None,
        cover_scene="apocalux",
        desc_short="末日世界里的街机式跑酷：一路向前跑、跳过断崖，尽量活得更久、拿更高分。",
        desc_long=(
            "《Super Apocalux》是一款开源 SNES 街机跑酷游戏：角色会自动向右跑，"
            "你只需要控制左右和跳跃，跳过断崖和障碍，活得更久、拿更高分。"
            "作者把它作为开源作品发布，游戏仍在开发中，没有敌人和完整关卡。\n\n"
            "本 ROM 由 WLA-DX 10.7 从上游源码编译；头部校验和已核对"
            "（校验和 + 补码 = 0xFFFF，全 ROM 字节和 = 头部校验和）。"
        ),
        controls="← / →：移动\n↑：跳跃\n角色会自动向右跑",
        notice=(
            "GPL-3.0。作者允许托管，条件是在分发时附带 README 或指向项目主页的链接："
            "https://github.com/DanielTheSilly/Super-Apocalux 。"
            "本目录的 SOURCE.txt 已包含该链接与版本信息。"
        ),
    ),
]

# ---------------------------------------------------------------- 占位（36）
PLACEHOLDERS = [
    # NES
    ("nes", "Super Mario Bros.", "超级马里奥兄弟", "平台跳跃", 2,
     "踩敌人、顶砖块、救公主。1985 年定义了横版平台游戏的经典之作。", "commercial"),
    ("nes", "Battle City", "坦克大战", "坦克对战", 2,
     "守卫基地、消灭敌方坦克的经典双人对战游戏，也是无数人的童年回忆。", "commercial"),
    ("nes", "Contra", "魂斗罗", "跑打射击", 2,
     "横版跑打射击的代表作：跳跃、八方向射击，和伙伴一起闯过异形巢穴。", "commercial"),
    ("nes", "The Legend of Zelda", "塞尔达传说", "动作冒险", 1,
     "探索海拉鲁、收集三角力量、救出塞尔达公主，动作冒险游戏的开山之作。", "commercial"),
    ("nes", "Mega Man 2", "洛克人 2", "动作", 1,
     "击败八大 Boss、获取他们的武器，再挑战威利博士的经典动作游戏。", "commercial"),
    ("nes", "Castlevania", "恶魔城", "动作", 1,
     "挥舞鞭子深入德古拉城堡，与怪物和 Boss 战斗的哥特动作经典。", "commercial"),
    ("nes", "Metroid", "银河战士", "探索动作", 1,
     "在泽贝斯星球探索、解锁能力、对抗母脑，非线性探索玩法的先驱。", "commercial"),
    ("nes", "Kirby's Adventure", "星之卡比 梦之泉物语", "平台", 1,
     "吸入敌人复制能力，用各种能力闯过色彩明快的梦之泉。", "commercial"),
    # GBA
    ("gba", "Pokémon Emerald", "宝可梦 绿宝石", "角色扮演", 1,
     "在丰缘地区收服宝可梦、挑战道馆和四天王，系列内容最完整的作品之一。", "commercial"),
    ("gba", "Pokémon FireRed", "宝可梦 火红", "角色扮演", 1,
     "关都地区的经典重制，收服初代 151 只宝可梦，挑战石英高原。", "commercial"),
    ("gba", "The Legend of Zelda: The Minish Cap", "塞尔达传说 缩小帽", "动作冒险", 1,
     "借助皮克尔的力量缩小身体，在微观世界里解谜、冒险。", "commercial"),
    ("gba", "Metroid Fusion", "银河战士 融合", "动作探索", 1,
     "穿上融合服，在空间站里躲避 X 寄生虫，系列的掌机经典。", "commercial"),
    ("gba", "Golden Sun", "黄金太阳", "角色扮演", 1,
     "收集元素精灵、组合职业、用精神力解谜的 GBA 原创 RPG。", "commercial"),
    ("gba", "Advance Wars", "高级战争", "回合制战棋", 2,
     "指挥陆海空部队、利用地形和兵种相克取胜的回合制策略经典。", "commercial"),
    ("gba", "Castlevania: Aria of Sorrow", "恶魔城 晓月圆舞曲", "动作", 1,
     "收集怪物之魂、组合能力，在德古拉城堡里探索的 GBA 恶魔城巅峰。", "commercial"),
    ("gba", "Mario Kart: Super Circuit", "马里奥赛车 超级赛道", "竞速", 1,
     "漂移、道具、彩虹赛道，GBA 上的马里奥赛车，支持联机对战。", "commercial"),
    # GBC
    ("gbc", "Pokémon Crystal", "宝可梦 水晶", "角色扮演", 1,
     "城都地区的完整版，加入动画、女性主角和水晶版专属剧情。", "commercial"),
    ("gbc", "The Legend of Zelda: Oracle of Seasons", "塞尔达传说 大地之章", "动作冒险", 1,
     "用四季之杖改变季节，在霍隆村展开的塞尔达外传。", "commercial"),
    ("gbc", "The Legend of Zelda: Oracle of Ages", "塞尔达传说 时空之章", "动作冒险", 1,
     "用时空之琴穿梭过去与现在，与大地之章联动的塞尔达外传。", "commercial"),
    ("gbc", "Super Mario Bros. Deluxe", "超级马里奥兄弟 DX", "平台", 1,
     "初代马里奥的 GBC 移植，附带挑战模式和双人对战。", "commercial"),
    ("gbc", "Wario Land 3", "瓦力欧乐园 3", "平台", 1,
     "在音乐盒世界里寻找宝藏，利用敌人攻击变身解谜。", "commercial"),
    ("gbc", "Metal Gear Solid", "合金装备 幽灵通天塔", "潜入动作", 1,
     "GBC 原创的潜入游戏，利用纸箱、雷达和潜行穿过基地。", "commercial"),
    ("gbc", "Shantae", "香缇", "平台", 1,
     "半精灵舞者香缇用舞蹈变身，在西部风格的平台上冒险。", "commercial"),
    ("gbc", "Tetris DX", "俄罗斯方块 DX", "益智", 1,
     "经典俄罗斯方块的 GBC 加强版，支持存档和双人对战。", "commercial"),
    # SNES
    ("snes", "Super Mario World", "超级马里奥世界", "平台", 2,
     "和耀西一起探索恐龙乐园，隐藏出口和羽毛披风让它成为 SNES 代表作。", "commercial"),
    ("snes", "The Legend of Zelda: A Link to the Past", "塞尔达传说 众神的三角力量", "动作冒险", 1,
     "光与暗两个世界并行探索，2D 塞尔达的模板之作。", "commercial"),
    ("snes", "Super Metroid", "超级银河战士", "探索动作", 1,
     "在泽贝斯星球独自探索、解锁能力，氛围与关卡设计的巅峰。", "commercial"),
    ("snes", "Chrono Trigger", "时空之轮", "角色扮演", 1,
     "时间旅行、群像叙事和多结局，SNES 时代最受赞誉的 RPG 之一。", "commercial"),
    ("snes", "Final Fantasy VI", "最终幻想 VI", "角色扮演", 1,
     "十四位角色、魔导装甲和歌剧场景，系列剧情最宏大的作品之一。", "commercial"),
    ("snes", "Donkey Kong Country", "大金刚国度", "平台", 2,
     "预渲染 3D 画面与精准平台跳跃，SNES 后期的技术展示。", "commercial"),
    ("snes", "Super Mario Kart", "超级马里奥赛车", "竞速", 2,
     "Mode 7 分屏竞速，马里奥赛车系列的开山之作。", "commercial"),
    ("snes", "EarthBound", "地球冒险", "角色扮演", 1,
     "现代小镇背景的怪奇 RPG，用棒球棒和 PSI 对抗宇宙邪恶。", "commercial"),
    # 免费但暂不提供 ROM 的 homebrew
    ("gba", "Google Dino Advance", "谷歌小恐龙 GBA", "跑酷", 1,
     "Chrome 断网小恐龙的 GBA 移植。代码是 MIT，但游戏美术版权归 Google 所有，"
     "为避免争议，本库只保留占位，不提供 ROM。", "homebrew-rights"),
    ("nes", "Super Tilt Bro.", "超级倾斜兄弟", "对战平台", 2,
     "免费开源的 NES 平台格斗游戏，支持 Rollback 联机。ROM 获取渠道不稳定，先保留占位。",
     "homebrew-unavailable"),
    ("gba", "Celeste Classic GBA", "蔚蓝 Classic GBA", "平台", 1,
     "《Celeste Classic》的 GBA 粉丝移植。作者未给出明确的再分发许可，"
     "因此只保留占位，不提供 ROM。", "homebrew-rights"),
    ("gba", "3D SnowTank GBA", "3D 雪地坦克", "坦克 / 技术演示", 1,
     "带 3D 引擎的 GBA 雪地坦克技术演示。没有找到明确的可再分发许可与稳定 ROM，先保留占位。",
     "homebrew-unavailable"),
]


# ROM 的实际来历（与源码仓库 provenance 分开记，避免「用源码 commit 冒充 ROM 出处」）
ROM_ORIGIN = {
    "nova-the-squirrel": "GitHub Releases v1.0.6a 的预编译 ROM",
    "falling": "上游发布包里的预编译 ROM",
    "roborun": "上游发布包 v1.1 的预编译 ROM",
    "fast-tanks": "上游仓库里的预编译 TANKS.nes",
    "opennes-snake": "本地用 cc65 从仓库源码编译",
    "8bit-table-tennis": "本地用 cc65 从仓库源码编译",
    "pong": "上游仓库 / 发布包里的预编译 ROM",
    "solar-guard": "GBA Jam 2021 发布包里的预编译 ROM",
    "notebook-adventure": "上游发布包 v1.3 的预编译 ROM",
    "tobu-tobu-girl-dx": "gbdev/database 收录的 tobudx.gb",
    "petris": "上游 release_1.1_gbdk2020 发布包",
    "ucity": "上游 master 发布包里的预编译 ROM",
    "2048gb": "上游发布包里的预编译 ROM",
    "flooder": "上游发布包里的预编译 ROM",
    "crossconnect": "上游发布包里的预编译 ROM",
    "space-rescue-squad-demo": "game-jam-v1.1 试玩版发布包",
    "super-apocalux": "本地用 WLA-DX 10.7 从上游源码编译（头部校验和已核对）",
}


def sha256_file(p: Path) -> str:
    h = hashlib.sha256()
    with p.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
    if bold:
        return ImageFont.truetype(CJK_BOLD, size, index=2)
    return ImageFont.truetype(CJK, size, index=0)


def latin(size: int, bold: bool = True) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(LATIN_BOLD if bold else CJK, size, index=0 if not bold else 0)


def vgradient(w: int, h: int, top: tuple, bottom: tuple) -> Image.Image:
    im = Image.new("RGB", (1, h))
    px = im.load()
    for y in range(h):
        t = y / max(1, h - 1)
        px[0, y] = tuple(round(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
    return im.resize((w, h), Image.Resampling.NEAREST)


def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
    m = Image.new("L", size, 0)
    ImageDraw.Draw(m).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
    return m


def pixel_fit(im: Image.Image, size: tuple[int, int]) -> Image.Image:
    """整屏等比放进卡片，不裁掉游戏画面；空出来的边用虚化的同图垫底。"""
    im = im.convert("RGB")
    w, h = size
    scale = min(w / im.width, h / im.height)
    nw, nh = max(1, round(im.width * scale)), max(1, round(im.height * scale))
    # 像素图放大用最近邻；轻微缩小用 LANCZOS，避免像素网格变得坑坑洼洼。
    method = Image.Resampling.NEAREST if scale >= 1.5 else Image.Resampling.LANCZOS
    sharp = im.resize((nw, nh), method)

    bg = ImageOps.fit(im, (w, h), method=Image.Resampling.LANCZOS)
    bg = bg.filter(ImageFilter.GaussianBlur(18))
    bg = Image.eval(bg, lambda v: int(v * 0.38))
    bg.paste(sharp, ((w - nw) // 2, (h - nh) // 2))
    return bg


def scene_pong(size=(448, 300)) -> Image.Image:
    w, h = 224, 150
    im = Image.new("RGB", (w, h), (10, 14, 26))
    d = ImageDraw.Draw(im)
    for y in range(0, h, 12):
        d.rectangle((w // 2 - 1, y, w // 2, y + 5), fill=(70, 80, 105))
    d.rectangle((13, 58, 17, 92), fill=(235, 240, 250))
    d.rectangle((w - 17, 48, w - 13, 82), fill=(235, 240, 250))
    d.rectangle((105, 70, 111, 76), fill=(235, 240, 250))
    f = latin(18, True)
    d.text((78, 10), "3", font=f, fill=(160, 170, 190))
    d.text((138, 10), "5", font=f, fill=(160, 170, 190))
    return im.resize(size, Image.Resampling.NEAREST)


def scene_tanks(size=(448, 300)) -> Image.Image:
    w, h = 224, 150
    im = Image.new("RGB", (w, h), (14, 18, 16))
    d = ImageDraw.Draw(im)
    # 砖墙迷宫
    for bx in range(0, w, 28):
        for by in range(14, h, 20):
            if (bx // 28 + by // 20) % 3 == 0:
                d.rectangle((bx, by, bx + 26, by + 16), fill=(78, 44, 34))
                d.line((bx, by + 8, bx + 26, by + 8), fill=(40, 22, 18))
    def tank(x, y, body, turret, barrel_dir):
        d.rectangle((x, y, x + 18, y + 14), fill=body, outline=(240, 240, 230))
        d.rectangle((x + 5, y + 3, x + 13, y + 11), fill=turret)
        if barrel_dir > 0:
            d.rectangle((x + 18, y + 7, x + 27, y + 9), fill=(235, 235, 220))
        else:
            d.rectangle((x - 9, y + 7, x, y + 9), fill=(235, 235, 220))
    tank(26, 96, (24, 24, 30), (90, 90, 110), 1)
    tank(174, 36, (150, 36, 36), (220, 90, 90), -1)
    d.rectangle((108, 72, 112, 76), fill=(250, 220, 80))
    return im.resize(size, Image.Resampling.NEAREST)


def scene_apocalux(size=(448, 300)) -> Image.Image:
    w, h = 224, 150
    im = vgradient(w, h, (36, 18, 54), (232, 122, 62))
    d = ImageDraw.Draw(im)
    # 太阳
    d.ellipse((152, 72, 190, 110), fill=(255, 208, 96))
    # 远山
    d.polygon([(0, 108), (36, 82), (70, 108), (106, 76), (146, 108), (186, 88), (224, 108), (224, 150), (0, 150)],
              fill=(46, 30, 48))
    # 地面方块
    for x in range(0, w, 16):
        d.rectangle((x, 124, x + 15, 134), fill=(96, 62, 42))
        d.rectangle((x + 2, 136, x + 13, 144), fill=(74, 46, 34))
    # 小人与平台
    d.rectangle((92, 112, 128, 118), fill=(120, 92, 60))
    d.rectangle((105, 96, 111, 106), fill=(232, 236, 246))  # 身体
    d.rectangle((107, 88, 112, 96), fill=(232, 236, 246))   # 头
    d.rectangle((104, 106, 107, 112), fill=(120, 180, 240)) # 腿
    d.rectangle((111, 106, 114, 112), fill=(120, 180, 240))
    return im.resize(size, Image.Resampling.NEAREST)


def draw_pill(d: ImageDraw.ImageDraw, xy, text, fg, bg, fnt):
    x, y = xy
    bbox = d.textbbox((0, 0), text, font=fnt)
    tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
    padx, pady = 10, 5
    w, h = tw + padx * 2, th + pady * 2 + 1
    d.rounded_rectangle((x, y, x + w, y + h), radius=h // 2, fill=bg)
    d.text((x + padx - bbox[0], y + pady - bbox[1]), text, font=fnt, fill=fg)
    return w, h


def make_cover(
    out: Path,
    platform: str,
    title_zh: str,
    title_en: str,
    screenshot: Path | None = None,
    scene: str | None = None,
    placeholder: bool = False,
) -> None:
    info = PLATFORMS[platform]
    W = H = 512
    im = vgradient(W, H, info["top"], info["bottom"]).convert("RGBA")

    # 半透明装饰画在独立图层上再合成：ImageDraw 在 RGBA 上是「写入」不是「混合」，
    # 直接画 fill=(255,255,255,10) 保存成 RGB 会变成纯白线。
    overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    for x in range(0, W, 32):
        od.line((x, 0, x, H), fill=(255, 255, 255, 10), width=1)
    for y in range(0, H, 32):
        od.line((0, y, W, y), fill=(255, 255, 255, 10), width=1)

    card = (32, 38, 480, 338)
    if placeholder:
        # 占位封面：低透明度色块 + 大号缩写 + 锁
        seed = int(hashlib.sha256(title_en.encode("utf-8")).hexdigest()[:8], 16)
        sw, sh = 10, 8
        for gy in range(sh):
            for gx in range(sw):
                if (seed >> ((gx + gy) % 32)) & 1:
                    a = 14 + ((seed >> ((gx * 3 + gy) % 16)) & 15)
                    x0 = 32 + gx * 44
                    y0 = 50 + gy * 28
                    od.rectangle((x0, y0, x0 + 42, y0 + 26), fill=info["accent"] + (a,))
        initials = "".join(w[0] for w in title_en.replace(":", " ").split()[:3]).upper() or title_en[:3].upper()
        f_big = latin(128, True)
        bbox = od.textbbox((0, 0), initials, font=f_big)
        od.text(((W - (bbox[2] - bbox[0])) / 2, 150 - (bbox[3] - bbox[1]) / 2), initials,
                font=f_big, fill=(255, 255, 255, 28))
        # 锁
        cx, cy = W // 2, 188
        od.rounded_rectangle((cx - 26, cy - 4, cx + 26, cy + 38), radius=8,
                             fill=(255, 255, 255, 235), outline=(30, 30, 34, 255), width=2)
        od.arc((cx - 17, cy - 30, cx + 17, cy + 4), 180, 360, fill=(255, 255, 255, 235), width=6)
        od.rectangle((cx - 2, cy + 10, cx + 2, cy + 24), fill=(40, 40, 46, 255))
        od.ellipse((cx - 5, cy + 5, cx + 5, cy + 15), fill=(40, 40, 46, 255))

    im = Image.alpha_composite(im, overlay)
    d = ImageDraw.Draw(im)

    if not placeholder:
        if screenshot is not None:
            shot = Image.open(screenshot)
            card_im = pixel_fit(shot, (card[2] - card[0], card[3] - card[1]))
        elif scene == "pong":
            card_im = scene_pong((card[2] - card[0], card[3] - card[1]))
        elif scene == "tanks":
            card_im = scene_tanks((card[2] - card[0], card[3] - card[1]))
        elif scene == "apocalux":
            card_im = scene_apocalux((card[2] - card[0], card[3] - card[1]))
        else:
            card_im = Image.new("RGB", (card[2] - card[0], card[3] - card[1]), (20, 20, 24))
        mask = rounded_mask(card_im.size, 18)
        # 外框
        d.rounded_rectangle((card[0] - 3, card[1] - 3, card[2] + 3, card[3] + 3), radius=21,
                            fill=(10, 10, 14, 255), outline=info["accent"] + (255,), width=2)
        im.paste(card_im, (card[0], card[1]), mask)

    # 机种胶囊 + 状态胶囊
    f_pill = font(17, bold=True)
    draw_pill(d, (32, 354), info["label"], (15, 15, 18, 255), info["accent"] + (255,), f_pill)
    f_badge = font(15, bold=True)
    if placeholder:
        badge = "ROM 未提供"
        bbox = d.textbbox((0, 0), badge, font=f_badge)
        bw = bbox[2] - bbox[0] + 20
        draw_pill(d, (480 - bw, 354), badge, (35, 22, 0, 255), (255, 196, 86, 255), f_badge)
    else:
        badge = "HOMEBREW"
        bbox = d.textbbox((0, 0), badge, font=f_badge)
        bw = bbox[2] - bbox[0] + 20
        draw_pill(d, (480 - bw, 354), badge, (8, 34, 18, 255), (120, 226, 150, 255), f_badge)

    # 中文标题：自动缩字号
    max_w = 448
    f_title = None
    for size in range(40, 23, -1):
        f = font(size, bold=True)
        if d.textlength(title_zh, font=f) <= max_w:
            f_title = f
            break
    if f_title is None:
        f_title = font(24, bold=True)
    d.text((32, 394), title_zh, font=f_title, fill=(250, 250, 252, 255))

    # 英文标题
    f_sub = latin(18, True)
    sub = title_en
    while d.textlength(sub, font=f_sub) > max_w and len(sub) > 4:
        sub = sub[:-2] + "…"
    d.text((33, 448), sub, font=f_sub, fill=(205, 205, 218, 255))

    # 页脚（用固定灰，避免 alpha 被 convert("RGB") 丢掉后变成纯白）
    f_foot = font(13)
    foot = "渣迪 · 游戏厅"
    d.text((480 - d.textlength(foot, font=f_foot), 486), foot, font=f_foot, fill=(145, 145, 158, 255))

    out.parent.mkdir(parents=True, exist_ok=True)
    im.convert("RGB").save(out, "PNG", optimize=True)


def active_game_notice(g: dict) -> str:
    ns = []
    if g.get("notice"):
        ns.append(g["notice"])
    if g.get("notic_extra"):
        ns.append(SOLAR_ART_ATTRIBUTION)
    return "\n\n".join(ns).strip()


SOLAR_ART_ATTRIBUTION = """\
第三方美术署名（Solar Guard，按上游 Art Acknowledgments 整理）
------------------------------------------------------------
· Planet Orbit Background — ArthCarvalho — OGA-BY 3.0
  https://opengameart.org/content/planet-orbit-background
· Space Ships — Eikester — CC BY 3.0
  https://opengameart.org/content/space-ships-0
· Brown Asteroid — FunwithPixels — CC BY 4.0 / CC BY 3.0 / CC BY-SA 4.0 / CC BY-SA 3.0
  https://opengameart.org/content/top-down-space-fighters
· Grid HUD — Ivan Voirol — CC BY 3.0（该作品另有 GPL 2.0/3.0 授权，本作按 CC BY 3.0 使用）
  https://opengameart.org/content/grid-hud
· Faction9 Spacestations — MillionthVector — CC BY 3.0
  https://opengameart.org/content/set-faction9-spacestations
· Textured space station — MillionthVector — CC BY 3.0
  https://opengameart.org/content/textured-space-station
· Space Carrier — NaksRifati — CC0 1.0
  https://opengameart.org/content/space-carrier
· Space Ships — nareiK — CC BY 3.0
  https://opengameart.org/content/space-ships-1
· Asteroid Belt — NASA — Public Domain
  https://commons.wikimedia.org/wiki/File:Asteroid_Belt.svg
· Some top-down spaceships — Rawdanitsu — CC0 1.0
  https://opengameart.org/content/some-top-down-spaceships
· Solar System — ShinyOgre — CC BY-SA 3.0
  https://opengameart.org/content/solar-system
· Top-down Space Fighters — Skorpio — CC BY-SA 3.0（该作品另有 GPL 3.0 授权，本作按 CC BY-SA 3.0 使用）
  https://opengameart.org/content/top-down-space-fighters
· font-8x8 — Pixel-boy / Sparklin Labs — CC0 1.0
  https://github.com/sparklinlabs/superpowers-asset-packs
· Asteroids/Debris Set — The_Scientist___ — CC0 1.0
  https://opengameart.org/content/asteroidsdebris-set

修改说明：上述素材在 Solar Guard 中经过裁切、调色、缩放等修改；
各作品的 Derivatives 副本与许可说明保存在上游仓库的 `Art Acknowledgments/` 目录。
音乐：使用 Beepbox 创作，经 OpenMPT 转成 Impulse Tracker（.it）；
音色来自 OpenMPT 自带示例采样与/或 Windows 的 GM.DLS 音色库。
"""


def write_text(p: Path, text: str) -> None:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(text.rstrip() + "\n", encoding="utf-8")


def build_actual(g: dict) -> dict:
    platform = g["platform"]
    slug = g["slug"]
    src = Path(g["src"])
    if not src.is_file():
        raise SystemExit(f"缺少 ROM：{src}")
    outdir = ROOT / platform / slug
    if outdir.exists():
        shutil.rmtree(outdir)
    outdir.mkdir(parents=True)

    game_name = f"game.{g['ext']}"
    dst = outdir / game_name
    shutil.copy2(src, dst)
    sha = sha256_file(dst)
    size = dst.stat().st_size
    gid = f"{platform}_{slug}_{sha[:8]}"
    rom_origin = ROM_ORIGIN.get(slug, "上游发布包里的预编译 ROM")

    # 截图
    if g.get("screenshot"):
        shot = Image.open(g["screenshot"]).convert("RGB")
        if g.get("screenshot_crop"):
            shot = shot.crop(g["screenshot_crop"])
        shot.save(outdir / "screenshot.png", "PNG", optimize=True)

    # 封面
    make_cover(
        outdir / "cover.png",
        platform,
        g["title_zh"],
        g["title_en"],
        screenshot=Path(g["screenshot"]) if g.get("screenshot") else None,
        scene=g.get("cover_scene"),
    )

    # 描述
    write_text(outdir / "description.txt", g["desc_short"])
    md = [
        f"# {g['title_zh']}（{g['title_en']}）",
        "",
        f"- 机种：{PLATFORMS[platform]['label']}",
        f"- 类型：{g['genre']}",
        f"- 人数：{g['players']} 人",
        f"- 版本：{g['version']}",
        "",
        "## 玩法",
        "",
        g["desc_long"].strip(),
        "",
        "## 操作",
        "",
        "```",
        g["controls"].strip(),
        "```",
        "",
        "## 许可与来源",
        "",
        f"- 作者：{g['author']}",
        f"- 许可：{g['license']}",
        f"- 来源：{g['source']}（{g['source_ref']}，`{g['source_commit'][:12]}`）",
        f"- ROM 来历：{rom_origin}",
    ]
    notice = active_game_notice(g)
    if notice:
        md += ["", notice]
    if g.get("source_archive"):
        md += ["", f"对应源码包：`{g['source_archive']}`（与本库一同部署）。"]
    md += ["", "> 本目录 `LICENSE.txt` 是上游许可全文；复合许可的其余文本见本库 `LICENSES/`。"]
    md += ["", "> 本游戏是免费 homebrew / 开源作品，可自由分发；请勿用于商业用途。"]
    write_text(outdir / "description.md", "\n".join(md))

    # SOURCE.txt
    source_lines = [
        f"游戏：{g['title_zh']}（{g['title_en']}）",
        f"机种：{PLATFORMS[platform]['label']}",
        f"版本：{g['version']}",
        f"作者：{g['author']}",
        f"仓库：{g['source']}",
        f"引用：{g['source_ref']}",
        f"提交：{g['source_commit']}",
        f"许可：{g['license']}",
        f"ROM 来历：{rom_origin}",
    ]
    if g.get("source_archive"):
        source_lines.append(f"源码包：{g['source_archive']}")
    if g.get("notice"):
        source_lines += ["", "说明：", g["notice"]]
    if g.get("notic_extra"):
        source_lines += ["", SOLAR_ART_ATTRIBUTION.strip()]
    write_text(outdir / "SOURCE.txt", "\n".join(source_lines))

    # LICENSE.txt / NOTICE.txt
    lf = Path(g["license_file"])
    if lf.is_file():
        shutil.copy2(lf, outdir / "LICENSE.txt")
    else:
        write_text(outdir / "LICENSE.txt", f"许可：{g['license']}\n许可全文见本库 LICENSES/ 目录。")
    notice = active_game_notice(g)
    if notice:
        write_text(outdir / "NOTICE.txt", notice)

    meta = {
        "id": gid,
        "slug": slug,
        "title": g["title_en"],
        "title_zh": g["title_zh"],
        "platform": platform,
        "status": "ready",
        "file": f"{platform}/{slug}/{game_name}",
        "cover": f"{platform}/{slug}/cover.png",
        "screenshot": f"{platform}/{slug}/screenshot.png" if g.get("screenshot") else None,
        "description": f"{platform}/{slug}/description.txt",
        "description_md": f"{platform}/{slug}/description.md",
        "size": size,
        "sha256": sha,
        "players": g["players"],
        "tags": g["tags"],
        "license": g["license"],
        "author": g["author"],
        "version": g["version"],
        "source": g["source"],
        "source_ref": g["source_ref"],
        "source_commit": g["source_commit"],
        "source_archive": g.get("source_archive"),
        "rom_origin": rom_origin,
    }
    write_text(outdir / "meta.json", json.dumps(meta, ensure_ascii=False, indent=2) + "\n")
    print(f"✓ {platform}/{slug}  {size/1024/1024:.2f} MB  {sha[:8]}")
    return meta


def placeholder_id(platform: str, slug: str) -> str:
    h = hashlib.sha256(f"{platform}:{slug}".encode()).hexdigest()[:8]
    return f"{platform}_{slug}_{h}"


def slugify(s: str) -> str:
    # 先做 ASCII 折叠：Pokémon -> Pokemon，避免 ID / 文件名里出现非 ASCII。
    s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii").lower()
    out = []
    for ch in s:
        if ch.isalnum():
            out.append(ch)
        elif out and out[-1] != "-":
            out.append("-")
    return "".join(out).strip("-")[:60]


def build_placeholder(platform: str, title_en: str, title_zh: str, genre: str,
                      players: int, desc: str, kind: str) -> dict:
    slug = slugify(title_en.replace("'", ""))
    outdir = ROOT / platform / slug
    if outdir.exists():
        shutil.rmtree(outdir)
    outdir.mkdir(parents=True)
    pid = placeholder_id(platform, slug)

    make_cover(outdir / "cover.png", platform, title_zh, title_en, placeholder=True)
    write_text(outdir / "description.txt", "ROM 未提供：" + desc)
    reason = {
        "commercial": (
            "这是商业作品，版权归原权利人所有。本库严格遵守「仓库、APK 和服务器都不分发商业 ROM」"
            "的原则，因此只保留封面、说明和占位，不提供 ROM 文件。\n\n"
            "如果你拥有正版卡带或数字版，可以自行 dump / 导出 ROM，再按服务器说明放入对应目录。"
        ),
        "homebrew-rights": (
            "这是免费 homebrew，但上游没有给出明确的再分发许可，或其中部分素材的权利状态不清晰。"
            "为避免争议，本库只保留封面、说明和占位，不提供 ROM。\n\n"
            "ROM 可以从作者发布页自行获取。"
        ),
        "homebrew-unavailable": (
            "这是免费 homebrew，但当前没有找到稳定、可核验的 ROM 下载地址。"
            "本库先保留封面、说明和占位，等确认来源后再补 ROM。"
        ),
    }[kind]
    md = [
        f"# {title_zh}（{title_en}）",
        "",
        "> **ROM 未提供。**",
        "",
        f"- 机种：{PLATFORMS[platform]['label']}",
        f"- 类型：{genre}",
        f"- 人数：{players} 人",
        "",
        "## 简介",
        "",
        desc,
        "",
        "## 为什么没有 ROM",
        "",
        reason,
        "",
        "## 许可",
        "",
        "未授予任何 ROM 分发许可。本目录只包含自制的封面图和文字说明。",
    ]
    write_text(outdir / "description.md", "\n".join(md))
    write_text(outdir / "SOURCE.txt", "\n".join([
        f"游戏：{title_zh}（{title_en}）",
        f"机种：{PLATFORMS[platform]['label']}",
        "状态：rom-not-provided",
        "ROM：未提供",
        "",
        "原因：",
        reason,
        "",
        f"标题与简介仅用于识别作品；版权归原权利人所有。",
    ]))
    write_text(outdir / "LICENSE.txt", "未授予 ROM 分发许可；本目录不包含 ROM。\n")
    write_text(outdir / "README.md", f"# {title_zh}\n\n**ROM 未提供。** 见 description.md 与 SOURCE.txt。\n")
    meta = {
        "id": pid,
        "slug": slug,
        "title": title_en,
        "title_zh": title_zh,
        "platform": platform,
        "status": "rom-not-provided",
        "file": None,
        "cover": f"{platform}/{slug}/cover.png",
        "screenshot": None,
        "description": f"{platform}/{slug}/description.txt",
        "description_md": f"{platform}/{slug}/description.md",
        "size": 0,
        "sha256": None,
        "players": players,
        "tags": [genre, "占位"],
        "license": "未提供 ROM",
        "author": None,
        "version": None,
        "source": None,
        "reason": kind,
    }
    write_text(outdir / "meta.json", json.dumps(meta, ensure_ascii=False, indent=2) + "\n")
    print(f"· {platform}/{slug}  占位")
    return meta


def write_index(actual, placeholders) -> None:
    def fmt_size(n: int) -> str:
        mb = n / 1024 / 1024
        return f"{mb:.2f} MB" if mb >= 0.1 else f"{n / 1024:.0f} KB"

    lines = [
        "# 游戏索引",
        "",
        f"实际可玩 **{len(actual)}** 个；占位 **{len(placeholders)}** 个。"
        "占位条目只有封面和说明，**不含 ROM**。",
        "",
    ]
    for platform in PLATFORMS:
        games = [m for m in actual if m["platform"] == platform]
        if not games:
            continue
        lines += [
            f"## {PLATFORMS[platform]['label']}（{len(games)}）",
            "",
            "| 游戏 | 类型 | 人数 | 大小 | 许可 |",
            "|---|---|---:|---:|---|",
        ]
        for m in sorted(games, key=lambda x: x["slug"]):
            link = f"[{m['title_zh']}]({m['description_md']})"
            lines.append(
                f"| {link}<br>{m['title']} | {m['tags'][0] if m['tags'] else ''} "
                f"| {m['players']} | {fmt_size(m['size'])} | {m['license']} |"
            )
        lines.append("")

    for platform in PLATFORMS:
        items = [p for p in placeholders if p["platform"] == platform]
        if not items:
            continue
        lines += [
            f"## {PLATFORMS[platform]['label']} · 占位（{len(items)}）",
            "",
            "| 游戏 | 类型 | 状态 |",
            "|---|---|---|",
        ]
        for p in sorted(items, key=lambda x: x["slug"]):
            state = "商业作品 · 未提供" if p["reason"] == "commercial" else "暂不可分发 · 未提供"
            lines.append(f"| [{p['title_zh']}]({p['description_md']})<br>{p['title']} | {p['tags'][0]} | {state} |")
        lines.append("")
    write_text(ROOT / "GAMES.md", "\n".join(lines))


def write_root_readme(actual, placeholders) -> None:
    text = f"""# 渣迪 · 游戏厅 —— 本地游戏暂存库

这个目录是 App「游戏厅」的**服务端内容暂存区**，不是 App 仓库的一部分。
App 运行时从服务器拉 `catalog.json`，再按需下载 ROM；APK 里不打包任何游戏。

## 目录结构

```
zhadi-games/
  catalog.json                 # App 读取的目录（只含 status=ready 的 17 个游戏）
  GAMES.md                     # 给人看的总索引（可玩 + 占位）
  placeholders.json            # 36 个占位条目（商业作品 / 暂不可分发作品）
  nes/<slug>/                  # 一个游戏一个目录
    game.nes                   # ROM（★ 商业作品没有这个文件）
    cover.png                  # 512×512 封面（自制）
    screenshot.png             # 游戏截图（自制封面之外的原始截图）
    description.txt            # 短说明（列表 / 详情都能用）
    description.md             # 完整说明：玩法、操作、许可、来源
    meta.json                  # 机器可读元数据
    SOURCE.txt                 # 作者、版本、仓库、commit
    LICENSE.txt                # 许可全文
    NOTICE.txt                 # 多许可 / 第三方素材署名（如有）
  gba/<slug>/  gbc/<slug>/  snes/<slug>/
  LICENSES/                    # 各许可全文（MIT / GPL / Zlib / CC / OGA-BY）
  sources/                     # GPL / ShareAlike 游戏的对应源码包
  SHA256SUMS                   # 所有 ROM 的 SHA-256
  MANIFEST.sha256              # 本目录所有文件（自身除外）的 SHA-256
  tools/build_staging.py       # 生成本暂存库的脚本（从 /tmp 的下载/构建产物整理）
  tools/build_catalog.py       # 扫描 meta.json 重新生成 catalog.json（可部署到服务器）
```

## 收录原则（重要）

- **仓库、APK、服务器都不放商业 ROM。** 商业作品只生成占位目录：封面、简介、说明，
  以及醒目的「ROM 未提供」标记，`catalog.json` 不收录。
- 实际收录的 {len(actual)} 个游戏全部是**免费 homebrew / 开源 / 明确允许分发**的作品。
- 每个实际游戏都保留作者、仓库、版本、commit、许可全文；GPL / ShareAlike 作品附带
  对应源码包，满足源码提供要求。
- Snes9x 核心仅限非商业使用；本 App 永久免费、无广告、无内购，SFC 功能没有付费墙。

## 部署到服务器（FirmwareWeb）

服务器上已有静态路由 `/media/games`，对应宿主机共享盘 `/mnt/firmware/games`。
把本目录内容同步过去即可（不要新增上传接口）：

```bash
# 不要加 --delete：服务器上可能还有用户自己放的游戏，删掉就找不回来了。
rsync -av /Users/panlv/work/zhadi-games/ jack@100.104.40.100:/mnt/firmware/games/
```

部署后验证：

```bash
curl -I https://firmware.allhex.com/media/games/catalog.json          # 200
curl -r 0-1023 -D - https://firmware.allhex.com/media/games/nes/nova-the-squirrel/game.nes | head   # 206 + Content-Range
curl -I https://firmware.allhex.com/media/games/nes/super-mario-bros/cover.png   # 占位封面可访问
```

App 端地址：

- 目录：`https://firmware.allhex.com/media/games/catalog.json`
- ROM：`https://firmware.allhex.com/media/games/<meta.file>`
- 封面：`https://firmware.allhex.com/media/games/<meta.cover>`

## 新增游戏

1. 在 `<platform>/<slug>/` 放 `game.<nes|gba|gb|gbc|sfc>`，再补 `cover.png`、
   `description.txt`、`description.md`、`LICENSE.txt`、`SOURCE.txt`；
2. 写 `meta.json`，`status` 设为 `"ready"`，`file` / `cover` / `size` / `sha256` 填好；
3. 运行 `python3 tools/build_catalog.py` 重新生成 `catalog.json`；
4. 运行 `python3 tools/build_catalog.py --check` 校验；
5. 同步到服务器。

**只允许加入有明确分发权的作品。** 商业 ROM、来源不明的 ROM、仅有「免费下载」但没有
再分发许可的 ROM，一律不要放进来了 —— 放在占位目录里就行。

## 当前统计

- 实际可玩：**{len(actual)}** 个
  - NES {sum(1 for m in actual if m['platform']=='nes')}
  - GBA {sum(1 for m in actual if m['platform']=='gba')}
  - GBC {sum(1 for m in actual if m['platform']=='gbc')}
  - SNES {sum(1 for m in actual if m['platform']=='snes')}
- 占位：**{len(placeholders)}** 个（商业作品 32 + 暂不可分发 homebrew 4）

## 特别说明

- **RoboRun** 仅支持 PAL / Dendy 制式；NTSC 时序下速度可能偏快。
- **Petris** 的联机双人需要 Game Boy Link Cable，本 App 只能单人游玩。
- **Super Apocalux** 的 ROM 由 WLA-DX 10.7 从上游源码编译，SNES 头部校验和已核对。
- 可玩游戏的封面使用该游戏自己的截图 + 自制排版；占位封面是纯自制图形，
  不使用商业游戏的封面 / 截图。
"""
    write_text(ROOT / "README.md", text)


def main():
    if not Path(CJK).is_file():
        raise SystemExit("找不到中文字体：" + CJK)
    ROOT.mkdir(parents=True, exist_ok=True)

    # 清掉四个机种目录，保证反复运行不会留下已经删除的游戏。
    for platform in PLATFORMS:
        d = ROOT / platform
        if d.exists():
            shutil.rmtree(d)

    actual = []
    for g in GAMES:
        actual.append(build_actual(g))

    placeholders = []
    for (platform, title_en, title_zh, genre, players, desc, kind) in PLACEHOLDERS:
        placeholders.append(build_placeholder(platform, title_en, title_zh, genre, players, desc, kind))

    write_root_readme(actual, placeholders)
    write_index(actual, placeholders)
    write_text(ROOT / "placeholders.json", json.dumps({
        "note": "占位条目；不含 ROM，catalog.json 不收录。",
        "games": placeholders,
    }, ensure_ascii=False, indent=2) + "\n")

    # SHA256SUMS（ROM）
    lines = []
    for m in actual:
        f = ROOT / m["file"]
        lines.append(f"{sha256_file(f)}  {m['file']}")
    write_text(ROOT / "SHA256SUMS", "\n".join(sorted(lines, key=lambda s: s.split("  ", 1)[1])))

    # 先生成并校验 catalog.json，再算 MANIFEST —— 否则 MANIFEST 会漏掉 catalog.json。
    import subprocess
    py = os.environ.get("PYTHON", "python3")
    subprocess.run([py, str(ROOT / "tools" / "build_catalog.py")], check=True)
    subprocess.run([py, str(ROOT / "tools" / "build_catalog.py"), "--check"], check=True)

    manifest = []
    for p in sorted(ROOT.rglob("*")):
        if not p.is_file() or p.name == "MANIFEST.sha256":
            continue
        if "__pycache__" in p.parts or p.suffix in (".pyc", ".tmp") or p.name == ".DS_Store":
            continue
        manifest.append(f"{sha256_file(p)}  {p.relative_to(ROOT).as_posix()}")
    write_text(ROOT / "MANIFEST.sha256", "\n".join(manifest))


if __name__ == "__main__":
    main()
