Files
Sprinter-SDCC/applications/PoP/docs/PORT_PLAN.md
T
Александр Петров 774b1cc7c4 docs(PoP): документация к актуальному статусу + план следующих уровней
Документы отстали от кода: PORT_PLAN писал «PoC не начат», хотя играется
весь уровень 1, а четыре плана были исполнены целиком.

- PORT_PLAN: таблица статусов по разделам; фазы 0-3 сделаны, 4-6 нет;
  риски §8 п.1/п.3 закрыты, п.2 переформулирован под реальный движок
  (спрайтовый движок для персонажей не используется, лимит «21 спрайт»
  неприменим), п.4 — найдено расхождение таймингов: оригинал считает
  логический кадр за 5 тиков при BASE_FPS=60 (83.3 мс, в бою 100 мс), а мы
  ждём три vsync (60 мс) — игра идёт примерно на 39 % быстрее эталона.
- levels_plan.md — новый: машинерия перехода между уровнями, второй
  тайлсет (palace), потабличные различия и читы SDLPoP, которые окупаются
  сразу.  Инвентарь тайлов снят прямо с res200N.bin: уровень 2 не требует
  ни одного нового ассета и ни одной новой механики.
- roomtest/TASKS.md — новый: доска текущих задач с критериями готовности.
- Удалены как исполненные и перекрытые кодом: clip_char_plan,
  double_buffer_plan, loose_floors_plan, size_optimization_plan.  Его §8
  (замеры скорости отрисовки) не был перекрыт — перенесён в
  layout_plan_v2 §9, чтобы не потерять цифры.
- KID_PLAN / gates_spikes_plan — шапки «реализовано, оставлено
  справочником»; room_model_plan — «S1 сделан, остальное не срочно».
- docs/README.md стал индексом с отметками актуальности.
- ideas_backlog: зелье переворота экрана — оригинал переворачивает готовый
  буфер построчно, спрайты не трогает; по данным уровней тип 4 встречается
  только на уровне 9, до него механика не нужна.
- examples/scroll: ссылка на удалённый план вела к неверному факту
  «теневая копия одна — общая»; заменено на подтверждённое «у каждой
  страницы своя».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:32:48 +03:00

47 KiB
Raw Blame History

Prince of Persia на ZX Sprinter — план порта

СТАТУС (обновлено 2026-08-01)

Документ составлен 2026-07-15 как план «с нуля» и с тех пор во многом исполнен. Читать его надо так:

Раздел Что с ним сейчас
§1 возможности библиотек актуально как обзор, но спрайтовый движок sprite.h для персонажей НЕ используется: Kid/страж рисуются прямыми блитами атласов (gfx_blit_cols_part*) с ручным heal — так требует модель оригинала (§6)
§2 held-state клавиатуры сделано (kbd_mod_state, <kbd_raw.h>). Открытая проблема — потеря байт при аккордах Shift+стрелка; диагноз и план в ../roomtest/TASKS.md (KBD-1)
§3 форматы данных актуально; уровень читается живьём (roomtest/pop_level.c)
§4 стратегия фона сделано — тайловый рендерер в рантайме (roomtest/pop_bg.c)
§5 PoC закрыт и превзойдён. poc/ (плейсхолдер-персонаж) — история; активная разработка ушла в roomtest/ с настоящей графикой
§6 модель движения сделано: play_seq + frame_table оригинала, не физика с нуля
§7 фазы см. отметки статуса прямо в разделе
§8 риски п.1 закрыт, п.3 закрыт (28 страниц-атласов Кида), п.2/п.4 — см. отметки в разделе
§10 режим памяти сделано и переросло план: huge + четыре банка кода; актуальная раскладка — layout_plan_v2.md

Где смотреть текущее состояние, а не план: ../roomtest/README.md (что играется), ../roomtest/TASKS.md (что в работе), levels_plan.md (следующие уровни), layout_plan_v2.md (раскладка кода по окнам и банкам).


Опирается на APPLEII_RESOURCE_FORMAT.md / MSDOS_RESOURCE_FORMAT.md / README.md в этой папке, на текущий sprinter-cc/libc/libbgi (см. §1) и на локальные копии applications/PoP/SDLPoP (github.com/NagyD/SDLPoP, GPLv3) и applications/PoP/PR (github.com/NagyD/PR, GPLv2) — используются только как справочник по структурам/константам оригинального движка и как источник готовых распакованных ассетов (SDLPoP/data/), не как код для копирования.


1. Что уже есть в sprinter-cc и библиотеках (используем как есть)

