#!/usr/bin/env python3 """ pop_pack_shadow.py — упаковщик спрайтов ТЕНИ. ЗАЧЕМ ОТДЕЛЬНЫЙ НАБОР. Оригинал не хранит спрайтов Тени вовсе: он кладёт обычный спрайт ДВАЖДЫ — в x обычным прозрачным блитом, и в x+1 «блиттером XOR» (draw_objtable_item, seg008.c:1600). Наш пакетный блит так не умеет, поэтому результат запекается заранее, здесь. ЧТО ИМЕННО ДЕЛАЕТ ОРИГИНАЛ (проверено по исходнику, не по памяти): * `blitters_2_or` — НЕ побитовое ИЛИ. В SDLPoP это обычный блит с colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306), то есть один в один наш прозрачный блит. * `blitters_3_xor` работает по 24-битному RGB, а не по индексам палитры (blit_xor, seg009.c:3190: конвертация в 24 бита, затем `*p_dest ^= *p_src` побайтно). Прозрачности у него нет — XOR'ится весь прямоугольник, но прозрачный пиксель это чёрный 0x000000, а XOR с нулём ничего не меняет. Отсюда своя палитра: XOR двух цветов игровой палитры даёт цвет, которого в ней нет. ЕДИНСТВЕННОЕ РАСХОЖДЕНИЕ. XOR считается по тому, что УЖЕ на экране, то есть зависит от фона. Разбор по пикселям: от фона зависит только кайма в один пиксель по ЛЕВЫМ кромкам силуэта (там, где спрайт прозрачен в x, но непрозрачен в x−1). На чёрном фоне `фон XOR цвет == цвет`, и запечка точна; Тень почти всегда на чёрном. Мы печём именно этот случай. ДВА НАБОРА В ОДНОМ АТЛАСЕ. Набор спрайтов у оригинала выбирает НЕ персонаж, а поле `sword` самого кадра: load_frame_to_obj (seg008.c:1752) держит chtab_base жёстко равным chtab_2 и прибавляет `sword >> 6`. У всех кадров frame_table_kid эти биты нулевые, у всех кадров frame_tbl_guard — 0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533), то есть идёт через chtab_5, а chtab_5 на 12-м уровне грузится из SHADOW.DAT (tbl_guard_type[12] == 4, seg000.c:1117) — и это графика КИДА в боевых позах, а не стража. Поэтому печём обе половины: KID — data/KID/res(401+id).png, id 0..218 -> sk0..skN.atl SHADOW — data/SHADOW/res(751+id).png, id 0..31 -> sf0..sfM.atl Выход: poc/res/shadow/sk*.atl + sf*.atl + pop_shadow_atlas.h (палитра внутри заголовка — 16 записей, её заливает приложение через gfx_pal_load). """ import os import sys from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import pop_pack_bg as B from pop_pack_kid import transpose_cols DATA = os.path.join(HERE, "..", "SDLPoP", "data") OUT_DIR = os.path.join(HERE, "..", "poc", "res", "shadow") PAL_BASE = 0xA0 # слоты Sprinter под Тень: 0xA0..0xAF NCOL = 16 # столько цветов в палитре Тени SHIFT = 3 # sk[id>>3], idx id&7 — как у Kid (кадры крупные) MASK = (1 << SHIFT) - 1 # Половины набора: (каталог, база ресурса, префикс выходных файлов). # Off-by-one тот же, что у Kid и стража: images[0] = res(база+1), значит # frame.image = N -> res(база+1+N) (см. pop_pack_kid.load_kid). HALVES = (("KID", 400, "sk"), ("SHADOW", 750, "sf")) def frames(dirname, base): """Все кадры половины: [(id, w, h, [[rgb|None]...])] — уже после наложения XOR, в честном RGB.""" out = [] id = 0 while True: path = os.path.join(DATA, dirname, f"res{base + 1 + id}.png") if not os.path.exists(path): break im = Image.open(path) if im.mode != "P": B.die(f"{path}: mode {im.mode}, ожидался P") pal = im.getpalette()[:NCOL * 3] rgb = [tuple(pal[i * 3:i * 3 + 3]) for i in range(NCOL)] w, h = im.size px = im.load() grid = [] for y in range(h): # Ширина на один больше: второй проход кладётся в x+1, и # суммарный след занимает [x, x+w]. row = [None] * (w + 1) for x in range(w): # проход 1: обычный блит i = px[x, y] if i: row[x] = rgb[i] for x in range(w): # проход 2: XOR в x+1 i = px[x, y] if not i: continue # чёрный: XOR — не операция s = rgb[i] t = x + 1 row[t] = s if row[t] is None else tuple(a ^ b for a, b in zip(row[t], s)) grid.append(row) out.append((id, w + 1, h, grid)) id += 1 return out def histogram(halves): h = {} for _, fr in halves: for _, _, _, grid in fr: for row in grid: for c in row: if c is not None: h[c] = h.get(c, 0) + 1 return h def _d2(a, b): return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2 def _kmeans(pts, k, init): """Взвешенный Ллойд по СПИСКУ ЦВЕТОВ с весами (их полсотни, не пиксели).""" cent = list(init) for _ in range(80): groups = [[] for _ in cent] for c, n in pts: groups[min(range(len(cent)), key=lambda j: _d2(c, cent[j]))].append((c, n)) new = [] for j, g in enumerate(groups): if not g: new.append(cent[j]) continue w = sum(n for _, n in g) new.append(tuple(int(round(sum(c[i] * n for c, n in g) / w)) for i in range(3))) if new == cent: break cent = new return cent def build_palette(hist): """16 цветов ГИБРИДОМ: часть слотов — самые частые цвета ТОЧНО, остаток — взвешенные кластеры хвоста. Почему не «просто самые частые»: тогда редкие цвета уезжают в далёкий ближайший, и грубых промахов (сдвиг >90 по евклиду в RGB при максимуме 441) получается впятеро больше — 381 пиксель против 76 на все 251 кадр (замер 2026-08-20). Почему не чистый k-means: он двигает вообще все цвета, включая массовые. Разделение слотов подбираем перебором по критерию «сначала минимум грубых промахов, потом заметных, потом худшая ошибка».""" items = sorted(hist.items(), key=lambda kv: -kv[1]) def stats(pal): bad = worst = mid = big = 0 for c, n in items: e = min(_d2(c, p) for p in pal) ** 0.5 if e > 0: bad += n worst = max(worst, e) if e >= 90: big += n elif e >= 40: mid += n return bad, mid, big, worst best = None for m in range(1, NCOL): # m слотов под хвост keep = [c for c, _ in items[:NCOL - m]] rest = [(c, n) for c, n in items[NCOL - m:]] if not rest: continue pal = keep + _kmeans(rest, m, [c for c, _ in rest[:m]]) bad, mid, big, worst = stats(pal) score = (big, mid, worst) if best is None or score < best[0]: best = (score, m, pal, (bad, mid, big, worst)) _, m, pal, st = best # Чёрный обязан быть ТОЧНЫМ и лежать в слоте 0: это XOR-погашенная # середина силуэта, больше половины всех пикселей Тени. j = min(range(len(pal)), key=lambda i: _d2((0, 0, 0), pal[i])) pal[j] = (0, 0, 0) pal[0], pal[j] = pal[j], pal[0] bad, mid, big, worst = st tot = sum(hist.values()) print(f" палитра: {NCOL - m} частых точно + {m} кластеров хвоста; " f"из {len(items)} цветов изменено {bad} px из {tot} " f"({100.0 * bad / tot:.2f} %), заметно {mid}, сильно {big}, худшая {worst:.0f}") return pal def encode(grid, w, h, pal): """RGB-сетка -> байты атласа: нет пикселя -> 0xFF, цвет -> PAL_BASE + k. Чёрный — НАСТОЯЩИЙ цвет Тени (слот 0), а не прозрачность.""" row = bytearray() for y in range(h): for x in range(w): c = grid[y][x] if c is None: row.append(0xFF) else: row.append(PAL_BASE + min(range(len(pal)), key=lambda k: _d2(c, pal[k]))) return transpose_cols(w, h, bytes(row)) def write_header(path, counts, pal): vals = [] for (r, g, b) in pal: vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0 lines = [ "/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.", " * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.", " *", " * Две половины, потому что и у оригинала их две (см. шапку", " * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),", " * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.", " * Какая половина нужна, приложение уже знает — тем же условием,", " * что выбирает таблицу кадров (pop_frame_tbl_is_guard).", " *", " * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход", " * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].", " * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.", " */", "#ifndef POP_SHADOW_ATLAS_H", "#define POP_SHADOW_ATLAS_H", "", "#include ", "", "#define POP_SHADOW_SHIFT %d" % SHIFT, "#define POP_SHADOW_MASK %d" % MASK, "#define POP_SHADOW_PAL_BASE 0x%02X /* слоты Sprinter под Тень */" % PAL_BASE, "", "/* Половина «вне боя» — кадры Кида (chtab_2). */", "#define POP_SHADOW_KID_PAGES %d" % counts["sk"], "/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */", "#define POP_SHADOW_FGT_PAGES %d" % counts["sf"], "", "static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {", ] lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])] lines += [ "};", "", "static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {", ] lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])] lines += [ "};", "", "/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */", "static const uint8_t pop_shadow_pal[64] = {", " " + ",".join("0x%02X" % v for v in vals) + ",", "};", "", "#endif", "", ] with open(path, "w") as f: f.write("\n".join(lines)) print(f" pop_shadow_atlas.h -> {path}") def main(): os.makedirs(OUT_DIR, exist_ok=True) halves = [(prefix, frames(d, base)) for d, base, prefix in HALVES] for (d, base, prefix), (_, fr) in zip(HALVES, halves): if not fr: B.die(f"нет спрайтов в data/{d} (SHADOW.DAT распакован?)") print(f" {d}: {len(fr)} кадров") pal = build_palette(histogram(halves)) counts, total, nspr = {}, 0, 0 for prefix, fr in halves: pages = {} for id, w, h, grid in fr: pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, encode(grid, w, h, pal)) nspr += 1 npg = max(pages) + 1 counts[prefix] = npg for pg in range(npg): nm = f"{prefix}{pg}.atl" cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {})) total += sz print(f" {prefix}*.atl: {len(fr)} спрайтов, {npg} EMM-страниц") write_header(os.path.join(HERE, "..", "roomtest", "pop_shadow_atlas.h"), counts, pal) print(f"ИТОГО: {nspr} спрайтов Тени, " f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}") if __name__ == "__main__": main()