cd8d566d82
Порт PoP на Sprinter. Текущий PoC — applications/PoP/roomtest/: комната 1 (фон-композиция тайлов) + Kid с управлением на raw-клавиатуре и коллизией с картой. - roomtest — pop_bg (фон), pop_kid (спрайты Kid, column-major флип, seqtbl-анимация), pop_ctrl (порт control() PoP на held-state kbd_raw), pop_map (коллизия seg004/005: бег/стоп у стены, падение/приземление, отскок seq_47, вертикальный прыжок K4.1). MEMORY=small (DATA сразу за CODE, ~23КБ кода не лезет в huge). - toolchain (PoP) — pop_pack_kid/pop_pack_bg/render_room/extract — распаковка res-графики MSDOS в атласы + композиция комнат. - toolchain/ (корень) — make_hdd.sh (быстрый HDD-тест вместо FDD), png_strip.py / room_compose.py (ассет-пайплайн). - docs — PORT_PLAN, KID_PLAN, форматы ресурсов (Apple II / MSDOS / DAT). - bgtest/coltest/poc — ранние PoC (фон, коллизия, первый прототип). .gitignore: build-артефакты applications/*/*/*; исключены внешние референс-репозитории (SDLPoP/mininim/PR/Apple-II — свои git-клоны) и оригинальные game-данные MSDOS/ (копирайт, только для реверса форматов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
#!/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}
|
||
|
||
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(id for ch, id in used if ch != R.CHTAB_WALL)
|
||
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()
|