Собрано из docs/TODO.md, docs/libc-reference.md, docs/sprite-api-design.md, libbgi/include/{gfx.h,sprite.h,graphics.h}, examples/rpgwalk.

  • Графика 320×256×256 (GFX_MODE_320x256x256, режим 0x81) — разрешение и глубина цвета совпадают почти впрямую с VGA-ассетами оригинала (SDLPoP/data/VPALACE, VDUNGEON — уже 256-цветные PNG). Не нужно ужимать в EGA/CGA палитру.
  • BGI-слой (graphics.h) — примитивы, палитра, текст, getimage/putimage — Фазы 1-2d готовы и проверены в MAME.
  • Спрайтовый движок v2 (sprite.h, ветка sprite-engine-v2) — ровно то, что нужно персонажам PoP:
    • retained-модель (sprite_update/sprite_flip, double-buffer, dirty-биты, heal+blit за один проход);
    • кадровая анимация по ленте (sprite_anim, LOOP/PINGPONG/ONCE, горизонтальная/вертикальная лента) и tween-перемещение (sprite_moveto, DDA без knowledge-heavy арифметики);
    • Y-сортировка слоями (gfx_sprite_ysort, layer) — то, что нужно для «Кид перед/за стражником» без ручной пересортировки;
    • атласы в EMM-страницах (atlas_t/atlas_load) — на восьмерых персонажей в rpgwalk уже работает: прямой прецедент для Кида/стражника;
    • ограничение кадра ≤ 64×64 — с запасом (см. §3: кадры Кида в оригинале ~12-30 × 39-42 px).
  • Frame pacing (gfx_set_fps_div) + цепочка кадровых IRQ — стабильный логический тик независимо от рендер-нагрузки экрана (проверено MAME).
  • EMM-бюджет: ~3.3 МБ свободно на старте (memory/sprinter_emm_budget) — с большим запасом на все спрайт-атласы и предрендеренные фоны комнат (см. §4) даже без выгрузки неиспользуемых уровней.
  • Файловый ввод-вывод (FILE* v2, fopen/fread/...) — для загрузки уровней/атласов/палитр с дискеты, по образцу rpgwalk (atlas_load, gfx_pal_fload).
  • Клавиатура (событийная)kbhit/getch/getkey (ASCII + KEY_* скан-код для стрелок), см. §2 — это НЕ то, что нужно для управления Кидом один в один (см. ниже).
  • Звукcbl.h (потоковый CBL/COVOX, callback-модель, verified MAME) — подходит для оцифрованных эффектов (digisnd*.dat — PC-звук ~11 кГц 8-бит, см. MSDOS_RESOURCE_FORMAT.md §4).

Вывод: движок отрисовки и анимации почти не требует нового кода — самый близкий по духу пример (rpgwalk: атласы, анимация, tween, дабл-буфер, FPS-делитель) переносится на PoP почти без изменений архитектуры.


2. Единственный принципиальный пробел: удержание клавиш

Спайк проведён (2026-07-15), вопрос закрыт артефактами — не догадкой.

getch/getkey — это события ESTEX WAITKEY/SCANKEY (по нажатию), без чёткой информации о СОСТОЯНИИ (что зажато прямо сейчас, несколько клавиш одновременно). Prince of Persia на управлении требует именно состояния: держать направление (бег) + одновременно нажать вверх (прыжок вперёд), держать Shift (модификатор) + направление и т.д.

