Files
Sprinter-SDCC/applications/PoP/toolchain/pop_pack_bg.py
T
snark13 f10771194b Шаг C: дворцовая кладка стен (wall_pattern, паласная ветка)
Во дворце тело стены — не кирпичи-спрайты, а шесть СПЛОШНЫХ ЗАЛИВОК плюс
пять моно-разделителей поверх (seg008:1946).  Порт целиком:

- gen_palace_wall_colors (seg000:1942): 3 ряда × 4 подряда × 11 колонок = 132
  цвета, сид = номер комнаты, подряды 1/3 из 0x61..0x64, подряды 0/2 из
  0x66..0x69, соседние по горизонтали не повторяются.  Одиннадцать колонок,
  а не десять: заливки 3 и 5 берут цвет СЛЕДУЮЩЕЙ колонки.  Пересчёт на
  смене комнаты — там же, где сбрасывается кэш кладки (wall_pattern_reset).
  Таблица не static: writable-данные банка живут в _DATA/W2.
- Геометрия заливок дословно из add_wipetable(layer, left, bottom, height,
  width): прямоугольник = x..x+width-1, (bottom-height+1)..bottom.
- Пять prandom(2) на тайл кэшируются так же, как подземельные решения
  (wp_a/wp_b переиспользуются — наборы в одной комнате не сосуществуют).
  Сохранён квирк порядка: при which_part == 0 разыгрывается ОДНО значение, и
  нижний разделитель берёт ПЕРВОЕ из серии, а не пятое.
- Заливки режутся по окну fore-клипа: иначе легли бы поверх областей, которые
  в этом кадре никто не восстанавливает.  Вне fore-прохода — pop_cd_touch,
  потому что bar идёт мимо pop_blit_b.
- wall_fram_bottom / wall_fram_main во дворце НЕ рисуются (seg008:576, 711) —
  и в горячей половине слоя (pop_bg.c), и в холодной (pop_room.c).
- Упаковщик: дворцовые wall-id 3..17 пакуются силуэтом в цвете 6 общей
  16-цветной палитры (blitters_46h_mono_6).  В подземелье те же id —
  обычные кирпичи, поэтому mono только у паласного набора.

ИЗВЕСТНОЕ РАСХОЖДЕНИЕ: рисунок цветов не совпадает с SDLPoP попиксельно,
потому что наш prandom — 16-битный xorshift, а не LCG оригинала (замена
сделана раньше по бюджету кадра, prng_alternatives.md).  Совпадают
геометрия, диапазоны цветов и правило «соседние не повторяются».

Проверено в MAME: уровень 4 — песочный мрамор с разделителями, структурно
как эталон SDLPoP; уровень 1 не изменился.  tests-host 5/5.

Остаётся расхождение по двери уровня (мы заполняем проём плетёнкой целиком,
оригинал рисует несколько кусков лестницы) — отдельным шагом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:19:42 +03:00

