#!/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} # Пол-оверлей подъёма (floor_left_overlay, draw_floor_overlay seg008): рисуется # рантаймом поверх нижней части Kid при подтягивании на пол у кромки — НЕ # попадает в used render_room, добавляем явно в env-фон. (32 обычно уже есть.) CLIMB_OVERLAY_ENV_IDS = {32, 150, 151} # Дрожащие/падающие кадры loose-плиты (loose_fram_left/right/bottom, seg008): # 41/42/43 = покой (уже в used из render_room), 69-74 = вибрация/раздвоение — # используются ТОЛЬКО динамикой (shake/fall), в статический render_room НЕ # попадают, поэтому добавляем явно. Без них дрожащая плита невидима. LOOSE_ANIM_ENV_IDS = {69, 70, 71, 72, 73, 74} # Кадры ВЫДВИЖЕНИЯ пик (seg008): три части — left (128-132, своя ячейка, # draw_tile_anim), right (134-138, правый сосед, draw_tile_anim_right), fore # (139-143, поверх Kid, draw_tile_fore). Рисуются ТОЛЬКО динамикой # (animate_spike); в уровнях пики статически убраны (modif=0 → кадр 0), # поэтому в used render_room НЕ попадают. Без них выдвинутые пики невидимы. SPIKE_ANIM_ENV_IDS = {128, 129, 130, 131, 132, # left 134, 135, 136, 137, 138, # right 139, 140, 141, 142, 143} # fore # Кадры ОТКРЫТИЯ ворот (portcullis, DOOR_FRAM_SLICE + gate pieces 50/51, # seg008 draw_gate): 50-59 + 67. В уровнях ворота статически ЗАКРЫТЫ # (низкий modif → используются только нижние срезы 50-53); срезы более # открытых состояний (54-59, 67) нужны анимации открытия — добавляем явно. GATE_ANIM_ENV_IDS = {50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 67} # НАЖАТАЯ кнопка-closer (get_tile_to_draw seg008:2FE: при таймере связи >1 # tiles_6_closer рисуется как tiles_5_stuck). tile_table[5] = база 41 (уже # есть), правая грань 35, нижняя 36 — оба id встречаются ТОЛЬКО в этом # динамическом состоянии, статически тайла 5 в уровнях нет, значит в used # render_room они не попадают. Без них у нажатой кнопки чёрный провал на # месте нижней грани и потерянная правая грань. STUCK_ENV_IDS = {35, 36} # Левая половина кнопки-opener, когда СЛЕВА пусто (draw_tile_base seg008:0A8E: # curr_tile==15 && tile_left==0 && level_type==0 → id 148 вместо базы 147). # render_room этой ветки не знает, поэтому добавляем явно. OPENER_NOFLOOR_ENV_IDS = {148} # ---- chtab_1 (flame/sword/potion), база ресурса 150, папка PRINCE ---- # Зелье (seg008 draw_tile_fore/draw_tile_anim): склянка id 12 (малая) / 13 # (большая, типы 2..4), кадры пузырька 16..22 (potion_fram_bubb) и его маска # 23. Пузырёк и маска рисуются MONO-блитом (одним цветом), поэтому пакуем их # силуэтами уже в нужном цвете: маска — чёрный env[0], пузырёк — env[12] # (красный, тип «лечение»). Цвета других типов (9 синий / 10 зелёный) — # TODO, когда появятся такие зелья. POT_DIR_NAME = "PRINCE" POT_FLAME_IDS = tuple(range(1, 10)) # пламя факела: modifier+1 (seg008:560) POT_FLASK_IDS = (12, 13) POT_BUBBLE_IDS = (16, 17, 18, 19, 20, 21, 22) POT_MASK_ID = 23 POT_PAL_BASE = 0x40 # 16 цветов chtab_1 -> слоты 0x40..0x4F # MONO-блит (method_3_blit_mono, seg009:3040) красит спрайт цветом из ОБЩЕЙ # 16-цветной палитры экрана, а не из палитры chtab. Кладём её в 0x30..0x3F. VGA16_PAL_BASE = 0x30 VGA16 = [(0,0,0),(0,0,42),(0,42,0),(0,42,42),(42,0,0),(42,0,42),(42,21,0), (42,42,42),(21,21,21),(21,21,63),(21,63,21),(21,63,63),(63,21,21), (63,21,63),(63,63,21),(63,63,63)] # 6-битные каналы, как в .pal POT_MASK_COLOR = VGA16_PAL_BASE + 0 # чёрный (blitters_40h_mono без цвета) POT_BUBBLE_COLOR = VGA16_PAL_BASE + 12 # ярко-красный (color 12: «лечение») # Основание факела (seg008:489 draw_tile_right: при левом соседе-факеле # рисуется chtab_6 id 146). В статический render_room не попадает. TORCH_ENV_IDS = {146} 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-блоб --------------------------------- # Спрайты chtab_6, которые оригинал рисует БЕЗ прозрачности # (blitters_0_no_transp): нижние грани тайлов (draw_tile_bottom, seg008:570) и # нижние кадры loose (draw_loose, seg008:600). У 96/149 (грани кнопок) и 73/74 # (кадры loose) ПОСЛЕДНЯЯ строка — пиксель 0; в оригинале она заливается ЧЁРНЫМ # поверх того, что сзади, а у нас 0 -> 0xFF делал её прозрачной, и сквозь неё # просвечивал Kid (спуск/подъём у кромки кнопки). 43/36 полностью непрозрачны — # для них флаг ничего не меняет, оставлены для полноты списка. NO_TRANSP_ENV_IDS = {36, 43, 73, 74, 96, 149} def load_indexed(chtab, id, opaque=False): """PNG (mode P, 16 цветов) -> (w, h, bytes 8bpp). Каскад VDUNGEON->VPALACE. Пиксель 0 -> 0xFF (прозрач); i -> base+i (env 0x50 / wall 0x60). opaque=True — пиксель 0 остаётся ЦВЕТОМ (base+0 = чёрный), а не ключом прозрачности: порт blitters_0_no_transp (см. NO_TRANSP_ENV_IDS).""" 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() if opaque: out = bytes((pal_base + v) for v in src) else: out = bytes((0xFF if v == 0 else pal_base + v) for v in src) return im.width, im.height, out def load_potion(id, mono=None, opaque=False): """chtab_1: res(150+id).png из data/PRINCE. mono=None — обычный спрайт (палитра chtab_1 -> слоты POT_PAL_BASE+i); mono=цвет — силуэт этим цветом (порт blitters_40h_mono); opaque=True — пиксель 0 остаётся ЦВЕТОМ (порт blitters_0_no_transp, см. POT_FLAME_IDS).""" path = os.path.join(os.path.dirname(R.VDUNGEON_DIR), POT_DIR_NAME, f"res{150 + id}.png") if not os.path.exists(path): die(f"нет файла {path}") im = Image.open(path) if im.mode != "P": die(f"res{150+id}: mode {im.mode}, ожидался P") src = im.tobytes() if mono is not None: out = bytes((0xFF if v == 0 else mono) for v in src) elif opaque: out = bytes((POT_PAL_BASE + v) for v in src) else: out = bytes((0xFF if v == 0 else POT_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) pot = read_pal("res150.pal", os.path.join(os.path.dirname(R.VDUNGEON_DIR), POT_DIR_NAME)) 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)) for i, (r, g, b) in enumerate(pot): o = (POT_PAL_BASE + i) * 4 pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0)) for i, (r, g, b) in enumerate(VGA16): o = (VGA16_PAL_BASE + 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_POT_ATL "pop_pot.atl" /* chtab_1: зелья */', "#define POP_PAL_POT 0x%02X" % POT_PAL_BASE, '#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(set(id for ch, id in used if ch != R.CHTAB_WALL) | CLIMB_OVERLAY_ENV_IDS # + пол-оверлей подъёма | LOOSE_ANIM_ENV_IDS # + дрожащие кадры loose | SPIKE_ANIM_ENV_IDS # + кадры выдвижения пик | GATE_ANIM_ENV_IDS # + кадры открытия ворот | TORCH_ENV_IDS # + основание факела | STUCK_ENV_IDS # + нажатая кнопка-closer | OPENER_NOFLOOR_ENV_IDS) # + opener без пола слева 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, id in NO_TRANSP_ENV_IDS) 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} Б") # POTION (chtab_1) -> idx=id: склянки + кадры пузырька + маска # chtab_1 целиком (res151..res173 = id 1..23): пламя факелов, меч-предмет, # склянки зелий. Пузырьки и их маска рисуются MONO-блитом — пакуем # силуэтами в нужном цвете (см. POT_*_COLOR). pot_entries = {} for id in range(1, 24): if id in POT_BUBBLE_IDS: pot_entries[id] = load_potion(id, POT_BUBBLE_COLOR) elif id == POT_MASK_ID: pot_entries[id] = load_potion(id, POT_MASK_COLOR) elif id in POT_FLAME_IDS: # Пламя оригинал рисует blitters_0_no_transp (seg008 # draw_tile_anim_right) — непрозрачным боксом поверх всего, что # сзади. Все 9 кадров лежат на одном канвасе 16x18, поэтому # непрозрачный блит сам стирает предыдущий кадр: heal не нужен. pot_entries[id] = load_potion(id, opaque=True) else: pot_entries[id] = load_potion(id) cnt, sz = pack_atlas(os.path.join(OUT_DIR, "pop_pot.atl"), pot_entries) total_bytes += sz print(f" pop_pot.atl (chtab_1: факелы+зелья): {len(pot_entries)} спрайтов, каталог {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()