Files
Sprinter-SDCC/applications/PoP/toolchain/pop_pack_bg.py
T
snark13 8b30dc20c8 roomtest: loose-полы — тряска (knock), падение с окклюзией, фикс дабл-буфера
Проваливающиеся полы в roomtest (порт SDLPoP seg007/seg008), проверено в MAME:

- Падающий кусок (mob): правый край env-42 (w=26, доходит до mob_x+57)
  теперь полностью покрыт heal-коридором (MOB_W 56->64) — убран тёмный
  хвост-тень на полу 2,7 ПОСЛЕ падения.
- Окклюзия правого куска во ВРЕМЯ падения: после отрисовки плиты
  перерисовывается сосед-пол draw_tile(row,col+1) в том же кадре — край
  прячется за полом 2,7 (порт redraw_at_cur_mob: set_redraw_full+1).
- Тряска от сотрясения (knock): seqtbl-команды KNOCK_DOWN/UP в play_seq ->
  флаг knock -> do_knock(ряд) трясёт loose ряда из покоя (modif=0x80).
  KNOCK_DOWN в land-seq (приземление) и runcyc (footstep).
- Фикс «бесконечной тряски» (дабл-буфер): при завершении тряски (0x84->0)
  перерисовать покойный кадр плиты на ОБЕИХ страницах (loose_rest, 2 кадра) —
  иначе на одной странице застревает дрожащий кадр правой грани (живёт в
  тайлах col и col+1) -> мерцание через флип.

Инфраструктура/документация:
- app.mk: цель `make hdd` (упаковка в D: для MCP-моста MAME).
- docs: POP-DAT-FormatSpecifications.pdf/.txt как каноническая спецификация
  форматов ресурсов; ссылки в README/MSDOS_RESOURCE_FORMAT.
- README.md/CLAUDE.md для applications/PoP и roomtest (правило «SDLPoP —
  источник истины», порядок слоёв, режим отладки freeze 1/2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:27:47 +03:00

302 lines
13 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}
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(set(id for ch, id in used if ch != R.CHTAB_WALL)
| CLIMB_OVERLAY_ENV_IDS # + пол-оверлей подъёма
| LOOSE_ANIM_ENV_IDS) # + дрожащие кадры loose
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()