Files
Sprinter-SDCC/applications/PoP/toolchain/pop_pack_bg.py
T
snark13 723da3c5c1 PoP roomtest: пики — редрой каждый кадр, полный draw_tile; пламя без heal
Выдвинутая пика живёт ТОЛЬКО в видео-ОЗУ (банк SPRITE, в запечённый фон не
входит), а kid_heal каждый кадр возвращает под спрайтом Кида печёный фон —
то есть стирает попавшие под него части остриёв.  Редрой «только при смене
видимой сигнатуры» их не возвращал: hold держится 15 кадров (modif
0x8F..0x81) с неизменным кадром 5.  Итог по дампу VRAM (комната 14, Kid на
(2,7), modif 0x8E): у спрайта 132 стёрто третье остриё (x 245..247), у 138 —
прямое целиком (x 258..260) и низ наклонного, страницы дабл-буфера
расходились на 8 пикселей.  SDLPoP: animate_spike (seg007:0353) зовёт
redraw_21h БЕЗУСЛОВНО, вне всяких if — редрой каждый кадр, пока trob жив.

pop_spike_redraw переписан на честный redraw_tile_height (seg007:0218):
heal + ПОЛНЫЙ draw_tile своего тайла и правого соседа.  Рисовать только два
спрайта остриёв нельзя — теряется порядок слоёв внутри тайла
(draw_tile_anim_right идёт ПЕРВЫМ, база и fore соседа ложатся поверх), и
остриё лезло на колонну (2,9).  draw_tile заодно сам восстанавливает вклад
соседних пик (ветка lcode==2), так что две пики подряд больше не гасят
друг друга.

Пламя факела: heal убран.  Оригинал рисует его blitters_0_no_transp (seg008
draw_tile_anim_right), все 9 кадров лежат на общем канвасе 16x18 и бокс
центрирован в ячейке — непрозрачный блит сам полностью накрывает предыдущий
кадр.  Кадры пакуются с opaque=True (POT_FLAME_IDS).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:34:07 +03:00

431 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()