2.1 Находки

  1. docs/converted/ProgrammerManual.txt документирует функцию, которую мы раньше пропустили: CTRLKEY (ESTEX $33h) — «Получить состояние клавиатуры». Дословно: «данные берутся не из буфера клавиатуры (как в остальных функциях), а непосредственно из результатов ПОСЛЕДНЕГО сканирования» — то есть это НАСТОЯЩЕЕ live-state, не событие. Но покрывает только модификаторы: Left/Right Shift, Ctrl, Alt, Rus/Lat, Num/Scroll/Caps Lock, Insert (не обычные клавиши вроде стрелок). Готовое решение для «держать Shift = бежать» — тривиальная обёртка, без архитектурных рисков.
  2. Для ОБЫЧНЫХ клавиш (стрелки, буквы) такого live-state нет нигде в ESTEX — WAITKEY/SCANKEY/TESTKEY ($30/$31/$37h) — все три отдают ОДИНАКОВЫЙ формат «очередное нажатие», без release. TESTKEY не удаляет событие из буфера (полезно для «подсмотреть, не потребляя»), но это тоже разовое нажатие, не состояние.
  3. Автоповтор клавиатуры (typematic) не годится как замена held-state: MAME_MCP_GUIDE.md фиксирует задержку до первого повтора ~1 секунда (типично для PS/2) — на порядок медленнее кадра (20 мс), не подходит для платформера.
  4. Решающий артефакт — libc/irq/_irq_tramp.c (сам трамплин прерывания, не гипотеза): вектор 0xFF общий для кадра/клавиатуры/CBL. Ветка клавиатуры (бит 0 порта 0x19 = SIO-A RR0 «байт принят») делает буквально jp 0x0038 (прямиком в DSS) до какого-либо чтения порта данных 0x18 И до нашей кадровой цепочки (_irq_chain) — наш irq_chain_add вообще не видит клавиатурные прерывания, они физически не доходят до цепочки (см. tr_notkbd/tr_frame разбор в файле). Значит текущая инфраструктура (тот же механизм, что несёт FPS-делитель) НЕ дает зацепки для клавиатуры без правки самого трамплина.
  5. Регистр данных SIO (порт 0x18) — аппаратный приёмный буфer, чтение деструктивно (дёргает байт из очереди); кто прочитал первым, тот и владеет байтом. Значит «подглядеть, не мешая DSS» технически невозможно — необходимо либо совсем не трогать этот путь (статус-кво), либо взять его СЕБЕ полностью на время геймплея.

2.2 Рекомендация (конкретная, не три равнозначных варианта)

A. Тривиально, почти без риска — обернуть CTRLKEY ($33h) отдельной функцией (например kbd_mod_state() в <conio.h>) — даёт настоящий held-state для Shift/Ctrl/Alt. Можно делать хоть сейчас, не архитектурное решение.

B. Для обычных клавиш (стрелки и т.д.) — по прецеденту CBL. В _irq_tramp.c уже есть пример «приватного» пути на том же векторе 0xFF, который сознательно НЕ чейнится к DSS (CBL: бит 7 порта 0xFE, свой хук _irq_cbl_hook, полный сейв, свой reti). Предлагаемый новый компонент <kbd_raw.h> — симметричный: ветка по биту 0 порта 0x19 читает порт 0x18 САМА (декодирует PS/2 make/break, 0xF0-префикс — протокол уже задокументирован в docs/samples/sprinterKeybLib.asm), ведёт битовую карту «клавиша N зажата», и НЕ прыгает в DSS, пока путь активен — жизненный цикл kbd_raw_open()/kbd_raw_close() один в один как у cbl_open/cbl_close.

Важное следствие (сообщить пользователю явно, не прятать): пока kbd_raw_open() активен, DSS вообще не получает клавиатурных байт — kbhit/getch/getkey/CTRLKEY заведомо не будут работать, ESC для выхода в DSS-смысле тоже (нужно проверять raw-битовую карту самим). Это нормально для активной фазы геймплея (у самой игры и так свой цикл ввода), но означает: экраны/паузы, которым нужен ESTEX-ввод (например, диалог сохранения через fopen, если тот когда-либо потребует ввода с консоли), должны на это время kbd_raw_close().

Не рекомендую вариант «таймаут-эвристика поверх SCANKEY» — after находки о typematic-задержке ~1с он не даёт нужной задержки для игры; рекомендация A+B закрывает потребность без компромиссов.

Статус: A+B РЕАЛИЗОВАНЫ (2026-07-15, по согласованию с пользователем).

  • A: kbd_mod_state()libc/conio/kbd_mod_state.c + <conio.h> (KBD_MOD_*).
  • B: <kbd_raw.h> (libc/kbd/) + правка libc/irq/_irq_tramp.c (новая ветка на бите 0 порта 0x19: raw активен → сама читает порт 0x18, декодирует make/break, НЕ чейнится к DSS; raw выключен — поведение как раньше, без изменений). Трамплин вырос со 150 до 220 байт — _IRQ_TRAMP_BUF_SIZE поднят с 224 до 288 (было 4 байта запаса, стало ≥60). make -C libc (fast+safe) — чисто.
  • Верификация в MAME (tests/kbdraw, полный цикл open→держать→ отпустить→ESC-выход→close): KBD_LEFT (0x16B, расширенный код E0 6B) — down на нажатие, up на отпускание, ТОЧНО совпало с константой из <kbd_raw.h>; KBD_ESC (0x76, обычный код) — корректно закрыл raw-канал и вернул DSS (IM вернулся в 1). Побочно найдено и задокументировано в docs/libc-reference.md (<kbd_raw.h>): MAME-мостовой press_key дёргает ОБЕ клавиатуры (PC+ZX) одновременно и через ZX-путь давал паразitный незатухающий бит — не относится к реальному сценарию (пользователь подтвердил: матрица на Sprinter давно не используется), но означает, что будущие MAME-тесты этой функции надо гонять через :kbd:ms_naturl:* напрямую, не через удобный press_key. UP/DOWN/RIGHT/SPACE/SHIFT константы — НЕ перепроверены поштучно (тот же общеизвестный стандарт PS/2 Set 2, что и подтверждённые LEFT/ESC — проверить перед использованием в PoC, если управление будет ощущаться неверно).
  • На реальном железе — не проверено (только MAME).

