Compare commits

...

32 Commits

Author SHA1 Message Date
snark13 ea8efdb0fd SprPoP: обобщить HDD-сборку и очистить метаданные
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
2026-08-30 16:16:06 +03:00
snark13 623199337e SprPoP: HDD-раскладка и пути от каталога EXE 2026-08-30 11:21:11 +03:00
snark13 602c3a20fa SprPoP: сняты последние глушения насоса — палитра на смене уровня и загрузки треков
Замер тем же способом (брейк на pop_sfx_fill + печать totalcycles, три окна
по 800 вызовов = 9 секунд каждое, с уже снятыми глушениями):

  период насоса   245 760 тактов (медиана во всех окнах)
  максимум        245 832 / 270 096 / 311 346 (1,00 / 1,10 / 1,27 периода)
  пропущено порций 0

Порция считается пропущенной, когда зазор доходит до ДВУХ периодов: сама
порция отдаётся железу за период до того, как она понадобится, поэтому
опоздание обработчика на 1,1 мс — джиттер, а не потеря.  Запас
десятикратный.

Поэтому сняты и оставшиеся места:
* палитра через BIOS на переходе БЕЗ катсцены (sprpop_cold.c) — то самое,
  где ловили скрежет 2026-08-25; в комментарии помечено, что при возврате
  скрежета возвращать надо именно сюда;
* массовые чтения треков и ресурсов под чёрным экраном (pop_intro.c):
  первая реплика PV, трек заставки между уровнями, «время вышло», ресурсы
  финала.

Осталось только то, что глушит звук ПО СМЫСЛУ, а не ради защиты: выходы из
сцен (pop_music_free + pause), уход в титры и в игру, выключение по Ctrl+S.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:01:27 +03:00
snark13 4ac3584bf9 SprPoP: идея «готовить следующий уровень под мелодию» — в бэклог, на дальнюю версию
Записана с оговорками, найденными при сегодняшнем разборе: загрузку придётся
разрезать на дисковую и палитро-экранную половины, шаг подкачки держать
полустраничным, проверить EMM-бюджет на два уровня разом.  Половина идеи уже
работает — трек заставки играет поверх загрузки (порядок оригинала, замер
насоса приложен в записи).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:55:31 +03:00
snark13 3bf28da8ee SprPoP: трек заставки переживает загрузку уровня — порядок как в оригинале
ЗАМЕР (MAME, 2026-08-28).  Брейк на pop_sfx_fill с печатью totalcycles, 500
подряд вызовов насоса через всю загрузку уровня 1->2 под звучащий трек 27:
медиана интервала 245 760 тактов, максимум 245 832 при дедлайне 251 000
(11,7 мс) — НИ ОДНОЙ пропущенной порции.  Загрузка уровня насос не морит.

Поэтому снята двойная заплатка:
* pop_level_switch больше не глушит насос перед pop_level_load_num;
* pre_cut_finish больше не досиживает трек на чёрном экране (это делалось
  только чтобы глушение не обрубило его на полуслове; ценой были ~8 секунд
  пустого экрана — трек 27 длиннее сцены: 10,7 с против 2,6 с).

Взамен восстановлен порядок оригинала (seg003:68-108, play_level):
катсцена возвращает управление сразу -> уровень грузится ПОД музыку ->
ожидание конца трека на чёрном экране (порт `while (check_sound_playing())`
+ stop_sounds) -> показ уровня.  Общая чернота теперь max(трек, загрузка), а
не их сумма, и трек не обрывается.  Ожидание со страховкой на ~20 с, чтобы
потоковый трек не подвесил переход.

Пропуск сцены по-прежнему обрывает музыку — как и было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:53:44 +03:00
snark13 770b946a36 SprPoP: доски — SND-PACE-DEAD снят, PV-RENDER-BOUND исправлен
Обе записи закрыты сегодняшними правками: вторая шкала по насосу удалена
вместе с гонкой, которая её выбирала, а «сцена дороже бюджета» оказалась не
ценой кадра, а местом отсчёта интервала.  Исходные разборы оставлены под
заголовками — они объясняют, как искали.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:41:08 +03:00
snark13 89e9663753 SprPoP: сняты остальные глушения насоса — появление/закрытие меню, настройки, quicksave
Продолжение предыдущего коммита: заплатка стояла не в одном месте.

* открытие меню (snapshot палитры + затемнение фона + первая полная
  перерисовка) и закрытие (menu_erase — те же две полностраничные копии) —
  DI у копира бандами по 16 строк (~1,6 мс против дедлайна 11,7 мс), а
  палитровое затемнение идёт и в титрах с катсценами, где музыку не рвёт;
* сохранение POP.CFG и проба quickload — это десятки байт и open/close;
  прежняя осторожность «ESTEX уходит в диск надолго» относилась к загрузке
  НАБОРА страниц.  Подтверждение с поля: HOF пишется под звучащий «won»;
* quicksave/quickload (pop_qsave.c) — единственное снятое место, где по
  диску реально едут 16 КБ (~три периода насоса).  Помечено в комментарии:
  если на F6/F9 появится скрежет, вернуть pop_sfx_pause/start точечно сюда.

Дисковых глушений в живом звуке больше не осталось; те, что стоят на
загрузке уровня и наборов страниц, не трогали — там они по делу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:36:20 +03:00
snark13 ea07a8d7b0 SprPoP: меню больше не глушит звук на время перерисовки
ui_begin() звал pop_sfx_pause() перед полностраничной accel-копией, и это
слышно: pause закрывает CBL целиком (cbl_close), курсор трека стоит, и
короткая мелодия начала уровня замирала ровно на время перерисовки меню —
а на перемещении по пунктам это повторялось на каждом кадре меню.

Обоснование заплатки устарело.  Лист, который зовёт gfx_copy_page
(_bgi_scroll_rows_raw), режет DI бандами по 16 строк — ~1,6 мс против
дедлайна насоса 11,7 мс (85,4 порции в секунду), между бандами есть окно
прерываний.  Тот же полностраничный копир каждый кадр делают игровой цикл
и катсцены, и звук там не рвётся.  Скрежет, под который заплатка ставилась,
шёл от чтения насосом мусора и вылечен отдельно.

Парный pop_sfx_start() в ui_end оставлен: он идемпотентен и чинит вход в
меню при закрытом выводе (например сразу после загрузки уровня).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:29:04 +03:00
snark13 d23126983e SprPoP: музыка не замолкает в меню и в долгих фейдах
Насос CBL идёт из прерывания (и его запрос защёлкивается до подтверждения —
проверено по MAME irqack_cb), поэтому сам звук главному циклу не нужен.  А
вот ПОТОКОВЫЙ трек — титульная тема играется кольцом — дочитывается с диска
только в pop_music_service: страница ложится в слот, который насос уже
прошёл.  Этот вызов был лишь в игровом цикле, HOF и сценах, но не в меню и
не в фейдах, а там главный цикл стоит секундами — кольцо опустошалось, и
музыка замолкала до закрытия меню (жалоба пользователя).

Добавлено: ui_wait_frame() в pop_menu.c (обслужить музыку + ждать фронт,
заменил все ожидания кадра в меню) и pop_music_service() в fade_run и
transition_ltr (pop_ui.c) — темп тот же, 50 раз в секунду, как в игровом
цикле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:18:44 +03:00
snark13 50570881f5 SprPoP: подгонка молнии под музыку снята — причина устранена
PV_MAGIC_LEAD двигал жест заклинания (замах, шаг назад, вспышка) на 100
тиков (1,67 с) раньше сценария: сцена была render-bound, шла ~49 тиков/с
вместо 60, а реплика играла по реальному времени — кода приходила раньше
молнии.  После перевода сцены на единые часы и блочную отрисовку подгонка
стала вредной: молния била больше чем на секунду РАНЬШЕ коды (проверка
пользователем).  Ставим 0; константу оставляем на месте — если запись
другого набора (mt32/ogg) разъедется, крутить надо её.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:17 +03:00
snark13 f25ed37d85 SprPoP: единые часы в сцене с Джафаром + конец уровня ждёт свою мелодию
1. СЦЕНА С ДЖАФАРОМ — ОДНИ ЧАСЫ, КАДРЫ ЛУЧА.  Было две шкалы, и выбор между
   ними делался гонкой на старте сцены: одна выборка pop_snd_tick через кадр,
   «успел ли диск раскрутить звук».  От прогона к прогону сцена шла то по
   тикам насоса CBL, то по кадрам луча, и кода реплики приходилась каждый раз
   на другое место картинки (наблюдение пользователя).  Насос был нужен
   потому, что кадр рисовался дольше своего интервала; теперь отрисовка
   разложена по интервалам (pv_restore_bg), и счёт кадров честен — ветка
   насоса убрана целиком.

2. ПОДКАЧКА ТРЕКА — ПОЛСТРАНИЦЫ ЗА ШАГ (pop_music_load_step).  8 КБ ≈ 16 мс
   влезают в кадровый интервал, целая страница (33 мс) не влезала и
   растягивала кадр сцены.  В сцене шаг остаётся безусловным (иначе реплики
   не успевали грузиться, memory pv_music_stall_regression) и оплачивается
   ровно одним интервалом.

3. КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ.  Оригинал (seg003:387, play_level_2) не
   сменяет уровень, пока `check_sound_playing()`: экран пройденного уровня
   живёт с анимацией факелов, пока звучит трек.  Мы уходили на смену сразу и
   обрывали мелодию на первых нотах.  Теперь ждём большего из двух:
   pop_music_busy() и счётчика pop_endmus_left по длине записи
   (gen/pop_music_ticks.h) — второе нужно потому, что при ВЫКЛЮЧЕННОЙ музыке
   busy ложен, а оригинал выдерживает паузу и молча.

Стартовый уровень возвращён на 1 (отладочный LEVEL=14 был только для замера).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:19:43 +03:00
snark13 3449f6f8c9 SprPoP: кадр катсцены — блоками по кадровому интервалу, а не «отрисовка плюс пять»
Катсцены шли ~8,2 fps вместо десяти.  Делитель тут ни при чём: у оригинала
cutscene_frame_time = 6 тиков по 1/60 с (reset_cutscene, seg001:527; его
зовёт load_intro прямо перед сценой) = 100 мс, у нас 5 кадров луча по 50 Гц
= те же 100 мс.  Причина в том, ГДЕ отсчитывался интервал: сначала рисовали
кадр целиком, и только потом ждали vsync и ещё четыре — то есть отрисовка
ПРИБАВЛЯЛАСЬ к делителю.  Полноэкранная gfx_copy_page стоит ~547 000 тактов
= 1,27 кадра, отсюда 6+ кадров вместо 5 (замер PV-RENDER-BOUND: 49 тиков/с
вместо 60).

Теперь отрисовка разложена на блоки, каждый из которых заведомо влезает в
кадровый интервал, и после каждого честно ждём vsync:
  фон верхняя половина -> vsync | фон нижняя половина -> vsync |
  актёры и декорации -> vsync (+ флип) | служебный блок (звук, подкачка
  трека) и добор до CUT_FRAME_VSYNC.
Фон восстанавливаем только по картинке (200 строк с POP_YOFF), а не по всем
256: сверху и снизу чёрная рамка.  Общий хелпер pv_restore_bg на все три
цикла — cut_run (сцены 8/9/12 и финал), pre_room_animated (2_6/4/12 и
time_expired) и intro_pv_draw_frame (сцена с Джафаром); последний теперь
возвращает 3 кадра вместо 1 (13 вместо 11 со вспышкой), вызывающий их и так
учитывал.

Сверка делителей с SDLPoP: у всех сцен 6 тиков = 5 наших кадров; плавает
только pv_scene (6 -> 8 -> 7, seg001:434/455) — это уже сделано кумулятивно
через pv_seq_period + POP_T60, и минимальный бюджет (5 кадров) больше трёх
съедаемых блоками.

Замер в MAME пока НЕ сделан: до финальной сцены на отладочном старте
LEVEL=14 не добраться (решётка перед комнатой 5 закрывается по таймеру, а
Ctrl-комбинации через MCP-мост до игры не доходят).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:55:27 +03:00
snark13 6dabe9b4b1 libc: пока raw-клавиатура открыта, не звать обработчик DSS — он крал наши скан-коды
Корень двух багов SprPoP (KBD-STUCK-WAIT, частично GRAB-KBD-TIMING) нашёлся
в исходниках DSS (docs/sources/Estex-DSS): обработчик прерывания DSS живёт в
IM1 по 0x0038 и ПЕРВЫМ ДЕЛОМ делает `CALL KEYSCAN`, а тот вычерпывает FIFO
SIO досуха.  Наш трамплин проверял «есть ли клавиатурный байт» один раз, на
входе в прерывание, а хвост кадрового пути уходил в DSS — значит скан-код,
прилетевший позже, доставался DSS и уезжал в его буфер.  Rx-overrun при этом
НЕ взводится (байт не потерян железом, а прочитан не тем владельцем) — отсюда
и загадка исходного диагноза: бит залип при `_kbdraw_overrun == 0`.

Измерено в MAME (брейки + totalcycles): окно 738 тактов (~34 мкс) на каждом
кадровом прерывании, из них 481 такт — пролог самого DSS.  Поэтому проверка
FIFO перед chain'ом снимает лишь треть и не годится (пробовали, кражи
продолжались); кадровый путь при открытом raw теперь заканчивается приватным
RETI, окно = 0.  Цена: на это время у DSS замирает опрос мыши и мигание
текстового курсора — зафиксировано в <kbd_raw.h>.

Пойманный случай (старая сборка): DSS прочитал 0x74 (make стрелки «вправо»)
при _kbdraw_pending = EXT, то есть посылку E0 74 разорвало пополам между
двумя владельцами канала.

Проверка: брейк на входе KEYSCAN с условием «страница точно DSS + raw открыт»
до правки срабатывал мгновенно (50/с), после — молчит; положительный контроль
на нашем RETI срабатывает сразу.

