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:
2026-08-27 12:12:28 +03:00
parent 4b74478d19
commit 31b82661eb
235 changed files with 51293 additions and 10 deletions
+353
View File
@@ -0,0 +1,353 @@
#!/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()