3. Формат данных — что напрямую переносим из docs/*RESOURCE_FORMAT.md

  • Уровень (BLUETYPE/BLUESPEC/LINKLOC/LINKMAP/MAP/INFO, 2304 байта, 24 экрана × 30 тайлов) — читаем один раз при загрузке уровня в свою C-структуру (прямой memcpy дампа файла, поля читаем по офсетам из APPLEII_RESOURCE_FORMAT.md §1). DOS levels.dat даёт то же самое +1 байт в конце записи — отбросить.
  • Графика фона/спрайтов — кодек сжатия DOS .DAT не восстановлен и восстанавливать не будем: используем уже распакованные PNG из SDLPoP/data/{KID,GUARD,VPALACE,VDUNGEON,...} (см. MSDOS_RESOURCE_FORMAT.md §5, §7 — тот же контейнерный формат/нумерация, просто другой релиз сборки данных). Измерено локально: кадры Кида — ~12×39 .. 30×42 px (P-режим, 4-бит палитра), фоновые тайлы подземелья — 32 px по ширине (10 колонок × 32 = 320 — сходится с шириной экрана), высота тайла 20/60/62 px (неоднородные ряды пола/потолка/арок) — укладывается в лимит спрайтового движка (кадр ≤ 64×64) без всяких изменений движка.
  • Звукdigisnd*.dat (PC-звук 8-бит ~11 кГц) — конвертация в сырой PCM и проигрывание через cbl_open/cbl_push_*; ibm_snd*.dat (PC-спикер тройки «частота×2Б + длительность») — тривиальный бипер, не требует CBL. MIDI-семейство (midisnd, mt32snd, prince.dat) — вне скоупа (нет синтеза MIDI на платформе; не блокирует геймплей).

4. Стратегия фона — ПЕРЕСМОТРЕНО 2026-07-15: тайловый рендерер В РАНТАЙМЕ

Было (первая версия плана): офлайн-склейка каждой комнаты в готовую растровую картинку 320×~193, gfx_blit целиком при входе — обоснование было «ноль нового кода в libbgi». Пересчёт по факту наличия структурных данных комнаты (§3.4 формата, level.h) показал: 16 уровней × 24 комнаты × ~60-80 КБ/картинка — это 30+ МБ, при том что одна и та же картинка тайла (пол/стена/колонна) переиспользуется в десятках комнат — офлайн- склейка печёт её заново в каждую копию.

Стало: тайлы — переиспользуемый набор картинок ОДИН на визуальный стиль (не на комнату), структурные данные комнаты — компактные (60 байт: 30×foretable+30×backtable, все 16 уровней ≈ 37 КБ, см. level.h). room_draw() (applications/PoP/poc/room.c) проходит 30 тайлов комнаты и зовёт gfx_blit для каждого, читая картинку из таблицы по типу тайла (tile_images[TILE_TYPE]). Итог: десятки-сотни КБ переиспользуемых тайл-картинок на весь визуальный стиль + ~37 КБ структуры уровней — вместо 30+ МБ.

Почему это НЕ бьёт по бюджету кадра: room_draw() зовётся ОДИН РАЗ при входе в комнату (смена комнаты — не every-frame событие), не в игровом цикле — это не sprite_update, тактовый бюджет кадра не затронут.

Анимированные тайлы (факел, шипы, дверь-плита) по-прежнему рисуются как отдельные sprite_t поверх фона — движок это уже умеет (Y-order/layers, dirty-биты, heal против фона через ОЗУ-копию); room_draw() кладёт в ОЗУ-копию именно статичную геометрию (пол/стены/колонны Фазы 1 — §5.2), поверх неё heal спрайтов работает как обычно.

toolchain/room_compose.py (генерик-компоновщик тайлов в одну картинку, §6.1) остаётся полезным ИНСТРУМЕНТОМ конвертации отдельных тайл-картинок (PNG → getimage raw), просто теперь его выход — 32 маленьких файла tileNN.raw (по одному на тип тайла), а не один большой файл на комнату; сама раскладка/повторное использование по комнатам — в C-коде (room_draw), не в офлайн-склейке.


5. Proof-of-Concept — цель: доказать, что порт вообще ощущается как PoP

Закрыт (исторический раздел). PoC в poc/ свою задачу выполнил и дальше не развивается: управление ощущается как PoP, held-state работает. Всё, что ниже про плейсхолдер-персонажа и приблизительную дугу прыжка, — уже неправда для активной ветки: в roomtest/ стоит настоящая графика Кида и авторские таблицы кадров (§6). Раздел оставлен ради истории решений (в частности §5.1 — почему сначала был плейсхолдер).

Объём: одна комната (например Level 1, экран старта Кида), без переходов между экранами, без стражников (стретч-цель, не обязательна).

Что показываем:

  1. Кид на экране, с закреплённым офлайн-конвертированным набором кадров (подмножество: idle, walk L/R, jump-начало/дуга/приземление, стоп-на-краю, возможно повисание на краю) — атлас в W0-странице, по образцу rpgwalk.
  2. Управление: держать влево/вправо — идёт; отпустил — тормозит/стоит; нажатие вверх во время бега — прыжок вперёд (дуга по авторским таблицам смещений, не по gravity-физике «с нуля» — см. §6). Здесь же проверяется решение по §2 (реальный held-state).
  3. Столкновения: пол/край экрана/провал — по факту чтения тайла из BLUETYPE под ногами (без LINKLOC-триггеров пока).
  4. Стабильный кадр 50 Гц через уже готовый gfx_wait_vsync/дабл-буфер (без FPS-делителя — Кид анимируется каждый видеокадр, как в оригинале).

Критерий успеха: субъективно «прыжок ощущается как в PoP» (дистанция и тайминг прыжка сверены с оригинальными таблицами, не подобраны на глаз — см. §6), управление отзывчивое (не событийное с задержкой), сцена не мерцает на стыке спрайт/фон.

Не входит в PoC: стражники/бой, звук, HUD/таймер, переходы между комнатами, ловушки/триггеры, титры/меню, сохранения.

Расположение: applications/PoP/poc/ (свой sprinter-cc проект + Python конвертер ассетов, по структуре examples/rpgwalk).

5.1 Статус (2026-07-15) — первая итерация: управление + коллизия края

Сделано и проверено в MAME (applications/PoP/poc/, make run): держать LEFT/RIGHT (kbd_raw_down, raw-канал из §2) — идёт непрерывно, отпустил — стоит на месте (не событийно, реальный held-state); столкновение с краями экрана (клип по MINX/MAXX); анимация ходьбы/разворота лицом по направлению (sprite_anim пинг-понг); дабл-буфер + gfx_wait_vsync — без видимого мерцания. Сборка — --memory huge без --bank (§10, подтверждено рабочим).

Важное отступление от плана (осознанно, не молча): персонаж — ВРЕМЕННАЯ заглушка (лицензированный спрайт-пак third_party/16x16-RPG-characters через tools/gen_kid_placeholder.py, тот же источник, что уже использует examples/rpgwalk), а НЕ конвертированная графика оригинальной Prince of Persia. Причина: исходный набор кадров Кида (SDLPoP/data/KID) — копирайт Broderbund/Ubisoft; автоматический конвейер, который систематически извлекает и переупаковывает его в новый формат, — это на практике внутрипроектное решение, которое стоит принимать пользователю явно для каждого шага, а не проводить асинхронно агентом без лишнего подтверждения. Сама графика — не то, что проверяет PoC (§5 явно: цель — ощущение управления/коллизий, не визуальная точность). Замена на настоящую графику Кида — отдельный шаг, на усмотрение пользователя.

Ещё не сделано (следующие итерации §5): авторские таблицы смещений кадров (§6 — движение при ходьбе линейное, px/кадр), реальный уровень/фон по BLUETYPE/LEVEL1 (сейчас — плейсхолдер: плоский пол на весь экран, без ямы/выступа), kbd_mod_state/ Shift-бег не подключены к игровому циклу (обёртка готова с Фазы A).

Прыжок/присед добавлены и ПРОВЕРЕНЫ (2026-07-15): состояние jumping/jump_t/crouching, своя приблизительная дуга прыжка (jump_height[], 40 кадров) — не авторская таблица, см. §6.1. HUD-текст статуса (нет отдельной позы).

Живое тестирование пользователем нашло реальный баг: держа UP чуть дольше 0.8 с (длительность дуги), получали ДВА прыжка подряд — код проверял kbd_raw_down(KBD_UP) как уровень (держится, пока клавиша физически зажата), а не как фронт нажатия, поэтому в момент приземления «UP всё ещё зажат» тут же триггерил новый прыжок. Исправлено edge-detect'ом (up_prev — предыдущее состояние UP, триггер только на переход 0→1). Проверено брейкпоинтом в отладчике MAME на адресе входа в код прыжка: за одно длинное удержание UP брейкпоинт срабатывает РОВНО ОДИН РАЗ — фикс подтверждён на уровне кода, не только «на глаз».

Побочный урок (см. docs/libc-reference.md <kbd_raw.h>): моя более ранняя попытка проверить UP/DOWN/RIGHT по скриншотам после press_key ошибочно решила, что скрипт их не нажимает вообще — на самом деле нажимает исправно, просто скриншот ловил случайный момент дуги. Брейкпоинт/watchpoint на конкретный адрес кода — надёжнее скриншота для таких проверок.


6. Модель движения: авторские таблицы кадров, не физика с нуля

Оригинальный движок PoP не считает прыжок как непрерывную физику (gravity/velocity каждый тик) — движение персонажа задано таблицами кадров анимации, где у части кадров зашито фиксированное смещение (dx, dy) для ЭТОГО конкретного кадра последовательности (структура видна и в исходниках Apple II — SEQTABLE.S/MOVER.S, и в SDLPoP seg003.c/seq* таблицах). Практическое следствие для нашего движка:

  • Не использовать sprite_anim/sprite_moveto для основного персонажа как есть (они лианейно тянут по таймеру/тянут к линейной цели) — вместо этого приложение само на каждый логический тик: переключает кадр (sprite_frame, атлас как лента поз, не «прогрессия первый..последний» автоматом) и одновременно применяет dx,dy ЭТОГО кадра к позиции (sprite_move).
  • Готовая автоматика движка (sprite_anim/sprite_moveto/tween, Y-сортировка) остаётся полезной для декоративных/фоновых элементов (факелы, патрулирующий стражник вне боя — почти один в один паттерн rpgwalk).
  • Источник таблиц смещений: переснять из Prince-of-Persia-Apple-II/01 POP Source/Source/{MOVER.S,SEQTABLE.S,FRAMEADV.S} и/или SDLPoP/src/seq*.c — задача Фазы 1 полной реализации (§7), не PoC (для PoC можно взять урезанный набор смещений вручную по количеству пикселей на кадр, посчитанному по видео/скриншотам оригинала, и уточнить позже).

6.1 Инструмент конвертации кадров разного размера (toolchain/png_strip.py)

Кадры персонажа в оригинале — РАЗНОГО размера каждый (bbox зависит от позы; sprite_t нашего движка (libbgi/include/sprite.h) хранит ОДИН фиксированный w/h на весь спрайт и рисует от угла, без per-frame смещения — в отличие от оригинала, где на каждый кадр было своё XCO/YCO (APPLEII_RESOURCE_FORMAT.md §2.2). toolchain/png_strip.py (генерик, не завязан на PoP — принимает произвольный список PNG) закрывает это ПАДДИНГОМ: канвас = макс. w/h среди кадров ленты, якорь по умолчанию bottom-center («ноги на месте»), остальное — прозрачность.

Компромисс, не полноценное решение: один сильно выбивающийся по размеру кадр в ленте раздувает канвас (и память) ВСЕХ кадров этой же ленты. Смягчается группировкой по похожим размерам в отдельные атласы (не одна лента на все позы актора — так уже сделано для ходьбы отдельно от прыжка).

Полноценное решение (кандидат в будущее расширение библиотеки, НЕ делать без предложения и подтверждения пользователя): per-frame смещение в sprite_t (аналог XCO/YCO оригинала) — тогда паддинг не нужен вообще, экономия памяти по полной. Делать только если память станет РЕАЛЬНОЙ проблемой (не гипотетической) — тогда предложить как отдельную правку sprite.h/движка. Подробности компромисса — memory/png_strip_padding_tradeoff.


7. Полноценное приложение — фазы (после PoC)

Порядок — по риску и зависимостям, не по геймплейной важности. Отметки статуса — на 2026-08-01.

Фаза 0 — инфраструктура портаСДЕЛАНА, но иначе, чем задумано:

  • Хелд-стейт клавиатуры по §2 — сделан.
  • Конвертер уровней не понадобился: res200N.bin из SDLPoP/data/LEVELS кладётся на образ как есть и читается по офсетам в рантайме (roomtest/pop_level.c), уровень живёт в EMM-странице.
  • Конвертер фона в растры отменён осознанно (§4): фон собирается тайлами в рантайме. Спрайты — toolchain/pop_pack_bg.py / pop_pack_kid.py / pop_pack_guard.py → атласы .atl (Kid — 28 страниц, риск §8 п.3 закрыт).

Фаза 1 — Кид, полный набор действийСДЕЛАНА: стоять/идти/бежать/ тормозить/разворот/прыжки/повисание/подтягивание/спуск/приседание/ осторожный шаг/питьё зелья/смерть от провала и от пик; переходы между комнатами во все четыре стороны. Осталось: старт по данным уровня (pop_level_start_* реализованы, но не подключены) — задача L1-START в ../roomtest/TASKS.md.

Фаза 2 — мир и ловушкиСДЕЛАНА: кнопки/ворота через LINKLOC/LINKMAP, шипы, loose-полы (тряска, обрушение, щебень, пробой потолка), зелья, дверь уровня (открывается), факелы. Подробности и справочник — gates_spikes_plan.md.

Фаза 3 — бойСДЕЛАНА в объёме обычного стражника: подбор и выхватывание меча, стойка, удар/парирование, коллизия клинков, HP обеих сторон, смерть; ИИ стража (замечает Кида, подходит, боевые ветки), персистентность трупа между комнатами.

Фаза 4 — разнообразие противниковНЕ НАЧАТА. Скелет нужен на уровне 3, толстый — на 6, тень — на 12, визирь — на 13; привязка «уровень → тип стража» (tbl_guard_type) описана в levels_plan.md §1.

Фаза 5 — звукНЕ НАЧАТА: CBL-эффекты (шаги, удары, двери, падение) из digisnd*.dat→PCM; PC-спикер тройки (ibm_snd*.dat) как опциональный дешёвый бипер без CBL, если формат подтвердится простым парсингом. Опкод SOUND в play_seq пока просто съедает свой аргумент — точки вызова уже на месте.

Фаза 6 — оболочкаНЕ НАЧАТА: титры, меню/выбор уровня, HUD (таймер/жизни), сохранение прогресса (FILE*), финальные катсцены — по минимуму, геймплейно не критично. Полоса HP — единственное, что уже есть.

Между Фазами 4 и 5 вклинивается то, чего в этом плане не было: переход между УРОВНЯМИ (загрузка следующего уровня, второй тайлсет palace, потабличные различия уровней). Отдельный документ — levels_plan.md.

Фаза 7 — стабилизация: полный прогон всех 14 уровней в MAME (mame_interactive.py), затем на реальном железе; профилирование бюджета кадра по методике sprite_engine_perf/sprite-api-design.md §9д на самых насыщенных экранах (несколько стражников + ловушки одновременно — проверить лимит ~21 спрайт/кадр и Y-sort лимит 32); при необходимости — банкинг (--memory big/huge) для кода/уровня, если размер вылезет за tiny/small.


8. Риски, требующие спайка/артефакта до架构 решений

(по правилу defer_unexplained_quirks — не гадать, проверять)

  1. Held-state клавиатуры (§2)закрыт (<kbd_raw.h>). Открытый остаток — не «есть ли held-state», а потеря байт при аккордах Shift+стрелка: ../roomtest/TASKS.md, KBD-1.
  2. Бюджет кадра — риск подтвердился, но не в том виде, в каком ожидался: спрайтовый движок для персонажей не используется, поэтому лимит «~21 спрайт/кадр» неприменим. Реальный бюджет упирается в heal+блиты и перерисовку тайлов; замер 2026-07-30 — типичный кадр ~371 К тактов (~86 % периода). Инструмент замера уже в коде: полосы бордюра PROF() в roomtest.c. План выжимания — ../roomtest/TASKS.md (CLIP-1) и ../roomtest/bug_list.md (T-1/T-2).
  3. Ёмкость атласа на акторазакрыт: Kid разложен на 28 атласов-страниц по 8 спрайтов (pop_pack_kid.py), страж — на 5; мульти-страничного формата .atl не потребовалось. Побочно подтвердился компромисс паддинга (§6.1).
  4. Тайминг оригиналаОТКРЫТ, и сверка 2026-08-01 показывает расхождение. Цифры оригинала (SDLPoP): базовый таймер BASE_FPS = 60 (types.h:1373), логический кадр игры — base_speed = 5 тиков (data.h:869), то есть 83.3 мс (12 лог. кадров/с); в бою fight_speed = 6100 мс (10/с). У нас (roomtest.c) — три ожидания gfx_wait_vsync() на итерацию, то есть 60 мс (16.7/с) и без отдельной скорости боя. Значит игра идёт примерно на 39 % быстрее эталона. Точное соответствие даёт 4 ожидания vsync (80 мс против 83.3) и 5 в бою (100 мс — совпадает точно). Проверять не «на глаз», а секундомером по одинаковому отрезку (SDLPoP рядом на том же экране), и только после того, как кадр перестанет иногда вылезать за период (см. п.2) — иначе замедление спрячет проблему бюджета вместо того, чтобы её показать.

10. Режим памяти сборки

Пользователь предложил huge (горячий код в W1, данные в W2, редко вызываемая логика — банками в W3) как целевой режим. Согласен, с уточнением по срокам принятия решения.

huge — правильная цель для ПОЛНОГО приложения, но не то, с чего надо стартовать:

  • Layout huge (см. memory_modes_implemented): CODE_LOC=0x4100 (W1), DATA_LOC=0x8000 (W2), банки — W3 (порт 0xE2), crt0_banked + автодетект W2 (как small). Состояние приложения (структуры Кида, уровня, массив sprite_t) остаётся в обычном W2-heap ДАЖЕ если код, который его трогает, забанкован — malloc из банка возвращает W2-указатель (bank_local_data_pattern), так что данные не привязаны к конкретному банку.
  • Оверхед __banked-вызова (trampoline: +3 байта на стеке между ret и аргументами, виртуальный 24-битный адрес, см. sdcc_banking) — фиксированная небольшая цена ЗА ВЫЗОВ, не за такт. Это не страшно для функций, которые вызываются РЕДКО за кадр (AI одного стражника, диалог, переход между комнатами) — страшно было бы забанковать что-то, что дёргается ВНУТРИ горячего цикла отрисовки (там уже и так основной бюджет уходит на sprite_update/блиты — см. sprite_engine_perf, ~19.5К тактов/спрайт). Правило простое: не банковать код на пути "раз в кадр на объект", банковать код на пути "раз в кадр на комнату/раз в переход/раз в редкое событие": логика ИИ стражника целиком, диалоги/катсцены, меню/ титры/выбор уровня, парсинг уровня при входе в комнату, сериализация сохранений — хорошие кандидаты в банки; тик Кида, чтение столкновений, вызов sprite_update/gfx_wait_vsync, обработка ввода — должны остаться небанкованными (W1/W2).
  • Гранулярность банкования — целый файл (--bank N=FILE.c), это уже системный паттерн проекта (тот же принцип, что и «1 файл = 1 юнит DCE» в libc) — значит выгодно с САМОГО начала Фазы 1 (не задним числом) резать исходники приложения по границе «горячее/холодное» файл-в-файл: например kid_tick.c/collision.c/room.c/input.c — неизменно вне банков; guard_ai_*.c/dialogue.c/menu.c/levelload.c/combat.c — кандидаты под --bank. Тогда переход на huge позже — это правка Makefile/ sprinter-cc-вызова (--memory huge --bank N=file.c ...), а не рефакторинг логики.

Уточнение (проверено в bin/sprinter-cc, строки ~342-350): можно сразу собирать PoC на --memory huge без единого --bank. Скрипт сам подставляет стаб const unsigned char n_banks = 0;, когда --bank не передан ни один раз — crt0_banked линкуется и корректно пропускает цикл загрузки банков при старте. Layout при этом byte-в-byte совпадает с тем, что делает crt0_small для режима small (CODE 0x4100/W1, DATA 0x8000/W2, автодетект W2) — разница только в том, что попутно линкуется сам bank.s (таблица _bank_pages + trampoline-инфраструктура), это незначительный довесок к размеру, не к рантайм-цене. Значит PoC можно сразу собирать вызовом sprinter-cc --memory huge без --bank-флагов — и когда в полном приложении появятся первые «холодные» файлы, переход на банкование — это просто добавление --bank N=file.c, без смены --memory/адресов/crt0. Сборочная конфигурация не потребует миграции между PoC и полным приложением.

Единственное, что стоит сделать уже в Фазе 1 полного приложения (не в PoC) — планировать структуру исходников с расчётом на будущий файл-в-файл сплит под банки (см. выше), раз гранулярность банкования — целый файл.


9. Что нужно от пользователя, прежде чем двигаться дальше

  • Подтверждение направления по §2 (какой из трёх вариантов held-state клавиатуры пробовать первым, или сначала спайк-эксперимент в MAME).
  • Подтверждение объёма PoC (§5) — устраивает ли «одна комната без стражников», или сразу закладывать хотя бы одного патрулирующего стражника (это не архитектурно сложнее — Y-order и tween уже есть, просто больше конвертации ассетов).