Трамплин 300 -> 310 Б (буфер W2-копии поднят 336 -> 384, запас 74 Б);
размерный эталон обновлён: +10 Б у программ, линкующих IRQ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:39 +03:00
snark13 a05970cd36 SprPoP: убрана устаревшая копия релизного дерева dist/SprPoP
dist/SprPoP/** — снимок готового к раздаче каталога (exe + упакованные
ассеты) от старой раскладки.  Ничто его больше не собирает: `make`
кладёт результат в build/, образ — в build/hdd/.  В dist/ остаются
только ИСХОДНИКИ README (README.txt / README.ru.txt), из которых
Makefile печёт README на образе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:22 +03:00
snark13 2176c12cc5 PoP: чистка архива roomtest + справка по клавишам оригинала
Замороженная roomtest: убраны рабочие заметки прошлых сессий
(NEXT_SESSION.md, «new bugs») и скриншоты уже закрытых багов;
в Makefile — PROF=0 по умолчанию и путь к HDD-образу.

applications/PoP/docs/keys.txt — раскладка управления оригинального
PoP (справочник для порта).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:15 +03:00
snark13 4e43890fce SprPoP: финал больше не убивает программу — прямой вызов в чужой банк
Пройденная игра доходила до таблицы рекордов и умирала: программа
исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила
в reset.  Одинаково из Flex Navigator и из голого DSS.

КОРЕНЬ.  pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked.  Пока
pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после
переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в
пустой хвост чужого банка.  Процессор полз по 0xFF до 0x0000, где ловушка
DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3,
которую было видно на трупе, оказалась уборкой, а не причиной.

Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка
MAME на узком участке: trace включалась брейкпоинтом на входе в
pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56).

ЧТО СДЕЛАНО

* pop_ui.h/.c — группа text_*_mapped помечена __banked.
* toolchain/check_bank_calls.py — две проверки банкового кода:
  1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено
     намеренной поломкой);
  2) указатель на данные своего банка, отданный в чужой (эвристика по
     форме кода, только предупреждает).
  Встроена в app.mk, запускается сразу после линковки.
* pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10
  и после пометки __banked уезжал из-под ног чужому банку, заливая экран
  знаками вопроса.
* libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды
  нумпада в EXT-половину карты.  Лечит залипание стрелок (потерянный
  префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем),
  заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз.
* pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и
  gfx_wait_vsync: без этого F10 в геймплее не работал вовсе.
* pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с
  оригиналом записано в docs/impl_diff.md).
* Экран версии показывается только через Menu/Settings/About: стартовый
  показ и Ctrl+V убраны, мёртвый код снят.
* sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с
  выросшим за уровень максимумом полоса HP моргала между страницами.

Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md
(FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY),
правило про банки — в applications/SprPoP/CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:11:01 +03:00
snark13 25b2db8b0b SprPoP: README на образе — в корень, а не в каталоги-однофамильцы
На готовом HDD-образе вместо двух файлов оказывались два КАТАЛОГА с их
именами, и README лежал внутри каждого.  Виноват генератор аргументов
упаковщика:

    $(foreach f,$(DISK),$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f))

Он безусловно брал первое слово до слэша как имя каталога.  Для BG/bg.arc
это верно, но у записи БЕЗ слэша первое слово — всё имя, и README.TXT
превращался в README.TXT:build/README.TXT, то есть «каталог README.TXT,
файл внутри».  Теперь префикс подставляется только при наличии слэша;
причина записана в комментарий, чтобы следующий файл в корне не наступил
на то же самое.

Заодно имена: README_E.TXT и README_R.TXT.  Язык суффиксом, а не
расширением — прежний README.RUS в 8.3 укладывался, но терял .TXT, и
просмотрщик не открыл бы его как текст.

Проверено чтением готового .chd через chdman + mtools, а не по логу
сборки: баг был именно в том, что попадает на диск.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:12:12 +03:00
snark13 f76914849f SprPoP: колонки Controls сдвинуты вправо
Слева оставалось 6 свободных точек, справа 36 — блок выглядел прижатым к
краю.  Содержимое шириной 278 точек, сдвиг на +15 делает поля 21 и ~20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:58:16 +03:00
snark13 6cc8611d03 SprPoP: README для игрока и экран Controls в две колонки
README, который едет НА ДИСК рядом с программой, в двух версиях: README.TXT
(английский) и README.RUS (русский).  Краткое описание, запуск, полная
раскладка управления, читы, файлы игры, чего не хватает против оригинала,
благодарности.

Русский обязан быть в CP866 — DSS и местные просмотрщики читают именно её,
UTF-8 показался бы кракозябрами.  Исходники лежат в dist/ как UTF-8 (чтобы
читались в репозитории), перекодировка и CRLF делаются правилом Makefile.
ПОРЯДОК В КОНВЕЙЕРЕ ВАЖЕН: sed ставит CRLF ДО iconv — BSD sed в UTF-8
локали отказывается работать с байтами CP866 («RE error: illegal byte
sequence»), а с валидным UTF-8 работает.  iconv БЕЗ -c намеренно: потеря
символа должна ломать сборку, а не молча портить текст.

ЭКРАН CONTROLS переписан в две колонки.  В одну раскладка больше не
помещалась: после фаз A-C клавиш стало вдвое больше, а по высоте есть
только 200 точек вместе с заголовком.  Слева игра (бег, лазание, бой),
справа служебное и читы; высота блока считается по ДЛИННОЙ колонке.
Строка «IN A FIGHT» — заголовок, а не клавиша: ниже неё те же стрелки и
Shift означают другое, и без разделителя список читался бы противоречиво.

Заодно экран перестал врать: там до сих пор висели P, I, U и F7/F8 —
клавиши, переназначенные ещё в фазе A.

Проверено в MAME: Backspace открывает меню, Settings -> Show key bindings
показывает обе колонки целиком.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:50:59 +03:00
snark13 659071838d SprPoP: KEYS-D, KEYS-R и KEYS-F12 — на доску отложенного
Три остатка плана keys_plan.md записаны в TASKS_OPEN.md с полным разбором,
чтобы не выводить его заново.

KEYS-D (осмотр соседних комнат) — главное, что стоит помнить: у SDLPoP это
три строки, потому что там drawn_room влияет ТОЛЬКО на отрисовку, а физика
ходит через get_tile(room, col, row) с явной комнатой.  У нас наоборот —
карта коллизий грузится для ОТРИСОВАННОЙ комнаты: room_fg, lcol_fg,
rcol_fg, above_fg, below_fg это ОДИН комплект на программу.  Уведи cur_room
к соседу, не трогая kid_room, и Кид считает столкновения по чужим тайлам.
Задел под расхождение уже стоит (kid_room отдельной переменной,
update_kid_render_dx со сдвигом ±140), но enter_room_side пишет обе разом —
это незакрытая часть S3 straddle.

Записаны оба пути с ценой: честный (правка ядра, дни) и смотровой режим с
остановкой игры (150-250 байт, часы) — плюс что главный риск не в
отрисовке, а в возврате.

KEYS-R (воскрешение) — четыре места, которые обязаны знать про окно
неуязвимости, со ссылками на seg-код.

KEYS-F12 (скриншот) — «возможно, когда-то», по пометке пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:38:22 +03:00
snark13 f8e96c0495 SprPoP: читы здоровья и пера — фаза C плана keys_plan.md
Shift+S — одна единица HP (малое красное зелье), Shift+T — поднять потолок
HP (большая склянка), Shift+W — медленное падение (зелье пера).  Механика
у всех трёх в движке уже была: читы просто ставят те же переменные, что и
подобранное зелье.

СВЕРКА С SDLPoP дала деталь, которую легко проглядеть: ветка ЧИТА и ветка
ЗЕЛЬЯ различаются.  Зелье в seg006:1871 зовёт stop_sounds, а чит в
seg000:838 — нет.  Повторяем чит, а не зелье.  Исключение — перо: там
stop_sounds сидит внутри самой feather_fall(), поэтому остаётся.

Вторая деталь: Shift+T НЕ проверяет, полное ли HP, — потолок растёт всегда
(упираясь в POP_MAX_HITP = 10, как max_hitp_allowed оригинала).  У Shift+S
условие hitp_curr != hitp_max есть и сохранено.

Модификаторы разведены: Shift+S делит скан-код с Ctrl+S (звук), поэтому
требует отпущенного Ctrl; Shift+T делит с голым T (таймер), но тот сам
требует отпущенного Shift.

Проверено в MAME по памяти, а не на глаз: Shift+T 3/3 -> 4/4, Shift+W
взводит pop_feather в 148, Shift+S на подпорченном отладчиком HP 2/4 -> 3/4.

R (воскрешение) НЕ входит: это не ещё один чит, а правка модели смерти —
окно неуязвимости, размазанное по кадровой цепочке (счётчик в seg003:512,
пропуск пик и челюстей в seg000:1245, пропуск урона мечом в seg000:876,
восстановление позы в seg006:1352).  Задевает гейт «мёртв», про который
memory pop_level_restart_scope прямо предупреждает.  Отложено по решению
пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:34:19 +03:00
snark13 76f02e76db SprPoP: раскладка управления — фазы A и B плана keys_plan.md
Приводим клавиши к keys.txt (раскладка SDLPoP).  Работа делится надвое, и
здесь только то, что не трогает игровую логику: переназначения (A) и три
мелкие функции поверх готовых механизмов (B).  Читы, требующие правки
логики, и осмотр соседних комнат — фазы C и D, отложены.

ПЕРЕНАЗНАЧЕНИЯ (A)

  U            -> Shift+I   переворот экрана; U отдан «комнате сверху»
  Esc          -> Backspace меню; Esc остаётся дублёром, как у SDLPoP
  F7 / F8      -> - / +     ±минута, основной ряд и цифровой блок
  - / +        -> Ctrl+- / Ctrl++  обход комнат (наш отладочный телепорт)
  I            -> Ctrl+I    бессмертие
  P            -> Ctrl+P    режим скорости
  1 и 2        -> Ctrl+F    стоп-кадр, теперь одной клавишей
  F10 в игре   -> Ctrl+Q    вторая клавиша выхода; F10 ловится глобально

Плюс новое на готовых путях: Ctrl+A — рестарт уровня, Ctrl+V — версия
сборки (функция была, её показывал только старт), Ctrl+D — отладочная
строка (тот же тумблер, что в Settings; POP.CFG не пишем), Home / Page Up —
дублёры диагональных прыжков (у SDLPoP это не отдельное действие, а те же
Up+Left / Up+Right, поэтому просто добавляются к стрелкам).

Все наши сверхштатные клавиши ушли под Ctrl, чтобы не занимать голые буквы
из раскладки, и вписаны в keys.txt отдельным разделом.  Ctrl+B намеренно
не занята: keys.txt держит её под «вернуться в комнату Кида» (фаза D).

ФУНКЦИИ (B)

  Space  «сколько осталось» (seg000:612).  Не печатает сама: поднимает тот
         же pop_show_time, которым пользуется автоматическое объявление
         минут, и строку собирает time_msg() — «59 MINUTES LEFT» и «11
         SECONDS LEFT» остаются в одном месте.
  T      постоянный показ таймера.  Переиспользует поле DBG_F_TIME
         отладочной строки, своего рендера нет.  У верхней полосы теперь
         три состояния, и отслеживается РЕЖИМ (0 нет / 1 таймер / 2 всё),
         а не флаг: переход «таймер -> полоса» тоже перерисовывает всё.
  Ctrl+R возврат в заставку — тот же переход, что «Restart Game» в меню.

ДВЕ ЛОВУШКИ, найденные по дороге

  Модификатор обязан входить в САМО значение, а не в условие блока: с
  `if (ctrl) { nav = ...; nav_prev = nav; }` при отпускании Ctrl кромка
  застревала ненулевой и следующее нажатие глохло.

  Один скан-код на два чита: Shift+I и Ctrl+I — это 0x43 в обоих случаях.
  По той же причине ±минута требует ОТПУЩЕННОГО Ctrl (иначе сработает и
  время, и обход комнат), а T — отпущенных Shift и Ctrl (Shift+T отдан
  «добавить HP» в фазе C).

Обработчики положены в pop_frame_ui (банк 8), а не в резидент: там куча
всего 308 байт.  Проверено в MAME: Ctrl+D поднимает строку
«Level 1, Room 1, Speed: NORMAL...», T — один таймер 59:30 без подписей,
Space — watchpoint на pop_show_time ловит запись значения 2 (именно
обработчик клавиши, автообъявление пишет 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:22:21 +03:00
snark13 c71981fdf9 SprPoP: pop_config и pop_hof — из банка 9 в банк 10
Банк 9 дошёл до 98,5% (247 свободных байт), и следующая заметная правка
в menu/status/ui упёрлась бы в потолок.  Критерий переселения тот же, что
у остальной раскладки, — ЧАСТОТА ВЫЗОВА, а не размер: POP.CFG и POP.HOF
работают раз за партию и упираются в диск, где один `open` стоит 51 мс,
так что трамплин банк→банк на их фоне не существует.

Отдельный банк заводить не пришлось: в десятом лежал один pop_pal.c на
485 байт, то есть 3% страницы.  Новой страницы в образе не появилось.

  банк 9:  16137 (98,5%) -> 11948 (72,9%), свободно 247 -> 4436
  банк 10:   485 ( 3,0%) ->  4674 (28,5%), свободно 11710

Переезд безопасен, потому что все публичные функции обоих модулей
помечены __banked: трамплин выбирается по пометке в объявлении, а не по
банку, и непомеченная функция пережила бы переезд только внутри своего
банка.  pop_settings — обычный глобал в W2, виден отовсюду.

Следующий рычаг, если понадобится: pop_menu (4745 байт, 29% банка 9,
работает только при открытом меню).  Трогать нельзя pop_ui, pop_status,
pop_music и pop_timer — они в кадровом пути.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:49:01 +03:00
snark13 808c2a5349 SprPoP: заставку можно прервать в любой её точке, F10 — выход
Три места, где нажатие раньше не работало или работало наполовину.

FADE.  fade_run() был замкнутым циклом на 2,13 с без опроса клавиш, а на
стыке экранов их два: пропуск работал внутри сценария, но не между его
шагами, и заставка ощущалась невыключаемой.  Добавлены прерываемые
варианты (pop_ui_fade_*_skip, обёртки в pop_pal) — отдельными функциями,
а не флагом в прежних: в меню паузы и на переходе уровня прерывать
нечего, и менять там поведение молча не следует.  Прерванный fade всё
равно доводит палитру до конца, экран не остаётся на промежуточной
ступени.  Подключено в интерпретаторе сценария, сцене с принцессой,
четырёх катсценах cut_*, титрах и таблице рекордов.

ПРОЯВЛЕНИЕ ПОЛОСАМИ.  pop_screen_present_ltr() была void и нажатие
ГЛОТАЛА: полосы схлопывались, картинка появлялась целиком — и всё,
вызывающий о нажатии не узнавал.  На заставке это выглядело как
«клавиша срабатывает наполовину».  Теперь возвращает признак прерывания,
и он проброшен по маршруту: первый экран истории, титры финала, логотип
между «свадьбой» и титрами.

Везде считается КРОМКА нажатия от входа, как в сценах: клавиша, которой
закончили предыдущий экран, ещё зажата, и принимать её за новое нажатие
нельзя — иначе весь маршрут заставки схлопывался бы сам собой.

F10 — НЕМЕДЛЕННЫЙ ВЫХОД, откуда угодно.  Проверка стоит в kbd_idle(), а
этот хук висит на gfx_wait_vsync, то есть вызывается везде, где программа
ждёт кадр: заставка, титры, fade, проявление полосами, меню, игра.  Одна
точка вместо десятка по циклам ожидания.  Флаг pop_quit_req резидентный —
взводится и читается без трамплина из любого банка.  Проверка в НАЧАЛЕ
витка автомата обязательна: заставку прерывает любая клавиша, и без неё
F10 успевал уронить программу в загрузку уровня перед закрытием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:49:01 +03:00
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
snark13 4b74478d19 Таблица рекордов на титрах проявляется собранной
Строки дорисовывались уже после полосового перехода: он копирует страницу
акселератором, а тот читает ОЗУ-копию, куда GFX_BANK_SPRITE (NOSHADOW +
TRANSPARENT) не пишет. Банк GFX_BANK_TRANSPARENT даёт ту же прозрачность
0xFF, но обновляет и копию, поэтому страницу можно собрать целиком до
показа — как offscreen оригинала (draw_full_image + show_hof, и только
потом transition_ltr).

Проверено в MAME: на промежуточных кадрах перехода строка рекорда видна в
уже проявившейся части экрана вместе с логотипом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:18:58 +03:00
snark13 747783c422 Реплики сцены с принцессой: вернуть подкачку в кадр
Регрессия 4086dde: тот коммит перенёс шаг постраничной подкачки «в паузу
кадра» — внутрь цикла ожидания тика насоса — и одновременно ввёл защиту от
промотки (snd_want подтягивается к snd_done). Вместе это убило подкачку:
паузы в этой сцене почти нет, цикл ожидания выходит сразу, шаг не
вызывается. Реплики Джафара не успевали загрузиться к своему тику,
pop_music_play() молча ничего не делал, и после первой реплики сцена шла
в тишине.

Замер в MAME (адреса статиков pop_music.c — от public _pop_mus_page по
смещениям из .sym): до фикса ld_next стоял на нуле 20 секунд при активной
загрузке; после — 11 страниц из 11, mus_ready=1, и звучат все три реплики
m50 → m53 → m52.

Фикс: один безусловный load_step в кадре сцены; шаг в паузе оставлен, он
бесплатный, когда время есть.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:00:16 +03:00
snark13 a3c5c600da Таблица рекордов: оба показа оригинала, ввод имени, переходы полосами
pop_hof.c был написан, но никуда не вызывался: после финала автомат уходил
мимо него в title, а на титрах экран получался чёрным. Теперь это хвост
end_sequence и шаг show_title между credits и attract-demo, как в оригинале.

Два корня пустого экрана:

- story.pal — 256 записей, и запись 0x3F (цвет глифов шрифта) в ней чёрная:
  текст рисовался, но был не виден. Генератор кладёт туда золотой 0xB7;
- полосовой переход копирует страницу акселератором, а тот читает ОЗУ-копию,
  куда прозрачный блит текста не пишет. Строки рисуются после перехода,
  прямо в видимую страницу.

Попутно:

- s5 в архиве PV — фон таблицы (рамка story + логотип на y=24, HOF_POP);
- отдельный индекс палитры под фон текстовой рамки: финал красит его в
  #800000, титры оставляют #100060 (load_title_images(bgcolor)). Прежний
  ремап в индекс 9 подменить было нельзя — им нарисована сама титульная
  картинка;
- второй набор глифов в font.atl цветом 0x3E: у SDLPoP шрифт маска и
  show_hof_text рисует текст дважды разным цветом, у нас цвет запечён в
  пиксели. Каталог SPA1 держит счётчик в байте, поэтому набор обрезан по '_';
- общий pop_screen_present_ltr(): им теперь пользуются story-переход интро,
  титры, таблица и титульная картинка финала. Заодно вернулся пропущенный
  шаг show_title — экран с логотипом и Jordan Mechner между «свадьбой» и
  титрами (BUGS_CLOSED#cutscene-ltr).

Формат POP.HOF — 6 записей, имя до 15 символов, версия 2. Проверено в MAME
на сборке LEVEL=14: HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:41:41 +03:00
snark13 faec9a7d3a Финальная тема won: кольцевой стриминг с диска
Последний неозвученный кусок игры. 115 секунд и 1,2 МБ в память не влезают
никак, поэтому тема играется кольцом из шести страниц (96 КБ = 9 с): насос
идёт по кругу, а pop_music_service дочитывает файл в те страницы, которые
насос уже прошёл. Страница звучит 1,5 с и читается 33 мс — запас
сорокакратный; дистанция считается без отдельных счётчиков, потому что
страница это ровно 128 блоков насоса.

Три вещи, всплывшие при живой проверке:

- кольцо обязано сниматься при любом обычном запуске трека, иначе
  следующая тема играет по кругу первых шести страниц (поймано на титрах
  сразу после победы);
- живые сцены комнаты принцессы теперь открывают CBL сами (cut_begin):
  pop_ending_show глушит звук первым действием, и «arrived to princess»
  не звучал вовсе;
- тема дослушивается до конца с возможностью прервать клавишей — как
  seg001:637. У оригинала там стоит ввод имени в таблицу рекордов; когда
  он появится, ожидание переедет за него (задача HOF-ENTRY).

Проверено в MAME на сборке LEVEL=14: после встречи с принцессой звучит
тема победы (id 56, кольцо 6), курсор уходит далеко за размер кольца —
подкачка успевает. Host-тесты: 15 наборов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:32:40 +03:00
snark13 5d912f0f3f Доска: ARC-ATLASES закрыта, SND закрыта иначе (PCM вместо AY)
Обе задачи выполнены, но SND — не так, как планировалась: музыка пошла не
на AY, а тем же PCM через CBL. Исходные постановки свёрнуты в details,
сверху — что фактически сделано и какие грабли попались.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:04:37 +03:00
snark13 4086dde1f1 Сцена с принцессой: убрана промотка кадров; GAME PAUSED чистит полосу
ПРОМОТКА. Подкачка следующей реплики (страница — 33 мс) и любой тяжёлый
кадр оставляли часы насоса впереди расписания, и цикл гнал кадры без
единого ожидания, пока не наверстает: пламя факелов мелькало, а события
соседних тиков слипались в один кадр — заявка на звук перезаписывала
заявку, и створка ворот или дверь покоев пропадала. Отсюда же ощущение,
что музыка до появления Джафара тянется дольше нужного.

Долг больше не наверстываем — тот же принцип, что у pop_pace_end: якорь
ставится по факту. Плюс сама подкачка перенесена ВНУТРЬ паузы кадра: 33 мс
диска укладываются в ожидание (кадр сцены — 133 мс) и к длительности кадра
не добавляются.

GAME PAUSED писалось поверх полосы, не очищая её: если в строке HP висело
сообщение игры («60 MINUTES LEFT», «QUICKSAVE»), две надписи ложились друг
на друга. Полосу теперь чистит pop_status_wipe.

Host-тесты: 15 наборов. Проверено в MAME: сцена с принцессой идёт ровно,
принцесса проявляется вместе с комнатой, GAME PAUSED чистое.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:01:48 +03:00
snark13 0b15ed7b8f Разгрузка W1: смена уровня и служебный слой — в банк 8
_main занимал 3658 байт резидента, и добрая половина этого — события «раз
в партию». Вынесены целиком:

- pop_level_change — смена уровня (катсцена, гашение, загрузка, палитра,
  звук): 61 строка, наружу осталось одно условие и вызов;
- pop_frame_ui — меню, F10 и QuickSave/Load: 58 строк, плюс туда же уехали
  edge-состояния клавиш и сами коды клавиш (снаружи они больше не нужны).

_CODE 24102 -> 23626, запас W2 вырос с 86 до 559 байт.

Заодно три правки по замечаниям:

- затемнение фона под меню считалось числом «2», а шкала ступеней выросла
  с 4 до 32 — фон почти не гас. Теперь доля от шкалы (POP_PAL_DIM_BLACK/2);
  тем же прошлись по остаткам pop_pal_apply(4);
- в катсценах персонажи появлялись только после fade in: первый кадр
  рисуется ПОД чёрной палитрой, и картинка проявляется целиком, вместе с
  актёрами (cut_scene_8/9/12_short/ending, pre_room_animated,
  intro_pv_animated);
- отладочная полоса по умолчанию выключена.

Host-тесты: 15 наборов. Проверено в MAME: меню открывается и закрывается,
фон под ним затемнён, полосы нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:42:56 +03:00
328 changed files with 55307 additions and 636 deletions
+18
View File
@@ -9,3 +9,21 @@ args = [
"mcp<2",
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
]
[mcp_servers.mame-z80.tools.clear_breakpoint]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.list_breakpoints]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.press_key]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.step_out]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.debugger_command]
approval_mode = "approve"
[mcp_servers.mame-z80.tools.pause]
approval_mode = "approve"
+13
View File
@@ -131,3 +131,16 @@ applications/PoP/PoP1_DOS_music/
# R1 — рабочая копия roomtest для экспериментов пользователя, в репозиторий
# не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/
# SprPoP — автономное приложение. В репозиторий идут исходники, генерируемые
# заголовки (gen/) и упакованные ресурсы (assets/packed/), потому что без них
# из чистого клона ничего не собрать. НЕ идут:
# assets/orig/ оригинальные данные игры — копирайт (SDLPoP, MSDOS,
# записи музыки DOS-версии); откуда их взять,
# написано в assets/orig/README.md
# assets/packed/LEVELS/ уровни res20xx.bin ложатся на диск прямо из
# оригинала, конвертации нет — то есть это тот же
# копирайтный материал, только в раскладке диска
# build/ уже покрыт общим правилом build/ выше
applications/SprPoP/assets/orig/
applications/SprPoP/assets/packed/LEVELS/
+3 -1
View File
@@ -80,7 +80,9 @@ floppy: tests examples tests/seek/big.txt
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
# ucsim идёт в комплекте нашего SDCC.
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
# applications/PoP/roomtest заморожена (её ветка развития — SprPoP), поэтому
# её набор здесь больше не гоняется.
HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
host-tests:
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
+45 -11
View File
@@ -13,6 +13,7 @@
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
#
# include $(PROJ_ROOT)/app.mk
#
@@ -36,17 +37,40 @@ LIB := $(PROJ_ROOT)/lib/sprinter.lib
MAME_DIR := $(PROJ_ROOT)/mame/v306
FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
HDD_IMG := $(MAME_DIR)/IMG/test_hdd.chd
# ?= — приложение со своим каталогом выхода (applications/SprPoP) держит
# образ у себя и связывает его с MAME символьной ссылкой.
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
MAKE_DISK := $(MAME_DIR)/make_disk.py
MAKE_HDD := $(PROJ_ROOT)/toolchain/make_hdd.sh
MAKE_HDD ?= $(PROJ_ROOT)/toolchain/make_hdd.sh
RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file.
MEMORY ?= tiny
SOURCES := $(EXAMPLE).c $(EXTRA_SRCS)
# SRC_DIR / BUILD_DIR — раскладка приложения, которое НЕ держит исходники и
# выхлоп в одной папке с Makefile (applications/SprPoP: src/ и build/). По
# умолчанию обе пусты, то есть всё как было: ./$(EXAMPLE).c → ./$(EXAMPLE).exe.
# Пустое значение обрабатывается отдельной веткой намеренно: "./prog.exe" и
# "prog.exe" — разные имена целей, и склеивать префикс безусловно нельзя.
SRC_DIR ?=
BUILD_DIR ?=
ifeq ($(strip $(SRC_DIR)),)
MAIN_SRC := $(EXAMPLE).c
else
MAIN_SRC := $(SRC_DIR)/$(EXAMPLE).c
endif
ifeq ($(strip $(BUILD_DIR)),)
EXE := $(EXAMPLE).exe
else
EXE := $(BUILD_DIR)/$(EXAMPLE).exe
endif
SOURCES := $(MAIN_SRC) $(EXTRA_SRCS)
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
# своей раскладкой каталогов может переопределить переменную до include.
HDD_PACK_ARGS ?= $(EXAMPLE).exe $(EXTRA_DATA)
HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA)
# Общий каталог назначения внутри HDD. Пустое значение сохраняет прежнюю
# укладку в корень; вложенные КАТАЛОГ:файл считаются относительно него.
HDD_DEST_DIR ?=
CC_FLAGS := --memory $(MEMORY)
ifneq ($(STACK_SIZE),)
@@ -54,15 +78,25 @@ CC_FLAGS += --stack-size $(STACK_SIZE)
endif
CC_FLAGS += $(EXTRA_FLAGS)
all: $(EXAMPLE).exe
all: $(EXE)
# runtime/*.s (crt0-семейство, bank.s, heap.s) собираются per-build
# внутри sprinter-cc — без этой зависимости их правка не перелинкует
# уже собранный exe (кусало: фикс bank.s не подхватился).
RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
$(EXAMPLE).exe: $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
# ПРОВЕРКА БАНКОВЫХ ВЫЗОВОВ — сразу после линковки, пока артефакты свежие.
# Ловит прямой `call` в чужой банк: он собирается МОЛЧА и стреляет диким
# переходом в пустой хвост банка (разбор — в шапке скрипта). Запускается
# только если банки вообще есть, то есть по наличию каталога сборки с
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
@@ -76,13 +110,13 @@ $(LIB):
$(MAKE) -C $(PROJ_ROOT)/libbgi
clean:
rm -rf .sprinter-cc-* $(EXAMPLE).exe
rm -rf $(if $(strip $(BUILD_DIR)),$(BUILD_DIR),.sprinter-cc-* $(EXE))
# `make floppy` packs ONLY this program (+ optional EXTRA_DATA files) into
# the MAME floppy image, replacing whatever was there. Handy for trying a
# single program without rebuilding everything.
floppy: $(EXAMPLE).exe
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
floppy: $(EXE)
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXE) $(EXTRA_DATA)
@echo
@echo "Floppy ready: $(FLOPPY_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA))) "
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
@@ -94,8 +128,8 @@ run: floppy
# HDD image mounted as disk D: (-hard2 test_hdd.chd). Гораздо быстрее FDD —
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
hdd: $(EXAMPLE).exe
$(MAKE_HDD) $(HDD_IMG) $(HDD_PACK_ARGS)
hdd: $(EXE)
$(MAKE_HDD) $(if $(strip $(HDD_DEST_DIR)),--dest "$(HDD_DEST_DIR)") $(HDD_IMG) $(HDD_PACK_ARGS)
@echo
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран."
+58
View File
@@ -0,0 +1,58 @@
* Left: turn or run left
* Right: turn or run right
* Up: jump or climb up
* Down: crouch or climb down
* Down+Left/Right: hop
* Shift: pick up things
* Shift+Left/Right: careful step
* Home or Up+Left: jump left
* Page Up or Up+Right: jump right
* Up while running: running jump
* Shift while falling: grab onto ledge
* Left/Right: walk (advance or retreat)
* Shift: strike (attack)
* Up: block (defend)
* Down: put sword away; press Shift to draw your sword again.
===
* Esc: Pause game.
* Space: Show how much time is left.
* Ctrl+A: Restart level.
* Ctrl+R: Return to intro.
* Ctrl+S: Sound on/off.
* Ctrl+M: Music on/off.
* Ctrl+V: Show version of SprPoP.
* Ctrl+Q: Quit game.
* F6: Quicksave: Save the exact state of the game.
* F9: Quickload: Load what the last quicksave saved.
* F12: Save a screenshot to the screenshots folder.
* Backspace: Display the in-game menu. (Esc will also display the menu by default, but you can turn that off.)
* Shift+L: Go to next level.
* -: Decrease remaining time by one minute.
* +: Increase remaining time by one minute.
* R: Resurrect kid.
* K: Kill guard.
* Shift+I: Flip the screen upside down.
* Shift+W: Slow falling.
* Shift+S: Restore a lost hit-point. (Like a small red potion.)
* Shift+T: Give more hit-points. (Like a big red potion.)
===
* H: Look at the room to the left.
* J: Look at the room to the right.
* U: Look at the room above.
* N: Look at the room below.
* Ctrl+B: Go back to the room where the prince is. (Undo H,J,U,N.)
===
* [: Shift kid 1 pixel to the left.
* ]: Shift kid 1 pixel to the right.
* T: Toggle display of timer (remaining minutes:seconds:ticks). Also shows the total elapsed ticks during playback.
+31 -5
View File
@@ -930,8 +930,34 @@ load_intro у оригинала и так начинается с ожидан
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
десяти с лишним секунд.
**Чего пока нет.** Финальный `won` (56): 115 с, 1,2 МБ, 78 страниц EMM —
в `POP_MUS_PAGES` (20) он не помещается даже теоретически. Ему нужен
КОЛЬЦЕВОЙ стриминг: держать в памяти несколько страниц и дочитывать в те,
что насос уже прошёл. Это отдельная задача — курсор насоса придётся
научить заворачиваться, а загрузчик — держать дистанцию от него.
## 8. ПОТОКОВЫЙ ТРЕК: ФИНАЛЬНАЯ ТЕМА (2026-08-26)
`won` (56) — 115 с, 1,2 МБ, 78 страниц EMM. В память он не влезает ни при
каком бюджете, поэтому играется КОЛЬЦОМ из шести страниц (96 КБ = 9 с): насос
идёт по кругу, а `pop_music_service` дочитывает файл в те страницы, которые
насос уже прошёл.
**Кто кого догоняет.** Страница звучит 1,5 с, а читается 33 мс — запас
сорокакратный. Дистанция считается без отдельных счётчиков: страница ровно
128 блоков насоса, поэтому проигранных страниц = (всего блоков − осталось)
/ 128. Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце
есть свободный слот. Файл читается ПОСЛЕДОВАТЕЛЬНО, без `lseek`.
**Что пришлось учесть.**
* Насос заворачивает страницу только при `pop_mus_ring != 0`; конец трека
по-прежнему определяет `left`. Обычный трек этой ветки не касается.
* Кольцо обязано сниматься при любом обычном запуске (`pop_music_play`,
`_stream`, `_load_begin`): иначе следующий трек играет по кругу первых
шести страниц — поймано на титрах сразу после победы.
* Живые сцены комнаты принцессы открывают CBL сами (`cut_begin`):
`pop_ending_show` глушит звук первым действием, и «arrived to princess»
(26) не звучал вовсе.
* Тема дослушивается до конца (прерывается клавишей), как `while
(check_sound_playing() && !key_test_quit())` в seg001:637. У оригинала
между титрами и этим ожиданием стоит ввод имени в таблицу рекордов —
когда он появится у нас, ожидание переедет за него (задача HOF-ENTRY).
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
за размер кольца — то есть подкачка успевает.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+171
View File
@@ -3571,3 +3571,174 @@ FIRST_LEVEL`. Рестарт ТОГО ЖЕ уровня (смерть, чекп
**Проверено в MAME:** после провала на 6-м `pop_current_level` = 7, Kid.room
= 16, то есть спецсобытие отработало (старт в 17, переход вниз).
---
<a id="cutscene-ltr"></a>
## CUTSCENE-LTR. Переходы story должны проявляться слева направо — **ЗАКРЫТ 2026-08-26**
**Было.** Полосовой переход (`transition_ltr` оригинала) стоял только на
«In the absence…»; «свадьба» → титры возникали мгновенно, а промежуточный
титульный экран отсутствовал вовсе.
**Стало.** Переход выделен в общий `pop_screen_present_ltr()` (pop_ui.c) —
им пользуются story-переход интро, титры, таблица рекордов и титульная
картинка финала. Восстановлен и пропущенный шаг `show_title()`
(seg000:2045): между «свадьбой» и титрами показывается композиция с
логотипом и Jordan Mechner, тоже полосами, и лишь через 0x78 тиков идут
титры.
**Грабля, которая стоила отладки.** Переход копирует страницу
акселератором, а тот читает ОЗУ-копию. `GFX_BANK_SPRITE` (NOSHADOW +
TRANSPARENT) в неё не пишет, поэтому переезжал один фон без текста (так вышла
пустая таблица рекордов, см. [HOF-ENTRY](TASKS_CLOSED.md#hof-entry)). Всё,
что должно проявляться ВМЕСТЕ с фоном, рисовать банком
`GFX_BANK_TRANSPARENT` — прозрачность та же, но копия обновляется.
---
<a id="pv-music-stall"></a>
## PV-MUSIC-STALL. В сцене с принцессой звучала только первая реплика — **ЗАКРЫТ 2026-08-26**
**Симптом (пользователь).** «Музыка после открытия двери для входа Джафара
пропала» — дальше сцена шла в тишине до конца: не звучали ни реплика входа
Джафара (m53), ни его ухода (m52).
**Корень.** Регрессия коммита `4086dde` («убрана промотка кадров»). До него
шаг постраничной подкачки стоял БЕЗУСЛОВНО в теле кадра сцены (плюс там же
вызывался `pop_music_service`). Тот коммит перенёс шаг «в паузу кадра» —
внутрь цикла ожидания тика насоса — и одновременно ввёл правильную защиту от
промотки (`if (snd_done > snd_want) snd_want = snd_done`). Вместе это и убило
подкачку: пауза в этой сцене почти не выпадает, цикл ожидания выходит сразу,
и шаг не вызывается.
**Замер, который это показал** (адреса статиков `pop_music.c` вычислены от
public `_pop_mus_page` по смещениям из `.sym`):
| | до фикса | после фикса |
|---|---|---|
| `ld_next` (прочитано страниц m53) | **0** за 20 с | **11 из 11** |
| `mus_ready` | 0 | 1 |
| что звучит | только m50 | m50 → m53 → m52 |
**Фикс.** Один безусловный `pop_music_load_step()` в кадре сцены (шаг в паузе
оставлен: он бесплатный, когда время есть). Страница стоит 33 мс против
кадра сцены в 100-133 мс, а шкала работает по факту прошедшего времени,
поэтому сцена от этого не разъезжается.
**Ловушка отладки, стоившая ложного диагноза.** Пока машина стоит на
брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`.
---
<a id="final-bankcall"></a>
## FINAL-BANKCALL. Финал убивал программу: прямой вызов в чужой банк
**Симптом (пользователь, 2026-08-27).** Пройденная игра доходила до
таблицы рекордов и умирала: программа исчезала, а машина следом вставала
намертво (`di; halt` по адресу 0x0000) либо уходила в reset. Из Flex
Navigator и из голого DSS — одинаково.
**Что оказалось не при чём** (проверено, чтобы не искать заново): Flex
Navigator, звук и CBL (с выключенным звуком падало так же), потоковое
чтение победной темы, второй `open` поверх открытого файла, фейды с
клавиатурным диспетчером, чтение архива PV, межбанковый вызов
банк 10 → банк 11, переполнение банка (самый полный — BANK8, 15376 из
16384), утечка манипуляторов (`_fd_open_count` = 1) и утечка ссылок на
IM2-таблицу (`_irq_refs` = 2, как задумано).
**Как искали.** Бисекция ключом `HOF=0/2/4/5` (пропустить таблицу целиком
/ только `hof_load` / без фона / без фейдов) сузила место до
`hof_draw_rows`, а точную инструкцию дала ТРАССИРОВКА MAME на узком
участке: `trace` включалась брейкпоинтом на входе в `pop_hof_show`
(0x44AB) и выключалась на процедуре завершения процесса DSS (0x1E56).
`history` для этого не годится — её 250 записей забиваются обработчиком
прерываний, пока процессор ползёт по мусору.
**Корень.**
```
CD42: ld hl,$B8AB ← _gfx_bank
CD45: ld (hl),$58 ← gfx_set_bank(GFX_BANK_TRANSPARENT)
CD47: call $E503 ← ПРЯМОЙ вызов; по карте 0009E503 = _pop_text_map, банк 9
E503: rst $38 ← а в W3 стоит банк 10, там пустой хвост (0xFF)
```
`pop_ui.h` объявлял группу `pop_text_*_mapped` БЕЗ `__banked`. Пока
`pop_hof.c` лежал в банке 9 рядом с `pop_ui.c`, прямой `call` был верен.
Когда `pop_hof`/`pop_config`/`pop_pal` перенесли в банк 10 ради разгрузки
банка 9, тот же `call` стал уходить в пустоту. Процессор полз по 0xFF до
0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс: закрывает его
файлы, возвращает контекст (байт 0x1E48: 2 = шелл, 3 = наша программа) и
восстанавливает страницы родителя. Именно это выглядело как «W2 подменили
у нас под ногами» — на самом деле подмена была уже уборкой трупа.
**Фикс.** Группа помечена `__banked`; появилась
`toolchain/check_bank_calls.py`, встроенная в `app.mk` и валящая сборку
(проверено намеренной поломкой). Правило записано в `CLAUDE.md`.
---
<a id="final-hof-garbage"></a>
## FINAL-HOF-GARBAGE. Таблица рекордов заливалась знаками вопроса
**Симптом.** После фикса FINAL-BANKCALL экран HOF рисовался правильно, но
с началом ввода имени строку заливало `?` во всю ширину.
**Корень — вторая мина того же класса, созданная первым фиксом.** Курсор
рисовался литералом:
```c
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); /* ___str_3 в _BANK10 */
```
Пока `pop_text_draw_mapped` был в том же банке, литерал был виден. После
пометки `__banked` трамплин на время вызова переключает W3 на банк 9 —
указатель показывает в чужой банк, функция читает оттуда байты до первого
нуля и рисует их знаками вопроса. Сходится и с наблюдением пользователя
«сначала рисуется правильно, потом мусор»: строка времени берётся из
локального массива (стек, W2 — виден всем), а мусор начинается с мигания
курсора.
**Фикс.** Курсор строится на стеке. Вторая проверка
`check_bank_calls.py` предупреждает о таких случаях (проверено: на сборке
с возвращённым литералом даёт ровно одно срабатывание).
---
<a id="kbd-arrow-phantom"></a>
## KBD-ARROW-PHANTOM. Залипшая стрелка вешала ожидания насмерть
**Симптом.** После игры (особенно 14-й уровень — он почти весь проходится
удержанием Left) текстовые экраны переставали прерываться, а «новая игра»
вставала намертво: `pop_new_game_load` начинается с
`while (kbd_raw_any_down())`. При этом ввод имени в HOF работал —
он опрашивает конкретные коды, а не всю карту.
**Корень (подтверждён дважды: 0x72 24.08, 0x6B 27.08).** Стрелка идёт по
проводу как `E0 6B`. При переполнении 3-байтового FIFO SIO теряется
префикс, make садится в PLAIN-половину карты как код нумпада (0x6B = KP4),
а break приходит уже с префиксом и снимает бит в EXT-половине. PLAIN-бит
остаётся зажатым навсегда, `kbd_raw_any_down()` отвечает «да» вечно.
Доказано вмешательством: запись 0 в этот байт отладчиком мгновенно
перевела состояние 5 (LEVEL_LOAD) → 6 (PLAYING).
**Фикс.** `kbd_raw_keypad_as_ext()` в libc: `kbd_raw_sync()` переносит
биты голых кодов нумпада в EXT-половину и гасит в PLAIN — make и break
начинают работать с одним битом. В PS/2 это и есть одни и те же
физические клавиши, поэтому нумпад заодно стал управлением (7/8/9, 4/6,
2 и 5 — вниз). НЕ лечит обратный случай (префикс потерян у break) — там
бит стоит уже в EXT и выглядит как реально зажатая клавиша.
---
<a id="f10-gameplay"></a>
## F10-GAMEPLAY. Выход по F10 не работал в игре
F10 проверяется в idle-хуке, а хук висит на `gfx_wait_vsync`. Когда
игровой цикл перевели на пейсинг по лучу, он стал ждать через
`pop_wait_edge`, который звал `kbd_raw_poll` напрямую — хук не вызывался,
и F10 в геймплее умер (в заставках и меню работал). Фикс: `pop_wait_edge`
зовёт тот же `pop_idle`.
+8 -10
View File
@@ -26,7 +26,6 @@
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
| [PAL-L1-AFTER-INTRO](#pal-l1-after-intro) | вход в игру на уровень 1 после интро — игровая палитра не установлена (чёрный экран) | палитра/fade | открыт: воспроизведение нестабильно, корень не установлен |
| [PAL-DUNGEON-STALE](#pal-dungeon-stale) | переход 3→4 (подземелье→дворец): остаётся палитра подземелья | палитра/fade | открыт: КОРЕНЬ ЯСЕН — fade_in восстанавливает из протухшего снимка |
| [CUTSCENE-LTR](#cutscene-ltr) | `Marry Jaffar` → title → credits должны проявляться полосами слева направо | заставка | открыт: зафиксировано 2026-08-24, взять после текущего цикла анимации PV |
| [BUG-CORPSE-AIR](#bug-corpse-air) | труп Кида висит в воздухе, когда убившая его плита улетела вниз | физика | открыт |
| [LOOSE-ROOM-CHANGE](#loose-room-change) | расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату | порт | закрыт 2026-08-13, кроме [LOOSE-SEAM-ANIM](#loose-seam-anim) |
| [JUMP-FLOOR-LEGS](#jump-floor-legs) | в прыжке с пола ноги Кида рисуются ПОВЕРХ кромки пола, а должны за ней | окклюзия | **корень найден и починен, ждёт проверки в MAME** |
@@ -162,17 +161,16 @@ Rx-overrun не срабатывала. Гашение бита отладчи
---
<a id="cutscene-ltr"></a>
## CUTSCENE-LTR. Переходы story через экран TITLE должны проявляться слева направо
## CUTSCENE-LTR. Переходы story через экран TITLE — ЗАКРЫТ 2026-08-26
**Наблюдение пользователя (2026-08-24).** В SDLPoP переходы `Marry Jaffar`
экран с надписью *Prince of Persia* → credits выполнены тем же приёмом, что и
`In the absence…`: следующая композиция появляется вертикальными полосами
слева направо. Сейчас это последовательность статичных сцен без этого
перехода.
Разбор и что именно добавилось — [`BUGS_CLOSED.md`](BUGS_CLOSED.md#cutscene-ltr).
**Очередь.** Не прерывать ради него текущую работу над палитрой и анимацией
Princess/Jaffar. Когда будет взят, выделить общий helper полосового перехода
и проверить все три story-маршрута, включая пропуск клавишей.
---
<a id="pv-music-stall"></a>
## PV-MUSIC-STALL. В сцене с принцессой звучала только первая реплика — ЗАКРЫТ 2026-08-26
Разбор — [`BUGS_CLOSED.md`](BUGS_CLOSED.md#pv-music-stall).
---
+5 -3
View File
@@ -3,6 +3,8 @@
PROJ_ROOT := $(abspath $(CURDIR)/../../..)
EXAMPLE := roomtest
HDD_IMG := $(CURDIR)/build/hdd/roomtest.chd
# small: W1+W2 как единое 32КБ-пространство, DATA сразу за CODE (иначе
# CODE ~23КБ переполняет W1 и лезет в фиксированный DATA@0x8000 в huge).
MEMORY ?= huge # small-раскладка + банки кода в W3 (стражи)
@@ -88,7 +90,7 @@ EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) \
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
# MAME = время фазы в растровых строках.
PROF ?= 1
PROF ?= 0
PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого.
# Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1.
@@ -214,7 +216,7 @@ SND_DATA := $(SND_ARC)
# игра 24 25 28 29 30 32 33 37 39 41 43
# Нет только 56 (won, 115 с / 1,2 МБ) — финалу нужен стриминг с диска.
MUS_DIR := $(CURDIR)/../poc/res/music
MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55
MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55 56
MUS_DATA := $(foreach i,$(MUS_IDS),$(MUS_DIR)/m$(i).bin)
# UI: два исходных шрифта SDLPoP в одном EMM-атласе. Файл короче страницы:
@@ -235,7 +237,7 @@ TITLE_DATA := $(TITLE_DIR)/title.pal $(TITLE_ARC)
# intro есть ещё чистый фон `b0_*` и шесть <=16K actor-атласов: они остаются
# в EMM на время сцены, а не перечитывают полный 320x200 кадр с HDD.
PV_DIR := $(CURDIR)/../poc/res/pv
PV_STORY := 0 1 2 3 4
PV_STORY := 0 1 2 3 4 5
PV_SCENES := 0 1 2 3 4 5 6 7
PV_PARTS := 0 1 2 3 4
PV_ANIM := $(foreach p,$(PV_PARTS),$(PV_DIR)/b0_$(p).atl) \
-187
View File
@@ -1,187 +0,0 @@
# Точка входа для следующей сессии (обновлено 2026-08-12, ночь)
Файл для старта с чистого контекста: где всё стоит, что делать первым, какие
грабли уже собраны. Читать целиком — он короткий. Дальше по ссылкам:
`TASKS_OPEN.md` (доска), `BUGS_OPEN.md` (открытые баги), `CLAUDE.md`.
---
## 1. СЛЕДУЮЩАЯ ЗАДАЧА: smoke-прогон уровня 9 и регресс 1-8
Переворот (зелье инверсии) **доведён и принят пользователем 2026-08-12**:
фон, персонажи, клинок, полоса HP, факелы, окклюзия передних граней и
переходы между комнатами работают зеркально; чит `U` и зелёное зелье идут
одним путём. Осталось пройти сам уровень 9 живьём и убедиться, что на
уровнях 1-8 ничего не поехало (весь слой фона теперь спрашивает `pop_upside`).
Что смотреть на прогоне в первую очередь:
- **вис на кромке и спуск в перевёрнутом виде** — это новый срез `clip_char`
(он режет кадр СНИЗУ вместо верха); тело должно уходить за кладку, а не
ложиться поверх неё;
- **бой со стражем перевёрнутым** — у соперника свой зеркальный набор
(`g*_v.atl`), а тень рисуется атласами Кида, отдельных файлов ей не надо;
- **зелье в другой комнате** — переворот перерисовывает не комнату, а чистит
факелы и отражает страницу; проверить, что после смены комнаты и возврата
фон остаётся чистым.
Открытый риск (пока не проявился, но проверить осознанно): имена зеркальных
атласов `kid10_v.atl``kid27_v.atl` — **9 символов до точки, а DSS работает
по 8.3**. Если часть кадров Кида в перевёрнутом виде рисуется обычными
(неперевёрнутыми) — это оно; лечится переименованием в `pop_pack_kid.py`
(например `k10v.atl`) плюс те же имена в `pop_kboot.c`.
Дальше по плану — уровни 10/11 (по анализу нового в них нет) и
[MEM-COLD2](TASKS_OPEN.md#mem-cold2) п.3, если снова понадобится место.
Ощущенческий хвост прежний — [GRAB-KBD-TIMING](BUGS_OPEN.md#grab-kbd-timing),
разбирается после готовности всех уровней (решение пользователя).
## 2. Состояние репозитория
Всё закоммичено, `main` чист. `tests-host`: **6 наборов** проходят
(`[char] 65`, `[geom] 3144`, `[grab] 55`, `[mouse] 17`, `[phys] 1727`,
`[wall] 1`).
Коммиты 2026-08-12 (сверху — свежие):
| хеш | что |
|---|---|
| `7f5c99b` | чистка факелов при перевороте — в СТАРОЙ системе координат |
| `c9aa6d9` | переворот: не перерисовывать комнату, стирать только запечённое пламя |
| `948d8f0` | переворот: перерисовка вместо отражения, переключение на границе кадра, `clip_char` |
| `5b6664f` | зеркальные кадры персонажей — готовыми файлами `*_v.atl` |
| `c4dd2c7` | отсев тайлов по fore-окну — в ЛОГИЧЕСКИХ координатах |
| `7551906` | L9-INVERT II.4: персонажи рисуются перевёрнутыми |
| `055d6d7` | чтение файлов нельзя выносить в банк (код банка сам живёт в W3) |
| `147cb18` | резидент: `pop_kid`/`pop_guard`/`main` расколоты по частоте вызова |
| `5d31ed0` | резидент: `pop_redraw` + холодная половина `pop_level` в банки |
| `f541c0a` | клип поля при перевороте (мусор в бортах) |
Уровни 1-8 проходятся, уровень 9 ждёт прогона.
## 3. Раскладка кода после разгрузки резидента
Куча выросла **902 → 5795 Б** (`_CODE` 25 628 → 20 569). Кандидатов выбирали
не по размеру, а по тому, КТО зовёт: если единственный вызывающий уже в
банке, код едет к нему и трамплин не появляется вовсе.
| банк | модули |
|---|---|
| 1 | `guards.c` |
| 2 | `pop_bg.c` |
| 3 | `pop_map.c` |
| 4 | `pop_cdraw.c` + **`pop_kdraw.c`** (клинок, блит по id — прямые вызовы) |
| 5 | `pop_ctrl.c` |
| 6 | `pop_trob.c` |
| 7 | `pop_room.c` + **`pop_redraw.c`** |
| 8 | `roomtest_cold.c` + **`pop_level_cold.c`** + **`pop_kboot.c`** + **`pop_guard_cold.c`** |
Внутренние контракты: `_pop_level.h`, `_pop_kid.h`, `_pop_kdraw.h`. В них же
записаны два правила, которые линкер НЕ проверяет:
1. **Банковый модуль не маппит W3** — он в этом окне и исполняется (через W0
можно). Поэтому `pop_level_read_file` и `pop_kid_data_load` остались в
резиденте: первый же вызов из банка улетал в halt по мусору.
2. **Прямые вызовы между модулями одного банка** корректны только пока они
действительно в одном банке; меняешь раскладку в Makefile — проверь пары.
## 4. Переворот (L9-INVERT) — как он устроен
- **Флаги:** `pop_upside_want` (пишут зелье, смерть Кида, чит `U`) и
`pop_upside` (читают все слои). Переключение — ОДНО место, начало кадра,
вместе с `pop_flip_screen`. Оригиналу этого не нужно (он рисует в offscreen
неперевёрнутым и зеркалит на выводе) — расхождение записано в
`../docs/impl_diff.md`.
- **Сам переворот:** чистка запечённого пламени (`pop_torch_wipe`, 0..4
тайла) + отражение страницы акселератором + прямая копия во вторую.
**904 247 тактов = 2.1 кадра.** Вариант с полной перерисовкой комнаты
(как `redraw_screen` в оригинале) стоил 6 245 477 = 14.5 кадра — отвергнут.
- **Спрайты персонажей:** column-major, вертикальное зеркало для них не
бесплатно, поэтому второй набор файлов делает упаковщик (`vflip_cols` в
`pop_pack_kid.py`). Загрузка одной страницы с диска — 970 003 такта,
разворот той же страницы в рантайме — 12 645 582; поэтому файлы.
`pop_vflip_load_all()` идемпотентна и зовётся откуда угодно (сейчас — старт
и смена уровня, если это `POP_UPSIDE_LEVEL`; задел под загрузку в интро).
- **Координаты:** у окна fore-клипа ДВЕ пары границ — экранная
(`pop_t_fclip_y0/y1`, по ней режется блит) и логическая
(`pop_t_fclip_ly0/ly1`, по ней идут ВСЕ отсевы тайлов). Смешать их — и
передние грани перестают перекрывать персонажа.
## 5. Состояние окружения
- **MAME запущена с мостом**, на HDD — roomtest с `LEVEL=9`. Стартовый
уровень теперь **параметр сборки**: `make LEVEL=9` (дефолт 9; для «настоящей»
игры — `LEVEL=1`), константа объявлена в `pop_tune.h`.
- **ПРАВИЛО ПОЛЬЗОВАТЕЛЯ: перед сборкой образа — `make clean`, потом `make`,
потом `make hdd`** (memory `mame_build_clean_rule`). После пересборки
образа MAME **обязана** пройти полный рестарт (memory
`mame_hdd_rebuild_restart`): остановка через `exit` в дебаггере, затем
`run_bridge.sh`.
- **Тайминги моста** (правило пользователя, длиннее не ждать; они же в
`docs/mame-autotest.md` §10): старт `run_bridge.sh`**6 с**`go`
**8 с**`keyseq d:{ENTER}` + `keyseq roomtest{ENTER}`**5 с**
программа работает. Весь цикл перезапуска ~20 секунд.
- **MCP-инструменты `mame-z80` работают** (в отличие от прошлой сессии):
`press_key`, `screenshot`, `read_logical_memory`, `debugger_command`.
Профилирование — `bpset <addr>` + `print totalcycles`, затем `out` и снова
`print totalcycles`; кадр = 430 000 тактов.
- Адреса состояния смещаются после КАЖДОЙ пересборки — брать из
`.sprinter-cc-roomtest/roomtest.map` заново. Банковые функции лежат по
`0x0008XXXX`; для брейкпоинта брать младшие 16 бит (`0xC912` и т.п.).
- **SDLPoP собран с отладочной информацией** (`-O0 -g3`).
- **НЕ ПРИБРАНО:** в `SDLPoP/src/seg008.c` диагностический `fprintf` с меткой
`DBGMIRROR` в начале `add_objtable`.
## 6. Остальное открытое
- **[FORE-DUP](BUGS_OPEN.md#fore-dup)** — передний слой тайла рисуется дважды
при перекрытии объектов. Картинку не портит, тратит такты. Сначала
замерить, потом чинить.
- **TORCH-ANIM-RIGHT** — под запечённым пламенем могут застыть пики/меч/зелье
справа от факела.
- **[GATE-FORE-KID](BUGS_OPEN.md#gate-fore-kid)** — Кид в проёме ворот виден
поверх решётки; портирован только шовный случай.
- Долгие: `BUG-SPIKE-1`, `BUG-CHOMP-JUMP-1`, `L3-COLOR`, `L1-SPEED`, `TUNE-1`.
- **Вид тени (OR+XOR) отложен** до готовности всех уровней —
[`../docs/shadow_render.md`](../docs/shadow_render.md).
## 7. Грабли, собранные 2026-08-12 (вечер/ночь)
- **`return 1;` из ветки у SDCC 4.5 может не положить единицу в A** — функция
возвращает мусор. Годами маскировался (в A случайно оставался ненулевой
байт) и вылез ровно на `__banked`. Репро — `docs/bugs/sdcc-z80-ret-const-lost/`,
обход — один выход через переменную. Аудит всех `.asm` roomtest: других
мест нет.
- **Банковый модуль не может маппить W3** (см. §3). Симптом — halt по мусору
в 0xC4xx на первом же вызове.
- **Логические и экранные координаты при перевороте — разные системы.** Три
бага подряд из одного корня: отсев тайлов по fore-окну, `clip_char`, чистка
факелов. Правило: всё, что считается ИЗ РЯДА комнаты (`63*row + …`), —
логическое; всё, что режет блит, — экранное.
- **Пламя факела ЗАПЕКАЕТСЯ в ОЗУ-копию** (heal'а у него нет, следующий кадр
накрывает предыдущий непрозрачно). Любое отражение/копирование страницы
тащит его с собой — перед такими операциями звать `pop_torch_wipe`.
- **`pop_upside` переключать в середине кадра нельзя**: остаток кадра
рисуется зеркально поверх ещё неперевёрнутой картинки, и запечённые
транзиенты остаются в комнате навсегда.
- **Перенос константы между файлами — потерянное значение.** При выносе
`FIRST_LEVEL` в `pop_tune.h` записал дефолт 1, и игра молча стартовала с
первого уровня вместо девятого; заметил пользователь. Теперь это параметр
сборки (`make LEVEL=N`).
- **`make -C tests-host` не пересобирает по изменению заголовков** — при
правке прототипов (особенно `__banked`) обязателен `make -C tests-host
clean`, иначе стаб и вызывающий разъезжаются по ABI и тест врёт.
## Грабли 2026-08-10/11
- **Не оценивать железо по своей же memory-заметке.** Дважды поправлял
пользователь: accel умеет не только копирование (есть AND/OR/XOR) и умеет
работать с обычной RAM, а не только с видео (`docs/part2/accelerator_doc.txt`:
«supports data blocks transfer of RAM and Video-RAM», не поддерживает только
ROM и FastRAM). Проверять по драйверу MAME и докам, а не по памяти.
- **lldb через FIFO — плохая идея.** Работает надёжно: `fprintf(stderr, …)`
прямо в SDLPoP, пересборка (секунды) и чтение stdout.
- **Доска отставала от кода на три задачи.** Перед планированием — сверять
`TASKS_OPEN.md` с кодом грепом, а не верить записи.
- Диапазон `obj_x` = **416..695** (посчитан из `kid_data.bin`).
+55
View File
@@ -1320,3 +1320,58 @@ make-бита для кода `0x6C`. Скратч-байты — хвост `o
**Остаток на следующую сессию:** влетая в комнату, Кид должен уметь
зацепиться за край пола, мимо которого пролетает — этой механики у нас,
похоже, нет (см. `NEXT_SESSION.md`, п. 1).
---
<a id="hof-entry"></a>
## HOF-ENTRY. Таблица рекордов — **СДЕЛАНО И ПРОВЕРЕНО В MAME 2026-08-26**
Порт двух показов оригинала. `pop_hof.c` был написан раньше, но никуда не
вызывался: после финала автомат уходил мимо него в title, а на титрах экран
получался ЧЁРНЫМ.
**Где это в оригинале.** `show_title()` (seg000:2060) показывает
сохранённую таблицу между credits и attract-demo — 240 тиков, переходом
слева направо, без fade. `end_sequence()` (seg001:5F1) после HAIL проявляет
титульную картинку (240 тиков), при подходящем результате гасит её, показывает
таблицу с золотой полосой ввода, принимает имя, ждёт 120 тиков, снова
проявляет титульную картинку и лишь потом дослушивает победную тему
(`while (check_sound_playing() && !key_test_quit())`, seg001:637).
**Два корня, из-за которых экран был пуст.**
1. `story.pal` — 256 записей, и запись 0x3F (цвет глифов шрифта,
`POP_FONT_COLOR`) в ней ЧЁРНАЯ. Текст рисовался, но был не виден.
Теперь генератор кладёт в 0x3F золотой 0xB7 оригинала.
2. Полосовой переход копирует страницу АКСЕЛЕРАТОРОМ, а тот читает ОЗУ-копию
(`gfx_copy_page`: «копируется чистый фон без спрайтов»). `GFX_BANK_SPRITE`
— это NOSHADOW + TRANSPARENT, то есть в копию он не пишет, и на титрах
переезжал один фон без строк. Лечится банком `GFX_BANK_TRANSPARENT`
(0x58): та же прозрачность 0xFF, но с записью в копию — страница
собирается ЦЕЛИКОМ до показа, как offscreen оригинала.
**Что появилось попутно.**
- `s5` в архиве PV — фон таблицы: рамка story + логотип PRINCE OF PERSIA на
y=24 (`HOF_POP` оригинала — тот же спрайт res54, что и в титрах).
- Отдельный индекс палитры под фон текстовой рамки (`POP_PAL_STORY_BG`, 16).
`load_title_images(bgcolor)` красит его в #100060 на титрах и в #800000 в
финале; раньше фон был ремаплен в индекс 9, а тот занят самой титульной
картинкой (8265 пикселей), и подменить его было нельзя.
- Второй набор глифов в `font.atl` цветом `POP_FONT_DARK_COLOR` (0x3E).
У SDLPoP шрифт — маска, и `show_hof_text` рисует текст дважды разным
цветом; у нас цвет запечён в пиксели, поэтому «другой цвет» = другой набор.
Каталог SPA1 держит счётчик картинок в одном байте, поэтому тёмный набор
обрезан по '_' (32..95): 190 + 64 = 254 из 255 возможных.
- Общий `pop_screen_present_ltr()` в `pop_ui.c` — им теперь пользуются и
story-переход интро, и таблица, и титульная картинка финала
([CUTSCENE-LTR](BUGS_CLOSED.md#cutscene-ltr)).
**Формат `POP.HOF`** — 6 записей (как `MAX_HOF_COUNT`), имя до 15 символов,
XOR+инверсия в хвосте; версия 2. Проверено на образе: после ввода имени файл
128 Б, `PHOF`, запись читается обратно и показывается на титрах.
**Что проверено в MAME** (сборка `LEVEL=14`, вход в комнату 5 читом обхода
комнат): цепочка HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.
+96 -7
View File
@@ -78,7 +78,10 @@
это максимум из встречавшихся; 12/22/24 — skill 2/3/3).
<a id="l9-invert"></a>
### L9-INVERT. Зелье ПЕРЕВОРОТА (уровень 9, комнаты 7 и 10)
### L9-INVERT. Зелье ПЕРЕВОРОТА — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Подробный план реализации — [`../docs/l9_invert_plan.md`](../docs/l9_invert_plan.md)**
> (задачи libbgi, задачи приложения, порядок работ, риски, открытые вопросы).
@@ -379,7 +382,10 @@ x = 106; разбор — [`../docs/perf_l11_room15.md`](../docs/perf_l11_room15
(6 126 тактов фиксированной накладной на любой блит, независимо от размера).
### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: встреча с тенью, бой, слияние
### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: тень, бой, слияние — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-13: код написан, хост-тесты зелёные (45 проверок,
> `tests-host/t_shadow.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.** Для неё
@@ -497,7 +503,10 @@ POP_RD_LOOSE, 1)` — полную перерисовку тайла, десят
берётся вместе с остальной шлифовкой переходов.
<a id="l13-jaffar"></a>
### L13-JAFFAR. Уровень 13: Джафар, победа, выход, падающая гряда
### L13-JAFFAR. Уровень 13: Джафар, победа, выход — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-13: код написан, хост-тесты зелёные (44 проверки,
> `tests-host/t_jaffar.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.**
@@ -550,7 +559,10 @@ POP_RD_LOOSE, 1)` — полную перерисовку тайла, десят
6. комната 23 — уровень молча становится 13-м, HP НЕ сбрасывается.
### <a id="l7-feather"></a>L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ (уровень 7, комната 1)
### <a id="l7-feather"></a>L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ — ЗАКРЫТА 2026-08-26
> Проверено пользователем в живой игре.
> **Статус 2026-08-12: код написан, ждёт живой проверки в MAME.** Сделаны все
> шесть шагов ниже плюс синие пузырьки зелья «−HP» (тип 5) — они всплыли по
@@ -1188,7 +1200,15 @@ N навык M» — отдельная надобность, если пона
**Оговорка по памяти** — та же, что в TUNE-1: парсер холодный, в банк.
### <a id="snd"></a>SND. Звук — эффекты через CBL, музыка на AY
### <a id="snd"></a>SND. Звук — эффекты через CBL, музыка на AY — ЗАКРЫТА ИНАЧЕ 2026-08-26
> **Решение пересмотрено.** Музыка пошла НЕ на AY, а тем же PCM через CBL:
> записи DOS-версии укладываются в формат железа без единого преобразования
> в рантайме (`../docs/sound_plan.md` §4). Озвучены заставка, сцены между
> уровнями и игровые джинглы — 21 трек. Единственный неозвученный кусок —
> финал `won`, см. [MUS-WON](#mus-won).
<details><summary>исходная постановка (путь A на AY)</summary>
Полный разбор с замерами — [`../docs/sound_plan.md`](../docs/sound_plan.md).
Коротко, почему это оказалось дешевле, чем выглядело:
@@ -1206,6 +1226,40 @@ N навык M» — отдельная надобность, если пона
Фазы З1..З5 и список «проверить артефактом до кодинга» — в доке.
</details>
### <a id="mob-spr-emm"></a>MOB-SPR-EMM. Композит падающего куска — в EMM (1284 Б W2)
`mob_spr` (`pop_room.c`) — самый крупный буфер в W2: 4 + 64*20 байт под
композит падающей плиты. Это половина нынешнего дефицита W2.
**В БАНК ЕГО ПЕРЕНЕСТИ НЕЛЬЗЯ** (разобрано 2026-08-26). Цепочка рисования
`pop_room` (банк 7) -> `pop_mem_b` (банк 5) -> `blit_b_clip` (резидент), и
трамплин на `pop_mem_b` переключает W3. Указатель на данные банка 7 после
этого показывает на страницу банка 5: блит нарисует мусор, причём молча.
`--bank-data` вдобавок действует на весь модуль, а `mobs` из того же файла
читают снаружи.
**ЧТО РАБОТАЕТ — EMM-страница с маппингом в W0.** W0 не переключают ни
трамплин (он трогает только W3), ни звуковой ISR (насос живёт в W3), значит
адрес `0x0000+off` валиден и в банке 7, и в банке 5, и в резиденте — ровно
как у атласных спрайтов, которые тем же блитом и рисуются. `blit_b_clip`
окно не трогает, маппингом занимается вызывающий.
**Цена.** `mob_spr_build` читает три части через `gfx_w0_map(a->page)`, а
приёмник должен быть в том же окне — держать оба нельзя. Сборку придётся
вести построчно: строка части в 64-байтовый буфер на стеке, перемаппить
окно, записать. Вызывается это лишь на смену тайлсета. Плюс
`gfx_w0_map`/`unmap` вокруг каждого куска — около 1350 тактов, при шести
кусках меньше 2 % кадра.
**Чем подтверждать.** Падающие плиты в комнате 1.2 и каскад уровня 13:
ошибка проявится не крашем, а мусором на месте куска.
**Дешёвая половинчатая мера** (если 1284 Б не нужны целиком): буфер объявлен
с запасом 64x20, фактический композит около 58x19 — точные константы дают
~180 байт без единого риска.
### <a id="pal-arc"></a>PAL-ARC. Палитры — в архив своей группы (полишинг)
Файлов палитр в игре шесть: `KID\kid.pal` (1024 Б), `PV\story.pal`,
@@ -1238,7 +1292,23 @@ N навык M» — отдельная надобность, если пона
нужны в разное время: `kid.pal` — каждому уровню, заставочные — только
заставке.
### <a id="mus-won"></a>MUS-WON. Финальная тема — кольцевой стриминг с диска
### <a id="hof-entry"></a>HOF-ENTRY. Таблица рекордов — ЗАКРЫТА 2026-08-26
> **Сделано.** Оба показа оригинала: между credits и attract-demo и хвостом
> `end_sequence` (с вводом имени, паузой 120 тиков и возвратом на титульную
> картинку). Экран — рамка story с логотипом, время в формате `59:43`,
> строки с тенью, мигающий курсор. Ожидание конца победной темы переехало
> за таблицу, как в оригинале. Протокол и грабли —
> [`TASKS_CLOSED.md`](TASKS_CLOSED.md#hof-entry).
### <a id="mus-won"></a>MUS-WON. Финальная тема — кольцевой стриминг — ЗАКРЫТА 2026-08-26
> **Сделано.** Кольцо из шести страниц (96 КБ = 9 с), насос заворачивается
> по `pop_mus_ring`, подкачка в `pop_music_service` по дистанции
> «проиграно / прочитано». Разбор и грабли — `../docs/sound_plan.md` §8.
> Проверено в MAME на сборке `LEVEL=14`.
<details><summary>исходная постановка</summary>
Трек 56 (`won`, 115 с) — это 1,2 МБ, 78 страниц EMM. Ни в `POP_MUS_PAGES`
(20), ни в разумный бюджет памяти он не влезает, поэтому единственный
@@ -1254,7 +1324,24 @@ N навык M» — отдельная надобность, если пона
**Заодно.** Тот же механизм снимает ограничение `POP_MUS_PAGES` со всех
треков: сейчас каждый должен помещаться в память целиком.
### <a id="arc-atlases"></a>ARC-ATLASES. Атласы — в архивы PBA1, как звук и музыка
</details>
### <a id="arc-atlases"></a>ARC-ATLASES. Атласы — в архивы PBA1 — ЗАКРЫТА 2026-08-26
> **Сделано.** Восемь архивов: kid (58 атласов), pv (78), shadow (32), bg
> (25), title (20), guard (10), vizier (5), skel (4). На образе было 232
> файла, стало 8 архивов плюс шесть палитр, `kid.ani` и таблицы уровней.
> Индексы печатает упаковщик (`ARC_<ГРУППА>_<ФАЙЛ>`), серии код проверяет
> статически. `atlas_load` разделён: `atlas_attach` принимает уже
> прочитанную страницу, поэтому чтение из архива не дублирует проверку
> магии. Грабля, стоившая отладки: имя архива обязано лежать в том же
> банке, что и код, который его читает, — строковый литерал из чужого банка
> после переключения W3 показывает на другие данные; поэтому наружу отдаётся
> НОМЕР архива (`pop_arc_open_id`).
>
> Остаток на будущее — палитры, см. [PAL-ARC](#pal-arc).
<details><summary>исходная постановка</summary>
**Что сделано.** Формат PBA1 и читатель готовы и работают на звуке:
`toolchain/pop_pack_arc.py` собирает архив (заголовок 512 Б, записи по 4
@@ -1276,6 +1363,8 @@ N навык M» — отдельная надобность, если пона
меряется по MAME), число файлов на образе, и что каждая сцена по-прежнему
собирается при неполном диске (пути ошибок в `atlas_load`).
</details>
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB

-6
View File
@@ -1,6 +0,0 @@
Уровень 5, комната 2 - с разбега чуть раньше - иногда попадаем в стену (некорректное отображение падения)
Уровень 7, комната 9 - падает плита -1,1 - звук потери HP у Кида (причем раз даже когда Кид просто бъет по плите стоя на 0,0)
/// Уровень 10, комната 1 - когда Кид стоя на кнопке 1,8 роняет плиту 0,8 на кнопку 1,8 - кнопка ломается (превращается в щебень)
/// но двери которые она должна открыть остаются закрытыми
+4 -7
View File
@@ -107,13 +107,10 @@ uint8_t pop_app_dispatch(pop_app_event_t event) __banked
case POP_APP_ENDING:
if (event == POP_APP_EV_ENDING_FINISHED) {
/* ВРЕМЕННО в обход Hall of Fame: экран HOF написан (pop_hof.c),
* но после финала показывает чёрное вместо таблицы, и упереться
* в него сразу после победы хуже, чем не показать вовсе.
* Решение пользователя 2026-08-24: пока уходим прямо в начало,
* HOF чинится отдельно (BUGS_OPEN.md#hof-after-ending).
* Из title таблица по-прежнему доступна (pop_hof_show_title). */
next = POP_APP_TITLE; accepted = 1;
/* Таблица рекордов — ХВОСТ end_sequence, а не отдельный экран
* по желанию: она же дослушивает победную тему и гасит экран
* перед возвратом в начало (seg001:5F1). */
next = POP_APP_HALL_OF_FAME; accepted = 1;
}
break;
+1 -1
View File
@@ -51,7 +51,7 @@ void pop_settings_defaults(void) __banked
pop_settings.show_build_info = 0;
pop_settings.enhancement_flags = 0;
pop_settings.time_limit_enabled = 1;
pop_settings.show_debug_bar = 1; /* roomtest — dev-сборка */
pop_settings.show_debug_bar = 0; /* по умолчанию чистый экран */
}
/* Применить модель к настоящим владельцам runtime-состояния. Sound нельзя
+7 -1
View File
@@ -9,6 +9,7 @@
#include <stdint.h>
#define POP_FONT_COLOR 0x3F
#define POP_FONT_DARK_COLOR 0x3E
#define POP_FONT_FIRST 32
#define POP_FONT_LAST 126
#define POP_FONT_SMALL_BASE 0
@@ -17,7 +18,12 @@
#define POP_FONT_BIG_BASE 95
#define POP_FONT_BIG_ASCENT 7
#define POP_FONT_BIG_SPACE 1
/* Тот же КРУПНЫЙ шрифт цветом POP_FONT_DARK_COLOR: тень и текст на светлом.
* Набор обрезан по '_' — им пишется только таблица рекордов. */
#define POP_FONT_DARK_BASE 190
#define POP_FONT_DARK_FIRST 32
#define POP_FONT_DARK_LAST 95
#define POP_FONT_SNAPSHOT_OFF 0x3C00
#define POP_FONT_ATLAS_SIZE 7667
#define POP_FONT_ATLAS_SIZE 10841
#endif
+247 -87
View File
@@ -1,6 +1,13 @@
/*
* pop_hof.c — persistent Hall of Fame, банк 9 (FG12).
*
* Порт двух показов оригинала: между credits и attract-demo (seg000:2060)
* и после победы, с вводом имени (end_sequence, seg001:5F1). Экран тот
* же самый — рамка story с логотипом PRINCE OF PERSIA (STORY_FRAME +
* HOF_POP), поэтому фон приходит готовой композицией из архива PV, а
* палитру выбирает вызывающая сцена: обе фазы идут на story.pal, и
* отличаются только цветом фона рамки (см. pop_pal_story_load).
*
* POP.HOF — небольшой самодостаточный формат: magic, version, fixed records
* и XOR+инверсия. Поэтому обрыв/мусор на HDD не может поломать старт игры:
* decode просто начинает с пустой таблицы. Ввод — raw-клавиатура, как весь
@@ -19,18 +26,44 @@
#include "pop_timer.h"
#include "pop_pal.h"
#include "pop_ui.h"
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include "pop_intro.h" /* фон экрана: рамка story + логотип */
#include "pop_title.h" /* титульная картинка после ввода имени */
#include "pop_music.h" /* подкачка потоковой темы победы */
#include "pop_hof.h"
#define HOF_COUNT 5
#define HOF_NAME_SIZE 10 /* девять букв + '\0' */
#define HOF_REC_SIZE 14
/* MAX_HOF_COUNT оригинала — шесть строк. */
#define HOF_COUNT 6
#define HOF_NAME_SIZE 16 /* пятнадцать букв + '\0' */
#define HOF_REC_SIZE 20
#define HOF_HEADER_SIZE 6
#define HOF_SIZE (HOF_HEADER_SIZE + HOF_COUNT * HOF_REC_SIZE + 2)
#define HOF_BASE 0x100
#define HOF_VERSION 1
#define HOF_FADE 4
#define HOF_VERSION 2
#define HOF_FADE POP_FADE_FRAMES
#define HOF_NO_ROW 0xFF
/* Геометрия hof_rects[] (seg001:0D92) в координатах 320x200: строка i
* занимает y = 84 + 14*i .. +12, имя от x=72, время по правому краю 248.
* Полоса ввода — та же строка, сдвинутая offset4_rect_add(-4,-1,-40,-1). */
#define HOF_ROW_TOP 84
#define HOF_ROW_STEP 14
#define HOF_ROW_H 12
#define HOF_LEFT 72
#define HOF_RIGHT 248
#define HOF_EDIT_LEFT (HOF_LEFT - 4)
#define HOF_EDIT_RIGHT (HOF_RIGHT - 40)
/* Крупный шрифт: глиф 7 строк, строка 12 — текст центрируется по вертикали.
* baseline = верх строки + 2 + POP_FONT_BIG_ASCENT. */
#define HOF_BASELINE(i) (POP_YOFF + HOF_ROW_TOP + (int)(i) * HOF_ROW_STEP + 9)
/* Золотой цвет полосы ввода: 0xB7 оригинала = седьмой цвет палитры title,
* а у нас story.pal сделана из неё же. */
#define HOF_GOLD 7
/* Паузы оригинала в тиках 60 Гц: показ таблицы на титрах и пауза после
* ввода имени (seg000:2065, seg001:628). */
#define HOF_TITLE_TICKS POP_T60(0xF0)
#define HOF_AFTER_TICKS POP_T60(120)
typedef struct {
char name[HOF_NAME_SIZE];
uint8_t minutes;
@@ -135,7 +168,7 @@ static void hof_load(void)
for (i = 0; i < HOF_SIZE; i++)
hof_buf[i] = ((const uint8_t *)HOF_BASE)[i];
gfx_w0_unmap();
if (!hof_decode(hof_buf, HOF_SIZE)) hof_clear();
if (!hof_decode(hof_buf, (uint8_t)HOF_SIZE)) hof_clear();
}
mem_free_block(blk);
}
@@ -208,62 +241,161 @@ static void hof_dec(uint8_t value, char *out)
*out = 0;
}
/* «59:43» — те же min-1 : tick/12, что печатает show_hof (seg001:0E94). */
static void hof_result_text(const hof_entry_t *entry, char *out)
{
uint8_t seconds;
if (!entry->limited) {
strcpy(out, "UNLIMITED");
return;
}
hof_dec(entry->minutes, out);
/* min == 0 у оригинала означает истёкшее время и печатается как 0:00. */
hof_dec(entry->minutes ? (uint8_t)(entry->minutes - 1) : 0, out);
while (*out) out++;
*out++ = ' ';
*out++ = 'M';
*out++ = 'I';
*out++ = 'N';
seconds = entry->minutes ? (uint8_t)(entry->ticks / 12) : 0;
*out++ = ':';
*out++ = (char)('0' + seconds / 10);
*out++ = (char)('0' + seconds % 10);
*out = 0;
}
static void hof_draw(uint8_t edit_row)
/* show_hof_text(): сначала тень со сдвигом (1,1), затем сам текст. У нас
* цвет запечён в глифы, поэтому «другой цвет» — это второй набор
* (POP_TEXT_BIG_DARK), а не параметр рисования. */
static void hof_text_left(int x, int baseline, const char *s)
{
uint8_t page, i;
page = pop_screen_begin();
pop_screen_fill(BLACK);
gfx_set_bank(GFX_BANK_SPRITE);
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, 160, 58, "HALL OF FAME");
pop_text_draw_mapped(POP_TEXT_SMALL, 64, 84, "NAME");
pop_text_right_mapped(POP_TEXT_SMALL, 255, 84, "TIME");
for (i = 0; i < HOF_COUNT; i++) {
char rank[3], value[12];
const hof_entry_t *entry = &hof_table[i];
int y = 106 + (int)i * 20;
rank[0] = (char)('1' + i); rank[1] = '.'; rank[2] = 0;
pop_text_draw_mapped(POP_TEXT_SMALL, 46, y, rank);
if (entry->name[0])
pop_text_draw_mapped(POP_TEXT_SMALL, 72, y, entry->name);
else if (i == edit_row)
pop_text_draw_mapped(POP_TEXT_SMALL, 72, y, "_");
else
pop_text_draw_mapped(POP_TEXT_SMALL, 72, y, "---");
if (entry->name[0]) {
hof_result_text(entry, value);
pop_text_right_mapped(POP_TEXT_SMALL, 255, y, value);
}
}
if (edit_row != HOF_NO_ROW)
pop_text_center_mapped(POP_TEXT_SMALL, 160, 228, "TYPE NAME - ENTER");
else
pop_text_center_mapped(POP_TEXT_SMALL, 160, 228, "PRESS ANY KEY");
pop_text_unmap();
pop_screen_present(page);
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x + 1, baseline + 1, s);
pop_text_draw_mapped(POP_TEXT_BIG, x, baseline, s);
}
static void hof_wait_release(void)
static void hof_text_right(int right, int baseline, const char *s)
{
pop_text_right_mapped(POP_TEXT_BIG_DARK, right + 1, baseline + 1, s);
pop_text_right_mapped(POP_TEXT_BIG, right, baseline, s);
}
/* Золотая полоса под ввод — draw_rect(&rect, bgcolor) оригинала. */
static void hof_edit_bar(uint8_t row)
{
int top = HOF_BASELINE(row) - 10;
gfx_set_bank(GFX_BANK_NORMAL);
setfillstyle(SOLID_FILL, HOF_GOLD);
bar(HOF_EDIT_LEFT, top, HOF_EDIT_RIGHT, top + HOF_ROW_H - 1);
}
/* Строки таблицы в ТЕКУЩУЮ draw-страницу.
*
* БАНК ЗДЕСЬ TRANSPARENT, А НЕ SPRITE. Полосовой переход копирует страницу
* акселератором, а тот читает ОЗУ-копию (gfx_copy_page); SPRITE — это
* NOSHADOW + TRANSPARENT, то есть в копию он не пишет, и на титрах
* переезжал бы один фон без строк. TRANSPARENT даёт ту же прозрачность
* 0xFF, но обновляет и копию, поэтому таблица проявляется уже собранной. */
static void hof_draw_rows(uint8_t edit_row)
{
uint8_t i;
gfx_set_bank(GFX_BANK_TRANSPARENT);
pop_text_map();
for (i = 0; i < HOF_COUNT; i++) {
char value[12];
const hof_entry_t *entry = &hof_table[i];
int baseline = HOF_BASELINE(i);
/* Строка, которую сейчас вписывают, уже содержит результат: имени
* ещё нет, а время справа оригинал показывает сразу (show_hof
* рисует все hof_count строк, включая только что вставленную). */
if (!entry->name[0] && i != edit_row) continue;
if (entry->name[0]) hof_text_left(HOF_LEFT, baseline, entry->name);
hof_result_text(entry, value);
hof_text_right(HOF_RIGHT, baseline, value);
}
pop_text_unmap();
}
/* Готовая страница: фон из архива, полоса ввода и строки. -1 — фон не
* прочитан, и тогда таблица просто не показывается: ни игра, ни title от
* этого не зависят. */
static int hof_page_build(uint8_t page, uint8_t edit_row)
{
if (pop_intro_hof_bg(page) != 0) return -1;
gfx_set_draw_page(page);
if (edit_row != HOF_NO_ROW) hof_edit_bar(edit_row);
hof_draw_rows(edit_row);
return 0;
}
/* Имя пишется ПРЯМО НА ЭКРАН, как input_str оригинала
* (current_target_surface = onscreen_surface_): страницы при вводе не
* меняются местами, поэтому дорисовывать фон второй страницы не нужно. */
static void hof_edit_draw(uint8_t row, const char *name, uint8_t cursor)
{
int x = HOF_LEFT, baseline = HOF_BASELINE(row);
gfx_set_draw_page(gfx_get_visible_page());
hof_edit_bar(row);
gfx_set_bank(GFX_BANK_TRANSPARENT);
pop_text_map();
/* Тёмный набор: на золотой полосе оригинал пишет цветом 0xBE. */
if (name[0]) {
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, name);
x += pop_text_width_mapped(POP_TEXT_BIG_DARK, name) + 1;
}
/* draw_text_cursor(): подчерк той же гарнитуры сразу за текстом,
* мигающий сменой цвета на фоновый — у нас проще перерисовать. */
if (cursor) pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_");
pop_text_unmap();
}
/* ---- ожидания -------------------------------------------------------- *
*
* Любое ожидание этого экрана держит ПОТОКОВУЮ музыку: победная тема
* (1,2 МБ) играет кольцом страниц, и дочитывать её надо всё время, пока
* игрок смотрит таблицу и печатает имя — иначе насос догонит загрузчика и
* пойдёт по кругу старыми данными.
*
* Прерывание — по НОВОМУ нажатию (детектор фронта, как в сценах intro):
* клавиша, которой закончили предыдущий экран, в момент входа ещё зажата,
* и считать её за «пропустить» нельзя. */
typedef struct { uint8_t armed; } hof_skip_t;
static void hof_skip_begin(hof_skip_t *sk)
{
while (kbd_raw_any_down()) {
kbd_raw_sync();
sk->armed = (uint8_t)!kbd_raw_any_down();
}
static uint8_t hof_skip_requested(hof_skip_t *sk)
{
kbd_raw_sync();
if (!kbd_raw_any_down()) { sk->armed = 1; return 0; }
return sk->armed;
}
/* Возврат 1 — ожидание прервано нажатием. */
static uint8_t hof_wait_frames(uint16_t frames)
{
hof_skip_t skip;
hof_skip_begin(&skip);
while (frames--) {
if (hof_skip_requested(&skip)) return 1;
pop_music_service();
gfx_wait_vsync();
}
return 0;
}
/* `while (check_sound_playing() && !key_test_quit())` (seg001:637): финал
* дослушивает победную тему и прерывается любой клавишей. */
static void hof_wait_music(void)
{
hof_skip_t skip;
hof_skip_begin(&skip);
while (pop_music_busy() && !hof_skip_requested(&skip)) {
pop_music_service();
gfx_wait_vsync();
}
}
@@ -277,8 +409,10 @@ static const uint8_t hof_key_code[30] = {
KBD_SPACE, 0x66, KBD_ENTER, KBD_ESC
};
/* Возвращает 1, когда ввод закончен (Enter/Esc); *changed — надо ли
* перерисовать строку. */
static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
uint8_t *previous)
uint8_t *previous, uint8_t *changed)
{
uint8_t i;
@@ -288,12 +422,16 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
if (i < 26 && *length < HOF_NAME_SIZE - 1) {
entry->name[(*length)++] = (char)('A' + i);
entry->name[*length] = 0;
*changed = 1;
} else if (i == 26 && *length && *length < HOF_NAME_SIZE - 1) {
entry->name[(*length)++] = ' ';
entry->name[*length] = 0;
*changed = 1;
} else if (i == 27 && *length) {
entry->name[--(*length)] = 0;
*changed = 1;
} else if (i == 28 || i == 29) {
previous[i] = down;
return 1;
}
}
@@ -302,22 +440,42 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
return 0;
}
/* Ввод имени в строку row. Пустое имя оригинал не отвергает, но пустая
* строка в таблице выглядела бы как свободное место — подставляем PLAYER. */
/* Курсор мигает каждые шесть тиков 60 Гц (start_timer(timer_0, 6) в
* input_str) — в кадрах луча это HOF_BLINK. */
#define HOF_BLINK POP_T60(6)
static void hof_edit(uint8_t row)
{
uint8_t length = 0, previous[30], i;
uint8_t length = 0, previous[30], i, changed = 1;
uint8_t cursor = 1, phase = 0;
hof_entry_t *entry = &hof_table[row];
for (i = 0; i < sizeof(previous); i++) previous[i] = 0;
hof_wait_release();
/* Стартовое состояние клавиш: зажатое с прошлого экрана не считается
* нажатием, поэтому первый sync только заполняет previous[]. */
kbd_raw_sync();
for (i = 0; i < sizeof(previous); i++)
previous[i] = kbd_raw_down(hof_key_code[i]);
for (;;) {
kbd_raw_sync();
if (hof_name_tick(entry, &length, previous)) {
if (hof_name_tick(entry, &length, previous, &changed)) {
if (!length) strcpy(entry->name, "PLAYER");
hof_save();
hof_wait_release();
return;
}
hof_draw(row);
if (changed) { cursor = 1; phase = 0; }
if (++phase >= HOF_BLINK) {
phase = 0;
cursor = (uint8_t)!cursor;
changed = 1;
}
if (changed) {
hof_edit_draw(row, entry->name, cursor);
changed = 0;
}
pop_music_service();
gfx_wait_vsync();
}
}
@@ -325,46 +483,48 @@ void pop_hof_show(void) __banked
{
uint8_t row;
pop_sfx_pause();
hof_load();
row = hof_insert_current();
pop_pal_story_load();
pop_pal_apply(POP_PAL_DIM_BLACK);
hof_draw(row);
pop_pal_fade_in(HOF_FADE);
if (row != HOF_NO_ROW) {
hof_edit(row);
} else {
hof_wait_release();
while (!kbd_raw_any_down()) {
kbd_raw_sync();
gfx_wait_vsync();
}
hof_wait_release();
}
uint8_t page;
/* fade_out_2 гасит титульную картинку перед таблицей (seg001:601).
* Палитру не меняем: story.pal с красным фоном рамки уже стоит от
* финального HAIL, и снимок для fade сделан ею же. */
pop_pal_fade_out(HOF_FADE);
page = pop_screen_begin();
if (hof_page_build(page, row) == 0) {
pop_screen_present(page);
pop_pal_fade_in(HOF_FADE);
hof_edit(row);
hof_save();
/* Имя вписано: строку показываем уже обычным золотым текстом,
* без полосы (restore_peel + show_hof_text оригинала). */
page = pop_screen_begin();
if (hof_page_build(page, HOF_NO_ROW) == 0)
pop_screen_present(page);
hof_wait_frames(HOF_AFTER_TICKS);
(void)pop_title_main_ltr();
}
}
hof_wait_music();
pop_pal_fade_out(HOF_FADE);
pop_music_free();
pop_sfx_pause();
}
void pop_hof_show_title(void) __banked
uint8_t pop_hof_show_title(void) __banked
{
/* Один keypress должен пропускать всю цепочку title/intro, а не внезапно
* останавливаться на уже известной таблице. */
if (kbd_raw_any_down()) return;
uint8_t page;
hof_load();
if (!hof_has_entries()) return;
pop_sfx_pause();
pop_pal_story_load();
pop_pal_apply(POP_PAL_DIM_BLACK);
hof_draw(HOF_NO_ROW);
pop_pal_fade_in(HOF_FADE);
hof_wait_release();
while (!kbd_raw_any_down()) {
kbd_raw_sync();
gfx_wait_vsync();
}
hof_wait_release();
pop_pal_fade_out(HOF_FADE);
if (!hof_has_entries()) return 0;
page = pop_screen_begin();
/* Страница собирается ЦЕЛИКОМ до показа — как offscreen оригинала:
* draw_full_image + show_hof, и только потом transition_ltr
* (seg000:2063). Ни fade, ни смены палитры: экран проявляется прямо
* поверх credits, на той же story.pal. */
if (hof_page_build(page, HOF_NO_ROW) != 0) return 0;
pop_screen_present_ltr();
return hof_wait_frames(HOF_TITLE_TICKS);
}
+13 -7
View File
@@ -2,14 +2,20 @@
#ifndef POP_HOF_H
#define POP_HOF_H
/* Показать Hall of Fame, при подходящем результате запросить имя и сохранить
* POP.HOF. Ошибка или повреждение файла означают пустую таблицу; gameplay и
* последующий title от этого не зависят. */
#include <stdint.h>
/* Хвост end_sequence: при подходящем результате — запрос имени, сохранение
* POP.HOF и возврат к титульной картинке; затем в любом случае
* дослушивается победная тема и гаснет экран. Ошибка или повреждение
* файла означают пустую таблицу; gameplay и последующий title от этого не
* зависят. Победная тема на входе ещё звучит — освобождает её эта
* функция. */
void pop_hof_show(void) __banked;
/* Показать уже существующую непустую таблицу между credits и demo. Новая
* запись здесь не создаётся; удержанная клавиша title/intro пропускает экран
* целиком. */
void pop_hof_show_title(void) __banked;
/* Показать уже существующую непустую таблицу между credits и demo
* (seg000:2060): полосовой переход, 240 тиков, ни fade, ни смены палитры.
* Возврат 1 — игрок нажал клавишу, то есть всю цепочку title/intro надо
* прервать. */
uint8_t pop_hof_show_title(void) __banked;
#endif /* POP_HOF_H */
+172 -55
View File
@@ -19,6 +19,8 @@
#include "pv_arc.h"
#include "pop_cutscene.h"
#include "pop_intro.h"
#include "pop_hof.h" /* таблица рекордов между credits и demo */
#include "pop_title.h" /* титульная картинка в финале */
#include "pop_pace.h" /* pop_wait_edge */
#include "pop_pal.h"
#include "pop_timer.h"
@@ -78,6 +80,7 @@ enum {
INTRO_STORY_CREDITS,
INTRO_STORY_HAIL,
INTRO_STORY_FRAME,
INTRO_STORY_HOF, /* рамка + логотип: фон таблицы рекордов */
INTRO_PV_WAITING,
INTRO_PV_JAFFAR,
INTRO_PV_MAGIC,
@@ -90,7 +93,10 @@ enum {
enum {
INTRO_PAL_STORY = 0,
INTRO_PAL_PV = 1
INTRO_PAL_PV = 1,
/* Та же story.pal, но фон текстовой рамки красный:
* load_title_images(0) в end_sequence (seg001:586). */
INTRO_PAL_STORY_END = 2
};
/* Экраны PV лежат в одном архиве по пять полос подряд, ровно в том же
@@ -98,10 +104,10 @@ enum {
* res41 + тексты 42/43/45/44 и чистая рамка), затем восемь стадий комнаты
* принцессы (res951 + персонажи) и чистый фон b0. Поэтому имён здесь нет
* — индекс первой полосы экрана считается умножением. */
#if (ARC_PV_P0_0 - ARC_PV_S0_0) != 5 * INTRO_PARTS
#if (ARC_PV_P0_0 - ARC_PV_S0_0) != 6 * INTRO_PARTS
#error "экраны PV в архиве лежат не по пять полос подряд"
#endif
#define INTRO_SCENES 13
#define INTRO_SCENES 14
/* Две текстовые половины show_title(). Между ними PV теперь идёт отдельным
* покадровым renderer-ом, а не четырьмя полными статичными композициями. */
@@ -151,10 +157,9 @@ static const pop_cs_cmd_t intro_marry[] = {
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t intro_credits[] = {
{ POP_CS_WAIT, POP_T60(0x78) },
{ POP_CS_SHOW, INTRO_STORY_CREDITS },
{ POP_CS_WAIT, POP_T60(0x168) },
/* Гашение — отдельным шагом: между credits и ним оригинал показывает
* таблицу рекордов, если файл с ней есть (seg000:2060). */
static const pop_cs_cmd_t intro_tail[] = {
{ POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
@@ -201,16 +206,19 @@ static const pop_cs_cmd_t time_expired_script[] = {
/* end_sequence_anim() — объятие/мышь — здесь сведён к отдельной PV-стадии;
* затем end_sequence() SDLPoP показывает HAIL ровно 900 тиков. */
/* Хвост финала после живой сцены встречи: только исходный story-экран. */
/* FADE_OUT в конце НЕТ намеренно: оригинал не гасит HAIL, а проявляет
* поверх него титульную картинку полосами (draw_full_image(TITLE_MAIN) +
* transition_ltr, seg001:590). Гасит экран уже таблица рекордов. */
static const pop_cs_cmd_t ending_hail_script[] = {
{ POP_CS_PALETTE, INTRO_PAL_STORY }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_PALETTE, INTRO_PAL_STORY_END }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t ending_script[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_MOUSE },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 54 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_PALETTE, INTRO_PAL_STORY }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_PALETTE, INTRO_PAL_STORY_END }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 },
{ POP_CS_END, 0 }
};
@@ -342,7 +350,7 @@ static void intro_prepare_palette(uint8_t pal)
static void intro_commit_palette(void)
{
if (intro_pending_pal == INTRO_PAL_PV) pop_pal_pv_load();
else pop_pal_story_load();
else pop_pal_story_load((uint8_t)(intro_pending_pal == INTRO_PAL_STORY_END));
pop_pal_apply(POP_PAL_DIM_BLACK);
intro_pal_pending = 0;
}
@@ -721,6 +729,7 @@ static int intro_pv_animated(void)
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
uint8_t sand_frame = 0;
uint8_t flash_done = 0;
uint8_t faded_in = 0;
uint8_t flash;
intro_skip_t skip;
int result = -1;
@@ -753,7 +762,9 @@ static int intro_pv_animated(void)
if (bad) return -1;
}
pop_pal_fade_in(INTRO_FADE);
/* Проявление — ПОСЛЕ первого нарисованного кадра (см. ниже в цикле):
* иначе две секунды fade идут по пустой комнате, а принцесса возникает
* уже на свету. */
/* Актёрские атласы в EMM; дальше по сцене диск трогает только подкачка
* следующей реплики — по одной странице за кадр (pop_music_load_step).
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
@@ -838,11 +849,25 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
pop_music_service(); /* заявки на музыку из сцены (эффект без оцифровки) */
/* Страницу следующей реплики читаем ЗДЕСЬ, до досыпания кадра:
* 33 мс диска укладываются в паузу логического кадра (133 мс) и
* анимации не стоят. */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
*
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем»
* (snd_want подтягивается к snd_done), и цикл ожидания выходит
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд,
* то есть одиннадцать страниц m53 не успевали за всю сцену —
* pop_music_play() на своём тике не находил готового трека, и
* реплики Джафара пропадали совсем.
*
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому
* один безусловный шаг в кадр сцену не ломает: он лишь тратит
* часть той же паузы, а если паузы не было — добавляет к кадру
* четверть его длины, и шкала это учтёт сама. */
if (pop_music_loading()) (void)pop_music_load_step();
/* Первый кадр собран под чёрной палитрой — теперь проявляем его
* целиком, вместе с принцессой и факелами. */
if (!faded_in) { faded_in = 1; pop_pal_fade_in(INTRO_FADE); }
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без
@@ -858,8 +883,21 @@ static int intro_pv_animated(void)
snd_done += (uint8_t)(now - snd_prev);
snd_prev = now;
if (snd_done >= snd_want) break;
gfx_wait_vsync();
/* Страницу следующей реплики читаем ЗДЕСЬ, в паузе кадра:
* 33 мс диска укладываются в ожидание (кадр сцены — 133 мс),
* и подкачка не добавляется к длительности кадра, а
* растворяется в ней. */
if (pop_music_loading()) (void)pop_music_load_step();
else gfx_wait_vsync();
}
/* ДОЛГ НЕ ДОГОНЯЕМ. Подкачка следующей реплики (страница — 33 мс)
* и любой тяжёлый кадр оставляют snd_done впереди snd_want, и
* дальше цикл гнал кадры без единого ожидания: пламя факелов
* мелькало, а события соседних тиков (створка ворот и дверь
* покоев) слипались в один кадр — заявка перезаписывала заявку, и
* один из звуков пропадал. Тот же принцип, что у pop_pace_end:
* якорь ставится ПО ФАКТУ, пропущенное время не наверстывается. */
if (snd_done > snd_want) snd_want = snd_done;
} else {
uint16_t target = POP_T60(tick);
@@ -940,7 +978,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
}
pop_screen_present(1);
pop_pal_fade_in(INTRO_FADE);
result = 0;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
intro_skip_begin(&skip);
@@ -968,6 +1005,9 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
/* Первый тик уже потрачен на commit: ещё четыре = 5/50 с. */
for (hold = 1; hold < 5; hold++) gfx_wait_vsync();
/* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
* пустой комнате, а часы и пламя возникают уже на свету. */
if (frame == 0) pop_pal_fade_in(INTRO_FADE);
torch_left = (uint8_t)((torch_left + 1) % 9);
torch_right = (uint8_t)((torch_right + 1) % 9);
sand = (uint8_t)((sand + 1) % 3);
@@ -996,29 +1036,8 @@ static int intro_story_prepare(void)
return intro_draw_page(INTRO_STORY_ABSENCE, target);
}
/* Полосовой показ подготовленной страницы (transition_ltr оригинала). */
static int intro_story_transition(void)
{
gfx_rect_t stripe;
uint8_t visible = gfx_get_visible_page();
int x;
gfx_set_draw_page(visible); /* source gfx_copy_page = target */
stripe.y = 0;
stripe.h = GFX_HEIGHT;
for (x = 0; x < GFX_WIDTH; x += 4) {
if (kbd_raw_any_down()) {
stripe.x = 0; stripe.w = GFX_WIDTH;
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
break;
}
stripe.x = x;
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
gfx_wait_vsync();
}
return 0;
}
/* Полосовой показ подготовленной страницы живёт в pop_ui: тем же приёмом
* появляются титульная картинка финала и таблица рекордов. */
static void intro_flash(uint16_t ticks)
{
@@ -1307,6 +1326,10 @@ static int cut_begin(uint8_t hg_state)
intro_commit_palette();
if (intro_pv_draw_base() != 0) return -1;
if (cut_load() != 0) return -1;
/* Атласы сцены прочитаны — можно открывать вывод. Без этого сцена шла
* молча: pop_ending_show глушит CBL первым действием, а включить его
* обратно было некому, и «arrived to princess» (26) не звучал вовсе. */
(void)pop_sfx_start();
return 0;
}
@@ -1332,8 +1355,13 @@ static int cut_scene_8(void)
cut_kid_init(CHARID_24_MOUSE, 144, 167, 106);
/* princess_crouching (seg001:024D): сидит и гладит мышь. */
cut_shad_init(131, 169, 110);
pop_pal_fade_in(INTRO_FADE);
aborted = cut_run(20, 1);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 1);
if (!aborted) pop_pal_fade_in(INTRO_FADE);
if (!aborted) aborted = cut_run(19, 1);
if (!aborted) {
cut_kid_seq(107); /* мышь встаёт и уходит */
aborted = cut_run(20, 1);
@@ -1356,10 +1384,15 @@ static int cut_scene_9(void)
/* princess_stand (seg001:026A) — поза PV1, лицом вправо. */
cut_shad_init(144, 169, 94);
Guard.direction = 0;
pop_pal_fade_in(INTRO_FADE);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 0); /* мышь ещё за кадром */
if (!aborted) pop_pal_fade_in(INTRO_FADE);
/* init_mouse_go (seg001:022A): мышь вбегает справа. */
cut_kid_init(CHARID_24_MOUSE, 199, 167, 105);
aborted = cut_run(5, 1);
if (!aborted) aborted = cut_run(5, 1);
if (!aborted) {
cut_shad_seq(112); /* принцесса приседает */
aborted = cut_run(9, 1);
@@ -1383,8 +1416,13 @@ static int cut_scene_12_short(void)
if (cut_begin(pre_hourglass_state()) != 0) return -1;
/* init_princess_x156 (seg001:0230) = init_princess + x = 156. */
cut_shad_init(156, 166, 94);
pop_pal_fade_in(INTRO_FADE);
aborted = cut_run(2, 0);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 0);
if (!aborted) pop_pal_fade_in(INTRO_FADE);
if (!aborted) aborted = cut_run(1, 0);
if (!aborted) {
cut_shad_seq(98); /* princess turn around [PV1] */
aborted = cut_run(24, 0);
@@ -1406,9 +1444,14 @@ static int cut_ending(void)
/* init_ending_princess (seg001:0251) + init_ending_kid (seg001:0287). */
cut_shad_init(136, 164, 109);
cut_kid_init(CHARID_0_KID, 198, 164, 1);
pop_pal_fade_in(INTRO_FADE);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 1);
if (!aborted) pop_pal_fade_in(INTRO_FADE);
pop_sfx_play(26); /* «arrived to princess» */
aborted = cut_run(8, 1);
if (!aborted) aborted = cut_run(7, 1);
if (!aborted) {
cut_shad_seq(108); /* поворот и объятие */
aborted = cut_run(5, 1);
@@ -1464,12 +1507,42 @@ static uint8_t intro_run(const pop_cs_cmd_t *script)
} else if (ev == POP_CS_EVENT_FADE_IN) pop_pal_fade_in(INTRO_FADE);
else if (ev == POP_CS_EVENT_FADE_OUT) pop_pal_fade_out(INTRO_FADE);
else if (ev == POP_CS_EVENT_FLASH) intro_flash(arg);
pop_music_service(); /* подкачка потокового трека (финал) */
gfx_wait_vsync();
}
return aborted;
}
/* Ожидание кадров у шагов, которые показываются НЕ сценарием, а напрямую.
* Оно не глушит поток: победная тема играет кольцом страниц, и дочитывать
* её надо каждый кадр, иначе насос догонит загрузчика. Прерывается новым
* нажатием (детектор фронта); 1 — прервано. */
static uint8_t intro_wait_frames(uint16_t frames)
{
intro_skip_t skip;
intro_skip_begin(&skip);
while (frames--) {
if (intro_skip_requested(&skip)) return 1;
pop_music_service();
pop_wait_edge();
}
return 0;
}
/* Титры проявляются полосами, как и всё остальное в show_title()
* (draw_full_image(STORY_CREDITS) + transition_ltr, seg000:2055). */
static uint8_t intro_credits_ltr(void)
{
uint8_t page = pop_screen_begin();
if (intro_draw_page(INTRO_STORY_CREDITS, page) == 0)
pop_screen_present_ltr();
return intro_wait_frames(POP_T60(0x168));
}
uint8_t pop_intro_show(void) __banked
{
int pv_result;
@@ -1502,7 +1575,7 @@ uint8_t pop_intro_show(void) __banked
}
(void)pop_sfx_start(); /* идемпотентно: титры его уже открыли */
pop_music_play();
(void)intro_story_transition();
pop_screen_present_ltr();
}
if (intro_run(intro_before_pv)) {
/* Пропуск по клавише. ГЛУШИМ ВЫВОД: сразу за возвратом пойдёт
@@ -1558,7 +1631,27 @@ uint8_t pop_intro_show(void) __banked
}
pop_music_free();
pop_sfx_pause();
if (intro_run(intro_credits)) {
/* ТИТУЛЬНЫЙ ЭКРАН МЕЖДУ «СВАДЬБОЙ» И ТИТРАМИ. show_title() рисует
* TITLE_MAIN + логотип + Jordan Mechner и проявляет их полосами
* (seg000:2045), и только через 0x78 тиков показывает credits — тоже
* полосами. Раньше обе картинки возникали мгновенно. */
if (pop_title_logo_ltr() == 0 && intro_wait_frames(POP_T60(0x78))) {
intro_restore_game_palette();
return 1;
}
if (intro_credits_ltr()) {
intro_restore_game_palette();
return 1;
}
/* ТАБЛИЦА РЕКОРДОВ между credits и attract-demo — ровно там же, где её
* показывает show_title(): та же story-палитра, тот же полосовой
* переход, никакого fade (seg000:2060). Пустой таблицы это не
* касается: она просто не показывается. */
if (pop_hof_show_title()) {
intro_restore_game_palette();
return 1;
}
if (intro_run(intro_tail)) {
intro_restore_game_palette();
return 1;
}
@@ -1655,6 +1748,14 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
pre_cut_finish(intro_run(script));
}
/* Фон таблицы рекордов — та же рамка story плюс логотип PRINCE OF PERSIA
* (STORY_FRAME + HOF_POP оригинала). Рисуем в ЗАДАННУЮ страницу и её не
* показываем: палитрой, строками и переходом распоряжается pop_hof. */
int pop_intro_hof_bg(uint8_t page) __banked
{
return intro_draw_page(INTRO_STORY_HOF, page);
}
void pop_time_expired_show(void) __banked
{
pop_sfx_pause();
@@ -1683,6 +1784,7 @@ void pop_time_expired_show(void) __banked
intro_restore_game_palette();
}
void pop_ending_show(void) __banked
{
pop_sfx_pause();
@@ -1694,7 +1796,22 @@ void pop_ending_show(void) __banked
/* Встреча с принцессой — живая сцена (end_sequence_anim). Дальше по
* скрипту идёт только текст HAIL, поэтому статический сценарий ниже
* начинается с него; при нехватке ресурсов отыгрывается целиком. */
if (cut_ending() == 0) intro_run(ending_hail_script);
else intro_run(ending_script);
intro_restore_game_palette();
if (cut_ending() == 0) {
/* ПОБЕДНАЯ ТЕМА (56) — потоком с диска: 115 секунд и 1,2 МБ в память
* не влезают никак. Оригинал пускает её здесь же, сразу после
* встречи с принцессой и до экрана HAIL (seg001:583). Дальше
* страницы дочитывает pop_music_service из циклов сцен. */
(void)pop_sfx_start();
(void)pop_music_ring_start(POP_MUS_WON);
intro_run(ending_hail_script);
} else {
intro_run(ending_script);
}
/* ТИТУЛЬНАЯ КАРТИНКА ПОВЕРХ ТЕКСТА. end_sequence заводит таймер на 240
* тиков, проявляет TITLE_MAIN полосами и ждёт его (seg001:590). Экран
* при этом не гаснет: рамка HAIL просто переползает в рисунок дворца.
* Дальше — состояние HALL_OF_FAME; тема продолжает звучать, и
* освобождает её уже таблица рекордов, как в оригинале. */
if (pop_title_main_ltr() == 0)
(void)intro_wait_frames(POP_T60(240));
}
+7 -1
View File
@@ -17,7 +17,13 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked;
* возвращается в TITLE. Любой raw-ввод сокращает ожидание. */
void pop_time_expired_show(void) __banked;
/* Финал level 14: Princess room, затем исходный story-screen HAIL. */
/* Финал level 14: Princess room, story-screen HAIL и титульная картинка.
* Победная тема при выходе ЕЩЁ ЗВУЧИТ: её дослушивает таблица рекордов,
* как в end_sequence оригинала. */
void pop_ending_show(void) __banked;
/* Фон таблицы рекордов (рамка story + логотип) в заданную СКРЫТУЮ
* страницу; -1 ресурс не прочитан. Ни палитры, ни показа. */
int pop_intro_hof_bg(uint8_t page) __banked;
#endif /* POP_INTRO_H */
+10 -1
View File
@@ -256,6 +256,11 @@ static void menu_draw(uint8_t sel)
for (i = 0; i < MENU_N_ITEMS; i++)
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2,
MENU_FIRST_BASE + (int)i * MENU_STEP, menu_text[i]);
/* Полосу сначала чистим: там могло висеть сообщение игры («60 MINUTES
* LEFT», «QUICKSAVE»), и две надписи ложились друг на друга. */
pop_text_unmap();
pop_status_wipe();
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, MENU_PAUSED_BASE,
"GAME PAUSED");
pop_text_unmap();
@@ -687,7 +692,11 @@ int8_t pop_menu_process(void) __banked
getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
pop_pal_snapshot();
pop_pal_menu_dim(2); /* 50%, диапазон UI остаётся ярким */
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
* теперь затемнение на одну шестнадцатую фон под меню почти не гас
* (поймано пользователем 2026-08-26). */
pop_pal_menu_dim(POP_PAL_DIM_BLACK / 2);
menu_draw(sel);
for (;;) {
+114
View File
@@ -62,6 +62,34 @@ static void slot_release(uint8_t n)
slot_blocks[n] = 0;
}
/* --- ПОТОКОВЫЙ ТРЕК: КОЛЬЦО СТРАНИЦ ---------------------------------- *
*
* Финальная тема (`won`, 115 с) это 1,2 МБ и 78 страниц EMM. Держать её
* в памяти нельзя ни при каком бюджете, поэтому она играется из НЕСКОЛЬКИХ
* страниц по кругу: насос идёт по кольцу (pop_mus_ring), а загрузчик
* дочитывает файл в те страницы, которые насос уже прошёл.
*
* Кто кого догоняет. Страница звучит 1,5 с, а читается 33 мс запас
* сорокакратный, так что «не успеть» можно только застряв на секунду.
* Дистанцию считаем без отдельных счётчиков: страница ровно 128 блоков
* насоса, поэтому проигранных страниц = (всего блоков - осталось) / 128.
* Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце есть
* свободный слот, и следующую страницу можно читать.
*
* Файл читаем ПОСЛЕДОВАТЕЛЬНО, без lseek: страницы идут по порядку, и fd
* сам стоит там, где надо. */
#define RING_PAGES 6 /* 96 КБ = 9 с звучания, запас с лихвой */
static uint8_t ring_on; /* идёт потоковый трек */
static uint8_t ring_blk; /* EMM-блок кольца */
static uint8_t ring_fd_open;
static int ring_fd;
static uint8_t ring_file_pages; /* страниц в файле */
static uint8_t ring_loaded; /* сколько уже прочитано */
static uint16_t ring_total; /* длина трека в блоках насоса */
void pop_music_ring_stop(void) __banked;
void pop_music_stop(void) __banked
{
pop_mus_left = 0;
@@ -69,6 +97,7 @@ void pop_music_stop(void) __banked
void pop_music_free(void) __banked
{
if (ring_on) pop_music_ring_stop();
if (ld_busy) { close(ld_fd); ld_busy = 0; }
pop_mus_left = 0;
slot_release(0);
@@ -96,6 +125,7 @@ int8_t pop_music_load_begin(uint8_t id) __banked
char name[12];
uint8_t pages, n;
if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */
if (ld_busy) load_abort();
if (id >= POP_MUS_COUNT) return -1;
pages = pop_mus_tbl[id].pages;
@@ -174,6 +204,7 @@ int8_t pop_music_stream(uint8_t id) __banked
{
uint8_t i, n;
if (ring_on) pop_music_ring_stop(); /* см. pop_music_play */
if (pop_music_load_begin(id) != 0) return -1;
if (pop_music_load_step() < 0) return -1; /* первая страница */
n = ld_slot;
@@ -197,6 +228,10 @@ void pop_music_play(void) __banked
uint8_t n, i;
if (!mus_ready) return;
/* СНЯТЬ КОЛЬЦО. Если до этого играл потоковый трек (финал), насос всё
* ещё заворачивал бы страницы по кругу шести обычный трек звучал бы
* своей первой третью без конца (поймано на титрах после победы). */
if (ring_on) pop_music_ring_stop();
n = (uint8_t)(mus_ready - 1);
/* Насос обязан замолчать ДО подмены таблицы страниц: иначе он успеет
* взять порцию по новому индексу из старого курсора. */
@@ -215,6 +250,84 @@ void pop_music_play(void) __banked
pop_mus_left = slot_blocks[n];
}
static void ring_close_file(void)
{
if (ring_fd_open) { close(ring_fd); ring_fd_open = 0; }
}
void pop_music_ring_stop(void) __banked
{
pop_mus_left = 0;
pop_mus_ring = 0;
ring_close_file();
if (ring_blk) { mem_free_block(ring_blk); ring_blk = 0; }
ring_on = 0;
}
int8_t pop_music_ring_start(uint8_t id) __banked
{
char name[12];
uint8_t i, page;
if (id >= POP_MUS_COUNT || !pop_mus_tbl[id].pages) return -1;
pop_music_free(); /* обычные слоты кольцу не нужны */
pop_music_ring_stop();
mus_name(name, id);
ring_fd = open(name, O_RDONLY);
if (ring_fd < 0) return -1;
ring_fd_open = 1;
ring_blk = mem_alloc_pages(RING_PAGES);
if (!ring_blk) { ring_close_file(); return -1; }
ring_file_pages = pop_mus_tbl[id].pages;
ring_total = pop_mus_tbl[id].blocks;
/* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд
* форы, и первая же тяжёлая сцена финала не догонит насос. */
for (i = 0; i < RING_PAGES; i++) {
page = mem_get_page(ring_blk, i);
pop_mus_page[i] = page;
if (!page ||
(i < ring_file_pages && bank_read_page(ring_fd, page, 0, 16384) < 0)) {
pop_music_ring_stop();
return -1;
}
}
ring_loaded = (uint8_t)(ring_file_pages < RING_PAGES ? ring_file_pages
: RING_PAGES);
if (ring_loaded >= ring_file_pages) ring_close_file();
ring_on = 1;
pop_mus_id = id;
pop_mus_pg = 0;
pop_mus_ptr = 0;
pop_mus_ring = RING_PAGES; /* насос пойдёт по кругу */
pop_mus_left = ring_total;
return 0;
}
/* Один шаг подкачки: страница файла в слот, который насос уже прошёл. */
static void ring_step(void)
{
uint16_t played;
uint8_t played_pg, page;
if (!ring_on) return;
if (!pop_mus_left) { /* трек доиграл — отпустить память */
pop_music_ring_stop();
return;
}
if (ring_loaded >= ring_file_pages) return; /* файл прочитан весь */
played = (uint16_t)(ring_total - pop_mus_left);
played_pg = (uint8_t)(played >> 7); /* 128 блоков на страницу */
if ((uint8_t)(ring_loaded - played_pg) >= RING_PAGES) return; /* полно */
page = pop_mus_page[ring_loaded % RING_PAGES];
if (!page || bank_read_page(ring_fd, page, 0, 16384) < 0) {
pop_music_ring_stop();
return;
}
ring_loaded++;
if (ring_loaded >= ring_file_pages) ring_close_file();
}
void pop_music_service(void) __banked
{
/* Один вызов на кадр из ЛЮБОГО цикла — игрового, интро, катсцены.
@@ -225,6 +338,7 @@ void pop_music_service(void) __banked
* демо проигрывает записанные нажатия, и любой музыкальный повод
* подобранный меч, зелье зазвучал бы там, где оригинал играет одни
* эффекты. */
if (ring_on) { ring_step(); return; } /* потоковый трек — свой путь */
if (!pop_mus_want || pop_app_state == POP_APP_DEMO) {
/* Выключили на ходу: оборвать поток и замолчать. Эффекты при этом
* продолжают звучать у них свой тумблер. */
+11
View File
@@ -34,6 +34,7 @@
#define POP_MUS_STORY_2 50 /* принцесса ждёт */
#define POP_MUS_STORY_3 53 /* входит Джафар */
#define POP_MUS_STORY_4 52 /* Джафар уходит */
#define POP_MUS_WON 56 /* победа: 115 с, только потоком */
/* Курсор проигрывания — читает НАСОС (pop_sfx.c, резидент), пишет
* загрузчик. Отдельный от эффектов: они звучат поверх музыки, а не
@@ -45,6 +46,8 @@ extern volatile uint16_t pop_mus_left; /* порций до конца, 0
/* Номер играющего трека (нумерация оригинала). Нужен менеджеру звука:
* музыка участвует в таблицах приоритета наравне с эффектами. */
extern uint8_t pop_mus_id;
/* Размер кольца страниц для потокового трека; 0 — обычный режим. */
extern uint8_t pop_mus_ring;
/* ДВА СЛОТА. Заставочные сцены идут цепочкой треков (титры -> история ->
* PV из трёх реплик), и загрузка каждого это полсекунды-секунда диска.
@@ -88,6 +91,14 @@ void pop_music_stop(void) __banked;
* стрима. Всё чтение с диска живёт здесь. */
void pop_music_service(void) __banked;
/* ПОТОКОВЫЙ ТРЕК ИЗ КОЛЬЦА СТРАНИЦ — для того, что в память не влезает.
* Финал `won` весит 1,2 МБ (78 страниц); играется из шести по кругу, а
* дочитывает его pop_music_service, пока трек звучит.
* 0 играет, -1 нет файла/памяти. Останавливает pop_music_ring_stop
* (её же зовёт pop_music_free). */
int8_t pop_music_ring_start(uint8_t id) __banked;
void pop_music_ring_stop(void) __banked;
/* Трек ещё звучит? 0 — доиграл или не запускался. */
uint8_t pop_music_busy(void) __banked;
+3 -2
View File
@@ -12,9 +12,9 @@
#include <stdint.h>
#define POP_MUS_COUNT 56
#define POP_MUS_COUNT 57
#define POP_MUS_BLOCK 128
#define POP_MUS_MAX_PAGES 20
#define POP_MUS_MAX_PAGES 78
typedef struct {
uint8_t pages; /* 0 — трека нет */
@@ -78,6 +78,7 @@ static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {
{ 11, 1403 }, /* 53 story_3_Jaffar_enters */
{ 17, 2064 }, /* 54 intro_theme */
{ 11, 1350 }, /* 55 story_1_absence */
{ 78, 9865 }, /* 56 won */
};
#endif /* POP_MUSIC_TBL_H */
+10 -1
View File
@@ -103,9 +103,18 @@ void pop_pal_title_load(void) __banked
pal_file_load("TITLE\\title.pal", "a:\\title.pal");
}
void pop_pal_story_load(void) __banked
/* ЕДИНСТВЕННОЕ ОТЛИЧИЕ ФИНАЛА ОТ ИНТРО — цвет фона текстовой рамки.
* load_title_images(bgcolor) SDLPoP красит его в #100060 на титрах и в
* #800000 в end_sequence (seg001:586); у нас фон запечён отдельным
* индексом POP_PAL_STORY_BG именно ради этой подмены. Она делается ПОСЛЕ
* файла и до нового снимка как тайлсет/Тень в pop_pal_game_load. */
void pop_pal_story_load(uint8_t ending) __banked
{
pal_file_load("PV\\story.pal", "a:\\story.pal");
if (!ending) return;
gfx_pal_set(0, POP_PAL_STORY_BG, 0x80, 0, 0);
gfx_pal_set(1, POP_PAL_STORY_BG, 0x80, 0, 0);
pop_ui_palette_snapshot();
}
void pop_pal_pv_load(void) __banked
+4 -1
View File
@@ -19,7 +19,10 @@ void pop_pal_black(void) __banked;
void pop_pal_game_load(void) __banked;
void pop_pal_level_load(uint8_t full) __banked;
void pop_pal_title_load(void) __banked;
void pop_pal_story_load(void) __banked;
/* Фон текстовой рамки story лежит в отдельном индексе палитры: ending=1
* красит его тёмно-красным (финал), 0 оставляет файловый тёмно-синий. */
#define POP_PAL_STORY_BG 16
void pop_pal_story_load(uint8_t ending) __banked;
void pop_pal_pv_load(void) __banked;
/* Показ текущего снимка: 0 — исходная яркость, 4 — чёрный. */
+16 -2
View File
@@ -55,6 +55,10 @@ uint8_t pop_mus_id;
/* Тик насоса: +1 на каждый отданный CBL блок, то есть 85,4 Гц. */
volatile uint8_t pop_snd_tick;
/* Размер КОЛЬЦА страниц для потоковой музыки, 0 — обычный трек целиком в
* памяти. Читает насос, ставит загрузчик (pop_music.c). */
uint8_t pop_mus_ring;
/* Заявка на музыкальный трек: номер в нумерации оригинала, 0 — нет заявки
* (нулевого трека не бывает). Ставит pop_sfx_play, снимает главный цикл. */
uint8_t pop_mus_req;
@@ -141,8 +145,18 @@ int pop_sfx_fill(uint16_t n)
pop_mus_ptr += n;
if (pop_mus_ptr >= 0x4000) {
pop_mus_ptr = 0;
if ((uint8_t)(pop_mus_pg + 1) < POP_MUS_PAGES) pop_mus_pg++;
else pop_mus_left = 0;
/* КОЛЬЦО. Финальная тема — 1,2 МБ, в память целиком не влезает
* никак, поэтому она играется из нескольких страниц по кругу, а
* загрузчик дочитывает в те, что мы уже прошли (pop_music.c).
* Конец трека определяет left, а не номер страницы. */
if (pop_mus_ring) {
if ((uint8_t)(pop_mus_pg + 1) < pop_mus_ring) pop_mus_pg++;
else pop_mus_pg = 0;
} else if ((uint8_t)(pop_mus_pg + 1) < POP_MUS_PAGES) {
pop_mus_pg++;
} else {
pop_mus_left = 0;
}
}
return 1;
}
+13
View File
@@ -219,6 +219,19 @@ static void build_text(char *buf)
}
}
/* Стереть полосу сообщения на ТЕКУЩЕЙ странице. Нужна меню: оно пишет в ту
* же полосу своё «GAME PAUSED», а под ним могло остаться сообщение игры
* («60 MINUTES LEFT», «QUICKSAVE») две надписи ложились друг на друга. */
void pop_status_wipe(void) __banked
{
uint8_t saved;
if (!pop_ui_ready()) return;
saved = gfx_get_bank();
wipe(POP_STATUS_L, MSG_TOP, POP_STATUS_R, MSG_BOTTOM);
gfx_set_bank(saved);
}
static void status_draw(void)
{
char buf[18];
+3
View File
@@ -81,4 +81,7 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked;
* затёрли чужим содержимым (вход в комнату, выход из меню). */
void pop_status_invalidate(void) __banked;
/* Стереть полосу сообщения на текущей странице (меню пишет туда своё). */
void pop_status_wipe(void) __banked;
#endif /* POP_STATUS_H */
+20
View File
@@ -92,6 +92,26 @@ static int title_draw(uint8_t scene, uint8_t show)
return page;
}
/* draw_full_image(TITLE_MAIN) + transition_ltr() финала (seg001:590 и
* :629): титульный рисунок проявляется полосами поверх текущего экрана.
* Палитру не трогаем story.pal и title.pal это одни и те же цвета
* (обе сделаны из res51). -1 архив не прочитан. */
int pop_title_main_ltr(void) __banked
{
if (title_draw(TITLE_MAIN, 0) < 0) return -1;
pop_screen_present_ltr();
return 0;
}
/* Тот же приём для композиции с логотипом и подписью Jordan Mechner: её
* show_title() показывает МЕЖДУ «свадьбой» и титрами (seg000:2045). */
int pop_title_logo_ltr(void) __banked
{
if (title_draw(TITLE_LOGO, 0) < 0) return -1;
pop_screen_present_ltr();
return 0;
}
static uint8_t title_wait(uint16_t ticks)
{
while (ticks--) {
+10
View File
@@ -14,4 +14,14 @@
* напрямую; 0 последовательность завершилась штатно, продолжать intro. */
uint8_t pop_title_show(void) __banked;
/* Одна титульная картинка (TITLE_MAIN) полосами слева направо — так финал
* возвращается к ней после HAIL и после ввода имени в таблицу рекордов.
* Палитру не меняет: story.pal и title.pal сделаны из одного res51.
* -1 архив титров не прочитан. */
int pop_title_main_ltr(void) __banked;
/* Композиция с логотипом PRINCE OF PERSIA и Jordan Mechner тем же
* переходом шаг show_title() между «свадьбой» и титрами. */
int pop_title_logo_ltr(void) __banked;
#endif /* POP_TITLE_H */
+44 -4
View File
@@ -7,6 +7,7 @@
*/
#include <stdint.h>
#include <string.h>
#include <kbd_raw.h>
#include <graphics.h>
#include <gfx.h>
#include <sprite.h>
@@ -20,11 +21,19 @@ typedef struct {
uint8_t base;
uint8_t ascent;
uint8_t space;
uint8_t first; /* первый код, который есть в наборе */
uint8_t last;
} pop_font_desc_t;
/* Тёмный набор — тот же мелкий шрифт другим цветом; глифов в нём меньше
* (см. pop_font.h), поэтому диапазон кодов хранится у каждого набора. */
static const pop_font_desc_t font_desc[] = {
{ POP_FONT_SMALL_BASE, POP_FONT_SMALL_ASCENT, POP_FONT_SMALL_SPACE },
{ POP_FONT_BIG_BASE, POP_FONT_BIG_ASCENT, POP_FONT_BIG_SPACE }
{ POP_FONT_SMALL_BASE, POP_FONT_SMALL_ASCENT, POP_FONT_SMALL_SPACE,
POP_FONT_FIRST, POP_FONT_LAST },
{ POP_FONT_BIG_BASE, POP_FONT_BIG_ASCENT, POP_FONT_BIG_SPACE,
POP_FONT_FIRST, POP_FONT_LAST },
{ POP_FONT_DARK_BASE, POP_FONT_BIG_ASCENT, POP_FONT_BIG_SPACE,
POP_FONT_DARK_FIRST, POP_FONT_DARK_LAST }
};
static atlas_t font_at;
@@ -38,8 +47,8 @@ __at (0x4000) uint8_t pop_pal_buf[256];
static uint8_t text_index(const pop_font_desc_t *font, uint8_t ch)
{
if (ch < POP_FONT_FIRST || ch > POP_FONT_LAST) ch = '?';
return (uint8_t)(font->base + ch - POP_FONT_FIRST);
if (ch < font->first || ch > font->last) ch = '?';
return (uint8_t)(font->base + ch - font->first);
}
int pop_ui_init(void) __banked
@@ -339,6 +348,37 @@ void pop_screen_present(uint8_t page) __banked
gfx_set_visible_page(page);
}
/* transition_ltr() SDLPoP: подготовленная СКРЫТАЯ страница переползает на
* видимую узкими вертикальными полосами слева направо. У оригинала
* 2px/120 FPS; здесь 4px/60 FPS даёт ту же длительность (~1,3 с), не
* удваивая число полных копий 320x256.
*
* Страницы при этом НЕ меняются местами: копируем в видимую, как оригинал
* копирует offscreen в onscreen. Поэтому у вызывающего после перехода на
* ЭКРАНЕ и в скрытой странице лежит одно и то же. */
void pop_screen_present_ltr(void) __banked
{
gfx_rect_t stripe;
uint8_t visible = gfx_get_visible_page();
int x;
gfx_set_draw_page(visible); /* source gfx_copy_page = target */
stripe.y = 0;
stripe.h = GFX_HEIGHT;
for (x = 0; x < GFX_WIDTH; x += 4) {
if (kbd_raw_any_down()) {
/* Прервали — дорисовать остаток разом, а не бросить полукадр. */
stripe.x = 0; stripe.w = GFX_WIDTH;
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
return;
}
stripe.x = x;
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
gfx_wait_vsync();
}
}
/* Нарисовать одну полосу и отпустить её страницу. Общая часть обоих
* загрузчиков ниже: они отличаются только тем, откуда взялся атлас. */
static int strip_blit(atlas_t *at, int x, int *y)
+11 -1
View File
@@ -12,7 +12,12 @@
typedef enum {
POP_TEXT_SMALL = 0,
POP_TEXT_BIG = 1
POP_TEXT_BIG = 1,
/* Крупный шрифт вторым цветом (POP_FONT_DARK_COLOR): тень строки и
* текст на светлом фоне так show_hof_text() SDLPoP рисует таблицу
* рекордов дважды (тень со сдвигом (1,1), затем сам текст). Набор
* обрезан по 'Z', см. pop_font.h. */
POP_TEXT_BIG_DARK = 2
} pop_text_font_t;
/* Жизненный цикл единственного FONT\FONT.ATL. Загрузка — в холодном boot,
@@ -86,6 +91,11 @@ void pop_screen_fill_game(void) __banked;
* входа в комнату. */
void pop_screen_fill_field(void) __banked;
void pop_screen_present(uint8_t page) __banked;
/* Показать подготовленную скрытую страницу полосами слева направо
* (transition_ltr оригинала). Любая зажатая клавиша достраивает переход
* мгновенно. Страницы местами не меняются: содержимое копируется в
* видимую, поэтому после возврата обе страницы одинаковы. */
void pop_screen_present_ltr(void) __banked;
/* Нарисовать в уже выбранную draw-страницу набор вертикальных полос SPA1.
* Полоса загружается, блитится и тут же освобождается; функция не вызывает
+59 -54
View File
@@ -3,7 +3,7 @@
#ifndef PV_ARC_H
#define PV_ARC_H
#define ARC_PV_COUNT 78
#define ARC_PV_COUNT 83
#define ARC_PV_S0_0 0
#define ARC_PV_S0_1 1
@@ -30,58 +30,63 @@
#define ARC_PV_S4_2 22
#define ARC_PV_S4_3 23
#define ARC_PV_S4_4 24
#define ARC_PV_P0_0 25
#define ARC_PV_P0_1 26
#define ARC_PV_P0_2 27
#define ARC_PV_P0_3 28
#define ARC_PV_P0_4 29
#define ARC_PV_P1_0 30
#define ARC_PV_P1_1 31
#define ARC_PV_P1_2 32
#define ARC_PV_P1_3 33
#define ARC_PV_P1_4 34
#define ARC_PV_P2_0 35
#define ARC_PV_P2_1 36
#define ARC_PV_P2_2 37
#define ARC_PV_P2_3 38
#define ARC_PV_P2_4 39
#define ARC_PV_P3_0 40
#define ARC_PV_P3_1 41
#define ARC_PV_P3_2 42
#define ARC_PV_P3_3 43
#define ARC_PV_P3_4 44
#define ARC_PV_P4_0 45
#define ARC_PV_P4_1 46
#define ARC_PV_P4_2 47
#define ARC_PV_P4_3 48
#define ARC_PV_P4_4 49
#define ARC_PV_P5_0 50
#define ARC_PV_P5_1 51
#define ARC_PV_P5_2 52
#define ARC_PV_P5_3 53
#define ARC_PV_P5_4 54
#define ARC_PV_P6_0 55
#define ARC_PV_P6_1 56
#define ARC_PV_P6_2 57
#define ARC_PV_P6_3 58
#define ARC_PV_P6_4 59
#define ARC_PV_P7_0 60
#define ARC_PV_P7_1 61
#define ARC_PV_P7_2 62
#define ARC_PV_P7_3 63
#define ARC_PV_P7_4 64
#define ARC_PV_B0_0 65
#define ARC_PV_B0_1 66
#define ARC_PV_B0_2 67
#define ARC_PV_B0_3 68
#define ARC_PV_B0_4 69
#define ARC_PV_A0 70
#define ARC_PV_A1 71
#define ARC_PV_A2 72
#define ARC_PV_J0 73
#define ARC_PV_J1 74
#define ARC_PV_J2 75
#define ARC_PV_H0 76
#define ARC_PV_T0 77
#define ARC_PV_S5_0 25
#define ARC_PV_S5_1 26
#define ARC_PV_S5_2 27
#define ARC_PV_S5_3 28
#define ARC_PV_S5_4 29
#define ARC_PV_P0_0 30
#define ARC_PV_P0_1 31
#define ARC_PV_P0_2 32
#define ARC_PV_P0_3 33
#define ARC_PV_P0_4 34
#define ARC_PV_P1_0 35
#define ARC_PV_P1_1 36
#define ARC_PV_P1_2 37
#define ARC_PV_P1_3 38
#define ARC_PV_P1_4 39
#define ARC_PV_P2_0 40
#define ARC_PV_P2_1 41
#define ARC_PV_P2_2 42
#define ARC_PV_P2_3 43
#define ARC_PV_P2_4 44
#define ARC_PV_P3_0 45
#define ARC_PV_P3_1 46
#define ARC_PV_P3_2 47
#define ARC_PV_P3_3 48
#define ARC_PV_P3_4 49
#define ARC_PV_P4_0 50
#define ARC_PV_P4_1 51
#define ARC_PV_P4_2 52
#define ARC_PV_P4_3 53
#define ARC_PV_P4_4 54
#define ARC_PV_P5_0 55
#define ARC_PV_P5_1 56
#define ARC_PV_P5_2 57
#define ARC_PV_P5_3 58
#define ARC_PV_P5_4 59
#define ARC_PV_P6_0 60
#define ARC_PV_P6_1 61
#define ARC_PV_P6_2 62
#define ARC_PV_P6_3 63
#define ARC_PV_P6_4 64
#define ARC_PV_P7_0 65
#define ARC_PV_P7_1 66
#define ARC_PV_P7_2 67
#define ARC_PV_P7_3 68
#define ARC_PV_P7_4 69
#define ARC_PV_B0_0 70
#define ARC_PV_B0_1 71
#define ARC_PV_B0_2 72
#define ARC_PV_B0_3 73
#define ARC_PV_B0_4 74
#define ARC_PV_A0 75
#define ARC_PV_A1 76
#define ARC_PV_A2 77
#define ARC_PV_J0 78
#define ARC_PV_J1 79
#define ARC_PV_J2 80
#define ARC_PV_H0 81
#define ARC_PV_T0 82
#endif
+21 -127
View File
@@ -128,11 +128,6 @@ static void kbd_idle(void)
#define SEQ_STAND 2
#define ROOM_1 1
#define LINK_DOWN 3 /* сторона links: 0=L,1=R,2=U,3=D */
#define KBD_QUICKSAVE 0x0B /* F6, PS/2 set 2 */
#define KBD_QUICKLOAD 0x01 /* F9, PS/2 set 2 */
#define KBD_MENU 0x76 /* Esc — открыть pause menu (edge) */
#define KBD_QUIT 0x09 /* F10 — отладочный немедленный выход (был на Esc,
* пока меню не готово рисовать см. план MS4) */
/* ОТЛАДОЧНЫЙ ОБХОД КОМНАТ: '+'/'-' (цифровой блок либо '='/'-' основного
@@ -299,15 +294,11 @@ int main(void)
}
}
if (pop_app_state == POP_APP_INTRO) {
/* Таблица рекордов между credits и demo показывается ВНУТРИ
* pop_intro_show там же, где её показывает show_title(). */
uint8_t skipped = pop_intro_show();
pop_app_event_t ev;
if (!skipped) {
/* show_title() SDLPoP помещает сохранённую таблицу после credits
* и до attract-demo. При skip таблица тоже пропускается. */
pop_hof_show_title();
}
ev = skipped ? POP_APP_EV_NEW_GAME : POP_APP_EV_CUTSCENE_FINISHED;
pop_app_event_t ev = skipped ? POP_APP_EV_NEW_GAME
: POP_APP_EV_CUTSCENE_FINISHED;
if (!pop_app_dispatch(ev)) {
failed = 1;
break;
@@ -417,63 +408,20 @@ int main(void)
* QuickSave/QuickLoad идут тем же механизмом, что F6/F9 (запрос
* подхватит блок ниже в этой же границе кадра), рестарты исполняются
* после вычисления back-страницы (как респавн), QUIT рвёт цикл. */
/* Меню, F10 и QuickSave/Load — целиком в банке 8 (pop_frame_ui).
* Всё это события «раз в партию», а кода на них было полсотни строк
* в резиденте; наружу остаются флаги, которые нужны самому циклу. */
if (!is_demo) {
uint8_t e = kbd_raw_down(KBD_MENU);
uint8_t menu_opened;
int8_t mrc = POP_MENU_NONE;
if (e && !esc_prev) pop_menu_request = 1;
esc_prev = e;
menu_opened = pop_menu_request;
if (menu_opened) {
if (!pop_app_dispatch(POP_APP_EV_PAUSE)) {
failed = 1; quit = 1; break;
}
mrc = pop_menu_process();
}
if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save();
else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load();
else if (mrc == POP_MENU_QUICKLOAD_BACKUP)
pop_qsave_request_load_backup();
else if (mrc == POP_MENU_RESTART_LEVEL) menu_restart = 1;
else if (mrc == POP_MENU_RESTART_GAME) {
if (!pop_app_dispatch(POP_APP_EV_RESTART_INTRO)) failed = 1;
uint8_t want_restart = 0, dead_reset = 0;
int8_t urc = pop_frame_ui(&want_restart, &dead_reset);
if (want_restart) menu_restart = 1;
if (dead_reset) dead_frames = 0;
if (urc) {
if (urc < 0) failed = 1;
if (urc != 1) quit = 1; /* 1 — restart game, без quit */
break;
}
else if (mrc == POP_MENU_QUIT) {
if (!pop_app_dispatch(POP_APP_EV_QUIT)) failed = 1;
quit = 1;
break;
}
if (menu_opened) {
/* Меню переписало обе страницы целиком. Снимки слотов об
* этом не знают и продолжают считать, что персонаж на
* странице уже нарисован, стоящий Кид оставался бы
* невидимым до первого своего движения. */
pop_cd_forget();
pop_status_invalidate();
if (!pop_app_dispatch(POP_APP_EV_RESUME)) {
failed = 1; quit = 1; break;
}
}
}
/* F10 — отладочный немедленный выход (раньше был на Esc, но Esc
* теперь открывает меню; уйдёт вместе с отладкой). */
if (!is_demo && kbd_raw_down(KBD_QUIT)) {
if (!pop_app_dispatch(POP_APP_EV_QUIT)) failed = 1;
quit = 1;
break;
}
/* F6/F9 только ставят запрос; файловая операция выполняется здесь,
* на границе кадров вне окон Char и до heal/tick/draw. */
if (!is_demo) {
uint8_t s = kbd_raw_down(KBD_QUICKSAVE);
uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
int8_t qrc;
if (s && !qs_save_prev) pop_qsave_request_save();
if (l && !qs_load_prev) pop_qsave_request_load();
qs_save_prev = s; qs_load_prev = l;
qrc = pop_qsave_process();
if (qrc > 0) dead_frames = 0;
}
if (frozen) {
PROF(0); /* чёрный: ждём vsync */
@@ -761,66 +709,12 @@ int main(void)
* или доиграли запись показать текущий кадр и затем вернуться в
* title. В частности, не вызываем level_switch(0->1). */
if (is_demo && (kid_room == 24 || pop_kid_dead)) demo_finished = 1;
if (!is_demo && pop_next_level != pop_current_level) {
pop_pre_cutscene_t pre = pop_flow_pre_cutscene(pop_next_level,
pop_timer_minutes);
/* Смена уровня целиком — в банке (pop_level_switch): грузится
* файл, спрайты соперника и тайлсет. До 2/4/6/8/9/12 таблица
* flow запускает отдельное состояние CUTSCENE, как play_level()
* SDLPoP: следующий уровень не трогаем до завершения/skip. */
if (pre != POP_PRE_NONE) {
if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_STARTED)) {
failed = 1;
quit = 1;
break;
}
pop_pre_cutscene_show(pre);
if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED)) {
failed = 1;
quit = 1;
break;
}
/* Катсцена оставляет экран чёрным. Новый уровень будет
* открыт одним применением его готовой палитры. */
reveal_pending = 1;
} else {
/* CBL глушим ДО первой же операции с палитрой. Палитра идёт
* через BIOS ($A4) с запрещёнными прерываниями: насос не
* успевает долить блок, железо крутит хвост своего буфера
* на слух скрежет. Раньше pop_sfx_pause() звался только
* внутри pop_level_switch, то есть УЖЕ ПОСЛЕ гашения, и
* переход без катсцены (в т.ч. телепорт Shift+L) скрежетал
* почти всегда поймано пользователем 2026-08-25. Ветка с
* катсценой свободна от этого: pop_pre_cutscene_show глушит
* звук первым действием. */
pop_sfx_pause();
/* Без fade: старый кадр гасим сразу и обязательно даём
* показать один чёрный растр ДО диска/перерисовки. */
pop_pal_apply(4);
pop_wait_edge();
reveal_pending = 1;
if (!pop_app_dispatch(POP_APP_EV_LEVEL_FINISHED)) {
failed = 1;
quit = 1;
break;
}
}
if (pop_level_switch() != 0 ||
!pop_app_dispatch(POP_APP_EV_LEVEL_READY)) {
failed = 1;
quit = 1;
break;
}
if (reveal_pending) {
/* Обе страницы и snapshot уже принадлежат новому уровню:
* включить его корректную палитру одним переходом. */
pop_pal_apply(0);
reveal_pending = 0;
}
/* level_switch оставляет CBL закрытым: palette/fade идут через
* BIOS и при открытом буфере давали скрежет на первом кадре. */
(void)pop_sfx_start();
gfx_set_draw_page(back);
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это
* событие раз в несколько минут, а кода на нём было под сотню строк
* в самом горячем месте программы резидент W1 у нас в дефиците. */
if (!is_demo && pop_next_level != pop_current_level &&
pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++;
+141 -3
View File
@@ -30,6 +30,16 @@
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
#include "pop_settings.h" /* pop_settings_defaults — стартовый профиль VANILLA */
#include "pop_cfg.h" /* pop_cfg_load — POP.CFG поверх дефолтов */
#include "pop_flow.h" /* таблица level-flow: какая сцена перед уровнем */
#include "pop_intro.h" /* pop_pre_cutscene_show — сцена перед уровнем */
#include "pop_app.h" /* pop_app_dispatch — состояния приложения */
#include "pop_pal.h" /* pop_pal_apply — гашение перед загрузкой */
#include "pop_timer.h" /* pop_timer_minutes — выбор сцены «времени мало» */
#include "pop_ui.h" /* POP_PAL_DIM_BLACK */
#include "pop_menu.h" /* пауза-меню: коды выбора */
#include "pop_qsave.h" /* QuickSave/QuickLoad по F6/F9 */
#include "pop_status.h" /* pop_status_invalidate после меню */
#include <kbd_raw.h>
#include "pop_status.h" /* статус-строка и отладочная полоса — банк 9 */
#include "pop_menu.h" /* pop_menu_init/free — атлас UI в bank 9 */
#include "pop_ui.h" /* ранняя чёрная палитра + snapshot стартового экрана */
@@ -73,6 +83,134 @@ static void find_start_level_door(uint8_t room)
}
}
/* МЕНЮ, F10 И QUICKSAVE/LOAD — граница кадра, но не горячий путь.
*
* Всё это события «раз в партию», а в главном цикле они занимали полсотни
* строк резидента W1. Здесь же держим и edge-состояния клавиш: снаружи они
* никому не нужны.
*
* restart_level «пользователь выбрал Restart Level» (главный цикл сделает
* это сам, после вычисления back-страницы, как респавн);
* dead_reset «QuickLoad удался, счётчик кадров смерти сбросить».
* Возврат: 0 продолжаем кадр, 1 выйти из игрового цикла (Restart Game),
* 2 выйти и завершить работу (Quit), -1 сбой диспетчера. */
int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
{
static uint8_t esc_prev, qs_save_prev, qs_load_prev;
uint8_t e = kbd_raw_down(KBD_MENU);
uint8_t menu_opened;
int8_t mrc = POP_MENU_NONE;
if (e && !esc_prev) pop_menu_request = 1;
esc_prev = e;
menu_opened = pop_menu_request;
if (menu_opened) {
if (!pop_app_dispatch(POP_APP_EV_PAUSE)) return -1;
mrc = pop_menu_process();
}
if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save();
else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load();
else if (mrc == POP_MENU_QUICKLOAD_BACKUP)
pop_qsave_request_load_backup();
else if (mrc == POP_MENU_RESTART_LEVEL) *restart_level = 1;
else if (mrc == POP_MENU_RESTART_GAME)
return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1);
else if (mrc == POP_MENU_QUIT)
return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
if (menu_opened) {
/* Меню переписало обе страницы целиком. Снимки слотов об этом не
* знают и продолжают считать, что персонаж на странице уже
* нарисован, стоящий Кид оставался бы невидимым до первого своего
* движения. */
pop_cd_forget();
pop_status_invalidate();
if (!pop_app_dispatch(POP_APP_EV_RESUME)) return -1;
}
/* F10 — отладочный немедленный выход (раньше был на Esc, но Esc теперь
* открывает меню; уйдёт вместе с отладкой). */
if (kbd_raw_down(KBD_QUIT))
return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
/* F6/F9 только ставят запрос; файловая операция выполняется здесь, на
* границе кадров вне окон Char и до heal/tick/draw. */
{
uint8_t s = kbd_raw_down(KBD_QUICKSAVE);
uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
if (s && !qs_save_prev) pop_qsave_request_save();
if (l && !qs_load_prev) pop_qsave_request_load();
qs_save_prev = s; qs_load_prev = l;
if (pop_qsave_process() > 0) *dead_reset = 1;
}
return 0;
}
/* СМЕНА УРОВНЯ — целиком здесь, а не в главном цикле.
*
* Событие редкое (раз в несколько минут игры), а кода на нём была без
* малого сотня строк и все они лежали в _main, то есть в резиденте W1,
* где у нас счёт на десятки байт. Наружу остаётся одно условие и один
* вызов.
*
* back страница, в которую главный цикл рисует кадр: после загрузки
* уровня её надо вернуть себе (level_switch показывает свою).
* Возврат: 0 уровень сменён, -1 сбой, главный цикл завершает работу. */
int8_t pop_level_change(uint8_t back) __banked
{
uint8_t reveal_pending = 0;
pop_pre_cutscene_t pre = pop_flow_pre_cutscene(pop_next_level,
pop_timer_minutes);
/* Загрузка файла уровня, спрайтов соперника и тайлсета — в
* pop_level_switch ниже. До 2/4/6/8/9/12 таблица
* flow запускает отдельное состояние CUTSCENE, как play_level()
* SDLPoP: следующий уровень не трогаем до завершения/skip. */
if (pre != POP_PRE_NONE) {
if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_STARTED))
return -1;
pop_pre_cutscene_show(pre);
if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED))
return -1;
/* Катсцена оставляет экран чёрным. Новый уровень будет
* открыт одним применением его готовой палитры. */
reveal_pending = 1;
} else {
/* CBL глушим ДО первой же операции с палитрой. Палитра идёт
* через BIOS ($A4) с запрещёнными прерываниями: насос не
* успевает долить блок, железо крутит хвост своего буфера
* на слух скрежет. Раньше pop_sfx_pause() звался только
* внутри pop_level_switch, то есть УЖЕ ПОСЛЕ гашения, и
* переход без катсцены (в т.ч. телепорт Shift+L) скрежетал
* почти всегда поймано пользователем 2026-08-25. Ветка с
* катсценой свободна от этого: pop_pre_cutscene_show глушит
* звук первым действием. */
pop_sfx_pause();
/* Без fade: старый кадр гасим сразу и обязательно даём
* показать один чёрный растр ДО диска/перерисовки. */
pop_pal_apply(POP_PAL_DIM_BLACK);
pop_wait_edge();
reveal_pending = 1;
if (!pop_app_dispatch(POP_APP_EV_LEVEL_FINISHED))
return -1;
}
if (pop_level_switch() != 0 ||
!pop_app_dispatch(POP_APP_EV_LEVEL_READY))
return -1;
if (reveal_pending) {
/* Обе страницы и snapshot уже принадлежат новому уровню:
* включить его корректную палитру одним переходом. */
pop_pal_apply(0);
reveal_pending = 0;
}
/* level_switch оставляет CBL закрытым: palette/fade идут через
* BIOS и при открытом буфере давали скрежет на первом кадре. */
(void)pop_sfx_start();
gfx_set_draw_page(back);
return 0;
}
/* start_level (seg003:0055) + do_startpos (seg003:01A3) + set_start_pos
* (seg003:028A).
*
@@ -937,7 +1075,7 @@ int pop_boot(void) __banked
* понадобится как источник обычного fade-in. Для build-info экран
* остаётся штатный яркий игровой фон. */
if (!pop_settings.show_build_info) {
pop_pal_apply(4);
pop_pal_apply(POP_PAL_DIM_BLACK);
}
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно заново
@@ -1091,7 +1229,7 @@ int pop_new_game_load(void) __banked
/* Title мог завершиться на яркой title-палитре. Сначала погасить её,
* затем восстановить полный game snapshot; level_switch собирает обе
* страницы уже невидимо, и первый игровой кадр проявляется. */
pop_pal_apply(4);
pop_pal_apply(POP_PAL_DIM_BLACK);
gfx_set_draw_page(0);
gfx_set_bank(GFX_BANK_NORMAL);
pop_screen_fill_game();
@@ -1099,7 +1237,7 @@ int pop_new_game_load(void) __banked
pop_screen_fill_game();
gfx_set_visible_page(1);
pop_pal_game_load();
pop_pal_apply(4);
pop_pal_apply(POP_PAL_DIM_BLACK);
if (pop_level_switch() != 0) return -1;
/* Первый уровень появляется сразу, без fade, но только после того как
* обе страницы и его собственная палитра полностью готовы. */
+17
View File
@@ -93,6 +93,23 @@ int pop_boot(void) __banked;
* файла нет молча остаться на текущем. -1 фатально. */
int pop_level_switch(void) __banked;
/* Смена уровня целиком (катсцена, гашение, загрузка, палитра, звук).
* back страница кадра главного цикла. 0 ок, -1 сбой. */
int8_t pop_level_change(uint8_t back) __banked;
/* Клавиши служебного слоя. Живут здесь, а не в roomtest.c: сам обработчик
* (pop_frame_ui) переехал в банк, а главный цикл о них больше не знает. */
#define KBD_QUICKSAVE 0x0B /* F6, PS/2 set 2 */
#define KBD_QUICKLOAD 0x01 /* F9, PS/2 set 2 */
#define KBD_MENU 0x76 /* Esc — открыть pause menu (edge) */
#define KBD_QUIT 0x09 /* F10 — отладочный немедленный выход (был на Esc,
* пока меню не готово рисовать см. план MS4) */
/* Меню, F10 и QuickSave/Load на границе кадра. restart_level/dead_reset —
* флаги для главного цикла. 0 продолжаем, 1 Restart Game, 2 Quit,
* -1 сбой. */
int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked;
/* Чистый старт настоящей игры после Enter/Esc на title/intro/credits либо
* клавиши в attract-demo. Ждёт отпускания клавиши, сбрасывает состояние
* новой игры, загружает FIRST_LEVEL и проявляет его первый кадр. */
+4 -4
View File
@@ -100,16 +100,16 @@ TC_TEST(restart_game_returns_to_intro)
TC_EQ(pop_app_state, POP_APP_TITLE);
}
/* Финал ВРЕМЕННО ведёт мимо Hall of Fame прямо в title: экран HOF после
* победы показывает чёрное, и застревать в нём хуже, чем не показывать
* (решение пользователя 2026-08-24, BUGS_OPEN.md#hof-after-ending). Сам
* маршрут HOF -> TITLE остаётся живым: в него ходит title-экран. */
/* Финал уходит в таблицу рекордов: она же дослушивает победную тему и
* гасит экран, как хвост end_sequence (seg001:5F1). */
TC_TEST(ending_to_title_route)
{
pop_app_state = POP_APP_PLAYING;
TC_TRUE(pop_app_dispatch(POP_APP_EV_ENDING_STARTED));
TC_EQ(pop_app_state, POP_APP_ENDING);
TC_TRUE(pop_app_dispatch(POP_APP_EV_ENDING_FINISHED));
TC_EQ(pop_app_state, POP_APP_HALL_OF_FAME);
TC_TRUE(pop_app_dispatch(POP_APP_EV_HOF_FINISHED));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
+205
View File
@@ -0,0 +1,205 @@
# SprPoP — правила приложения
Порт Prince of Persia (DOS/Apple II) на Sprinter Sp2000 поверх нашего
sprinter-cc / libc / libbgi. Действуют правила корневого `CLAUDE.md`
(сборка, libc, ABI, MAME-автотест); ниже — только специфика порта.
Общение и комментарии — на русском.
Приложение **автономно**: код, оригинальные данные, конверторы и сборка
лежат внутри этой папки. Наружу оно знает ровно один путь — корень
тулчейна, `SPRINTER_ROOT` (по умолчанию `../..`).
История: SprPoP выделен из `applications/PoP/roomtest` 2026-08-26. Та
папка ЗАМОРОЖЕНА и остаётся архивом (закрытые задачи и баги с разбором
корней — `../PoP/roomtest/TASKS_CLOSED.md` и `BUGS_CLOSED.md`; там же
исполненные планы в `../PoP/docs/`). Разработка идёт только здесь.
## Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь
**`assets/orig/SDLPoP/src/` (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ
авторитетный источник того, как оригинальный движок это делает.** Правило
без исключений:
1. **Перед реализацией ЛЮБОЙ функции** (движение, коллизия, окклюзия,
падение, loose-полы, стражники, отрисовка, тайминги, любые числовые
константы) — СНАЧАЛА найди и прочитай соответствующий код в SDLPoP и
портируй по нему. Не пиши по памяти, не выводи логику «из общих
соображений», не угадывай значения — это источник багов, которые потом
ловятся в MAME часами.
2. **По любому вопросу «как в оригинале должно быть»** (что окклюдит что,
в каком порядке слои, когда меняется тайл, какая скорость/задержка, что
делает такой-то кадр анимации) — ответ ищи в SDLPoP, а не строй гипотезу.
Если не нашёл — это повод копать дальше в исходнике, а не додумывать.
3. Расхождение нашей реализации с SDLPoP — по умолчанию **баг у нас**, пока
не доказано обратное (наша платформа/ABI требует отличия — тогда явно
зафиксировать почему в комментарии **и записью в `docs/impl_diff.md`**:
что делает оригинал, что делаем мы, чем платим, что проверять при
регрессе).
Карта сегментов: `seg005` control-диспетчер, `seg006` play_kid/коллизия/
seqtbl, `seg007` mob/loose/падающие объекты, `seg008` отрисовка тайлов/
слои/окклюзия, `seg009` чтение ресурсов. Слои окклюзии у нас = слои SDLPoP.
См. memory `pop_check_sdlpop_first`.
Вторичные референсы (когда в SDLPoP непонятно или нужен другой ракурс)
лежат вне приложения, в `../PoP/`: `Prince-of-Persia-Apple-II/`
оригинальный 6502-исходник 1989 (Мехнер), `PR/` — Princed Resources,
`mininim/` — независимая реализация. Все они — **справочник логики,
структур и констант**, но НЕ код для копирования (лицензии несовместимы,
наш ABI другой): читаем и переписываем под наш движок.
## Доски
**Что в работе сейчас — `docs/TASKS_OPEN.md`** (приоритеты, критерии
готовности). Баги — `docs/BUGS_OPEN.md` (только ОТКРЫТЫЕ).
Правило разнесения: как только задача или баг закрыт — запись целиком
переезжает в архив roomtest (`../PoP/roomtest/TASKS_CLOSED.md` /
`BUGS_CLOSED.md`), а в открытом файле остаётся ссылка. Перед заведением
нового бага грепни архив по симптому: корень мог уже разбираться.
Открытые файлы обязаны читаться целиком за раз.
Индекс документации с отметками актуальности — **`docs/README.md`**,
начинать чтение оттуда.
## Сборка и запуск
```
make собрать build/sprpop.exe и разложить build/
make hdd + образ build/hdd/sprpop.chd
make mame-link однократно: подставить образ в MAME (см. ниже)
make -C tests/host модульные тесты движка под ucsim_z80 (секунды, без MAME)
make resources перегенерировать ресурсы из assets/orig/
make music-mp3 музыка из другого набора (flac|mp3|ogg|mt32)
make clean снести build/ (ассеты не трогает)
make distclean clean + снести assets/packed/ (вернуть — make resources)
```
`MEMORY=huge`, `--gfx 256`, 11 банков кода в W3. Отладочные ключи сборки:
`make LEVEL=9` (стартовый уровень), `make ROOM=15 POS=2` (стартовая комната
и тайл `row*10+col`), `make PROF=1` (профиль фаз кадра полосами бордюра),
`make ALLOCS=100000` (плотная упаковка регистров — для замеров размера).
Сравнивать занятость банков можно **только при одном `ALLOCS`**.
**Ловушка `make mame-link`:** он подменяет `mame/v306/IMG/test_hdd.chd`
символьной ссылкой на наш образ. Пока ссылка стоит, `make hdd` любого
другого приложения (в т.ч. замороженной roomtest) писал бы через неё в
наш `build/hdd/` — если понадобится собрать чужой образ, ссылку сначала
убрать.
## Раскладка папки
| Папка | Что |
|---|---|
| `src/` | рукописный C: главный цикл `sprpop.c`, холодная половина `sprpop_cold.c`, движок `pop_*.c` |
| `gen/` | генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). **Руками не править** — их печатают упаковщики; лежат в репозитории, потому что без них `src/` не собрать |
| `assets/orig/` | оригинальные данные (SDLPoP, MSDOS, записи музыки). Вне репозитория — копирайт; что и откуда взять, написано в `assets/orig/README.md` |
| `assets/packed/` | то, что ложится на диск игры, уже в раскладке диска (`BG/`, `KID/`, …). В репозитории — иначе из чистого клона не собрать |
| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках |
| `docs/` | планы, доски, справочники; `docs/PoP/` — форматы ресурсов оригинала |
| `tests/host/` | модульные тесты движка под ucsim_z80 |
| `build/` | выход: `sprpop.exe`, каталоги ресурсов, `hdd/`, промежуточные `atl/` |
Музыка собирается из одного из четырёх наборов записей
(`assets/orig/PoP1_DOS_music/`): `flac` по умолчанию, плюс `mp3`, `ogg` и
`mt32` (исполнение Roland MT-32 — звучит иначе, длина треков другая). Набор
входит в имя stamp'а, поэтому `make music-mt32` пересобирает музыку сам, без
`-B`; разовая сборка без смены умолчания — `make MUSIC_FMT=ogg resources-music`.
Ресурсы: упаковщик пишет промежуточные атласы в `build/atl/<набор>/`, из них
Makefile склеивает архивы прямо в `assets/packed/<КАТАЛОГ>/`, а `make`
копирует их в `build/`. Порядок файлов в списках `*_ATL` Makefile — это
порядок индексов внутри архива, и он же напечатан в `gen/*_arc.h`: менять
порядок нельзя, не перегенерировав заголовок.
**Каноническая спецификация форматов `.DAT`** —
`docs/PoP/POP-DAT-FormatSpecifications.pdf` (грепаемая копия — `.txt`):
первоисточник Princed для DAT v1.0. Наши разборы
(`docs/PoP/MSDOS_RESOURCE_FORMAT.md`, `docs/PoP/APPLEII_RESOURCE_FORMAT.md`) —
практические заметки; при расхождении источник истины — спецификация.
## Проверка в MAME
Только через `toolchain/mame_interactive.py` или MCP-мост `mame-z80`
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
старт `run_bridge.sh` → 6 с`go` → 8 с`keyseq d:{ENTER}` +
`keyseq sprpop{ENTER}` → 5 с → программа работает.
Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
(`src/pop_cheat.h`): **K** — убить стража, **I** — бессмертие,
**Shift+L** — следующий уровень, **+/−** — обход комнат (`ROOMNAV`).
Не чит, а штатная клавиша оригинала: **Ctrl+S** — вкл/выкл звук (пурпурная
палочка в борте = звука нет). Целевая раскладка управления, к которой
подгоняем SprPoP, — `docs/keys.txt`.
Логику, которую можно проверить без железа, покрывать в `tests/host/`
(обвязка — `testkit/`). MAME остаётся для отрисовки, банков, таймингов и
клавиатуры.
## Модули
| Файл | Роль (порт SDLPoP) |
|------|--------------------|
| `sprpop.c` | Главный цикл: дабл-буфер (2 страницы + флип на vsync), порядок tick→draw. |
| `sprpop_cold.c` | Холодная половина главного цикла (банк 8): старт уровня, отладочная навигация. |
| `pop_tile.c/.h` | Общие ЛИСТЬЯ слоя фона — РЕЗИДЕНТ W1: блит куска атласа, чтение тайла комнаты, `tile_table`, метка «фон трогали вот здесь» (`pop_cd_touch`). Резидент потому, что их зовут обе половины слоя фона из разных банков, а `const`-таблицы банка из чужого банка не видны. |
| `pop_bg.c/.h` | ГОРЯЧАЯ половина слоя фона (банк 2, каждый кадр): fore-окклюзия поверх ЛЮБОГО персонажа (`seg003` redraw_at_char/char2 — один проход на всех Char), оверлеи кромки, кладка стены (`wall_pattern`), клип. `pop_bg.h` — публичный API ВСЕГО слоя. |
| `pop_room.c` | ХОЛОДНАЯ половина слоя фона (банк 7, раз на комнату/событие): полная отрисовка комнаты (`seg008` draw_tile), точечные перерисовки тайлов (пики, ворота, кнопки, дверь уровня, дрожащие плиты), падающие куски loose (`seg007` mob), загрузка атласов. |
| `_pop_bg.h` | Внутренний контракт между половинами: общее состояние + три тонкие `__banked`-обёртки; там же почему рез прошёл именно здесь и сколько стоит. |
| `pop_kid.c/.h` | Анимация/движение: интерпретатор seqtbl `play_seq` + frame_table (`seg006`), окна `Char` (loadkid/loadshad), атласы Кида. |
| `pop_cdraw.c/.h` | ОТРИСОВКА персонажей — одна на всех Char (порт `add_kid_to_objtable`/`add_guard_to_objtable`): спрайт, clip_char, брызги, клинок, heal; полоса HP и палитры соперника. Там же ПРОПУСК неизменившегося кадра (`pop_char_skip_mask`). |
| `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх `<kbd_raw.h>`. |
| `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). |
## Банки: две мины, которых компилятор не видит
Обе стреляют молча, обе поймали нас 2026-08-27 (разбор — в
`../PoP/roomtest/BUGS_CLOSED.md`, поиск «FINAL-BANKCALL»).
**1. Прямой вызов в чужой банк.** Трамплин SDCC выбирает по `__banked`
в ОБЪЯВЛЕНИИ, а не по тому, где функция лежит. Функция без пометки
зовётся прямым `call` — верно ровно пока вызывающий и вызываемый в одном
банке. Перенесли модуль ради разгрузки банка — и тот же `call` уходит в
пустой хвост чужого банка, процессор ползёт по 0xFF до 0x0000, DSS убивает
процесс. Ловит `toolchain/check_bank_calls.py`, она встроена в сборку и
ВАЛИТ её (проверено намеренной поломкой).
**2. Указатель на данные своего банка, отданный в чужой.** Обратная
сторона: пока обе стороны в одном банке, указатель на литерал или
const-таблицу работает; пометили функцию `__banked` — и трамплин на время
вызова переключает W3, а указатель показывает уже в чужой банк. Так экран
таблицы рекордов залило знаками вопроса: в `pop_text_draw_mapped` (банк 9)
уходил литерал `"_"` из банка 10.
**Правило:** всё, что уходит указателем в другой банк, обязано лежать в
W2 — стек, `_DATA` или копия. `const`-таблицы и строковые литералы
банкового модуля наружу отдавать нельзя (то же, что уже записано про
`pop_tile.c`). Вторая проверка скрипта предупреждает о явных случаях, но
она эвристическая и гарантией не является.
## Порядок слоёв в кадре (важно для окклюзии)
`sprpop.c` каждый тик рисует в СКРЫТУЮ страницу: `pop_char_heal` (стереть
прошлый кадр — по слоту на персонажа) → `kid_tick``pop_phys_tick`
`pop_loose_tick` (loose СЛОЙ ФОНА — ДО персонажей, чтобы они были поверх
плиты) → `pop_char_draw`/`pop_char_fore` для соперника и Кида (кто позже —
тот поверх, порядок задаёт обход тайлов) → `pop_room_clip_borders` (чистка
бортов, гейт по флагу — только в кадрах падения) → флип на vsync.
**Дабл-буфер:** у каждой страницы своя видео-ОЗУ и теневая ОЗУ-копия; heal
берёт чистый фон из копии ТОЙ страницы, в которую рисуем. Любой
динамический элемент (Kid, loose-плита, падающий кусок) обязан чистить свой
прошлый кадр на КАЖДОЙ из двух страниц — иначе остаток виден через кадр как
**мерцание**. Типовой источник багов «остаётся кусочек» (memory
`pop_fall_debug_baseline`).
## Ключевые memory
`pop_port_project`, `pop_check_sdlpop_first`, `pop_banking_architecture`,
`pop_background_strategy`, `pop_kid_plan`, `pop_hang_state`, `pop_fore_layer`,
`pop_fall_debug_baseline`, `accel_vertical_copy`, `kbd_raw_fifo_drain`,
`pop_perf_registry`.
+532
View File
@@ -0,0 +1,532 @@
# SprPoP — порт Prince of Persia на Sprinter Sp2000.
#
# Приложение АВТОНОМНО: весь код, все исходные ресурсы, все конверторы лежат
# здесь. Наружу оно знает ровно один путь — корень тулчейна sprinter-cc:
#
# export SPRINTER_ROOT=/путь/к/C-Compiler
# make
#
# Без переменной берётся ../.. (раскладка applications/SprPoP внутри дерева
# компилятора), так что в родном дереве ничего настраивать не надо.
#
# Раскладка:
# src/ рукописный C
# gen/ генерируемые заголовки (в репозитории: без них src/ не
# собрать, а генератору нужны оригиналы из assets/orig/)
# assets/orig/ оригинальные данные — SDLPoP, MSDOS, записи музыки.
# НЕ в репозитории (копирайт), см. assets/orig/README.md
# assets/packed/ то, что ложится на диск игры, в раскладке диска
# tools/ конверторы ресурсов
# build/ ВЫХОД: sprpop.exe + BG/ KID/ ... + hdd/sprpop.chd
#
# Основные цели:
# make собрать sprpop.exe и разложить build/
# make hdd + образ build/hdd/sprpop.chd
# make mame-link однократно связать образ с MAME (см. ниже)
# make resources перегенерировать ресурсы из assets/orig/
# make music-mp3 музыка из другого набора записей (flac|mp3|ogg|mt32)
# make clean снести build/ (ассеты не трогает)
# make distclean clean + снести упакованные ассеты assets/packed/
SPRINTER_ROOT ?= $(abspath $(CURDIR)/../..)
PROJ_ROOT := $(SPRINTER_ROOT)
EXAMPLE := sprpop
SRC_DIR := src
BUILD_DIR := build
# huge: small-раскладка (CODE в W1, DATA в W2) + банки кода в W3.
MEMORY ?= huge
# --max-allocs-per-node: во сколько вариантов размещения регистров упирается
# SDCC на узел. Дефолт sprinter-cc (100000) на модулях такого размера — это
# МИНУТЫ на банк. 6000 — компромисс: сборка дольше секунд на двадцать, зато
# резидент W1 худеет примерно на 150 байт против дефолтных 3000 SDCC, а это
# ровно те байты, которых не хватает куче.
#
# make — обычная сборка (ALLOCS=6000)
# make ALLOCS=100000 — минимальный код, для замеров размера и релиза
#
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000
# Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell git log -1 --format=%cs 2>/dev/null || echo unknown)
BUILD_FLAGS := -DPOP_BUILD_ID=\"$(BUILD_ID)\" -DPOP_BUILD_DATE=\"$(BUILD_DATE)\"
# ---------------------------------------------------------------------------
# Банковая раскладка
# ---------------------------------------------------------------------------
# Единственный список банковых исходников: из него строятся И ключи
# sprinter-cc, И зависимости sprpop.exe. Держать два списка нельзя — так уже
# ловили stale-бинарник (модуль компилировался, но make не замечал правок).
#
# Банк 2 = отрисовка (pop_bg + pop_cdraw в соседних банках): вызовы банк→банк
# работают, трамплин сохраняет страницу окна на СТЕКЕ. Резидента --w3 нет.
# Банк 5 = pop_ctrl.c: кандидат выбран не по размеру, а по частоте вызова —
# диспетчер управления дёргается раз в кадр на персонажа и горячих банк→банк
# переходов не создаёт (в отличие от pop_level, чей pop_level_tile зовётся из
# банка 2 на КАЖДЫЙ тайл).
# Банк 9 = оболочка (menu/settings/config/font, автомат, title, attract-demo,
# таблица рекордов) — мелкие редкие точки входа, но не рендеры заставок.
# Банк 10 = единственный владелец экранных палитр/fade; отделён от UI, потому
# что его зовут и игровой lifecycle, и все ветки катсцен.
# Банк 11 = тяжёлая кинематика заставок, чтобы анимации не вытесняли
# menu/config из банка оболочки.
BANK1_SRCS := guards.c
BANK2_SRCS := pop_bg.c
BANK3_SRCS := pop_map.c
BANK4_SRCS := pop_cdraw.c pop_kdraw.c
BANK5_SRCS := pop_ctrl.c pop_tile_cold.c
BANK6_SRCS := pop_trob.c
BANK7_SRCS := pop_room.c pop_redraw.c
BANK8_SRCS := sprpop_cold.c pop_arc.c pop_level_cold.c pop_kboot.c pop_guard_cold.c \
pop_shadow.c pop_sfx_cold.c pop_qsave.c pop_qsave_coldio.c \
pop_level_qsave.c
BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_app.c pop_timer.c \
pop_ui.c pop_title.c pop_demo.c pop_flow.c
# Банк 10 = палитры + РЕДКОЕ С ФАЙЛАМИ. pop_config (POP.CFG) и pop_hof
# (POP.HOF) переехали сюда из банка 9, когда тот дошёл до 98,5% (247 байт
# свободно). Критерий тот же, что и у остальной раскладки, — частота, а не
# размер: обе вещи работают раз за партию и упираются в диск, где `open`
# стоит 51 мс, так что трамплин банк→банк на их фоне не существует.
# Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на
# 485 байт, то есть 3% страницы.
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c pop_path_bank.c
BANK11_SRCS := pop_cutscene.c pop_intro.c
BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11
BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS))
BANK_FLAGS := $(foreach n,$(BANK_NUMS),\
$(foreach f,$(BANK$(n)_SRCS),--bank $(n)=$(SRC_DIR)/$(f)))
# Модули резидента (кроме sprpop.c — его подставляет app.mk).
EXTRA_SRCS := $(addprefix $(SRC_DIR)/,pop_vflip.c pop_pace.c pop_sfx.c pop_state.c \
pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c \
pop_qsave_io.c)
# ---------------------------------------------------------------------------
# Отладочные ключи
# ---------------------------------------------------------------------------
# Профилирование полосами бордюра (PROF() в sprpop.c): каждая фаза кадра красит
# бордюр в свой цвет, высота полосы на скриншоте MAME = время фазы в строках.
PROF ?= 0
PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень: make LEVEL=9 — начать сразу с девятого.
LEVEL ?= 1
PROF_FLAGS += -DFIRST_LEVEL=$(LEVEL)
# Стартовая КОМНАТА и позиция Кида: make ROOM=15 POS=2 — начать прямо в
# целевой комнате, минуя проход уровня. POS — тайл 0..29, то есть row*10+col.
# make ROOM= (пусто) возвращает штатный старт из данных уровня.
ROOM ?=
POS ?= 2
ifneq ($(strip $(ROOM)),)
PROF_FLAGS += -DDBG_START_ROOM=$(ROOM) -DDBG_START_POS=$(POS)
endif
EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/gen -I $(CURDIR)/$(SRC_DIR) \
--max-allocs $(ALLOCS) $(BANK_FLAGS) $(PROF_FLAGS) $(BUILD_FLAGS)
# ---------------------------------------------------------------------------
# Ресурсы: что уходит на диск
# ---------------------------------------------------------------------------
# assets/packed/<КАТАЛОГ>/ повторяет раскладку диска (8.3, как в DSS), поэтому
# отдельного списка «файл → каталог образа» больше нет: каталог виден из пути.
PACKED := assets/packed
ATL := $(BUILD_DIR)/atl
# Промежуточные атласы (в build/atl, в репозиторий не идут) — из них
# упаковщик архивов склеивает то, что реально ложится на диск. Порядок в
# списках = порядок индексов внутри архива, а их упаковщик печатает в gen/*.h;
# менять порядок нельзя, не перегенерировав заголовок.
BG_SETS := pop pal
BG_PAGES := 0 1 2 3 4 5 6 7 8 9
BG_ATL := $(foreach s,$(BG_SETS),\
$(foreach n,$(BG_PAGES),$(ATL)/bg/$(s)_env$(n).atl) \
$(ATL)/bg/$(s)_wall.atl $(ATL)/bg/$(s)_fore.atl) \
$(ATL)/bg/pop_pot.atl
# ВСЕ 28 страниц Kid — иначе кадры торможения/разворота/приседа (image>23) не
# рисуются. Плюс зеркальный ПО ВЕРТИКАЛИ набор (зелье инверсии, уровень 9):
# разворот готовит упаковщик, а не рантайм — страница разворачивается 0,59 с
# против 0,045 с на её чтение с диска.
KID_N := 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
KID_ATL := $(foreach n,$(KID_N),$(ATL)/kid/kid$(n).atl) \
$(foreach n,$(KID_N),$(ATL)/kid/kid$(n)_v.atl) \
$(ATL)/kid/sword.atl $(ATL)/kid/sword_v.atl
GUARD_ATL := $(foreach n,0 1 2 3 4,$(ATL)/guard/g$(n).atl) \
$(foreach n,0 1 2 3 4,$(ATL)/guard/g$(n)_v.atl)
# Скелет — ОТДЕЛЬНЫЙ набор (SKEL.DAT), его грузит уровень 3; кадров меньше.
# Зеркального набора у него нет: зелье инверсии — уровень 9, там обычный страж.
SKEL_ATL := $(foreach n,0 1 2 3,$(ATL)/skel/g$(n).atl)
# Джафар (VIZIER.DAT) — уровень 13; кадров столько же, сколько у стража, но
# палитра своя, из файла набора.
VIZIER_ATL := $(foreach n,0 1 2 3 4,$(ATL)/vizier/g$(n).atl)
# ТЕНЬ — набор, запечённый упаковщиком (наложение спрайта на себя со сдвигом;
# наш блит так не умеет). sk* — кадры вне боя, sf* — кадры 150..189.
SHADOW_ATL := $(foreach n,$(KID_N),$(ATL)/shadow/sk$(n).atl) \
$(foreach n,0 1 2 3,$(ATL)/shadow/sf$(n).atl)
# Звук: оригинальная оцифровка на частоте CBL (10 937,5 Гц, 8 бит моно).
# Число файлов задаёт упаковщик (POP_SND_PAGES в gen/pop_sound_tbl.h) —
# список ОБЯЗАН совпадать: недостающий файл это молча пропавшие эффекты.
SND_ATL := $(foreach n,0 1 2 3 4 5 6 7 8 9,$(ATL)/sound/s$(n).bin)
TITLE_ATL := $(foreach s,0 1 2 3,$(foreach p,0 1 2 3 4,$(ATL)/title/t$(s)_$(p).atl))
# PV/INTRO: story и стадии режутся на полосы; у настоящего PV intro есть ещё
# чистый фон b0_* и шесть <=16K actor-атласов — они остаются в EMM на время
# сцены, а не перечитывают полный 320x200 кадр с диска.
PV_ATL := $(foreach s,0 1 2 3 4 5,$(foreach p,0 1 2 3 4,$(ATL)/pv/s$(s)_$(p).atl)) \
$(foreach s,0 1 2 3 4 5 6 7,$(foreach p,0 1 2 3 4,$(ATL)/pv/p$(s)_$(p).atl)) \
$(foreach p,0 1 2 3 4,$(ATL)/pv/b0_$(p).atl) \
$(ATL)/pv/a0.atl $(ATL)/pv/a1.atl $(ATL)/pv/a2.atl \
$(ATL)/pv/j0.atl $(ATL)/pv/j1.atl $(ATL)/pv/j2.atl \
$(ATL)/pv/h0.atl $(ATL)/pv/t0.atl
# МУЗЫКА: записи DOS-версии в формате CBL. Номера — оригинала, чтобы игровой
# код просил трек тем же числом, каким SDLPoP зовёт play_sound.
# заставка 54 55 50 53 52 | катсцены 26 27 35 36 40
# игра 24 25 28 29 30 32 33 37 39 41 43 | 56 = won (стриминг с диска)
MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55 56
# Набор записей, из которого делается музыка: flac (по умолчанию — лучший
# исходник), mp3, ogg, mt32 (то же в исполнении Roland MT-32). Все четыре
# лежат в assets/orig/PoP1_DOS_music/, имена треков совпадают.
# Формат входит в имя stamp'а, поэтому смена MUSIC_FMT сама по себе делает
# музыку устаревшей — принудительная пересборка не нужна.
MUSIC_FMT ?= flac
# res2000 — attract-demo; res2001..res2014 — штатная игра. res2015 (copy
# protection) намеренно не пакуется и не существует в маршруте загрузчика.
LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
# ЕДИНСТВЕННЫЙ список того, что ложится на диск. Из него выводятся и
# staging в build/, и аргументы упаковщика образа.
# Архивы вместо россыпи файлов не ради места: замер 2026-08-25 — `open` 51,4 мс
# против 32,6 мс на чтение 16 КБ, то есть на одних открытиях набор Кида терял
# три секунды. Четыре архива актёров, а не один: имена внутри наборов
# совпадают (g0.atl есть и у стража, и у скелета, и у Джафара), а константы
# индексов упаковщик делает из имени файла.
# README для игрока идёт НА ДИСК рядом с программой, В КОРЕНЬ. Русский
# обязан быть в CP866: DSS и любой местный просмотрщик читают именно её,
# UTF-8 покажется кракозябрами. Имена по 8.3, язык — суффиксом, а не
# расширением: расширение .TXT должно остаться, чтобы файл открывался
# просмотрщиком как текст.
DISK := README_E.TXT README_R.TXT \
BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \
KID/kid.arc KID/kid.pal KID/kid.ani \
GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \
SND/snd.arc \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) \
FONT/font.atl \
TITLE/title.arc TITLE/title.pal \
PV/pv.arc PV/story.pal PV/pv.pal \
$(foreach n,$(LVL_NUMS),LEVELS/res20$(n).bin)
# Все шестнадцать генерируемых заголовков перечислены явно, а не wildcard'ом:
# список — это и зависимость exe, и то, что умеет чинить себя само.
GEN_ARC_H := bg_arc.h kid_arc.h guard_arc.h skel_arc.h vizier_arc.h \
shadow_arc.h title_arc.h pv_arc.h
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_data.h \
pop_guard_pal.h pop_shadow_atlas.h pop_sound_tbl.h \
pop_music_tbl.h pop_music_ticks.h pop_font.h)
PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
EXTRA_DATA := $(STAGED)
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог HDD_DEST_DIR, голый путь —
# непосредственно в HDD_DEST_DIR. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё
# `README_E.TXT` превращался
# в `README_E.TXT:build/README_E.TXT`, то есть упаковщик заводил каталог с
# именем файла и клал файл внутрь него (поймано пользователем на готовом
# образе).
HDD_PACK_ARGS := $(BUILD_DIR)/$(EXAMPLE).exe \
$(foreach f,$(DISK),$(if $(findstring /,$(f)),\
$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f),\
$(BUILD_DIR)/$(f)))
# Всё дерево SprPoP лежит рядом с EXE, но не меняет дефолт глобального
# упаковщика: остальные приложения по-прежнему попадают в корень HDD.
HDD_DEST_DIR := games/sprpop
# Образ живёт в build/, а не в mame/v306/IMG — приложение автономно. Связь с
# MAME — одна символьная ссылка, ставится однократно: make mame-link.
HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd
include $(PROJ_ROOT)/app.mk
# ---------------------------------------------------------------------------
# Сборка приложения
# ---------------------------------------------------------------------------
# sprinter-cc собирает все модули одним вызовом и depfile не выдаёт, поэтому
# зависимости перечисляем сами. wildcard по заголовкам намеренно широкий:
# цена лишней полной сборки ниже риска получить stale-бинарник.
HEADERS := $(wildcard $(SRC_DIR)/*.h)
# ОТПЕЧАТОК КЛЮЧЕЙ СБОРКИ. LEVEL/ROOM/POS/PROF/ALLOCS — это -D на входе
# компилятора, а не файлы, и make их «не видит»: после `make LEVEL=14`
# обычный `make` считал бы отладочный бинарь свежим и молча оставил его.
# Поэтому ключи пишутся в файл, и exe зависит от него; меняется строка —
# перелинковка. Файл переписывается ТОЛЬКО при изменении, иначе каждый
# make дёргал бы полную сборку заново.
BUILD_SIG := MEMORY=$(MEMORY) ALLOCS=$(ALLOCS) $(PROF_FLAGS) $(BANK_FLAGS)
BUILD_STAMP := $(BUILD_DIR)/.buildflags
# Отпечаток обновляется НА РАЗБОРЕ Makefile, а не рецептом. Так время файла
# уже верное к моменту, когда make сверяет его с exe — иначе не работает ни
# один из простых вариантов:
# * рецепт с cmp — под `make -n` не выполняется, и make врёт про работу;
# * подпись в имени файла — при ВОЗВРАТЕ к прежним ключам файл уже лежит
# и оказывается старше exe, то есть перелинковки не будет.
# Запись идёт только при отличии, поэтому обычный make ничего не трогает.
# На целях очистки пропускаем, чтобы `make clean` не создавал build/ заново.
ifeq ($(filter clean distclean,$(MAKECMDGOALS)),)
$(shell mkdir -p $(BUILD_DIR); printf '%s\n' '$(BUILD_SIG)' | cmp -s - $(BUILD_STAMP) \
|| printf '%s\n' '$(BUILD_SIG)' > $(BUILD_STAMP))
endif
# Цепочка: exe -> gen/*.h -> ресурсы -> упаковщик -> assets/orig (см. ниже).
$(EXE): Makefile $(PROJ_ROOT)/app.mk $(addprefix $(SRC_DIR)/,$(BANKED_SRCS)) \
$(HEADERS) $(GEN_H) $(BUILD_STAMP)
# Полная сборка = exe + разложенный build/.
all: stage
# ---------------------------------------------------------------------------
# staging: assets/packed/ -> build/
# ---------------------------------------------------------------------------
# build/ обязан быть самодостаточным (его можно снести целиком), поэтому
# ресурсы именно КОПИРУЮТСЯ, а не берутся с диска из assets/.
stage: $(STAGED)
$(BUILD_DIR)/%: $(PACKED)/%
@mkdir -p $(dir $@)
@cp $< $@
# ---------------------------------------------------------------------------
# Ресурсы: цепочка зависимостей
# ---------------------------------------------------------------------------
# Направление одно и сквозное:
#
# build/sprpop.exe -> gen/*.h -> архивы и атласы -> упаковщик -> assets/orig/
#
# Код зависит от генерируемых заголовков, заголовки — от ресурсов, которые они
# описывают (индексы внутри .arc, число страниц звука, таблицы кадров), а те —
# от упаковщика и оригиналов. Поэтому собрать код против устаревших констант
# нельзя: gen/*.h делаются ТЕМ ЖЕ запуском упаковщика, что и сам ресурс, и
# объявлены его целями наравне с ним.
#
# Правила генерации живут в ветке GENERATE=1 и включаются в двух случаях:
# * явная цель `make resources*` — перегенерация с учётом таймстемпов
# (поправил упаковщик — ресурс пересоберётся);
# * какого-то ресурса или заголовка НЕТ на месте (после `make distclean`,
# либо файл снесли руками) — тогда обычный `make` сам уходит в
# рекурсивный вызов и доделывает недостающее вместе с его gen/*.h.
#
# Почему не одни таймстемпы на все случаи: assets/packed/ и gen/ лежат в
# репозитории, а git не сохраняет времена файлов — в свежем клоне упаковщик
# и его выхлоп получают одинаковую метку, и любое сравнение по времени
# превращается в лотерею «перепаковать 6 МБ ресурсов на ровном месте, причём
# на машине, где assets/orig/ может вообще не быть». Отсутствие файла —
# признак однозначный, и именно на нём построена автоматика.
TOOLS := $(CURDIR)/tools
PY := python3
ifeq ($(GENERATE),1)
# ===========================================================================
# Ветка генерации
# ===========================================================================
STAMP := $(BUILD_DIR)/.stamps
$(STAMP):
@mkdir -p $@
# Упаковщики создают сразу ГРУППУ файлов. Обычное multi-output правило
# заставляет make при `-B` запускать один и тот же упаковщик отдельно для
# каждого выходного файла. Stamp представляет ОДИН запуск генератора; все
# созданные им файлы зависят от stamp без собственных рецептов.
#
# check-orig — order-only: без оригиналов запускать упаковщик бессмысленно, и
# понятное сообщение лучше трейсбека Python. На «устарел ли stamp» он не
# влияет, только выполняется раньше.
define stamp_rule
$(STAMP)/$(1): $(2) | $(STAMP) check-orig
cd $(TOOLS) && $(PY) $(3)
@touch $$@
endef
$(eval $(call stamp_rule,bg,$(TOOLS)/pop_pack_bg.py $(TOOLS)/render_room.py $(TOOLS)/paths.py,pop_pack_bg.py dungeon && $(PY) pop_pack_bg.py palace))
$(eval $(call stamp_rule,kid,$(TOOLS)/pop_pack_kid.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/paths.py,pop_pack_kid.py))
$(eval $(call stamp_rule,kid-bin,$(TOOLS)/pop_extract_kid_data.py $(TOOLS)/paths.py,pop_extract_kid_data.py))
$(eval $(call stamp_rule,guard,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py))
$(eval $(call stamp_rule,skel,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py SKEL))
$(eval $(call stamp_rule,vizier,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py VIZIER))
$(eval $(call stamp_rule,shadow,$(TOOLS)/pop_pack_shadow.py $(TOOLS)/pop_pack_bg.py $(TOOLS)/pop_pack_kid.py $(TOOLS)/paths.py,pop_pack_shadow.py))
$(eval $(call stamp_rule,sound,$(TOOLS)/pop_pack_sound.py $(TOOLS)/paths.py,pop_pack_sound.py))
$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/paths.py,pop_pack_music.py --format $(MUSIC_FMT) $(MUS_IDS)))
$(eval $(call stamp_rule,font,$(TOOLS)/pop_extract_font.py $(TOOLS)/paths.py,pop_extract_font.py))
$(eval $(call stamp_rule,title,$(TOOLS)/pop_pack_title.py $(TOOLS)/paths.py,pop_pack_title.py))
$(eval $(call stamp_rule,pv,$(TOOLS)/pop_pack_intro.py $(TOOLS)/paths.py,pop_pack_intro.py))
# Файл, сделанный упаковщиком, зависит только от stamp. Если stamp свежий, а
# файла нет (снесли руками, либо build/ вычистили) — перезапускаем генератор
# принудительно. Заголовки gen/, которые упаковщик печатает попутно, стоят в
# ТЕХ ЖЕ списках: это и есть «gen зависит от ресурсов, для которых сделан».
define from_stamp
$(1): $(STAMP)/$(2)
@test -f $$@ || { $(MAKE) GENERATE=1 -B $(STAMP)/$(2); test -f $$@; }
endef
$(eval $(call from_stamp,$(BG_ATL) $(ATL)/bg/pop_tile.pal $(ATL)/bg/pal_tile.pal gen/pop_bg_atlas.h,bg))
$(eval $(call from_stamp,$(KID_ATL) $(ATL)/kid/kid.pal,kid))
$(eval $(call from_stamp,$(ATL)/kid/kid.ani gen/kid_data.h,kid-bin))
$(eval $(call from_stamp,$(GUARD_ATL) gen/pop_guard_pal.h,guard))
$(eval $(call from_stamp,$(SKEL_ATL),skel))
$(eval $(call from_stamp,$(VIZIER_ATL),vizier))
$(eval $(call from_stamp,$(SHADOW_ATL) gen/pop_shadow_atlas.h,shadow))
$(eval $(call from_stamp,$(SND_ATL) gen/pop_sound_tbl.h,sound))
$(eval $(call from_stamp,$(foreach i,$(MUS_IDS),$(ATL)/music/m$(i).bin) gen/pop_music_tbl.h gen/pop_music_ticks.h,music-$(MUSIC_FMT)))
$(eval $(call from_stamp,$(ATL)/font/font.atl gen/pop_font.h,font))
$(eval $(call from_stamp,$(TITLE_ATL) $(ATL)/title/title.pal,title))
$(eval $(call from_stamp,$(PV_ATL) $(ATL)/pv/story.pal $(ATL)/pv/pv.pal,pv))
# Архивы: собираются ПРЯМО в assets/packed — это уже дисковый артефакт.
# Заголовок с индексами объявлен целью НАРАВНЕ с архивом: индексы описывают
# именно это содержимое и разъехаться с ним не должны.
ARC := $(PY) $(TOOLS)/pop_pack_arc.py
define arc_rule
$(PACKED)/$(1)/$(2).arc gen/$(3)_arc.h: $(4) $(TOOLS)/pop_pack_arc.py
@mkdir -p $(PACKED)/$(1)
$(ARC) --header gen/$(3)_arc.h --prefix $(5) $(PACKED)/$(1)/$(2).arc $(4)
endef
$(eval $(call arc_rule,BG,bg,bg,$(BG_ATL),BG))
$(eval $(call arc_rule,KID,kid,kid,$(KID_ATL),KID))
$(eval $(call arc_rule,GUARD,guard,guard,$(GUARD_ATL),GUARD))
$(eval $(call arc_rule,SKEL,skel,skel,$(SKEL_ATL),SKEL))
$(eval $(call arc_rule,VIZIER,vizier,vizier,$(VIZIER_ATL),VIZIER))
$(eval $(call arc_rule,SHADOW,shadow,shadow,$(SHADOW_ATL),SHADOW))
$(eval $(call arc_rule,TITLE,title,title,$(TITLE_ATL),TITLE))
$(eval $(call arc_rule,PV,pv,pv,$(PV_ATL),PV))
# У звука своего *_arc.h нет: содержимое адресуется таблицей pop_sound_tbl.h,
# которую печатает сам упаковщик звука.
$(PACKED)/SND/snd.arc: $(SND_ATL) $(TOOLS)/pop_pack_arc.py
@mkdir -p $(dir $@)
$(ARC) $@ $(SND_ATL)
# Файлы, которые ложатся на диск как есть, — простое копирование из staging.
define copy_rule
$(PACKED)/$(1): $(2)
@mkdir -p $$(dir $$@)
@cp $$< $$@
endef
$(eval $(call copy_rule,BG/pop_tile.pal,$(ATL)/bg/pop_tile.pal))
$(eval $(call copy_rule,BG/pal_tile.pal,$(ATL)/bg/pal_tile.pal))
$(eval $(call copy_rule,KID/kid.pal,$(ATL)/kid/kid.pal))
$(eval $(call copy_rule,KID/kid.ani,$(ATL)/kid/kid.ani))
$(eval $(call copy_rule,FONT/font.atl,$(ATL)/font/font.atl))
$(eval $(call copy_rule,TITLE/title.pal,$(ATL)/title/title.pal))
$(eval $(call copy_rule,PV/story.pal,$(ATL)/pv/story.pal))
$(eval $(call copy_rule,PV/pv.pal,$(ATL)/pv/pv.pal))
$(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i).bin)))
# Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик
# читает как есть, упаковщика для них нет. check-orig здесь тоже order-only —
# без оригиналов взять уровни неоткуда, и сказать об этом надо внятно.
LVL_ORIG := assets/orig/SDLPoP/data/LEVELS
define lvl_rule
$(PACKED)/LEVELS/res20$(1).bin: $(LVL_ORIG)/res20$(1).bin | check-orig
@mkdir -p $$(dir $$@)
@cp $$< $$@
endef
$(foreach n,$(LVL_NUMS),$(eval $(call lvl_rule,$(n))))
# README: перевод строки CRLF (текст читают в DOS-подобной среде) и, для
# русского, перекодировка в CP866. iconv БЕЗ -c намеренно: потеря символа
# должна ломать сборку, а не молча портить текст (memory
# iconv_cp866_workflow; //TRANSLIT на macOS падает).
$(PACKED)/README_E.TXT: dist/README.txt
@mkdir -p $(dir $@)
@sed 's/$$/\r/' $< > $@
# ПОРЯДОК ВАЖЕН: CRLF ставится ДО перекодировки. BSD sed в UTF-8 локали
# отказывается работать с байтами CP866 («RE error: illegal byte sequence»),
# а с валидным UTF-8 — работает.
$(PACKED)/README_R.TXT: dist/README.ru.txt
@mkdir -p $(dir $@)
@sed 's/$$/\r/' $< | iconv -f UTF-8 -t CP866 > $@
resources-readme: $(PACKED)/README_E.TXT $(PACKED)/README_R.TXT
resources-bg: $(PACKED)/BG/bg.arc $(PACKED)/BG/pop_tile.pal $(PACKED)/BG/pal_tile.pal
resources-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid.ani
resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \
$(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc
resources-sound: $(PACKED)/SND/snd.arc
resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin)
resources-font: $(PACKED)/FONT/font.atl
resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal
resources-pv: $(PACKED)/PV/pv.arc $(PACKED)/PV/story.pal $(PACKED)/PV/pv.pal
resources-levels: $(foreach n,$(LVL_NUMS),$(PACKED)/LEVELS/res20$(n).bin)
resources: resources-bg resources-kid resources-actors resources-sound \
resources-music resources-font resources-title resources-pv \
resources-levels $(GEN_H)
check-orig:
@test -d $(LVL_ORIG) || { \
echo "SprPoP: нет assets/orig/ — генерировать ресурсы не из чего." >&2; \
echo " Что и откуда положить, написано в assets/orig/README.md" >&2; false; }
else
# ===========================================================================
# Обычная сборка: assets/packed/ и gen/ — готовые входы
# ===========================================================================
# Ни одного правила с таймстемпами: пока файлы на месте, make их даже не
# рассматривает. Пропал файл — доделываем ровно его, рекурсивным вызовом в
# ветку генерации (она подтянет и заголовок, и оригиналы).
$(PACKED_FILES) $(GEN_H):
@echo "SprPoP: нет $@ — генерирую ресурсы"
@$(MAKE) GENERATE=1 $@
RES_TARGETS := resources resources-readme resources-bg resources-kid \
resources-actors resources-sound resources-music resources-font \
resources-title resources-pv resources-levels
$(RES_TARGETS):
@$(MAKE) GENERATE=1 $@
# Перегенерация ВСЕГО, не глядя на таймстемпы.
resources-rebuild:
@$(MAKE) GENERATE=1 -B resources
.PHONY: $(RES_TARGETS) resources-rebuild
endif
# ---------------------------------------------------------------------------
# Прочее
# ---------------------------------------------------------------------------
# Образ HDD собирается после staging — иначе на диск уедет старое содержимое.
# Рецепта здесь нет намеренно: он в app.mk, а это только зависимости.
hdd: stage | $(BUILD_DIR)/hdd
$(BUILD_DIR)/hdd:
@mkdir -p $@
# Однократная связка с MAME: run_bridge.sh жёстко открывает IMG/test_hdd.chd,
# поэтому подсовываем ему символьную ссылку на наш образ. chdman переписывает
# сам файл, ссылка остаётся живой — но MAME после пересборки образа ОБЯЗАН
# полный рестарт (новый inode).
mame-link: hdd
ln -sf $(HDD_IMG) $(PROJ_ROOT)/mame/v306/IMG/test_hdd.chd
@echo "MAME: IMG/test_hdd.chd -> $(HDD_IMG)"
# Музыка из конкретного набора записей. Каждая цель пересобирает музыку и
# сразу раскладывает её в build/, чтобы `make hdd` взял уже новую.
# make music-mp3 собрать музыку из mp3
# make music-mt32 вариант Roland MT-32
# Разовая сборка без смены умолчания: make MUSIC_FMT=ogg resources-music
MUSIC_TARGETS := music-flac music-mp3 music-ogg music-mt32
$(MUSIC_TARGETS):
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) resources-music
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) stage
# Перелинковка приложения без пересборки библиотек и ресурсов.
relink:
$(MAKE) -W Makefile $(EXE)
# clean (из app.mk) сносит только build/ — ассеты остаются на месте.
# distclean дополнительно убирает УПАКОВАННЫЕ ассеты; вернуть их можно
# командой `make resources`, но для неё нужны оригиналы в assets/orig/.
# Сам assets/orig/ не трогается НИКОГДА: он не производный, а исходный, и
# восстановить его командой нельзя — только положить руками.
# gen/ тоже остаётся: без него не скомпилировать src/, а вес его — байты.
distclean: clean
rm -rf $(PACKED)
@echo "assets/packed снесён; вернуть — make resources (нужен assets/orig)"
# Цели генерации ресурсов объявлены .PHONY в своей ветке (см. выше): в
# GENERATE=1 они настоящие агрегаты, в обычной сборке — переходники.
.PHONY: stage check-orig resources resources-rebuild resources-bg resources-kid \
resources-actors resources-sound resources-music resources-font \
resources-title resources-pv resources-levels $(MUSIC_TARGETS) \
relink distclean mame-link
+64
View File
@@ -0,0 +1,64 @@
# SprPoP — Prince of Persia на Sprinter Sp2000
Порт оригинального Prince of Persia (1989) на компьютер Sprinter Sp2000:
режим 320×256×256, дабл-буфер, звук через CBL, код разложен по одиннадцати
16-килобайтным банкам.
Приложение **автономно** — код, оригинальные данные, конверторы ресурсов и
сборка лежат внутри этой папки. Наружу нужен только компилятор:
```sh
export SPRINTER_ROOT=/путь/к/C-Compiler # не нужен, если папка лежит внутри его дерева
make # build/sprpop.exe + ресурсы в build/
make hdd # + образ build/hdd/sprpop.chd
```
## Что где
```
src/ рукописный C: главный цикл sprpop.c + движок pop_*.c
gen/ генерируемые заголовки — руками не править
assets/orig/ оригинальные данные игры (вне репозитория, см. там README)
assets/packed/ то, что ложится на диск игры, в раскладке диска
tools/ конверторы ресурсов; все пути — в tools/paths.py
docs/ планы, доски задач и багов, справочники (начинать с docs/README.md)
tests/host/ модульные тесты движка под ucsim_z80
build/ выход: sprpop.exe, каталоги ресурсов, hdd/, промежуточные atl/
```
## Цели сборки
| Команда | Что делает |
|---|---|
| `make` | собрать `build/sprpop.exe` и разложить ресурсы в `build/` |
| `make hdd` | + образ жёсткого диска `build/hdd/sprpop.chd` |
| `make mame-link` | однократно подставить образ в MAME (символьная ссылка на `IMG/test_hdd.chd`) |
| `make resources` | перегенерировать ресурсы из `assets/orig/` |
| `make music-mp3` | музыка из другого набора записей: `music-flac` (умолчание), `music-mp3`, `music-ogg`, `music-mt32` |
| `make -C tests/host` | модульные тесты движка (секунды, без эмулятора) |
| `make clean` | снести `build/` (ассеты не трогает) |
| `make distclean` | `clean` + снести упакованные ассеты `assets/packed/` |
Отладочные ключи: `make LEVEL=9` — стартовый уровень, `make ROOM=15 POS=2`
стартовая комната и тайл (`row*10+col`), `make PROF=1` — профиль фаз кадра
полосами бордюра, `make ALLOCS=100000` — плотная упаковка регистров для
замеров размера.
## Сборка из чистого клона
Работает без `assets/orig/`: упакованные ресурсы (`assets/packed/`) и
генерируемые заголовки (`gen/`) лежат в репозитории. Исключение —
`assets/packed/LEVELS/`: файлы уровней ложатся на диск прямо из оригинала,
без конвертации, поэтому они, как и `assets/orig/`, вне репозитория. Что и
откуда положить — `assets/orig/README.md`.
`assets/orig/` нужен только чтобы ресурсы **перегенерировать** (`make
resources`) и как **эталон механики**: любая правка поведения сверяется с
`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`.
## Происхождение
Выделен из `applications/PoP/roomtest` 2026-08-26; та папка заморожена и
служит архивом закрытых задач, багов и исполненных планов. Референсные
реализации (Apple II 6502, Princed Resources, mininim) остались в
`applications/PoP/`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More