SprPoP: автономное приложение, выделенное из roomtest

Порт 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>
This commit is contained in:
2026-08-27 12:12:28 +03:00
parent 4b74478d19
commit 31b82661eb
235 changed files with 51293 additions and 10 deletions
+632
View File
@@ -0,0 +1,632 @@
#!/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->позиция.
Выход: в build/atl/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 (экстрактор + каскад загрузки)
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)
OUT_DIR = str(P.atl("bg"))
# Спрайты FORE-слоя (add_foretable в seg008 — «перед персонажем»): фронты
# столбов, ворот, дебриса. См. tile_table.fore_id + анализ pop_background_strategy.
# 119..123 — фронт чомпера и его кровь; 77 — фронт ЗЕРКАЛА (MIRROR_ENV_IDS).
#
# id 12 ЗДЕСЬ НЕТ намеренно. В tile_table он стоит как fore_id ЗЕЛЬЯ
# (0x0A), но зелье рисуется из chtab_1 (`add_foretable(id_chtab_1_...)`,
# seg008 draw_tile_fore; у нас — pop_potion_flask), а в chtab_6 под этим
# номером лежит ПРАВАЯ ЧАСТЬ АРКИ БАЛКОНА (32x62), и она идёт в backtable:
# `BT room=18 r=1 c=7 ch=6 id=12` в трассе оригинала. Пока 12 был в этом
# списке, он уезжал в fore-атлас, а env-страница получала дырку — правая
# половина портала не рисовалась вовсе (BUG-BALCONY-RIGHT).
FORE_ENV_IDS = {9, 49, 88, 91, 95, 100} | {106, 107, 108, 109, 110} \
| {119, 120, 121, 122, 123} | {77}
# Пол-оверлей подъёма (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
# Два цвета игра ПРАВИТ на старте (init_game_main, seg000:164) — стандартные
# VGA-значения там не годятся:
# 6 -> #C09850 (0x30,0x26,0x14) «palace wall pattern» — швы дворцовой кладки
# 12 -> #E00030 (0x38,0x00,0x0C) «blood, hurt flash» — кровь и вспышка урона
# Проверено дампом палитры живого SDLPoP: PAL[6]=48,38,20 PAL[12]=56,0,12.
VGA16 = [(0,0,0),(0,0,42),(0,42,0),(0,42,42),(42,0,0),(42,0,42),(48,38,20),
(42,42,42),(21,21,21),(21,21,63),(21,63,21),(21,63,63),(56,0,12),
(63,21,63),(63,63,21),(63,63,63)] # 6-битные каналы, как в .pal
# ЧЁРНЫЙ ФОН АНИМАЦИЙ — ИМЕННО ЦВЕТ 0, а не «чёрный из палитры набора».
# Вспышки (молния, урон, зелья) подменяют НУЛЕВУЮ запись палитры, и всё,
# что закрашено 0x30/0x40, остаётся чёрным островом посреди вспышки —
# пламя факелов и склянки «не мигали» (поймано пользователем 2026-08-25).
POT_MASK_COLOR = 0 # маска mono-блита = фон вспышки
POT_BUBBLE_COLOR = VGA16_PAL_BASE + 12 # ярко-красный (color 12: «лечение»)
# Цвет пузырька зависит от ТИПА зелья (draw_tile_anim, seg008:652): типы 3/4
# (перо / переворот) — зелёный, 5/6 (вред / открыть) — синий, остальные —
# красный. У нас спрайт красится при УПАКОВКЕ (mono-блиттера с параметром
# цвета в libbgi нет), поэтому каждый цвет — свой набор кадров: те же семь
# силуэтов под сдвинутыми id. Синий появится вместе с зельями 5/6.
POT_BUBBLE_GREEN_COLOR = VGA16_PAL_BASE + 10 # color 10 (seg008:663)
POT_BUBBLE_GREEN_BASE = 30 # id 30..36 = кадры 16..22
POT_BUBBLE_BLUE_COLOR = VGA16_PAL_BASE + 9 # color 9 (seg008:658)
POT_BUBBLE_BLUE_BASE = 40 # id 40..46 = кадры 16..22
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}
# ЗЕРКАЛО (tile_table[0x0D] = база 75, фронт 77; 42/43 — общие грани пола).
# Тайла 13 НЕТ НИ В ОДНОМ уровне статически (проверено перебором всех 15
# res200N.bin): оригинал СТАВИТ его в рантайме, когда дверь выхода уровня 4
# дорисовала открытие — animate_leveldoor, seg007:0457:
# curr_room_tiles[curr_tilepos] = tiles_13_mirror;
# Значит в used от render_room эти id не попадают и без явного добавления на
# месте зеркала был бы чёрный провал (те же грабли, что у нажатой кнопки и
# дрожащей плиты — memory pop_atlas_dynamic_ids).
MIRROR_ENV_IDS = {75, 77}
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:
# Пиксель 0 — ЦВЕТ 0 (общий фон игрового поля), остальные — палитра
# chtab_1. Ноль здесь принципиален: непрозрачный бокс пламени иначе
# закрашивал бы фон «своим» чёрным (0x40) и не участвовал во вспышке.
out = bytes((0 if v == 0 else 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,
"/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),",
" зелёный (перо/переворот) и синий (вред/открыть) = те же кадры",
" под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */"
% (POT_BUBBLE_GREEN_BASE, POT_BUBBLE_GREEN_BASE + len(POT_BUBBLE_IDS) - 1,
POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1),
"#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE,
"#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_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 # + куски, что рисует только палас
| MIRROR_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)
# Наборы пузырьков ДРУГИХ цветов: те же кадры другим mono-цветом.
# зелёный — типы 3/4 (перо, переворот), синий — типы 5/6 (вред, открыть).
for i, id in enumerate(POT_BUBBLE_IDS):
pot_entries[POT_BUBBLE_GREEN_BASE + i] = load_potion(id, POT_BUBBLE_GREEN_COLOR)
pot_entries[POT_BUBBLE_BLUE_BASE + i] = load_potion(id, POT_BUBBLE_BLUE_COLOR)
# 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(str(P.GEN / "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()