Files
Sprinter-SDCC/applications/SprPoP/tools/pop_extract_font.py
T
snark13 31b82661eb 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>
2026-08-27 12:12:28 +03:00

213 lines
9.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
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()