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
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
pop_extract_kid_data.py — извлекает данные анимации Kid из исходников
SDLPoP в C-заголовок gen/kid_data.h (Фаза K1).
Извлекает:
frame_table_kid[] (seg006.c) -> kframe kid_frames[] {image,dx,dy,flags}
original_seqtbl[] (seqtbl.c) -> byte kid_seqtbl[] (2310 Б, база 0x196E)
original_seqtbl_offsets[] (seqtbl.c) -> word kid_seq_off[] (seq_id -> адрес)
seqtbl адресуется базой SEQTBL_BASE=0x196E: элемент = kid_seqtbl[addr-0x196E].
Автогенерация — чтобы не переписывать ~180 кадров + 2КБ байткода руками.
"""
import os
import re
import sys as _sys
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
SRC = str(P.SDLPOP_SRC)
OUT_BIN_DIR = str(P.atl("kid"))
OUT = str(P.GEN / "kid_data.h")
SEQTBL_BASE = 0x196E
def read(name):
return open(os.path.join(SRC, name)).read()
def parse_frame_table(txt, name="frame_table_kid"):
"""frame_table_kid[]/frame_tbl_guard[] -> список (image, dx, dy, flags).
Записи вида { IMG, 0xC0| 4, DX, DY, 0x40| 4 } — sword/flags как
OR-выражения."""
m = re.search(r'const frame_type ' + name + r'\[\]\s*=\s*\{(.*?)\n\};', txt, re.S)
body = m.group(1)
frames = []
for row in re.findall(r'\{([^}]*)\}', body):
parts = [p.strip() for p in row.split(',')]
image = int(parts[0], 0)
sword = eval(parts[1], {}, {}) # напр. "0x00| 9"
dx = int(parts[2], 0)
dy = int(parts[3], 0)
flags = eval(parts[4], {}, {})
frames.append((image & 0xFF, dx & 0xFF, dy & 0xFF, flags & 0xFF, sword & 0xFF))
return frames
def parse_sword_tbl(txt):
"""sword_tbl[] (seg006:2047) -> список (id, x, y). id 255 = клинка нет.
Индекс — sword_frame (cur_frame.sword & 0x3F); один и тот же chtab_0 и у
Кида, и у стража."""
m = re.search(r'const sword_table_type sword_tbl\[\]\s*=\s*\{(.*?)\n\};', txt, re.S)
rows = []
for row in re.findall(r'\{([^}]*)\}', m.group(1)):
a, b, c = [int(p.strip(), 0) for p in row.split(',')]
rows.append((a & 0xFF, b & 0xFF, c & 0xFF))
return rows
def parse_byte_array(txt, name):
m = re.search(r'const byte %s\[\]\s*=\s*\{(.*?)\}' % name, txt, re.S)
return [int(x, 0) for x in re.findall(r'0x[0-9A-Fa-f]+|\d+', m.group(1))]
def parse_word_array(txt, name):
m = re.search(r'const word %s\[\]\s*=\s*\{(.*?)\}' % name, txt, re.S)
return [int(x, 0) for x in re.findall(r'0x[0-9A-Fa-f]+|\d+', m.group(1))]
def emit_c(vals, per_line=12, fmt="0x%02X"):
out = []
for i in range(0, len(vals), per_line):
out.append(" " + ",".join(fmt % v for v in vals[i:i + per_line]) + ",")
return "\n".join(out)
def main():
seg006 = read("seg006.c")
seqc = read("seqtbl.c")
frames = parse_frame_table(seg006)
gframes = parse_frame_table(seg006, "frame_tbl_guard")
# Кадры КАТСЦЕН (frame_tbl_cuts, seg006:417) — Princess и Vizier. Нужны
# сценам 8/9 и финалу: там персонажи ходят теми же последовательностями
# seqtbl, что и в игре, но кадры адресуют свои chtab (sword>>6: 1 = набор
# PV1 ресурса 800, 2 = PV2 ресурса 900).
cframes = parse_frame_table(seg006, "frame_tbl_cuts")
seqtbl = parse_byte_array(seqc, "original_seqtbl")
offs = parse_word_array(seqc, "original_seqtbl_offsets")
swtbl = parse_sword_tbl(seg006)
with open(OUT, "w") as f:
f.write("/* kid_data.h — данные анимации Kid (frame_table + seqtbl +\n"
" * offsets), извлечено toolchain/pop_extract_kid_data.py из\n"
" * SDLPoP. НЕ править вручную. seqtbl адресуется базой\n"
" * SEQTBL_BASE: kid_seqtbl[addr - SEQTBL_BASE]. */\n")
f.write("#ifndef KID_DATA_H\n#define KID_DATA_H\n#include <stdint.h>\n\n")
f.write("#define SEQTBL_BASE 0x%04Xu\n\n" % SEQTBL_BASE)
# frame table и seqtbl В .h НЕ ПОПАДАЮТ — они уезжают в kid.ani
# (EMM-страница): 3.5 КБ в _CODE окна W1/W2 нам дороже, чем один
# gfx_w0_map за тик. См. applications/PoP/docs/layout_plan_v2.md §5.1.
f.write("/* {image, dx, dy, flags, sword}; chtab = sword>>6 (0=kid).\n"
" * САМА таблица кадров и seqtbl лежат в kid.ani (EMM-\n"
" * страница); здесь только тип и размеры. */\n")
f.write("typedef struct { uint8_t image; int8_t dx, dy; "
"uint8_t flags, sword; } kframe;\n")
f.write("#define KID_NFRAMES %d\n" % len(frames))
f.write("#define KID_SEQTBL_LEN %d\n\n" % len(seqtbl))
# раскладка kid.ani (смещения ОТ НАЧАЛА ДАННЫХ страницы)
f.write("/* Раскладка kid.ani: кадры (KID_NFRAMES*5), затем seqtbl. */\n")
f.write("#define KID_BIN_FRAMES_OFF 0\n")
f.write("#define KID_BIN_SEQTBL_OFF %d\n" % (len(frames) * 5))
f.write("/* Таблица кадров СТРАЖА (frame_tbl_guard, seg006:372): индекс\n"
" * frame + add_frame - 149 (add_frame=70 для кадров 102..106). */\n")
f.write("#define KID_NGFRAMES %d\n" % len(gframes))
f.write("#define KID_BIN_GFRAMES_OFF %d\n" % (len(frames) * 5 + len(seqtbl)))
f.write("/* Таблица кадров КАТСЦЕН (frame_tbl_cuts, seg006:417):\n"
" * Princess/Vizier, индекс — номер кадра как есть. Набор\n"
" * спрайтов выбирает sword>>6 (1 = PV1 res800, 2 = PV2 res900). */\n")
f.write("#define KID_NCFRAMES %d\n" % len(cframes))
f.write("#define KID_BIN_CFRAMES_OFF %d\n\n"
% (len(frames) * 5 + len(seqtbl) + len(gframes) * 5))
# seq offsets
# kid_seq_off — МАКРО-инициализатором, не готовым массивом: заголовок
# включают почти все модули PoP, и `static const` размножал таблицу по
# каждому TU мёртвой копией (замер 2026-08-08: 230 Б × 9 модулей =
# 1840 Б впустую, в т.ч. 460 Б резидента W1/W2). Сам массив
# определяет ОДИН pop_kid.c — та же причина, что у sword_tbl ниже.
f.write("#define KID_NSEQ %d\n" % len(offs))
f.write("#define KID_SEQ_OFF_INIT { \\\n")
f.write(emit_c(offs, 8, "0x%04X").replace("\n", " \\\n") + " \\\n}\n\n")
# sword_tbl — макро-инициализаторами, а не готовыми массивами: так
# 159 байт таблицы лягут в ОДИН TU (pop_kid.c), а не в каждый, кто
# включил заголовок.
f.write("/* sword_tbl (seg006:2047): клинок отдельным спрайтом chtab_0.\n"
" * Индекс — sword_frame (кадр.sword & 0x3F), id 255 = клинка нет.\n"
" * Смещения x/y — ЭКРАННЫЕ (применяются после calc_screen_x_coord),\n"
" * x — «вперёд» по направлению персонажа. */\n")
f.write("#define KID_NSWORD %d\n" % len(swtbl))
for i, (nm, fmt) in enumerate((("ID", "0x%02X"), ("DX", "%d"), ("DY", "%d"))):
# dx/dy — знаковые: печатаем со знаком, иначе int8_t = overflow
vals = [r[i] if i == 0 else (r[i] - 256 if r[i] > 127 else r[i])
for r in swtbl]
f.write("#define KID_SWORD_%s_INIT { \\\n" % nm)
f.write(emit_c(vals, 12, fmt).replace("\n", " \\\n") + " \\\n}\n")
f.write("\n#endif\n")
# kid.ani: кадры (по 5 байт) + seqtbl подряд
os.makedirs(OUT_BIN_DIR, exist_ok=True)
binpath = os.path.join(OUT_BIN_DIR, "kid.ani")
with open(binpath, "wb") as b:
for (img, dx, dy, fl, sw) in frames:
b.write(bytes((img & 0xFF, dx & 0xFF, dy & 0xFF, fl & 0xFF, sw & 0xFF)))
b.write(bytes(seqtbl))
for (img, dx, dy, fl, sw) in gframes:
b.write(bytes((img & 0xFF, dx & 0xFF, dy & 0xFF, fl & 0xFF, sw & 0xFF)))
for (img, dx, dy, fl, sw) in cframes:
b.write(bytes((img & 0xFF, dx & 0xFF, dy & 0xFF, fl & 0xFF, sw & 0xFF)))
print(f"kid.ani: {len(frames)*5 + len(seqtbl) + len(gframes)*5 + len(cframes)*5} Б "
f"(кадры Kid + seqtbl + кадры стража + кадры катсцен) -> {binpath}")
print(f"kid_data.h: {len(frames)} кадров, seqtbl {len(seqtbl)} Б, "
f"{len(offs)} seq-оффсетов -> {OUT}")
if __name__ == "__main__":
main()