576 lines
32 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} | {106, 107, 108, 109, 110} \
| {119, 120, 121, 122, 123} # фронт чомпера + его кровь
# Пол-оверлей подъёма (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
# Кадры ЧОМПЕРА (seg008:638 chomper_fram_bot/top/for + кровь). В уровнях
# чомпер стоит с modif = 0, а chomper_fram1[0] = 3 — значит render_room
# (он анимацию пропускает вовсе, см. render_room.py:611) не кладёт в атлас
# НИ ОДНОГО кадра, и чомпер не виден даже в покое. Поэтому весь набор —
# явно. Низ и верх идут в backtable (ПОД персонажем), фронт — в foretable
# (ПОВЕРХ), поэтому фронтовые id продублированы в FORE_ENV_IDS.
CHOMPER_BOT_IDS = {101, 102, 103, 104, 105} # chomper_fram_bot
CHOMPER_TOP_IDS = {111, 112, 113} # chomper_fram_top (фазы 2..4)
CHOMPER_FORE_IDS = {106, 107, 108, 109, 110} # chomper_fram_for
# Кровь на сомкнутых челюстях (modif & 0x80): chomper_num + 114 в backtable и
# + 119 в foretable, обе — MONO-блитом цветом 12 (blitters_4Ch_mono_12), то
# есть силуэтом, как пузырёк зелья. Появляется после смерти Кида в чомпере.
CHOMPER_BLOOD_IDS = {114, 115, 116, 117, 118}
CHOMPER_BLOOD_FORE_IDS = {119, 120, 121, 122, 123}
CHOMPER_BLOOD_COLOR = None # ставится ниже, когда известен VGA16_PAL_BASE
# Кадры ОТКРЫТИЯ ворот (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}
# ВЕРХ ворот, который рисует тайл НАД ними (draw_tile_anim_topright,
# seg008:0568): маска 68 (mono, чёрным — «продавить» фон под створкой) и
# кадр door_fram_top[(modifier>>2) % 8] = 60..67 по открытости. Без них
# над воротами остаётся чёрный треугольник (видно у шва во дворце).
GATE_TOP_ENV_IDS = {60, 61, 62, 63, 64, 65, 66, 67}
GATE_TOP_MASK_ID = 68
# НАЖАТАЯ кнопка-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: «лечение»)
CHOMPER_BLOOD_COLOR = VGA16_PAL_BASE + 12 # тот же mono-цвет 12 (seg008:678)
# Кладка ДВОРЦА (wall_pattern, seg008:1928, ветка palace): тело стены —
# сплошные заливки цветом из palace_wall_colors, а поверх идут ПЯТЬ
# разделителей chtab_7 id 3..17 (prandom(2) + 3/6/9/12/15), и рисуются они
# blitters_46h_mono_6 — mono-блитом ОДНИМ цветом 6 общей 16-цветной палитры
# экрана. В подземелье те же id — обычные спрайты кирпичей (wall_fram_main/
# bottom), поэтому силуэтом пакуем ТОЛЬКО дворцовый набор.
PALACE_WALL_MONO_IDS = set(range(3, 18))
PALACE_WALL_MONO_COLOR = VGA16_PAL_BASE + 6
# Основание факела (seg008:489 draw_tile_right: при левом соседе-факеле
# рисуется chtab_6 id 146). В статический render_room не попадает.
TORCH_ENV_IDS = {146}
# ---- Два тайлсета: подземелье и дворец --------------------------------
# Оригинал грузит РАЗНЫЙ .DAT под один и тот же набор id:
# tbl_envir_ki[tbl_level_type[level]] -> VDUNGEON / VPALACE (seg000:1108),
# то есть id спрайта от набора НЕ зависит — меняется только картинка и
# палитра (env 0x50..0x5F, wall 0x60..0x6F). Поэтому оба набора пакуются
# по ОДНОМУ И ТОМУ ЖЕ объединению id: раскладка id -> (страница, idx)
# остаётся одна, и коду достаточно подменить ИМЕНА файлов.
#
# Порядок каталогов — каскад: сначала «свой» набор, потом чужой как
# фолбэк (в распакованном data/ несколько id есть только в одном из них:
# res230/231/348 только в VDUNGEON, два десятка — только в VPALACE).
SETS = {
"dungeon": ("pop_", lambda: (R.VDUNGEON_DIR, R.VPALACE_DIR)),
"palace": ("pal_", lambda: (R.VPALACE_DIR, R.VDUNGEON_DIR)),
}
SRC_DIRS = None # ставит main() по выбранному набору
# Спрайты, которые рисуются ТОЛЬКО на паласных уровнях (seg008):
# 78, 80, 82 doortop_fram_bot (низ верха двери, draw_tile_right)
# 81, 83 doortop_fram_top (draw_tile_topright)
# 84 полоска на стене (draw_tile_right, tiles_20_wall)
# 145 stripe_id соседнего тайла (draw_tile_right)
# render_room про них не знает (он рисует по подземельной ветке), поэтому
# добавляем явно — иначе при переключении набора в этих местах будет дыра.
PALACE_ENV_IDS = {78, 80, 81, 82, 83, 84, 145}
ENV_SHIFT = 4 # env фон: страница = id>>SHIFT, idx = id&MASK.
# Было 5: паласный набор добавляет куски верха
# двери (78..84), и страница 2 переваливала за
# 16 КБ. При 4 обе раскладки укладываются, цена
# — 10 страниц EMM на набор вместо 5.
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 полностью непрозрачны —
# для них флаг ничего не меняет, оставлены для полноты списка.
# Дверь уровня (draw_leveldoor, seg008:1D29): решётка створки (33 — слайс 4px,
# 34 — верх), лестница за ней (99 — низ, 144 — марш). render_room.py
# draw_leveldoor не реализует, поэтому в автосбор эти id не попадают.
LEVELDOOR_ENV_IDS = {33, 34, 99, 144}
# Куски двери уровня (draw_leveldoor) оригинал рисует blitters_0_no_transp:
# марш лестницы 144 обязан ЗАКРАШИВАТЬ поднявшуюся створку, иначе слева от
# лестницы сквозь него видна решётка (0 -> 0xFF делал фон спрайта прозрачным).
NO_TRANSP_ENV_IDS = {36, 43, 73, 74, 96, 149} | LEVELDOOR_ENV_IDS
def load_indexed(chtab, id, opaque=False, mono=None):
"""PNG (mode P, 16 цветов) -> (w, h, bytes 8bpp). Каскад по SRC_DIRS
(порядок задаёт выбранный тайлсет, см. SETS).
Пиксель 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 SRC_DIRS:
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 mono is not None:
# MONO-блит (blitters_4Ch_mono_12): спрайт красится ОДНИМ цветом из
# 16-цветной палитры экрана — пакуем силуэтом, как пузырёк зелья.
out = bytes((0xFF if v == 0 else mono) for v in src)
elif 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 read_pal16(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)]
def build_tileset_palette(out_path):
"""Палитра ТОЛЬКО тайлсета: 32 записи B,G,R,0 — env (16) затем wall (16).
Ею подменяются слоты 0x50..0x6F при смене типа уровня; полная игровая
палитра (kid.pal) при этом не трогается, потому что Кид, страж, меч и
зелья живут в других слотах (levels_plan §3). Порядок каталогов —
SRC_DIRS, то есть файл соответствует выбранному набору."""
pal = bytearray(32 * 4)
src = read_pal16("res200.pal", SRC_DIRS[0]) + read_pal16("res360.pal", SRC_DIRS[0])
for i, (r, g, b) in enumerate(src):
pal[i * 4:i * 4 + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
with open(out_path, "wb") as f:
f.write(pal)
def build_palette(out_path):
"""256-записная палитра B,G,R,0: env@0x50..0x5F, wall@0x60..0x6F."""
def read_pal(res, d):
return read_pal16(res, d)
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):
"""Заголовок ОДИН на оба тайлсета: раскладка id -> (страница, idx) у них
общая (пакуются по одному объединению id), различаются только имена
файлов и палитра. Поэтому здесь два массива имён и один индекс."""
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",
" *",
" * ДВА ТАЙЛСЕТА (порт tbl_envir_ki[tbl_level_type], seg000:1108):",
" * 0 = подземелье (pop_*), 1 = дворец (pal_*). Набор id и раскладка",
" * у них ОДНИ И ТЕ ЖЕ — меняются только файлы и 32 записи палитры",
" * (env 0x50..0x5F, wall 0x60..0x6F), см. *tile.pal.",
" */",
"#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),
"#define POP_TILESETS 2",
"",
"/* Палитра: env-слоты, wall-слоты (спрайт-пиксель i -> база+i). */",
"#define POP_PAL_ENV 0x50",
"#define POP_PAL_WALL 0x60",
"",
"/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы",
" * видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия",
" * строк уедет в каждый включивший заголовок модуль. */",
"#ifdef POP_BG_ATLAS_NAMES",
"static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {",
]
for prefix, _ in SETS.values():
row = ", ".join(('"%s%s"' % (prefix, nm.split("_", 1)[1]) if nm else "0")
for nm in env_pages)
lines.append(" { %s }," % row)
lines += [
"};",
'static const char *const pop_wall_atl[POP_TILESETS] = { %s };'
% ", ".join('"%swall.atl"' % p for p, _ in SETS.values()),
'static const char *const pop_fore_atl[POP_TILESETS] = { %s };'
% ", ".join('"%sfore.atl"' % p for p, _ in SETS.values()),
'static const char *const pop_tile_pal[POP_TILESETS] = { %s };'
% ", ".join('"%stile.pal"' % p for p, _ in SETS.values()),
"#endif /* POP_BG_ATLAS_NAMES */",
'#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():
global SRC_DIRS
set_name = sys.argv[1] if len(sys.argv) > 1 else "dungeon"
if set_name not in SETS:
die(f"набор '{set_name}': ожидался один из {', '.join(SETS)}")
prefix, dirs = SETS[set_name]
SRC_DIRS = dirs()
print(f"набор: {set_name} (источник {os.path.basename(SRC_DIRS[0])}, "
f"фолбэк {os.path.basename(SRC_DIRS[1])}), префикс {prefix}")
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 # + кадры открытия ворот
| GATE_TOP_ENV_IDS # + верх ворот (тайл сверху)
| {GATE_TOP_MASK_ID} # и его маска (mono)
| TORCH_ENV_IDS # + основание факела
| STUCK_ENV_IDS # + нажатая кнопка-closer
| CHOMPER_BOT_IDS # + чомпер: низ
| CHOMPER_TOP_IDS # верх
| CHOMPER_FORE_IDS # фронт (поверх Kid)
| CHOMPER_BLOOD_IDS # кровь (mono)
| CHOMPER_BLOOD_FORE_IDS
| OPENER_NOFLOOR_ENV_IDS # + opener без пола слева
| LEVELDOOR_ENV_IDS # + дверь уровня (створка/лестница)
| PALACE_ENV_IDS) # + куски, что рисует только палас
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,
CHOMPER_BLOOD_COLOR if id in CHOMPER_BLOOD_IDS
else (POT_MASK_COLOR if id == GATE_TOP_MASK_ID
else None))
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"{prefix}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, False,
PALACE_WALL_MONO_COLOR
if (set_name == "palace" and id in PALACE_WALL_MONO_IDS)
else None)
for id in wall_ids}
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "wall.atl"), wall_entries)
total_bytes += sz
print(f" {prefix}wall.atl: {len(wall_ids)} спрайтов, каталог {cnt}, {sz} Б")
# FORE -> idx=id
fore_entries = {id: load_indexed(R.CHTAB_ENV, id, False,
CHOMPER_BLOOD_COLOR
if id in CHOMPER_BLOOD_FORE_IDS else None)
for id in env_fore}
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "fore.atl"), fore_entries)
total_bytes += sz
print(f" {prefix}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)
# chtab_1 (пламя/меч/зелья) от тайлсета НЕ зависит — пакуем один раз.
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_tileset_palette(os.path.join(OUT_DIR, prefix + "tile.pal"))
print(f" {prefix}tile.pal: 32 записи (env 0x50..0x5F + wall 0x60..0x6F)")
build_palette(os.path.join(OUT_DIR, "pop_bg.pal"))
write_header(os.path.join(OUT_DIR, "pop_bg_atlas.h"), env_page_names)
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()