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>
217 lines
10 KiB
Python
217 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
pop_pack_guard.py — упаковщик спрайтов СТРАЖА (chtab_5_guard, база 750).
|
||
|
||
Оригинал грузит стражей из отдельного .DAT по типу уровня
|
||
(GUARD/FAT/SKEL/VIZIER/SHADOW, seg000:1092); уровень 1 — обычный «guard»,
|
||
то есть data/GUARD/res(750+id).png. Палитра — НЕ из PNG: оригинал
|
||
подменяет её из res10.bin (guard_palettes, 7 палитр по 16 цветов, 6-бит) по
|
||
level.guards_color; на уровне 1 у обоих стражей color = 2.
|
||
|
||
Выход: build/atl/guard/g0..gN.atl (по 8 спрайтов на страницу, адресация
|
||
id>>3 / id&7 — как у Kid) + группа палитры 0x90..0x9F в общий kid.pal
|
||
(его собирает pop_pack_kid.py, см. GUARD_PAL_BASE там).
|
||
"""
|
||
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, vflip_page
|
||
|
||
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)
|
||
|
||
# Набор спрайтов соперника выбирается ТИПОМ УРОВНЯ (load_lev_spr, seg000:1092):
|
||
# tbl_guard_type = {0 guard, 1 fat, 2 skeleton, 3 vizier, 4 shadow, −1 нет}.
|
||
# У обычного стража палитра подменяется из res10.bin по guards_color, у
|
||
# остальных наборов curr_guard_color = 0 (seg002:186) — значит цвет берётся
|
||
# из ФАЙЛА набора (res750.pal), и подменять его не надо.
|
||
SETS = {
|
||
"GUARD": ("GUARD", "guard", None),
|
||
"SKEL": ("SKEL", "skel", "res750.pal"),
|
||
"VIZIER": ("VIZIER", "vizier", "res750.pal"),
|
||
}
|
||
SET_NAME = "GUARD"
|
||
GUARD_DIR = str(P.SDLPOP_DATA / "GUARD")
|
||
PAL_BIN = str(P.SDLPOP_DATA / "PRINCE" / "res10.bin")
|
||
OUT_DIR = str(P.atl("guard"))
|
||
PAL_BASE = 0x90 # слоты палитры Sprinter под стража
|
||
COLOR = 2 # level.guards_color обоих стражей уровня 1
|
||
SHIFT = 3 # g[id>>3], idx id&7 (спрайты крупные)
|
||
MASK = (1 << SHIFT) - 1
|
||
MAX_ID = 33 # атлас-индексы 0..33 = res751..res784
|
||
|
||
|
||
def select_set(name):
|
||
"""Переключить упаковщик на набор (GUARD/SKEL/VIZIER): каталоги и число
|
||
кадров."""
|
||
global SET_NAME, GUARD_DIR, OUT_DIR, MAX_ID
|
||
if name not in SETS:
|
||
B.die("неизвестный набор %s (есть: %s)" % (name, ", ".join(SETS)))
|
||
SET_NAME = name
|
||
src, out, _ = SETS[name]
|
||
GUARD_DIR = str(P.SDLPOP_DATA / src)
|
||
OUT_DIR = str(P.atl(out))
|
||
# Сколько кадров реально есть в наборе (у скелета их меньше, чем у стража).
|
||
n = 0
|
||
while os.path.exists(os.path.join(GUARD_DIR, f"res{751 + n}.png")):
|
||
n += 1
|
||
MAX_ID = n - 1
|
||
|
||
|
||
def set_palette():
|
||
"""16 цветов набора из его res750.pal (для наборов, где цвет НЕ
|
||
подменяется из res10.bin). None — у набора своей палитры нет."""
|
||
fname = SETS[SET_NAME][2]
|
||
if fname is None:
|
||
return None
|
||
b = open(os.path.join(GUARD_DIR, fname), "rb").read()
|
||
return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)]
|
||
|
||
|
||
def load_guard(id):
|
||
"""Атлас-индекс id (== frame.image) -> res(751+id).png -> (w, h, 8bpp
|
||
COLUMN-MAJOR); 0 -> 0xFF (прозрачность).
|
||
|
||
Off-by-one как у Kid (см. pop_pack_kid.load_kid): load_chtab_from_file
|
||
зовётся с ресурсом 750, а images[i-1] = res(750+i), то есть images[0] =
|
||
res751. Рисование берёт get_image(chtab, obj_id+1 − 1) = images[image],
|
||
значит frame.image=N -> res(751+N). С res(750+N) страж стоял в позе
|
||
предыдущего кадра (замечено на frame_166_stand_inactive: image 17
|
||
рисовался как res767 — выпад — вместо res768). """
|
||
path = os.path.join(GUARD_DIR, f"res{751 + id}.png")
|
||
if not os.path.exists(path):
|
||
return None
|
||
im = Image.open(path)
|
||
if im.mode != "P":
|
||
B.die(f"res{750+id}: mode {im.mode}, ожидался P")
|
||
src = im.tobytes()
|
||
rowmajor = bytes((0xFF if v == 0 else PAL_BASE + v) for v in src)
|
||
return im.width, im.height, transpose_cols(im.width, im.height, rowmajor)
|
||
|
||
|
||
NPAL = 7 # guard_palettes: 7 палитр по 16 цветов (res10.bin)
|
||
|
||
|
||
def guard_palette(color=None):
|
||
"""16 цветов (r,g,b 0..63) из res10.bin для палитры color (1..7).
|
||
Индексация как в оригинале (seg003:257): &guard_palettes[0x30*color-0x30].
|
||
color=None — палитра COLOR (та, что запекается в kid.pal по умолчанию)."""
|
||
if color is None:
|
||
color = COLOR
|
||
b = open(PAL_BIN, "rb").read()
|
||
off = 0x30 * color - 0x30
|
||
return [(b[off + i * 3], b[off + i * 3 + 1], b[off + i * 3 + 2]) for i in range(16)]
|
||
|
||
|
||
def write_pal_header(out_path):
|
||
"""Таблица ВСЕХ палитр стражей -> заголовок для приложения.
|
||
|
||
Оригинал держит спрайты стража в одной палитре, а цвет подменяет на лету:
|
||
redraw_screen (seg003:255) зовёт set_chtab_palette(chtab_5_guard,
|
||
&guard_palettes[0x30*curr_guard_color - 0x30], 16) ПЕРЕД отрисовкой
|
||
комнаты, где curr_guard_color = level.guards_color[room-1] & 0x0F
|
||
(enter_guard, seg002:184). У нас спрайты уже лежат в индексах
|
||
PAL_BASE + v, поэтому достаточно залить 16 слотов палитры.
|
||
|
||
Формат записи — родной для gfx_pal_load: 4 байта (B, G, R, 0)."""
|
||
rows = []
|
||
for color in range(1, NPAL + 1):
|
||
vals = []
|
||
for (r, g, b) in guard_palette(color):
|
||
# res10.bin хранит 6-битные каналы, палитра Sprinter — 8-битные
|
||
# (так же делает pop_pack_kid при записи kid.pal).
|
||
vals += [B.scale6to8(b), B.scale6to8(g), B.scale6to8(r), 0]
|
||
rows.append(" { " + ",".join("0x%02X" % v for v in vals) + " },")
|
||
lines = [
|
||
"/* pop_guard_pal.h — палитры стражей (guard_palettes из res10.bin).",
|
||
" * СГЕНЕРЕНО toolchain/pop_pack_guard.py — НЕ править вручную.",
|
||
" *",
|
||
" * Индексация как в оригинале: цвет из level.guards_color[room-1]",
|
||
" * (младшие 4 бита), 1..%d -> строка [color-1]; 0 = палитру НЕ менять" % NPAL,
|
||
" * (seg003:255 при curr_guard_color == 0 set_chtab_palette не зовёт).",
|
||
" * Запись — 4 байта (B, G, R, 0), формат gfx_pal_load. */",
|
||
"#ifndef POP_GUARD_PAL_H",
|
||
"#define POP_GUARD_PAL_H",
|
||
"",
|
||
"#include <stdint.h>",
|
||
"",
|
||
"#define POP_GUARD_PAL_BASE 0x%02X /* слоты Sprinter под chtab_5 */" % PAL_BASE,
|
||
"#define POP_GUARD_NPAL %d" % NPAL,
|
||
"",
|
||
"static const uint8_t pop_guard_pal[POP_GUARD_NPAL][64] = {",
|
||
] + rows + [
|
||
"};",
|
||
"",
|
||
]
|
||
# Палитры наборов со СВОИМ цветом (скелет, визирь) — из их res750.pal, а
|
||
# не из res10.bin: на таких уровнях curr_guard_color = 0 (seg002:186), и
|
||
# оригинал палитру не подменяет вовсе.
|
||
saved = SET_NAME
|
||
for name, cname, human in (("SKEL", "pop_skel_pal", "Скелет"),
|
||
("VIZIER", "pop_vizier_pal", "Джафар")):
|
||
select_set(name)
|
||
pal = set_palette()
|
||
select_set(saved)
|
||
if not pal:
|
||
continue
|
||
vals = []
|
||
for (r, g, b) in pal:
|
||
vals += [B.scale6to8(b), B.scale6to8(g), B.scale6to8(r), 0]
|
||
lines += [
|
||
"/* %s (%s/res750.pal): свой набор — свой цвет. */" % (human, name),
|
||
"static const uint8_t %s[64] = {" % cname,
|
||
" " + ",".join("0x%02X" % v for v in vals) + ",",
|
||
"};",
|
||
"",
|
||
]
|
||
lines += [
|
||
"#endif",
|
||
"",
|
||
]
|
||
with open(out_path, "w") as f:
|
||
f.write("\n".join(lines))
|
||
print(f" pop_guard_pal.h: {NPAL} палитр x 16 цветов = {NPAL*64} Б -> {out_path}")
|
||
|
||
|
||
def main():
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
pages = {}
|
||
n = 0
|
||
for id in range(0, MAX_ID + 1):
|
||
g = load_guard(id)
|
||
if g is None:
|
||
continue
|
||
pages.setdefault(id >> SHIFT, {})[id & MASK] = g
|
||
n += 1
|
||
if not pages:
|
||
B.die("нет спрайтов стража")
|
||
total = 0
|
||
for pg in range(max(pages) + 1):
|
||
nm = f"g{pg}.atl"
|
||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
|
||
total += sz
|
||
# Зеркальный по вертикали набор — пара к kid*_v.atl (см. vflip_cols).
|
||
# ТОЛЬКО для обычного стража: зелье инверсии есть на уровне 9, а там
|
||
# tbl_guard_type = 0, то есть страж; скелет (уровень 3), визирь и
|
||
# жирный с переворотом не встречаются. Если чит U всё-таки включат
|
||
# на таком уровне, соперник нарисуется неперевёрнутым кадром —
|
||
# pop_vflip_page вернёт 0 (pop_vflip.h).
|
||
if SET_NAME == "GUARD":
|
||
_, szv = B.pack_atlas(os.path.join(OUT_DIR, f"g{pg}_v.atl"),
|
||
vflip_page(pages.get(pg, {})))
|
||
total += szv
|
||
print(f" {nm}: {len(pages.get(pg, {}))} спрайтов, каталог {cnt}, {sz} Б")
|
||
write_pal_header(str(P.GEN / "pop_guard_pal.h"))
|
||
print(f"ИТОГО: {n} спрайтов стража, {max(pages)+1} EMM-страниц, {total} Б -> {OUT_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if len(sys.argv) > 1:
|
||
select_set(sys.argv[1].upper())
|
||
main()
|