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,72 @@
|
||||
"""paths.py — единственное место, где инструменты SprPoP знают раскладку
|
||||
каталогов приложения. Все упаковщики берут пути отсюда, поэтому папку
|
||||
SprPoP можно перенести куда угодно: важно лишь, что рядом с tools/ лежат
|
||||
assets/, gen/ и build/.
|
||||
|
||||
Раскладка:
|
||||
|
||||
assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, MSDOS, записи музыки).
|
||||
В репозиторий не идут — копирайт; откуда их взять,
|
||||
написано в assets/orig/README.md.
|
||||
build/atl/<set>/ промежуточный выхлоп упаковщиков: отдельные атласы,
|
||||
из которых потом склеиваются архивы. Пересоздаётся,
|
||||
в репозиторий не идёт.
|
||||
assets/packed/ ровно то, что ложится на диск игры, уже в раскладке
|
||||
каталогов диска (BG/, KID/, ...). В репозитории —
|
||||
иначе из чистого клона без assets/orig/ ничего не
|
||||
собрать.
|
||||
gen/ генерируемые C-заголовки. Тоже в репозитории и по
|
||||
той же причине: это такой же производный артефакт,
|
||||
как .arc, и без него не скомпилировать src/.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).resolve().parent
|
||||
APP = TOOLS.parent
|
||||
|
||||
# --- вход: оригинальные данные -------------------------------------------
|
||||
ORIG = APP / "assets" / "orig"
|
||||
SDLPOP = ORIG / "SDLPoP"
|
||||
SDLPOP_DATA = SDLPOP / "data"
|
||||
SDLPOP_SRC = SDLPOP / "src"
|
||||
MSDOS = ORIG / "MSDOS"
|
||||
MUSIC_ROOT = ORIG / "PoP1_DOS_music"
|
||||
# Записи DOS-версии лежат в четырёх вариантах. Имена треков во всех наборах
|
||||
# одинаковые, различаются каталог и расширение; ogg_MT-32 — та же музыка в
|
||||
# исполнении Roland MT-32, а не другой кодек. Ключ = имя формата в Makefile.
|
||||
MUSIC_FORMATS = {
|
||||
"flac": ("flac", ".flac"),
|
||||
"mp3": ("mp3", ".mp3"),
|
||||
"ogg": ("ogg", ".ogg"),
|
||||
"mt32": ("ogg_MT-32", ".ogg"),
|
||||
}
|
||||
|
||||
|
||||
def music_src(fmt="flac"):
|
||||
"""(каталог, расширение) исходников музыки для формата."""
|
||||
if fmt not in MUSIC_FORMATS:
|
||||
raise SystemExit(
|
||||
"неизвестный формат музыки %r; есть: %s"
|
||||
% (fmt, ", ".join(sorted(MUSIC_FORMATS))))
|
||||
sub, ext = MUSIC_FORMATS[fmt]
|
||||
return MUSIC_ROOT / sub, ext
|
||||
|
||||
# --- выход ----------------------------------------------------------------
|
||||
ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
|
||||
PACKED = APP / "assets" / "packed" # раскладка диска игры
|
||||
GEN = APP / "gen" # генерируемые заголовки
|
||||
|
||||
|
||||
def atl(name):
|
||||
"""Каталог промежуточного выхлопа набора; создаётся при обращении."""
|
||||
p = ATL / name
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def packed(name):
|
||||
"""Каталог диска игры (BG, KID, ...); создаётся при обращении."""
|
||||
p = PACKED / name
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_extract_font.py — SDLPoP-шрифты в один Sprinter .atl-ресурс.
|
||||
|
||||
Источник истины — оригинальные встроенные шрифты SDLPoP:
|
||||
* hc_small_font_data[] из menu.c — пункты меню;
|
||||
* hc_font_data[] из seg009.c — крупные сообщения/заголовки.
|
||||
|
||||
В исходнике глиф — IMAGE_DATA(height, width, flags) и 1-битные строки.
|
||||
Здесь его силуэт переводится в getimage (0xFF = прозрачность) и пакуется в
|
||||
FONT\\FONT.ATL. Это именно шрифтовой атлас, а не заранее нарисованные
|
||||
строки: номер ленты = код ASCII, поэтому runtime сохраняет произвольный
|
||||
текст и пропорциональную ширину оригинала.
|
||||
|
||||
Хвост страницы [0x3C00..0x3FFF] намеренно оставлен свободным: pop_menu.c
|
||||
кладёт туда снимок игровой палитры на время паузы. Тогда 1 КБ не занимает
|
||||
дефицитную W2.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
SRC_SMALL = P.SDLPOP_SRC / "menu.c"
|
||||
SRC_BIG = P.SDLPOP_SRC / "seg009.c"
|
||||
OUT_DIR = P.atl("font")
|
||||
OUT_ATL = OUT_DIR / "font.atl"
|
||||
OUT_H = P.GEN / "pop_font.h"
|
||||
|
||||
FIRST, LAST = 32, 126
|
||||
FONT_COLOR = 0x3F
|
||||
# ВТОРОЙ ЦВЕТ КРУПНОГО ШРИФТА. У SDLPoP шрифт — маска, и цвет задаётся
|
||||
# параметром рисования (show_hof_text рисует текст дважды: тень цветом 0 со
|
||||
# сдвигом (1,1) и сам текст цветом 0xB7). У нас цвет запечён в пиксели
|
||||
# глифа, поэтому второй цвет — это второй набор глифов. Он нужен таблице
|
||||
# рекордов: чёрная тень строк и тёмное имя на золотой полосе ввода.
|
||||
# Набор крупный, потому что draw_text() рисует hc_font (seg009:1594).
|
||||
FONT_DARK_COLOR = 0x3E
|
||||
# Каталог SPA1 хранит число картинок одним байтом, а три полных набора по
|
||||
# 95 глифов — это 285. Тёмному набору полный ASCII и не нужен: им пишется
|
||||
# только таблица рекордов (имена A..Z, время вида 59:43 и курсор ввода
|
||||
# '_'), поэтому он обрезан по '_'. 190 + 64 = 254 картинки — потолок.
|
||||
DARK_FIRST, DARK_LAST = 32, 95
|
||||
ATL_MAGIC = b"SPA1"
|
||||
ATL_DIR_OFF = 0x68
|
||||
SNAPSHOT_OFF = 0x3C00
|
||||
PAGE_SIZE = 16384
|
||||
|
||||
|
||||
def extract_array(path: Path, name: str) -> list[int]:
|
||||
text = path.read_text()
|
||||
match = re.search(rf"byte {name}\[\] = \{{(.*?)\n\}};", text, re.S)
|
||||
assert match, f"{name} not found in {path}"
|
||||
body = re.sub(r"//[^\n]*", "", match.group(1))
|
||||
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
|
||||
|
||||
def word(mm: re.Match) -> str:
|
||||
value = int(mm.group(1), 0)
|
||||
return f"{value & 0xFF}, {(value >> 8) & 0xFF}"
|
||||
|
||||
def bin4(mm: re.Match) -> str:
|
||||
b7, b6, b5, b4 = (int(v, 0) if v != "_" else 0 for v in mm.groups())
|
||||
return str((b4 << 4) | (b5 << 5) | (b6 << 6) | (b7 << 7))
|
||||
|
||||
def bin8(mm: re.Match) -> str:
|
||||
bits = [int(v, 0) if v != "_" else 0 for v in mm.groups()]
|
||||
return str(sum(bit << (7 - i) for i, bit in enumerate(bits)))
|
||||
|
||||
def image_data(mm: re.Match) -> str:
|
||||
h, w, flags = (int(v, 0) for v in mm.groups())
|
||||
return (f"{h & 0xFF}, {(h >> 8) & 0xFF}, "
|
||||
f"{w & 0xFF}, {(w >> 8) & 0xFF}, "
|
||||
f"{flags & 0xFF}, {(flags >> 8) & 0xFF}")
|
||||
|
||||
body = re.sub(r"\bWORD\s*\(\s*(\w+)\s*\)", word, body)
|
||||
body = re.sub(r"\bIMAGE_DATA\s*\(\s*(\w+)\s*,\s*(\w+)\s*,\s*(\w+)\s*\)",
|
||||
image_data, body)
|
||||
body = re.sub(r"\bBINARY_4\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin4, body)
|
||||
body = re.sub(r"\bBINARY_8\s*\(\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*"
|
||||
r"([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*,\s*([_\dxXa-fA-F]+)\s*\)", bin8, body)
|
||||
body = re.sub(r"\b_\b", "0", body)
|
||||
# Hex обязан идти первым: иначе regexp съедает в `0x20` только `0`.
|
||||
return [int(v, 0) for v in re.findall(r"0[xX][0-9a-fA-F]+|\d+", body)]
|
||||
|
||||
|
||||
def parse_glyphs(vals: list[int]) -> tuple[dict, list[dict]]:
|
||||
first, last = vals[0], vals[1]
|
||||
font = {
|
||||
"first": first,
|
||||
"last": last,
|
||||
"ascent": vals[2] | (vals[3] << 8),
|
||||
"below": vals[4] | (vals[5] << 8),
|
||||
"space_lines": vals[6] | (vals[7] << 8),
|
||||
"space_chars": vals[8] | (vals[9] << 8),
|
||||
}
|
||||
pos = 10 + (last - first + 1) * 2 # offsets у SDLPoP вычисляются в runtime
|
||||
glyphs = []
|
||||
while pos < len(vals):
|
||||
h = vals[pos] | (vals[pos + 1] << 8)
|
||||
w = vals[pos + 2] | (vals[pos + 3] << 8)
|
||||
pos += 6 # height, width, flags
|
||||
stride = (w + 7) // 8
|
||||
rows = []
|
||||
for _ in range(h):
|
||||
rows.append(vals[pos:pos + stride])
|
||||
pos += stride
|
||||
glyphs.append({"h": h, "w": w, "rows": rows})
|
||||
assert len(glyphs) == last - first + 1, f"glyph count {len(glyphs)}"
|
||||
return font, glyphs
|
||||
|
||||
|
||||
def glyph_pixels(glyph: dict, color: int) -> bytes:
|
||||
"""1 бит/пиксель SDLPoP -> 8bpp getimage: 1 = color."""
|
||||
out = bytearray()
|
||||
for row in glyph["rows"]:
|
||||
for x in range(glyph["w"]):
|
||||
byte = row[x // 8]
|
||||
on = byte & (1 << (7 - (x & 7)))
|
||||
out.append(color if on else 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def pack_atlas(fonts: list[tuple[dict, list[dict], int]]) -> int:
|
||||
entries = []
|
||||
for font, glyphs, color, first, last in fonts:
|
||||
assert font["first"] <= first <= last <= font["last"]
|
||||
entries.extend((glyph, color) for glyph in
|
||||
glyphs[first - font["first"]:last - font["first"] + 1])
|
||||
assert len(entries) <= 255
|
||||
|
||||
data_off = ATL_DIR_OFF + len(entries) * 8
|
||||
out = bytearray(data_off)
|
||||
out[0:4] = ATL_MAGIC
|
||||
out[4] = len(entries)
|
||||
for index, (glyph, color) in enumerate(entries):
|
||||
offset = len(out)
|
||||
pixels = glyph_pixels(glyph, color)
|
||||
out += glyph["w"].to_bytes(2, "little")
|
||||
out += glyph["h"].to_bytes(2, "little")
|
||||
out += pixels
|
||||
directory = ATL_DIR_OFF + index * 8
|
||||
out[directory:directory + 2] = offset.to_bytes(2, "little")
|
||||
out[directory + 2] = glyph["w"]
|
||||
out[directory + 3] = glyph["h"]
|
||||
out[directory + 4] = 1
|
||||
out[directory + 5] = 1
|
||||
|
||||
if len(out) > SNAPSHOT_OFF:
|
||||
raise SystemExit(f"font atlas: {len(out)} Б, нет места для palette snapshot @ {SNAPSHOT_OFF:#x}")
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUT_ATL.write_bytes(out)
|
||||
return len(out)
|
||||
|
||||
|
||||
def emit_header(small: dict, big: dict, atlas_size: int) -> None:
|
||||
small_n = LAST - FIRST + 1
|
||||
text = f"""/*
|
||||
* pop_font.h — контракт FONT\\FONT.ATL, СГЕНЕРИРОВАН.
|
||||
* Источник: SDLPoP hc_small_font_data[] и hc_font_data[].
|
||||
* Генератор: toolchain/pop_extract_font.py. Руками не править.
|
||||
*/
|
||||
#ifndef POP_FONT_H
|
||||
#define POP_FONT_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define POP_FONT_COLOR 0x{FONT_COLOR:02X}
|
||||
#define POP_FONT_DARK_COLOR 0x{FONT_DARK_COLOR:02X}
|
||||
#define POP_FONT_FIRST {FIRST}
|
||||
#define POP_FONT_LAST {LAST}
|
||||
#define POP_FONT_SMALL_BASE 0
|
||||
#define POP_FONT_SMALL_ASCENT {small['ascent']}
|
||||
#define POP_FONT_SMALL_SPACE {small['space_chars']}
|
||||
#define POP_FONT_BIG_BASE {small_n}
|
||||
#define POP_FONT_BIG_ASCENT {big['ascent']}
|
||||
#define POP_FONT_BIG_SPACE {big['space_chars']}
|
||||
/* Тот же КРУПНЫЙ шрифт цветом POP_FONT_DARK_COLOR: тень и текст на светлом.
|
||||
* Набор обрезан по '_' — им пишется только таблица рекордов. */
|
||||
#define POP_FONT_DARK_BASE {2 * small_n}
|
||||
#define POP_FONT_DARK_FIRST {DARK_FIRST}
|
||||
#define POP_FONT_DARK_LAST {DARK_LAST}
|
||||
#define POP_FONT_SNAPSHOT_OFF 0x{SNAPSHOT_OFF:04X}
|
||||
#define POP_FONT_ATLAS_SIZE {atlas_size}
|
||||
|
||||
#endif
|
||||
"""
|
||||
OUT_H.write_text(text)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parsed = []
|
||||
for path, name in ((SRC_SMALL, "hc_small_font_data"), (SRC_BIG, "hc_font_data")):
|
||||
font, glyphs = parse_glyphs(extract_array(path, name))
|
||||
print(f"{name}: chars={font['first']}..{font['last']} ascent={font['ascent']} "
|
||||
f"space={font['space_chars']}")
|
||||
parsed.append((font, glyphs))
|
||||
sets = [(parsed[0][0], parsed[0][1], FONT_COLOR, FIRST, LAST),
|
||||
(parsed[1][0], parsed[1][1], FONT_COLOR, FIRST, LAST),
|
||||
(parsed[1][0], parsed[1][1], FONT_DARK_COLOR, DARK_FIRST, DARK_LAST)]
|
||||
atlas_size = pack_atlas(sets)
|
||||
emit_header(parsed[0][0], parsed[1][0], atlas_size)
|
||||
print(f"{OUT_ATL}: 2 x {LAST - FIRST + 1} + {DARK_LAST - DARK_FIRST + 1} glyphs, "
|
||||
f"{atlas_size} Б; "
|
||||
f"snapshot from 0x{SNAPSHOT_OFF:04X}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_pack_arc.py — сложить несколько ресурсов в один архив PBA1.
|
||||
|
||||
ЗАЧЕМ. На диске игры лежит больше трёхсот отдельных файлов, и дело не в
|
||||
беспорядке: замер в MAME (2026-08-25) показал, что ОДИН `open` стоит
|
||||
51,4 мс, а чтение 16 КБ из уже открытого файла — 32,6 мс. То есть 61 %
|
||||
времени загрузки уходит на открытие, а не на данные. Одна только стартовая
|
||||
пачка (kid + shadow + bg + sound + guard, ~142 файла) — это 7,3 секунды
|
||||
чистого `open`. Сложив группу в архив и открыв его ОДИН раз, мы эти
|
||||
секунды возвращаем.
|
||||
|
||||
ФОРМАТ (512-байтовый заголовок, дальше данные):
|
||||
|
||||
0..3 'PBA1' магия и версия — как SPA1 у атласов, PCFG, POPQ
|
||||
4 count число записей, 1..126
|
||||
5..7 резерв (нули)
|
||||
8.. записи по 4 байта:
|
||||
+0..1 offset в СЕКТОРАХ по 512 от начала файла (uint16 LE)
|
||||
+2..3 size в байтах (uint16 LE)
|
||||
|
||||
Оффсет именно в секторах: архив `pv` — мегабайт, в байтах uint16 не хватило
|
||||
бы. В секторах uint16 покрывает 32 МБ, то есть весь наш образ. Размер при
|
||||
этом спокойно живёт в байтах — ни один ресурс не крупнее 16 КБ, потому что
|
||||
каждый обязан помещаться в EMM-страницу.
|
||||
|
||||
Данные каждого элемента выровнены на 512, поэтому его смещение всегда
|
||||
целое число секторов, а чтение попадает в границу сектора DSS.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAGIC = b"PBA1"
|
||||
HEADER = 512
|
||||
SECTOR = 512
|
||||
MAX_ENTRIES = (HEADER - 8) // 4 # 126
|
||||
|
||||
|
||||
def build(out_path: Path, files: list[Path]) -> None:
|
||||
if len(files) > MAX_ENTRIES:
|
||||
raise SystemExit(f"{out_path.name}: {len(files)} файлов, "
|
||||
f"в заголовок влезает {MAX_ENTRIES}")
|
||||
header = bytearray(HEADER)
|
||||
header[0:4] = MAGIC
|
||||
header[4] = len(files)
|
||||
|
||||
blob = bytearray()
|
||||
for i, f in enumerate(files):
|
||||
data = f.read_bytes()
|
||||
if len(data) > 0xFFFF:
|
||||
raise SystemExit(f"{f}: {len(data)} Б — размер не влезает в uint16")
|
||||
offset = HEADER + len(blob)
|
||||
assert offset % SECTOR == 0
|
||||
rec = 8 + i * 4
|
||||
header[rec:rec + 2] = (offset // SECTOR).to_bytes(2, "little")
|
||||
header[rec + 2:rec + 4] = len(data).to_bytes(2, "little")
|
||||
blob += data
|
||||
pad = -len(blob) % SECTOR
|
||||
blob += bytes(pad)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(bytes(header) + bytes(blob))
|
||||
|
||||
|
||||
def const_name(prefix: str, path: Path) -> str:
|
||||
"""kid12.atl -> ARC_KID_KID12; kid.pal -> ARC_KID_KID_PAL."""
|
||||
stem = path.name
|
||||
if stem.lower().endswith(".atl"):
|
||||
stem = stem[:-4]
|
||||
return f"ARC_{prefix}_" + re.sub(r"[^A-Za-z0-9]", "_", stem).upper()
|
||||
|
||||
|
||||
def write_header(path: Path, prefix: str, files: list[Path]) -> None:
|
||||
"""Индексы элементов — константами, чтобы код не считал их на память.
|
||||
|
||||
Порядок задаёт Makefile, и он же единственный источник истины: сдвинулся
|
||||
список — сдвинулись константы, а серии вроде kid0..kid27 код проверяет
|
||||
статически (`#if ARC_KID_KID27 - ARC_KID_KID0 != 27`).
|
||||
"""
|
||||
lines = [f"/* {path.name} — индексы в архиве {prefix}. СГЕНЕРИРОВАН",
|
||||
" * (toolchain/pop_pack_arc.py), руками не править. */",
|
||||
f"#ifndef {path.stem.upper()}_H",
|
||||
f"#define {path.stem.upper()}_H",
|
||||
"",
|
||||
f"#define ARC_{prefix}_COUNT {len(files)}",
|
||||
""]
|
||||
width = max(len(const_name(prefix, f)) for f in files)
|
||||
for i, f in enumerate(files):
|
||||
lines.append(f"#define {const_name(prefix, f):<{width}} {i}")
|
||||
lines += ["", "#endif", ""]
|
||||
path.write_text("\n".join(lines))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
header = prefix = None
|
||||
while args and args[0].startswith("--"):
|
||||
opt = args.pop(0)
|
||||
if opt == "--header":
|
||||
header = Path(args.pop(0))
|
||||
elif opt == "--prefix":
|
||||
prefix = args.pop(0)
|
||||
else:
|
||||
raise SystemExit(f"неизвестный ключ {opt}")
|
||||
if len(args) < 2:
|
||||
raise SystemExit("usage: pop_pack_arc.py [--header H.h --prefix NAME] "
|
||||
"OUT.ARC file [file ...]")
|
||||
out = Path(args[0])
|
||||
files = [Path(a) for a in args[1:]]
|
||||
for f in files:
|
||||
if not f.exists():
|
||||
raise SystemExit(f"нет файла: {f}")
|
||||
build(out, files)
|
||||
if header:
|
||||
if not prefix:
|
||||
raise SystemExit("--header требует --prefix")
|
||||
write_header(header, prefix, files)
|
||||
total = out.stat().st_size
|
||||
print(f"{out.name}: {len(files)} записей, {total / 1024:.0f} КБ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_bg.py — упаковщик спрайтов СТАТИЧЕСКОГО ФОНА PoP (Шаг 2 порта).
|
||||
|
||||
Что делает:
|
||||
1. Определяет РЕАЛЬНО используемый набор спрайтов фона (прогоняет
|
||||
render_room.py по всем комнатам всех уровней, собирает (chtab,id)).
|
||||
2. Грузит исходные PNG (VDUNGEON→VPALACE каскад, как в игре), переводит
|
||||
16-цветный индексированный пиксель в 8bpp getimage-блоб:
|
||||
индекс 0 -> 0xFF (прозрачность getimage)
|
||||
индекс i -> 0x50+i (env, chtab_6) / 0x60+i (wall, chtab_7)
|
||||
3. Пакует в атласы .atl с ПРЯМОЙ адресацией (idx = id), каталог любого
|
||||
размера (блобы кладутся ПОСЛЕ каталога — загрузчик atlas_image читает
|
||||
offset явно, лимит 19 из libbgi/mkatlas.py не действует). libbgi НЕ
|
||||
трогаем. Дырки в каталоге (неиспользуемые id) = фейковые записи,
|
||||
ссылающиеся на общий пустой 0×0-блоб (осознанная трата места в EMM
|
||||
ради нулевых remap-таблиц в W2 — требование пользователя).
|
||||
4. Собирает палитру Sprinter (.pal, записи B,G,R,0) из res200.pal (env
|
||||
-> слоты 0x50..0x5F) и res360.pal (wall -> 0x60..0x6F). VGA 6-бит
|
||||
каналы масштабируются в 8-бит.
|
||||
5. Пишет C-заголовок с раскладкой (имена файлов, SHIFT, число страниц,
|
||||
набор fore-id).
|
||||
|
||||
Раскладка (прямая адресация, SHIFT=5):
|
||||
ENV фон : 5 страниц, страница = id>>5, idx = id&31
|
||||
WALL : 1 страница, idx = id
|
||||
FORE : 1 страница (спрайты «перед персонажем»: фронты столбов/
|
||||
ворот/дебриса), idx = id — отдельный атлас (жизненный цикл
|
||||
другой: рисуются каждый кадр как sprite_t, не запекаются).
|
||||
W2-цена: 7 хэндлов atlas_t (~21 Б), ноль таблиц id->позиция.
|
||||
|
||||
Выход: в build/atl/bg/ — *.atl + pop_bg.pal + pop_bg_atlas.h
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import render_room as R # noqa: E402 (экстрактор + каскад загрузки)
|
||||
|
||||
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)
|
||||
|
||||
OUT_DIR = str(P.atl("bg"))
|
||||
|
||||
# Спрайты FORE-слоя (add_foretable в seg008 — «перед персонажем»): фронты
|
||||
# столбов, ворот, дебриса. См. tile_table.fore_id + анализ pop_background_strategy.
|
||||
# 119..123 — фронт чомпера и его кровь; 77 — фронт ЗЕРКАЛА (MIRROR_ENV_IDS).
|
||||
#
|
||||
# id 12 ЗДЕСЬ НЕТ намеренно. В tile_table он стоит как fore_id ЗЕЛЬЯ
|
||||
# (0x0A), но зелье рисуется из chtab_1 (`add_foretable(id_chtab_1_...)`,
|
||||
# seg008 draw_tile_fore; у нас — pop_potion_flask), а в chtab_6 под этим
|
||||
# номером лежит ПРАВАЯ ЧАСТЬ АРКИ БАЛКОНА (32x62), и она идёт в backtable:
|
||||
# `BT room=18 r=1 c=7 ch=6 id=12` в трассе оригинала. Пока 12 был в этом
|
||||
# списке, он уезжал в fore-атлас, а env-страница получала дырку — правая
|
||||
# половина портала не рисовалась вовсе (BUG-BALCONY-RIGHT).
|
||||
FORE_ENV_IDS = {9, 49, 88, 91, 95, 100} | {106, 107, 108, 109, 110} \
|
||||
| {119, 120, 121, 122, 123} | {77}
|
||||
|
||||
# Пол-оверлей подъёма (floor_left_overlay, draw_floor_overlay seg008): рисуется
|
||||
# рантаймом поверх нижней части Kid при подтягивании на пол у кромки — НЕ
|
||||
# попадает в used render_room, добавляем явно в env-фон. (32 обычно уже есть.)
|
||||
CLIMB_OVERLAY_ENV_IDS = {32, 150, 151}
|
||||
|
||||
# Дрожащие/падающие кадры loose-плиты (loose_fram_left/right/bottom, seg008):
|
||||
# 41/42/43 = покой (уже в used из render_room), 69-74 = вибрация/раздвоение —
|
||||
# используются ТОЛЬКО динамикой (shake/fall), в статический render_room НЕ
|
||||
# попадают, поэтому добавляем явно. Без них дрожащая плита невидима.
|
||||
LOOSE_ANIM_ENV_IDS = {69, 70, 71, 72, 73, 74}
|
||||
|
||||
# Кадры ВЫДВИЖЕНИЯ пик (seg008): три части — left (128-132, своя ячейка,
|
||||
# draw_tile_anim), right (134-138, правый сосед, draw_tile_anim_right), fore
|
||||
# (139-143, поверх Kid, draw_tile_fore). Рисуются ТОЛЬКО динамикой
|
||||
# (animate_spike); в уровнях пики статически убраны (modif=0 → кадр 0),
|
||||
# поэтому в used render_room НЕ попадают. Без них выдвинутые пики невидимы.
|
||||
SPIKE_ANIM_ENV_IDS = {128, 129, 130, 131, 132, # left
|
||||
134, 135, 136, 137, 138, # right
|
||||
139, 140, 141, 142, 143} # fore
|
||||
|
||||
# Кадры ЧОМПЕРА (seg008:638 chomper_fram_bot/top/for + кровь). В уровнях
|
||||
# чомпер стоит с modif = 0, а chomper_fram1[0] = 3 — значит render_room
|
||||
# (он анимацию пропускает вовсе, см. render_room.py:611) не кладёт в атлас
|
||||
# НИ ОДНОГО кадра, и чомпер не виден даже в покое. Поэтому весь набор —
|
||||
# явно. Низ и верх идут в backtable (ПОД персонажем), фронт — в foretable
|
||||
# (ПОВЕРХ), поэтому фронтовые id продублированы в FORE_ENV_IDS.
|
||||
CHOMPER_BOT_IDS = {101, 102, 103, 104, 105} # chomper_fram_bot
|
||||
CHOMPER_TOP_IDS = {111, 112, 113} # chomper_fram_top (фазы 2..4)
|
||||
CHOMPER_FORE_IDS = {106, 107, 108, 109, 110} # chomper_fram_for
|
||||
|
||||
# Кровь на сомкнутых челюстях (modif & 0x80): chomper_num + 114 в backtable и
|
||||
# + 119 в foretable, обе — MONO-блитом цветом 12 (blitters_4Ch_mono_12), то
|
||||
# есть силуэтом, как пузырёк зелья. Появляется после смерти Кида в чомпере.
|
||||
CHOMPER_BLOOD_IDS = {114, 115, 116, 117, 118}
|
||||
CHOMPER_BLOOD_FORE_IDS = {119, 120, 121, 122, 123}
|
||||
CHOMPER_BLOOD_COLOR = None # ставится ниже, когда известен VGA16_PAL_BASE
|
||||
|
||||
# Кадры ОТКРЫТИЯ ворот (portcullis, DOOR_FRAM_SLICE + gate pieces 50/51,
|
||||
# seg008 draw_gate): 50-59 + 67. В уровнях ворота статически ЗАКРЫТЫ
|
||||
# (низкий modif → используются только нижние срезы 50-53); срезы более
|
||||
# открытых состояний (54-59, 67) нужны анимации открытия — добавляем явно.
|
||||
GATE_ANIM_ENV_IDS = {50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 67}
|
||||
# ВЕРХ ворот, который рисует тайл НАД ними (draw_tile_anim_topright,
|
||||
# seg008:0568): маска 68 (mono, чёрным — «продавить» фон под створкой) и
|
||||
# кадр door_fram_top[(modifier>>2) % 8] = 60..67 по открытости. Без них
|
||||
# над воротами остаётся чёрный треугольник (видно у шва во дворце).
|
||||
GATE_TOP_ENV_IDS = {60, 61, 62, 63, 64, 65, 66, 67}
|
||||
GATE_TOP_MASK_ID = 68
|
||||
|
||||
|
||||
# НАЖАТАЯ кнопка-closer (get_tile_to_draw seg008:2FE: при таймере связи >1
|
||||
# tiles_6_closer рисуется как tiles_5_stuck). tile_table[5] = база 41 (уже
|
||||
# есть), правая грань 35, нижняя 36 — оба id встречаются ТОЛЬКО в этом
|
||||
# динамическом состоянии, статически тайла 5 в уровнях нет, значит в used
|
||||
# render_room они не попадают. Без них у нажатой кнопки чёрный провал на
|
||||
# месте нижней грани и потерянная правая грань.
|
||||
STUCK_ENV_IDS = {35, 36}
|
||||
|
||||
# Левая половина кнопки-opener, когда СЛЕВА пусто (draw_tile_base seg008:0A8E:
|
||||
# curr_tile==15 && tile_left==0 && level_type==0 → id 148 вместо базы 147).
|
||||
# render_room этой ветки не знает, поэтому добавляем явно.
|
||||
OPENER_NOFLOOR_ENV_IDS = {148}
|
||||
|
||||
# ---- chtab_1 (flame/sword/potion), база ресурса 150, папка PRINCE ----
|
||||
# Зелье (seg008 draw_tile_fore/draw_tile_anim): склянка id 12 (малая) / 13
|
||||
# (большая, типы 2..4), кадры пузырька 16..22 (potion_fram_bubb) и его маска
|
||||
# 23. Пузырёк и маска рисуются MONO-блитом (одним цветом), поэтому пакуем их
|
||||
# силуэтами уже в нужном цвете: маска — чёрный env[0], пузырёк — env[12]
|
||||
# (красный, тип «лечение»). Цвета других типов (9 синий / 10 зелёный) —
|
||||
# TODO, когда появятся такие зелья.
|
||||
POT_DIR_NAME = "PRINCE"
|
||||
POT_FLAME_IDS = tuple(range(1, 10)) # пламя факела: modifier+1 (seg008:560)
|
||||
POT_FLASK_IDS = (12, 13)
|
||||
POT_BUBBLE_IDS = (16, 17, 18, 19, 20, 21, 22)
|
||||
POT_MASK_ID = 23
|
||||
POT_PAL_BASE = 0x40 # 16 цветов chtab_1 -> слоты 0x40..0x4F
|
||||
# MONO-блит (method_3_blit_mono, seg009:3040) красит спрайт цветом из ОБЩЕЙ
|
||||
# 16-цветной палитры экрана, а не из палитры chtab. Кладём её в 0x30..0x3F.
|
||||
VGA16_PAL_BASE = 0x30
|
||||
# Два цвета игра ПРАВИТ на старте (init_game_main, seg000:164) — стандартные
|
||||
# VGA-значения там не годятся:
|
||||
# 6 -> #C09850 (0x30,0x26,0x14) «palace wall pattern» — швы дворцовой кладки
|
||||
# 12 -> #E00030 (0x38,0x00,0x0C) «blood, hurt flash» — кровь и вспышка урона
|
||||
# Проверено дампом палитры живого SDLPoP: PAL[6]=48,38,20 PAL[12]=56,0,12.
|
||||
VGA16 = [(0,0,0),(0,0,42),(0,42,0),(0,42,42),(42,0,0),(42,0,42),(48,38,20),
|
||||
(42,42,42),(21,21,21),(21,21,63),(21,63,21),(21,63,63),(56,0,12),
|
||||
(63,21,63),(63,63,21),(63,63,63)] # 6-битные каналы, как в .pal
|
||||
# ЧЁРНЫЙ ФОН АНИМАЦИЙ — ИМЕННО ЦВЕТ 0, а не «чёрный из палитры набора».
|
||||
# Вспышки (молния, урон, зелья) подменяют НУЛЕВУЮ запись палитры, и всё,
|
||||
# что закрашено 0x30/0x40, остаётся чёрным островом посреди вспышки —
|
||||
# пламя факелов и склянки «не мигали» (поймано пользователем 2026-08-25).
|
||||
POT_MASK_COLOR = 0 # маска mono-блита = фон вспышки
|
||||
POT_BUBBLE_COLOR = VGA16_PAL_BASE + 12 # ярко-красный (color 12: «лечение»)
|
||||
# Цвет пузырька зависит от ТИПА зелья (draw_tile_anim, seg008:652): типы 3/4
|
||||
# (перо / переворот) — зелёный, 5/6 (вред / открыть) — синий, остальные —
|
||||
# красный. У нас спрайт красится при УПАКОВКЕ (mono-блиттера с параметром
|
||||
# цвета в libbgi нет), поэтому каждый цвет — свой набор кадров: те же семь
|
||||
# силуэтов под сдвинутыми id. Синий появится вместе с зельями 5/6.
|
||||
POT_BUBBLE_GREEN_COLOR = VGA16_PAL_BASE + 10 # color 10 (seg008:663)
|
||||
POT_BUBBLE_GREEN_BASE = 30 # id 30..36 = кадры 16..22
|
||||
POT_BUBBLE_BLUE_COLOR = VGA16_PAL_BASE + 9 # color 9 (seg008:658)
|
||||
POT_BUBBLE_BLUE_BASE = 40 # id 40..46 = кадры 16..22
|
||||
CHOMPER_BLOOD_COLOR = VGA16_PAL_BASE + 12 # тот же mono-цвет 12 (seg008:678)
|
||||
|
||||
# Кладка ДВОРЦА (wall_pattern, seg008:1928, ветка palace): тело стены —
|
||||
# сплошные заливки цветом из palace_wall_colors, а поверх идут ПЯТЬ
|
||||
# разделителей chtab_7 id 3..17 (prandom(2) + 3/6/9/12/15), и рисуются они
|
||||
# blitters_46h_mono_6 — mono-блитом ОДНИМ цветом 6 общей 16-цветной палитры
|
||||
# экрана. В подземелье те же id — обычные спрайты кирпичей (wall_fram_main/
|
||||
# bottom), поэтому силуэтом пакуем ТОЛЬКО дворцовый набор.
|
||||
PALACE_WALL_MONO_IDS = set(range(3, 18))
|
||||
PALACE_WALL_MONO_COLOR = VGA16_PAL_BASE + 6
|
||||
|
||||
|
||||
# Основание факела (seg008:489 draw_tile_right: при левом соседе-факеле
|
||||
# рисуется chtab_6 id 146). В статический render_room не попадает.
|
||||
TORCH_ENV_IDS = {146}
|
||||
|
||||
# ---- Два тайлсета: подземелье и дворец --------------------------------
|
||||
# Оригинал грузит РАЗНЫЙ .DAT под один и тот же набор id:
|
||||
# tbl_envir_ki[tbl_level_type[level]] -> VDUNGEON / VPALACE (seg000:1108),
|
||||
# то есть id спрайта от набора НЕ зависит — меняется только картинка и
|
||||
# палитра (env 0x50..0x5F, wall 0x60..0x6F). Поэтому оба набора пакуются
|
||||
# по ОДНОМУ И ТОМУ ЖЕ объединению id: раскладка id -> (страница, idx)
|
||||
# остаётся одна, и коду достаточно подменить ИМЕНА файлов.
|
||||
#
|
||||
# Порядок каталогов — каскад: сначала «свой» набор, потом чужой как
|
||||
# фолбэк (в распакованном data/ несколько id есть только в одном из них:
|
||||
# res230/231/348 только в VDUNGEON, два десятка — только в VPALACE).
|
||||
SETS = {
|
||||
"dungeon": ("pop_", lambda: (R.VDUNGEON_DIR, R.VPALACE_DIR)),
|
||||
"palace": ("pal_", lambda: (R.VPALACE_DIR, R.VDUNGEON_DIR)),
|
||||
}
|
||||
SRC_DIRS = None # ставит main() по выбранному набору
|
||||
|
||||
# Спрайты, которые рисуются ТОЛЬКО на паласных уровнях (seg008):
|
||||
# 78, 80, 82 doortop_fram_bot (низ верха двери, draw_tile_right)
|
||||
# 81, 83 doortop_fram_top (draw_tile_topright)
|
||||
# 84 полоска на стене (draw_tile_right, tiles_20_wall)
|
||||
# 145 stripe_id соседнего тайла (draw_tile_right)
|
||||
# render_room про них не знает (он рисует по подземельной ветке), поэтому
|
||||
# добавляем явно — иначе при переключении набора в этих местах будет дыра.
|
||||
PALACE_ENV_IDS = {78, 80, 81, 82, 83, 84, 145}
|
||||
|
||||
# ЗЕРКАЛО (tile_table[0x0D] = база 75, фронт 77; 42/43 — общие грани пола).
|
||||
# Тайла 13 НЕТ НИ В ОДНОМ уровне статически (проверено перебором всех 15
|
||||
# res200N.bin): оригинал СТАВИТ его в рантайме, когда дверь выхода уровня 4
|
||||
# дорисовала открытие — animate_leveldoor, seg007:0457:
|
||||
# curr_room_tiles[curr_tilepos] = tiles_13_mirror;
|
||||
# Значит в used от render_room эти id не попадают и без явного добавления на
|
||||
# месте зеркала был бы чёрный провал (те же грабли, что у нажатой кнопки и
|
||||
# дрожащей плиты — memory pop_atlas_dynamic_ids).
|
||||
MIRROR_ENV_IDS = {75, 77}
|
||||
|
||||
ENV_SHIFT = 4 # env фон: страница = id>>SHIFT, idx = id&MASK.
|
||||
# Было 5: паласный набор добавляет куски верха
|
||||
# двери (78..84), и страница 2 переваливала за
|
||||
# 16 КБ. При 4 обе раскладки укладываются, цена
|
||||
# — 10 страниц EMM на набор вместо 5.
|
||||
ENV_MASK = (1 << ENV_SHIFT) - 1
|
||||
|
||||
# ---- .atl формат (== libbgi, но каталог не ограничен 19) --------------
|
||||
ATL_MAGIC = b"SPA1"
|
||||
ATL_DIR_OFF = 0x68 # _ATL_DIR_OFF
|
||||
ATL_MIN_SIZE = 0x100 # atlas_load требует n >= _ATL_DATA_OFF
|
||||
PAGE_SIZE = 16384
|
||||
|
||||
|
||||
def die(msg):
|
||||
sys.exit(f"pop_pack_bg: {msg}")
|
||||
|
||||
|
||||
# ---- 1. Набор используемых спрайтов ----------------------------------
|
||||
def collect_used():
|
||||
"""Прогон render_room по всем комнатам -> множество (chtab, id)."""
|
||||
used = set()
|
||||
orig = R.load_sprite
|
||||
|
||||
def spy(id, chtab=R.CHTAB_ENV, reason=""):
|
||||
img = orig(id, chtab, reason)
|
||||
if img is not None and id > 0:
|
||||
used.add((chtab, id))
|
||||
return img
|
||||
|
||||
R.load_sprite = spy
|
||||
for lvl in range(1, 15):
|
||||
fn = os.path.join(R.LEVEL_DIR, f"res{2000 + lvl}.bin")
|
||||
if not os.path.exists(fn):
|
||||
continue
|
||||
lv = R.Level(open(fn, "rb").read())
|
||||
for room in range(1, 25):
|
||||
rm = R.Room(lv, room)
|
||||
L = lv.links[room][0]
|
||||
if L:
|
||||
rm.left_room = R.Room(lv, L)
|
||||
canvas = Image.new("RGBA", (320, 192), (0, 0, 0, 0))
|
||||
for dr in (2, 1, 0):
|
||||
for c in range(10):
|
||||
R.draw_tile(canvas, rm, dr, c)
|
||||
R.load_sprite = orig
|
||||
return used
|
||||
|
||||
|
||||
# ---- 2. Загрузка спрайта в 8bpp-блоб ---------------------------------
|
||||
# Спрайты chtab_6, которые оригинал рисует БЕЗ прозрачности
|
||||
# (blitters_0_no_transp): нижние грани тайлов (draw_tile_bottom, seg008:570) и
|
||||
# нижние кадры loose (draw_loose, seg008:600). У 96/149 (грани кнопок) и 73/74
|
||||
# (кадры loose) ПОСЛЕДНЯЯ строка — пиксель 0; в оригинале она заливается ЧЁРНЫМ
|
||||
# поверх того, что сзади, а у нас 0 -> 0xFF делал её прозрачной, и сквозь неё
|
||||
# просвечивал Kid (спуск/подъём у кромки кнопки). 43/36 полностью непрозрачны —
|
||||
# для них флаг ничего не меняет, оставлены для полноты списка.
|
||||
# Дверь уровня (draw_leveldoor, seg008:1D29): решётка створки (33 — слайс 4px,
|
||||
# 34 — верх), лестница за ней (99 — низ, 144 — марш). render_room.py
|
||||
# draw_leveldoor не реализует, поэтому в автосбор эти id не попадают.
|
||||
LEVELDOOR_ENV_IDS = {33, 34, 99, 144}
|
||||
|
||||
# Куски двери уровня (draw_leveldoor) оригинал рисует blitters_0_no_transp:
|
||||
# марш лестницы 144 обязан ЗАКРАШИВАТЬ поднявшуюся створку, иначе слева от
|
||||
# лестницы сквозь него видна решётка (0 -> 0xFF делал фон спрайта прозрачным).
|
||||
NO_TRANSP_ENV_IDS = {36, 43, 73, 74, 96, 149} | LEVELDOOR_ENV_IDS
|
||||
|
||||
|
||||
def load_indexed(chtab, id, opaque=False, mono=None):
|
||||
"""PNG (mode P, 16 цветов) -> (w, h, bytes 8bpp). Каскад по SRC_DIRS
|
||||
(порядок задаёт выбранный тайлсет, см. SETS).
|
||||
Пиксель 0 -> 0xFF (прозрач); i -> base+i (env 0x50 / wall 0x60).
|
||||
|
||||
opaque=True — пиксель 0 остаётся ЦВЕТОМ (base+0 = чёрный), а не ключом
|
||||
прозрачности: порт blitters_0_no_transp (см. NO_TRANSP_ENV_IDS)."""
|
||||
base_res = 360 if chtab == R.CHTAB_WALL else 200
|
||||
pal_base = 0x60 if chtab == R.CHTAB_WALL else 0x50
|
||||
fname = f"res{base_res + id}.png"
|
||||
path = None
|
||||
for d in SRC_DIRS:
|
||||
p = os.path.join(d, fname)
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
break
|
||||
if path is None:
|
||||
die(f"нет файла {fname}")
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
die(f"{fname}: mode {im.mode}, ожидался P (16-цветный)")
|
||||
src = im.tobytes()
|
||||
if mono is not None:
|
||||
# MONO-блит (blitters_4Ch_mono_12): спрайт красится ОДНИМ цветом из
|
||||
# 16-цветной палитры экрана — пакуем силуэтом, как пузырёк зелья.
|
||||
out = bytes((0xFF if v == 0 else mono) for v in src)
|
||||
elif opaque:
|
||||
out = bytes((pal_base + v) for v in src)
|
||||
else:
|
||||
out = bytes((0xFF if v == 0 else pal_base + v) for v in src)
|
||||
return im.width, im.height, out
|
||||
|
||||
|
||||
def load_potion(id, mono=None, opaque=False):
|
||||
"""chtab_1: res(150+id).png из data/PRINCE. mono=None — обычный спрайт
|
||||
(палитра chtab_1 -> слоты POT_PAL_BASE+i); mono=цвет — силуэт этим цветом
|
||||
(порт blitters_40h_mono); opaque=True — пиксель 0 остаётся ЦВЕТОМ
|
||||
(порт blitters_0_no_transp, см. POT_FLAME_IDS)."""
|
||||
path = os.path.join(os.path.dirname(R.VDUNGEON_DIR), POT_DIR_NAME,
|
||||
f"res{150 + id}.png")
|
||||
if not os.path.exists(path):
|
||||
die(f"нет файла {path}")
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
die(f"res{150+id}: mode {im.mode}, ожидался P")
|
||||
src = im.tobytes()
|
||||
if mono is not None:
|
||||
out = bytes((0xFF if v == 0 else mono) for v in src)
|
||||
elif opaque:
|
||||
# Пиксель 0 — ЦВЕТ 0 (общий фон игрового поля), остальные — палитра
|
||||
# chtab_1. Ноль здесь принципиален: непрозрачный бокс пламени иначе
|
||||
# закрашивал бы фон «своим» чёрным (0x40) и не участвовал во вспышке.
|
||||
out = bytes((0 if v == 0 else POT_PAL_BASE + v) for v in src)
|
||||
else:
|
||||
out = bytes((0xFF if v == 0 else POT_PAL_BASE + v) for v in src)
|
||||
return im.width, im.height, out
|
||||
|
||||
|
||||
def blob(w, h, pix):
|
||||
"""getimage-блоб: u16 w, u16 h (LE) + пиксели."""
|
||||
return w.to_bytes(2, "little") + h.to_bytes(2, "little") + pix
|
||||
|
||||
|
||||
# ---- 3. Пакер .atl (каталог любого размера) --------------------------
|
||||
def pack_atlas(path, entries):
|
||||
"""entries: dict idx -> (w, h, pix). Пустые idx (0..maxidx) заполняются
|
||||
фейковой ссылкой на общий 0×0-блоб. Каталог count записей с 0x68,
|
||||
блобы — сразу после каталога."""
|
||||
if not entries:
|
||||
die(f"{path}: пустой атлас")
|
||||
count = max(entries) + 1
|
||||
if count > 255:
|
||||
die(f"{path}: count {count} > 255")
|
||||
data_off = ATL_DIR_OFF + count * 8
|
||||
|
||||
blobs = bytearray()
|
||||
empty_off = data_off + len(blobs) # общий 0×0-блоб для дырок
|
||||
blobs += blob(0, 0, b"")
|
||||
dir_bytes = bytearray(count * 8)
|
||||
for idx in range(count):
|
||||
e = entries.get(idx)
|
||||
if e is None:
|
||||
off = empty_off
|
||||
fw = fh = 0
|
||||
else:
|
||||
w, h, pix = e
|
||||
off = data_off + len(blobs)
|
||||
blobs += blob(w, h, pix)
|
||||
fw = min(w, 255)
|
||||
fh = min(h, 255)
|
||||
d = idx * 8
|
||||
dir_bytes[d:d + 2] = off.to_bytes(2, "little")
|
||||
dir_bytes[d + 2] = fw
|
||||
dir_bytes[d + 3] = fh
|
||||
dir_bytes[d + 4] = 1 # nx
|
||||
dir_bytes[d + 5] = 1 # ny
|
||||
# d+6,d+7 резерв
|
||||
|
||||
out = bytearray(data_off)
|
||||
out[0:4] = ATL_MAGIC
|
||||
out[4] = count
|
||||
out[ATL_DIR_OFF:ATL_DIR_OFF + len(dir_bytes)] = dir_bytes
|
||||
out += blobs
|
||||
if len(out) < ATL_MIN_SIZE:
|
||||
out += bytes(ATL_MIN_SIZE - len(out)) # atlas_load: n >= 0x100
|
||||
if len(out) > PAGE_SIZE:
|
||||
die(f"{path}: {len(out)} Б > {PAGE_SIZE} (одна EMM-страница)")
|
||||
with open(path, "wb") as f:
|
||||
f.write(out)
|
||||
return count, len(out)
|
||||
|
||||
|
||||
# ---- 4. Палитра ------------------------------------------------------
|
||||
def scale6to8(v):
|
||||
return (v * 255) // 63
|
||||
|
||||
|
||||
def read_pal16(res, d):
|
||||
b = open(os.path.join(d, res), "rb").read()
|
||||
return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)]
|
||||
|
||||
|
||||
def build_tileset_palette(out_path):
|
||||
"""Палитра ТОЛЬКО тайлсета: 32 записи B,G,R,0 — env (16) затем wall (16).
|
||||
Ею подменяются слоты 0x50..0x6F при смене типа уровня; полная игровая
|
||||
палитра (kid.pal) при этом не трогается, потому что Кид, страж, меч и
|
||||
зелья живут в других слотах (levels_plan §3). Порядок каталогов —
|
||||
SRC_DIRS, то есть файл соответствует выбранному набору."""
|
||||
pal = bytearray(32 * 4)
|
||||
src = read_pal16("res200.pal", SRC_DIRS[0]) + read_pal16("res360.pal", SRC_DIRS[0])
|
||||
for i, (r, g, b) in enumerate(src):
|
||||
pal[i * 4:i * 4 + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(pal)
|
||||
|
||||
|
||||
def build_palette(out_path):
|
||||
"""256-записная палитра B,G,R,0: env@0x50..0x5F, wall@0x60..0x6F."""
|
||||
def read_pal(res, d):
|
||||
return read_pal16(res, d)
|
||||
|
||||
env = read_pal("res200.pal", R.VDUNGEON_DIR)
|
||||
wall = read_pal("res360.pal", R.VDUNGEON_DIR)
|
||||
pot = read_pal("res150.pal", os.path.join(os.path.dirname(R.VDUNGEON_DIR),
|
||||
POT_DIR_NAME))
|
||||
pal = bytearray(256 * 4)
|
||||
for i, (r, g, b) in enumerate(env):
|
||||
o = (0x50 + i) * 4
|
||||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||||
for i, (r, g, b) in enumerate(wall):
|
||||
o = (0x60 + i) * 4
|
||||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||||
for i, (r, g, b) in enumerate(pot):
|
||||
o = (POT_PAL_BASE + i) * 4
|
||||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||||
for i, (r, g, b) in enumerate(VGA16):
|
||||
o = (VGA16_PAL_BASE + i) * 4
|
||||
pal[o:o + 4] = bytes((scale6to8(b), scale6to8(g), scale6to8(r), 0))
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(pal)
|
||||
|
||||
|
||||
# ---- 5. C-заголовок --------------------------------------------------
|
||||
def write_header(out_path, env_pages):
|
||||
"""Заголовок ОДИН на оба тайлсета: раскладка id -> (страница, idx) у них
|
||||
общая (пакуются по одному объединению id), различаются только имена
|
||||
файлов и палитра. Поэтому здесь два массива имён и один индекс."""
|
||||
lines = [
|
||||
"/* pop_bg_atlas.h — раскладка атласов статического фона PoP.",
|
||||
" * Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Прямая адресация (ноль remap-таблиц в W2):",
|
||||
" * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK),
|
||||
" * WALL id N -> atlas wall, idx N",
|
||||
" * FORE id N -> atlas fore, idx N",
|
||||
" *",
|
||||
" * ДВА ТАЙЛСЕТА (порт tbl_envir_ki[tbl_level_type], seg000:1108):",
|
||||
" * 0 = подземелье (pop_*), 1 = дворец (pal_*). Набор id и раскладка",
|
||||
" * у них ОДНИ И ТЕ ЖЕ — меняются только файлы и 32 записи палитры",
|
||||
" * (env 0x50..0x5F, wall 0x60..0x6F), см. *tile.pal.",
|
||||
" */",
|
||||
"#ifndef POP_BG_ATLAS_H",
|
||||
"#define POP_BG_ATLAS_H",
|
||||
"",
|
||||
"#define POP_ENV_SHIFT %d" % ENV_SHIFT,
|
||||
"#define POP_ENV_MASK %d" % ENV_MASK,
|
||||
"#define POP_ENV_PAGES %d" % len(env_pages),
|
||||
"#define POP_TILESETS 2",
|
||||
"",
|
||||
"/* Палитра: env-слоты, wall-слоты (спрайт-пиксель i -> база+i). */",
|
||||
"#define POP_PAL_ENV 0x50",
|
||||
"#define POP_PAL_WALL 0x60",
|
||||
"",
|
||||
"/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы",
|
||||
" * видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия",
|
||||
" * строк уедет в каждый включивший заголовок модуль. */",
|
||||
"#ifdef POP_BG_ATLAS_NAMES",
|
||||
"static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {",
|
||||
]
|
||||
for prefix, _ in SETS.values():
|
||||
row = ", ".join(('"%s%s"' % (prefix, nm.split("_", 1)[1]) if nm else "0")
|
||||
for nm in env_pages)
|
||||
lines.append(" { %s }," % row)
|
||||
lines += [
|
||||
"};",
|
||||
'static const char *const pop_wall_atl[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%swall.atl"' % p for p, _ in SETS.values()),
|
||||
'static const char *const pop_fore_atl[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%sfore.atl"' % p for p, _ in SETS.values()),
|
||||
'static const char *const pop_tile_pal[POP_TILESETS] = { %s };'
|
||||
% ", ".join('"%stile.pal"' % p for p, _ in SETS.values()),
|
||||
"#endif /* POP_BG_ATLAS_NAMES */",
|
||||
'#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */',
|
||||
"#define POP_PAL_POT 0x%02X" % POT_PAL_BASE,
|
||||
"/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),",
|
||||
" зелёный (перо/переворот) и синий (вред/открыть) = те же кадры",
|
||||
" под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */"
|
||||
% (POT_BUBBLE_GREEN_BASE, POT_BUBBLE_GREEN_BASE + len(POT_BUBBLE_IDS) - 1,
|
||||
POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1),
|
||||
"#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE,
|
||||
"#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_BASE,
|
||||
'#define POP_BG_PAL "pop_bg.pal"',
|
||||
"",
|
||||
"#endif",
|
||||
"",
|
||||
]
|
||||
with open(out_path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
def main():
|
||||
global SRC_DIRS
|
||||
set_name = sys.argv[1] if len(sys.argv) > 1 else "dungeon"
|
||||
if set_name not in SETS:
|
||||
die(f"набор '{set_name}': ожидался один из {', '.join(SETS)}")
|
||||
prefix, dirs = SETS[set_name]
|
||||
SRC_DIRS = dirs()
|
||||
print(f"набор: {set_name} (источник {os.path.basename(SRC_DIRS[0])}, "
|
||||
f"фолбэк {os.path.basename(SRC_DIRS[1])}), префикс {prefix}")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
used = collect_used()
|
||||
env_ids = sorted(set(id for ch, id in used if ch != R.CHTAB_WALL)
|
||||
| CLIMB_OVERLAY_ENV_IDS # + пол-оверлей подъёма
|
||||
| LOOSE_ANIM_ENV_IDS # + дрожащие кадры loose
|
||||
| SPIKE_ANIM_ENV_IDS # + кадры выдвижения пик
|
||||
| GATE_ANIM_ENV_IDS # + кадры открытия ворот
|
||||
| GATE_TOP_ENV_IDS # + верх ворот (тайл сверху)
|
||||
| {GATE_TOP_MASK_ID} # и его маска (mono)
|
||||
| TORCH_ENV_IDS # + основание факела
|
||||
| STUCK_ENV_IDS # + нажатая кнопка-closer
|
||||
| CHOMPER_BOT_IDS # + чомпер: низ
|
||||
| CHOMPER_TOP_IDS # верх
|
||||
| CHOMPER_FORE_IDS # фронт (поверх Kid)
|
||||
| CHOMPER_BLOOD_IDS # кровь (mono)
|
||||
| CHOMPER_BLOOD_FORE_IDS
|
||||
| OPENER_NOFLOOR_ENV_IDS # + opener без пола слева
|
||||
| LEVELDOOR_ENV_IDS # + дверь уровня (створка/лестница)
|
||||
| PALACE_ENV_IDS # + куски, что рисует только палас
|
||||
| MIRROR_ENV_IDS) # + зеркало (ставится в рантайме)
|
||||
wall_ids = sorted(id for ch, id in used if ch == R.CHTAB_WALL)
|
||||
|
||||
env_bg = [i for i in env_ids if i not in FORE_ENV_IDS]
|
||||
env_fore = [i for i in env_ids if i in FORE_ENV_IDS]
|
||||
|
||||
# ENV фон -> страницы по id>>SHIFT
|
||||
pages = {}
|
||||
for id in env_bg:
|
||||
w, h, pix = load_indexed(R.CHTAB_ENV, id, id in NO_TRANSP_ENV_IDS,
|
||||
CHOMPER_BLOOD_COLOR if id in CHOMPER_BLOOD_IDS
|
||||
else (POT_MASK_COLOR if id == GATE_TOP_MASK_ID
|
||||
else None))
|
||||
pages.setdefault(id >> ENV_SHIFT, {})[id & ENV_MASK] = (w, h, pix)
|
||||
max_pg = max(pages) if pages else -1
|
||||
env_page_names = []
|
||||
total_bytes = 0
|
||||
for pg in range(max_pg + 1):
|
||||
if pg in pages:
|
||||
nm = f"{prefix}env{pg}.atl"
|
||||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, nm), pages[pg])
|
||||
total_bytes += sz
|
||||
env_page_names.append(nm)
|
||||
print(f" {nm}: {len(pages[pg])} спрайтов, каталог {cnt}, {sz} Б")
|
||||
else:
|
||||
env_page_names.append(None)
|
||||
print(f" (env стр {pg}: пусто)")
|
||||
|
||||
# WALL -> idx=id
|
||||
wall_entries = {id: load_indexed(
|
||||
R.CHTAB_WALL, id, False,
|
||||
PALACE_WALL_MONO_COLOR
|
||||
if (set_name == "palace" and id in PALACE_WALL_MONO_IDS)
|
||||
else None)
|
||||
for id in wall_ids}
|
||||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "wall.atl"), wall_entries)
|
||||
total_bytes += sz
|
||||
print(f" {prefix}wall.atl: {len(wall_ids)} спрайтов, каталог {cnt}, {sz} Б")
|
||||
|
||||
# FORE -> idx=id
|
||||
fore_entries = {id: load_indexed(R.CHTAB_ENV, id, False,
|
||||
CHOMPER_BLOOD_COLOR
|
||||
if id in CHOMPER_BLOOD_FORE_IDS else None)
|
||||
for id in env_fore}
|
||||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, prefix + "fore.atl"), fore_entries)
|
||||
total_bytes += sz
|
||||
print(f" {prefix}fore.atl: {len(env_fore)} спрайтов, каталог {cnt}, {sz} Б")
|
||||
|
||||
# POTION (chtab_1) -> idx=id: склянки + кадры пузырька + маска
|
||||
# chtab_1 целиком (res151..res173 = id 1..23): пламя факелов, меч-предмет,
|
||||
# склянки зелий. Пузырьки и их маска рисуются MONO-блитом — пакуем
|
||||
# силуэтами в нужном цвете (см. POT_*_COLOR).
|
||||
pot_entries = {}
|
||||
for id in range(1, 24):
|
||||
if id in POT_BUBBLE_IDS:
|
||||
pot_entries[id] = load_potion(id, POT_BUBBLE_COLOR)
|
||||
elif id == POT_MASK_ID:
|
||||
pot_entries[id] = load_potion(id, POT_MASK_COLOR)
|
||||
elif id in POT_FLAME_IDS:
|
||||
# Пламя оригинал рисует blitters_0_no_transp (seg008
|
||||
# draw_tile_anim_right) — непрозрачным боксом поверх всего, что
|
||||
# сзади. Все 9 кадров лежат на одном канвасе 16x18, поэтому
|
||||
# непрозрачный блит сам стирает предыдущий кадр: heal не нужен.
|
||||
pot_entries[id] = load_potion(id, opaque=True)
|
||||
else:
|
||||
pot_entries[id] = load_potion(id)
|
||||
# Наборы пузырьков ДРУГИХ цветов: те же кадры другим mono-цветом.
|
||||
# зелёный — типы 3/4 (перо, переворот), синий — типы 5/6 (вред, открыть).
|
||||
for i, id in enumerate(POT_BUBBLE_IDS):
|
||||
pot_entries[POT_BUBBLE_GREEN_BASE + i] = load_potion(id, POT_BUBBLE_GREEN_COLOR)
|
||||
pot_entries[POT_BUBBLE_BLUE_BASE + i] = load_potion(id, POT_BUBBLE_BLUE_COLOR)
|
||||
# chtab_1 (пламя/меч/зелья) от тайлсета НЕ зависит — пакуем один раз.
|
||||
cnt, sz = pack_atlas(os.path.join(OUT_DIR, "pop_pot.atl"), pot_entries)
|
||||
total_bytes += sz
|
||||
print(f" pop_pot.atl (chtab_1: факелы+зелья): {len(pot_entries)} спрайтов, каталог {cnt}, {sz} Б")
|
||||
|
||||
build_tileset_palette(os.path.join(OUT_DIR, prefix + "tile.pal"))
|
||||
print(f" {prefix}tile.pal: 32 записи (env 0x50..0x5F + wall 0x60..0x6F)")
|
||||
build_palette(os.path.join(OUT_DIR, "pop_bg.pal"))
|
||||
write_header(str(P.GEN / "pop_bg_atlas.h"), env_page_names)
|
||||
|
||||
n_pages = sum(1 for n in env_page_names if n) + 2
|
||||
print(f"ИТОГО: {len(env_bg)} env-фон + {len(wall_ids)} wall + "
|
||||
f"{len(env_fore)} fore = {len(env_bg)+len(wall_ids)+len(env_fore)} спрайтов")
|
||||
print(f" {n_pages} EMM-страниц, {total_bytes} Б на диске "
|
||||
f"(+1КБ палитра), W2: {n_pages}×atlas_t = {n_pages*3} Б хэндлов")
|
||||
print(f" -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_guard.py — упаковщик спрайтов СТРАЖА (chtab_5_guard, база 750).
|
||||
|
||||
Оригинал грузит стражей из отдельного .DAT по типу уровня
|
||||
(GUARD/FAT/SKEL/VIZIER/SHADOW, seg000:1092); уровень 1 — обычный «guard»,
|
||||
то есть data/GUARD/res(750+id).png. Палитра — НЕ из PNG: оригинал
|
||||
подменяет её из res10.bin (guard_palettes, 7 палитр по 16 цветов, 6-бит) по
|
||||
level.guards_color; на уровне 1 у обоих стражей color = 2.
|
||||
|
||||
Выход: build/atl/guard/g0..gN.atl (по 8 спрайтов на страницу, адресация
|
||||
id>>3 / id&7 — как у Kid) + группа палитры 0x90..0x9F в общий kid.pal
|
||||
(его собирает pop_pack_kid.py, см. GUARD_PAL_BASE там).
|
||||
"""
|
||||
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
|
||||
from pop_pack_kid import transpose_cols, vflip_page
|
||||
|
||||
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)
|
||||
|
||||
# Набор спрайтов соперника выбирается ТИПОМ УРОВНЯ (load_lev_spr, seg000:1092):
|
||||
# tbl_guard_type = {0 guard, 1 fat, 2 skeleton, 3 vizier, 4 shadow, −1 нет}.
|
||||
# У обычного стража палитра подменяется из res10.bin по guards_color, у
|
||||
# остальных наборов curr_guard_color = 0 (seg002:186) — значит цвет берётся
|
||||
# из ФАЙЛА набора (res750.pal), и подменять его не надо.
|
||||
SETS = {
|
||||
"GUARD": ("GUARD", "guard", None),
|
||||
"SKEL": ("SKEL", "skel", "res750.pal"),
|
||||
"VIZIER": ("VIZIER", "vizier", "res750.pal"),
|
||||
}
|
||||
SET_NAME = "GUARD"
|
||||
GUARD_DIR = str(P.SDLPOP_DATA / "GUARD")
|
||||
PAL_BIN = str(P.SDLPOP_DATA / "PRINCE" / "res10.bin")
|
||||
OUT_DIR = str(P.atl("guard"))
|
||||
PAL_BASE = 0x90 # слоты палитры Sprinter под стража
|
||||
COLOR = 2 # level.guards_color обоих стражей уровня 1
|
||||
SHIFT = 3 # g[id>>3], idx id&7 (спрайты крупные)
|
||||
MASK = (1 << SHIFT) - 1
|
||||
MAX_ID = 33 # атлас-индексы 0..33 = res751..res784
|
||||
|
||||
|
||||
def select_set(name):
|
||||
"""Переключить упаковщик на набор (GUARD/SKEL/VIZIER): каталоги и число
|
||||
кадров."""
|
||||
global SET_NAME, GUARD_DIR, OUT_DIR, MAX_ID
|
||||
if name not in SETS:
|
||||
B.die("неизвестный набор %s (есть: %s)" % (name, ", ".join(SETS)))
|
||||
SET_NAME = name
|
||||
src, out, _ = SETS[name]
|
||||
GUARD_DIR = str(P.SDLPOP_DATA / src)
|
||||
OUT_DIR = str(P.atl(out))
|
||||
# Сколько кадров реально есть в наборе (у скелета их меньше, чем у стража).
|
||||
n = 0
|
||||
while os.path.exists(os.path.join(GUARD_DIR, f"res{751 + n}.png")):
|
||||
n += 1
|
||||
MAX_ID = n - 1
|
||||
|
||||
|
||||
def set_palette():
|
||||
"""16 цветов набора из его res750.pal (для наборов, где цвет НЕ
|
||||
подменяется из res10.bin). None — у набора своей палитры нет."""
|
||||
fname = SETS[SET_NAME][2]
|
||||
if fname is None:
|
||||
return None
|
||||
b = open(os.path.join(GUARD_DIR, fname), "rb").read()
|
||||
return [(b[4 + i * 3], b[4 + i * 3 + 1], b[4 + i * 3 + 2]) for i in range(16)]
|
||||
|
||||
|
||||
def load_guard(id):
|
||||
"""Атлас-индекс id (== frame.image) -> res(751+id).png -> (w, h, 8bpp
|
||||
COLUMN-MAJOR); 0 -> 0xFF (прозрачность).
|
||||
|
||||
Off-by-one как у Kid (см. pop_pack_kid.load_kid): load_chtab_from_file
|
||||
зовётся с ресурсом 750, а images[i-1] = res(750+i), то есть images[0] =
|
||||
res751. Рисование берёт get_image(chtab, obj_id+1 − 1) = images[image],
|
||||
значит frame.image=N -> res(751+N). С res(750+N) страж стоял в позе
|
||||
предыдущего кадра (замечено на frame_166_stand_inactive: image 17
|
||||
рисовался как res767 — выпад — вместо res768). """
|
||||
path = os.path.join(GUARD_DIR, f"res{751 + id}.png")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
B.die(f"res{750+id}: 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)
|
||||
|
||||
|
||||
NPAL = 7 # guard_palettes: 7 палитр по 16 цветов (res10.bin)
|
||||
|
||||
|
||||
def guard_palette(color=None):
|
||||
"""16 цветов (r,g,b 0..63) из res10.bin для палитры color (1..7).
|
||||
Индексация как в оригинале (seg003:257): &guard_palettes[0x30*color-0x30].
|
||||
color=None — палитра COLOR (та, что запекается в kid.pal по умолчанию)."""
|
||||
if color is None:
|
||||
color = COLOR
|
||||
b = open(PAL_BIN, "rb").read()
|
||||
off = 0x30 * color - 0x30
|
||||
return [(b[off + i * 3], b[off + i * 3 + 1], b[off + i * 3 + 2]) for i in range(16)]
|
||||
|
||||
|
||||
def write_pal_header(out_path):
|
||||
"""Таблица ВСЕХ палитр стражей -> заголовок для приложения.
|
||||
|
||||
Оригинал держит спрайты стража в одной палитре, а цвет подменяет на лету:
|
||||
redraw_screen (seg003:255) зовёт set_chtab_palette(chtab_5_guard,
|
||||
&guard_palettes[0x30*curr_guard_color - 0x30], 16) ПЕРЕД отрисовкой
|
||||
комнаты, где curr_guard_color = level.guards_color[room-1] & 0x0F
|
||||
(enter_guard, seg002:184). У нас спрайты уже лежат в индексах
|
||||
PAL_BASE + v, поэтому достаточно залить 16 слотов палитры.
|
||||
|
||||
Формат записи — родной для gfx_pal_load: 4 байта (B, G, R, 0)."""
|
||||
rows = []
|
||||
for color in range(1, NPAL + 1):
|
||||
vals = []
|
||||
for (r, g, b) in guard_palette(color):
|
||||
# res10.bin хранит 6-битные каналы, палитра Sprinter — 8-битные
|
||||
# (так же делает pop_pack_kid при записи kid.pal).
|
||||
vals += [B.scale6to8(b), B.scale6to8(g), B.scale6to8(r), 0]
|
||||
rows.append(" { " + ",".join("0x%02X" % v for v in vals) + " },")
|
||||
lines = [
|
||||
"/* pop_guard_pal.h — палитры стражей (guard_palettes из res10.bin).",
|
||||
" * СГЕНЕРЕНО toolchain/pop_pack_guard.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Индексация как в оригинале: цвет из level.guards_color[room-1]",
|
||||
" * (младшие 4 бита), 1..%d -> строка [color-1]; 0 = палитру НЕ менять" % NPAL,
|
||||
" * (seg003:255 при curr_guard_color == 0 set_chtab_palette не зовёт).",
|
||||
" * Запись — 4 байта (B, G, R, 0), формат gfx_pal_load. */",
|
||||
"#ifndef POP_GUARD_PAL_H",
|
||||
"#define POP_GUARD_PAL_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
"#define POP_GUARD_PAL_BASE 0x%02X /* слоты Sprinter под chtab_5 */" % PAL_BASE,
|
||||
"#define POP_GUARD_NPAL %d" % NPAL,
|
||||
"",
|
||||
"static const uint8_t pop_guard_pal[POP_GUARD_NPAL][64] = {",
|
||||
] + rows + [
|
||||
"};",
|
||||
"",
|
||||
]
|
||||
# Палитры наборов со СВОИМ цветом (скелет, визирь) — из их res750.pal, а
|
||||
# не из res10.bin: на таких уровнях curr_guard_color = 0 (seg002:186), и
|
||||
# оригинал палитру не подменяет вовсе.
|
||||
saved = SET_NAME
|
||||
for name, cname, human in (("SKEL", "pop_skel_pal", "Скелет"),
|
||||
("VIZIER", "pop_vizier_pal", "Джафар")):
|
||||
select_set(name)
|
||||
pal = set_palette()
|
||||
select_set(saved)
|
||||
if not pal:
|
||||
continue
|
||||
vals = []
|
||||
for (r, g, b) in pal:
|
||||
vals += [B.scale6to8(b), B.scale6to8(g), B.scale6to8(r), 0]
|
||||
lines += [
|
||||
"/* %s (%s/res750.pal): свой набор — свой цвет. */" % (human, name),
|
||||
"static const uint8_t %s[64] = {" % cname,
|
||||
" " + ",".join("0x%02X" % v for v in vals) + ",",
|
||||
"};",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
"#endif",
|
||||
"",
|
||||
]
|
||||
with open(out_path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
print(f" pop_guard_pal.h: {NPAL} палитр x 16 цветов = {NPAL*64} Б -> {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
pages = {}
|
||||
n = 0
|
||||
for id in range(0, MAX_ID + 1):
|
||||
g = load_guard(id)
|
||||
if g is None:
|
||||
continue
|
||||
pages.setdefault(id >> SHIFT, {})[id & MASK] = g
|
||||
n += 1
|
||||
if not pages:
|
||||
B.die("нет спрайтов стража")
|
||||
total = 0
|
||||
for pg in range(max(pages) + 1):
|
||||
nm = f"g{pg}.atl"
|
||||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
|
||||
total += sz
|
||||
# Зеркальный по вертикали набор — пара к kid*_v.atl (см. vflip_cols).
|
||||
# ТОЛЬКО для обычного стража: зелье инверсии есть на уровне 9, а там
|
||||
# tbl_guard_type = 0, то есть страж; скелет (уровень 3), визирь и
|
||||
# жирный с переворотом не встречаются. Если чит U всё-таки включат
|
||||
# на таком уровне, соперник нарисуется неперевёрнутым кадром —
|
||||
# pop_vflip_page вернёт 0 (pop_vflip.h).
|
||||
if SET_NAME == "GUARD":
|
||||
_, szv = B.pack_atlas(os.path.join(OUT_DIR, f"g{pg}_v.atl"),
|
||||
vflip_page(pages.get(pg, {})))
|
||||
total += szv
|
||||
print(f" {nm}: {len(pages.get(pg, {}))} спрайтов, каталог {cnt}, {sz} Б")
|
||||
write_pal_header(str(P.GEN / "pop_guard_pal.h"))
|
||||
print(f"ИТОГО: {n} спрайтов стража, {max(pages)+1} EMM-страниц, {total} Б -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
select_set(sys.argv[1].upper())
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_pack_music.py — музыка PoP в сырой поток для CBL.
|
||||
|
||||
Источник — готовые записи DOS-версии (assets/orig/PoP1_DOS_music).
|
||||
Набор выбирается ключом --format: flac (по умолчанию), mp3, ogg, mt32. Оцифровки музыки в самой игре НЕТ: в digisnd*.dat лежат
|
||||
только эффекты, а музыка хранится как MIDI под Adlib. Поэтому здесь мы
|
||||
не «конвертируем ресурс игры», а укладываем готовую запись в формат,
|
||||
который наше железо играет без единого преобразования в рантайме.
|
||||
|
||||
ФОРМАТ — тот же, что у эффектов (см. pop_pack_sound.py и sound_plan.md):
|
||||
8 бит БЕЗЗНАКОВЫЙ моно на 10 937,5 Гц — ровно CBL_FREQ_10K9. Частоту
|
||||
берём не 11 000, а частоту железа: тогда CBL открывается ОДИН раз за
|
||||
запуск и не переключается ни между эффектами, ни при переходе на музыку.
|
||||
|
||||
ОДИН ФАЙЛ НА ТРЕК. Резать его на куски не нужно: читаем порциями по
|
||||
16 КБ из ОДНОГО открытого файла (`bank_read_page` + `lseek`), и «дорогой»
|
||||
open платится раз на трек, а не на каждую страницу — замер 2026-08-25:
|
||||
open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же путь работает для
|
||||
финального `won` (1,2 МБ), который в память целиком не помещается и
|
||||
обязан подгружаться по ходу.
|
||||
|
||||
Хвост дополняется тишиной (0x80) до кратности 128: насос отдаёт CBL
|
||||
блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
|
||||
досчитывать в прерывании.
|
||||
|
||||
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами:
|
||||
самый длинный трек (won, 115 с) — это 1,2 МБ, что не влезает в uint16, а
|
||||
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
|
||||
avoid_32bit_arith_z80).
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
# Формат источника выбирается ключом --format (по умолчанию flac — он же
|
||||
# лучший по качеству исходник). Каталог и расширение знает tools/paths.py.
|
||||
FMT = "flac"
|
||||
SRC_DIR, SRC_EXT = P.music_src(FMT)
|
||||
OUT_DIR = P.atl("music")
|
||||
OUT_H = P.GEN / "pop_music_tbl.h"
|
||||
OUT_TICKS = P.GEN / "pop_music_ticks.h"
|
||||
|
||||
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
|
||||
PAGE = 16384 # EMM-страница
|
||||
BLOCK = 128 # порция насоса CBL
|
||||
SILENCE = 0x80 # центр беззнакового 8-битного сэмпла
|
||||
|
||||
# id — нумерация звуков ОРИГИНАЛА (SDLPoP data/music/names.txt). Держим её,
|
||||
# чтобы игровой код просил трек тем же номером, каким оригинал зовёт
|
||||
# play_sound, и таблицу можно было сверить глазами.
|
||||
TRACKS = {
|
||||
24: "regular_death",
|
||||
25: "find_mirror_and_find_shadow",
|
||||
26: "embrace",
|
||||
27: "cutscene_before_2_4_6_12",
|
||||
28: "fight_death",
|
||||
29: "meet_Jaffar",
|
||||
30: "life_potion",
|
||||
32: "end_level_4_and_killed_by_shadow",
|
||||
33: "potion",
|
||||
35: "cutscene_before_8_9",
|
||||
36: "out_of_time",
|
||||
37: "guard_death_and_obtaining_the_sword",
|
||||
39: "floating",
|
||||
40: "cutscene_not_much_time_left",
|
||||
41: "story_5_level_end_and_merge_with_shadow",
|
||||
43: "killed_Jaffar",
|
||||
50: "story_2_princess",
|
||||
52: "story_4_Jaffar_leaves",
|
||||
53: "story_3_Jaffar_enters",
|
||||
54: "intro_theme",
|
||||
55: "story_1_absence",
|
||||
56: "won",
|
||||
}
|
||||
|
||||
|
||||
def decode(path: Path) -> bytes:
|
||||
"""запись любого формата -> сырой u8 моно на частоте CBL."""
|
||||
out = subprocess.run(
|
||||
["ffmpeg", "-v", "error", "-i", str(path),
|
||||
"-ac", "1", "-ar", str(int(CBL_RATE)), "-f", "u8", "-"],
|
||||
capture_output=True, check=True)
|
||||
return out.stdout
|
||||
|
||||
|
||||
def pack(track_id: int, name: str) -> tuple[int, int]:
|
||||
src = SRC_DIR / f"{name}{SRC_EXT}"
|
||||
if not src.exists():
|
||||
raise SystemExit(f"нет источника: {src}")
|
||||
data = bytearray(decode(src))
|
||||
|
||||
# Добить до кратности блока насоса — хвост тишиной, а не обрезкой:
|
||||
# обрезать значило бы щёлкнуть на последнем сэмпле.
|
||||
tail = len(data) % BLOCK
|
||||
if tail:
|
||||
data += bytes([SILENCE]) * (BLOCK - tail)
|
||||
|
||||
chunks = (len(data) + PAGE - 1) // PAGE
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(OUT_DIR / f"m{track_id:02d}.bin").write_bytes(bytes(data))
|
||||
return chunks, len(data) // BLOCK
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global FMT, SRC_DIR, SRC_EXT
|
||||
args = sys.argv[1:]
|
||||
if args and args[0] == "--format":
|
||||
FMT = args[1]
|
||||
SRC_DIR, SRC_EXT = P.music_src(FMT)
|
||||
args = args[2:]
|
||||
wanted = [int(a) for a in args] or sorted(TRACKS)
|
||||
print(f"музыка из набора {FMT}: {SRC_DIR}")
|
||||
rows = []
|
||||
total = 0
|
||||
for tid in wanted:
|
||||
if tid not in TRACKS:
|
||||
raise SystemExit(f"неизвестный трек {tid}")
|
||||
chunks, blocks = pack(tid, TRACKS[tid])
|
||||
rows.append((tid, TRACKS[tid], chunks, blocks))
|
||||
total += blocks * BLOCK
|
||||
print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, "
|
||||
f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с")
|
||||
|
||||
max_id = max(t for t, *_ in rows)
|
||||
lines = [
|
||||
"/*",
|
||||
" * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.",
|
||||
f" * Источник: assets/orig/PoP1_DOS_music/{P.MUSIC_FORMATS[FMT][0]}"
|
||||
" (записи DOS-версии).",
|
||||
" * Генератор: tools/pop_pack_music.py. Руками не править.",
|
||||
" *",
|
||||
" * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —",
|
||||
" * сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека",
|
||||
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).",
|
||||
" *",
|
||||
" * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица",
|
||||
" * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,",
|
||||
" * платить за неё копией таблицы незачем (поймано на pop_intro.c:",
|
||||
" * +172 байта в банк 11).",
|
||||
" *",
|
||||
" * POP_MUS_TICKS_<id> — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).",
|
||||
" * Нужна там, где сценарий ждёт окончания реплики: у оригинала это",
|
||||
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза",
|
||||
" * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум",
|
||||
" * причинам: blocks*40 переполняет uint16 на won (9855 порций), и",
|
||||
" * число обязано соответствовать НАБОРУ, которым собрали (flac и",
|
||||
" * mt32 — разные исполнения разной длины).",
|
||||
" *",
|
||||
" * Отношение 40/57 — обратное к тому, которым шкала сцены переводит",
|
||||
" * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,",
|
||||
" * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы",
|
||||
" * считались одной и той же арифметикой. Округление — к ближайшему",
|
||||
" * (+28 перед делением): отбрасывание вниз теряло по тику на реплику.",
|
||||
" */",
|
||||
"#ifndef POP_MUSIC_TBL_H",
|
||||
"#define POP_MUSIC_TBL_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
f"#define POP_MUS_COUNT {max_id + 1}",
|
||||
f"#define POP_MUS_BLOCK {BLOCK}",
|
||||
f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
"",
|
||||
"typedef struct {",
|
||||
" uint8_t pages; /* 0 — трека нет */",
|
||||
" uint16_t blocks; /* длина в порциях по 128 байт */",
|
||||
"} pop_mus_ent_t;",
|
||||
"",
|
||||
"static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {",
|
||||
]
|
||||
by_id = {t: (c, b, n) for t, n, c, b in rows}
|
||||
for i in range(max_id + 1):
|
||||
if i in by_id:
|
||||
c, b, n = by_id[i]
|
||||
lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */")
|
||||
else:
|
||||
lines.append(f" {{ 0, 0 }}, /* {i:2d} */")
|
||||
lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""]
|
||||
OUT_H.write_text("\n".join(lines))
|
||||
|
||||
# Отдельный заголовок ТОЛЬКО с длительностями реплик. Данных в нём нет,
|
||||
# поэтому его может включать кто угодно, ничего не утаскивая в свой банк.
|
||||
tk = [
|
||||
"/*",
|
||||
" * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.",
|
||||
f" * Набор: {P.MUSIC_FORMATS[FMT][0]}. Генератор: tools/pop_pack_music.py.",
|
||||
" * Руками не править.",
|
||||
" *",
|
||||
" * Нужны там, где сценарий ждёт окончания реплики: у оригинала это",
|
||||
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной",
|
||||
" * в НАШУ запись. Число обязано соответствовать набору, которым собрана",
|
||||
" * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985",
|
||||
" * тиков против 1044), и захардкоженная константа молча ломает сцену.",
|
||||
" *",
|
||||
" * Считается здесь, а не в C: blocks*40 переполняет uint16 на won",
|
||||
" * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала",
|
||||
" * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём",
|
||||
" * именно его, чтобы длина реплики и ход шкалы считались одной",
|
||||
" * арифметикой. Округление к ближайшему: вниз теряло по тику.",
|
||||
" */",
|
||||
"#ifndef POP_MUSIC_TICKS_H",
|
||||
"#define POP_MUSIC_TICKS_H",
|
||||
"",
|
||||
]
|
||||
for tid, name, _c, blocks in rows:
|
||||
tk.append(f"#define POP_MUS_TICKS_{tid:<3d} {(blocks * 40 + 28) // 57:5d}"
|
||||
f" /* {name} */")
|
||||
tk += ["", "#endif /* POP_MUSIC_TICKS_H */", ""]
|
||||
OUT_TICKS.write_text("\n".join(tk))
|
||||
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
|
||||
print(f"каталог: {OUT_H}")
|
||||
print(f"тики: {OUT_TICKS}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_shadow.py — упаковщик спрайтов ТЕНИ.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНЫЙ НАБОР. Оригинал не хранит спрайтов Тени вовсе: он кладёт
|
||||
обычный спрайт ДВАЖДЫ — в x обычным прозрачным блитом, и в x+1 «блиттером
|
||||
XOR» (draw_objtable_item, seg008.c:1600). Наш пакетный блит так не умеет,
|
||||
поэтому результат запекается заранее, здесь.
|
||||
|
||||
ЧТО ИМЕННО ДЕЛАЕТ ОРИГИНАЛ (проверено по исходнику, не по памяти):
|
||||
|
||||
* `blitters_2_or` — НЕ побитовое ИЛИ. В SDLPoP это обычный блит с
|
||||
colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306), то
|
||||
есть один в один наш прозрачный блит.
|
||||
* `blitters_3_xor` работает по 24-битному RGB, а не по индексам палитры
|
||||
(blit_xor, seg009.c:3190: конвертация в 24 бита, затем `*p_dest ^=
|
||||
*p_src` побайтно). Прозрачности у него нет — XOR'ится весь
|
||||
прямоугольник, но прозрачный пиксель это чёрный 0x000000, а XOR с
|
||||
нулём ничего не меняет.
|
||||
|
||||
Отсюда своя палитра: XOR двух цветов игровой палитры даёт цвет, которого в
|
||||
ней нет.
|
||||
|
||||
ЕДИНСТВЕННОЕ РАСХОЖДЕНИЕ. XOR считается по тому, что УЖЕ на экране, то
|
||||
есть зависит от фона. Разбор по пикселям: от фона зависит только кайма в
|
||||
один пиксель по ЛЕВЫМ кромкам силуэта (там, где спрайт прозрачен в x, но
|
||||
непрозрачен в x−1). На чёрном фоне `фон XOR цвет == цвет`, и запечка
|
||||
точна; Тень почти всегда на чёрном. Мы печём именно этот случай.
|
||||
|
||||
ДВА НАБОРА В ОДНОМ АТЛАСЕ. Набор спрайтов у оригинала выбирает НЕ
|
||||
персонаж, а поле `sword` самого кадра: load_frame_to_obj (seg008.c:1752)
|
||||
держит chtab_base жёстко равным chtab_2 и прибавляет `sword >> 6`. У всех
|
||||
кадров frame_table_kid эти биты нулевые, у всех кадров frame_tbl_guard —
|
||||
0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533), то
|
||||
есть идёт через chtab_5, а chtab_5 на 12-м уровне грузится из SHADOW.DAT
|
||||
(tbl_guard_type[12] == 4, seg000.c:1117) — и это графика КИДА в боевых
|
||||
позах, а не стража. Поэтому печём обе половины:
|
||||
|
||||
KID — data/KID/res(401+id).png, id 0..218 -> sk0..skN.atl
|
||||
SHADOW — data/SHADOW/res(751+id).png, id 0..31 -> sf0..sfM.atl
|
||||
|
||||
Выход: build/atl/shadow/sk*.atl + sf*.atl + pop_shadow_atlas.h (палитра
|
||||
внутри заголовка — 16 записей, её заливает приложение через gfx_pal_load).
|
||||
"""
|
||||
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
|
||||
from pop_pack_kid import transpose_cols
|
||||
|
||||
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)
|
||||
|
||||
DATA = str(P.SDLPOP_DATA)
|
||||
OUT_DIR = str(P.atl("shadow"))
|
||||
PAL_BASE = 0xA0 # слоты Sprinter под Тень: 0xA0..0xAF
|
||||
NCOL = 16 # столько цветов в палитре Тени
|
||||
SHIFT = 3 # sk[id>>3], idx id&7 — как у Kid (кадры крупные)
|
||||
MASK = (1 << SHIFT) - 1
|
||||
|
||||
# Половины набора: (каталог, база ресурса, префикс выходных файлов).
|
||||
# Off-by-one тот же, что у Kid и стража: images[0] = res(база+1), значит
|
||||
# frame.image = N -> res(база+1+N) (см. pop_pack_kid.load_kid).
|
||||
HALVES = (("KID", 400, "sk"),
|
||||
("SHADOW", 750, "sf"))
|
||||
|
||||
|
||||
def frames(dirname, base):
|
||||
"""Все кадры половины: [(id, w, h, [[rgb|None]...])] — уже после
|
||||
наложения XOR, в честном RGB."""
|
||||
out = []
|
||||
id = 0
|
||||
while True:
|
||||
path = os.path.join(DATA, dirname, f"res{base + 1 + id}.png")
|
||||
if not os.path.exists(path):
|
||||
break
|
||||
im = Image.open(path)
|
||||
if im.mode != "P":
|
||||
B.die(f"{path}: mode {im.mode}, ожидался P")
|
||||
pal = im.getpalette()[:NCOL * 3]
|
||||
rgb = [tuple(pal[i * 3:i * 3 + 3]) for i in range(NCOL)]
|
||||
w, h = im.size
|
||||
px = im.load()
|
||||
grid = []
|
||||
for y in range(h):
|
||||
# Ширина на один больше: второй проход кладётся в x+1, и
|
||||
# суммарный след занимает [x, x+w].
|
||||
row = [None] * (w + 1)
|
||||
for x in range(w): # проход 1: обычный блит
|
||||
i = px[x, y]
|
||||
if i:
|
||||
row[x] = rgb[i]
|
||||
for x in range(w): # проход 2: XOR в x+1
|
||||
i = px[x, y]
|
||||
if not i:
|
||||
continue # чёрный: XOR — не операция
|
||||
s = rgb[i]
|
||||
t = x + 1
|
||||
row[t] = s if row[t] is None else tuple(a ^ b for a, b in zip(row[t], s))
|
||||
grid.append(row)
|
||||
out.append((id, w + 1, h, grid))
|
||||
id += 1
|
||||
return out
|
||||
|
||||
|
||||
def histogram(halves):
|
||||
h = {}
|
||||
for _, fr in halves:
|
||||
for _, _, _, grid in fr:
|
||||
for row in grid:
|
||||
for c in row:
|
||||
if c is not None:
|
||||
h[c] = h.get(c, 0) + 1
|
||||
return h
|
||||
|
||||
|
||||
def _d2(a, b):
|
||||
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
|
||||
|
||||
|
||||
def _kmeans(pts, k, init):
|
||||
"""Взвешенный Ллойд по СПИСКУ ЦВЕТОВ с весами (их полсотни, не пиксели)."""
|
||||
cent = list(init)
|
||||
for _ in range(80):
|
||||
groups = [[] for _ in cent]
|
||||
for c, n in pts:
|
||||
groups[min(range(len(cent)), key=lambda j: _d2(c, cent[j]))].append((c, n))
|
||||
new = []
|
||||
for j, g in enumerate(groups):
|
||||
if not g:
|
||||
new.append(cent[j])
|
||||
continue
|
||||
w = sum(n for _, n in g)
|
||||
new.append(tuple(int(round(sum(c[i] * n for c, n in g) / w)) for i in range(3)))
|
||||
if new == cent:
|
||||
break
|
||||
cent = new
|
||||
return cent
|
||||
|
||||
|
||||
def build_palette(hist):
|
||||
"""16 цветов ГИБРИДОМ: часть слотов — самые частые цвета ТОЧНО, остаток
|
||||
— взвешенные кластеры хвоста.
|
||||
|
||||
Почему не «просто самые частые»: тогда редкие цвета уезжают в далёкий
|
||||
ближайший, и грубых промахов (сдвиг >90 по евклиду в RGB при максимуме
|
||||
441) получается впятеро больше — 381 пиксель против 76 на все 251
|
||||
кадр (замер 2026-08-20). Почему не чистый k-means: он двигает вообще
|
||||
все цвета, включая массовые.
|
||||
|
||||
Разделение слотов подбираем перебором по критерию «сначала минимум
|
||||
грубых промахов, потом заметных, потом худшая ошибка»."""
|
||||
items = sorted(hist.items(), key=lambda kv: -kv[1])
|
||||
|
||||
def stats(pal):
|
||||
bad = worst = mid = big = 0
|
||||
for c, n in items:
|
||||
e = min(_d2(c, p) for p in pal) ** 0.5
|
||||
if e > 0:
|
||||
bad += n
|
||||
worst = max(worst, e)
|
||||
if e >= 90:
|
||||
big += n
|
||||
elif e >= 40:
|
||||
mid += n
|
||||
return bad, mid, big, worst
|
||||
|
||||
best = None
|
||||
for m in range(1, NCOL): # m слотов под хвост
|
||||
keep = [c for c, _ in items[:NCOL - m]]
|
||||
rest = [(c, n) for c, n in items[NCOL - m:]]
|
||||
if not rest:
|
||||
continue
|
||||
pal = keep + _kmeans(rest, m, [c for c, _ in rest[:m]])
|
||||
bad, mid, big, worst = stats(pal)
|
||||
score = (big, mid, worst)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, m, pal, (bad, mid, big, worst))
|
||||
_, m, pal, st = best
|
||||
# Чёрный обязан быть ТОЧНЫМ и лежать в слоте 0: это XOR-погашенная
|
||||
# середина силуэта, больше половины всех пикселей Тени.
|
||||
j = min(range(len(pal)), key=lambda i: _d2((0, 0, 0), pal[i]))
|
||||
pal[j] = (0, 0, 0)
|
||||
pal[0], pal[j] = pal[j], pal[0]
|
||||
bad, mid, big, worst = st
|
||||
tot = sum(hist.values())
|
||||
print(f" палитра: {NCOL - m} частых точно + {m} кластеров хвоста; "
|
||||
f"из {len(items)} цветов изменено {bad} px из {tot} "
|
||||
f"({100.0 * bad / tot:.2f} %), заметно {mid}, сильно {big}, худшая {worst:.0f}")
|
||||
return pal
|
||||
|
||||
|
||||
def encode(grid, w, h, pal):
|
||||
"""RGB-сетка -> байты атласа: нет пикселя -> 0xFF, цвет -> PAL_BASE + k.
|
||||
Чёрный — НАСТОЯЩИЙ цвет Тени (слот 0), а не прозрачность."""
|
||||
row = bytearray()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
c = grid[y][x]
|
||||
if c is None:
|
||||
row.append(0xFF)
|
||||
else:
|
||||
row.append(PAL_BASE + min(range(len(pal)), key=lambda k: _d2(c, pal[k])))
|
||||
return transpose_cols(w, h, bytes(row))
|
||||
|
||||
|
||||
def write_header(path, counts, pal):
|
||||
vals = []
|
||||
for (r, g, b) in pal:
|
||||
vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
|
||||
lines = [
|
||||
"/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
|
||||
" * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Две половины, потому что и у оригинала их две (см. шапку",
|
||||
" * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
|
||||
" * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.",
|
||||
" * Какая половина нужна, приложение уже знает — тем же условием,",
|
||||
" * что выбирает таблицу кадров (pop_frame_tbl_is_guard).",
|
||||
" *",
|
||||
" * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход",
|
||||
" * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].",
|
||||
" * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.",
|
||||
" */",
|
||||
"#ifndef POP_SHADOW_ATLAS_H",
|
||||
"#define POP_SHADOW_ATLAS_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
"#define POP_SHADOW_SHIFT %d" % SHIFT,
|
||||
"#define POP_SHADOW_MASK %d" % MASK,
|
||||
"#define POP_SHADOW_PAL_BASE 0x%02X /* слоты Sprinter под Тень */" % PAL_BASE,
|
||||
"",
|
||||
"/* Половина «вне боя» — кадры Кида (chtab_2). */",
|
||||
"#define POP_SHADOW_KID_PAGES %d" % counts["sk"],
|
||||
"/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
|
||||
"#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
|
||||
"",
|
||||
"static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
|
||||
]
|
||||
lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
|
||||
"static const uint8_t pop_shadow_pal[64] = {",
|
||||
" " + ",".join("0x%02X" % v for v in vals) + ",",
|
||||
"};",
|
||||
"",
|
||||
"#endif",
|
||||
"",
|
||||
]
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
print(f" pop_shadow_atlas.h -> {path}")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
halves = [(prefix, frames(d, base)) for d, base, prefix in HALVES]
|
||||
for (d, base, prefix), (_, fr) in zip(HALVES, halves):
|
||||
if not fr:
|
||||
B.die(f"нет спрайтов в data/{d} (SHADOW.DAT распакован?)")
|
||||
print(f" {d}: {len(fr)} кадров")
|
||||
pal = build_palette(histogram(halves))
|
||||
|
||||
counts, total, nspr = {}, 0, 0
|
||||
for prefix, fr in halves:
|
||||
pages = {}
|
||||
for id, w, h, grid in fr:
|
||||
pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, encode(grid, w, h, pal))
|
||||
nspr += 1
|
||||
npg = max(pages) + 1
|
||||
counts[prefix] = npg
|
||||
for pg in range(npg):
|
||||
nm = f"{prefix}{pg}.atl"
|
||||
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
|
||||
total += sz
|
||||
print(f" {prefix}*.atl: {len(fr)} спрайтов, {npg} EMM-страниц")
|
||||
write_header(str(P.GEN / "pop_shadow_atlas.h"), counts, pal)
|
||||
print(f"ИТОГО: {nspr} спрайтов Тени, "
|
||||
f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pop_pack_sound.py — упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет).
|
||||
|
||||
ИСТОЧНИК. `MSDOS/digisnd1..3.dat` — оригинальные контейнеры. Разбор:
|
||||
индекс по 8 байт (id, offset, size), **первый байт ресурса — контрольная
|
||||
сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`,
|
||||
§3.1.2). Тело — один из двух вариантов заголовка:
|
||||
|
||||
digi_type (1.0/1.1): word rate, word count, word unk, byte size=8
|
||||
digi_new_type (1.3/1.4): word rate, byte size=8, word count, word, word
|
||||
|
||||
Различаем по позиции байта размера: у нового он на смещении 3, у старого
|
||||
на 7. Сэмплы — 8 бит БЕЗЗНАКОВЫЕ, МОНО (проверено по `convert_digi_sound`,
|
||||
seg009.c:2358: один байт на кадр, `(b | b<<8) - 32768`; стерео в данных
|
||||
нет вовсе, каналы размножаются уже на выходе). Это ровно формат нашего
|
||||
CBL — конвертировать нечего.
|
||||
|
||||
ЧАСТОТА. Приводим ВСЁ к 10 937,5 Гц — это `CBL_FREQ_10K9`, то есть
|
||||
частота железа. Не к 11 000: проигрывание 11 000-данных на 10 937,5 дало
|
||||
бы сдвиг тона −9,9 цента, а пересчитывать три файла (2 750, 8 200,
|
||||
14 000 Гц) всё равно надо. Взамен CBL открывается ОДИН раз и частота не
|
||||
меняется никогда — ни между эффектами, ни при переходе на музыку.
|
||||
|
||||
РАСКЛАДКА. Всё склеивается в EMM-страницы по 16 384 Б, и начало каждого
|
||||
звука ВЫРАВНИВАЕТСЯ НА 128. Это не косметика: блок запроса CBL — ровно
|
||||
128 байт, а 16 384 кратно 128, поэтому при таком выравнивании ни один
|
||||
блок не пересекает границу страницы. Проигрыватель за счёт этого не
|
||||
знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель.
|
||||
Плата — до 127 байт на звук, всего ~2 КБ.
|
||||
|
||||
Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
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)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DAT_DIR = str(P.MSDOS)
|
||||
OUT_DIR = str(P.atl("sound"))
|
||||
HDR = str(P.GEN / "pop_sound_tbl.h")
|
||||
|
||||
PAGE = 16384
|
||||
BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h)
|
||||
RATE = 10937.5 # CBL_FREQ_10K9
|
||||
NSOUND = 57 # id 0..56; у музыки оцифровки нет — длина 0
|
||||
|
||||
|
||||
def dat_resources(path):
|
||||
"""id -> тело ресурса (без байта контрольной суммы)."""
|
||||
d = open(path, "rb").read()
|
||||
off, _ = struct.unpack("<IH", d[0:6])
|
||||
n = struct.unpack("<H", d[off:off + 2])[0]
|
||||
out = {}
|
||||
for i in range(n):
|
||||
e = off + 2 + i * 8
|
||||
rid = struct.unpack("<H", d[e:e + 2])[0]
|
||||
roff = struct.unpack("<I", d[e + 2:e + 6])[0]
|
||||
rsz = struct.unpack("<H", d[e + 6:e + 8])[0]
|
||||
out[rid - 10000] = d[roff + 1:roff + 1 + rsz]
|
||||
return out
|
||||
|
||||
|
||||
def digi(body):
|
||||
"""(rate, samples) или None, если ресурс не оцифровка."""
|
||||
if not body or (body[0] & 7) != 1:
|
||||
return None
|
||||
if len(body) > 4 and body[3] == 8: # digi_new_type
|
||||
rate = struct.unpack("<H", body[1:3])[0]
|
||||
cnt = struct.unpack("<H", body[4:6])[0]
|
||||
head = 10
|
||||
elif len(body) > 7 and body[7] == 8: # digi_type
|
||||
rate, cnt = struct.unpack("<HH", body[1:5])
|
||||
head = 8
|
||||
else:
|
||||
return None
|
||||
return rate, body[head:head + cnt]
|
||||
|
||||
|
||||
def resample(src, src_rate, dst_rate):
|
||||
"""Линейная интерполяция. Данные 8 бит беззнаковые, поэтому и работаем
|
||||
в беззнаковых — никаких знаковых промежуточных представлений."""
|
||||
if src_rate == dst_rate or not src:
|
||||
return bytes(src)
|
||||
n = int(round(len(src) * dst_rate / src_rate))
|
||||
out = bytearray(n)
|
||||
step = src_rate / dst_rate
|
||||
for i in range(n):
|
||||
x = i * step
|
||||
i0 = int(x)
|
||||
if i0 >= len(src) - 1:
|
||||
out[i] = src[-1]
|
||||
else:
|
||||
a = x - i0
|
||||
out[i] = int(round(src[i0] * (1.0 - a) + src[i0 + 1] * a))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def main():
|
||||
res = {}
|
||||
for i in (1, 2, 3):
|
||||
p = os.path.join(DAT_DIR, f"digisnd{i}.dat")
|
||||
if not os.path.exists(p):
|
||||
sys.exit(f"нет файла {p}")
|
||||
res.update(dat_resources(p))
|
||||
|
||||
sounds = {}
|
||||
for sid, body in sorted(res.items()):
|
||||
g = digi(body)
|
||||
if g is None:
|
||||
print(f" звук {sid}: не оцифровка, пропуск")
|
||||
continue
|
||||
rate, samples = g
|
||||
pcm = resample(samples, rate, RATE)
|
||||
sounds[sid] = pcm
|
||||
if rate != 11000:
|
||||
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
|
||||
|
||||
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
|
||||
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
|
||||
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост
|
||||
# своего буфера (жужжание). Держим тишину СВОЮ, а не просим у libc
|
||||
# (CBL_UNDERRUN_SILENCE делает malloc), потому что куча в резиденте W2
|
||||
# тесная и её отказ ронял бы весь звук.
|
||||
blob = bytearray([0x80] * BLOCK)
|
||||
table = {}
|
||||
for sid in sorted(sounds):
|
||||
pad = (-len(blob)) % BLOCK
|
||||
blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит
|
||||
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это
|
||||
# выравнивающая тишина следующего звука, и проигрыватель за счёт
|
||||
# этого не знает слова «неполный блок».
|
||||
need = (len(sounds[sid]) + BLOCK - 1) // BLOCK * BLOCK
|
||||
# ЗВУК ЦЕЛИКОМ В ОДНОЙ СТРАНИЦЕ (решение пользователя 2026-08-25).
|
||||
# Раньше длинные эффекты перетекали в следующую страницу, и насос
|
||||
# переключал её прямо посреди воспроизведения — лишняя логика в ISR
|
||||
# и целый класс отказов «играет не тот звук». Теперь страница —
|
||||
# как атлас: не влезает в остаток — начинаем новую, хвост добиваем
|
||||
# тишиной. Платим страницами EMM, их у нас с запасом.
|
||||
if len(blob) % PAGE + need > PAGE:
|
||||
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
|
||||
table[sid] = (len(blob), need)
|
||||
blob += sounds[sid]
|
||||
blob += bytes([0x80]) * ((-len(blob)) % PAGE)
|
||||
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
npages = len(blob) // PAGE
|
||||
for i in range(npages):
|
||||
with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f:
|
||||
f.write(blob[i * PAGE:(i + 1) * PAGE])
|
||||
|
||||
lines = [
|
||||
"/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.",
|
||||
" * СГЕНЕРЕНО toolchain/pop_pack_sound.py — НЕ править вручную.",
|
||||
" *",
|
||||
" * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE,
|
||||
" * CBL один в один, конвертировать в рантайме нечего.",
|
||||
" *",
|
||||
" * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK,
|
||||
" * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE,
|
||||
" * границу страницы. Больше того: с 2026-08-25 каждый звук лежит\n * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем\n * новую (страницы EMM у нас с запасом). Единственное исключение —\n * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает\n * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.",
|
||||
" *",
|
||||
" * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80",
|
||||
" * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.",
|
||||
" * Длина округлена вверх до блока — неполных блоков не бывает.",
|
||||
" */",
|
||||
"#ifndef POP_SOUND_TBL_H",
|
||||
"#define POP_SOUND_TBL_H",
|
||||
"",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
"#define POP_SND_PAGES %d" % npages,
|
||||
"#define POP_SND_COUNT %d" % NSOUND,
|
||||
"#define POP_SND_BLOCK %d" % BLOCK,
|
||||
"/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */",
|
||||
"#define POP_SND_SILENCE_PAGE 0",
|
||||
"#define POP_SND_SILENCE_OFF 0x0000",
|
||||
"",
|
||||
"typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;",
|
||||
"",
|
||||
"/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */",
|
||||
"static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {",
|
||||
]
|
||||
for sid in range(NSOUND):
|
||||
off, ln = table.get(sid, (0, 0))
|
||||
lines.append(" { %d, 0x%04X, %5d }, /* %2d */" % (off >> 14, off & 0x3FFF, ln, sid))
|
||||
lines += [
|
||||
"};",
|
||||
"",
|
||||
"/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная",
|
||||
" * половина): иначе копия строк уезжает в каждый включивший заголовок",
|
||||
" * модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же",
|
||||
" * приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */",
|
||||
"#ifdef POP_SND_FILES",
|
||||
"static const char *const pop_snd_files[POP_SND_PAGES] = {",
|
||||
]
|
||||
lines += [' "SND\\\\s%d.bin",' % i for i in range(npages)]
|
||||
lines += ["};", "#endif", "", "#endif", ""]
|
||||
with open(HDR, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
have = sum(1 for s in sounds.values() if s)
|
||||
print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}")
|
||||
print(f" заголовок -> {HDR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_pack_title.py — титульные композиции SDLPoP для Sprinter.
|
||||
|
||||
Исходник — PNG-ресурсы ``data/TITLE/res51..55`` из локального SDLPoP.
|
||||
Runtime не должен держать 320x200 картинку в W2 и не разбирает PNG: каждая
|
||||
готовая композиция разрезана на пять getimage-лент высотой не более 49
|
||||
строк. Лента вместе с заголовком атласа занимает одну EMM-страницу; экран
|
||||
рисуется лента за лентой, и страница сразу освобождается.
|
||||
|
||||
Композиции в точности повторяют ``show_title()`` до story/intro:
|
||||
t0 — основной рисунок;
|
||||
t1 — рисунок + PRESENTS;
|
||||
t2 — рисунок + название игры;
|
||||
t3 — рисунок + логотип PRINCE OF PERSIA и Jordan Mechner.
|
||||
"""
|
||||
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
|
||||
SRC = P.SDLPOP_DATA / "TITLE"
|
||||
OUT = P.atl("title")
|
||||
|
||||
ATL_MAGIC = b"SPA1"
|
||||
ATL_DIR_OFF = 0x68
|
||||
ATL_MIN_SIZE = 0x100
|
||||
PAGE_SIZE = 16384
|
||||
STRIP_H = 49
|
||||
|
||||
|
||||
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")
|
||||
# Каталог хранит preview-габарит в uint8, но atlas_image читает только
|
||||
# offset; реальный getimage-width — u16 в блобе. Так уже пакуются
|
||||
# широкие 320-пиксельные полосы фона.
|
||||
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"title atlas {len(out)} B > {PAGE_SIZE} B")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def image(n: int) -> Image.Image:
|
||||
im = Image.open(SRC / f"res{n}.png")
|
||||
if im.mode != "P":
|
||||
raise SystemExit(f"res{n}.png: expected indexed PNG, got {im.mode}")
|
||||
return im.copy()
|
||||
|
||||
|
||||
def assert_palette(base: Image.Image, overlay: Image.Image, n: int) -> None:
|
||||
if base.getpalette()[:48] != overlay.getpalette()[:48]:
|
||||
raise SystemExit(f"res{n}.png uses a palette different from res51.png")
|
||||
|
||||
|
||||
def paste_opaque(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
|
||||
assert_palette(dst, src, n)
|
||||
dst.paste(src, (x, y))
|
||||
|
||||
|
||||
def paste_transparent(dst: Image.Image, src: Image.Image, x: int, y: int, n: int) -> None:
|
||||
assert_palette(dst, src, n)
|
||||
# TITLE_POP — единственный из пяти элементов, который SDLPoP рисует
|
||||
# blitters_10h_transp. В PNG тот же факт записан colour-key index 0.
|
||||
# Image.point() у P-изображения возвращает снова P, а paste() принимает
|
||||
# маску только L/1/RGBA. Строим L явно, не меняя индексы самой картинки.
|
||||
mask = Image.frombytes("L", src.size,
|
||||
bytes(0 if p == 0 else 255 for p in src.tobytes()))
|
||||
dst.paste(src, (x, y), mask)
|
||||
|
||||
|
||||
# Семнадцатая запись — фон текстовой рамки story (STORY_BG_INDEX в
|
||||
# pop_pack_intro.py). Она нужна здесь потому, что «In the absence…»
|
||||
# проявляется полосами ПОВЕРХ последнего кадра титров, то есть при ещё
|
||||
# загруженной title.pal: без этой записи фон рамки был бы чёрным.
|
||||
STORY_BG_INDEX = 16
|
||||
STORY_BG_COLOR = (0x10, 0x00, 0x60)
|
||||
|
||||
|
||||
def write_palette(base: Image.Image) -> None:
|
||||
rgb = base.getpalette()[:48]
|
||||
pal = bytearray()
|
||||
for i in range(16):
|
||||
r, g, b = rgb[i * 3:i * 3 + 3]
|
||||
pal += bytes((b, g, r, 0)) # родной формат gfx_pal_fload
|
||||
assert len(pal) == STORY_BG_INDEX * 4
|
||||
r, g, b = STORY_BG_COLOR
|
||||
pal += bytes((b, g, r, 0))
|
||||
(OUT / "title.pal").write_bytes(pal)
|
||||
|
||||
|
||||
def write_scene(scene: int, canvas: Image.Image) -> int:
|
||||
count = 0
|
||||
for part, top in enumerate(range(0, canvas.height, STRIP_H)):
|
||||
band = canvas.crop((0, top, canvas.width,
|
||||
min(top + STRIP_H, canvas.height)))
|
||||
path = OUT / f"t{scene}_{part}.atl"
|
||||
path.write_bytes(atlas_blob(band.tobytes(), band.width, band.height))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
main_image = image(51)
|
||||
if main_image.size != (320, 200):
|
||||
raise SystemExit(f"res51.png: expected 320x200, got {main_image.size}")
|
||||
|
||||
presents = main_image.copy()
|
||||
paste_opaque(presents, image(52), 96, 106, 52)
|
||||
|
||||
game = main_image.copy()
|
||||
paste_opaque(game, image(53), 96, 122, 53)
|
||||
|
||||
logo = main_image.copy()
|
||||
paste_transparent(logo, image(54), 24, 107, 54)
|
||||
paste_opaque(logo, image(55), 48, 184, 55)
|
||||
|
||||
write_palette(main_image)
|
||||
scenes = (main_image, presents, game, logo)
|
||||
strips = [write_scene(i, scene) for i, scene in enumerate(scenes)]
|
||||
assert strips == [5, 5, 5, 5]
|
||||
print(f"{OUT}: {len(scenes)} title compositions x {strips[0]} strips, "
|
||||
f"one strip <= {STRIP_H} rows / one EMM page")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+853
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse SDLPoP level file (res2001.bin) and render the first room by
|
||||
faithfully replaying the tile-drawing logic of seg008.c (draw_room/draw_tile).
|
||||
|
||||
Pixel model (from seg008.c):
|
||||
- screen is 320px wide, 10 columns.
|
||||
- col_xh[col] = {0,4,8,12,16,20,24,28,32,36}; x_px = xh*8 -> 0,32,64,...,288
|
||||
(tile column is 32px; the 4px offset handles overlap of floor/wall edges)
|
||||
- rows drawn 2,1,0 (bottom..top). For each row:
|
||||
draw_bottom_y = 63*drawn_row + 65
|
||||
draw_main_y = draw_bottom_y - 3
|
||||
row2 -> bottom_y=191, row1 -> 128, row0 -> 65
|
||||
- a tile sprite of height h drawn at ybottom is placed with its BOTTOM at ybottom:
|
||||
y = ybottom - h + 1
|
||||
|
||||
We replay draw_tile() which composites (in order):
|
||||
floorright, anim_topright, right, anim_right, bottom, loose, base, anim, fore
|
||||
Dependencies on NEIGHBOURS:
|
||||
- tile_left = tile in the column to the left (same room, same row)
|
||||
- row_below_left_[col] = tile in (room_below, col-1, row_below) -> for topright/floorright
|
||||
- For a single room with no links we treat out-of-range neighbours as walls
|
||||
(level edges), matching SDLPoP's custom->drawn_tile_*_level_edge defaults
|
||||
(left = wall, top = floor).
|
||||
|
||||
We ignore animated variants (spike frames, chomper, torch flames, gate
|
||||
animation) for this first static render; they use curr_modifier/backtable.
|
||||
"""
|
||||
|
||||
import os
|
||||
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)
|
||||
|
||||
LEVEL_DIR = str(P.SDLPOP_DATA / "LEVELS")
|
||||
VPALACE_DIR = str(P.SDLPOP_DATA / "VPALACE")
|
||||
VDUNGEON_DIR = str(P.SDLPOP_DATA / "VDUNGEON")
|
||||
|
||||
# For dungeon levels (tbl_level_type == 0) the environment sprites come from
|
||||
# VDUNGEON where present, otherwise fall back to the shared VPALACE set
|
||||
# (PRINCE.DAT common images). For palace levels use VPALACE only.
|
||||
IS_DUNGEON = True # room 1 (res2001) is the dungeon
|
||||
|
||||
# tile_table[code] = (base_id, floor_left, base_y, right_id, floor_right,
|
||||
# right_y, stripe_id, topright_id, bottom_id, fore_id,
|
||||
# fore_x, fore_y)
|
||||
TILE_TABLE = [
|
||||
( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), # 0x00 empty
|
||||
( 41, 1, 0, 42, 1, 2, 145, 0, 43, 0, 0, 0), # 0x01 floor
|
||||
(127, 1, 0, 133, 1, 2, 145, 0, 43, 0, 0, 0), # 0x02 spike
|
||||
( 92, 1, 0, 93, 1, 2, 0, 94, 43, 95, 1, 0), # 0x03 pillar
|
||||
( 46, 1, 0, 47, 1, 2, 0, 48, 43, 49, 3, 0), # 0x04 gate
|
||||
( 41, 1, 1, 35, 1, 3, 145, 0, 36, 0, 0, 0), # 0x05 stuck floor
|
||||
( 41, 1, 0, 42, 1, 2, 145, 0, 96, 0, 0, 0), # 0x06 close button
|
||||
( 46, 1, 0, 0, 0, 2, 0, 0, 43, 49, 3, 0), # 0x07 door top with floor
|
||||
( 86, 1, 0, 87, 1, 2, 0, 0, 43, 88, 1, 0), # 0x08 big pillar bottom
|
||||
( 0, 0, 0, 89, 0, 3, 0, 90, 0, 91, 1, 3), # 0x09 big pillar top
|
||||
( 41, 1, 0, 42, 1, 2, 145, 0, 43, 12, 2, -3), # 0x0A potion
|
||||
( 0, 1, 0, 0, 0, 0, 145, 0, 0, 0, 0, 0), # 0x0B loose floor
|
||||
( 0, 0, 0, 0, 0, 2, 0, 0, 85, 49, 3, 0), # 0x0C door top
|
||||
( 75, 1, 0, 42, 1, 2, 0, 0, 43, 77, 0, 0), # 0x0D mirror
|
||||
( 97, 1, 0, 98, 1, 2, 145, 0, 43, 100, 0, 0), # 0x0E debris
|
||||
(147, 1, 0, 42, 1, 1, 145, 0, 149, 0, 0, 0), # 0x0F open button
|
||||
( 41, 1, 0, 37, 0, 0, 0, 38, 43, 0, 0, 0), # 0x10 leveldoor left
|
||||
( 0, 0, 0, 39, 1, 2, 0, 40, 43, 0, 0, 0), # 0x11 leveldoor right
|
||||
( 0, 0, 0, 42, 1, 2, 145, 0, 43, 0, 0, 0), # 0x12 chomper
|
||||
( 41, 1, 0, 42, 1, 2, 0, 0, 43, 0, 0, 0), # 0x13 torch
|
||||
( 0, 0, 0, 1, 1, 2, 0, 2, 0, 0, 0, 0), # 0x14 wall
|
||||
( 30, 1, 0, 31, 1, 2, 0, 0, 43, 0, 0, 0), # 0x15 skeleton
|
||||
( 41, 1, 0, 42, 1, 2, 145, 0, 43, 0, 0, 0), # 0x16 sword
|
||||
( 41, 1, 0, 10, 0, 0, 0, 11, 43, 0, 0, 0), # 0x17 balcony left
|
||||
( 0, 0, 0, 12, 1, 2, 0, 13, 43, 0, 0, 0), # 0x18 balcony right
|
||||
( 92, 1, 0, 42, 1, 2, 145, 0, 43, 95, 1, 0), # 0x19 lattice pillar
|
||||
( 1, 0, 0, 0, 0, 0, 0, 0, 2, 9, 0, -53), # 0x1A lattice down
|
||||
( 3, 0, -10, 0, 0, 0, 0, 0, 0, 9, 0, -53), # 0x1B lattice small
|
||||
( 4, 0, -10, 0, 0, 0, 0, 0, 0, 9, 0, -53), # 0x1C lattice left
|
||||
( 5, 0, -10, 0, 0, 0, 0, 0, 0, 9, 0, -53), # 0x1D lattice right
|
||||
( 97, 1, 0, 98, 1, 2, 0, 0, 43, 100, 0, 0), # 0x1E debris with torch
|
||||
]
|
||||
|
||||
TILE_NAMES = {
|
||||
0x00: "empty", 0x01: "floor", 0x02: "spike", 0x03: "pillar",
|
||||
0x04: "gate", 0x05: "stuck_floor", 0x06: "close_button", 0x07: "door_top_floor",
|
||||
0x08: "big_pillar_bottom", 0x09: "big_pillar_top", 0x0A: "potion",
|
||||
0x0B: "loose_floor", 0x0C: "door_top", 0x0D: "mirror", 0x0E: "debris",
|
||||
0x0F: "open_button", 0x10: "leveldoor_left", 0x11: "leveldoor_right",
|
||||
0x12: "chomper", 0x13: "torch", 0x14: "wall", 0x15: "skeleton",
|
||||
0x16: "sword", 0x17: "balcony_left", 0x18: "balcony_right",
|
||||
0x19: "lattice_pillar", 0x1A: "lattice_down", 0x1B: "lattice_small",
|
||||
0x1C: "lattice_left", 0x1D: "lattice_right", 0x1E: "debris_torch",
|
||||
}
|
||||
|
||||
COL_XH = [0, 4, 8, 12, 16, 20, 24, 28, 32, 36]
|
||||
|
||||
# chtab -> directory/subfolder + id offset
|
||||
# id_chtab_6_environment -> VPALACE, res(200+id).png
|
||||
# id_chtab_7_environmentwall -> VPALACE (wall images also here, res(200+id))
|
||||
# id_chtab_1_flameswordpotion -> flames etc (skipped in static render)
|
||||
CHTAB_ENV = 6
|
||||
CHTAB_WALL = 7
|
||||
CHTAB_FSP = 1 # flames/sword/potion (animated, skipped)
|
||||
|
||||
|
||||
# ---- POP1 level file structure (see POP-DAT-FormatSpecifications.txt, sec 3.4) ----
|
||||
# Block layout (for the standard 2305-byte level):
|
||||
# 0 .. 720 pop1 foretable (24 rooms x 30 bytes)
|
||||
# 720 .. 1440 pop1 backtable (24 rooms x 30 bytes)
|
||||
# 1440 .. 1696 door I (256 bytes, one per event line)
|
||||
# 1696 .. 1952 door II (256 bytes)
|
||||
# 1952 .. 2048 links (24 rooms x 4 bytes: L,R,Up,Dn)
|
||||
# 2048 .. 2112 unknown I
|
||||
# 2112 .. 2115 start position (room, location, direction)
|
||||
# 2115 .. 2116 unknown II
|
||||
# 2116 .. 2119 unknown III
|
||||
# 2119 .. 2143 guard location (24)
|
||||
# 2143 .. 2167 guard direction (24)
|
||||
# 2167 .. 2191 unknown IV (a)
|
||||
# 2191 .. 2215 unknown IV (b)
|
||||
# 2215 .. 2239 guard skill (24)
|
||||
# 2239 .. 2263 unknown IV (c)
|
||||
# 2263 .. 2287 guard colour (24)
|
||||
# 2287 .. 2303 unknown IV (d)
|
||||
# 2303 .. 2305 0F 09
|
||||
|
||||
# foretable byte format: rrmccccc (2 random bits, 1 modifier bit, 5 code bits)
|
||||
FG_RAND_MASK = 0xC0
|
||||
FG_MODBIT_MASK = 0x20
|
||||
FG_CODE_MASK = 0x1F
|
||||
|
||||
# backtable: a tile modifier that depends on the group of the foretable code.
|
||||
# group ids map foretable code -> "group" used to interpret backtable value.
|
||||
# (see Table 7/8). We only need the group for door/event interpretation.
|
||||
GROUP_OF_CODE = {
|
||||
0x00: "free", 0x01: "free", 0x02: "spike", 0x03: "none",
|
||||
0x04: "gate", 0x05: "none", 0x06: "event", 0x07: "tapest",
|
||||
0x08: "none", 0x09: "none", 0x0A: "potion", 0x0B: "none",
|
||||
0x0C: "ttop", 0x0D: "none", 0x0E: "none", 0x0F: "event",
|
||||
0x10: "none", 0x11: "none", 0x12: "chomp", 0x13: "none",
|
||||
0x14: "wall", 0x15: "none", 0x16: "none", 0x17: "none",
|
||||
0x18: "none", 0x19: "none", 0x1A: "none", 0x1B: "none",
|
||||
0x1C: "none", 0x1D: "none", 0x1E: "none", 0x1F: "none",
|
||||
}
|
||||
|
||||
# backtable modifier meanings per group (subset, from Table 8):
|
||||
GATE_MOD = {0: "closed", 1: "open", 2: "with_lattice", 3: "alt_design"}
|
||||
TTOP_MOD = {0: "normal", 1: "black", 2: "empty", 3: "?3", 4: "?4", 5: "?5", 6: "?6", 7: "?7"}
|
||||
SPIKE_MOD = {0: "normal", 1: "barely_out", 2: "half_out", 3: "fully_out"}
|
||||
CHOMP_MOD = {0: "normal", 1: "barely_out", 2: "half_out", 3: "fully_out", 4: "out?"}
|
||||
POTION_MOD = {0: "blue", 1: "no_blue", 2: "window", 3: "spot?"}
|
||||
TAPEST_MOD = {0: "normal", 1: "alt_design", 2: "black", 3: "empty"}
|
||||
EVENT_MOD_DESC = { # group "event": the backtable byte IS the event id
|
||||
0: "closer", 1: "opener",
|
||||
}
|
||||
|
||||
|
||||
class Level:
|
||||
"""Full parsed POP1 level file."""
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.rooms_fg = {} # room -> list[30] of (fg_byte, code, modbit)
|
||||
self.rooms_bg = {} # room -> list[30] of backtable byte
|
||||
for room in range(1, 25):
|
||||
fg = []; bg = []
|
||||
for off in range(30):
|
||||
i = (room - 1) * 30 + off
|
||||
fb = data[i]; bb = data[720 + i]
|
||||
fg.append((fb, fb & FG_CODE_MASK, (fb & FG_MODBIT_MASK) >> 5))
|
||||
bg.append(bb)
|
||||
self.rooms_fg[room] = fg
|
||||
self.rooms_bg[room] = bg
|
||||
# door I / II (event lines 0..255)
|
||||
self.door_i = data[1440:1696]
|
||||
self.door_ii = data[1696:1952]
|
||||
# links: 24 rooms x (L,R,Up,Dn)
|
||||
self.links = {}
|
||||
for room in range(1, 25):
|
||||
b = data[1952 + (room - 1) * 4: 1952 + (room - 1) * 4 + 4]
|
||||
self.links[room] = tuple(b) # (left, right, up, down)
|
||||
# start position
|
||||
self.start = tuple(data[2112:2115])
|
||||
# guards
|
||||
self.guard_loc = list(data[2119:2143])
|
||||
self.guard_dir = list(data[2143:2167])
|
||||
self.guard_skill = list(data[2215:2239])
|
||||
self.guard_colour = list(data[2263:2287])
|
||||
|
||||
def door_for_event(self, event_id):
|
||||
"""Decode door I/II for an event line -> (room, location, trigger_next)."""
|
||||
bi = self.door_i[event_id]
|
||||
bii = self.door_ii[event_id]
|
||||
# Byte I: t1 s4 s5 l1 l2 l3 l4 l5
|
||||
t1 = (bi >> 7) & 1
|
||||
s4 = (bi >> 6) & 1
|
||||
s5 = (bi >> 5) & 1
|
||||
l1 = (bi >> 4) & 1
|
||||
l2 = (bi >> 3) & 1
|
||||
l3 = (bi >> 2) & 1
|
||||
l4 = (bi >> 1) & 1
|
||||
l5 = bi & 1
|
||||
# Byte II: s1 s2 s3 00000
|
||||
s1 = (bii >> 7) & 1
|
||||
s2 = (bii >> 6) & 1
|
||||
s3 = (bii >> 5) & 1
|
||||
screen = (s1 << 4) | (s2 << 3) | (s3 << 2) | (s4 << 1) | s5
|
||||
loc = (l1 << 4) | (l2 << 3) | (l3 << 2) | (l4 << 1) | l5
|
||||
return screen, loc, t1
|
||||
|
||||
def backtable_desc(self, code, bb):
|
||||
grp = GROUP_OF_CODE.get(code, "none")
|
||||
tbl = {"gate": GATE_MOD, "ttop": TTOP_MOD, "spike": SPIKE_MOD,
|
||||
"chomp": CHOMP_MOD, "potion": POTION_MOD, "tapest": TAPEST_MOD}
|
||||
if code == 0x06 or code == 0x0F: # event group
|
||||
act = EVENT_MOD_DESC.get(bb & 1, "?")
|
||||
return f"event(id={bb}, {act})"
|
||||
if grp in tbl:
|
||||
return tbl[grp].get(bb, f"?0x{bb:02X}")
|
||||
return f"0x{bb:02X}"
|
||||
|
||||
|
||||
class Room:
|
||||
def __init__(self, level, room_num):
|
||||
self.level = level
|
||||
self.room_num = room_num
|
||||
self.fg = level.rooms_fg[room_num]
|
||||
self.bg = level.rooms_bg[room_num]
|
||||
self.left_room = None # соседняя комната слева (link L); load_leftroom
|
||||
|
||||
def tile(self, row, col):
|
||||
"""Return (code, fg_byte, modbit, backtable_byte) for an in-room tile.
|
||||
Out-of-range neighbours follow seg008 defaults:
|
||||
col<0 -> берётся из ЛЕВОЙ комнаты (col 9), как load_leftroom();
|
||||
если левой комнаты нет — уровневая кромка = стена (20).
|
||||
row<0 -> top level edge = floor (code 1, mod 0)
|
||||
"""
|
||||
if col < 0:
|
||||
if self.left_room is not None and 0 <= row < 3:
|
||||
return self.left_room.tile(row, 9)
|
||||
return 20, 0, 0, 0
|
||||
if row < 0:
|
||||
return 1, 0, 0, 0
|
||||
if not (0 <= row < 3 and 0 <= col < 10):
|
||||
return 20, 0, 0, 0
|
||||
i = row * 10 + col
|
||||
fb, code, mbit = self.fg[i]
|
||||
return code, fb, mbit, self.bg[i]
|
||||
|
||||
|
||||
# Log of every sprite loaded during a render, for the per-tile report.
|
||||
SPRITE_LOG = []
|
||||
|
||||
|
||||
def load_sprite(id, chtab=CHTAB_ENV, reason=""):
|
||||
"""Load resource PNG для картинки id из набора chtab.
|
||||
|
||||
База ресурса зависит от chtab (seg000.c:1109/1122):
|
||||
chtab_6 environment -> res(200+id)
|
||||
chtab_7 environmentwall -> res(360+id)
|
||||
chtab_1 flame/sword/pot -> res(150+id) (анимация, статикой не грузим)
|
||||
|
||||
Dungeon (IS_DUNGEON) cascade: try VDUNGEON first (dungeon-specific
|
||||
overrides), then fall back to VPALACE (shared base set).
|
||||
"""
|
||||
if id <= 0:
|
||||
return None
|
||||
base = {CHTAB_ENV: 200, CHTAB_WALL: 360, CHTAB_FSP: 150}.get(chtab, 200)
|
||||
fname = f"res{base + id}.png"
|
||||
if IS_DUNGEON:
|
||||
vd = os.path.join(VDUNGEON_DIR, fname)
|
||||
if os.path.exists(vd):
|
||||
try:
|
||||
img = Image.open(vd).convert("RGBA")
|
||||
CURRENT_TILE_LOG.append((id, "VDUNGEON", reason))
|
||||
return img
|
||||
except Exception:
|
||||
pass
|
||||
vp = os.path.join(VPALACE_DIR, fname)
|
||||
try:
|
||||
img = Image.open(vp).convert("RGBA")
|
||||
CURRENT_TILE_LOG.append((id, "VPALACE", reason))
|
||||
return img
|
||||
except FileNotFoundError:
|
||||
CURRENT_TILE_LOG.append((id, "MISSING", reason))
|
||||
return None
|
||||
|
||||
|
||||
def blit(canvas, img, x, y):
|
||||
"""Composite img onto canvas at (x,y) using its alpha as mask (draw_main style)."""
|
||||
if img is None:
|
||||
return
|
||||
w, h = img.size
|
||||
if x + w <= 0 or y + h <= 0 or x >= canvas.width or y >= canvas.height:
|
||||
return
|
||||
canvas.alpha_composite(img, (x, y))
|
||||
|
||||
|
||||
def blit_black(canvas, img, x, y):
|
||||
"""blitters_9_black (seg008.c:1054): рисует МАСКУ спрайта ЧЁРНЫМ —
|
||||
непрозрачная область формы заливается чёрным (тень-силуэт), а не
|
||||
пикселями картинки. Используется для floor-right в draw_tile_floorright
|
||||
(тень уступа тайла снизу-слева)."""
|
||||
if img is None:
|
||||
return
|
||||
w, h = img.size
|
||||
if x + w <= 0 or y + h <= 0 or x >= canvas.width or y >= canvas.height:
|
||||
return
|
||||
alpha = img.split()[3]
|
||||
solid = Image.new("RGBA", img.size, (0, 0, 0, 255))
|
||||
canvas.alpha_composite(Image.composite(solid, Image.new("RGBA", img.size, (0, 0, 0, 0)), alpha), (x, y))
|
||||
|
||||
|
||||
# ---- Dungeon wall_pattern() (seg008.c:1928) ----
|
||||
# PRNG: seg009.c:321 random_seed = random_seed*214013 + 2531011; return (random_seed>>16) % (max+1)
|
||||
_random_seed = 0
|
||||
|
||||
|
||||
def prandom(maxval):
|
||||
global _random_seed
|
||||
_random_seed = (_random_seed * 214013 + 2531011) & 0xFFFFFFFF
|
||||
return (_random_seed >> 16) % (maxval + 1)
|
||||
|
||||
|
||||
# wall sprite ids (chtab_7 / RSET_WALL = 7) res(200+id).png
|
||||
RES_WALL_FACE_MAIN, RES_WALL_FACE_TOP, RES_WALL_CENTRE_BASE, RES_WALL_CENTRE_MAIN = 1, 2, 3, 4
|
||||
RES_WALL_RIGHT_BASE, RES_WALL_RIGHT_MAIN, RES_WALL_SINGLE_BASE, RES_WALL_SINGLE_MAIN = 5, 6, 7, 8
|
||||
RES_WALL_LEFT_BASE, RES_WALL_LEFT_MAIN, RES_WALL_DIVIDER1, RES_WALL_DIVIDER2 = 9, 10, 11, 12
|
||||
RES_WALL_RNDBLOCK = 13
|
||||
RES_WALL_MARK_TL, RES_WALL_MARK_BL, RES_WALL_MARK_TR, RES_WALL_MARK_BR = 14, 15, 16, 17
|
||||
|
||||
WALL_MODIFIER_SWS, WALL_MODIFIER_SWW, WALL_MODIFIER_WWS, WALL_MODIFIER_WWW = 0, 1, 2, 3
|
||||
|
||||
# Сплошная грань стены (chtab_7), индекс = wall_modifier (seg008.c:567/687):
|
||||
WALL_FRAM_BOTTOM = [7, 9, 5, 3] # нижняя часть (draw_tile_bottom)
|
||||
WALL_FRAM_MAIN = [8, 10, 6, 4] # основная часть (draw_tile_fore)
|
||||
|
||||
LPOS = [58, 41, 37, 20, 16] # draw_left_mark vertical offsets
|
||||
RPOS = [52, 42, 31, 21] # draw_right_mark vertical offsets
|
||||
|
||||
TBL_LINE = [0, 10, 20] # смещение строки в 30-тайловом массиве комнаты
|
||||
|
||||
|
||||
def wall_modifier(room, drawn_row, col):
|
||||
"""Модификатор стены (SWS/SWW/WWS/WWW = 0..3) по соседям слева/справа.
|
||||
seg008.c:1255-1305 (без USE_FAKE_TILES): сосед считается стеной, если
|
||||
его тип == wall(20). На кромке комнаты без линка сосед по умолчанию
|
||||
считается стеной (wall_to_left/right = 1)."""
|
||||
left_code = room.tile(drawn_row, col - 1)[0] if col > 0 else 20
|
||||
right_code = room.tile(drawn_row, col + 1)[0] if col < 9 else 20
|
||||
wall_left = (left_code == 20)
|
||||
wall_right = (right_code == 20)
|
||||
if wall_left and wall_right:
|
||||
return WALL_MODIFIER_WWW # 3
|
||||
if wall_left:
|
||||
return WALL_MODIFIER_WWS # 2
|
||||
if wall_right:
|
||||
return WALL_MODIFIER_SWW # 1
|
||||
return WALL_MODIFIER_SWS # 0
|
||||
|
||||
|
||||
def _wall_add(canvas, which_table, id, xh, xl, ybottom, reason):
|
||||
"""Emulate ptr_add_table(RSET_WALL, id, xh, xl, ybottom, blit, 0).
|
||||
which_table 0 -> backtable (blit transparent), 1 -> foretable.
|
||||
RSET_WALL id -> res(200+id).png, cascade VDUNGEON->VPALACE."""
|
||||
img = load_sprite(id, CHTAB_WALL, reason=reason)
|
||||
if img is None:
|
||||
return
|
||||
y = ybottom - img.height + 1
|
||||
x = xh * 8 + xl
|
||||
blit(canvas, img, x, y)
|
||||
|
||||
|
||||
def draw_left_mark(canvas, decal_variant, arg2, arg1, drawn_row, col):
|
||||
image_id = RES_WALL_MARK_TL
|
||||
if decal_variant % 2:
|
||||
image_id = RES_WALL_MARK_BL
|
||||
lv2 = 0
|
||||
if decal_variant > 3:
|
||||
lv2 = arg1 + 6
|
||||
elif decal_variant > 1:
|
||||
lv2 = arg2 + 6
|
||||
xh = COL_XH[col]
|
||||
draw_bottom_y = 63 * drawn_row + 65
|
||||
_wall_add(canvas, 1, image_id, xh + (1 if decal_variant in (2, 3) else 0),
|
||||
lv2, draw_bottom_y - LPOS[decal_variant],
|
||||
reason=f"wall left-mark var{decal_variant}")
|
||||
|
||||
|
||||
def draw_right_mark(canvas, decal_variant, arg1, drawn_row, col):
|
||||
image_id = RES_WALL_MARK_TR
|
||||
if decal_variant % 2:
|
||||
image_id = RES_WALL_MARK_BR
|
||||
a = 24 if decal_variant < 2 else arg1 - 3
|
||||
xh = COL_XH[col]
|
||||
draw_bottom_y = 63 * drawn_row + 65
|
||||
_wall_add(canvas, 1, image_id, xh + (1 if decal_variant > 1 else 0),
|
||||
a, draw_bottom_y - RPOS[decal_variant],
|
||||
reason=f"wall right-mark var{decal_variant}")
|
||||
|
||||
|
||||
def wall_pattern(canvas, room, drawn_row, col, which_part, which_table):
|
||||
"""Replay seg008.c wall_pattern() for a dungeon wall tile.
|
||||
which_part: 0 = bottom part (draw_tile_bottom), 1 = fore part (draw_tile_fore).
|
||||
which_table: 0 = backtable, 1 = foretable."""
|
||||
global _random_seed
|
||||
xh = COL_XH[col]
|
||||
draw_bottom_y = 63 * drawn_row + 65
|
||||
draw_main_y = draw_bottom_y - 3
|
||||
|
||||
saved_seed = _random_seed
|
||||
# seg008.c:1941 — зерно зависит от НОМЕРА КОМНАТЫ (не строки!):
|
||||
# random_seed = drawn_room + tbl_line[drawn_row] + drawn_col
|
||||
room_num = getattr(room, "room_num", 1)
|
||||
_random_seed = room_num + TBL_LINE[drawn_row] + col
|
||||
prandom(1) # discard
|
||||
is_dungeon = IS_DUNGEON
|
||||
|
||||
middle_divider = prandom(1)
|
||||
middle_divider_offset = prandom(4)
|
||||
bottom_divider = prandom(1)
|
||||
bottom_divider_offset = prandom(4)
|
||||
|
||||
# Модификатор стены (какие соседи — стены) — тот же, что для сплошной грани.
|
||||
bg = wall_modifier(room, drawn_row, col)
|
||||
|
||||
if bg == WALL_MODIFIER_WWW:
|
||||
if which_part != 0:
|
||||
if prandom(4) == 0:
|
||||
_wall_add(canvas, which_table, RES_WALL_RNDBLOCK, xh, 0,
|
||||
draw_bottom_y - 42, reason="wall rndblock")
|
||||
_wall_add(canvas, which_table, RES_WALL_DIVIDER1 + middle_divider, xh + 1,
|
||||
middle_divider_offset, draw_bottom_y - 21, reason="wall middle-divider")
|
||||
_wall_add(canvas, which_table, RES_WALL_DIVIDER1 + bottom_divider, xh,
|
||||
bottom_divider_offset, draw_bottom_y, reason="wall bottom-divider")
|
||||
if which_part != 0 and is_dungeon:
|
||||
if prandom(4) == 0:
|
||||
draw_right_mark(canvas, prandom(3), middle_divider_offset, drawn_row, col)
|
||||
if prandom(4) == 0:
|
||||
draw_left_mark(canvas, prandom(4),
|
||||
middle_divider_offset - middle_divider,
|
||||
bottom_divider_offset - bottom_divider, drawn_row, col)
|
||||
elif bg == WALL_MODIFIER_SWS:
|
||||
if is_dungeon and which_part != 0:
|
||||
if prandom(6) == 0:
|
||||
draw_left_mark(canvas, prandom(1),
|
||||
middle_divider_offset - middle_divider,
|
||||
bottom_divider_offset - bottom_divider, drawn_row, col)
|
||||
elif bg == WALL_MODIFIER_SWW:
|
||||
if which_part != 0:
|
||||
if prandom(4) == 0:
|
||||
_wall_add(canvas, which_table, RES_WALL_RNDBLOCK, xh, 0,
|
||||
draw_bottom_y - 42, reason="wall rndblock")
|
||||
_wall_add(canvas, which_table, RES_WALL_DIVIDER1 + middle_divider, xh + 1,
|
||||
middle_divider_offset, draw_bottom_y - 21, reason="wall middle-divider")
|
||||
if is_dungeon:
|
||||
if prandom(4) == 0:
|
||||
draw_right_mark(canvas, prandom(3), middle_divider_offset, drawn_row, col)
|
||||
if prandom(4) == 0:
|
||||
draw_left_mark(canvas, prandom(3),
|
||||
middle_divider_offset - middle_divider,
|
||||
bottom_divider_offset - bottom_divider, drawn_row, col)
|
||||
elif bg == WALL_MODIFIER_WWS:
|
||||
if which_part != 0:
|
||||
_wall_add(canvas, which_table, RES_WALL_DIVIDER1 + middle_divider, xh + 1,
|
||||
middle_divider_offset, draw_bottom_y - 21, reason="wall middle-divider")
|
||||
_wall_add(canvas, which_table, RES_WALL_DIVIDER1 + bottom_divider, xh,
|
||||
bottom_divider_offset, draw_bottom_y, reason="wall bottom-divider")
|
||||
if which_part != 0 and is_dungeon:
|
||||
if prandom(4) == 0:
|
||||
draw_right_mark(canvas, prandom(1) + 2, middle_divider_offset, drawn_row, col)
|
||||
if prandom(4) == 0:
|
||||
draw_left_mark(canvas, prandom(4),
|
||||
middle_divider_offset - middle_divider,
|
||||
bottom_divider_offset - bottom_divider, drawn_row, col)
|
||||
|
||||
_random_seed = saved_seed
|
||||
|
||||
|
||||
# Per-tile sprite log: draw_tile resets CURRENT_TILE_LOG, load_sprite appends to it.
|
||||
CURRENT_TILE_LOG = []
|
||||
TILE_LOGS = {} # (row, col) -> list of (id, source, reason)
|
||||
|
||||
# seg008.c:1108 — вертикальные слайсы решётки ворот (portcullis).
|
||||
DOOR_FRAM_SLICE = [67, 59, 58, 57, 56, 55, 54, 53, 52]
|
||||
# seg008.c:517 — правый край шипов по кадру get_spike_frame.
|
||||
SPIKES_FRAM_RIGHT = [0, 134, 135, 136, 137, 138, 137, 135, 134, 0]
|
||||
# seg008.c:450 — «blueline»-декали на ЗАДНЕЙ стене (силуэты кладки/окна),
|
||||
# рисуются правым соседом по МОДИФИКАТОРУ соседа слева:
|
||||
# tile_left == empty -> blueline_fram1[mod] (окно/решётка-фрагменты 124..126)
|
||||
# на высоте blueline_fram_y[mod] + main_y;
|
||||
# tile_left == floor -> blueline_fram3[mod] (кирпичные силуэты 44/45)
|
||||
# на main_y - 20 (если mod != !!level_type).
|
||||
BLUELINE_FRAM1 = [0, 124, 125, 126]
|
||||
BLUELINE_FRAM_Y = [0, -20, -20, 0]
|
||||
BLUELINE_FRAM3 = [44, 44, 45, 45]
|
||||
|
||||
|
||||
def draw_gate_back(canvas, modifier_left, draw_xh, draw_bottom_y, draw_main_y):
|
||||
"""seg008.c:1110 draw_gate_back — портикулис (решётка) ворот. Рисуется
|
||||
на тайле СПРАВА от ворот (draw_tile_anim_right, tile_left==gate). У нас
|
||||
это col 0 room1, где сосед слева — ворота из room 5. modifier_left —
|
||||
модификатор тайла-ворот (степень поднятия решётки)."""
|
||||
x = draw_xh * 8
|
||||
gate_top_y = draw_bottom_y - 62
|
||||
gate_openness = (min(modifier_left, 188) >> 2) + 1
|
||||
gate_bottom_y = draw_main_y - gate_openness
|
||||
if gate_bottom_y + 12 < draw_main_y:
|
||||
img = load_sprite(50, reason="gate bottom+B") # gate поднята высоко
|
||||
if img:
|
||||
blit(canvas, img, x, gate_bottom_y - img.height + 1)
|
||||
else:
|
||||
# id 47 (правая грань ворот) уже рисует draw_tile_right — не дублируем.
|
||||
img = load_sprite(51, reason="gate bottom")
|
||||
if img:
|
||||
blit(canvas, img, x, (gate_bottom_y - 2) - img.height + 1)
|
||||
# Стек 8px-слайсов решётки снизу вверх.
|
||||
ybottom = gate_bottom_y - 12
|
||||
if ybottom < 192:
|
||||
while ybottom >= 0 and ybottom > 7 and (ybottom - 7) > gate_top_y:
|
||||
img = load_sprite(52, reason="gate slice 8px")
|
||||
if img:
|
||||
blit(canvas, img, x, ybottom - img.height + 1)
|
||||
ybottom -= 8
|
||||
gate_frame = ybottom - gate_top_y + 1
|
||||
if 0 < gate_frame < 9:
|
||||
img = load_sprite(DOOR_FRAM_SLICE[gate_frame], reason="gate top slice")
|
||||
if img:
|
||||
blit(canvas, img, x, ybottom - img.height + 1)
|
||||
|
||||
|
||||
def draw_tile(canvas, room, drawn_row, col, left_edge_code=20):
|
||||
global CURRENT_TILE_LOG
|
||||
CURRENT_TILE_LOG = []
|
||||
code, fg_byte, modbit, bg_mod = room.tile(drawn_row, col)
|
||||
# tile_left: колонка слева. col-1 == -1 -> Room.tile берёт из левой
|
||||
# комнаты (или уровневая кромка = стена), см. Room.tile / load_leftroom.
|
||||
left_code, _, _, left_mod = room.tile(drawn_row, col - 1)
|
||||
draw_xh = COL_XH[col]
|
||||
x = draw_xh * 8
|
||||
draw_bottom_y = 63 * drawn_row + 65
|
||||
draw_main_y = draw_bottom_y - 3
|
||||
|
||||
# For topright/floorright we need the tile below-left.
|
||||
# Room below = same room (no room links) -> row_below = drawn_row+1 if <3 else treat as empty/floor top edge.
|
||||
row_below = drawn_row + 1
|
||||
if row_below > 2:
|
||||
# below the bottom row of the level: floor top edge
|
||||
rbl_code, rbl_mod = 1, 0
|
||||
else:
|
||||
rbl_code, _, _, rbl_mod = room.tile(row_below, col - 1)
|
||||
row_below_left_code = rbl_code
|
||||
|
||||
t = TILE_TABLE[code]
|
||||
lt = TILE_TABLE[left_code]
|
||||
|
||||
# ---- draw_tile_floorright (seg008.c:392) ----
|
||||
# Рисуется ТОЛЬКО когда can_see_bottomleft(): текущий тайл —
|
||||
# empty/bigpillar_top/doortop/lattice_down. Иначе topright соседа
|
||||
# снизу-слева перекрыт и рисовать его нельзя (иначе «арки» повсюду —
|
||||
# это была главная причина замусоренного рендера).
|
||||
if code in (0, 9, 12, 26): # can_see_bottomleft
|
||||
_draw_topright(canvas, row_below_left_code, rbl_mod, x, draw_bottom_y,
|
||||
reason=f"topright(below-left tile {row_below_left_code:02X})")
|
||||
if lt[4]: # tile_table[tile_left].floor_right
|
||||
# blitters_9_black: ЧЁРНЫЙ силуэт (тень уступа), не светлый спрайт.
|
||||
img = load_sprite(42, reason=f"floor-right shadow(left tile {left_code:02X})")
|
||||
blit_black(canvas, img, x, draw_main_y + TILE_TABLE[1][5] - (img.height if img else 0) + 1)
|
||||
|
||||
# ---- draw_tile_anim_topright ---- (gate top mask) - skipped: no anim
|
||||
# ---- draw_tile_right (seg008.c:456) ----
|
||||
if code != 20: # у самой стены правый край не рисуется
|
||||
level_type = 0 if IS_DUNGEON else 1
|
||||
if left_code == 20:
|
||||
# сосед слева — стена: её правая грань в текущую колонку (chtab_7 id 1)
|
||||
img = load_sprite(1, CHTAB_WALL, reason="wall right-face(left tile 14)")
|
||||
ry = TILE_TABLE[20][5] # right_y стены = 2
|
||||
blit(canvas, img, x, ry + draw_main_y - (img.height if img else 0) + 1)
|
||||
elif left_code == 0:
|
||||
# сосед слева — empty: фрагмент окна/решётки по его модификатору
|
||||
if left_mod <= 3:
|
||||
bid = BLUELINE_FRAM1[left_mod]
|
||||
if bid:
|
||||
img = load_sprite(bid, reason=f"blueline-window(left empty mod{left_mod})")
|
||||
blit(canvas, img, x,
|
||||
BLUELINE_FRAM_Y[left_mod] + draw_main_y - (img.height if img else 0) + 1)
|
||||
elif left_code == 1:
|
||||
# сосед слева — floor: правый треугольник (42) + СИЛУЭТ КЛАДКИ (44/45)
|
||||
# на задней стене, если модификатор пола != !!level_type.
|
||||
img = load_sprite(42, reason="floor-right(part of left tile 01)")
|
||||
ry = TILE_TABLE[1][5] # right_y пола = 2
|
||||
blit(canvas, img, x, ry + draw_main_y - (img.height if img else 0) + 1)
|
||||
num = left_mod if left_mod <= 3 else 0
|
||||
if num != level_type:
|
||||
bid = BLUELINE_FRAM3[num]
|
||||
img = load_sprite(bid, reason=f"brick-silhouette(left floor mod{num})")
|
||||
blit(canvas, img, x, draw_main_y - 20 - (img.height if img else 0) + 1)
|
||||
else:
|
||||
rid = lt[3] # tile_left.right_id
|
||||
if rid:
|
||||
ry = lt[5]
|
||||
img = load_sprite(rid, reason=f"right(part of left tile {left_code:02X})")
|
||||
blit(canvas, img, x, ry + draw_main_y - (img.height if img else 0) + 1)
|
||||
# stripe: only drawn when tbl_level_type != 0 (palace). Dungeon: skip.
|
||||
sid = lt[6]
|
||||
if sid and not IS_DUNGEON:
|
||||
img = load_sprite(sid, reason=f"stripe(left tile {left_code:02X})")
|
||||
blit(canvas, img, x, draw_main_y - 27 - (img.height if img else 0) + 1)
|
||||
|
||||
# ---- draw_tile_anim_right (seg008.c:530) ----
|
||||
# «Анимированные» части соседа слева, проецируемые в текущий тайл.
|
||||
# Статикой (кадр покоя) рисуем те, что формируют ГЕОМЕТРИЮ фона:
|
||||
# gate -> решётка (portcullis) на границе с левой комнатой;
|
||||
# loose -> правый треугольный край шаткой плиты (иначе провал в полу);
|
||||
# spike -> статичный кадр шипов по модификатору.
|
||||
# torch-flame/chomper — чистая анимация, пропускаем (отдельные sprite_t).
|
||||
if left_code == 4: # tiles_4_gate
|
||||
draw_gate_back(canvas, left_mod, draw_xh, draw_bottom_y, draw_main_y)
|
||||
elif left_code == 11: # tiles_11_loose — правый край плиты (loose_fram_right[0]=42)
|
||||
img = load_sprite(42, reason="loose right (part of left tile 0B)")
|
||||
if img:
|
||||
blit(canvas, img, x, (draw_bottom_y - 1) - img.height + 1)
|
||||
elif left_code == 2: # tiles_2_spike — статичный кадр (spikes_fram_right)
|
||||
sf = 5 if (left_mod & 0x80) else left_mod
|
||||
sid = SPIKES_FRAM_RIGHT[sf] if 0 <= sf < len(SPIKES_FRAM_RIGHT) else 0
|
||||
if sid:
|
||||
img = load_sprite(sid, reason="spike right (part of left tile 02)")
|
||||
if img:
|
||||
blit(canvas, img, x, (draw_main_y - 7) - img.height + 1)
|
||||
|
||||
# ---- draw_tile_bottom (seg008.c:570) ----
|
||||
if code == 20:
|
||||
# Сплошная НИЖНЯЯ грань стены (chtab_7 wall_fram_bottom) + декали.
|
||||
wmod = wall_modifier(room, drawn_row, col)
|
||||
img = load_sprite(WALL_FRAM_BOTTOM[wmod], CHTAB_WALL, reason="wall bottom-face")
|
||||
if img:
|
||||
blit(canvas, img, x, draw_bottom_y - img.height + 1)
|
||||
wall_pattern(canvas, room, drawn_row, col, 0, 0)
|
||||
else:
|
||||
bid = t[8]
|
||||
img = load_sprite(bid, reason=f"bottom({TILE_NAMES.get(code,'?')})")
|
||||
if img:
|
||||
blit(canvas, img, x, draw_bottom_y - img.height + 1)
|
||||
|
||||
# ---- draw_loose (seg008.c:600) — статический кадр loose floor ----
|
||||
# Нижняя часть шаткой плиты (loose_fram_bottom[0]=43) на draw_bottom_y.
|
||||
if code == 11: # 0x0B loose floor
|
||||
img = load_sprite(43, reason="loose bottom (draw_loose)")
|
||||
if img:
|
||||
blit(canvas, img, x, draw_bottom_y - img.height + 1)
|
||||
|
||||
# ---- draw_tile_base (seg008.c:611) ----
|
||||
base_id = t[0]
|
||||
base_y = t[2]
|
||||
ybottom = draw_main_y
|
||||
if left_code == 0x1A and code == 0x0C:
|
||||
# seg008:622 «Lattice + door A»: у doortop своей базы нет (base_id 0),
|
||||
# и рядом с аркой (lattice_down слева) он рисуется ОДНИМ спрайтом 6,
|
||||
# опущенным на 3 px. Без этой ветки на месте тайла чёрная дыра.
|
||||
base_id = 6
|
||||
ybottom += 3
|
||||
elif code == 11: # loose floor: базовая (верхняя) часть = loose_fram_left[0] = 41
|
||||
base_id = 41
|
||||
img = load_sprite(base_id, reason=f"base({TILE_NAMES.get(code,'?')})")
|
||||
if img:
|
||||
blit(canvas, img, x, base_y + ybottom - img.height + 1)
|
||||
|
||||
# ---- draw_tile_anim ---- (spike/potion/chomper animated) skipped
|
||||
|
||||
# ---- draw_tile_fore (seg008.c:690) ----
|
||||
if code == 20:
|
||||
# Основная (средняя) грань стены (chtab_7 wall_fram_main) + декали/марки.
|
||||
wmod = wall_modifier(room, drawn_row, col)
|
||||
img = load_sprite(WALL_FRAM_MAIN[wmod], CHTAB_WALL, reason="wall main-face")
|
||||
if img:
|
||||
blit(canvas, img, x, draw_main_y - img.height + 1)
|
||||
wall_pattern(canvas, room, drawn_row, col, 1, 1)
|
||||
fid = t[9]
|
||||
if fid:
|
||||
# fore_x — в единицах xh (×8 пикс!), не в пикселях: seg008.c:743
|
||||
# xh = fore_x + draw_xh, затем x = xh*8. fore_y — в пикселях.
|
||||
fx = t[10] * 8 + x
|
||||
fy = t[11] + draw_main_y
|
||||
img = load_sprite(fid, reason=f"fore({TILE_NAMES.get(code,'?')})")
|
||||
if img:
|
||||
blit(canvas, img, fx, fy - img.height + 1)
|
||||
|
||||
# Save per-tile log (keyed by logical room position: row=drawn_row, col)
|
||||
TILE_LOGS[(drawn_row, col)] = list(CURRENT_TILE_LOG)
|
||||
|
||||
|
||||
def _draw_topright(canvas, tiletype, modifier, x, draw_bottom_y, reason=""):
|
||||
if tiletype in (7, 12): # doortop_with_floor / doortop -> palace uses frame
|
||||
return
|
||||
elif tiletype == 20: # wall
|
||||
img = load_sprite(2, CHTAB_WALL, reason=reason)
|
||||
else:
|
||||
tid = TILE_TABLE[tiletype][7]
|
||||
if tid == 0:
|
||||
return
|
||||
img = load_sprite(tid, reason=reason)
|
||||
if img:
|
||||
blit(canvas, img, x, draw_bottom_y - img.height + 1)
|
||||
|
||||
|
||||
def render_room(room_num):
|
||||
with open(os.path.join(LEVEL_DIR, f"res{2000 + room_num}.bin"), "rb") as f:
|
||||
data = f.read()
|
||||
level = Level(data)
|
||||
room = Room(level, room_num)
|
||||
# load_leftroom(): левая комната по линку L — её col 9 виден на границе
|
||||
# (col 0) текущей комнаты (напр. решётка ворот из соседней комнаты).
|
||||
left_link = level.links[room_num][0]
|
||||
if left_link:
|
||||
room.left_room = Room(level, left_link)
|
||||
|
||||
canvas = Image.new("RGBA", (320, 192), (0, 0, 0, 0))
|
||||
# rows drawn 2,1,0 (bottom first)
|
||||
for drawn_row in (2, 1, 0):
|
||||
for col in range(10):
|
||||
draw_tile(canvas, room, drawn_row, col)
|
||||
|
||||
return canvas, room, level
|
||||
|
||||
|
||||
def render_single_tile(code, modifier=0, left_code=20, below_left_code=1):
|
||||
"""Render one tile in isolation on a 40x64 canvas (matching SDLPoP tile box)."""
|
||||
TILE_W, TILE_H = 40, 64
|
||||
canvas = Image.new("RGBA", (TILE_W, TILE_H), (0, 0, 0, 0))
|
||||
# Place tile as if it were at drawn_row=2 (bottom), col=0
|
||||
drawn_row = 2
|
||||
col = 0
|
||||
# Build a fake Room backed by single values
|
||||
class FakeRoom:
|
||||
def tile(self, r, c):
|
||||
if c < 0:
|
||||
return left_code, 0, 0, 0
|
||||
if r < 0:
|
||||
return 1, 0, 0, 0
|
||||
if r == drawn_row and c == 0:
|
||||
return code, 0, 0, modifier
|
||||
return below_left_code, 0, 0, 0
|
||||
room = FakeRoom()
|
||||
draw_tile(canvas, room, drawn_row, col, left_edge_code=left_code)
|
||||
return canvas
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
room_num = 1
|
||||
if len(sys.argv) > 1:
|
||||
room_num = int(sys.argv[1])
|
||||
|
||||
canvas, room, level = render_room(room_num)
|
||||
out = f"./room{room_num}_render.png"
|
||||
# Прозрачный фон (задняя чёрная стена, которую мы не рисуем) -> ЧЁРНЫМ,
|
||||
# а не белым как показывает вьюер прозрачность. Композитим по альфе.
|
||||
flat = Image.new("RGB", canvas.size, (0, 0, 0))
|
||||
flat.paste(canvas, (0, 0), canvas)
|
||||
flat.save(out)
|
||||
print(f"Saved {out} ({canvas.width}x{canvas.height})")
|
||||
|
||||
# debug grid with code labels (на чёрном фоне)
|
||||
from PIL import ImageDraw
|
||||
dbg = flat.copy()
|
||||
d = ImageDraw.Draw(dbg)
|
||||
for r in range(3):
|
||||
by = 63 * r + 65
|
||||
for c in range(10):
|
||||
x = COL_XH[c] * 8
|
||||
d.rectangle([x, by - 63, x + 31, by], outline=(255, 0, 0), width=1)
|
||||
code, _, _, _ = room.tile(r, c)
|
||||
d.text((x + 2, by - 60), f"{code:02X}", fill=(255, 255, 0))
|
||||
dbg.save(f"./room{room_num}_debug.png")
|
||||
print(f"Saved room{room_num}_debug.png")
|
||||
|
||||
# Per-tile individual renders (the 30 tiles of the room)
|
||||
# For proper neighbour context, render each tile inside its real room,
|
||||
# then crop its 32x64 (40x64) column box.
|
||||
tiles_dir = f"./tiles_room{room_num}"
|
||||
os.makedirs(tiles_dir, exist_ok=True)
|
||||
for r in range(3):
|
||||
by = 63 * r + 65
|
||||
for c in range(10):
|
||||
x = COL_XH[c] * 8
|
||||
code, _, _, _ = room.tile(r, c)
|
||||
# crop the column box (40 wide to include left overlap, 64 tall above bottom)
|
||||
box = (x, by - 64, x + 40, by)
|
||||
tile_img = canvas.crop(box)
|
||||
tile_img.save(os.path.join(tiles_dir, f"r{r}c{c}_{code:02X}.png"))
|
||||
print(f"Saved 30 tile crops to {tiles_dir}/")
|
||||
# Per-tile report: which images each tile pulls and why.
|
||||
print()
|
||||
print("=" * 78)
|
||||
print(f"PER-TILE SPRITE REPORT (room {room_num}, {'DUNGEON' if IS_DUNGEON else 'PALACE'})")
|
||||
print("=" * 78)
|
||||
for r in range(3):
|
||||
for c in range(10):
|
||||
code, fg_byte, mbit, bb = room.tile(r, c)
|
||||
name = TILE_NAMES.get(code, f"?0x{code:02X}")
|
||||
btd = level.backtable_desc(code, bb)
|
||||
line = (f"\n[{r},{c}] code=0x{code:02X} ({name}) "
|
||||
f"fg=0x{fg_byte:02X} mbit={mbit} backtable=0x{bb:02X} ({btd})")
|
||||
# If this tile is an event activator (group 'event'), show the door it drives.
|
||||
if code in (0x06, 0x0F):
|
||||
scr, loc, trig = level.door_for_event(bb)
|
||||
line += f"\n -> DOOR EVENT id={bb}: screen={scr} loc={loc} trigger_next={trig}"
|
||||
print(line)
|
||||
logs = TILE_LOGS.get((r, c), [])
|
||||
if not logs:
|
||||
print(" (no sprites drawn)")
|
||||
for sid, source, reason in logs:
|
||||
print(f" res{200+sid:03d}.png <- {source:8s} {reason}")
|
||||
print()
|
||||
print("=" * 78)
|
||||
|
||||
# Level-level summary: start position, links, door events, guards.
|
||||
print()
|
||||
print("LEVEL STRUCTURE SUMMARY")
|
||||
print("-" * 78)
|
||||
print(f"start position: room={level.start[0]} loc={level.start[1]} dir={level.start[2]}")
|
||||
links = level.links.get(room_num, (0, 0, 0, 0))
|
||||
print(f"room {room_num} links: L={links[0]} R={links[1]} Up={links[2]} Dn={links[3]}")
|
||||
# guard in this room
|
||||
gi = room_num - 1
|
||||
gloc = level.guard_loc[gi]
|
||||
if gloc < 30:
|
||||
gr, gc = gloc // 10, gloc % 10
|
||||
print(f"guard: loc=tile(r{gr},c{gc}) dir={level.guard_dir[gi]} "
|
||||
f"skill={level.guard_skill[gi]} colour={level.guard_colour[gi]}")
|
||||
else:
|
||||
print("guard: none")
|
||||
# door events driven by any tile in this room
|
||||
print("door events referenced by this room's tiles:")
|
||||
seen = set()
|
||||
for off in range(30):
|
||||
fb, code, mbit = room.fg[off]
|
||||
bb = room.bg[off]
|
||||
if code in (0x06, 0x0F):
|
||||
if bb not in seen:
|
||||
seen.add(bb)
|
||||
scr, loc, trig = level.door_for_event(bb)
|
||||
print(f" event id={bb} (tile off {off}): screen={scr} loc={loc} trigger_next={trig}")
|
||||
if not seen:
|
||||
print(" (none)")
|
||||
|
||||
print()
|
||||
print("Room", room_num, "foreground codes (3 rows x 10 cols):")
|
||||
for r in range(3):
|
||||
print(" ", " ".join(f"{room.tile(r,c)[0]:02X}" for c in range(10)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user