#!/usr/bin/env python3 """ pop_pack_bg.py — упаковщик спрайтов СТАТИЧЕСКОГО ФОНА PoP (Шаг 2 порта). Что делает: 1. Определяет РЕАЛЬНО используемый набор спрайтов фона (прогоняет render_room.py по всем комнатам всех уровней, собирает (chtab,id)). 2. Грузит исходные PNG (VDUNGEON→VPALACE каскад, как в игре), переводит 16-цветный индексированный пиксель в 8bpp getimage-блоб: индекс 0 -> 0xFF (прозрачность getimage) индекс i -> 0x50+i (env, chtab_6) / 0x60+i (wall, chtab_7) 3. Пакует в атласы .atl с ПРЯМОЙ адресацией (idx = id), каталог любого размера (блобы кладутся ПОСЛЕ каталога — загрузчик atlas_image читает offset явно, лимит 19 из libbgi/mkatlas.py не действует). libbgi НЕ трогаем. Дырки в каталоге (неиспользуемые id) = фейковые записи, ссылающиеся на общий пустой 0×0-блоб (осознанная трата места в EMM ради нулевых remap-таблиц в W2 — требование пользователя). 4. Собирает палитру Sprinter (.pal, записи B,G,R,0) из res200.pal (env -> слоты 0x50..0x5F) и res360.pal (wall -> 0x60..0x6F). VGA 6-бит каналы масштабируются в 8-бит. 5. Пишет C-заголовок с раскладкой (имена файлов, SHIFT, число страниц, набор fore-id). Раскладка (прямая адресация, SHIFT=5): ENV фон : 5 страниц, страница = id>>5, idx = id&31 WALL : 1 страница, idx = id FORE : 1 страница (спрайты «перед персонажем»: фронты столбов/ ворот/дебриса), idx = id — отдельный атлас (жизненный цикл другой: рисуются каждый кадр как sprite_t, не запекаются). W2-цена: 7 хэндлов atlas_t (~21 Б), ноль таблиц id->позиция. Выход: в applications/PoP/poc/res/bg/ — *.atl + pop_bg.pal + pop_bg_atlas.h """ import os import sys from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import render_room as R # noqa: E402 (экстрактор + каскад загрузки) OUT_DIR = os.path.join(HERE, "..", "poc", "res", "bg") # Спрайты FORE-слоя (add_foretable в seg008 — «перед персонажем»): фронты # столбов, ворот, дебриса. См. tile_table.fore_id + анализ pop_background_strategy. FORE_ENV_IDS = {9, 12, 49, 88, 91, 95, 100} ENV_SHIFT = 5 # env фон: страница = id>>5, idx = id&31 ENV_MASK = (1 << ENV_SHIFT) - 1 # ---- .atl формат (== libbgi, но каталог не ограничен 19) -------------- ATL_MAGIC = b"SPA1" ATL_DIR_OFF = 0x68 # _ATL_DIR_OFF ATL_MIN_SIZE = 0x100 # atlas_load требует n >= _ATL_DATA_OFF PAGE_SIZE = 16384 def die(msg): sys.exit(f"pop_pack_bg: {msg}") # ---- 1. Набор используемых спрайтов ---------------------------------- def collect_used(): """Прогон render_room по всем комнатам -> множество (chtab, id).""" used = set() orig = R.load_sprite def spy(id, chtab=R.CHTAB_ENV, reason=""): img = orig(id, chtab, reason) if img is not None and id > 0: used.add((chtab, id)) return img R.load_sprite = spy for lvl in range(1, 15): fn = os.path.join(R.LEVEL_DIR, f"res{2000 + lvl}.bin") if not os.path.exists(fn): continue lv = R.Level(open(fn, "rb").read()) for room in range(1, 25): rm = R.Room(lv, room) L = lv.links[room][0] if L: rm.left_room = R.Room(lv, L) canvas = Image.new("RGBA", (320, 192), (0, 0, 0, 0)) for dr in (2, 1, 0): for c in range(10): R.draw_tile(canvas, rm, dr, c) R.load_sprite = orig return used # ---- 2. Загрузка спрайта в 8bpp-блоб --------------------------------- def load_indexed(chtab, id): """PNG (mode P, 16 цветов) -> (w, h, bytes 8bpp). Каскад VDUNGEON->VPALACE. Пиксель 0 -> 0xFF (прозрач); i -> base+i (env 0x50 / wall 0x60).""" base_res = 360 if chtab == R.CHTAB_WALL else 200 pal_base = 0x60 if chtab == R.CHTAB_WALL else 0x50 fname = f"res{base_res + id}.png" path = None for d in (R.VDUNGEON_DIR, R.VPALACE_DIR): p = os.path.join(d, fname) if os.path.exists(p): path = p break if path is None: die(f"нет файла {fname}") im = Image.open(path) if im.mode != "P": die(f"{fname}: mode {im.mode}, ожидался P (16-цветный)") src = im.tobytes() out = bytes((0xFF if v == 0 else pal_base + v) for v in src) return im.width, im.height, out def blob(w, h, pix): """getimage-блоб: u16 w, u16 h (LE) + пиксели.""" return w.to_bytes(2, "little") + h.to_bytes(2, "little") + pix # ---- 3. Пакер .atl (каталог любого размера) -------------------------- def pack_atlas(path, entries): """entries: dict idx -> (w, h, pix). Пустые idx (0..maxidx) заполняются фейковой ссылкой на общий 0×0-блоб. Каталог count записей с 0x68, блобы — сразу после каталога.""" if not entries: die(f"{path}: пустой атлас") count = max(entries) + 1 if count > 255: die(f"{path}: count {count} > 255") data_off = ATL_DIR_OFF + count * 8 blobs = bytearray() empty_off = data_off + len(blobs) # общий 0×0-блоб для дырок blobs += blob(0, 0, b"") dir_bytes = bytearray(count * 8) for idx in range(count): e = entries.get(idx) if e is None: off = empty_off fw = fh = 0 else: w, h, pix = e off = data_off + len(blobs) blobs += blob(w, h, pix) fw = min(w, 255) fh = min(h, 255) d = idx * 8 dir_bytes[d:d + 2] = off.to_bytes(2, "little") dir_bytes[d + 2] = fw dir_bytes[d + 3] = fh dir_bytes[d + 4] = 1 # nx dir_bytes[d + 5] = 1 # ny # d+6,d+7 резерв out = bytearray(data_off) out[0:4] = ATL_MAGIC out[4] = count out[ATL_DIR_OFF:ATL_DIR_OFF + len(dir_bytes)] = dir_bytes out += blobs if len(out) < ATL_MIN_SIZE: out += bytes(ATL_MIN_SIZE - len(out)) # atlas_load: n >= 0x100 if len(out) > PAGE_SIZE: die(f"{path}: {len(out)} Б > {PAGE_SIZE} (одна EMM-страница)") with open(path, "wb") as f: f.write(out) return count, len(out) # ---- 4. Палитра ------------------------------------------------------ def scale6to8(v): return (v * 255) // 63 def build_palette(out_path): """256-записная палитра B,G,R,0: env@0x50..0x5F, wall@0x60..0x6F.""" def read_pal(res, d): b = open(os.path.join(d, res), "rb").read() return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)] env = read_pal("res200.pal", R.VDUNGEON_DIR) wall = read_pal("res360.pal", R.VDUNGEON_DIR) pal = bytearray(256 * 4) for i, (r, g, b) in enumerate(env): o = (0x50 + i) * 4 pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0)) for i, (r, g, b) in enumerate(wall): o = (0x60 + i) * 4 pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0)) with open(out_path, "wb") as f: f.write(pal) # ---- 5. C-заголовок -------------------------------------------------- def write_header(out_path, env_pages, wall_name, fore_name): lines = [ "/* pop_bg_atlas.h — раскладка атласов статического фона PoP.", " * Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.", " *", " * Прямая адресация (ноль remap-таблиц в W2):", " * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK), " * WALL id N -> atlas wall, idx N", " * FORE id N -> atlas fore, idx N", " */", "#ifndef POP_BG_ATLAS_H", "#define POP_BG_ATLAS_H", "", "#define POP_ENV_SHIFT %d" % ENV_SHIFT, "#define POP_ENV_MASK %d" % ENV_MASK, "#define POP_ENV_PAGES %d" % len(env_pages), "", "/* Палитра: env-слоты, wall-слоты (сприйт-пиксель i -> база+i). */", "#define POP_PAL_ENV 0x50", "#define POP_PAL_WALL 0x60", "", "/* Имена файлов атласов (грузятся atlas_load). */", "static const char *const pop_env_atl[POP_ENV_PAGES] = {", ] for pg in range(len(env_pages)): nm = env_pages[pg] lines.append(' %s,' % ('"%s"' % nm if nm else "0 /* пусто */")) lines += [ "};", '#define POP_WALL_ATL "%s"' % wall_name, '#define POP_FORE_ATL "%s"' % fore_name, '#define POP_BG_PAL "pop_bg.pal"', "", "#endif", "", ] with open(out_path, "w") as f: f.write("\n".join(lines)) def main(): os.makedirs(OUT_DIR, exist_ok=True) used = collect_used() env_ids = sorted(id for ch, id in used if ch != R.CHTAB_WALL) wall_ids = sorted(id for ch, id in used if ch == R.CHTAB_WALL) env_bg = [i for i in env_ids if i not in FORE_ENV_IDS] env_fore = [i for i in env_ids if i in FORE_ENV_IDS] # ENV фон -> страницы по id>>SHIFT pages = {} for id in env_bg: w, h, pix = load_indexed(R.CHTAB_ENV, id) pages.setdefault(id >> ENV_SHIFT, {})[id & ENV_MASK] = (w, h, pix) max_pg = max(pages) if pages else -1 env_page_names = [] total_bytes = 0 for pg in range(max_pg + 1): if pg in pages: nm = f"pop_env{pg}.atl" cnt, sz = pack_atlas(os.path.join(OUT_DIR, nm), pages[pg]) total_bytes += sz env_page_names.append(nm) print(f" {nm}: {len(pages[pg])} спрайтов, каталог {cnt}, {sz} Б") else: env_page_names.append(None) print(f" (env стр {pg}: пусто)") # WALL -> idx=id wall_entries = {id: load_indexed(R.CHTAB_WALL, id) for id in wall_ids} cnt, sz = pack_atlas(os.path.join(OUT_DIR, "pop_wall.atl"), wall_entries) total_bytes += sz print(f" pop_wall.atl: {len(wall_ids)} спрайтов, каталог {cnt}, {sz} Б") # FORE -> idx=id fore_entries = {id: load_indexed(R.CHTAB_ENV, id) for id in env_fore} cnt, sz = pack_atlas(os.path.join(OUT_DIR, "pop_fore.atl"), fore_entries) total_bytes += sz print(f" pop_fore.atl: {len(env_fore)} спрайтов, каталог {cnt}, {sz} Б") build_palette(os.path.join(OUT_DIR, "pop_bg.pal")) write_header(os.path.join(OUT_DIR, "pop_bg_atlas.h"), env_page_names, "pop_wall.atl", "pop_fore.atl") n_pages = sum(1 for n in env_page_names if n) + 2 print(f"ИТОГО: {len(env_bg)} env-фон + {len(wall_ids)} wall + " f"{len(env_fore)} fore = {len(env_bg)+len(wall_ids)+len(env_fore)} спрайтов") print(f" {n_pages} EMM-страниц, {total_bytes} Б на диске " f"(+1КБ палитра), W2: {n_pages}×atlas_t = {n_pages*3} Б хэндлов") print(f" -> {OUT_DIR}") if __name__ == "__main__": main()