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:
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_kid.py — упаковщик спрайтов персонажа Kid (Фаза K0 порта).
|
||||
|
||||
Аналог pop_pack_bg.py, но для chtab_2_kid (база ресурса 400): image id N
|
||||
= res(400+N).png (data/KID), палитра res400.pal (16 цветов) -> слоты
|
||||
Sprinter 0x70-0x7F (env 0x50 / wall 0x60 заняты фоном). Пиксель i:
|
||||
0->0xFF (прозрач), i->0x70+i.
|
||||
|
||||
Прямая адресация (ноль remap в W2): kid[img>>5], idx img&31. Дырки —
|
||||
фейковые записи (как в фоне). Переиспользует pack_atlas/blob/scale6to8
|
||||
из pop_pack_bg.
|
||||
|
||||
Выход: build/atl/kid/kid0..N.atl + kid.pal + kid_atlas.h
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import pop_pack_bg as B # pack_atlas, blob, scale6to8, ENV_SHIFT
|
||||
|
||||
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)
|
||||
|
||||
KID_DIR = str(P.SDLPOP_DATA / "KID")
|
||||
OUT_DIR = str(P.atl("kid"))
|
||||
PAL_BASE = 0x70 # слоты палитры Sprinter под Kid
|
||||
SWORD_PAL_BASE = 0x80 # chtab_0 (меч в руке) -> слоты 0x80..0x8F
|
||||
GUARD_PAL_BASE = 0x90 # chtab_5 (страж) -> слоты 0x90..0x9F
|
||||
# chtab_0 целиком: sword_tbl (seg006:2047) адресует id 0..33 — подъём меча,
|
||||
# ножны И вся боёвка. Меч в оригинале ОДИН на всех: и Кид, и страж рисуют
|
||||
# клинок из этого же chtab_0 (add_sword_to_objtable, seg006:1798).
|
||||
SWORD_IDS = tuple(range(0, 34))
|
||||
SHIFT = 3 # kid[img>>3], idx img&7 (Kid-кадры крупные: 8/стр ≤16КБ)
|
||||
MASK = (1 << SHIFT) - 1
|
||||
|
||||
|
||||
def transpose_cols(w, h, rowmajor):
|
||||
"""row-major (pix[row*w+col]) -> column-major (out[col*h+row]).
|
||||
Спрайты Kid хранятся КОЛОНКАМИ: gfx_blit_cols рисует вертикальным accel-
|
||||
проходом (даёт бесплатный горизонтальный флип реверсом порядка колонок;
|
||||
см. memory/accel_vertical_copy). Заголовок getimage (w,h) не меняется."""
|
||||
out = bytearray(w * h)
|
||||
for col in range(w):
|
||||
base = col * h
|
||||
for row in range(h):
|
||||
out[base + row] = rowmajor[row * w + col]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def vflip_cols(w, h, colmajor):
|
||||
"""COLUMN-MAJOR (out[col*h + row]) -> тот же формат, но кадр ЗЕРКАЛЕН ПО
|
||||
ВЕРТИКАЛИ: внутри каждой колонки байты идут снизу вверх.
|
||||
|
||||
Зачем отдельный набор. Спрайты персонажей лежат колонками ради
|
||||
БЕСПЛАТНОГО горизонтального флипа (accel копирует колонки, направление
|
||||
задаётся знаком страйда). Вертикальное зеркало у такой раскладки — это
|
||||
разворот байтов ВНУТРИ колонки, а accel копирует блок только вперёд:
|
||||
в рантайме это стоит ~0.6 с на страницу (замер MAME 2026-08-12), тогда
|
||||
как ГОТОВАЯ страница читается с диска за 0.045 с. Поэтому зелье
|
||||
инверсии (уровень 9) обслуживается вторым набором файлов *_v.atl.
|
||||
Раскладка каталога у него та же — движку достаточно подменить номер
|
||||
EMM-страницы (см. src/pop_vflip.h)."""
|
||||
out = bytearray(len(colmajor))
|
||||
for col in range(w):
|
||||
b = col * h
|
||||
out[b:b + h] = colmajor[b:b + h][::-1]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def vflip_page(page):
|
||||
"""{idx: (w,h,pix)} -> то же с зеркальными по вертикали пикселями."""
|
||||
return {i: (w, h, vflip_cols(w, h, pix)) for i, (w, h, pix) in page.items()}
|
||||
|
||||
|
||||
def load_kid(aidx):
|
||||
"""Атлас-индекс aidx (== frame.image) -> res(401+aidx).png -> (w, h, 8bpp
|
||||
COLUMN-MAJOR). 0->0xFF, i->0x70+i. Пиксели транспонированы в колонки.
|
||||
|
||||
МАППИНГ (seg008.c:1589): персонаж рисуется add_midtable(chtab, obj_id+1),
|
||||
внутри get_image(chtab, id-1)=get_image(chtab, obj_id) -> chtab-индекс =
|
||||
frame.image; первая картинка chtab_2_kid = res401 (res400 = палитра).
|
||||
Значит frame.image=N -> res(401+N). Раньше грузили res(400+N) — off-by-one:
|
||||
стойка frame_15 (image 14) рисовалась res414 (беговой наклон) вместо res415
|
||||
(прямая стойка)."""
|
||||
path = os.path.join(KID_DIR, f"res{401 + aidx}.png")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
B.die(f"res{401+aidx}: mode {im.mode}, ожидался P")
|
||||
src = im.tobytes()
|
||||
rowmajor = bytes((0xFF if v == 0 else PAL_BASE + v) for v in src)
|
||||
return im.width, im.height, transpose_cols(im.width, im.height, rowmajor)
|
||||
|
||||
|
||||
def load_sword(id):
|
||||
"""chtab_0 (меч в руке): sword_tbl[].id = N -> res(700+N+1).png в data/PRINCE
|
||||
(в оригинале add_midtable(chtab, obj_id+1) -> get_image(chtab, obj_id)).
|
||||
Пиксели, как у Kid, COLUMN-MAJOR (gfx_blit_cols + бесплатный флип)."""
|
||||
path = os.path.join(os.path.dirname(B.R.VDUNGEON_DIR), B.POT_DIR_NAME,
|
||||
f"res{700 + id + 1}.png")
|
||||
if not os.path.exists(path):
|
||||
B.die(f"нет файла {path}")
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
B.die(f"res{700+id+1}: mode {im.mode}, ожидался P")
|
||||
src = im.tobytes()
|
||||
rowmajor = bytes((0xFF if v == 0 else SWORD_PAL_BASE + v) for v in src)
|
||||
return im.width, im.height, transpose_cols(im.width, im.height, rowmajor)
|
||||
|
||||
|
||||
def _read16(path):
|
||||
b = open(path, "rb").read()
|
||||
return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)]
|
||||
|
||||
|
||||
def build_palette(out_path):
|
||||
"""ЕДИНАЯ игровая палитра (gfx_pal_fload пишет с индекса 0, перезатирая
|
||||
всё — bg и Kid обязаны жить в одном файле): chtab_1@0x40 (пламя/зелья),
|
||||
env@0x50, wall@0x60, kid@0x70.
|
||||
"""
|
||||
pal = bytearray(256 * 4)
|
||||
groups = [
|
||||
(0x50, os.path.join(B.R.VDUNGEON_DIR, "res200.pal")), # env
|
||||
(0x60, os.path.join(B.R.VDUNGEON_DIR, "res360.pal")), # wall
|
||||
(PAL_BASE, os.path.join(KID_DIR, "res400.pal")), # kid 0x70
|
||||
(B.POT_PAL_BASE, os.path.join(os.path.dirname(B.R.VDUNGEON_DIR),
|
||||
B.POT_DIR_NAME, "res150.pal")), # chtab_1 0x40
|
||||
(SWORD_PAL_BASE, os.path.join(os.path.dirname(B.R.VDUNGEON_DIR),
|
||||
B.POT_DIR_NAME, "res700.pal")), # chtab_0 0x80
|
||||
]
|
||||
# 16 базовых цветов экрана (mono-блит красит ими: пламя, пузырьки зелья)
|
||||
for i, (r, g, bl) in enumerate(B.VGA16):
|
||||
o = (B.VGA16_PAL_BASE + i) * 4
|
||||
pal[o:o + 4] = bytes((B.scale6to8(bl), B.scale6to8(g), B.scale6to8(r), 0))
|
||||
for base, path in groups:
|
||||
for i, (r, g, bl) in enumerate(_read16(path)):
|
||||
o = (base + i) * 4
|
||||
pal[o:o + 4] = bytes((B.scale6to8(bl), B.scale6to8(g), B.scale6to8(r), 0))
|
||||
# Палитра стража берётся НЕ из .pal, а из res10.bin (guard_palettes:
|
||||
# 7 палитр по 16 цветов, 6-бит) по level.guards_color; на уровне 1 у
|
||||
# обоих стражей color = 2. См. toolchain/pop_pack_guard.py.
|
||||
import pop_pack_guard as G
|
||||
for i, (r, g, bl) in enumerate(G.guard_palette()):
|
||||
o = (GUARD_PAL_BASE + i) * 4
|
||||
pal[o:o + 4] = bytes((B.scale6to8(bl), B.scale6to8(g), B.scale6to8(r), 0))
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(pal)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
# атлас-индексы aidx (== frame.image), для которых есть res(401+aidx).png
|
||||
ids = []
|
||||
for aidx in range(0, 255):
|
||||
if os.path.exists(os.path.join(KID_DIR, f"res{401 + aidx}.png")):
|
||||
ids.append(aidx)
|
||||
if not ids:
|
||||
B.die("нет спрайтов KID")
|
||||
|
||||
pages = {}
|
||||
for id in ids:
|
||||
w, h, pix = load_kid(id)
|
||||
pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, pix)
|
||||
|
||||
max_pg = max(pages)
|
||||
names = []
|
||||
total = 0
|
||||
for pg in range(max_pg + 1):
|
||||
if pg in pages:
|
||||
nm = f"kid{pg}.atl"
|
||||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages[pg])
|
||||
total += sz
|
||||
names.append(nm)
|
||||
print(f" {nm}: {len(pages[pg])} спрайтов, каталог {cnt}, {sz} Б")
|
||||
# Зеркальный по вертикали набор (зелье инверсии, уровень 9).
|
||||
nmv = f"kid{pg}_v.atl"
|
||||
_, szv = B.pack_atlas(os.path.join(OUT_DIR, nmv), vflip_page(pages[pg]))
|
||||
total += szv
|
||||
else:
|
||||
names.append(None)
|
||||
print(f" (kid стр {pg}: пусто)")
|
||||
|
||||
# chtab_0: меч в руке — весь chtab (id 0..33), индекс в атласе = id.
|
||||
sw = {}
|
||||
for i, id in enumerate(SWORD_IDS):
|
||||
w, h, pix = load_sword(id)
|
||||
sw[i] = (w, h, pix)
|
||||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, "sword.atl"), sw)
|
||||
total += sz
|
||||
_, szv = B.pack_atlas(os.path.join(OUT_DIR, "sword_v.atl"), vflip_page(sw))
|
||||
total += szv
|
||||
print(f" sword.atl (chtab_0, id {SWORD_IDS[0]}..{SWORD_IDS[-1]}): "
|
||||
f"{len(sw)} спрайтов, каталог {cnt}, {sz} Б")
|
||||
|
||||
build_palette(os.path.join(OUT_DIR, "kid.pal"))
|
||||
|
||||
# C-заголовок
|
||||
hdr = str(P.GEN / "kid_atlas.h")
|
||||
with open(hdr, "w") as f:
|
||||
f.write("/* kid_atlas.h — раскладка атласов Kid. Сгенерировано "
|
||||
"pop_pack_kid.py. */\n#ifndef KID_ATLAS_H\n#define KID_ATLAS_H\n")
|
||||
f.write(f"#define KID_SHIFT {SHIFT}\n#define KID_MASK {MASK}\n")
|
||||
f.write(f"#define KID_PAGES {len(names)}\n#define KID_PAL 0x{PAL_BASE:02X}\n")
|
||||
f.write("static const char *const kid_atl[KID_PAGES] = {\n")
|
||||
for nm in names:
|
||||
f.write(" %s,\n" % ('"%s"' % nm if nm else "0"))
|
||||
f.write('};\n#define KID_PAL_FILE "kid.pal"\n')
|
||||
f.write('#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */\n')
|
||||
f.write(f"#define KID_SWORD_ID0 {SWORD_IDS[0]} /* индекс в атласе = sword_tbl.id - ID0 */\n")
|
||||
f.write("#endif\n")
|
||||
|
||||
print(f"ИТОГО: {len(ids)} спрайтов Kid, {len(names)} EMM-страниц, "
|
||||
f"{total} Б диск (+1КБ палитра) -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user