Подготовить автономную сборку и запуск перед разделением проектов
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
#!/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
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC_SMALL = ROOT / "SDLPoP" / "src" / "menu.c"
|
||||
SRC_BIG = ROOT / "SDLPoP" / "src" / "seg009.c"
|
||||
OUT_DIR = ROOT / "poc" / "res" / "font"
|
||||
OUT_ATL = OUT_DIR / "font.atl"
|
||||
OUT_H = ROOT / "roomtest" / "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,349 @@
|
||||
#!/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
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
TITLE = ROOT / "SDLPoP" / "data" / "TITLE"
|
||||
PV = ROOT / "SDLPoP" / "data" / "PV"
|
||||
PRINCE = ROOT / "SDLPoP" / "data" / "PRINCE"
|
||||
KID = ROOT / "SDLPoP" / "data" / "KID"
|
||||
OUT = ROOT / "poc" / "res" / "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()
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
|
||||
KID_DIR = "/Users/alex/Projects/DIY/Z80/Sprinter/C-Compiler/applications/PoP/SDLPoP/data/KID"
|
||||
KID_DIR = os.path.join(HERE, "..", "SDLPoP", "data", "KID")
|
||||
OUT_DIR = os.path.join(HERE, "..", "poc", "res", "kid")
|
||||
PAL_BASE = 0x70 # слоты палитры Sprinter под Kid
|
||||
SWORD_PAL_BASE = 0x80 # chtab_0 (меч в руке) -> слоты 0x80..0x8F
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/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
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "SDLPoP" / "data" / "TITLE"
|
||||
OUT = ROOT / "poc" / "res" / "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()
|
||||
@@ -30,9 +30,10 @@ animation) for this first static render; they use curr_modifier/backtable.
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
LEVEL_DIR = "/Users/alex/Projects/DIY/Z80/Sprinter/C-Compiler/applications/PoP/SDLPoP/data/LEVELS"
|
||||
VPALACE_DIR = "/Users/alex/Projects/DIY/Z80/Sprinter/C-Compiler/applications/PoP/SDLPoP/data/VPALACE"
|
||||
VDUNGEON_DIR = "/Users/alex/Projects/DIY/Z80/Sprinter/C-Compiler/applications/PoP/SDLPoP/data/VDUNGEON"
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LEVEL_DIR = os.path.join(ROOT, "SDLPoP", "data", "LEVELS")
|
||||
VPALACE_DIR = os.path.join(ROOT, "SDLPoP", "data", "VPALACE")
|
||||
VDUNGEON_DIR = os.path.join(ROOT, "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
|
||||
|
||||
Reference in New Issue
Block a user