Files
Sprinter-SDCC/toolchain/pop_extract_font.py
T
snark13 47a4b084c4 Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры
Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
  снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
  (UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
  равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
  ABOUT.  About показывает тот же текст, что стартовый Sprinter screen,
  минус строка про клавиши — общий about_text(), чтобы экраны не
  разъехались.  CONTROLS собирается таблицей и центрируется по
  фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
  (SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
  а не пункт меню.  POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
  модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах.  Нижняя:
  порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
  24 тика).  Верхняя отладочная переведена с палочек на текст
  «Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
  своим форматированием чисел (без printf и без деления).
  Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
  не растёт, весь рендер в банке.  Бюджет после правок не изменился
  (_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
  всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
  не блитятся вовсе, а вход в комнату заливает только игровое поле
  (pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
  ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
  статус-строке: геометрия, семантика text_time_total как идентификатора
  сообщения, мигание, рестарт по истечении 36/288.

Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:58:36 +03:00

186 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 / "applications" / "PoP" / "SDLPoP" / "src" / "menu.c"
SRC_BIG = ROOT / "applications" / "PoP" / "SDLPoP" / "src" / "seg009.c"
OUT_DIR = ROOT / "applications" / "PoP" / "poc" / "res" / "font"
OUT_ATL = OUT_DIR / "font.atl"
OUT_H = ROOT / "applications" / "PoP" / "roomtest" / "pop_font.h"
FIRST, LAST = 32, 126
FONT_COLOR = 0x3F
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) -> bytes:
"""1 бит/пиксель SDLPoP -> 8bpp getimage: 1 = FONT_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(FONT_COLOR if on else 0xFF)
return bytes(out)
def pack_atlas(fonts: list[tuple[dict, list[dict]]]) -> int:
entries = []
for font, glyphs in fonts:
assert font["first"] <= FIRST <= LAST <= font["last"]
entries.extend(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 in enumerate(entries):
offset = len(out)
pixels = glyph_pixels(glyph)
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_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']}
#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))
atlas_size = pack_atlas(parsed)
emit_header(parsed[0][0], parsed[1][0], atlas_size)
print(f"{OUT_ATL}: 2 x {LAST - FIRST + 1} glyphs, {atlas_size} Б; "
f"snapshot from 0x{SNAPSHOT_OFF:04X}")
if __name__ == "__main__":
main()