31b82661eb
Порт PoP переехал в applications/SprPoP — приложение, которое собирается само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT, по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся архивом закрытых задач, багов и исполненных планов. Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита, все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host- тесты зелёные (15/15). Раскладка: src/ рукописный C (roomtest.c -> sprpop.c) gen/ генерируемые заголовки, в репозитории assets/orig/ оригинальные данные игры, вне репозитория (копирайт) assets/packed/ то, что ложится на диск, в раскладке диска tools/ конверторы; все пути — в одном tools/paths.py build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/ Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по времени превращалось бы в лотерею. Недостающий ресурс или заголовок чинится сам, рекурсивным вызовом в ветку генерации. Музыка собирается из любого из четырёх наборов записей (make music-mp3, music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них — иначе mt32 (реплики на 6% длиннее) молча ломал катсцену. Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR), HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой make host-tests переключён на SprPoP. Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики приходила раньше молнии. Это обход, а не лечение; разбор с замерами — docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
297 lines
14 KiB
Python
297 lines
14 KiB
Python
#!/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
|
||
|
||
Выход: build/atl/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
|
||
|
||
import sys as _sys, os as _os
|
||
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
|
||
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
|
||
|
||
DATA = str(P.SDLPOP_DATA)
|
||
OUT_DIR = str(P.atl("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 <stdint.h>",
|
||
"",
|
||
"#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(str(P.GEN / "pop_shadow_atlas.h"), counts, pal)
|
||
print(f"ИТОГО: {nspr} спрайтов Тени, "
|
||
f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|