31b82661eb
Порт 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>
354 lines
17 KiB
Python
354 lines
17 KiB
Python
#!/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()
|