Files
Sprinter-SDCC/applications/SprPoP/tools/pop_pack_intro.py
T
snark13 31b82661eb 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>
2026-08-27 12:12:28 +03:00

354 lines
17 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
"""Упаковка story/PV intro SDLPoP в полосы SPA1 для Sprinter.
Исходники остаются единственным источником истины: TITLE/res41..45, res51 и
PV/res800/850/950/980. Runtime получает только готовые 320x200 композиции
и 256-цветные палитры. Это намеренно переносит смешивание палитров Princess,
Jaffar, спальни и кровати из ограниченного Z80 в build-time.
"""
from pathlib import Path
from PIL import Image
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)
ROOT = Path(__file__).resolve().parent.parent
TITLE = P.SDLPOP_DATA / "TITLE"
PV = P.SDLPOP_DATA / "PV"
PRINCE = P.SDLPOP_DATA / "PRINCE"
KID = P.SDLPOP_DATA / "KID"
OUT = P.atl("pv")
ATL_MAGIC = b"SPA1"
ATL_DIR_OFF = 0x68
ATL_MIN_SIZE = 0x100
PAGE_SIZE = 16384
STRIP_H = 49
# ФОН ТЕКСТОВОЙ РАМКИ — ОТДЕЛЬНЫЙ ИНДЕКС. У оригинала это индекс 14 в
# строке палитры спрайтов title40, и load_title_images() красит его либо в
# #100060 (интро), либо в #800000 (финал, seg001:586). Раньше мы
# ремапили 14 в 9 — «тёмно-синий» палитры res51; подменить его нельзя,
# потому что тем же индексом нарисована сама титульная картинка (8265
# пикселей). Поэтому фон уезжает в свободный индекс, и финал красит
# только его (pop_pal_story_ending).
STORY_BG_INDEX = 16
STORY_BG_COLOR = (0x10, 0x00, 0x60)
# Цвет текста таблицы рекордов: 0xB7 оригинала = седьмой цвет палитры
# title (224,160,0). Глифы шрифта запечены индексом POP_FONT_COLOR, так
# что цвет им даёт именно эта запись; тёмный набор (POP_FONT_DARK_COLOR)
# остаётся чёрным — старшие записи story.pal и так чёрные.
STORY_FONT_INDEX = 0x3F
def transpose_cols(width: int, height: int, rowmajor: bytes) -> bytes:
"""Row-major -> column-major для бесплатного horizontal flip в BGI."""
out = bytearray(width * height)
for column in range(width):
base = column * height
for row in range(height):
out[base + row] = rowmajor[row * width + column]
return bytes(out)
def atlas_blob(pixels: bytes, width: int, height: int) -> bytes:
"""Один непрозрачный getimage в минимальном SPA1-атласе."""
data_off = ATL_DIR_OFF + 8
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = 1
out[ATL_DIR_OFF:ATL_DIR_OFF + 2] = data_off.to_bytes(2, "little")
out[ATL_DIR_OFF + 2] = min(width, 255)
out[ATL_DIR_OFF + 3] = height
out[ATL_DIR_OFF + 4] = out[ATL_DIR_OFF + 5] = 1
out += width.to_bytes(2, "little") + height.to_bytes(2, "little")
out += pixels
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"intro atlas {len(out)} B > {PAGE_SIZE} B")
return bytes(out)
def sprite_atlas(path: Path, entries: dict[int, tuple[int, int, bytes]]) -> None:
"""Атлас небольших прозрачных кадров, целиком живущий в одной EMM.
В отличие от atlas_blob() для полноэкранной полосы, здесь нужен каталог
нескольких картинок: runtime держит страницу открытой всю PV-сцену и
переключает только idx. Пустые индексы не требуются — все наборы ниже
нумеруются подряд с нуля.
"""
count = len(entries)
if not count or count > 255:
raise SystemExit(f"{path}: bad sprite atlas count {count}")
data_off = ATL_DIR_OFF + count * 8
directory = bytearray(count * 8)
blobs = bytearray()
for idx in range(count):
width, height, pixels = entries[idx]
offset = data_off + len(blobs)
blobs += width.to_bytes(2, "little") + height.to_bytes(2, "little")
blobs += pixels
d = idx * 8
directory[d:d + 2] = offset.to_bytes(2, "little")
directory[d + 2] = width
directory[d + 3] = height
directory[d + 4] = directory[d + 5] = 1
out = bytearray(data_off)
out[:4] = ATL_MAGIC
out[4] = count
out[ATL_DIR_OFF:ATL_DIR_OFF + len(directory)] = directory
out += blobs
if len(out) < ATL_MIN_SIZE:
out += bytes(ATL_MIN_SIZE - len(out))
if len(out) > PAGE_SIZE:
raise SystemExit(f"{path}: {len(out)} B > one EMM page")
path.write_bytes(out)
def sprite_frames(directory: Path, first: int, last: int,
color_base: int, column_major: bool = False
) -> dict[int, tuple[int, int, bytes]]:
"""PNG-кадры -> getimage. Ноль PNG становится прозрачным 0xFF BGI."""
result = {}
for index, number in enumerate(range(first, last + 1)):
image = png(directory, number)
pixels = bytes(0xFF if pixel == 0 else color_base + pixel
for pixel in image.tobytes())
if column_major:
pixels = transpose_cols(image.width, image.height, pixels)
result[index] = image.width, image.height, pixels
return result
def png(directory: Path, number: int) -> Image.Image:
image = Image.open(directory / f"res{number}.png")
if image.mode != "P":
raise SystemExit(f"res{number}.png: expected indexed PNG, got {image.mode}")
return image.copy()
def pal_file(path: Path) -> list[tuple[int, int, int]]:
"""16 цветов из .pal ресурса (4 байта заголовка, дальше RGB по 6 бит)."""
raw = path.read_bytes()
return [(raw[4 + i * 3] << 2, raw[5 + i * 3] << 2, raw[6 + i * 3] << 2)
for i in range(16)]
def palette(image: Image.Image) -> list[tuple[int, int, int]]:
raw = image.getpalette()
return [tuple(raw[index * 3:index * 3 + 3]) for index in range(16)]
def write_palette(path: Path, entries: list[tuple[int, int, int]],
extra=None) -> None:
"""B,G,R,0 — ровно формат gfx_pal_fload; незадействованное чёрное."""
out = bytearray()
entries = (entries + [(0, 0, 0)] * 256)[:256]
for index, color in (extra or {}).items():
entries[index] = color
for red, green, blue in entries:
out += bytes((blue, green, red, 0))
path.write_bytes(out)
def write_scene(prefix: str, number: int, canvas: Image.Image) -> None:
if canvas.size != (320, 200):
raise SystemExit(f"{prefix}{number}: expected 320x200, got {canvas.size}")
for part, top in enumerate(range(0, 200, STRIP_H)):
band = canvas.crop((0, top, 320, min(top + STRIP_H, 200)))
(OUT / f"{prefix}{number}_{part}.atl").write_bytes(
atlas_blob(band.tobytes(), band.width, band.height))
def story_scenes() -> list[Image.Image]:
"""show_title/end_sequence в единой с title палитре.
res41 кодирует тёмный фон индексом 14, а его палитра делает этот индекс
чёрным; show_title() SDLPoP подменяет его #100060 либо #800000. Тексты
res42..45 имеют индекс 1, но этот индекс у res41 тоже тёмный; ремап на
индекс title/res51 15 (белый) позволяет проявлять историю прямо поверх
последнего title-экрана без смены палитры. Фон уезжает в
STORY_BG_INDEX, чтобы финал мог покрасить его отдельно.
Последний экран — фон таблицы рекордов: та же рамка плюс логотип
PRINCE OF PERSIA на y=24 (HOF_POP оригинала — тот же спрайт res54, что
и в титрах, только выше).
"""
frame_raw = bytearray(png(TITLE, 41).tobytes())
for offset, pixel in enumerate(frame_raw):
if pixel == 14:
frame_raw[offset] = STORY_BG_INDEX
frame = Image.frombytes("P", (320, 200), bytes(frame_raw))
result = []
for text_number, ypos in ((42, 25), (43, 25), (45, 26), (44, 25)):
canvas = frame.copy()
raw = bytearray(canvas.tobytes())
text = png(TITLE, text_number)
for sy in range(text.height):
for sx in range(text.width):
if text.getpixel((sx, sy)):
raw[(ypos + sy) * 320 + 24 + sx] = 15
canvas.frombytes(bytes(raw))
result.append(canvas)
result.append(frame)
hof = frame.copy()
raw = bytearray(hof.tobytes())
logo = png(TITLE, 54)
for sy in range(logo.height):
for sx in range(logo.width):
pixel = logo.getpixel((sx, sy))
if pixel: # blitters_10h_transp: 0 сквозной
raw[(24 + sy) * 320 + 24 + sx] = pixel
hof.frombytes(bytes(raw))
result.append(hof)
return result
def pv_palette_canvas() -> tuple[Image.Image, list[tuple[int, int, int]]]:
"""Спальня + bed. Номера 0..15=room, 16..31=Princess,
32..47=Jaffar, 48..63=bed. Благодаря этому у смешанных PNG не теряются
исходные цвета при одном gfx_pal_fload() на весь PV."""
room = png(PV, 951)
bed = png(PV, 981)
raw = bytearray(room.tobytes())
bed_raw = bed.tobytes()
for sy in range(bed.height):
for sx in range(bed.width):
pixel = bed_raw[sy * bed.width + sx]
if pixel:
raw[(142 + sy) * 320 + sx] = 48 + pixel
canvas = Image.frombytes("P", room.size, bytes(raw))
# 80..95 — пламя факелов. Это отдельный chtab PRINCE в оригинале; без
# отдельной строки цвета динамические кадры становились бы цветами стены.
# 112..127 (0x70) — палитра КИДА: на PV-экране финала и сцен 8/9 он и
# мышь рисуются СВОИМИ атласами (chtab_2), а их индексы запечены под
# игровые слоты 0x70. Без этой строки они выходили бы чёрными.
return (canvas, palette(room) + palette(png(PV, 801)) +
palette(png(PV, 851)) + palette(bed) + palette(png(PV, 901)) +
palette(png(PRINCE, 151)) + [(0, 0, 0)] * 16 +
pal_file(KID / "res400.pal"))
def sprite_paste(dst: Image.Image, sprite: Image.Image, x: int, floor: int,
color_base: int) -> None:
"""Положить спрайт ногами на floor, remap его палитру в отдельную строку."""
raw = bytearray(dst.tobytes())
src = sprite.tobytes()
y = floor - sprite.height
for sy in range(sprite.height):
dy = y + sy
if dy < 0 or dy >= 200:
continue
for sx in range(sprite.width):
dx = x + sx
pixel = src[sy * sprite.width + sx]
if pixel and 0 <= dx < 320:
raw[dy * 320 + dx] = color_base + pixel
dst.frombytes(bytes(raw))
def pv_scenes() -> tuple[list[Image.Image], list[tuple[int, int, int]]]:
base, colors = pv_palette_canvas()
# Позиции Char из init_princess()/init_vizier(): x=120/198, y=166.
# В оригинале точный offset задаёт frame table; статические композиции
# выравнивают персонажей по полу y=166, не хранят таблицы PV-анимации.
waiting = base.copy()
sprite_paste(waiting, png(PV, 801), 110, 166, 16)
jaffar = waiting.copy()
sprite_paste(jaffar, png(PV, 851), 188, 166, 32)
magic = base.copy()
sprite_paste(magic, png(PV, 805), 110, 166, 16)
sprite_paste(magic, png(PV, 870), 188, 166, 32)
# res953 — первый полный кадр песочных часов; он использует палитру room.
sprite_paste(magic, png(PV, 953), 18, 166, 0)
alone = base.copy()
sprite_paste(alone, png(PV, 816), 110, 166, 16)
sprite_paste(alone, png(PV, 959), 18, 166, 0)
# FG9: статические ключевые позы тех же scene 2/4/6/8/9/12. PV2
# использует второй набор Princess (res900), поэтому он лежит в строке
# палитры 64..79. Мышь в оригинале меняет только короткую позу рядом с
# Princess; здесь соответствующая стадия выделена отдельным кадром.
stand = base.copy()
sprite_paste(stand, png(PV, 801), 134, 166, 16)
lying = base.copy()
sprite_paste(lying, png(PV, 901), 78, 162, 64)
# Часы для cutscene_4 выбираются runtime по оставшимся минутам и
# анимируются вместе с песком; в статической основе их быть не должно.
mouse = base.copy()
sprite_paste(mouse, png(PV, 920), 120, 169, 64)
sprite_paste(mouse, png(PV, 959), 18, 166, 0)
short_time = base.copy()
sprite_paste(short_time, png(PV, 816), 146, 166, 16)
sprite_paste(short_time, png(PV, 953), 18, 166, 0)
return [waiting, jaffar, magic, alone, stand, lying, mouse, short_time], colors
def pv_animation_assets() -> None:
"""Ресурсы покадрового intro.
`b0_*` — единственный чистый фон. Он копируется accelerator-ом между
обеими экранными страницами; спрайты рисуются банком без shadow, так что
следующий кадр автоматически начинает с фона. Каждый actor-атлас <=16K,
поэтому нет чтения 320x200 с HDD в анимационном цикле.
"""
base, _ = pv_palette_canvas()
write_scene("b", 0, base)
# frame_tbl_cuts из SDLPoP адресует Princess как image 0..16 ресурса
# 800 (то есть PNG res801..817). Для Jaffar PV1 адресуется ресурс 850:
# image 0..37 = res851..888. Все 38 кадров не помещаются в одну EMM,
# поэтому сохраняем их тремя последовательными атласами. Runtime знает
# границы, но номер кадра остаётся исходным image-id 0..37.
sprite_atlas(OUT / "a0.atl", sprite_frames(PV, 801, 817, 16, True))
sprite_atlas(OUT / "j0.atl", sprite_frames(PV, 851, 863, 32, True))
sprite_atlas(OUT / "j1.atl", sprite_frames(PV, 864, 874, 32, True))
sprite_atlas(OUT / "j2.atl", sprite_frames(PV, 875, 888, 32, True))
# ВТОРОЙ набор Princess (PV2, res901..930 = image 0..29 ресурса 900).
# Он нужен сценам 8/9 (сидит и гладит мышь, встаёт, приседает) и финалу
# (поворачивается и обнимает Кида) — поз PV1 для этого не хватает.
# 26.7 КБ не влезают в одну EMM, поэтому два атласа; номер кадра при
# этом остаётся исходным image-id 0..29, границу знает runtime.
# Палитровая строка 64..79 — та же, что у PV2 в статических сценах.
sprite_atlas(OUT / "a1.atl", sprite_frames(PV, 901, 917, 64, True))
sprite_atlas(OUT / "a2.atl", sprite_frames(PV, 918, 930, 64, True))
# res953/res954 — состояния часов, res960..962 — три фазы песка.
sprite_atlas(OUT / "h0.atl", sprite_frames(PV, 952, 962, 0))
sprite_atlas(OUT / "t0.atl", sprite_frames(PRINCE, 151, 159, 80))
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
stories = story_scenes()
write_palette(OUT / "story.pal", palette(png(TITLE, 51)),
{STORY_BG_INDEX: STORY_BG_COLOR,
STORY_FONT_INDEX: (224, 160, 0)})
for number, scene in enumerate(stories):
write_scene("s", number, scene)
scenes, colors = pv_scenes()
write_palette(OUT / "pv.pal", colors)
for number, scene in enumerate(scenes):
write_scene("p", number, scene)
pv_animation_assets()
print(f"{OUT}: {len(stories)} story + 8 PV compositions + base x 5 strips, "
f"6 animated actor atlases, one strip/page <= {STRIP_H} rows")
if __name__ == "__main__":
main()