Compare commits

...

20 Commits

Author SHA1 Message Date
snark13 df5071a967 SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть I плана music_runtime_index_plan.md (MI0..MI5).  gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools).  Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.

ГДЕ ЖИВЁТ ИНДЕКС.  228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта.  Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21.  Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c).  Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.

ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ.  pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит».  Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9).  Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4).  Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.

PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились.  План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.

ПАМЯТЬ.  За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б.  Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.

ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.

НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре.  Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 17:17:59 +03:00
snark13 2349481b86 SprPoP: звуковые эффекты без перелинковки — раскладка уехала на диск
Часть II плана music_runtime_index_plan.md (SI0..SI4).  gen/pop_sound_tbl.h
БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат
PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools).
Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS
(10) — sha256 бинарника при смене набора не меняется.

Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop):
сборка обязана работать без оригинального дистрибутива DOS.  У кого он
есть, включает лучший набор явно — make SND_SRC=msdos (там полнее
оцифровка: в SDLPoP звук 48 spiked пустой).

Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные
(pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h.  Записи
читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5
стало частью дискового контракта: проверяется статически и полем размера
записи в заголовке.  POP_SND_PAGES как compile-time размер набора исчез —
вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages.

Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не
изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию.

Валидация не доверяет файлу: заголовок целиком плюс каждая запись
(страница, смещение, кратность блоку, непересечение с блоком тишины,
выход за последнюю страницу).  Последнее считается В БЛОКАХ — байтовый
адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога.

НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ.  Первая версия просто
возвращала ошибку, и игра становилась непроходимой: тишину льёт первый
блок набора, без набора CBL не открывался, а с ним вставала музыка (её
блоки считает тот же насос) — заставка ждала конца трека вечно.  Теперь
поднимается пустой набор с блоком тишины.  Заливается ровно 128 байт и
под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей
страницы затирала их — первое же прерывание давало чёрный экран.

Грабли сборки: смена SND_SRC тихо давала неверный результат
(sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed).  Причина
не в логике, а в секундной гранулярности mtime.  Лечение убирает время из
решения: смена варианта сносит stamp'ы своего семейства, а упаковка,
сборка архива и копия индекса делаются одним рецептом.  То же получила и
музыка (MUSIC_FMT).

Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово;
один EXE поднимает оба набора; отладочный --order reverse (30 из 31
записей отличаются от штатных) звучит правильно; битый индекс выключает
эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S
работает в обоих режимах.  Разбор — docs/sound_plan.md §10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:53 +03:00
snark13 589894c50d SprPoP: автономность — внешние данные качаются, а не хранятся
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой.  Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.

Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева.  По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.

MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.

У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/).  Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались.  Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.

assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:12 +03:00
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
91 changed files with 4271 additions and 1189 deletions
+18
View File
@@ -9,3 +9,21 @@ args = [
"mcp<2", "mcp<2",
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py", "/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"
+6 -12
View File
@@ -132,15 +132,9 @@ applications/PoP/PoP1_DOS_music/
# не идёт (сама roomtest и есть версируемая ветка разработки). # не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/ applications/PoP/R1/
# SprPoP — автономное приложение. В репозиторий идут исходники, генерируемые # SprPoP — автономное приложение, и правила игнора у него СВОИ:
# заголовки (gen/) и упакованные ресурсы (assets/packed/), потому что без них # applications/SprPoP/.gitignore. Он написан так, чтобы стать корневым
# из чистого клона ничего не собрать. НЕ идут: # .gitignore, когда SprPoP выделят в отдельный репозиторий, — поэтому
# assets/orig/ оригинальные данные игры — копирайт (SDLPoP, MSDOS, # здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
# записи музыки DOS-версии); откуда их взять, # репозиторий не идут assets/orig/ (чужие данные — их выкачивает
# написано в assets/orig/README.md # `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
# assets/packed/LEVELS/ уровни res20xx.bin ложатся на диск прямо из
# оригинала, конвертации нет — то есть это тот же
# копирайтный материал, только в раскладке диска
# build/ уже покрыт общим правилом build/ выше
applications/SprPoP/assets/orig/
applications/SprPoP/assets/packed/LEVELS/
+15 -2
View File
@@ -13,6 +13,7 @@
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir # # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc # # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
# # EXTRA_DATA := test.txt # extra files to add to `make floppy` # # EXTRA_DATA := test.txt # extra files to add to `make floppy`
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
# #
# include $(PROJ_ROOT)/app.mk # include $(PROJ_ROOT)/app.mk
# #
@@ -40,7 +41,7 @@ FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
# образ у себя и связывает его с MAME символьной ссылкой. # образ у себя и связывает его с MAME символьной ссылкой.
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
MAKE_DISK := $(MAME_DIR)/make_disk.py 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 RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file. # Optional knobs — see top of file.
@@ -67,6 +68,9 @@ SOURCES := $(MAIN_SRC) $(EXTRA_SRCS)
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со # Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
# своей раскладкой каталогов может переопределить переменную до include. # своей раскладкой каталогов может переопределить переменную до include.
HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA) HDD_PACK_ARGS ?= $(EXE) $(EXTRA_DATA)
# Общий каталог назначения внутри HDD. Пустое значение сохраняет прежнюю
# укладку в корень; вложенные КАТАЛОГ:файл считаются относительно него.
HDD_DEST_DIR ?=
CC_FLAGS := --memory $(MEMORY) CC_FLAGS := --memory $(MEMORY)
ifneq ($(STACK_SIZE),) ifneq ($(STACK_SIZE),)
@@ -81,9 +85,18 @@ all: $(EXE)
# уже собранный exe (кусало: фикс bank.s не подхватился). # уже собранный exe (кусало: фикс bank.s не подхватился).
RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s) RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# ПРОВЕРКА БАНКОВЫХ ВЫЗОВОВ — сразу после линковки, пока артефакты свежие.
# Ловит прямой `call` в чужой банк: он собирается МОЛЧА и стреляет диким
# переходом в пустой хвост банка (разбор — в шапке скрипта). Запускается
# только если банки вообще есть, то есть по наличию каталога сборки с
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS) $(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@)) $(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) $(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): $(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe $(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
@@ -116,7 +129,7 @@ run: floppy
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа # используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory). # MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
hdd: $(EXE) hdd: $(EXE)
$(MAKE_HDD) $(HDD_IMG) $(HDD_PACK_ARGS) $(MAKE_HDD) $(if $(strip $(HDD_DEST_DIR)),--dest "$(HDD_DEST_DIR)") $(HDD_IMG) $(HDD_PACK_ARGS)
@echo @echo
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))" @echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран." @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.
+112
View File
@@ -3630,3 +3630,115 @@ public `_pop_mus_page` по смещениям из `.sym`):
брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр. 20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`. Прежде чем толковать значения, проверять `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`.
+3 -1
View File
@@ -3,6 +3,8 @@
PROJ_ROOT := $(abspath $(CURDIR)/../../..) PROJ_ROOT := $(abspath $(CURDIR)/../../..)
EXAMPLE := roomtest EXAMPLE := roomtest
HDD_IMG := $(CURDIR)/build/hdd/roomtest.chd
# small: W1+W2 как единое 32КБ-пространство, DATA сразу за CODE (иначе # small: W1+W2 как единое 32КБ-пространство, DATA сразу за CODE (иначе
# CODE ~23КБ переполняет W1 и лезет в фиксированный DATA@0x8000 в huge). # CODE ~23КБ переполняет W1 и лезет в фиксированный DATA@0x8000 в huge).
MEMORY ?= huge # small-раскладка + банки кода в W3 (стражи) MEMORY ?= huge # small-раскладка + банки кода в W3 (стражи)
@@ -88,7 +90,7 @@ EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) \
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает. # умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте # Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
# MAME = время фазы в растровых строках. # MAME = время фазы в растровых строках.
PROF ?= 1 PROF ?= 0
PROF_FLAGS := -DPROF_BORDER=$(PROF) PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого. # Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого.
# Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1. # Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1.
-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`).
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 - кнопка ломается (превращается в щебень)
/// но двери которые она должна открыть остаются закрытыми
+75
View File
@@ -0,0 +1,75 @@
# ===========================================================================
# SprPoP — .gitignore приложения
# ===========================================================================
# Пока SprPoP лежит внутри репозитория C-Compiler, git читает этот файл как
# правила ДЛЯ ЭТОГО ПОДДЕРЕВА, и он дополняет корневой .gitignore тулчейна.
# Когда SprPoP выделят в самостоятельный проект, файл станет КОРНЕВЫМ и
# продолжит работать как есть: все пути написаны от корня SprPoP и ничего
# не знают ни про applications/, ни про раскладку тулчейна.
# ---------------------------------------------------------------------------
# ЧУЖИЕ ДАННЫЕ — в репозиторий не идут никогда
# ---------------------------------------------------------------------------
# assets/orig/ — оригинальные данные игры (копирайт). В репозитории вместо
# них лежит ЗНАНИЕ, откуда их взять: tools/fetch_orig.py (`make fetch`)
# качает SDLPoP и записи саундтрека, MSDOS/ пользователь кладёт сам (он
# необязателен). Скачанные архивы кэшируются в assets/orig/.cache/.
#
# Шаблон со ЗВЁЗДОЧКОЙ, а не `assets/orig/`: git не заходит внутрь
# игнорируемого каталога, и исключение ниже иначе не сработало бы, а
# README.md — единственное, что отсюда ОБЯЗАНО быть в репозитории: без
# него в чистом клоне не написано, откуда брать данные.
assets/orig/*
!assets/orig/README.md
# Уровни res20xx.bin ложатся на диск ПРЯМО из оригинала, конвертации нет —
# то есть это тот же копирайтный материал, только в раскладке диска.
# Остальное содержимое assets/packed/ версионируется намеренно: без него из
# чистого клона не собрать игру, а исходников (assets/orig/) может не быть.
assets/packed/LEVELS/
# ---------------------------------------------------------------------------
# ВЫХОД СБОРКИ
# ---------------------------------------------------------------------------
# build/ — .exe, раскладка диска, hdd/, промежуточные атласы, stamp'ы.
# Правило без слэша спереди намеренно: под ним и tests/host/build/.
build/
# Промежуточный каталог sprinter-cc.
.sprinter-cc-*/
# Артефакты компиляции, которые SDCC кладёт рядом с целью — в корень
# приложения. Только корень: единственный версионируемый ассемблерный
# файл (tests/host/bank_stub.s) лежит глубже и под правило не попадает.
/*.exe
/*.asm
/*.lst
/*.lk
/*.ihx
/*.noi
/*.sym
/*.map
/*.rel
/*.cdb
/*.mem
/*.rst
# Хостовые тесты и утилиты собираются нативным cc.
*.o
*.obj
*.dSYM/
# ---------------------------------------------------------------------------
# ОС / редакторы / локальные настройки
# ---------------------------------------------------------------------------
.DS_Store
._*
*~
*.swp
*.bak
*.orig
*.rej
.vscode/
.idea/
.kilo/
.claude/
+42 -4
View File
@@ -17,8 +17,9 @@ sprinter-cc / libc / libbgi. Действуют правила корневог
## Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь ## Главное правило: SDLPoP — источник истины. Сначала читай, потом кодь
**`assets/orig/SDLPoP/src/` (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ **`assets/orig/SDLPoP/src/` (github.com/NagyD/SDLPoP, GPLv3) — ЕДИНСТВЕННЫЙ
авторитетный источник того, как оригинальный движок это делает.** Правило авторитетный источник того, как оригинальный движок это делает.** В
без исключений: репозитории его нет: заполняется `make fetch` (`make fetch-check` — что уже
на месте). Правило без исключений:
1. **Перед реализацией ЛЮБОЙ функции** (движение, коллизия, окклюзия, 1. **Перед реализацией ЛЮБОЙ функции** (движение, коллизия, окклюзия,
падение, loose-полы, стражники, отрисовка, тайминги, любые числовые падение, loose-полы, стражники, отрисовка, тайминги, любые числовые
@@ -69,6 +70,8 @@ make собрать build/sprpop.exe и разложить build
make hdd + образ build/hdd/sprpop.chd make hdd + образ build/hdd/sprpop.chd
make mame-link однократно: подставить образ в MAME (см. ниже) make mame-link однократно: подставить образ в MAME (см. ниже)
make -C tests/host модульные тесты движка под ucsim_z80 (секунды, без MAME) make -C tests/host модульные тесты движка под ucsim_z80 (секунды, без MAME)
make test-tools тесты упаковщиков на хосте (форматы, которые читает Z80)
make fetch скачать внешние данные в assets/orig/ (SDLPoP + музыка)
make resources перегенерировать ресурсы из assets/orig/ make resources перегенерировать ресурсы из assets/orig/
make music-mp3 музыка из другого набора (flac|mp3|ogg|mt32) make music-mp3 музыка из другого набора (flac|mp3|ogg|mt32)
make clean снести build/ (ассеты не трогает) make clean снести build/ (ассеты не трогает)
@@ -93,13 +96,19 @@ make distclean clean + снести assets/packed/ (вернуть — mak
|---|---| |---|---|
| `src/` | рукописный C: главный цикл `sprpop.c`, холодная половина `sprpop_cold.c`, движок `pop_*.c` | | `src/` | рукописный C: главный цикл `sprpop.c`, холодная половина `sprpop_cold.c`, движок `pop_*.c` |
| `gen/` | генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). **Руками не править** — их печатают упаковщики; лежат в репозитории, потому что без них `src/` не собрать | | `gen/` | генерируемые заголовки (индексы архивов, таблицы кадров, шрифт, палитры). **Руками не править** — их печатают упаковщики; лежат в репозитории, потому что без них `src/` не собрать |
| `assets/orig/` | оригинальные данные (SDLPoP, MSDOS, записи музыки). Вне репозитория — копирайт; что и откуда взять, написано в `assets/orig/README.md` | | `assets/orig/` | оригинальные данные (SDLPoP, записи музыки; MSDOS — необязательно). Вне репозитория — копирайт; вместо них в репозитории лежит `tools/fetch_orig.py`, который их качает (`make fetch`). Правила — в `assets/orig/README.md` |
| `assets/packed/` | то, что ложится на диск игры, уже в раскладке диска (`BG/`, `KID/`, …). В репозитории — иначе из чистого клона не собрать | | `assets/packed/` | то, что ложится на диск игры, уже в раскладке диска (`BG/`, `KID/`, …). В репозитории — иначе из чистого клона не собрать |
| `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках | | `tools/` | конверторы ресурсов. Раскладку путей знает ОДИН файл — `tools/paths.py`; менять пути нужно там, а не в отдельных упаковщиках. Адреса ВНЕШНИХ источников — так же в одном: `tools/fetch_orig.py` |
| `docs/` | планы, доски, справочники; `docs/PoP/` — форматы ресурсов оригинала | | `docs/` | планы, доски, справочники; `docs/PoP/` — форматы ресурсов оригинала |
| `tests/host/` | модульные тесты движка под ucsim_z80 | | `tests/host/` | модульные тесты движка под ucsim_z80 |
| `build/` | выход: `sprpop.exe`, каталоги ресурсов, `hdd/`, промежуточные `atl/` | | `build/` | выход: `sprpop.exe`, каталоги ресурсов, `hdd/`, промежуточные `atl/` |
Звуковые ЭФФЕКТЫ по умолчанию берутся из SDLPoP (`SND_SRC=sdlpop`) — сборка
обязана работать без оригинального дистрибутива DOS. У кого лежит
`assets/orig/MSDOS/`, включает его набор явно (`make SND_SRC=msdos`): там
оцифровка полнее — в SDLPoP пуст звук 48 `spiked` (насаживание на пики).
Разбор — `docs/sound_plan.md`.
Музыка собирается из одного из четырёх наборов записей Музыка собирается из одного из четырёх наборов записей
(`assets/orig/PoP1_DOS_music/`): `flac` по умолчанию, плюс `mp3`, `ogg` и (`assets/orig/PoP1_DOS_music/`): `flac` по умолчанию, плюс `mp3`, `ogg` и
`mt32` (исполнение Roland MT-32 — звучит иначе, длина треков другая). Набор `mt32` (исполнение Roland MT-32 — звучит иначе, длина треков другая). Набор
@@ -153,8 +162,37 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
| `pop_kid.c/.h` | Анимация/движение: интерпретатор seqtbl `play_seq` + frame_table (`seg006`), окна `Char` (loadkid/loadshad), атласы Кида. | | `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_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_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх `<kbd_raw.h>`. |
| `pop_sfx.c` + `pop_sfx_cold.c` | Звук: насос CBL в резиденте + холодная загрузка набора. Раскладка эффектов НЕ компилируется в EXE — читается с диска (`SND/snd.idx`, формат `PSI1`); см. `docs/sound_plan.md` §10. |
| `pop_music.c` | Музыка (банк 9): загрузка треков, кольцо для финальной темы. Длины и длительности — из `MUS/mus.idx` (EMM-страница + accessor `pop_music_info`), в EXE их нет; см. `docs/sound_plan.md` §11. |
| `pop_snd_tbl.h` + `pop_snd_data.c` | Тип записи набора и инварианты (рукописный заголовок) + резидентные `pop_snd_tbl`/`pop_snd_page`/`pop_snd_pages`. Размер записи 5 байт — часть дискового контракта, проверяется статически. |
| `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). | | `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` (стереть `sprpop.c` каждый тик рисует в СКРЫТУЮ страницу: `pop_char_heal` (стереть
+136 -28
View File
@@ -91,7 +91,7 @@ BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_app.c pop_timer.c \
# стоит 51 мс, так что трамплин банк→банк на их фоне не существует. # стоит 51 мс, так что трамплин банк→банк на их фоне не существует.
# Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на # Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на
# 485 байт, то есть 3% страницы. # 485 байт, то есть 3% страницы.
BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c BANK10_SRCS := pop_pal.c pop_config.c pop_hof.c pop_path_bank.c
BANK11_SRCS := pop_cutscene.c pop_intro.c BANK11_SRCS := pop_cutscene.c pop_intro.c
BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11 BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11
BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS)) BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS))
@@ -100,6 +100,7 @@ BANK_FLAGS := $(foreach n,$(BANK_NUMS),\
# Модули резидента (кроме sprpop.c — его подставляет app.mk). # Модули резидента (кроме sprpop.c — его подставляет app.mk).
EXTRA_SRCS := $(addprefix $(SRC_DIR)/,pop_vflip.c pop_pace.c pop_sfx.c pop_state.c \ EXTRA_SRCS := $(addprefix $(SRC_DIR)/,pop_vflip.c pop_pace.c pop_sfx.c pop_state.c \
pop_snd_data.c \
pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c \ pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c \
pop_qsave_io.c) pop_qsave_io.c)
@@ -166,7 +167,10 @@ SHADOW_ATL := $(foreach n,$(KID_N),$(ATL)/shadow/sk$(n).atl) \
# Звук: оригинальная оцифровка на частоте CBL (10 937,5 Гц, 8 бит моно). # Звук: оригинальная оцифровка на частоте CBL (10 937,5 Гц, 8 бит моно).
# Число файлов задаёт упаковщик (POP_SND_PAGES в gen/pop_sound_tbl.h) — # Число файлов задаёт упаковщик (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) # Страниц звука СТАТИЧЕСКИ НЕ ПОСЧИТАТЬ: их число зависит от источника
# (SDLPoP даёт 9, MSDOS — 10, см. SND_SRC). Поэтому списка s0..sN здесь нет,
# а архив зависит от stamp'а упаковщика и берёт страницы из каталога уже
# после того, как тот отработал (см. правило SND/snd.arc ниже).
TITLE_ATL := $(foreach s,0 1 2 3,$(foreach p,0 1 2 3 4,$(ATL)/title/t$(s)_$(p).atl)) 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 есть ещё # PV/INTRO: story и стадии режутся на полосы; у настоящего PV intro есть ещё
# чистый фон b0_* и шесть <=16K actor-атласов — они остаются в EMM на время # чистый фон b0_* и шесть <=16K actor-атласов — они остаются в EMM на время
@@ -189,6 +193,15 @@ MUS_IDS := 24 25 26 27 28 29 30 32 33 35 36 37 39 40 41 43 50 52 53 54 55 56
# Формат входит в имя stamp'а, поэтому смена MUSIC_FMT сама по себе делает # Формат входит в имя stamp'а, поэтому смена MUSIC_FMT сама по себе делает
# музыку устаревшей — принудительная пересборка не нужна. # музыку устаревшей — принудительная пересборка не нужна.
MUSIC_FMT ?= flac MUSIC_FMT ?= flac
# Источник оцифрованных ЭФФЕКТОВ (digisnd1..3): sdlpop (умолчание) или msdos.
# Умолчание — SDLPoP, потому что он выкачивается автоматически (`make fetch`)
# и сборка обязана работать вообще без оригинального дистрибутива DOS.
# Наборы НЕ одинаковы: в MSDOS оцифровка полнее (в SDLPoP `spiked` (48)
# фактически пустой, `sword_vs_sword` (10) короче — разбор в
# docs/sound_plan.md). У кого дистрибутив есть — `make SND_SRC=msdos`.
# Источник входит в имя stamp'а, поэтому смена SND_SRC сама делает звук
# устаревшим, как и смена MUSIC_FMT.
SND_SRC ?= sdlpop
# res2000 — attract-demo; res2001..res2014 — штатная игра. res2015 (copy # res2000 — attract-demo; res2001..res2014 — штатная игра. res2015 (copy
# protection) намеренно не пакуется и не существует в маршруте загрузчика. # protection) намеренно не пакуется и не существует в маршруте загрузчика.
LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
@@ -200,15 +213,17 @@ LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
# три секунды. Четыре архива актёров, а не один: имена внутри наборов # три секунды. Четыре архива актёров, а не один: имена внутри наборов
# совпадают (g0.atl есть и у стража, и у скелета, и у Джафара), а константы # совпадают (g0.atl есть и у стража, и у скелета, и у Джафара), а константы
# индексов упаковщик делает из имени файла. # индексов упаковщик делает из имени файла.
# README для игрока идёт НА ДИСК рядом с программой. Русский обязан быть в # README для игрока идёт НА ДИСК рядом с программой, В КОРЕНЬ. Русский
# CP866: DSS и любой местный просмотрщик читают именно её, UTF-8 покажется # обязан быть в CP866: DSS и любой местный просмотрщик читают именно её,
# кракозябрами. Имена в 8.3: README.TXT и README.RUS. # UTF-8 покажется кракозябрами. Имена по 8.3, язык — суффиксом, а не
DISK := README.TXT README.RUS \ # расширением: расширение .TXT должно остаться, чтобы файл открывался
# просмотрщиком как текст.
DISK := README_E.TXT README_R.TXT \
BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \ BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \
KID/kid.arc KID/kid.pal KID/kid.ani \ KID/kid.arc KID/kid.pal KID/kid.ani \
GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \ GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \
SND/snd.arc \ SND/snd.arc SND/snd.idx \
$(foreach i,$(MUS_IDS),MUS/m$(i).bin) \ $(foreach i,$(MUS_IDS),MUS/m$(i).bin) MUS/mus.idx \
FONT/font.atl \ FONT/font.atl \
TITLE/title.arc TITLE/title.pal \ TITLE/title.arc TITLE/title.pal \
PV/pv.arc PV/story.pal PV/pv.pal \ PV/pv.arc PV/story.pal PV/pv.pal \
@@ -218,16 +233,32 @@ DISK := README.TXT README.RUS \
# список — это и зависимость exe, и то, что умеет чинить себя само. # список — это и зависимость exe, и то, что умеет чинить себя само.
GEN_ARC_H := bg_arc.h kid_arc.h guard_arc.h skel_arc.h vizier_arc.h \ 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 shadow_arc.h title_arc.h pv_arc.h
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_atlas.h kid_data.h \ # pop_sound_tbl.h здесь БОЛЬШЕ НЕТ: раскладка эффектов уехала на диск
pop_guard_pal.h pop_shadow_atlas.h pop_sound_tbl.h \ # (SND/snd.idx), и EXE от звукового набора не зависит — пару
pop_music_tbl.h pop_music_ticks.h pop_font.h) # snd.idx + snd.arc можно менять без перекомпиляции.
# Ни звукового, ни музыкальных заголовков здесь БОЛЬШЕ НЕТ: раскладка
# эффектов и длины треков уехали на диск (SND/snd.idx, MUS/mus.idx), и EXE
# не зависит ни от звукового набора, ни от набора записей.
GEN_H := $(addprefix gen/,$(GEN_ARC_H) pop_bg_atlas.h kid_data.h \
pop_guard_pal.h pop_shadow_atlas.h pop_font.h)
PACKED_FILES := $(addprefix $(PACKED)/,$(DISK)) PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK)) STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
EXTRA_DATA := $(STAGED) 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 \ HDD_PACK_ARGS := $(BUILD_DIR)/$(EXAMPLE).exe \
$(foreach f,$(DISK),$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f)) $(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 — приложение автономно. Связь с # Образ живёт в build/, а не в mame/v306/IMG — приложение автономно. Связь с
# MAME — одна символьная ссылка, ставится однократно: make mame-link. # MAME — одна символьная ссылка, ставится однократно: make mame-link.
HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd
@@ -330,6 +361,28 @@ $(STAMP)/$(1): $(2) | $(STAMP) check-orig
@touch $$@ @touch $$@
endef endef
# ВАРИАНТЫ РЕСУРСА (источник звука SND_SRC, набор записей MUSIC_FMT). Оба
# варианта пишут в ОДИН выход — build/atl/sound, build/atl/music, — поэтому
# одного stamp'а на вариант мало: вернувшись к прежнему варианту, make видит
# его stamp свежим и не делает НИЧЕГО, а в выходе лежит чужое (поймано на
# sdlpop -> msdos -> sdlpop: заголовок остался с 10 страницами вместо 9).
# Лечится файлом с именем текущего варианта: он переписывается только при
# смене (cmp), а его свежесть делает stamp устаревшим.
.PHONY: variant-check
variant-check: ;
# СМЕНА ВАРИАНТА СНОСИТ STAMP'Ы СВОЕГО СЕМЕЙСТВА, а не полагается на то,
# что make заметит свежесть variant-файла. Причина — секундная
# гранулярность mtime: упаковка укладывается в одну секунду с записью
# variant, и stamp того же имени считается актуальным. Возврат к прежнему
# варианту (`sdlpop -> msdos -> sdlpop`) тогда ТИХО оставлял чужой набор.
# Нет файла — правило обязано выполниться, и время тут ни при чём.
define variant_rule
$(STAMP)/$(1).variant: variant-check | $(STAMP)
@echo "$(2)" | cmp -s - $$@ || { echo "$(2)" > $$@; rm -f $(STAMP)/$(1)-*; }
endef
$(eval $(call variant_rule,sound,$(SND_SRC)))
$(eval $(call variant_rule,music,$(MUSIC_FMT)))
$(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,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,$(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,kid-bin,$(TOOLS)/pop_extract_kid_data.py $(TOOLS)/paths.py,pop_extract_kid_data.py))
@@ -337,8 +390,25 @@ $(eval $(call stamp_rule,guard,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_
$(eval $(call stamp_rule,skel,$(TOOLS)/pop_pack_guard.py $(TOOLS)/paths.py,pop_pack_guard.py SKEL)) $(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,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,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))) # ОДНИМ рецептом, а не тремя правилами через таймстемпы.
#
# Почему. Упаковщик и копирование укладываются в одну секунду, а make
# сравнивает mtime с секундной гранулярностью: цель, сделанная в ту же
# секунду, что и её зависимость, считается актуальной. При смене SND_SRC
# это давало ТИХО НЕВЕРНЫЙ результат — `sdlpop -> msdos -> sdlpop`
# перепаковывал build/atl, но в assets/packed оставался прежний набор
# (10 страниц вместо 9; поймано 2026-08-31, с паузой в секунду не
# воспроизводится). Внутри одного рецепта сравнивать нечего.
$(STAMP)/sound-$(SND_SRC): $(TOOLS)/pop_pack_sound.py $(TOOLS)/pop_idx.py \
$(TOOLS)/paths.py $(TOOLS)/pop_pack_arc.py \
$(STAMP)/sound.variant | $(STAMP) check-orig
cd $(TOOLS) && $(PY) pop_pack_sound.py --source $(SND_SRC)
@mkdir -p $(PACKED)/SND
$(ARC) $(PACKED)/SND/snd.arc $$(ls $(ATL)/sound/s*.bin | sort -V)
@cp $(ATL)/sound/snd.idx $(PACKED)/SND/snd.idx
@touch $@
$(eval $(call stamp_rule,music-$(MUSIC_FMT),$(TOOLS)/pop_pack_music.py $(TOOLS)/pop_idx.py $(TOOLS)/paths.py $(STAMP)/music.variant,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,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,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)) $(eval $(call stamp_rule,pv,$(TOOLS)/pop_pack_intro.py $(TOOLS)/paths.py,pop_pack_intro.py))
@@ -352,14 +422,13 @@ $(1): $(STAMP)/$(2)
@test -f $$@ || { $(MAKE) GENERATE=1 -B $(STAMP)/$(2); test -f $$@; } @test -f $$@ || { $(MAKE) GENERATE=1 -B $(STAMP)/$(2); test -f $$@; }
endef 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,$(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 gen/kid_atlas.h,kid)) $(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,$(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,$(GUARD_ATL) gen/pop_guard_pal.h,guard))
$(eval $(call from_stamp,$(SKEL_ATL),skel)) $(eval $(call from_stamp,$(SKEL_ATL),skel))
$(eval $(call from_stamp,$(VIZIER_ATL),vizier)) $(eval $(call from_stamp,$(VIZIER_ATL),vizier))
$(eval $(call from_stamp,$(SHADOW_ATL) gen/pop_shadow_atlas.h,shadow)) $(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) $(ATL)/music/mus.idx,music-$(MUSIC_FMT)))
$(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,$(ATL)/font/font.atl gen/pop_font.h,font))
$(eval $(call from_stamp,$(TITLE_ATL) $(ATL)/title/title.pal,title)) $(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)) $(eval $(call from_stamp,$(PV_ATL) $(ATL)/pv/story.pal $(ATL)/pv/pv.pal,pv))
@@ -383,9 +452,15 @@ $(eval $(call arc_rule,TITLE,title,title,$(TITLE_ATL),TITLE))
$(eval $(call arc_rule,PV,pv,pv,$(PV_ATL),PV)) $(eval $(call arc_rule,PV,pv,pv,$(PV_ATL),PV))
# У звука своего *_arc.h нет: содержимое адресуется таблицей pop_sound_tbl.h, # У звука своего *_arc.h нет: содержимое адресуется таблицей pop_sound_tbl.h,
# которую печатает сам упаковщик звука. # которую печатает сам упаковщик звука.
$(PACKED)/SND/snd.arc: $(SND_ATL) $(TOOLS)/pop_pack_arc.py # Зависимость на stamp, а не на список страниц: сколько их будет, известно
@mkdir -p $(dir $@) # только после запуска упаковщика (источник задаёт число). Порядок страниц
$(ARC) $@ $(SND_ATL) # в архиве обязан быть ЧИСЛОВЫМ — pop_snd_tbl адресует их индексом, а голый
# ls даёт s10 перед s2. sort -V, а не `-t s -k n`: буква s есть и в пути.
# Пара snd.arc + snd.idx согласована по построению: её делает ОДИН рецепт
# stamp'а выше (см. там же, почему не раздельными правилами). Здесь
# только восстановление, если файл снесли руками.
$(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx: $(STAMP)/sound-$(SND_SRC)
@test -f $@ || { $(MAKE) GENERATE=1 -B $(STAMP)/sound-$(SND_SRC); test -f $@; }
# Файлы, которые ложатся на диск как есть, — простое копирование из staging. # Файлы, которые ложатся на диск как есть, — простое копирование из staging.
define copy_rule define copy_rule
@@ -402,10 +477,12 @@ $(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/story.pal,$(ATL)/pv/story.pal))
$(eval $(call copy_rule,PV/pv.pal,$(ATL)/pv/pv.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))) $(foreach i,$(MUS_IDS),$(eval $(call copy_rule,MUS/m$(i).bin,$(ATL)/music/m$(i).bin)))
$(eval $(call copy_rule,MUS/mus.idx,$(ATL)/music/mus.idx))
# Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик # Уровни идут на диск ПРЯМО из оригинала: формат res20xx.bin наш загрузчик
# читает как есть, упаковщика для них нет. check-orig здесь тоже order-only — # читает как есть, упаковщика для них нет. check-orig здесь тоже order-only —
# без оригиналов взять уровни неоткуда, и сказать об этом надо внятно. # без оригиналов взять уровни неоткуда, и сказать об этом надо внятно.
LVL_ORIG := assets/orig/SDLPoP/data/LEVELS LVL_ORIG := assets/orig/SDLPoP/data/LEVELS
SND_ORIG_MSDOS := assets/orig/MSDOS
define lvl_rule define lvl_rule
$(PACKED)/LEVELS/res20$(1).bin: $(LVL_ORIG)/res20$(1).bin | check-orig $(PACKED)/LEVELS/res20$(1).bin: $(LVL_ORIG)/res20$(1).bin | check-orig
@mkdir -p $$(dir $$@) @mkdir -p $$(dir $$@)
@@ -417,25 +494,25 @@ $(foreach n,$(LVL_NUMS),$(eval $(call lvl_rule,$(n))))
# русского, перекодировка в CP866. iconv БЕЗ -c намеренно: потеря символа # русского, перекодировка в CP866. iconv БЕЗ -c намеренно: потеря символа
# должна ломать сборку, а не молча портить текст (memory # должна ломать сборку, а не молча портить текст (memory
# iconv_cp866_workflow; //TRANSLIT на macOS падает). # iconv_cp866_workflow; //TRANSLIT на macOS падает).
$(PACKED)/README.TXT: dist/README.txt $(PACKED)/README_E.TXT: dist/README.txt
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@sed 's/$$/\r/' $< > $@ @sed 's/$$/\r/' $< > $@
# ПОРЯДОК ВАЖЕН: CRLF ставится ДО перекодировки. BSD sed в UTF-8 локали # ПОРЯДОК ВАЖЕН: CRLF ставится ДО перекодировки. BSD sed в UTF-8 локали
# отказывается работать с байтами CP866 («RE error: illegal byte sequence»), # отказывается работать с байтами CP866 («RE error: illegal byte sequence»),
# а с валидным UTF-8 — работает. # а с валидным UTF-8 — работает.
$(PACKED)/README.RUS: dist/README.ru.txt $(PACKED)/README_R.TXT: dist/README.ru.txt
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@sed 's/$$/\r/' $< | iconv -f UTF-8 -t CP866 > $@ @sed 's/$$/\r/' $< | iconv -f UTF-8 -t CP866 > $@
resources-readme: $(PACKED)/README.TXT $(PACKED)/README.RUS 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-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-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid.ani
resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \ resources-actors: $(PACKED)/GUARD/guard.arc $(PACKED)/SKEL/skel.arc \
$(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc $(PACKED)/VIZIER/vizier.arc $(PACKED)/SHADOW/shadow.arc
resources-sound: $(PACKED)/SND/snd.arc resources-sound: $(PACKED)/SND/snd.arc $(PACKED)/SND/snd.idx
resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) resources-music: $(foreach i,$(MUS_IDS),$(PACKED)/MUS/m$(i).bin) $(PACKED)/MUS/mus.idx
resources-font: $(PACKED)/FONT/font.atl resources-font: $(PACKED)/FONT/font.atl
resources-title: $(PACKED)/TITLE/title.arc $(PACKED)/TITLE/title.pal 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-pv: $(PACKED)/PV/pv.arc $(PACKED)/PV/story.pal $(PACKED)/PV/pv.pal
@@ -446,8 +523,12 @@ resources: resources-bg resources-kid resources-actors resources-sound \
check-orig: check-orig:
@test -d $(LVL_ORIG) || { \ @test -d $(LVL_ORIG) || { \
echo "SprPoP: нет assets/orig/ — генерировать ресурсы не из чего." >&2; \ echo "SprPoP: нет assets/orig/SDLPoP — генерировать ресурсы не из чего." >&2; \
echo " Что и откуда положить, написано в assets/orig/README.md" >&2; false; } echo " Скачать: make fetch (подробности — assets/orig/README.md)" >&2; false; }
@test "$(SND_SRC)" != msdos || test -d $(SND_ORIG_MSDOS) || { \
echo "SprPoP: SND_SRC=msdos, но нет $(SND_ORIG_MSDOS)." >&2; \
echo " Дистрибутив DOS не качается — положить руками, либо собирать" >&2; \
echo " со звуком из SDLPoP: make SND_SRC=sdlpop" >&2; false; }
else else
# =========================================================================== # ===========================================================================
@@ -497,6 +578,33 @@ $(MUSIC_TARGETS):
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) resources-music @$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) resources-music
@$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) stage @$(MAKE) MUSIC_FMT=$(patsubst music-%,%,$@) stage
# ---------------------------------------------------------------------------
# Внешние исходные данные (assets/orig/)
# ---------------------------------------------------------------------------
# В репозитории чужих данных нет — есть tools/fetch_orig.py, который знает,
# откуда их взять, и таблица источников внутри него. Качаем только то,
# чего нет; `FETCH_ARGS=--force` перекачивает заново, `FETCH_ARGS=--ref v1.23`
# берёт конкретный тег SDLPoP.
# make fetch SDLPoP + записи музыки
# make fetch-sdlpop только SDLPoP (data/ + src/-эталон)
# make fetch-music только записи саундтрека (43 МБ)
# make fetch-check что уже на месте
# make fetch-list таблица источников
# MSDOS/ не качается — это копирайтный дистрибутив; он НЕОБЯЗАТЕЛЕН, всё
# нужное (уровни, оцифровка) есть в SDLPoP. См. SND_SRC выше.
FETCH := $(PY) $(TOOLS)/fetch_orig.py
fetch: ; $(FETCH) all $(FETCH_ARGS)
fetch-sdlpop: ; $(FETCH) sdlpop $(FETCH_ARGS)
fetch-music: ; $(FETCH) music $(FETCH_ARGS)
fetch-check: ; @$(FETCH) --check
fetch-list: ; @$(FETCH) --list
FETCH_TARGETS := fetch fetch-sdlpop fetch-music fetch-check fetch-list
# Тесты инструментов (хост, питон): форматы, которые читает Z80. Секунды,
# ни эмулятора, ни оригиналов не нужно.
test-tools:
@cd $(CURDIR) && $(PY) -m unittest discover -s tools/tests
# Перелинковка приложения без пересборки библиотек и ресурсов. # Перелинковка приложения без пересборки библиотек и ресурсов.
relink: relink:
$(MAKE) -W Makefile $(EXE) $(MAKE) -W Makefile $(EXE)
@@ -516,4 +624,4 @@ distclean: clean
.PHONY: stage check-orig resources resources-rebuild resources-bg resources-kid \ .PHONY: stage check-orig resources resources-rebuild resources-bg resources-kid \
resources-actors resources-sound resources-music resources-font \ resources-actors resources-sound resources-music resources-font \
resources-title resources-pv resources-levels $(MUSIC_TARGETS) \ resources-title resources-pv resources-levels $(MUSIC_TARGETS) \
relink distclean mame-link $(FETCH_TARGETS) test-tools relink distclean mame-link
+19 -2
View File
@@ -13,12 +13,19 @@ make # build/sprpop.exe + ресурсы
make hdd # + образ build/hdd/sprpop.chd make hdd # + образ build/hdd/sprpop.chd
``` ```
Чужих данных в репозитории нет — есть адреса, откуда их взять. Чтобы
перегенерировать ресурсы (или получить эталон механики), сначала:
```sh
make fetch # SDLPoP + записи саундтрека DOS-версии в assets/orig/
```
## Что где ## Что где
``` ```
src/ рукописный C: главный цикл sprpop.c + движок pop_*.c src/ рукописный C: главный цикл sprpop.c + движок pop_*.c
gen/ генерируемые заголовки — руками не править gen/ генерируемые заголовки — руками не править
assets/orig/ оригинальные данные игры (вне репозитория, см. там README) assets/orig/ оригинальные данные игры (вне репозитория, качает `make fetch`)
assets/packed/ то, что ложится на диск игры, в раскладке диска assets/packed/ то, что ложится на диск игры, в раскладке диска
tools/ конверторы ресурсов; все пути — в tools/paths.py tools/ конверторы ресурсов; все пути — в tools/paths.py
docs/ планы, доски задач и багов, справочники (начинать с docs/README.md) docs/ планы, доски задач и багов, справочники (начинать с docs/README.md)
@@ -33,9 +40,12 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/,
| `make` | собрать `build/sprpop.exe` и разложить ресурсы в `build/` | | `make` | собрать `build/sprpop.exe` и разложить ресурсы в `build/` |
| `make hdd` | + образ жёсткого диска `build/hdd/sprpop.chd` | | `make hdd` | + образ жёсткого диска `build/hdd/sprpop.chd` |
| `make mame-link` | однократно подставить образ в MAME (символьная ссылка на `IMG/test_hdd.chd`) | | `make mame-link` | однократно подставить образ в MAME (символьная ссылка на `IMG/test_hdd.chd`) |
| `make fetch` | скачать внешние исходные данные в `assets/orig/`: `fetch-sdlpop`, `fetch-music`, `fetch-check`, `fetch-list` |
| `make resources` | перегенерировать ресурсы из `assets/orig/` | | `make resources` | перегенерировать ресурсы из `assets/orig/` |
| `make music-mp3` | музыка из другого набора записей: `music-flac` (умолчание), `music-mp3`, `music-ogg`, `music-mt32` | | `make music-mp3` | музыка из другого набора записей: `music-flac` (умолчание), `music-mp3`, `music-ogg`, `music-mt32` |
| `make SND_SRC=msdos` | оцифровка эффектов из дистрибутива DOS вместо SDLPoP (нужен свой `assets/orig/MSDOS/`) |
| `make -C tests/host` | модульные тесты движка (секунды, без эмулятора) | | `make -C tests/host` | модульные тесты движка (секунды, без эмулятора) |
| `make test-tools` | тесты упаковщиков на хосте: форматы файлов, которые читает Z80 |
| `make clean` | снести `build/` (ассеты не трогает) | | `make clean` | снести `build/` (ассеты не трогает) |
| `make distclean` | `clean` + снести упакованные ассеты `assets/packed/` | | `make distclean` | `clean` + снести упакованные ассеты `assets/packed/` |
@@ -54,7 +64,14 @@ build/ выход: sprpop.exe, каталоги ресурсов, hdd/,
`assets/orig/` нужен только чтобы ресурсы **перегенерировать** (`make `assets/orig/` нужен только чтобы ресурсы **перегенерировать** (`make
resources`) и как **эталон механики**: любая правка поведения сверяется с resources`) и как **эталон механики**: любая правка поведения сверяется с
`assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`. `assets/orig/SDLPoP/src/` до кодинга, см. `CLAUDE.md`. Заполняется он одной
командой `make fetch` — качается SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека; адрес архива музыки скрипт берёт из самого SDLPoP.
Оригинальный дистрибутив DOS (`assets/orig/MSDOS/`) **не нужен**: и уровни, и
оцифровка звука берутся из SDLPoP. Он даёт ровно одно — более полный набор
эффектов (в SDLPoP пуст звук `spiked`, 48); у кого дистрибутив есть, включает
его явно: `make SND_SRC=msdos`. Подробности — `assets/orig/README.md`.
## Происхождение ## Происхождение
+82
View File
@@ -0,0 +1,82 @@
# assets/orig — оригинальные данные
Здесь лежат **исходные** данные Prince of Persia, из которых `make resources`
делает ресурсы Sprinter-версии. В репозиторий этот каталог не идёт —
материал копирайтный (см. `.gitignore`). Вместо данных в репозитории лежит
**знание, откуда они берутся**: `tools/fetch_orig.py` и таблица источников
внутри него.
## Как заполнить
```
make fetch # SDLPoP + записи саундтрека (всё, что качается)
make fetch-check # что уже на месте
make fetch-list # таблица источников с адресами
```
Тонкости:
```
make fetch-sdlpop только SDLPoP
make fetch-music только музыка (43 МБ)
make FETCH_ARGS=--force fetch перекачать заново
make FETCH_ARGS="--ref v1.23" fetch-sdlpop конкретный тег SDLPoP
```
Скачанные архивы кэшируются в `assets/orig/.cache/` — повторный `make fetch`
сеть не трогает. Версия SDLPoP записывается в `assets/orig/SDLPoP/.fetch.json`
(ветка/тег, sha, дата), и следующий `make fetch` по ней понимает, последняя
у нас версия или нет.
Собрать игру без `assets/orig/` **можно**: упакованные ресурсы лежат в
`../packed/`, а генерируемые заголовки — в `../../gen/`. `assets/orig` нужен
только чтобы ресурсы **перегенерировать**.
Исключение — `LEVELS`: файлы `res20xx.bin` ложатся на диск игры как есть, без
конвертации, поэтому `assets/packed/LEVELS/` тоже вне репозитория и заполняется
из `orig` командой `make resources-levels`.
## Что и откуда
| Каталог | Что это | Где взять |
|---|---|---|
| `SDLPoP/data/` | Распакованные ресурсы DOS-версии: PNG-спрайты (`VDUNGEON`, `VPALACE`, `KID`, `GUARD`, `SKEL`, `VIZIER`, `SHADOW`, `TITLE`, `PV`, `PRINCE`), палитры, `LEVELS/res20xx.bin`, оцифровка `DIGISND*.DAT` | `make fetch-sdlpop` — <https://github.com/NagyD/SDLPoP> (GPLv3) |
| `SDLPoP/src/` | Исходники SDLPoP. Нужны не только упаковщикам (`menu.c` и `seg009.c` содержат встроенные шрифты, из `seg005/seg006` берутся таблицы кадров и `seqtbl`), но и как **эталон механики**: расхождение в поведении всегда сверяется с ними | тем же `make fetch-sdlpop` |
| `PoP1_DOS_music/` | Записи музыки DOS-версии в четырёх наборах: `flac/` (по умолчанию), `mp3/`, `ogg/`, `ogg_MT-32/` (исполнение Roland MT-32). Имена треков во всех наборах одинаковые; какой брать — задаёт `MUSIC_FMT` | `make fetch-music`. Адрес архива скрипт читает из самого SDLPoP (`ReadMe.amigaos4`, секция «AUDIO IS SLOW/AWFULL»), запасной — popot.org |
| `MSDOS/` | **Необязательно.** Оригинальная поставка PoP 1.0 для MS-DOS. Используется только `digisnd*.dat` со звуком, и только если попросить: `make SND_SRC=msdos` | Не качается — дистрибутив игры, положить руками |
## MSDOS: зачем он и почему без него живётся
По умолчанию **всё** берётся из SDLPoP — и уровни, и оцифровка звука. Так
сборка работает на чистой машине сразу после `make fetch`.
`MSDOS/` даёт ровно одно: более полный набор оцифрованных эффектов. Из 31
звука 28 совпадают побайтно, а три — нет, и все три лучше в дистрибутиве:
`spiked` (48, насаживание на пики) в наборе SDLPoP длиной 7 сэмплов, то есть
его нет; `sword_vs_sword` (10) короче; `sword_moving` (11) — другая запись.
Разбор — `docs/sound_plan.md`. Включается это явно:
```
make SND_SRC=msdos resources-sound # перепаковать звук из дистрибутива
make SND_SRC=msdos # и собрать с ним
```
Источник входит в имя stamp'а, поэтому переключение `SND_SRC` само делает
звук устаревшим — `-B` не нужен.
## Кто что читает
| Упаковщик | Читает |
|---|---|
| `pop_pack_bg.py`, `render_room.py` | `SDLPoP/data/{VDUNGEON,VPALACE,LEVELS}` |
| `pop_pack_kid.py` | `SDLPoP/data/KID` |
| `pop_pack_guard.py` | `SDLPoP/data/{GUARD,SKEL,VIZIER,PRINCE}` |
| `pop_pack_shadow.py` | `SDLPoP/data/{KID,SHADOW}` |
| `pop_pack_title.py`, `pop_pack_intro.py` | `SDLPoP/data/{TITLE,PV,PRINCE,KID}` |
| `pop_extract_font.py` | `SDLPoP/src/{menu.c,seg009.c}` |
| `pop_extract_kid_data.py` | `SDLPoP/src/` (таблицы кадров и `seqtbl`) |
| `pop_pack_sound.py` | `SDLPoP/data/DIGISND*.DAT` (`--source sdlpop`, умолчание) либо `MSDOS/digisnd*.dat` (`--source msdos`) |
| `pop_pack_music.py` | `PoP1_DOS_music/<набор>/` (`--format flac\|mp3\|ogg\|mt32`) |
Точная раскладка путей — в `tools/paths.py`; менять её нужно там, а не в
отдельных упаковщиках. Адреса внешних источников — в `tools/fetch_orig.py`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+92 -8
View File
@@ -21,7 +21,7 @@
| [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт | | [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт |
| [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается | | [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается |
| [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз | | [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз |
| [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | открыт: подозрение на клавиатурный модуль, не на физику | | [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | подозрение на клавиатуру ПОДТВЕРДИЛОСЬ частично: кража байт DSS устранена 2026-08-28, перепроверить |
| [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** | | [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** |
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов | | [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
| [PAL-L1-AFTER-INTRO](#pal-l1-after-intro) | вход в игру на уровень 1 после интро — игровая палитра не установлена (чёрный экран) | палитра/fade | открыт: воспроизведение нестабильно, корень не установлен | | [PAL-L1-AFTER-INTRO](#pal-l1-after-intro) | вход в игру на уровень 1 после интро — игровая палитра не установлена (чёрный экран) | палитра/fade | открыт: воспроизведение нестабильно, корень не установлен |
@@ -32,11 +32,11 @@
| [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 | | [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 |
| [MID-OVERLAY-LAYER](#mid-overlay-layer) | оверлей кромки всегда поверх плит и персонажей (у оригинала — midtable с сортировкой) | слои | открыт: нужен разбор архитектуры | | [MID-OVERLAY-LAYER](#mid-overlay-layer) | оверлей кромки всегда поверх плит и персонажей (у оригинала — midtable с сортировкой) | слои | открыт: нужен разбор архитектуры |
| [GUARD-FALLOUT-VICTORY](#guard-fallout-victory) | страж, выпавший из комнаты, не засчитывается убитым | порт | открыт: две строки | | [GUARD-FALLOUT-VICTORY](#guard-fallout-victory) | страж, выпавший из комнаты, не засчитывается убитым | порт | открыт: две строки |
| [KBD-STUCK-WAIT](#kbd-stuck-wait) | потерянный break-код клавиши вешает игру НАСМЕРТЬ в межуровневой заставке | клавиатура/клин | открыт: диагноз полный, чинить не сейчас (решение пользователя 2026-08-24) | | [KBD-STUCK-WAIT](#kbd-stuck-wait) | потерянный break-код клавиши вешает игру НАСМЕРТЬ в межуровневой заставке | клавиатура/клин | **корень найден и устранён** 2026-08-28 (байты крал KEYSCAN DSS); ждёт полевой проверки |
| [L10-BUTTON-DEBRIS](#l10-button-debris) | ур.10 к.1: плита упала на кнопку (1,8) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) | | [L10-BUTTON-DEBRIS](#l10-button-debris) | ур.10 к.1: плита упала на кнопку (1,8) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) |
| [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» | | [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | открыт: диагноз полный, симптом обойдён подгонкой (2026-08-27) | | [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | открыт: замер есть, лечение — удешевить кадр | | [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился | | [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
--- ---
@@ -98,8 +98,67 @@ POS=18`, MAME): Кид стоит на кнопке -> решётка `(2,7)` о
<a id="kbd-stuck-wait"></a> <a id="kbd-stuck-wait"></a>
## KBD-STUCK-WAIT. Потерянный break-код вешает игру насмерть в межуровневой заставке ## KBD-STUCK-WAIT. Потерянный break-код вешает игру насмерть в межуровневой заставке
> **Решение пользователя (2026-08-24): не чинить сейчас.** Записан разбор и > **КОРЕНЬ НАЙДЕН И УСТРАНЁН 2026-08-28.** Байты у нас крал сам DSS — см.
> варианты; вернуться, когда дойдут руки до клавиатурного слоя. > раздел «Корень» ниже. Правка в `libc/irq/_irq_tramp.c`. Запись остаётся
> открытой до полевой проверки и решения по страховке-таймауту.
## Корень (2026-08-28): скан-коды воровал KEYSCAN DSS
Пришёл из чтения исходников DSS (`docs/sources/Estex-DSS/DSS/`, подсказка
пользователя). Обработчик прерывания DSS живёт в IM1 по `0x0038`
(`DSS-MAIN.ASM`), и его `INTx38_Handler` ПЕРВЫМ ДЕЛОМ делает `CALL KEYSCAN`,
а `KEYSCAN.RESCAN` (`KEYINTER.ASM`) вычерпывает FIFO SIO досуха.
Наш трамплин проверял «есть ли клавиатурный байт» ОДИН раз, на входе в
прерывание, а хвост кадрового пути уходил в DSS (`jp 0x0038`). Значит
скан-код, прилетевший ПОЗЖЕ этой проверки, доставался DSS и уезжал в его
буфер — при этом **Rx-overrun не взводится**, потому что байт не потерян
железом, он просто прочитан не тем владельцем. Отсюда и загадка
исходного диагноза: бит залип, а `_kbdraw_overrun == 0`.
**Измерено в MAME (сессия 2026-08-28, брейки + `totalcycles`):**
| Что | Значение |
|---|---|
| окно от входа трамплина до чтения байта DSS | **738 тактов ≈ 34 мкс**, на КАЖДОМ кадровом прерывании (50/с) |
| из них пролог самого DSS (`jp 0x38``.Handler``call INTx38_Handler` → 11 `push``call KEYSCAN`) | 481 такт ≈ 22 мкс |
| цепочка кадровых хендлеров у SprPoP | пуста (`_irq_chain_n = 0`) — окно уже минимальное |
| расчётная частота на железе при удержании клавиши (typematic 30 Гц = 60 байт/с) | потеря байта раз в ~10 с |
Последняя строка сходится с полевым наблюдением пользователя: залипание
стрелки ловилось на ур. 14, когда Кид долго бежит влево через несколько
комнат.
**Пойманный случай (MAME, старая сборка).** Стоп на `IN A,($18)` внутри
`KEYSCAN` при `_kbdraw_active = 1`: DSS прочитал `A = 0x74` — make стрелки
«вправо», при том что `_kbdraw_pending = 2` (EXT), то есть префикс `E0` уже
разобрали МЫ. Посылка `E0 74` разорвана пополам между двумя владельцами
канала. Злейший подвид — кража `E0` у break'а `E0 F0 6B`: тогда мы гасим
бит в PLAIN-половине, а взведённый бит в EXT остаётся навсегда, и это ровно
залипшая стрелка `0x6B`/`0x72` из исходного разбора.
**Правка.** Пока raw-канал открыт, кадровый путь трамплина НЕ вызывает
обработчик DSS вообще (приватный `RETI` вместо `jp 0x0038`) — окно ровно
ноль. Промежуточный вариант «проверить FIFO перед chain'ом» пробовали и
отвергли замером: он снимает лишь свою треть окна (738 → 481 такт), кражи
продолжались. Цена принятого варианта: пока открыт raw, у DSS замирает
кадровое обслуживание — опрос мыши и мигание текстового курсора (PoP не
нужно ни то, ни другое; зафиксировано в `<kbd_raw.h>`).
**Проверка (MAME 2026-08-28).** До правки брейк на входе `KEYSCAN` с
условием «страница точно DSS + наш raw открыт» срабатывал мгновенно (50/с);
после правки молчит, а положительный контроль — брейк на нашем приватном
`RETI` — срабатывает сразу. ВАЖНО про методику: первый брейк без проверки
сигнатуры байтов дал ЛОЖНОЕ срабатывание — в W0 периодически лежит
драйверная страница DSS, и по адресу `0x0570` там своя команда.
**Что осталось открытым.** Кража устранена, но потерять break всё ещё
может настоящий Rx-overrun SIO (редкий: FIFO вычерпывает `kbd_raw_poll` из
главного цикла). Значит вариант A из таблицы ниже — таймаут в циклах
«ждём отпускания всех клавиш» — остаётся разумной страховкой от КЛАССА
отказа «вечный клин», и его стоит сделать отдельно.
## Исходный разбор (2026-08-24)
**Наблюдение (пользователь, 2026-08-24).** Нажат Shift+L (чит «следующий **Наблюдение (пользователь, 2026-08-24).** Нажат Shift+L (чит «следующий
уровень») — игра встала намертво: картинка прежнего уровня, реакции нет. уровень») — игра встала намертво: картинка прежнего уровня, реакции нет.
@@ -169,6 +228,13 @@ Rx-overrun не срабатывала. Гашение бита отладчи
> **Решение пользователя (2026-08-12): отложено до готовности всех уровней.** > **Решение пользователя (2026-08-12): отложено до готовности всех уровней.**
> Механика работает, это вопрос ощущения, а не проходимости. > Механика работает, это вопрос ощущения, а не проходимости.
> **Обновление 2026-08-28.** Гипотеза «дело в клавиатурном модуле, а не в
> физике» подтвердилась ЧАСТИЧНО: найдена и устранена кража скан-кодов
> обработчиком DSS (разбор — [KBD-STUCK-WAIT](#kbd-stuck-wait)). Украденный
> make Shift'а или стрелки — это ровно «нажал, а не сработало», и на
> железе при удержании клавиши такое случалось раз в ~10 с. Перепроверить
> ощущение зацепа на новой сборке ДО того, как лезть в физику.
**Наблюдение (пользователь).** Вход на уровень 7: Кид влетает в комнату сверху **Наблюдение (пользователь).** Вход на уровень 7: Кид влетает в комнату сверху
и, пролетая мимо края пола, МОЖЕТ за него зацепиться — но получается заметно и, пролетая мимо края пола, МОЖЕТ за него зацепиться — но получается заметно
реже, чем в оригинале. «Похоже, это проблемы нашего клавиатурного модуля». реже, чем в оригинале. «Похоже, это проблемы нашего клавиатурного модуля».
@@ -1140,7 +1206,15 @@ fade_in по построению пишет все 256 записей из сн
--- ---
<a id="snd-pace-dead"></a> <a id="snd-pace-dead"></a>
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается ## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается — СНЯТ 2026-08-28
> **Закрыт удалением самой ветки.** Сцена с Джафаром переведена на ЕДИНЫЕ
> часы — кадры луча (`intro_pv_animated`): вторая шкала по насосу и гонка,
> которая её выбирала, убраны целиком. Насос как часы был нужен потому,
> что кадр сцены рисовался дольше своего интервала; теперь отрисовка
> разложена по интервалам (`pv_restore_bg`), и счёт кадров честен.
> Побочный эффект гонки — «кода реплики каждый прогон в другом месте» —
> исчез; проверено пользователем. Ниже — исходный разбор.
**Замер (MAME, 2026-08-27).** В сцене `intro_pv_scene()` зонды показали **Замер (MAME, 2026-08-27).** В сцене `intro_pv_scene()` зонды показали
`snd_done == 0` на всём прогоне — то есть ветка `if (snd_pace)`, ради `snd_done == 0` на всём прогоне — то есть ветка `if (snd_pace)`, ради
@@ -1171,7 +1245,17 @@ snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально и
--- ---
<a id="pv-render-bound"></a> <a id="pv-render-bound"></a>
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра ## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра — ИСПРАВЛЕНО 2026-08-28
> **Причина оказалась не в цене кадра, а в том, ГДЕ отсчитывался интервал.**
> Кадр рисовался целиком, и только потом ждали vsync и ещё четыре — то есть
> отрисовка ПРИБАВЛЯЛАСЬ к делителю. Теперь она разложена на блоки, каждый
> из которых влезает в кадровый интервал, и после каждого ждём vsync
> (`pv_restore_bg` + раскладка кадра в `cut_run` / `pre_room_animated` /
> `intro_pv_draw_frame`); фон восстанавливаем только по картинке, 200 строк
> вместо 256. Удешевлять сцену не понадобилось: отрисовка ~2 кадра при
> бюджете 5. Подгонка `PV_MAGIC_LEAD` снята (0) — проверено пользователем:
> кода попадает в нужный кадр. Ниже — исходный замер.
**Замер (MAME, 2026-08-27).** Реплика Джафара `m53` — 1403 порции насоса, **Замер (MAME, 2026-08-27).** Реплика Джафара `m53` — 1403 порции насоса,
то есть 16,42 с реального времени. За это время шкала сцены прошла то есть 16,42 с реального времени. За это время шкала сцены прошла
+2
View File
@@ -13,6 +13,7 @@
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде | | [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде |
| [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP | | [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP |
| [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе | | [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе |
| [`../assets/orig/README.md`](../assets/orig/README.md) | **Откуда берутся исходные данные**: `make fetch` (SDLPoP + музыка), зачем нужен и почему необязателен `MSDOS/`, `SND_SRC` |
## Производительность ## Производительность
@@ -41,6 +42,7 @@
| [`palette_plan.md`](palette_plan.md) | Карта всех 256 слотов палитры + механика fade | | [`palette_plan.md`](palette_plan.md) | Карта всех 256 слотов палитры + механика fade |
| [`status_line_text.md`](status_line_text.md) | Строка HP как статус-строка: инвентаризация ВСЕХ текстов SDLPoP | | [`status_line_text.md`](status_line_text.md) | Строка HP как статус-строка: инвентаризация ВСЕХ текстов SDLPoP |
| [`sound_plan.md`](sound_plan.md) | Звук через CBL: разбор и архитектура | | [`sound_plan.md`](sound_plan.md) | Звук через CBL: разбор и архитектура |
| [`music_runtime_index_plan.md`](music_runtime_index_plan.md) | Будущее улучшение: runtime-индексы музыки и PCM-эффектов, смена аудионаборов без перелинковки EXE |
| [`shadow_render.md`](shadow_render.md) | Вид Тени (OR+XOR) — отложено: почему XOR несовместим с прозрачностью `#FF` | | [`shadow_render.md`](shadow_render.md) | Вид Тени (OR+XOR) — отложено: почему XOR несовместим с прозрачностью `#FF` |
| [`roomnav_skip.md`](roomnav_skip.md) | Комнаты для отладочного телепорта `+`/``: какие пропускать и почему | | [`roomnav_skip.md`](roomnav_skip.md) | Комнаты для отладочного телепорта `+`/``: какие пропускать и почему |
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии, дифф против SDLPoP | | [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии, дифф против SDLPoP |
+92
View File
@@ -89,3 +89,95 @@ raw-клавиатура (`<kbd_raw.h>`), которую мы и так заби
после выхода — заметный. Делать после того, как закроем стражей и после выхода — заметный. Делать после того, как закроем стражей и
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
возможный возврат `pop_bg` в резидент `--w3`). возможный возврат `pop_bg` в резидент `--w3`).
## Готовить следующий уровень, пока играет мелодия конца текущего
**САМАЯ ДАЛЬНЯЯ ВЕРСИЯ.** Не полишинг и не порт: это улучшение ПРОТИВ
оригинала. Планируем, но не раньше, чем закроем уровни и полишинг.
**Идея (пользователь, 2026-08-28).** Пока звучит мелодия конца уровня (и
трек заставки между уровнями), экран не меняется — значит в это время можно
успеть прочитать с диска следующий уровень, чтобы после музыки он появлялся
сразу, а не через секунду-другую загрузки.
**Что УЖЕ сделано и мерено (2026-08-28).** Половина этого уже работает:
трек заставки играет ПОВЕРХ загрузки уровня — так же, как в оригинале
(`seg003:68-108`: `load_intro` возвращает управление, `load_level()` идёт под
музыку, и только потом `while (check_sound_playing())` на чёрном экране).
Насос это переживает: 500 подряд вызовов `pop_sfx_fill` через всю загрузку —
максимальный зазор 245 832 такта при дедлайне 251 000, ни одной пропущенной
порции. Осталась вторая половина: мелодия конца уровня досиживается на
ЖИВОМ экране пройденного уровня (как в оригинале, `play_level_2`), и только
после неё начинается загрузка.
**Оговорки, найденные при разборе.**
1. **Загрузку придётся разрезать надвое.** Сейчас `pop_level_switch` мешает
дисковую работу (файл уровня в EMM-страницу, атласы стража и тайлсета) с
экранно-палитровой (`pop_bg_load` переписывает записи палитры и атласы,
`pop_guard_load` физически применяет цветовые слоты, обе страницы
заливаются, `pop_pal_black`). Вторую половину НЕЛЬЗЯ выполнять, пока на
экране ещё живёт пройденный уровень — иначе палитра поедет прямо на
картинке. То есть в «музыкальное окно» можно вынести только диск, а всё,
что трогает палитру и VRAM, остаётся после.
2. **Бюджет кадра.** Ожидание мелодии идёт в игровом цикле (гейт
`pop_endmus_left` + `pop_music_busy()`), кадр там обычный, 80-100 мс.
Шаг подкачки должен быть такого же размера, как музыкальный — полстраницы
(~16 мс), а не страница целиком; иначе кадры на экране пройденного уровня
начнут дёргаться.
3. **Память.** Страницы следующего уровня придётся держать одновременно со
страницами текущего — проверить EMM-бюджет (memory `sprinter_emm_budget`),
тяжёлые тут не файл уровня, а атласы тайлсета и стража.
4. **Риск невелик, но он в самом хрупком месте.** Палитро-атласный обмен —
источник уже пойманных багов (`PAL-DUNGEON-STALE`, `PAL-L1-AFTER-INTRO`),
и трогать его ради экономии секунды стоит только на спокойную голову.
**Выигрыш.** Одна-две секунды один раз на уровень.
## Музыка одним постоянно открытым архивом (замер 2026-08-31, НЕ сейчас)
**Идея.** Сейчас каждый трек — отдельный файл `MUS/mNN.bin`, и
`pop_music_load_begin` открывает свой на каждый запуск. Свести треки в
один файл и держать его `fd` открытым: вместо `chdir`+`open`+`chdir`
останется `lseek` к смещению трека.
**Почему это стоит внимания — разложение окна старта музыки** (полный
замер и метод — `sound_plan.md` §9):
| шаг | цена | уйдёт? |
|---|---:|---|
| ожидание `pop_music_service` в кадре | 22,6 мс | нет |
| `chdir` #1 | 46,1 мс | **да** |
| `open` файла трека | 34,5 мс | **да** |
| `chdir` #2 | 59,0 мс | **да** |
| чтение первых 8 КБ | 34,0 мс | нет |
| **итого** | **196,7 мс** | |
Уходит **139,6 мс** — но это ВЕРХНЯЯ граница: появляется `lseek` к
смещению трека, которого сейчас нет вовсе (файл читается последовательно
с нуля), и его цена НЕ ИЗМЕРЕНА. Реальный выигрыш = 139,6 минус `lseek`;
померить можно тем же способом, `lseek` уже используется в
`pop_arc.c:89`. Остаётся ≈ 57 мс, то есть старт трека ускоряется
примерно вчетверо.
**Где заметно.** Реплики PV-сцены идут встык (три трека подряд), плюс
каждый игровой джингл — смерть, зелье, подобранный меч.
**Чем осложнено.**
1. **`PBA1` под это не годится.** Размер элемента там `uint16` (≤ 64 КБ),
а трек — до 1,2 МБ (m56, 78 страниц); резать по страницам нельзя,
элементов вышло бы ~240 при `POP_ARC_MAX = 126`. Нужен свой индекс со
смещениями — то есть эта задача СМЫКАЕТСЯ с `MUS/MUSIC.IDX` из
`music_runtime_index_plan.md`: одно изменение раскладки, а не два.
2. **Постоянно занятый файловый манипулятор** — один из восьми (memory
`dss_fd_limit`, девятый `open` вешает DSS). Рядом свои открывают
`POP.CFG`, quicksave и загрузка уровня.
3. **`chdir` убрать нельзя** — он и есть половина выигрыша, но нужен
старым DSS (`POP_PATH_CALL`); экономия берётся не его удалением, а тем,
что открытие вообще перестаёт выполняться на каждый трек.
**Оговорка.** Задержка старта музыки сама по себе НЕ является дефектом:
трек начинается на границе события, на слух это не сбой. У эффектов
такой задержки нет вовсе — они целиком в EMM (см. `sound_plan.md` §9.1).
Так что это оптимизация, а не исправление.
+37
View File
@@ -647,3 +647,40 @@ Enter/Shift — либо оставить как есть уже осознан
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE` стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
кадров — труп должен успеть лечь. кадров — труп должен успеть лечь.
---
## Таблица рекордов: Esc отменяет запись, а оригинал уйти не даёт
**Как в оригинале.** `show_hof` вставляет результат в таблицу ДО ввода
имени и крутит ввод, пока тот не вернёт положительную длину:
```c
/* SDLPoP seg001.c:624 */
while (input_str(&rect, hof[hof_index].name, 24, "", 0, 4, color, bgcolor) <= 0);
/* seg009.c input_str: Esc -> return -1; Enter -> return length */
```
То есть **уйти нельзя ни по Esc, ни с пустым именем**: и 1, и 0 просто
начинают ввод заново. Запись в таблице остаётся в любом случае.
**Как у нас.** Enter записывает имя (пустое подставляется как `PLAYER`),
**Esc отказывается от записи целиком**: таблица перечитывается с диска
(`hof_load`), `hof_save` не вызывается, экран показывается уже без строки
ввода.
**Зачем.** Решение пользователя, 2026-08-27. Оригинальное поведение
означает, что случайно добравшийся до финала игрок обязан вписать имя,
чтобы вообще уйти с экрана, — на клавиатуре без цифрового блока и с нашим
raw-каналом это выглядит как зависание, а не как требование.
**Чем платим.** Расхождение видно игроку: в оригинале таблица после
победы пополняется всегда. Плюс отмена стоит одного лишнего чтения
POP.HOF — откатывать сдвиг строк, который сделал `hof_insert_current`,
дешевле перечитыванием файла, чем обратным сдвигом (экран холодный,
50 мс там никому не мешают).
**Что проверять при регрессе.** После Esc: таблица на экране прежняя (без
новой строки), POP.HOF на диске не изменился, следующий показ таблицы —
между credits и attract-demo — даёт тот же список. После Enter с пустым
именем строка называется `PLAYER` и сохраняется.
-1
View File
@@ -24,7 +24,6 @@
* Ctrl+R: Return to intro. * Ctrl+R: Return to intro.
* Ctrl+S: Sound on/off. * Ctrl+S: Sound on/off.
* Ctrl+M: Music on/off. * Ctrl+M: Music on/off.
* Ctrl+V: Show version of SprPoP.
* Ctrl+Q / F10: Quit game. * Ctrl+Q / F10: Quit game.
* F6: Quicksave: Save the exact state of the game. * F6: Quicksave: Save the exact state of the game.
@@ -0,0 +1,592 @@
# Аудио без перелинковки: runtime-индексы музыки и эффектов
> Статус: **ОБЕ ЧАСТИ СДЕЛАНЫ 2026-08-31** — эффекты (SI0..SI4) и музыка
> (MI0..MI5). Разбор и результаты: `sound_plan.md` §10 (эффекты) и §11
> (музыка). Ниже — исходный план; расхождения перечислены следом.
>
> Что в реализации разошлось с планом ниже: файл назван `SND/snd.idx`
> (единый basename с `snd.arc`, оба в нижнем регистре), а при отсутствии
> индекса поднимается ПУСТОЙ набор с блоком тишины — иначе встаёт музыка
> и игра непроходима (§10.5 sound_plan). Про `ticks60` в `PMI1` решено:
> поле в формате оставить даже если сценарии перейдут на ожидание звука —
> загрузчик волен не тащить его в память.
>
> Расхождения части I с планом ниже:
> * файл назван `MUS/mus.idx` (единый стиль с `snd.idx`);
> * пауза конца уровня переведена НЕ на runtime-тики, а на состояние
> «заявка/загрузка/звучание» (`pop_music_active`) — §11.2: это снимает и
> зависимость от набора, и враньё делителя `/4` в быстрых режимах, и
> расхождение с SDLPoP при выключенном звуке;
> * PV-сцена (§8 плана) — не структура времён через пять функций, а четыре
> якоря-статики (8 байт), от которых отсчитываются прежние выражения;
> * `pages` в PMI1 не хранится — считается из `blocks`.
## 1. Цель
Один и тот же `sprpop.exe` должен корректно работать с любым штатным набором
музыки (`flac`, `mp3`, `ogg`, `mt32`) без перекомпиляции и перелинковки.
Замена набора должна сводиться к замене файлов в `MUS/` вместе с описывающим
их индексом.
Сейчас это невозможно: упаковщик печатает зависящие от набора значения в
`gen/pop_music_tbl.h` и `gen/pop_music_ticks.h`, после чего они становятся
частью EXE. Если заменить только `MUS/mNN.bin`, загрузчик продолжает верить
размерам старого набора, а сценарии — его длительностям.
## 2. Подтверждённое текущее состояние
После преобразования в PCM наборы `flac`, `mp3` и `ogg` имеют одинаковые
длины. Исполнение MT-32 отличается. Значимые примеры:
| трек | FLAC/MP3/OGG | MT-32 |
|---|---:|---:|
| m32, конец уровня 4 | 438 тиков | 449 тиков |
| m41, обычный конец уровня | 732 | 685 |
| m50, принцесса ждёт | 831 | 867 |
| m53, реплика Джафара | 985 | 1044 |
| m56, финальная тема | 9865 блоков / 78 страниц | 10462 / 82 |
Все обычные треки каждого из четырёх наборов по-прежнему помещаются в
`POP_MUS_PAGES == 20`. Только m56 требует кольцевого проигрывателя.
`uint16_t` достаточно и для самого длинного MT-32-трека: 10462 блока.
Compile-time длительности используются не повсюду:
- `pop_music.c` читает из `pop_music_tbl.h` наличие, число страниц и блоков;
- `pop_intro.c` использует длительности m50 и m53 для шкалы PV-сцены;
- `pop_kid.c` использует длительности m32 и m41 для паузы конца уровня,
включая режим с выключенной музыкой;
- остальные ожидания уже опираются на `pop_music_busy()` и автоматически
заработают с правильным runtime-числом блоков.
## 3. Что должно остаться константами программы
В индекс переносятся только свойства конкретного набора. Инварианты формата
остаются в коде, а заголовок IDX лишь подтверждает их:
- PCM unsigned 8-bit mono;
- частота `CBL_FREQ_10K9`;
- один блок насоса — 128 байт;
- пространство оригинальных sound id — 0..56;
- `POP_MUS_PAGES == 20` для обычного загрузчика;
- размер кольца потокового проигрывателя.
Это важно: IDX не должен обещать программе другой формат PCM, который насос
физически не умеет воспроизводить.
## 4. Предлагаемый файл `MUS/MUSIC.IDX`
Предпочтителен отдельный индекс набора, а не заголовок в каждом треке:
- все сценарные длительности доступны сразу после старта;
- `mNN.bin` остаются простыми сырыми PCM-потоками;
- загрузчик трека меняется минимально;
- один индекс легко заменить вместе с набором;
- формат помещается в один 512-байтовый сектор DSS.
Предлагаемая версия `PMI1`:
```text
0..3 "PMI1" magic и версия
4 57 число плотных записей (id 0..56)
5 4 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат PCM: u8 mono, CBL_FREQ_10K9
8..15 0 резерв будущих версий
16.. 57 записей по 4 байта:
+0..1 uint16 blocks длина PCM в блоках по 128 байт
+2..3 uint16 ticks60 длительность ожидания в тиках оригинала
244..511 нулевой резерв до одного сектора
```
Нулевые `blocks` и `ticks60` означают, что трека с таким id в наборе нет.
`pages` в IDX не хранится: это производная величина, и её дублирование может
разойтись с `blocks`:
```c
pages = blocks / 128 + ((blocks & 127) != 0);
```
По умолчанию упаковщик вычисляет тики той же формулой, что сейчас:
```text
ticks60 = round(blocks * 40 / 57)
```
Хранить `ticks60` отдельно всё же полезно: в будущем момент окончания
сценарной реплики можно будет уточнить независимо от технического хвоста PCM.
Генератор обязан печатать предупреждение, если явно заданные тики заметно
отличаются от длительности файла.
## 5. Размещение индекса в памяти
Загружать таблицу в обычный изменяемый `static` нельзя. Данные банковых
модулей сейчас попадают в общий `_DATA`/W2, а свободная куча составляет около
238 байт. Даже таблица из 228 байт практически уничтожит этот запас.
Индекс следует держать в одной EMM-странице:
1. `pop_music_init()` выделяет блок из одной страницы;
2. загружает туда первые 512 байт `MUS\MUSIC.IDX`;
3. проверяет magic, версию, размеры полей и формат PCM;
4. готовит страницу для безопасного временного отображения в W0;
5. сохраняет в W2 только номер блока/страницы и флаг готовности.
Потеря 16 КБ EMM ради маленькой таблицы допустима: EMM у игры с запасом, а
W1/W2 — самый дефицитный ресурс. Не следует ради экономии страницы прятать
индекс в хвост `kid.ani` или звукового набора: это создаст ненужную связь
между независимыми ресурсами.
Доступ к записи предоставляет банковая функция наподобие:
```c
int8_t pop_music_info(uint8_t id, pop_music_info_t *out) __banked;
```
Она на короткое время отображает страницу IDX в W0, копирует четыре байта в
буфер вызывающего и сразу восстанавливает окно. Наружу указатель на EMM не
выдаётся.
Индекс живёт до выхода из программы. `pop_music_free()`, который вызывается
между сценами, освобождать его не должен; для полного завершения нужен
отдельный shutdown либо освобождение в общем маршруте выхода.
## 6. Изменения загрузчика музыки
`pop_music_load_begin(id)` должен получать из runtime-индекса:
- наличие трека;
- число блоков;
- вычисленное число EMM-страниц.
Далее существующая архитектура почти не меняется:
- `slot_blocks[]` уже хранит runtime-длину загруженного трека;
- `pop_mus_left` получает её при `play`/`stream`;
- насос сам останавливается на правильном блоке;
- `pop_music_busy()` автоматически отражает фактический конец;
- кольцевой проигрыватель получает правильные 82 страницы MT-32 m56 вместо
78 страниц FLAC.
При каждом открытии `mNN.bin` надо без дополнительного `open` проверить
фактический размер:
1. `lseek(fd, 0, SEEK_END)`;
2. убедиться, что размер положительный и кратен 128;
3. сравнить его с `blocks * 128` из IDX;
4. вернуть позицию через `lseek(fd, 0, SEEK_SET)`.
`lseek(SEEK_END)` уже реализован в libc поверх DSS `MOVE_FP $15` и
используется самой игрой для `POP.CFG`.
Рекомендуемая политика несовпадения:
- вывести диагностическое сообщение;
- считать фактический размер файла главным;
- пересчитать `blocks/pages/ticks60` в загруженной EMM-копии индекса;
- продолжить работу, если размер проходит ограничения.
Так случайно забытый старый IDX не приведёт к чтению чужой EMM-страницы или
обрыву трека. При этом штатная поставка обязана всегда включать согласованные
IDX и PCM.
## 7. Runtime-тики без 32-битного переполнения
Если тики приходится восстанавливать из фактического размера, прямое
`blocks * 40` может переполнить `uint16_t`. Та же формула считается только
16-битной арифметикой:
```c
ticks = (blocks / 57) * 40
+ ((blocks % 57) * 40 + 28) / 57;
```
Даже при `blocks == 65535` промежуточные значения остаются в `uint16_t`.
Это холодный путь, поэтому небольшая цена деления допустима.
## 8. Перевод сценариев с compile-time на runtime
### PV-сцена (`pop_intro.c`)
Текущий большой `enum` смешивает два класса величин:
- неизменные интервалы сценария;
- абсолютные точки, сдвигаемые длительностями m50 и m53.
Надо сохранить относительные сценарные константы, а при входе в PV-сцену
один раз собрать локальную структуру `pv_timing_t`:
```text
m50_end = MUS_2_START + ticks(m50)
wait_end = m50_end + 40
...
dialog1_start = предыдущая фиксированная цепочка
exit_start = dialog1_start + ticks(m53)
anim_end = exit_start + 469
```
Структуру лучше держать на стеке `intro_pv_animated()` и передавать нужным
helper-функциям указателем. File-scope изменяемая таблица снова попала бы в
W2.
m50 уже загружен до начала PV. m53 начинает подгружаться задолго до своего
старта, поэтому метаданные обоих треков к моменту использования доступны.
### Конец уровня (`pop_kid.c`)
Вместо `POP_MUS_TICKS_32/41` редкое событие окончания уровня вызывает
банковый accessor IDX и вычисляет `pop_endmus_left` из runtime-тиков.
Индекс используется даже при выключенной музыке: оригинал выдерживает эту
паузу молча. Поэтому нельзя заменять её одним `pop_music_busy()`.
## 9. Генератор и сборка
`tools/pop_pack_music.py` должен:
1. как сейчас преобразовать выбранный набор в `mNN.bin`;
2. проверить кратность каждого результата 128 байтам;
3. собрать плотные записи 0..56;
4. записать `MUS/MUSIC.IDX` в формате `PMI1`;
5. больше не генерировать `pop_music_tbl.h` и `pop_music_ticks.h`.
Makefile должен:
- добавить `MUS/MUSIC.IDX` в `DISK` и staging;
- убрать музыкальные generated-header'ы из `GEN_H`;
- убрать их из зависимостей EXE;
- оставить `MUSIC_FMT` зависимостью только музыкальных ресурсов;
- гарантировать, что `make music-flac/mp3/ogg/mt32` заменяет и PCM, и IDX,
но не перелинковывает EXE.
Критерий архитектуры: SHA/дата `sprpop.exe` не меняется при переключении
между четырьмя музыкальными целями.
## 10. Ошибки и совместимость
Игра не должна падать из-за необязательной музыки:
- нет IDX — музыка отключена, игра продолжает работать;
- неверная magic/версия/формат — музыка отключена с диагностикой;
- записи нет — конкретный трек считается отсутствующим;
- файла нет — запрос трека завершается молча/с диагностикой, как сейчас;
- обычный трек требует больше 20 страниц — не загружать обычным путём;
- потоковый трек имеет больше 255 страниц — отвергнуть, потому что текущие
счётчики страниц восьмибитные;
- IDX и файл расходятся — применить политику §6;
- все файловые операции выполнять через `POP_PATH_CALL`, чтобы сохранить
работу на старых DSS с повреждением текущего каталога.
Для отсутствующего/повреждённого IDX остаётся выбрать поведение немой паузы
конца уровня: нулевая пауза либо небольшой канонический fallback. Это не
мешает основной архитектуре, но решение надо принять до реализации.
## 11. Этапы реализации
### MI0 — формат и host-тест
- вынести writer/reader `PMI1` в тестируемый код упаковщика;
- проверить magic, размеры, LE-поля, нулевые записи и padding;
- для всех четырёх наборов сверить `blocks/ticks` с фактическими PCM;
- зафиксировать тестом значения MT-32 m50/m53/m56 как отличающиеся от FLAC.
### MI1 — runtime-загрузка IDX
- `pop_music_init()`/shutdown;
- одна EMM-страница, загрузка и валидация;
- accessor одной записи;
- отказ без порчи W0/W3, EMM и файловых дескрипторов.
### MI2 — loader/pump
- заменить `pop_mus_tbl[]` runtime-записью;
- проверять реальный размер файла;
- обычный, немедленный и кольцевой пути должны использовать одну метаинформацию;
- удалить `pop_music_tbl.h`.
### MI3 — runtime-шкала сцен
- перевести m50/m53 в `pop_intro.c` на локальную runtime-шкалу;
- перевести m32/m41 в `pop_kid.c`;
- удалить `pop_music_ticks.h`.
### MI4 — сборка и образы
- добавить IDX в каждый музыкальный набор;
- исключить музыку из зависимостей линковки;
- собрать четыре HDD-варианта с одним EXE.
### MI5 — приёмка в MAME/на железе
- FLAC: title -> story -> PV, обычный конец уровня, конец уровня 4, ending;
- MT-32: те же маршруты, особенно m50/m53 и полный m56;
- музыка выключена: пауза конца уровня берётся из IDX;
- замена набора без пересборки EXE;
- повреждённый IDX, отсутствующий трек, несовпадающий размер;
- контроль, что не запущено более одного MAME и предыдущий экземпляр закрыт
перед новым прогоном.
## 12. Критерии готовности
- один бинарник запускается со всеми четырьмя наборами;
- ни один музыкальный generated-header не входит в сборку C;
- начало следующей сцены/уровня соответствует фактической записи;
- MT-32 m56 проигрывает все 82 страницы и не обрывается как FLAC-вариант;
- обычные треки не читают за пределами выделенного EMM-блока;
- IDX не расходует сотни байт W2;
- ошибочный набор отключает музыку безопасно;
- переключение `music-*` не меняет EXE;
- старые DSS продолжают работать через восстановление каталога приложения.
---
# Часть II. Звуковые эффекты без перелинковки
## 13. Цель и отличие от музыки
Один и тот же `sprpop.exe` должен работать с разными наборами PCM-эффектов,
если в них сохранена исходная нумерация sound id 0..56. Замена набора должна
сводиться к замене согласованной пары `SND/snd.idx` + `SND/snd.arc`, без
генерации C-заголовка и перелинковки EXE.
Набор состоит из двух согласованных файлов:
- `SND/snd.arc` — обычный архив PBA1 с PCM-страницами;
- `SND/snd.idx` — описание раскладки эффектов внутри этих страниц.
Отдельный IDX предпочтительнее расширения заголовка `snd.arc`: PBA1 остаётся
универсальным и не получает специального варианта только для звука, а
формат индекса можно независимо версионировать и проверять тем же способом,
что будущий `MUS/MUSIC.IDX`.
Существующий формат записи менять не требуется:
```c
typedef struct {
uint8_t page;
uint16_t off;
uint16_t len;
} pop_snd_ent_t;
```
Это ровно необходимые проигрывателю номер логической страницы, смещение в
ней и длина PCM. В текущем ABI SDCC/z80 запись занимает 5 байт; дисковый
формат обязан описывать эти пять байт явно (`uint16` little-endian), а код
должен проверять `sizeof(pop_snd_ent_t) == 5` на этапе сборки.
## 14. Подтверждённое текущее состояние
Сейчас `tools/pop_pack_sound.py` печатает раскладку в
`gen/pop_sound_tbl.h`, а `pop_sfx.c` включает её как
`static const pop_snd_tbl[57]`. Поэтому конкретные `page/off/len` становятся
частью EXE.
В текущей сборке таблица занимает 285 байт в `_CODE`, по адресам
`0x52D8..0x53F4`. Это общий резидентный диапазон игры: при huge-модели
`_CODE`, `_DATA`, heap и stack совместно используют плоские 32 КБ
`0x4000..0xBFFF`; граница W1/W2 отдельного бюджета здесь не создаёт.
Проверены оба имеющихся исходных набора DIGISND:
| набор | PCM-эффектов | страниц после упаковки | отличия раскладки |
|---|---:|---:|---:|
| MSDOS 1.3/1.4 | 31 из 57 | 10 | эталон текущей сборки |
| SDLPoP 1.0/1.1 | 31 из 57 | 9 | отличаются 8 id |
Максимальный эффект занимает 17 664 байта, или 138 блоков по 128 байт.
У SDLPoP эффект 48 содержит только 7 исходных сэмплов; это свойство самого
набора, а не ошибка runtime-индекса.
## 15. Файл `SND/snd.idx`
IDX занимает один 512-байтовый сектор и после короткого заголовка является
точным дисковым дампом 57 записей `pop_snd_tbl`:
```text
0..3 "PSI1" magic и версия индекса эффектов
4 57 число плотных записей, id 0..56
5 5 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат: unsigned 8-bit mono, CBL_FREQ_10K9
8 pages фактическое число PCM-страниц в snd.arc
9..15 0 резерв
16..300 57 записей по 5 байт:
+0 uint8 page
+1..2 uint16 off, little-endian
+3..4 uint16 len, little-endian
301..511 0 резерв до одного сектора
```
`pages` — фактический размер конкретного набора, например 10 для MSDOS или
9 для SDLPoP. Его не следует называть или трактовать как
`POP_SND_MAX_PAGES`: максимум 16 является compile-time-вместимостью
загрузчика, а IDX сообщает число реально нужных страниц и обязан укладываться
в этот предел.
Загрузчик сверяет `pages` с числом элементов внешнего PBA1 и не доверяет
расходящимся файлам. `off` и `len` обязаны быть кратны 128; нулевая длина
означает отсутствие PCM для данного id.
IDX загружается прямо в резидентный `pop_snd_tbl`, поэтому отдельной страницы
EMM для него не требуется. Цена решения — одно дополнительное открытие и
чтение 512 байт при старте звука; на фоне загрузки 9–10 страниц PCM это
приемлемый холодный расход.
## 16. Размещение runtime-таблицы в памяти
`pop_snd_tbl` становится обычным изменяемым внутренним объектом:
```c
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
```
Его следует определить в отдельном internal data-модуле и объявить через
`extern` в `_pop_sfx.h`, потому что таблицу заполняет холодный загрузчик из
банка 8, а читает резидентный `pop_sfx.c`.
После снятия `const` SDCC перенесёт эти 285 байт из `_CODE` в `_DATA`. Это
не добавляет 285 байт к общему резидентному расходу: `_CODE` одновременно
уменьшается на тот же размер, а `_CODE` и `_DATA` последовательно лежат в
одном диапазоне `0x4000..0xBFFF`. Возможна лишь небольшая разница из-за
выравнивания, которую надо проверить итоговой map-картой и size-check.
Банк 8 может читать индекс напрямую в этот буфер: весь диапазон
`0x4000..0xBFFF` остаётся доступен, пока банковый код исполняется в W3.
Горячие обращения `pop_snd_tbl[id].page/off/len` и ISR не требуют новых
маппингов или accessor-функций.
## 17. Фактическое число страниц
Инвариантами программы остаются:
```c
#define POP_SND_MAX_PAGES 16
#define POP_SND_COUNT 57
#define POP_SND_BLOCK 128
```
`POP_SND_PAGES == 10` больше не должен означать размер конкретного набора.
Вместо него появляется runtime-состояние:
```c
uint8_t pop_snd_pages;
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
```
Загрузчик выделяет `mem_alloc_pages(pop_snd_pages)` и заполняет только
фактическое число элементов массива. MSDOS-набор займёт 10 страниц,
SDLPoP-набор — 9. Предел 16 оставляет запас будущим наборам без
перекомпиляции и увеличивает постоянный массив лишь на 6 байт относительно
текущего; ещё один байт занимает `pop_snd_pages`.
Насос при переходе длинного эффекта через границу страницы сравнивает
`sfx_pg + 1` с `pop_snd_pages`, а не с compile-time-константой.
## 18. Загрузка и валидация
`pop_sfx_init()` должен выполнять следующую последовательность:
1. открыть `SND/snd.idx` через существующий путь `POP_PATH_CALL`;
2. прочитать 16-байтовый заголовок, затем 285 байт записей прямо в
`pop_snd_tbl`, и проверить полный размер IDX;
3. проверить magic, версию, число/размер записей, формат и размер блока;
4. получить из IDX фактическое число страниц и проверить диапазон 1..16;
5. открыть `SND/snd.arc` через `POP_PATH_CALL`;
6. прочитать обычную таблицу PBA1 с ёмкостью `POP_SND_MAX_PAGES`;
7. сверить `PSI1.pages` с числом элементов PBA1;
8. проверить каждую ненулевую запись;
9. только после этого выделить фактическое число EMM-страниц и загрузить их.
Для каждой записи проверяются:
- `page < pop_snd_pages`;
- `off < 16384` и `off % POP_SND_BLOCK == 0`;
- `len % POP_SND_BLOCK == 0`;
- диапазон `page/off/len` не выходит за загруженный набор;
- переход длинного эффекта на следующие страницы не превышает
`pop_snd_pages`.
При любой ошибке блок EMM и файловый дескриптор освобождаются, таблица не
используется, эффекты остаются выключенными. Набор без `snd.idx` считается
неполным; хранить в EXE старую таблицу как fallback не следует, иначе
зависимость бинарника от конкретного набора останется.
## 19. Генератор и сборка
`tools/pop_pack_sound.py` должен:
1. как сейчас разобрать DIGISND, привести PCM к CBL_FREQ_10K9 и выровнять
начала/длины на 128 байт;
2. вычислить фактическое число страниц и отвергнуть набор больше 16;
3. сформировать плотные 57 записей `page/off/len`;
4. записать `SND/snd.idx` в формате `PSI1`, включая фактическое число
страниц;
5. собрать обычный `SND/snd.arc` из полученных страниц;
6. больше не генерировать `gen/pop_sound_tbl.h`.
Чтобы число страниц не было захардкожено списком `s0.bin`..`s9.bin` в
Makefile, упаковщик звука предпочтительно должен сразу формировать
`assets/packed/SND/snd.arc` либо передавать упаковщику архивов динамический
список результатов без сохранения устаревшей десятой страницы.
Стабильные тип и константы переносятся в обычный internal-заголовок. EXE не
должен зависеть от результата упаковки звука: цель выбора набора меняет
согласованную пару `snd.idx` + `snd.arc`.
## 20. Этапы реализации эффектов
### SI0 — формат и host-тест
- writer/reader отдельного `snd.idx` в формате `PSI1`;
- проверка точного пятибайтового LE-формата записи;
- сборка и разбор обоих имеющихся DIGISND-наборов;
- проверки 10 страниц MSDOS, 9 страниц SDLPoP и отличающихся записей.
### SI1 — runtime-таблица
- изменяемый `pop_snd_tbl` в общем резидентном CODE/DATA-диапазоне;
- загрузка и полная валидация `PSI1`;
- удаление generated `pop_sound_tbl.h`;
- проверка map-карты: перенос CODE -> DATA не должен съесть heap.
### SI2 — гибкое выделение страниц
- `POP_SND_MAX_PAGES == 16` и runtime `pop_snd_pages`;
- фактический размер стека таблицы PBA1;
- выделение/освобождение 9, 10 и тестовых 16 страниц;
- runtime-гард перехода длинного эффекта между страницами.
### SI3 — сборка без перелинковки
- убрать фиксированный `SND_ATL` из десяти имён;
- сделать `snd.idx` и `snd.arc` согласованными результатами выбора набора;
- проверить неизменность SHA/даты `sprpop.exe` при смене набора.
### SI4 — приёмка
- проиграть короткий, обычный и переходящий страницу эффекты;
- проверить приоритеты и перебивание звуков;
- проверить музыку поверх общего CBL после runtime-загрузки эффектов;
- отсутствующий IDX и повреждённые magic/count/pages/page/off/len должны
безопасно отключать эффекты;
- перед каждым MAME-прогоном завершать предыдущий экземпляр и никогда не
запускать две копии одновременно.
## 21. Критерии готовности эффектов
- один `sprpop.exe` работает с MSDOS- и SDLPoP-наборами;
- замена согласованной пары `SND/snd.idx` + `SND/snd.arc` не требует
компиляции C и не меняет EXE;
- `pop_snd_tbl` больше не генерируется как C-код;
- загружается фактическое число страниц в диапазоне 1..16;
- таблица не расходует дополнительную страницу EMM и не увеличивает
суммарный резидентный CODE+DATA на свои 285 байт;
- горячий путь и ISR используют прежние прямые `page/off/len`;
- повреждённый индекс не приводит к чтению за пределами EMM-блока;
- файловые операции сохраняют совместимость со старыми DSS через
восстановление каталога приложения.
+329 -6
View File
@@ -163,10 +163,16 @@ B имеет смысл только если C окажется неподъё
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница 1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
есть, и она влияет на выбор источника. есть, и она влияет на выбор источника.
### Оцифровка: берём MSDOS ### Оцифровка: по умолчанию SDLPoP, MSDOS — опция
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из **С 2026-08-31 умолчание — SDLPoP** (`SND_SRC=sdlpop`): он выкачивается
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP: автоматически (`make fetch`), а оригинального дистрибутива DOS у сборки
может не быть вовсе — и она обязана работать без него. У кого дистрибутив
есть, включает лучший набор явно: `make SND_SRC=msdos`.
Платим за это ровно тем, что разобрано ниже. Заголовок в поставках разный
(`digi_new_type` против `digi_type`), но **28 звуков из 31 совпадают
побайтно**. Различаются три, и все не в пользу SDLPoP:
| id | звук | MSDOS | SDLPoP | | id | звук | MSDOS | SDLPoP |
|---|---|---:|---:| |---|---|---:|---:|
@@ -174,9 +180,13 @@ B имеет смысл только если C окажется неподъё
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** | | 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
| 48 | `spiked` | 5 069 | **7** — то есть звука нет | | 48 | `spiked` | 5 069 | **7** — то есть звука нет |
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает `spiked` в наборе SDLPoP фактически пустой: это звук насаживания на пики
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики, (`spiked()`, seg005.c:245, кадр 177), и с умолчанием он не звучит — от пик
где источник наоборот SDLPoP. остаётся только шипение выдвижения (49 `spikes`). Звон клинков (10) на
секунду короче, а 11 просто другая запись.
Всё остальное — включая уровни `LEVELS/res20xx.bin` — берётся из SDLPoP
всегда: `MSDOS/` нужен ровно для этих трёх звуков и ни для чего больше.
### MIDI: если дойдём до музыки — брать SDLPoP ### MIDI: если дойдём до музыки — брать SDLPoP
@@ -961,3 +971,316 @@ load_intro у оригинала и так начинается с ожидан
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
за размер кольца — то есть подкачка успевает. за размер кольца — то есть подкачка успевает.
## 9. ЗАДЕРЖКА СТАРТА ЗВУКА: ЗАМЕР (2026-08-31)
Замерено в MAME на живой сборке: точка отсчёта — подстановка заявки
отладчиком при ОСТАНОВЛЕННОЙ машине, конец — условный watchpoint
`wpset 19FF6,1,w,{wpdata != 0}` на старший байт `pop_mus_left`, то есть
момент, когда трек реально зазвучал. Масштаб тактов откалиброван не по
памяти, а по аппаратным часам: период тика насоса CBL — ровно
`128/10937,5 = 11,7029` мс, измерено 245 748 и 245 778 тактов, отсюда
**21,00 МГц** (кадр луча — 429 973 такта, развёртка 48,8 Гц, не 50).
### 9.1 Эффекты стартуют без задержки, музыка — через ~200 мс
Разница принципиальная и вытекает из устройства:
* **Оцифрованные эффекты диска не касаются вовсе.** `pop_sfx_init()`
вызывается ОДИН раз за запуск (`sprpop_cold.c`), вычитывает весь
`SND/snd.arc` в EMM и закрывает файл. Дальше `pop_sfx_play` только
номинирует, а `pop_sfx_tick` берёт `page/off/len` из таблицы в памяти.
Задержка = остаток текущего кадра + ближайший запрос CBL (≤ 11,7 мс).
Держать архив открытым НЕЧЕГО: после старта он не нужен.
* **Музыка читается с диска на каждый трек** — `mNN.bin` лежат
отдельными файлами, и `pop_music_load_begin` открывает свой.
### 9.2 Разложение окна музыки
От заявки (`pop_mus_req`) до звучания (`pop_mus_left != 0`), одна
загрузка по шагам:
| шаг | цена |
|---|---:|
| ожидание `pop_music_service` в кадре | 22,6 мс |
| `chdir` #1 (вход в `POP_PATH_CALL`) | **46,1 мс** |
| `open` файла трека | 34,5 мс |
| `chdir` #2 (восстановление каталога) | **59,0 мс** |
| чтение первых 8 КБ (`bank_read_page`) | 34,0 мс |
| **итого** | **196,7 мс** |
**Главное здесь: два `chdir` стоят 105 мс — больше половины окна, и
каждый дороже самого `open`.** Убрать их нельзя: старые DSS оставляют
рабочий каталог внутри последнего подкаталога, и без восстановления
ломаются относительные пути (ради этого `POP_PATH_CALL` и заведён).
Теоретический запас — снять ОДИН из двух (≈ 50 мс), но это риск для
других мест, которые полагаются на восстановленный каталог.
Для музыки задержка непринципиальна: трек и так начинается на границе
события. Критична она была бы для эффектов — там её нет по построению.
### 9.3 Разброс и режимы скорости
Семь прогонов: **189,3 … 232,8 мс**, среднее 200,1. Величина
ФИЗИЧЕСКАЯ и от режима скорости не зависит, меняется только её пересчёт
в логические кадры:
| режим | логический кадр (замерено) | окно в кадрах |
|---|---:|---:|
| NORMAL вне боя | 81,93 мс = 4,00 кадра луча | 2,31 … 2,84 |
| FAST / FASTEST вне боя | 61,4 мс = 3,00 кадра луча | 3,09 … 3,42 |
Худший случай (232,8 мс в коротком кадре) — 3,79 логического кадра.
Пейсер при этом работает ровно как задумано: `pop_pace_n` заказывает 4
кадра луча вне боя при NORMAL и 3 при FAST/FASTEST, замер это
подтвердил. Подкачка трека кадр НЕ просаживает.
### 9.4 Следствие для паузы конца уровня
Ждать окончания мелодии по одному `pop_music_busy()` НЕЛЬЗЯ: в этом окне
он ещё ложен, и уровень сменился бы до первой ноты. Правильный признак
— состояние «аудио-событие в работе», которое выводится из уже
существующих переменных без новых сущностей:
```c
uint8_t pop_music_active(void) __banked /* банк 9 — там ld_busy */
{
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
}
```
Переход «запрошен → грузится → звучит» снаружи непрерывен: `service`
обнуляет `pop_mus_req` и тут же в `load_begin` взводит `ld_busy`, а
главный цикл между этими операторами не выполняется.
Это лучше фиксированной задержки «на раскачку»: не нужна подобранная
константа, нет зависимости от режима скорости, а при провале загрузки
(нет файла, нет памяти) все три признака ложны и уровень меняется сразу,
вместо слепого ожидания. Заодно чинится расхождение с SDLPoP при
ВЫКЛЮЧЕННОМ звуке: `pop_sfx_play` возвращается рано при `!pop_snd_ok`,
заявка не кладётся, паузы нет — как в оригинале (`seg006:651` ставит
трек только `if (is_sound_on)`, а `seg003:387` ждёт
`check_sound_playing()`). Сейчас `pop_endmus_left` взводится
безусловно, и при выключенном звуке игра держит пройденный уровень
лишние 12 с молча.
## 10. ЭФФЕКТЫ БЕЗ ПЕРЕЛИНКОВКИ: РАСКЛАДКА УЕХАЛА НА ДИСК (2026-08-31)
Сделана часть II плана `music_runtime_index_plan.md` (этапы SI0..SI4).
Раскладка звукового набора больше не компилируется в EXE: пара
`SND/snd.arc` + `SND/snd.idx` меняется целиком, бинарник остаётся прежним.
### 10.1 Что именно изменилось
`gen/pop_sound_tbl.h` УДАЛЁН — упаковщик его не печатает. Вместо него:
* `src/pop_snd_tbl.h` — рукописный: тип записи и инварианты программы
(`POP_SND_COUNT` 57, `POP_SND_BLOCK` 128, `POP_SND_MAX_PAGES` 16,
константы блока тишины). От набора не зависит НИЧЕГО из этого;
* `src/pop_snd_data.c` — резидентные данные: `pop_snd_tbl[57]`,
`pop_snd_page[16]`, `pop_snd_pages`;
* `SND/snd.idx` — дисковый индекс, формат `PSI1` (писатель и разборщик —
`tools/pop_idx.py`, покрыт `make test-tools`, 22 теста);
* `pop_sfx_cold.c` — чтение и полная валидация индекса.
`POP_SND_PAGES` как compile-time размер набора БОЛЬШЕ НЕ СУЩЕСТВУЕТ. Его
заменили две разные величины: `POP_SND_MAX_PAGES` — вместимость
загрузчика (16, запас под чужие наборы), и runtime `pop_snd_pages` —
сколько страниц у ЭТОГО набора. Насос сверяет переход длинного эффекта
через границу страницы со вторым, а не с первым.
### 10.2 Формат `PSI1` и почему запись ровно 5 байт
Один сектор DSS: 16 байт заголовка (магия, число записей, размер записи,
log2 блока, формат PCM, число страниц) и 57 записей по 5 байт —
`page` (u8), `off` (u16 LE), `len` (u16 LE), — то есть побайтовый образ
`pop_snd_tbl[]`. Записи читаются ОДНИМ `read` прямо в резидентную
таблицу, без разбора по полям.
Поэтому `sizeof(pop_snd_ent_t) == 5` в ABI SDCC z80 — часть ДИСКОВОГО
контракта, а не деталь компилятора. Ловится с двух сторон: статически
(`pop_snd_data.c`) и полем размера записи в заголовке, которое загрузчик
сверяет со своим `sizeof`.
### 10.3 Цена в памяти
Таблица была `static const` в `_CODE`, стала изменяемой в `_DATA`.
Замер до/после:
| | было | стало | Δ |
|---|---:|---:|---:|
| `_CODE` | 23865 | 23582 | 283 |
| данные | 7128 | 7421 | +293 |
| куча резидента | 239 Б | 229 Б | 10 |
| банк 8 | 14824 | 15425 | +601 |
Суммарный резидентный расход почти не изменился — в huge-модели `_CODE`
и данные лежат подряд в одном диапазоне `0x4000..0xBFFF`. Реальные
10 байт — это расширение `pop_snd_page` с размера набора до
`POP_SND_MAX_PAGES` плюс байт счётчика.
### 10.4 Валидация: чему именно нельзя доверять
Индекс — файл на диске, и битый индекс увёл бы насос за пределы
выделенного блока EMM. Проверяются заголовок целиком и КАЖДАЯ запись:
`page < pop_snd_pages`, `off` внутри страницы, кратность `off`/`len`
блоку 128, непересечение с блоком тишины и то, что длинный эффект не
выходит за последнюю страницу набора.
Последняя проверка считается В БЛОКАХ, а не в байтах: байтовый адрес
конца (`page * 16384 + off + len`) не влезает в `uint16`, а 32-битная
арифметика на Z80 дорога. В блоках весь набор — не больше
`16 * 128 = 2048`, и всё считается шестнадцатью битами.
Число страниц из индекса дополнительно сверяется с числом элементов в
`snd.arc`: расходящаяся пара означает, что файлы из разных наборов.
### 10.5 Нет индекса — эффектов нет, но МУЗЫКА ОСТАЁТСЯ
Первая версия просто возвращала ошибку, и это оказалось непроходимой
игрой: тишину в паузах насос льёт из первого блока набора, поэтому без
набора CBL не открывался вовсе — а вместе с ним вставала музыка, чьи
блоки отсчитывает тот же насос. Заставка ждала конца трека, который не
начинал играть, и `pop_music_busy()` не гас никогда.
Теперь при отказе индекса поднимается ПУСТОЙ набор: одна страница EMM с
блоком тишины. Все `len` нулевые, поэтому `pop_sfx_play` уходит по ветке
«нет оцифровки» — эффекты молчат, музыка играет, игра проходима.
Две тонкости этой заливки, обе выяснены на живой машине:
1. **Заливается только первый блок (128 байт), а не вся страница.**
`gfx_w0_page_prepare` ставит в страницу IRQ/NMI-стабы, чтобы
прерывание при замапленном W0 нашло валидный код. Заливка всей
страницы затирает их, и первое же прерывание уводит процессор в
мусор — чёрный экран на старте. Насосу больше и не нужно: он берёт
ровно блок тишины, да и то через W3.
2. **Запись под `IRQ_DISABLE`** — 128 байт накрывают стаб RST38, и до
`gfx_w0_unmap` прерываний быть не должно.
### 10.6 Грабли сборки: секундная гранулярность mtime
Смена `SND_SRC` тихо давала НЕВЕРНЫЙ результат: `sdlpop -> msdos ->
sdlpop` перепаковывал `build/atl`, а в `assets/packed` оставался прежний
набор. Причина не в логике, а во времени: упаковка укладывается в одну
секунду с записью stamp'а, а `make` считает цель, сделанную в ту же
секунду, что и зависимость, актуальной. С паузой в секунду не
воспроизводится вовсе.
Лечится двумя приёмами, и оба убирают время из решения:
* смена варианта СНОСИТ stamp'ы своего семейства (`rm -f
$(STAMP)/sound-*`) — нет файла, значит правило обязано выполниться;
* упаковка, сборка архива и копия индекса делаются ОДНИМ рецептом:
внутри рецепта сравнивать нечего.
То же лечение получила и музыка (`MUSIC_FMT`) — механизм общий.
### 10.7 Что проверено на живой машине
* таблица в памяти совпадает с файлом из образа ПОБАЙТОВО (285 байт);
* один и тот же EXE (sha256 не меняется) поднимает SDLPoP (9 страниц) и
MSDOS (10 страниц);
* СТРОГАЯ проверка: у двух реальных наборов совпадают 23 записи из 31,
поэтому сделан отладочный ключ `pop_pack_sound.py --order reverse` —
набор с обратным порядком укладки, где от штатного отличаются 30 из 31
записей. Игра на нём звучит правильно, значит раскладка берётся с
диска, а не из памяти;
* битый индекс (испорченная магия) — эффекты выключаются, игра жива;
* отсутствующий индекс — музыка играет, эффектов нет, PV-сцена проходит;
* Ctrl+S в обоих режимах работает штатно.
Маркер для ручной проверки: звук 51 `princess_door_opening` (дверь в
сцене с принцессой) лежит у наборов по РАЗНЫМ страницам — 8 у SDLPoP,
9 у MSDOS. А вот 4 `gate_closing` из той же сцены совпадает побайтно и
для проверки подмены не годится.
## 11. МУЗЫКА БЕЗ ПЕРЕЛИНКОВКИ: ДЛИНЫ И ДЛИТЕЛЬНОСТИ УЕХАЛИ НА ДИСК (2026-08-31)
Сделана часть I плана `music_runtime_index_plan.md` (этапы MI0..MI5, у нас
B0..B7). `gen/pop_music_tbl.h` и `gen/pop_music_ticks.h` УДАЛЕНЫ: длины
треков и длительности реплик читаются из `MUS/mus.idx` (формат `PMI1`).
Один и тот же `sprpop.exe` работает с любым из четырёх наборов записей.
### 11.1 Где живёт индекс и почему именно там
228 байт таблицы в W2 положить нельзя — там свободной кучи порядка двух
сотен. Поэтому индекс лежит в ОДНОЙ странице EMM, а в резиденте от него
остаются два байта (номер блока и страницы). Наружу указатель на EMM не
выдаётся: `pop_music_info()` подключает страницу в W0, копирует четыре
байта в буфер вызывающего и сразу снимает окно.
**Данные в странице лежат со смещения `POP_MUS_IDX_BASE` (0x100), а не с
нуля.** Причина в `gfx_w0_page_prepare`: он делает страницу безопасной
для W0, записывая В НЕЁ переход на IRQ-восстановитель (0x38) и RETN для
NMI (0x66). С нуля эти байты легли бы прямо в записи (id 10 и 21) и
испортили индекс; со смещением стабы и данные не пересекаются, и работает
штатная защита от прерываний, а не запрет их. Тот же приём и по той же
причине — в `pop_config.c` (`CFG_BASE`).
Числа страниц в индексе НЕТ: это производная от `blocks`, и дублирование
однажды разъехалось бы с ним. Считает `pop_music_pages()`.
### 11.2 Пауза конца уровня: состояние вместо счётчика
`pop_endmus_left` и `POP_MUS_TICKS_32/41` УДАЛЕНЫ. Главный цикл ждёт
`pop_music_active()` — «заявка лежит, идёт загрузка или трек звучит».
Одного `pop_music_busy()` там мало: между заявкой и первой нотой проходит
190230 мс (§9), и в этой щели он ложен. Раньше щель закрывал счётчик по
длине записи, но он тянул за собой две беды: зависимость EXE от набора и
жёсткий делитель `/4`, который врал в быстрых режимах (там логический кадр
3 кадра луча, а не 4). Три признака подряд закрывают щель без единой
подобранной константы, потому что `pop_music_service` обнуляет `req` и
тут же взводит `ld_busy`, а тот держится до записи `pop_mus_left`.
Побочно исправилось расхождение с оригиналом: при ВЫКЛЮЧЕННОМ звуке
`pop_sfx_play` не кладёт заявку, все три признака ложны, и уровень
меняется сразу — как в SDLPoP, где трек ставится под `if (is_sound_on)`
(seg006:651), а ожидание это `check_sound_playing()` (seg003:387).
Прежний счётчик взводился безусловно и держал пройденный уровень лишние
12 секунд в тишине.
### 11.3 PV-сцена: четыре якоря вместо compile-time шкалы
Шкала сцены осталась абсолютной (переход на ожидание звука — отдельный
разговор, см. план), но точки, зависящие от длины реплик, считаются при
входе в сцену: `pv_m50_end`, `pv_dialog1`, `pv_exit`, `pv_anim_end`.
Остальные выражения не изменились — они отсчитываются от якорей.
Восемь байт статики вместо протаскивания структуры времён через пять
функций (`pv_princess_step`, `pv_jaffar_step`, `pv_seq_period`,
`intro_pv_draw_frame`, `intro_pv_animated`), как предлагал план: сцена
одна на запуск, и переделка всех сигнатур того не стоит.
Нет индекса — длительности нулевые, и сцена просто идёт без пауз на
реплики: ждать нечего, звука-то нет.
### 11.4 Цена в памяти
За обе фазы (эффекты и музыка) резидент не вырос, а освободился:
| | до фазы A | после фазы B |
|---|---:|---:|
| `_CODE` | 23865 | 23544 |
| куча резидента | 239 Б | 256 Б |
Банк 9 похудел на 118 Б (ушла `pop_mus_tbl` из его rodata), банк 11 — на
длительности реплик. Прибавка: 2 байта на страницу индекса музыки и 8 на
якоря PV.
### 11.5 Что проверено на живой машине
* `sprpop.exe` ПОБАЙТОВО одинаков для наборов flac и mt32;
* все 22 трека в индексах различаются, и контрольные значения совпали с
тем, что план предсказывал по замерам: m32 438→449, m41 732→685,
m50 831→867, m53 985→1044, m56 9865→10462 блоков (78→82 страницы);
* на mt32-наборе PV-сцена проходит целиком: m50 → m53 → m52 звучат по
длительностям mt32, хотя в EXE их больше нет;
* без `mus.idx` музыки нет, эффекты работают, игра проходима — сцены
проскакивают без пауз на реплики.
**НЕ ПРОВЕРЕНО:** потоковый трек m56 на 82 страницах (у flac 78).
Кольцевой проигрыватель получает число страниц из индекса, и код к этому
готов, но живого прогона финала на mt32 не было — до него надо дойти в
игре. Это оставшийся пункт приёмки.
-41
View File
@@ -1,41 +0,0 @@
/* kid_atlas.h — раскладка атласов Kid. Сгенерировано pop_pack_kid.py. */
#ifndef KID_ATLAS_H
#define KID_ATLAS_H
#define KID_SHIFT 3
#define KID_MASK 7
#define KID_PAGES 28
#define KID_PAL 0x70
static const char *const kid_atl[KID_PAGES] = {
"kid0.atl",
"kid1.atl",
"kid2.atl",
"kid3.atl",
"kid4.atl",
"kid5.atl",
"kid6.atl",
"kid7.atl",
"kid8.atl",
"kid9.atl",
"kid10.atl",
"kid11.atl",
"kid12.atl",
"kid13.atl",
"kid14.atl",
"kid15.atl",
"kid16.atl",
"kid17.atl",
"kid18.atl",
"kid19.atl",
"kid20.atl",
"kid21.atl",
"kid22.atl",
"kid23.atl",
"kid24.atl",
"kid25.atl",
"kid26.atl",
"kid27.atl",
};
#define KID_PAL_FILE "kid.pal"
#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */
#define KID_SWORD_ID0 0 /* индекс в атласе = sword_tbl.id - ID0 */
#endif
+3 -13
View File
@@ -1,5 +1,5 @@
/* pop_bg_atlas.h — раскладка атласов статического фона PoP. /* pop_bg_atlas.h — раскладка атласов статического фона PoP.
* Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную. * Сгенерировано tools/pop_pack_bg.py — НЕ править вручную.
* *
* Прямая адресация (ноль remap-таблиц в W2): * Прямая адресация (ноль remap-таблиц в W2):
* ENV фон id N -> atlas env_bg[N>>4], idx N&15 * ENV фон id N -> atlas env_bg[N>>4], idx N&15
@@ -23,25 +23,15 @@
#define POP_PAL_ENV 0x50 #define POP_PAL_ENV 0x50
#define POP_PAL_WALL 0x60 #define POP_PAL_WALL 0x60
/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы /* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена
* видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия * видит только модуль загрузки, чтобы не размножать строки. */
* строк уедет в каждый включивший заголовок модуль. */
#ifdef POP_BG_ATLAS_NAMES #ifdef POP_BG_ATLAS_NAMES
static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {
{ "pop_env0.atl", "pop_env1.atl", "pop_env2.atl", "pop_env3.atl", "pop_env4.atl", "pop_env5.atl", "pop_env6.atl", "pop_env7.atl", "pop_env8.atl", "pop_env9.atl" },
{ "pal_env0.atl", "pal_env1.atl", "pal_env2.atl", "pal_env3.atl", "pal_env4.atl", "pal_env5.atl", "pal_env6.atl", "pal_env7.atl", "pal_env8.atl", "pal_env9.atl" },
};
static const char *const pop_wall_atl[POP_TILESETS] = { "pop_wall.atl", "pal_wall.atl" };
static const char *const pop_fore_atl[POP_TILESETS] = { "pop_fore.atl", "pal_fore.atl" };
static const char *const pop_tile_pal[POP_TILESETS] = { "pop_tile.pal", "pal_tile.pal" }; static const char *const pop_tile_pal[POP_TILESETS] = { "pop_tile.pal", "pal_tile.pal" };
#endif /* POP_BG_ATLAS_NAMES */ #endif /* POP_BG_ATLAS_NAMES */
#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */
#define POP_PAL_POT 0x40
/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала), /* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),
зелёный (перо/переворот) и синий (вред/открыть) = те же кадры зелёный (перо/переворот) и синий (вред/открыть) = те же кадры
под id 30..36 и 40..46 (draw_tile_anim, seg008:652). */ под id 30..36 и 40..46 (draw_tile_anim, seg008:652). */
#define POP_POT_BUBB_GREEN 30 #define POP_POT_BUBB_GREEN 30
#define POP_POT_BUBB_BLUE 40 #define POP_POT_BUBB_BLUE 40
#define POP_BG_PAL "pop_bg.pal"
#endif #endif
-104
View File
@@ -1,104 +0,0 @@
/*
* pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.
* Источник: assets/orig/PoP1_DOS_music/flac (записи DOS-версии).
* Генератор: tools/pop_pack_music.py. Руками не править.
*
* Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —
* сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека
* в порциях насоса по 128 байт (в байтах не влезло бы в uint16).
*
* Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица
* ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,
* платить за неё копией таблицы незачем (поймано на pop_intro.c:
* +172 байта в банк 11).
*
* POP_MUS_TICKS_<id> — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).
* Нужна там, где сценарий ждёт окончания реплики: у оригинала это
* `do { frame(1) } while (check_sound_playing())`, у нас — пауза
* длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум
* причинам: blocks*40 переполняет uint16 на won (9855 порций), и
* число обязано соответствовать НАБОРУ, которым собрали (flac и
* mt32 — разные исполнения разной длины).
*
* Отношение 40/57 — обратное к тому, которым шкала сцены переводит
* тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,
* а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы
* считались одной и той же арифметикой. Округление — к ближайшему
* (+28 перед делением): отбрасывание вниз теряло по тику на реплику.
*/
#ifndef POP_MUSIC_TBL_H
#define POP_MUSIC_TBL_H
#include <stdint.h>
#define POP_MUS_COUNT 57
#define POP_MUS_BLOCK 128
#define POP_MUS_MAX_PAGES 78
typedef struct {
uint8_t pages; /* 0 — трека нет */
uint16_t blocks; /* длина в порциях по 128 байт */
} pop_mus_ent_t;
static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {
{ 0, 0 }, /* 0 */
{ 0, 0 }, /* 1 */
{ 0, 0 }, /* 2 */
{ 0, 0 }, /* 3 */
{ 0, 0 }, /* 4 */
{ 0, 0 }, /* 5 */
{ 0, 0 }, /* 6 */
{ 0, 0 }, /* 7 */
{ 0, 0 }, /* 8 */
{ 0, 0 }, /* 9 */
{ 0, 0 }, /* 10 */
{ 0, 0 }, /* 11 */
{ 0, 0 }, /* 12 */
{ 0, 0 }, /* 13 */
{ 0, 0 }, /* 14 */
{ 0, 0 }, /* 15 */
{ 0, 0 }, /* 16 */
{ 0, 0 }, /* 17 */
{ 0, 0 }, /* 18 */
{ 0, 0 }, /* 19 */
{ 0, 0 }, /* 20 */
{ 0, 0 }, /* 21 */
{ 0, 0 }, /* 22 */
{ 0, 0 }, /* 23 */
{ 3, 305 }, /* 24 regular_death */
{ 2, 196 }, /* 25 find_mirror_and_find_shadow */
{ 9, 1132 }, /* 26 embrace */
{ 8, 915 }, /* 27 cutscene_before_2_4_6_12 */
{ 6, 656 }, /* 28 fight_death */
{ 4, 428 }, /* 29 meet_Jaffar */
{ 3, 299 }, /* 30 life_potion */
{ 0, 0 }, /* 31 */
{ 5, 624 }, /* 32 end_level_4_and_killed_by_shadow */
{ 2, 205 }, /* 33 potion */
{ 0, 0 }, /* 34 */
{ 7, 888 }, /* 35 cutscene_before_8_9 */
{ 10, 1256 }, /* 36 out_of_time */
{ 3, 280 }, /* 37 guard_death_and_obtaining_the_sword */
{ 0, 0 }, /* 38 */
{ 13, 1591 }, /* 39 floating */
{ 5, 596 }, /* 40 cutscene_not_much_time_left */
{ 9, 1043 }, /* 41 story_5_level_end_and_merge_with_shadow */
{ 0, 0 }, /* 42 */
{ 3, 285 }, /* 43 killed_Jaffar */
{ 0, 0 }, /* 44 */
{ 0, 0 }, /* 45 */
{ 0, 0 }, /* 46 */
{ 0, 0 }, /* 47 */
{ 0, 0 }, /* 48 */
{ 0, 0 }, /* 49 */
{ 10, 1184 }, /* 50 story_2_princess */
{ 0, 0 }, /* 51 */
{ 20, 2443 }, /* 52 story_4_Jaffar_leaves */
{ 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 */
-44
View File
@@ -1,44 +0,0 @@
/*
* pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.
* Набор: flac. Генератор: tools/pop_pack_music.py.
* Руками не править.
*
* Нужны там, где сценарий ждёт окончания реплики: у оригинала это
* `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной
* в НАШУ запись. Число обязано соответствовать набору, которым собрана
* сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985
* тиков против 1044), и захардкоженная константа молча ломает сцену.
*
* Считается здесь, а не в C: blocks*40 переполняет uint16 на won
* (9855 порций). Отношение 40/57 — обратное к тому, которым шкала
* сцены переводит тики в порции насоса (57/40, pop_intro.c); берём
* именно его, чтобы длина реплики и ход шкалы считались одной
* арифметикой. Округление к ближайшему: вниз теряло по тику.
*/
#ifndef POP_MUSIC_TICKS_H
#define POP_MUSIC_TICKS_H
#define POP_MUS_TICKS_24 214 /* regular_death */
#define POP_MUS_TICKS_25 138 /* find_mirror_and_find_shadow */
#define POP_MUS_TICKS_26 794 /* embrace */
#define POP_MUS_TICKS_27 642 /* cutscene_before_2_4_6_12 */
#define POP_MUS_TICKS_28 460 /* fight_death */
#define POP_MUS_TICKS_29 300 /* meet_Jaffar */
#define POP_MUS_TICKS_30 210 /* life_potion */
#define POP_MUS_TICKS_32 438 /* end_level_4_and_killed_by_shadow */
#define POP_MUS_TICKS_33 144 /* potion */
#define POP_MUS_TICKS_35 623 /* cutscene_before_8_9 */
#define POP_MUS_TICKS_36 881 /* out_of_time */
#define POP_MUS_TICKS_37 196 /* guard_death_and_obtaining_the_sword */
#define POP_MUS_TICKS_39 1116 /* floating */
#define POP_MUS_TICKS_40 418 /* cutscene_not_much_time_left */
#define POP_MUS_TICKS_41 732 /* story_5_level_end_and_merge_with_shadow */
#define POP_MUS_TICKS_43 200 /* killed_Jaffar */
#define POP_MUS_TICKS_50 831 /* story_2_princess */
#define POP_MUS_TICKS_52 1714 /* story_4_Jaffar_leaves */
#define POP_MUS_TICKS_53 985 /* story_3_Jaffar_enters */
#define POP_MUS_TICKS_54 1448 /* intro_theme */
#define POP_MUS_TICKS_55 947 /* story_1_absence */
#define POP_MUS_TICKS_56 6923 /* won */
#endif /* POP_MUSIC_TICKS_H */
+1 -39
View File
@@ -1,5 +1,5 @@
/* pop_shadow_atlas.h — раскладка атласа ТЕНИ. /* pop_shadow_atlas.h — раскладка атласа ТЕНИ.
* СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную. * СГЕНЕРЕНО tools/pop_pack_shadow.py — НЕ править вручную.
* *
* Две половины, потому что и у оригинала их две (см. шапку * Две половины, потому что и у оригинала их две (см. шапку
* упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида), * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),
@@ -25,44 +25,6 @@
/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */ /* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */
#define POP_SHADOW_FGT_PAGES 4 #define POP_SHADOW_FGT_PAGES 4
static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {
"SHADOW\\sk0.atl",
"SHADOW\\sk1.atl",
"SHADOW\\sk2.atl",
"SHADOW\\sk3.atl",
"SHADOW\\sk4.atl",
"SHADOW\\sk5.atl",
"SHADOW\\sk6.atl",
"SHADOW\\sk7.atl",
"SHADOW\\sk8.atl",
"SHADOW\\sk9.atl",
"SHADOW\\sk10.atl",
"SHADOW\\sk11.atl",
"SHADOW\\sk12.atl",
"SHADOW\\sk13.atl",
"SHADOW\\sk14.atl",
"SHADOW\\sk15.atl",
"SHADOW\\sk16.atl",
"SHADOW\\sk17.atl",
"SHADOW\\sk18.atl",
"SHADOW\\sk19.atl",
"SHADOW\\sk20.atl",
"SHADOW\\sk21.atl",
"SHADOW\\sk22.atl",
"SHADOW\\sk23.atl",
"SHADOW\\sk24.atl",
"SHADOW\\sk25.atl",
"SHADOW\\sk26.atl",
"SHADOW\\sk27.atl",
};
static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {
"SHADOW\\sf0.atl",
"SHADOW\\sf1.atl",
"SHADOW\\sf2.atl",
"SHADOW\\sf3.atl",
};
/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */ /* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */
static const uint8_t pop_shadow_pal[64] = { static const uint8_t pop_shadow_pal[64] = {
0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00, 0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00,
-113
View File
@@ -1,113 +0,0 @@
/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.
* СГЕНЕРЕНО toolchain/pop_pack_sound.py — НЕ править вручную.
*
* Данные: 8 бит беззнаковые, МОНО, 10937.5 Гц (CBL_FREQ_10K9) — формат
* CBL один в один, конвертировать в рантайме нечего.
*
* Начало каждого звука кратно 128 — размеру блока запроса CBL, а
* страница (16384) кратна ему же. Поэтому НИ ОДИН блок не пересекает
* границу страницы. Больше того: с 2026-08-25 каждый звук лежит
* ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем
* новую (страницы EMM у нас с запасом). Единственное исключение —
* эффект, который сам ДЛИННЕЕ страницы: для него насос переключает
* страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.
*
* Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80
* 32-битная арифметика дорога, а так всё влезает в 8/16 бит.
* Длина округлена вверх до блока — неполных блоков не бывает.
*/
#ifndef POP_SOUND_TBL_H
#define POP_SOUND_TBL_H
#include <stdint.h>
#define POP_SND_PAGES 10
#define POP_SND_COUNT 57
#define POP_SND_BLOCK 128
/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */
#define POP_SND_SILENCE_PAGE 0
#define POP_SND_SILENCE_OFF 0x0000
typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;
/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */
static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {
{ 0, 0x0080, 1664 }, /* 0 */
{ 0, 0x0700, 12032 }, /* 1 */
{ 1, 0x0000, 6272 }, /* 2 */
{ 1, 0x1880, 1280 }, /* 3 */
{ 1, 0x1D80, 1152 }, /* 4 */
{ 1, 0x2200, 1408 }, /* 5 */
{ 1, 0x2780, 5504 }, /* 6 */
{ 2, 0x0000, 896 }, /* 7 */
{ 2, 0x0380, 1664 }, /* 8 */
{ 2, 0x0A00, 2816 }, /* 9 */
{ 2, 0x1500, 4992 }, /* 10 */
{ 2, 0x2880, 1280 }, /* 11 */
{ 2, 0x2D80, 1280 }, /* 12 */
{ 3, 0x0000, 3840 }, /* 13 */
{ 3, 0x0F00, 7552 }, /* 14 */
{ 4, 0x0000, 17664 }, /* 15 */
{ 5, 0x0500, 4736 }, /* 16 */
{ 5, 0x1780, 4224 }, /* 17 */
{ 6, 0x0000, 6912 }, /* 18 */
{ 6, 0x1B00, 2816 }, /* 19 */
{ 6, 0x2600, 1408 }, /* 20 */
{ 6, 0x2B80, 1152 }, /* 21 */
{ 6, 0x3000, 1280 }, /* 22 */
{ 6, 0x3500, 1408 }, /* 23 */
{ 0, 0x0000, 0 }, /* 24 */
{ 0, 0x0000, 0 }, /* 25 */
{ 0, 0x0000, 0 }, /* 26 */
{ 0, 0x0000, 0 }, /* 27 */
{ 0, 0x0000, 0 }, /* 28 */
{ 0, 0x0000, 0 }, /* 29 */
{ 0, 0x0000, 0 }, /* 30 */
{ 0, 0x0000, 0 }, /* 31 */
{ 0, 0x0000, 0 }, /* 32 */
{ 0, 0x0000, 0 }, /* 33 */
{ 0, 0x0000, 0 }, /* 34 */
{ 0, 0x0000, 0 }, /* 35 */
{ 0, 0x0000, 0 }, /* 36 */
{ 0, 0x0000, 0 }, /* 37 */
{ 0, 0x0000, 0 }, /* 38 */
{ 0, 0x0000, 0 }, /* 39 */
{ 0, 0x0000, 0 }, /* 40 */
{ 0, 0x0000, 0 }, /* 41 */
{ 0, 0x0000, 0 }, /* 42 */
{ 0, 0x0000, 0 }, /* 43 */
{ 7, 0x0000, 2688 }, /* 44 */
{ 7, 0x0A80, 4224 }, /* 45 */
{ 7, 0x1B00, 4608 }, /* 46 */
{ 8, 0x0000, 5120 }, /* 47 */
{ 8, 0x1400, 5120 }, /* 48 */
{ 8, 0x2800, 4480 }, /* 49 */
{ 0, 0x0000, 0 }, /* 50 */
{ 9, 0x0000, 4864 }, /* 51 */
{ 0, 0x0000, 0 }, /* 52 */
{ 0, 0x0000, 0 }, /* 53 */
{ 0, 0x0000, 0 }, /* 54 */
{ 0, 0x0000, 0 }, /* 55 */
{ 0, 0x0000, 0 }, /* 56 */
};
/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная
* половина): иначе копия строк уезжает в каждый включивший заголовок
* модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же
* приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */
#ifdef POP_SND_FILES
static const char *const pop_snd_files[POP_SND_PAGES] = {
"SND\\s0.bin",
"SND\\s1.bin",
"SND\\s2.bin",
"SND\\s3.bin",
"SND\\s4.bin",
"SND\\s5.bin",
"SND\\s6.bin",
"SND\\s7.bin",
"SND\\s8.bin",
"SND\\s9.bin",
};
#endif
#endif
+33
View File
@@ -0,0 +1,33 @@
/*
* _pop_file.h — внутренний файловый слой SprPoP для старых версий DSS.
*
* DSS до исправления относительных путей оставляет рабочий каталог внутри
* последнего подкаталога. POP_PATH_CALL восстанавливает абсолютный каталог
* приложения до и после операции. Это макрос намеренно: код исполняется в
* банке вызывающего, где доступен переданный указатель, и не расходует W1.
*/
#ifndef _POP_FILE_H
#define _POP_FILE_H
#include <stdint.h>
#include <errno.h>
/* BANK10: получить каталог EXE через APPINFO B=1 и сделать его текущим. */
int8_t pop_path_init(void) __banked;
int8_t pop_chdir_home(void) __banked;
/* expression вычисляется только после успешного chdir. errno файловой
* операции сохраняется через завершающее восстановление каталога. */
#define POP_PATH_CALL(result, expression) do { \
if (pop_chdir_home() != 0) { \
(result) = -1; \
} else { \
int _pop_path_errno; \
(result) = (expression); \
_pop_path_errno = errno; \
(void)pop_chdir_home(); \
errno = _pop_path_errno; \
} \
} while (0)
#endif
+10 -5
View File
@@ -1,17 +1,22 @@
/* /*
* _pop_sfx.h — внутренний контракт между половинами звука (НЕ публичный, * _pop_sfx.h — внутренний контракт между половинами звука (НЕ публичный,
* публичный — pop_sfx.h). Горячая половина живёт в резиденте * публичный — pop_sfx.h). Горячая половина живёт в резиденте
* (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они таблицу * (pop_sfx.c), холодная в банке (pop_sfx_cold.c); делят они массив
* звуков, массив физических страниц и насос. * физических страниц и насос.
*/ */
#ifndef _POP_SFX_INTERNAL_H #ifndef _POP_SFX_INTERNAL_H
#define _POP_SFX_INTERNAL_H #define _POP_SFX_INTERNAL_H
#include <stdint.h> #include <stdint.h>
#include "pop_sound_tbl.h" /* pop_snd_tbl, POP_SND_* — генерит упаковщик */ #include "pop_snd_tbl.h" /* тип записи и инварианты (НЕ генерируется) */
/* Физические страницы набора, по индексу из pop_snd_tbl[].page. */ /* РАСКЛАДКА НАБОРА и его страницы — общие данные обеих половин, живут в
extern uint8_t pop_snd_page[POP_SND_PAGES]; * pop_snd_data.c. Значения приезжают с диска (SND/snd.idx): холодная
* половина их читает и проверяет, горячая только пользуется. */
extern pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
extern uint8_t pop_snd_page[POP_SND_MAX_PAGES];
/* Сколько страниц у загруженного набора; 0 — набор не поднят. */
extern uint8_t pop_snd_pages;
/* 1 — набор поднят и CBL открыт; 0 — играем молча. */ /* 1 — набор поднят и CBL открыт; 0 — играем молча. */
extern uint8_t pop_snd_ok; extern uint8_t pop_snd_ok;
+2 -1
View File
@@ -12,6 +12,7 @@
#include <string.h> #include <string.h>
#include <sprinter_mem.h> #include <sprinter_mem.h>
#include "pop_arc.h" #include "pop_arc.h"
#include "_pop_file.h"
/* Пути 8.3, как требует DSS. Держим их ЗДЕСЬ: строка обязана лежать в том /* Пути 8.3, как требует DSS. Держим их ЗДЕСЬ: строка обязана лежать в том
* же банке, что и код, который её читает (см. pop_arc.h). */ * же банке, что и код, который её читает (см. pop_arc.h). */
@@ -42,7 +43,7 @@ int8_t pop_arc_open(pop_arc_t *a, const char *path,
a->fd = -1; a->fd = -1;
a->count = 0; a->count = 0;
fd = open(path, O_RDONLY); POP_PATH_CALL(fd, open(path, O_RDONLY));
if (fd < 0) return -1; if (fd < 0) return -1;
if (read(fd, head, sizeof(head)) != (int)sizeof(head) || if (read(fd, head, sizeof(head)) != (int)sizeof(head) ||
memcmp(head, "PBA1", 4) != 0 || memcmp(head, "PBA1", 4) != 0 ||
+4 -3
View File
@@ -29,6 +29,7 @@
#include "pop_cheat.h" #include "pop_cheat.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "_pop_config.h" #include "_pop_config.h"
#include "_pop_file.h"
#define CFG_BASE 0x100 #define CFG_BASE 0x100
@@ -151,7 +152,7 @@ static uint8_t cfg_read_file(uint8_t *buf, uint16_t *size)
int fd, got; int fd, got;
long n; long n;
fd = open("POP.CFG", O_RDONLY); POP_PATH_CALL(fd, open("POP.CFG", O_RDONLY));
if (fd < 0) return 0; if (fd < 0) return 0;
n = lseek(fd, 0, SEEK_END); n = lseek(fd, 0, SEEK_END);
if (n < POP_CFG_MIN_SIZE || n > POP_CFG_MAX_SIZE || if (n < POP_CFG_MIN_SIZE || n > POP_CFG_MAX_SIZE ||
@@ -172,7 +173,7 @@ int8_t pop_cfg_load(void) __banked
if (!cfg_read_file(buf, &size) || !pop_cfg_decode(buf, size)) return 0; if (!cfg_read_file(buf, &size) || !pop_cfg_decode(buf, size)) return 0;
if (buf[9] != POP_PROFILE_VANILLA) if (buf[9] != POP_PROFILE_VANILLA)
printf("POP.CFG: ENHANCED profile -> VANILLA\n"); puts("POP.CFG: ENHANCED profile -> VANILLA\n");
return 1; return 1;
} }
@@ -192,7 +193,7 @@ int8_t pop_cfg_save(void) __banked
gfx_w0_unmap(); gfx_w0_unmap();
/* Не unlink: bank_save_file сам делает O_TRUNC и не создаёт лишнего /* Не unlink: bank_save_file сам делает O_TRUNC и не создаёт лишнего
* окна, когда POP.CFG вообще отсутствует после ошибки unlink. */ * окна, когда POP.CFG вообще отсутствует после ошибки unlink. */
rc = bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf)); POP_PATH_CALL(rc, bank_save_file(page, CFG_BASE, "POP.CFG", sizeof(buf)));
mem_free_block(blk); mem_free_block(blk);
return (rc == sizeof(buf)) ? 1 : 0; return (rc == sizeof(buf)) ? 1 : 0;
} }
+17 -1
View File
@@ -665,6 +665,12 @@ void pop_control(void) __banked
* «откуда взялся прыжок» останется одним. */ * «откуда взялся прыжок» останется одним. */
#define KBD_HOME (KBD_EXT | 0x6C) #define KBD_HOME (KBD_EXT | 0x6C)
#define KBD_PGUP (KBD_EXT | 0x7D) #define KBD_PGUP (KBD_EXT | 0x7D)
/* Центр нумпада (KP5) — тоже «вниз», как просит раскладка оригинала: там
* весь блок 7/8/9-4/6-1/2/3 был управлением, и середина работала как
* приседание. Своей навигационной клавиши у KP5 нет, поэтому после
* kbd_raw_keypad_as_ext его голый код 0x73 переезжает в EXT-половину как
* есть — читаем оттуда и добавляем к стрелке «вниз». */
#define KBD_KP5 (KBD_EXT | 0x73)
static void read_input(void) static void read_input(void)
{ {
@@ -673,7 +679,7 @@ static void read_input(void)
uint8_t l = (uint8_t)(kbd_raw_down(KBD_LEFT) || home); uint8_t l = (uint8_t)(kbd_raw_down(KBD_LEFT) || home);
uint8_t r = (uint8_t)(kbd_raw_down(KBD_RIGHT) || pgup); uint8_t r = (uint8_t)(kbd_raw_down(KBD_RIGHT) || pgup);
uint8_t u = (uint8_t)(kbd_raw_down(KBD_UP) || home || pgup); uint8_t u = (uint8_t)(kbd_raw_down(KBD_UP) || home || pgup);
uint8_t d = kbd_raw_down(KBD_DOWN); uint8_t d = (uint8_t)(kbd_raw_down(KBD_DOWN) || kbd_raw_down(KBD_KP5));
control_x = r ? CONTROL_HELD_RIGHT : (l ? CONTROL_HELD_LEFT : CONTROL_RELEASED); control_x = r ? CONTROL_HELD_RIGHT : (l ? CONTROL_HELD_LEFT : CONTROL_RELEASED);
control_y = d ? CONTROL_HELD_DOWN : (u ? CONTROL_HELD_UP : CONTROL_RELEASED); control_y = d ? CONTROL_HELD_DOWN : (u ? CONTROL_HELD_UP : CONTROL_RELEASED);
control_shift = (kbd_raw_down(KBD_LSHIFT) || kbd_raw_down(KBD_RSHIFT)) control_shift = (kbd_raw_down(KBD_LSHIFT) || kbd_raw_down(KBD_RSHIFT))
@@ -781,6 +787,16 @@ int pop_ctrl_init(void) __banked
control_forward = control_backward = control_up = control_down = CONTROL_RELEASED; control_forward = control_backward = control_up = control_down = CONTROL_RELEASED;
control_shift2 = CONTROL_RELEASED; control_shift2 = CONTROL_RELEASED;
ctrl1_forward = ctrl1_backward = ctrl1_up = ctrl1_down = ctrl1_shift2 = CONTROL_RELEASED; ctrl1_forward = ctrl1_backward = ctrl1_up = ctrl1_down = ctrl1_shift2 = CONTROL_RELEASED;
/* НУМПАД НАМ НЕ НУЖЕН, И ЭТО ЛЕЧИТ ЗАЛИПАНИЕ СТРЕЛОК. Управление и
* меню читают только расширенные коды (KBD_LEFT = KBD_EXT|0x6B и т.д.).
* А при переполнении FIFO SIO теряется префикс E0, и make стрелки
* садится в PLAIN-половину карты как код нумпада — снять его нечем,
* потому что break придёт уже с префиксом. Дальше kbd_raw_any_down()
* навсегда «да», и всякое ожидание «пока ничего не нажато» виснет
* насмерть (BUG KBD-STUCK-WAIT; поймано на 14-м уровне, где почти весь
* путь идёт удержанием Left). Гасит биты kbd_raw_sync(), а его
* pop_ctrl_tick зовёт каждый кадр. */
kbd_raw_keypad_as_ext(1);
return kbd_raw_open(); return kbd_raw_open();
} }
+41 -12
View File
@@ -31,6 +31,7 @@
#include "pop_title.h" /* титульная картинка после ввода имени */ #include "pop_title.h" /* титульная картинка после ввода имени */
#include "pop_music.h" /* подкачка потоковой темы победы */ #include "pop_music.h" /* подкачка потоковой темы победы */
#include "pop_hof.h" #include "pop_hof.h"
#include "_pop_file.h"
/* MAX_HOF_COUNT оригинала — шесть строк. */ /* MAX_HOF_COUNT оригинала — шесть строк. */
#define HOF_COUNT 6 #define HOF_COUNT 6
@@ -162,7 +163,7 @@ static void hof_load(void)
if (!blk) return; if (!blk) return;
page = mem_get_page(blk, 0); page = mem_get_page(blk, 0);
gfx_w0_page_prepare(page); gfx_w0_page_prepare(page);
got = bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE); POP_PATH_CALL(got, bank_load_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
if (got == HOF_SIZE) { if (got == HOF_SIZE) {
gfx_w0_map(page); gfx_w0_map(page);
for (i = 0; i < HOF_SIZE; i++) for (i = 0; i < HOF_SIZE; i++)
@@ -187,7 +188,11 @@ static void hof_save(void)
gfx_w0_unmap(); gfx_w0_unmap();
/* Неудача записи не меняет только что показанную таблицу: следующее /* Неудача записи не меняет только что показанную таблицу: следующее
* открытие разумно начнёт с прежнего файла или с пустого списка. */ * открытие разумно начнёт с прежнего файла или с пустого списка. */
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE); {
int saved;
POP_PATH_CALL(saved,
bank_save_file(page, HOF_BASE, "POP.HOF", HOF_SIZE));
}
mem_free_block(blk); mem_free_block(blk);
} }
@@ -343,8 +348,22 @@ static void hof_edit_draw(uint8_t row, const char *name, uint8_t cursor)
x += pop_text_width_mapped(POP_TEXT_BIG_DARK, name) + 1; x += pop_text_width_mapped(POP_TEXT_BIG_DARK, name) + 1;
} }
/* draw_text_cursor(): подчерк той же гарнитуры сразу за текстом, /* draw_text_cursor(): подчерк той же гарнитуры сразу за текстом,
* мигающий сменой цвета на фоновый — у нас проще перерисовать. */ * мигающий сменой цвета на фоновый — у нас проще перерисовать.
if (cursor) pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); *
* СТРОКА СТРОИТСЯ НА СТЕКЕ, И ЭТО НЕ ПРИДИРКА (поймано в MAME
* 2026-08-27). Литерал "_" лёг бы в _BANK10, то есть в окно W3, а
* pop_text_draw_mapped живёт в банке 9: трамплин на время вызова
* переключает W3, и указатель показывает уже в ЧУЖОЙ банк. Функция
* честно читает оттуда байты до первого нуля и рисует их знаками
* вопроса — экран заливало мусором на каждое мигание курсора. Стек
* лежит в W2, он виден из любого банка. */
if (cursor) {
char cur[2];
cur[0] = '_';
cur[1] = 0;
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, cur);
}
pop_text_unmap(); pop_text_unmap();
} }
@@ -409,8 +428,8 @@ static const uint8_t hof_key_code[30] = {
KBD_SPACE, 0x66, KBD_ENTER, KBD_ESC KBD_SPACE, 0x66, KBD_ENTER, KBD_ESC
}; };
/* Возвращает 1, когда ввод закончен (Enter/Esc); *changed — надо ли /* Возвращает 1 ввод завершён (Enter), 2 — отменён (Esc); 0 — продолжаем.
* перерисовать строку. */ * *changed — надо ли перерисовать строку. */
static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length, static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
uint8_t *previous, uint8_t *changed) uint8_t *previous, uint8_t *changed)
{ {
@@ -432,7 +451,7 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
*changed = 1; *changed = 1;
} else if (i == 28 || i == 29) { } else if (i == 28 || i == 29) {
previous[i] = down; previous[i] = down;
return 1; return (uint8_t)(i == 28 ? 1 : 2); /* Enter : Esc */
} }
} }
previous[i] = down; previous[i] = down;
@@ -446,7 +465,8 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
* input_str) — в кадрах луча это HOF_BLINK. */ * input_str) — в кадрах луча это HOF_BLINK. */
#define HOF_BLINK POP_T60(6) #define HOF_BLINK POP_T60(6)
static void hof_edit(uint8_t row) /* 1 — игрок отказался вписывать имя (Esc). */
static uint8_t hof_edit(uint8_t row)
{ {
uint8_t length = 0, previous[30], i, changed = 1; uint8_t length = 0, previous[30], i, changed = 1;
uint8_t cursor = 1, phase = 0; uint8_t cursor = 1, phase = 0;
@@ -459,10 +479,14 @@ static void hof_edit(uint8_t row)
for (i = 0; i < sizeof(previous); i++) for (i = 0; i < sizeof(previous); i++)
previous[i] = kbd_raw_down(hof_key_code[i]); previous[i] = kbd_raw_down(hof_key_code[i]);
for (;;) { for (;;) {
uint8_t done;
kbd_raw_sync(); kbd_raw_sync();
if (hof_name_tick(entry, &length, previous, &changed)) { done = hof_name_tick(entry, &length, previous, &changed);
if (done == 2) return 1; /* Esc — запись не нужна */
if (done) {
if (!length) strcpy(entry->name, "PLAYER"); if (!length) strcpy(entry->name, "PLAYER");
return; return 0;
} }
if (changed) { cursor = 1; phase = 0; } if (changed) { cursor = 1; phase = 0; }
if (++phase >= HOF_BLINK) { if (++phase >= HOF_BLINK) {
@@ -496,8 +520,13 @@ void pop_hof_show(void) __banked
if (hof_page_build(page, row) == 0) { if (hof_page_build(page, row) == 0) {
pop_screen_present(page); pop_screen_present(page);
(void)pop_pal_fade_in_skip(HOF_FADE); (void)pop_pal_fade_in_skip(HOF_FADE);
hof_edit(row); /* ОТКАЗ ПО Esc — РАСХОЖДЕНИЕ С ОРИГИНАЛОМ (docs/impl_diff.md).
hof_save(); * SDLPoP крутит `while (input_str(...) <= 0)`, то есть уйти без
* имени нельзя вовсе. Мы разрешаем: тогда таблицу перечитываем
* с диска (hof_insert_current уже сдвинул строки, и откатывать
* их дешевле всего именно так) и НЕ сохраняем. */
if (hof_edit(row)) hof_load();
else hof_save();
/* Имя вписано: строку показываем уже обычным золотым текстом, /* Имя вписано: строку показываем уже обычным золотым текстом,
* без полосы (restore_peel + show_hof_text оригинала). */ * без полосы (restore_peel + show_hof_text оригинала). */
page = pop_screen_begin(); page = pop_screen_begin();
+201 -137
View File
@@ -15,7 +15,6 @@
#include "pop_shadow.h" #include "pop_shadow.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "pop_music.h" #include "pop_music.h"
#include "pop_music_ticks.h" /* POP_MUS_TICKS_<id> — длина реплик набора */
#include "pop_arc.h" #include "pop_arc.h"
#include "pv_arc.h" #include "pv_arc.h"
#include "pop_cutscene.h" #include "pop_cutscene.h"
@@ -60,9 +59,10 @@
* своего куска (например, всё поведение Джафара от PV_DIALOG1_START). * своего куска (например, всё поведение Джафара от PV_DIALOG1_START).
* 2) «ЖДЁМ КОНЦА РЕПЛИКИ» PV_M50_END и PV_EXIT_START. Это целиком * 2) «ЖДЁМ КОНЦА РЕПЛИКИ» PV_M50_END и PV_EXIT_START. Это целиком
* длина НАШЕЙ записи, и она у каждого набора своя: m50 831 тик на * длина НАШЕЙ записи, и она у каждого набора своя: m50 831 тик на
* flac против 867 на mt32, m53 985 против 1044. Поэтому берём их * flac против 867 на mt32, m53 985 против 1044. Раньше их
* из POP_MUS_TICKS_<id>, которые печатает упаковщик того набора, * печатал упаковщик в gen/pop_music_ticks.h, из-за чего EXE был
* которым собрана сборка (gen/pop_music_tbl.h). * привязан к набору; теперь они приезжают из индекса набора
* (MUS/mus.idx) при входе в сцену см. ЯКОРЯ ниже.
* *
* Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом отсюда * Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом отсюда
* сложения от PV_M50_END и PV_EXIT_START вместо литералов. */ * сложения от PV_M50_END и PV_EXIT_START вместо литералов. */
@@ -72,48 +72,76 @@ enum {
/* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834 /* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834
* тика на три больше нашей записи (831); расхождение осталось с * тика на три больше нашей записи (831); расхождение осталось с
* времён, когда длину подставляли руками. */ * времён, когда длину подставляли руками. */
PV_M50_END = PV_MUS_2_START + POP_MUS_TICKS_50, PV_MAGIC_LEAD = 0
PV_WAIT_END = PV_M50_END + 40, /* +frame(5) -> створка ворот */
PV_GATE_END = PV_WAIT_END + 48,
PV_DOOR_END = PV_GATE_END + 24,
PV_TURN_START = PV_DOOR_END,
PV_WALK1_START = PV_DOOR_END + 40,
PV_DIALOG1_START = PV_WALK1_START + 48, /* Джафар входит — реплика m53 */
/* Ниже — биты сценария внутри реплики Джафара, все от её начала. */
PV_MUS_4_LOAD = PV_DIALOG1_START + 54, /* подкачка m52 */
PV_WALK2_START = PV_DIALOG1_START + 176,
PV_DIALOG2_START = PV_DIALOG1_START + 416,
/* ЗАКЛИНАНИЕ СДВИНУТО РАНЬШЕ СЦЕНАРИЯ — ПОДГОНКА ПОД МУЗЫКУ.
*
* Сцена render-bound: кадр рисуется дороже своего бюджета, кадровая
* ветка пейсинга догона не имеет, и анимация идёт ~49 тиков в секунду
* вместо 60. Реплика Джафара при этом играет по РЕАЛЬНОМУ времени и
* успевает кончиться, когда шкала стоит на 1854 вместо 2028 (замер в
* MAME 2026-08-27 зондом на конец трека: длина насосу отдана полная,
* 1403 порции, курсор дошёл до последней страницы трек доигрывает
* целиком, отстаёт именно картинка).
*
* Из-за этого кода реплики приходила примерно на секунду раньше
* молнии. Честное лечение удешевить кадр сцены; до тех пор двигаем
* жест целиком (замах, шаг назад, вспышка), чтобы он не сломался
* внутри себя. Величина подобрана НА СЛУХ и уточняется прогоном:
* расчётные полсотни тиков (секунда отставания при ~49 тиках/с) на
* слух почти не изменили картины, поэтому взято вдвое больше.
*
* Эффектов дверей это не касается: у них и звук, и картинка идут по
* одной и той же шкале и разъехаться не могут. */
PV_MAGIC_LEAD = 100,
PV_RAISE_START = PV_DIALOG1_START + 696 - PV_MAGIC_LEAD,
PV_STEPBACK_START = PV_DIALOG1_START + 703 - PV_MAGIC_LEAD,
PV_MAGIC_START = PV_DIALOG1_START + 822 - PV_MAGIC_LEAD,
/* Конец m53 — снова длина записи, а не бит сценария. */
PV_EXIT_START = PV_DIALOG1_START + POP_MUS_TICKS_53,
PV_MUS_4_START = PV_EXIT_START + 42, /* +frame(6) -> «Джафар уходит» */
PV_GLASS_DONE = PV_EXIT_START + 210,
PV_SLUMP_START = PV_EXIT_START + 273,
PV_ANIM_TICKS = PV_EXIT_START + 469
}; };
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* История: сцена была render-bound кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам кадрам луча,
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
/* ЯКОРЯ ШКАЛЫ. Четыре точки, от которых отсчитывается всё остальное;
* считаются ОДИН РАЗ при входе в сцену из длительностей набора
* (pop_music_info). Держать их статикой, а не тащить структуру времён
* через пять функций: восемь байт против переделки всех сигнатур, а
* сцена всё равно одна на запуск.
*
* Нет индекса или трека длительность нулевая, и сцена просто проходит
* без пауз на реплики: ждать нечего, звука-то нет. */
static uint16_t pv_m50_end; /* конец «принцесса ждёт» (m50) */
static uint16_t pv_dialog1; /* Джафар входит — начало реплики m53 */
static uint16_t pv_exit; /* конец m53: Джафар уходит */
static uint16_t pv_anim_end; /* конец всей сцены */
/* Ниже — те же выражения, что были в enum: биты сценария отсчитываются от
* своего якоря и от записи НЕ зависят. */
#define PV_M50_END pv_m50_end
#define PV_WAIT_END ((uint16_t)(pv_m50_end + 40))
#define PV_GATE_END ((uint16_t)(pv_m50_end + 88))
#define PV_DOOR_END ((uint16_t)(pv_m50_end + 112))
#define PV_TURN_START PV_DOOR_END
#define PV_WALK1_START ((uint16_t)(pv_m50_end + 152))
#define PV_DIALOG1_START pv_dialog1
#define PV_MUS_4_LOAD ((uint16_t)(pv_dialog1 + 54))
#define PV_WALK2_START ((uint16_t)(pv_dialog1 + 176))
#define PV_DIALOG2_START ((uint16_t)(pv_dialog1 + 416))
#define PV_RAISE_START ((uint16_t)(pv_dialog1 + 696 - PV_MAGIC_LEAD))
#define PV_STEPBACK_START ((uint16_t)(pv_dialog1 + 703 - PV_MAGIC_LEAD))
#define PV_MAGIC_START ((uint16_t)(pv_dialog1 + 822 - PV_MAGIC_LEAD))
#define PV_EXIT_START pv_exit
#define PV_MUS_4_START ((uint16_t)(pv_exit + 42))
#define PV_GLASS_DONE ((uint16_t)(pv_exit + 210))
#define PV_SLUMP_START ((uint16_t)(pv_exit + 273))
#define PV_ANIM_TICKS pv_anim_end
/* Собрать шкалу под НАШ набор записей. 200 — цепочка битов сценария от
* конца m50 до входа Джафара (40+48+24+40+48), 469 хвост после его
* ухода; оба от записи не зависят. */
static void pv_timing_init(void)
{
pop_mus_info_t m50, m53;
(void)pop_music_info(POP_MUS_STORY_2, &m50);
(void)pop_music_info(POP_MUS_STORY_3, &m53);
pv_m50_end = (uint16_t)(PV_MUS_2_START + m50.ticks);
pv_dialog1 = (uint16_t)(pv_m50_end + 200);
pv_exit = (uint16_t)(pv_dialog1 + m53.ticks);
pv_anim_end = (uint16_t)(pv_exit + 469);
}
/* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один /* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один
* кадр сцены и сколько их вообще имеет смысл копить. 1 порция 11,7 мс; * кадр сцены и сколько их вообще имеет смысл копить. 1 порция 11,7 мс;
* 128 порций полторы секунды, дальше догонять уже нечего. */ * 128 порций полторы секунды, дальше догонять уже нечего. */
@@ -430,6 +458,44 @@ static int intro_pv_draw_base(void)
return 0; return 0;
} }
/* ВОССТАНОВЛЕНИЕ ФОНА ЗАДНЕЙ СТРАНИЦЫ — ДВУМЯ БЛОКАМИ ПО КАДРОВОМУ ИНТЕРВАЛУ.
*
* Персонажи и живой фон рисуются банком спрайтов и в ОЗУ-копию не оседают,
* поэтому «стереть прошлый кадр» = скопировать чистый фон из теневой копии
* соседней страницы. Раньше это была ОДНА копия на весь экран, и она стоила
* ~547 000 тактов = 1,27 кадра луча (модель blit_cost_model). Хвост кадра
* сцены ждал vsync ПОСЛЕ отрисовки, так что эти 1,27 кадра ПРИБАВЛЯЛИСЬ к
* пяти интервалам делителя: сцена шла 6+ кадров вместо 5, то есть ~8,2 fps
* вместо десяти (замер 49/60 тиков docs/BUGS_OPEN.md PV-RENDER-BOUND).
*
* Лечение не «удешевить», а РАЗЛОЖИТЬ по интервалам: каждый блок заведомо
* влезает в кадр, и после него честно ждём vsync. Тогда отрисовка живёт
* ВНУТРИ делителя, а не сверх него.
* - копируем только картинку (200 строк с POP_YOFF), а не все 256:
* сверху и снизу чёрная рамка, туда никто не рисует;
* - двумя половинами по 100 строк: ~219 000 тактов каждая, чуть больше
* половины кадра запас двукратный.
*
* ТРАТИТ РОВНО ДВА кадровых интервала (по gfx_wait_vsync на блок) это
* учтено в раскладке кадра у всех трёх сцен (см. вызовы). */
#define PV_BG_Y0 POP_YOFF
#define PV_BG_H 200
static void pv_restore_bg(void)
{
gfx_rect_t r;
r.x = 0;
r.w = GFX_WIDTH;
r.h = PV_BG_H / 2;
r.y = PV_BG_Y0;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
r.y = PV_BG_Y0 + PV_BG_H / 2;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
}
static void pv_blit_floor(const atlas_t *at, uint8_t frame, int x, int floor) static void pv_blit_floor(const atlas_t *at, uint8_t frame, int x, int floor)
{ {
const uint8_t *img; const uint8_t *img;
@@ -701,12 +767,10 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
uint8_t sand_frame, uint8_t flash, uint8_t sand_frame, uint8_t flash,
uint16_t tick) uint16_t tick)
{ {
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1); uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back); gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT; pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_copy_page(&full, GFX_COPY_DIRECT);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
/* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */ /* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */
pv_blit_floor(torch, torch_left, 93, 116); pv_blit_floor(torch, torch_left, 93, 116);
@@ -730,7 +794,7 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
* его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед * его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед
* правой передней колонной. */ * правой передней колонной. */
pv_blit_floor(hourglass, 0, 240, 167); pv_blit_floor(hourglass, 0, 240, 167);
gfx_wait_vsync(); gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back); gfx_set_visible_page(back);
if (flash) { if (flash) {
uint8_t pulse; uint8_t pulse;
@@ -743,9 +807,9 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
pop_pal_flash_white(0); pop_pal_flash_white(0);
gfx_wait_vsync(); gfx_wait_vsync();
} }
return 11; return 13; /* три интервала кадра + десять на молнию */
} }
return 1; return 3; /* сколько кадровых интервалов съели */
} }
/* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот, /* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот,
@@ -759,12 +823,7 @@ static int intro_pv_animated(void)
uint16_t tick; uint16_t tick;
uint16_t frame_end; uint16_t frame_end;
uint16_t render_tick; uint16_t render_tick;
uint16_t beam = 0; /* запасной путь: кадров луча ждали */ uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
uint16_t snd_done = 0; /* тиков насоса прошло с начала сцены */
uint16_t snd_want = 0; /* сколько их должно было пройти */
uint16_t snd_acc = 0; /* дробный остаток пересчёта 57/40 */
uint8_t snd_prev = 0;
uint8_t snd_pace = 0;
uint8_t period; uint8_t period;
uint8_t anim_step = 0; uint8_t anim_step = 0;
uint8_t torch_left = 1; uint8_t torch_left = 1;
@@ -813,18 +872,22 @@ static int intro_pv_animated(void)
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI * Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
* бандами по 16 строк. */ * бандами по 16 строк. */
(void)pop_sfx_start(); (void)pop_sfx_start();
/* ЧЕМ МЕРИТЬ ВРЕМЯ. Пока сцена была немой, кадры просто считали /* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
* ожидания vsync и этого хватало. С музыкой выяснилось, что так *
* считаются НАШИ ожидания, а не прошедшее время: полноэкранная копия * Была вторая шкала тики насоса CBL, и выбор между ними делался
* страницы (~0,55 млн тактов) съедает больше кадра луча, и сцена * гонкой на старте сцены (одна выборка `pop_snd_tick` через кадр:
* незаметно отстаёт в MAME молния била уже ПОСЛЕ конца реплики. * успел ли диск раскрутить звук). Значит от прогона к прогону сцена
* Часы берём у насоса CBL: он идёт от расхода буфера железом, 85,4 Гц, * шла то по одним часам, то по другим, и кода реплики приходилась
* и ему безразлично, чем занят главный цикл. Если звук выключен, * каждый раз на другое место картинки (наблюдение пользователя
* часы стоят тогда работает прежний путь по кадрам луча. */ * 2026-08-28; баг SND-PACE-DEAD). Насос как часы был нужен потому,
snd_prev = pop_snd_tick; * что кадр сцены рисовался ДОЛЬШЕ своего интервала и счёт наших
gfx_wait_vsync(); * ожиданий врал; теперь отрисовка разложена на блоки по кадровому
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */ * интервалу (см. pv_restore_bg), и счёт кадров луча честен.
snd_prev = pop_snd_tick; *
* Осталось учесть единственную работу, которая в интервал не влезала
* сама, подкачку страницы трека: шаг читает ПОЛСТРАНИЦЫ (~16 мс,
* pop_music_load_step) и оплачивается ровно одним интервалом, как
* любой другой блок кадра. */
result = 0; result = 0;
princess_actor.x = 120; princess_actor.x = 120;
princess_actor.frame = 11; /* Pstand, facing left */ princess_actor.frame = 11; /* Pstand, facing left */
@@ -837,6 +900,7 @@ static int intro_pv_animated(void)
jaffar_actor.visible = 0; jaffar_actor.visible = 0;
jaffar_actor.step = 0; jaffar_actor.step = 0;
tick = 0; tick = 0;
pv_timing_init(); /* шкала под длительности НАШЕГО набора */
intro_skip_begin(&skip); intro_skip_begin(&skip);
while (tick < PV_ANIM_TICKS) { while (tick < PV_ANIM_TICKS) {
if (intro_skip_requested(&skip)) { if (intro_skip_requested(&skip)) {
@@ -892,59 +956,42 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash, torch_right, sand_frame, flash,
render_tick); render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */ pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время». /* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
* *
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это * Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти * верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем» * не было: кадр дорогой, шкала работает по принципу «долг не
* (snd_want подтягивается к snd_done), и цикл ожидания выходит * догоняем», и цикл ожидания выходил сразу. Замер в MAME
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд, * 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
* то есть одиннадцать страниц m53 не успевали за всю сцену * страниц m53 не успевали за всю сцену pop_music_play() на своём
* pop_music_play() на своём тике не находил готового трека, и * тике не находил готового трека, и реплики Джафара пропадали
* реплики Джафара пропадали совсем. * совсем (memory pv_music_stall_regression).
* *
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому * Поэтому шаг безусловный и оплачен как обычный блок кадра: он
* один безусловный шаг в кадр сцену не ломает: он лишь тратит * читает полстраницы (~16 мс), а следующий gfx_wait_vsync ставит
* часть той же паузы, а если паузы не было добавляет к кадру * нас на край интервала, который мы этим и заняли. */
* четверть его длины, и шкала это учтёт сама. */ if (pop_music_loading()) {
if (pop_music_loading()) (void)pop_music_load_step(); (void)pop_music_load_step();
gfx_wait_vsync();
beam++;
}
/* Первый кадр собран под чёрной палитрой — теперь проявляем его /* Первый кадр собран под чёрной палитрой — теперь проявляем его
* целиком, вместе с принцессой и факелами. */ * целиком, вместе с принцессой и факелами. */
if (!faded_in) { if (!faded_in) {
faded_in = 1; faded_in = 1;
if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; } if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
} }
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по /* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 6/8/7 тиков по
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425 * 1/60 с (pv_seq_period), в кадры луча переводит POP_T60, и цель
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без * КУМУЛЯТИВНА: нецелые 6,67 и 5,83 не копят ошибку.
* деления в кадре и без 32-битной арифметики. */ *
if (snd_pace) { * ДОЛГ НЕ ДОГОНЯЕМ: если кадр вышел дороже своего бюджета, цикл
snd_acc += (uint16_t)period * 57u; * ожидания просто не сработает, но beam уже учёл потраченное и
while (snd_acc >= 40u) { snd_acc -= 40u; snd_want++; } * следующая цель считается от той же общей шкалы. Наверстывать
for (;;) { * пропущенное нельзя: события соседних тиков (створка ворот, дверь
uint8_t now = pop_snd_tick; * покоев) слиплись бы в один кадр, и один из звуков пропал бы. */
{
/* Разность в uint8_t: счётчик переполняется каждые 3 с, а
* заглядываем мы в него не реже раза в кадр. */
snd_done += (uint8_t)(now - snd_prev);
snd_prev = now;
if (snd_done >= snd_want) break;
/* Страницу следующей реплики читаем ЗДЕСЬ, в паузе кадра:
* 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); uint16_t target = POP_T60(tick);
while (beam < target) { gfx_wait_vsync(); beam++; } while (beam < target) { gfx_wait_vsync(); beam++; }
@@ -995,7 +1042,6 @@ static uint8_t pre_hourglass_state(void)
static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames) static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
{ {
atlas_t hourglass, torch; atlas_t hourglass, torch;
gfx_rect_t full;
uint8_t torch_left = 1, torch_right = 6, sand = 0, frame; uint8_t torch_left = 1, torch_right = 6, sand = 0, frame;
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 }; uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
intro_skip_t skip; intro_skip_t skip;
@@ -1025,7 +1071,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
pop_screen_present(1); pop_screen_present(1);
result = 0; result = 0;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
intro_skip_begin(&skip); intro_skip_begin(&skip);
for (frame = 0; frame < frames; frame++) { for (frame = 0; frame < frames; frame++) {
uint8_t back, hold, star; uint8_t back, hold, star;
@@ -1033,7 +1078,7 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
if (intro_skip_requested(&skip)) { result = 1; break; } if (intro_skip_requested(&skip)) { result = 1; break; }
back = (uint8_t)(gfx_get_visible_page() ^ 1); back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back); gfx_set_draw_page(back);
gfx_copy_page(&full, GFX_COPY_DIRECT); pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&torch, torch_left, 93, 116); pv_blit_floor(&torch, torch_left, 93, 116);
pv_blit_floor(&torch, torch_right, 211, 116); pv_blit_floor(&torch, torch_right, 211, 116);
@@ -1046,10 +1091,11 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
pv_blit_floor(&hourglass, (uint8_t)(8 + sand), 160, 164); pv_blit_floor(&hourglass, (uint8_t)(8 + sand), 160, 164);
} }
pv_blit_floor(&hourglass, 0, 240, 167); /* передняя колонна */ pv_blit_floor(&hourglass, 0, 240, 167); /* передняя колонна */
gfx_wait_vsync(); gfx_wait_vsync(); /* блок 3: живой фон */
gfx_set_visible_page(back); gfx_set_visible_page(back);
/* Первый тик уже потрачен на commit: ещё четыре = 5/50 с. */ /* Три интервала уже потрачены на блоки отрисовки — добираем до
for (hold = 1; hold < 5; hold++) gfx_wait_vsync(); * пяти (5/50 с = кадр катсцены оригинала). */
for (hold = 3; hold < 5; hold++) gfx_wait_vsync();
/* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по /* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
* пустой комнате, а часы и пламя возникают уже на свету. */ * пустой комнате, а часы и пламя возникают уже на свету. */
@@ -1128,8 +1174,14 @@ static void intro_restore_game_palette(void)
/* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1, /* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1,
* 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */ * 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */
#define CUT_PV2_SPLIT 17 #define CUT_PV2_SPLIT 17
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен /* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен: у
* (cutscene_frame_time SDLPoP; см. pre_room_animated). */ * оригинала `cutscene_frame_time = 6` тиков по 1/60 с (reset_cutscene,
* seg001:527, и его зовёт load_intro прямо перед сценой) = 100 мс, у нас
* 5 кадров луча по 50 Гц = те же 100 мс.
*
* РАСКЛАДКА ПО ИНТЕРВАЛАМ (иначе отрисовка прибавлялась к делителю, а не
* жила внутри него см. pv_restore_bg): фон половинами 1-2, актёры и
* декорации 3, служебный блок (звук/подкачка музыки) 4-5. */
#define CUT_FRAME_VSYNC 5 #define CUT_FRAME_VSYNC 5
static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch; static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch;
@@ -1290,11 +1342,9 @@ static void cut_shad_init(uint8_t x, uint8_t y, uint8_t seq)
* прервал сцену (любое НОВОЕ нажатие). */ * прервал сцену (любое НОВОЕ нажатие). */
static uint8_t cut_run(uint16_t frames, uint8_t kid_visible) static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
{ {
gfx_rect_t full;
uint16_t i; uint16_t i;
uint8_t hold; uint8_t hold;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
for (i = 0; i < frames; i++) { for (i = 0; i < frames; i++) {
uint8_t back; uint8_t back;
kframe kid_fr, pr_fr; kframe kid_fr, pr_fr;
@@ -1316,8 +1366,9 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
back = (uint8_t)(gfx_get_visible_page() ^ 1); back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back); gfx_set_draw_page(back);
/* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются /* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются
* банком спрайтов и потому в неё не оседают. */ * банком спрайтов и потому в неё не оседают. Двумя блоками по
gfx_copy_page(&full, GFX_COPY_DIRECT); * кадровому интервалу см. pv_restore_bg. */
pv_restore_bg(); /* блоки 1-2 */
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&cut_torch, cut_torch_l, 93, 116); pv_blit_floor(&cut_torch, cut_torch_l, 93, 116);
pv_blit_floor(&cut_torch, cut_torch_r, 211, 116); pv_blit_floor(&cut_torch, cut_torch_r, 211, 116);
@@ -1339,12 +1390,17 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
* сквозь неё поймано пользователем 2026-08-25). */ * сквозь неё поймано пользователем 2026-08-25). */
pv_blit_floor(&cut_hg, 0, 240, 167); pv_blit_floor(&cut_hg, 0, 240, 167);
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
gfx_wait_vsync(); gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back); gfx_set_visible_page(back);
for (hold = 1; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
/* Блок 4 — служебный, и он НЕ добавляется к кадру, а живёт внутри
* оставшихся интервалов: подкачка страницы трека стоит ~33 мс, то
* есть меньше двух интервалов, которые всё равно надо переждать.
* Порядок как в сцене с Джафаром: шаг подкачки безусловный (см.
* memory pv_music_stall_regression). */
pop_sfx_tick(); pop_sfx_tick();
pop_music_service(); /* «встреча с принцессой» — трек, а не эффект */ pop_music_service(); /* «встреча с принцессой» — трек, а не эффект */
for (hold = 3; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
cut_torch_l = (uint8_t)((cut_torch_l + 1) % 9); cut_torch_l = (uint8_t)((cut_torch_l + 1) % 9);
cut_torch_r = (uint8_t)((cut_torch_r + 1) % 9); cut_torch_r = (uint8_t)((cut_torch_r + 1) % 9);
cut_sand = (uint8_t)((cut_sand + 1) % 3); cut_sand = (uint8_t)((cut_sand + 1) % 3);
@@ -1662,7 +1718,9 @@ uint8_t pop_intro_show(void) __banked
/* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине: /* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине:
* дальше внутри сцены остаётся только пуск, а следующие две реплики * дальше внутри сцены остаётся только пуск, а следующие две реплики
* подкачиваются по странице за кадр. */ * подкачиваются по странице за кадр. */
pop_sfx_pause(); /* Глушение снято 2026-08-28: массовое чтение страниц насос не морит
* (замер на загрузке уровня ни одной пропущенной порции), а трек
* титров может и должен доигрывать. */
(void)pop_music_load(POP_MUS_STORY_2); (void)pop_music_load(POP_MUS_STORY_2);
pv_result = intro_pv_animated(); pv_result = intro_pv_animated();
if (pv_result > 0) { if (pv_result > 0) {
@@ -1740,15 +1798,21 @@ uint8_t pop_intro_show(void) __banked
* дальше, а не чтобы досидеть до конца трека. */ * дальше, а не чтобы досидеть до конца трека. */
static void pre_cut_finish(uint8_t aborted) static void pre_cut_finish(uint8_t aborted)
{ {
if (!aborted) { /* ТРЕК ПЕРЕЖИВАЕТ СЦЕНУ — как в оригинале. load_intro (seg001:690)
intro_skip_t tail; * ничего не ждёт: гасит экран и возвращает управление, а звук играет
* дальше поверх загрузки уровня и первых секунд игры. Мы вместо этого
intro_skip_begin(&tail); * ДОСИЖИВАЛИ трек на чёрном экране (27 10,7 с против 2,6 с картинки),
while (pop_music_busy() && !intro_skip_requested(&tail)) * то есть дарили игроку восемь секунд пустого экрана, и только потом
pop_wait_edge(); * глушили. ЗАМЕР 2026-08-28: снято вместе с глушением на загрузке
* уровня; если загрузка не укладывается в дедлайн насоса (251 000
* тактов), вернуть оба места разом.
*
* Пропуск сцены по-прежнему обрывает и музыку: игрок нажал клавишу,
* чтобы идти дальше, а не чтобы дослушать. */
if (aborted) {
pop_music_free();
pop_sfx_pause();
} }
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette(); intro_restore_game_palette();
} }
@@ -1756,7 +1820,7 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
{ {
const pop_cs_cmd_t *script = 0; const pop_cs_cmd_t *script = 0;
pop_sfx_pause(); /* Глушение перед чтением трека снято 2026-08-28 (см. замер насоса). */
/* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих /* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих
* сцен (27 10,7 с против 2,6 с картинки), и оригинал их не обрывает: * сцен (27 10,7 с против 2,6 с картинки), и оригинал их не обрывает:
* load_intro просто гасит экран и возвращает управление, а звук * load_intro просто гасит экран и возвращает управление, а звук
@@ -1824,7 +1888,7 @@ int pop_intro_hof_bg(uint8_t page) __banked
void pop_time_expired_show(void) __banked void pop_time_expired_show(void) __banked
{ {
pop_sfx_pause(); /* Глушение снято 2026-08-28 (см. замер насоса). */
/* time_expired (seg001:04D3) начинается с play_sound(sound_36). */ /* time_expired (seg001:04D3) начинается с play_sound(sound_36). */
if (pop_music_load(36) == 0) { if (pop_music_load(36) == 0) {
(void)pop_sfx_start(); (void)pop_sfx_start();
@@ -1853,7 +1917,7 @@ void pop_time_expired_show(void) __banked
void pop_ending_show(void) __banked void pop_ending_show(void) __banked
{ {
pop_sfx_pause(); /* Глушение снято 2026-08-28 (см. замер насоса). */
/* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который /* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который
* начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка * начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка
* ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает» * ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает»
+6 -9
View File
@@ -18,18 +18,14 @@
#include "pop_guard.h" /* pop_guard_vflip_load — зеркала соперника */ #include "pop_guard.h" /* pop_guard_vflip_load — зеркала соперника */
#include "pop_arc.h" #include "pop_arc.h"
#include "kid_arc.h" #include "kid_arc.h"
#include "_pop_file.h"
/* Серии в архиве обязаны лежать подряд — так их кладёт список в Makefile, /* Серии в архиве обязаны лежать подряд — так их кладёт список в Makefile,
* и только поэтому индекс считается сложением. Если порядок собьётся, * и только поэтому индекс считается сложением. Если порядок собьётся,
* сборка встанет здесь, а не рассыплется картинкой на экране. */ * сборка встанет здесь, а не рассыплется картинкой на экране. */
#if (ARC_KID_KID27 - ARC_KID_KID0) != 27 #if (ARC_KID_SWORD - ARC_KID_KID0_V) != KID_PAGES
#error "kid0..kid27 в архиве не подряд" #error "обычная и зеркальная серии Kid должны иметь одинаковую длину"
#endif #endif
#if (ARC_KID_KID27_V - ARC_KID_KID0_V) != 27
#error "kid0_v..kid27_v в архиве не подряд"
#endif
#define KID_MAXPAGES 28
/* Таблица архива живёт на стеке вызывающего (4 байта на запись, здесь 232) /* Таблица архива живёт на стеке вызывающего (4 байта на запись, здесь 232)
* держать её постоянно незачем, а W2 у игры и без того тесный. */ * держать её постоянно незачем, а W2 у игры и без того тесный. */
@@ -39,7 +35,7 @@ int pop_kid_load(uint8_t npages) __banked
pop_arc_t arc; pop_arc_t arc;
uint8_t i; uint8_t i;
if (npages > KID_MAXPAGES) npages = KID_MAXPAGES; if (npages > KID_PAGES) npages = KID_PAGES;
if (pop_arc_open_id(&arc, POP_ARC_KID, tbl, ARC_KID_COUNT) < 0) return -1; if (pop_arc_open_id(&arc, POP_ARC_KID, tbl, ARC_KID_COUNT) < 0) return -1;
for (i = 0; i < npages; i++) for (i = 0; i < npages; i++)
if (pop_arc_atlas(&arc, tbl, (uint8_t)(ARC_KID_KID0 + i), if (pop_arc_atlas(&arc, tbl, (uint8_t)(ARC_KID_KID0 + i),
@@ -75,7 +71,8 @@ int pop_kid_data_load(const char *path) __banked
blk=mem_alloc_pages(1); blk=mem_alloc_pages(1);
if (!blk) return -1; if (!blk) return -1;
page=mem_get_page(blk,0); page=mem_get_page(blk,0);
n=bank_load_file(page,KD_DATA_OFF,path,16384-KD_DATA_OFF); POP_PATH_CALL(n, bank_load_file(page,KD_DATA_OFF,path,
16384-KD_DATA_OFF));
if (n < (int)(KID_BIN_SEQTBL_OFF + KID_SEQTBL_LEN)) { if (n < (int)(KID_BIN_SEQTBL_OFF + KID_SEQTBL_LEN)) {
mem_free_block(blk); return -1; mem_free_block(blk); return -1;
} }
+9 -5
View File
@@ -18,6 +18,7 @@
#include "pop_map.h" /* pop_clip_char_top (clip_char) */ #include "pop_map.h" /* pop_clip_char_top (clip_char) */
#include "pop_geom.h" #include "pop_geom.h"
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */ #include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include <sprinter_mem.h> #include <sprinter_mem.h>
@@ -42,8 +43,7 @@ uint8_t pop_next_level;
* НЕ static: набор спрайтов слота POP_CH_KID общей отрисовки персонажей * НЕ static: набор спрайтов слота POP_CH_KID общей отрисовки персонажей
* (pop_cdraw.c, банк 4). Отдан как есть, а не через аксессор: читается раз * (pop_cdraw.c, банк 4). Отдан как есть, а не через аксессор: читается раз
* в кадр из другого банка, и вызов стоил бы дороже самого чтения. */ * в кадр из другого банка, и вызов стоил бы дороже самого чтения. */
#define KID_MAXPAGES 28 atlas_t kidp[KID_PAGES];
atlas_t kidp[KID_MAXPAGES];
uint8_t kid_npages; uint8_t kid_npages;
/* Таблица «seq_id -> адрес в seqtbl» (original_seqtbl_offsets). Определена /* Таблица «seq_id -> адрес в seqtbl» (original_seqtbl_offsets). Определена
@@ -358,9 +358,13 @@ void play_seq(void)
else if (ev == 4) { else if (ev == 4) {
/* seg006:646. На четвёртом уровне уход за дверь — это /* seg006:646. На четвёртом уровне уход за дверь — это
* встреча с тенью, и трек там свой; на 13-м (Джафар) и * встреча с тенью, и трек там свой; на 13-м (Джафар) и
* 15-м оригинал молчит. */ * 15-м оригинал молчит. Держать уровень на экране,
if (pop_current_level == 4) pop_sfx_play(32); * пока трек звучит, забота главного цикла: он ждёт
else if (pop_current_level != 13) pop_sfx_play(41); * pop_music_active(), и своей задержки здесь больше
* нет (была по длине записи из gen/pop_music_ticks.h,
* из-за чего EXE зависел от набора музыки). */
if (pop_current_level == 4) pop_sfx_play(32);
else if (pop_current_level != 13) pop_sfx_play(41);
} }
} }
break; break;
+6
View File
@@ -13,8 +13,13 @@
#include <stdint.h> #include <stdint.h>
#include "pop_char.h" #include "pop_char.h"
#include "kid_data.h" /* kframe — тип кадра для pop_sword_draw */ #include "kid_data.h" /* kframe — тип кадра для pop_sword_draw */
#include "kid_arc.h" /* границы серий атласов в kid.arc */
#include <sprite.h> /* atlas_t — набор спрайтов Кида (см. kidp ниже) */ #include <sprite.h> /* atlas_t — набор спрайтов Кида (см. kidp ниже) */
/* Число страниц Кида берём из метаданных архива: зеркальная серия начинается
* сразу после обычной. Не дублировать литералом в загрузчике/хранилище. */
#define KID_PAGES (ARC_KID_KID0_V - ARC_KID_KID0)
/* Состояние персонажа — общий тип для Кида, стража и Char (pop_char.h). */ /* Состояние персонажа — общий тип для Кида, стража и Char (pop_char.h). */
extern pop_char_t Kid; extern pop_char_t Kid;
@@ -26,6 +31,7 @@ extern int8_t knock;
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому * пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
* там рестарт того же уровня; загрузка следующего levels_plan.md. */ * там рестарт того же уровня; загрузка следующего levels_plan.md. */
extern uint8_t pop_next_level; extern uint8_t pop_next_level;
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph. /* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 0 OK, -1 ошибка. */ * 0 OK, -1 ошибка. */
+1 -1
View File
@@ -45,7 +45,7 @@ extern uint8_t pop_current_level;
void pop_level_qsave(pop_qs_io_t *io); void pop_level_qsave(pop_qs_io_t *io);
/* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять /* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять
* `LEVELS\res20NN.bin` (fallback `a:\res20NN.bin`) и выставить * `LEVELS\res20NN.bin` относительно каталога EXE и выставить
* pop_current_level. 0 OK, -1 файла нет / не влез (тогда * pop_current_level. 0 OK, -1 файла нет / не влез (тогда
* pop_current_level и страница НЕ тронуты играем дальше на старом). * pop_current_level и страница НЕ тронуты играем дальше на старом).
* Порт связки load_lev_spr + load_level (seg000:1098/1169). */ * Порт связки load_lev_spr + load_level (seg000:1098/1169). */
+6 -5
View File
@@ -17,6 +17,7 @@
#include "pop_level.h" #include "pop_level.h"
#include "_pop_level.h" #include "_pop_level.h"
#include "pop_geom.h" /* pop_x_bump / FIRST_ONSCREEN_COLUMN — pos_guards */ #include "pop_geom.h" /* pop_x_bump / FIRST_ONSCREEN_COLUMN — pos_guards */
#include "_pop_file.h"
/* Низкоуровневое чтение через W3 выполняет резидентный bank_load_file(); /* Низкоуровневое чтение через W3 выполняет резидентный bank_load_file();
* остальная одноразовая подготовка уровня безопасно остаётся в банке 8. */ * остальная одноразовая подготовка уровня безопасно остаётся в банке 8. */
@@ -58,7 +59,8 @@ static int level_read_file(const char *path)
blk=mem_alloc_pages(1); blk=mem_alloc_pages(1);
if (!blk) return -1; if (!blk) return -1;
page=mem_get_page(blk,0); page=mem_get_page(blk,0);
n=bank_load_file(page,LVL_DATA_OFF,path,16384-LVL_DATA_OFF); POP_PATH_CALL(n, bank_load_file(page,LVL_DATA_OFF,path,
16384-LVL_DATA_OFF));
if (n < (int)MIN_SIZE) { mem_free_block(blk); return -1; } if (n < (int)MIN_SIZE) { mem_free_block(blk); return -1; }
gfx_w0_page_prepare(page); gfx_w0_page_prepare(page);
gfx_w0_map(page); gfx_w0_map(page);
@@ -80,18 +82,17 @@ int pop_level_load_num(uint8_t n) __banked
/* Имена патчим на месте, а не через sprintf: printf-семейство тянет в /* Имена патчим на месте, а не через sprintf: printf-семейство тянет в
* резидент W1 форматтер, которого здесь больше негде применить. */ * резидент W1 форматтер, которого здесь больше негде применить. */
static char pri[] = "LEVELS\\res2000.bin"; /* цифры — [12],[13] */ static char pri[] = "LEVELS\\res2000.bin"; /* цифры — [12],[13] */
static char alt[] = "a:\\res2000.bin"; /* цифры — [8],[9] */
uint8_t saved_blk = pop_lvl_blk, saved_page = pop_lvl_page, saved_ok = pop_lvl_ok; uint8_t saved_blk = pop_lvl_blk, saved_page = pop_lvl_page, saved_ok = pop_lvl_ok;
if (n > POP_LEVEL_LAST) return -1; if (n > POP_LEVEL_LAST) return -1;
pri[12] = alt[8] = (char)('0' + n / 10); pri[12] = (char)('0' + n / 10);
pri[13] = alt[9] = (char)('0' + n % 10); pri[13] = (char)('0' + n % 10);
/* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой: /* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой:
* level_read_file сам аллоцирует страницу, и если файла нет остаёмся * level_read_file сам аллоцирует страницу, и если файла нет остаёмся
* на текущем уровне вместо падения в пустой уровень. */ * на текущем уровне вместо падения в пустой уровень. */
pop_lvl_ok = 0; /* чтобы level_load_path не мешался */ pop_lvl_ok = 0; /* чтобы level_load_path не мешался */
if (level_read_file(pri) != 0 && level_read_file(alt) != 0) { if (level_read_file(pri) != 0) {
pop_lvl_blk = saved_blk; pop_lvl_page = saved_page; pop_lvl_ok = saved_ok; pop_lvl_blk = saved_blk; pop_lvl_page = saved_page; pop_lvl_ok = saved_ok;
return -1; return -1;
} }
+62 -68
View File
@@ -27,6 +27,7 @@
#include "pop_settings.h" #include "pop_settings.h"
#include "pop_cfg.h" #include "pop_cfg.h"
#include "pop_qsave.h" #include "pop_qsave.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца, см. ui_wait_frame */
#include "pop_status.h" #include "pop_status.h"
#include "pop_sfx.h" #include "pop_sfx.h"
@@ -196,10 +197,21 @@ static uint8_t ui_begin(void)
gfx_rect_t full; gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1); uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
/* Полностраничная accel-копия держит DI дольше половины CBL-буфера. /* ЗВУК ЗДЕСЬ БОЛЬШЕ НЕ ГЛУШИМ (снято 2026-08-28).
* Закрываем вывод, иначе железо повторяет старые 128 байт (скрежет). *
* ui_end откроет его уже с заново заполненной тишиной. */ * Был вызов pop_sfx_pause() с обоснованием «полностраничная accel-копия
pop_sfx_pause(); * держит DI дольше половины CBL-буфера, иначе железо повторяет старые
* 128 байт». Обоснование устарело: лист, который зовёт gfx_copy_page
* (_bgi_scroll_rows_raw), режет DI бандами по 16 строк это ~1,6 мс
* против дедлайна насоса 11,7 мс, и окно прерываний между бандами есть.
* Тот же полностраничный копир каждый кадр делают игровой цикл и
* катсцены, и звук там не рвётся.
*
* А цена была слышимой: pause закрывает CBL целиком (cbl_close), курсор
* трека остаётся на месте, и короткая мелодия начала уровня замирала
* ровно на время перерисовки меню «тормозится на кусочек» (жалоба
* пользователя 2026-08-28). У оригинала такого нет и быть не может:
* там нет насоса, который надо кормить руками. */
gfx_set_draw_page(back); gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT; full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT); gfx_copy_page(&full, GFX_COPY_DIRECT);
@@ -207,10 +219,32 @@ static uint8_t ui_begin(void)
return back; return back;
} }
/* КАДР МЕНЮ: перед ожиданием фронта — обслужить музыку.
*
* Насос CBL живёт в прерывании и главный цикл ему не нужен, НО потоковый
* трек (титульная тема играется КОЛЬЦОМ, pop_music.c) дочитывается с диска
* именно из pop_music_service: очередная страница попадает в слот, который
* насос уже прошёл. Пока открыто меню, главный цикл стоит, и без этого
* вызова кольцо опустошалось за несколько секунд музыка замолкала до
* закрытия меню, а на перемещениях по пунктам это слышно как паузы
* (жалоба пользователя 2026-08-28).
*
* Место рядом с ожиданием кадра: получается тот же темп 50 раз в
* секунду, что и в игровом цикле, и подкачка не чаще, чем нужна. */
static void ui_wait_frame(void)
{
pop_music_service();
pop_wait_edge();
}
static void ui_end(uint8_t back) static void ui_end(uint8_t back)
{ {
pop_wait_edge(); /* готовый кадр показать только на фронте */ ui_wait_frame(); /* готовый кадр показать только на фронте */
gfx_set_visible_page(back); gfx_set_visible_page(back);
/* Парный к снятому pop_sfx_pause (см. ui_begin) вызов оставлен: он
* идемпотентен (открытый CBL возвращает 0 сразу) и чинит случай, когда
* в меню вошли при закрытом выводе например сразу после загрузки
* уровня, которая глушит звук намеренно. */
(void)pop_sfx_start(); (void)pop_sfx_start();
} }
@@ -227,7 +261,7 @@ static void ui_wait_release(void)
kbd_raw_down(KBD_SPACE) || kbd_raw_down(KBD_SPACE) ||
kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) || kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) ||
kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT)) kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT))
pop_wait_edge(); ui_wait_frame();
} }
/* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и /* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и
@@ -250,7 +284,7 @@ static void ui_wait_ack(void)
return; return;
} }
esc_prev = esc; act_prev = act; esc_prev = esc; act_prev = act;
pop_wait_edge(); ui_wait_frame();
} }
} }
@@ -478,7 +512,7 @@ static uint8_t ui_confirm(const char *question)
confirm_draw(question, sel); confirm_draw(question, sel);
menu_sound(MENU_SND_MOVE); menu_sound(MENU_SND_MOVE);
} }
pop_wait_edge(); ui_wait_frame();
} }
} }
@@ -522,10 +556,12 @@ static uint8_t settings_finish(uint8_t changed)
int8_t rc; int8_t rc;
if (!changed) return 0; if (!changed) return 0;
pop_settings_apply(1); pop_settings_apply(1);
/* ESTEX может уйти в диск на много CBL-периодов. */ /* Звук не глушим: POP.CFG — десятки байт, а на практике HOF пишется
pop_sfx_pause(); * под звучащий «won» и насос это переживает (проверка пользователя
* 2026-08-28). Прежняя осторожность «ESTEX может уйти в диск на много
* CBL-периодов» относилась к загрузке НАБОРА страниц, а не к записи
* одного мелкого файла. */
rc = pop_cfg_save(); rc = pop_cfg_save();
(void)pop_sfx_start();
ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR"); ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR");
return 1; return 1;
} }
@@ -609,17 +645,16 @@ static uint8_t settings_process(void)
settings_draw(sel); settings_draw(sel);
menu_sound(sound); menu_sound(sound);
} }
pop_wait_edge(); ui_wait_frame();
} }
} }
static int8_t menu_quickload(void) static int8_t menu_quickload(void)
{ {
uint8_t slots; uint8_t slots;
/* probe делает open/close на D:, поэтому насос в это время выключен. */ /* probe — это только open/close на D:, данных не читает; глушить насос
pop_sfx_pause(); * ради него незачем (см. settings_finish). */
slots = pop_qsave_probe(); slots = pop_qsave_probe();
(void)pop_sfx_start();
if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD; if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD;
if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?")) if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?"))
return POP_MENU_QUICKLOAD_BACKUP; return POP_MENU_QUICKLOAD_BACKUP;
@@ -641,65 +676,18 @@ static void menu_erase(uint8_t entry_page)
/* Из shadow видимой страницы берём кадр без текстов. */ /* Из shadow видимой страницы берём кадр без текстов. */
gfx_set_draw_page(hidden); gfx_set_draw_page(hidden);
gfx_copy_page(&full, GFX_COPY_DIRECT); gfx_copy_page(&full, GFX_COPY_DIRECT);
pop_wait_edge(); ui_wait_frame();
gfx_set_visible_page(hidden); gfx_set_visible_page(hidden);
/* Теперь чистая hidden — источник для второй страницы. */ /* Теперь чистая hidden — источник для второй страницы. */
gfx_set_draw_page(visible); gfx_set_draw_page(visible);
gfx_copy_page(&full, GFX_COPY_DIRECT); gfx_copy_page(&full, GFX_COPY_DIRECT);
if (entry_page != hidden) { if (entry_page != hidden) {
pop_wait_edge(); ui_wait_frame();
gfx_set_visible_page(entry_page); gfx_set_visible_page(entry_page);
} }
} }
static void build_info_draw(void)
{
uint8_t back = pop_screen_begin();
int top = UI_CENTER_TOP(ABOUT_H + BUILD_KEYS_GAP);
pop_screen_fill(BLACK);
gfx_set_bank(GFX_BANK_SPRITE);
pop_text_map();
about_text(top);
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2,
top + ABOUT_H + BUILD_KEYS_GAP, UI_KEYS_TEXT);
pop_text_unmap();
pop_screen_present(back);
}
/* В SprPoP ещё нет автомата TITLE (это FG1/FG6), поэтому build screen
* показывается перед первым игровым кадром и после выхода возвращает ровно
* ту же сцену. Настройка уже прочитана в pop_boot до initgraph. */
void pop_build_info_show(void) __banked
{
uint8_t entry_page, saved_page, saved_bank, frames;
struct fillsettingstype saved_fill;
if (!pop_ui_ready() || !pop_settings.show_build_info) return;
entry_page = gfx_get_visible_page();
saved_page = gfx_get_draw_page();
saved_bank = gfx_get_bank();
getfillsettings(&saved_fill);
/* Первый реальный пользователь FG5 fade: старая игровая палитра
* запоминается в pop_ui, затем full-screen появляется из чёрного. */
pop_pal_fade_out(4);
build_info_draw();
pop_pal_fade_in(4);
for (frames = 0; frames < 150; frames++) { /* около трёх секунд */
if (kbd_raw_down(KBD_ESC) || kbd_raw_down(KBD_ENTER) ||
kbd_raw_down(KBD_SPACE))
break;
pop_wait_edge();
}
ui_wait_release();
pop_pal_fade_out(4);
menu_erase(entry_page);
pop_pal_fade_in(4);
setfillstyle(saved_fill.pattern, saved_fill.color);
gfx_set_bank(saved_bank);
gfx_set_draw_page(saved_page);
}
int pop_menu_init(void) __banked int pop_menu_init(void) __banked
{ {
@@ -726,13 +714,18 @@ int8_t pop_menu_process(void) __banked
/* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие /* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие
* закроет меню раньше первого кадра. pop_wait_edge опрашивает FIFO. */ * закроет меню раньше первого кадра. pop_wait_edge опрашивает FIFO. */
while (kbd_raw_down(KBD_ESC)) pop_wait_edge(); while (kbd_raw_down(KBD_ESC)) ui_wait_frame();
entry_page = gfx_get_visible_page(); entry_page = gfx_get_visible_page();
saved_page = gfx_get_draw_page(); saved_page = gfx_get_draw_page();
saved_bank = gfx_get_bank(); saved_bank = gfx_get_bank();
getfillsettings(&saved_fill); getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */ /* Звук НЕ глушим (снято 2026-08-28, вторая половина той же заплатки —
* см. ui_begin). Здесь под ней были снимок палитры, затемнение фона и
* первая полная перерисовка: копия режет DI бандами по 16 строк, а
* палитровое затемнение идёт и в титрах с катсценами, где музыку оно не
* рвёт. Цена глушения была слышна как пауза в мелодии ровно на
* появление меню. */
pop_pal_snapshot(); pop_pal_snapshot();
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не /* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы * числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
@@ -789,11 +782,12 @@ int8_t pop_menu_process(void) __banked
menu_draw(sel); menu_draw(sel);
menu_sound(sound); menu_sound(sound);
} }
pop_wait_edge(); ui_wait_frame();
pop_pace_end(pop_pace_n(0)); /* игра стоит — обычный темп */ pop_pace_end(pop_pace_n(0)); /* игра стоит — обычный темп */
} }
pop_sfx_pause(); /* две full-page копии menu_erase */ /* И на закрытии не глушим: menu_erase — те же две полностраничные
* копии, что и в ui_begin, с DI бандами по 16 строк. */
menu_erase(entry_page); menu_erase(entry_page);
/* menu_erase вернул обе страницы из тени, снятой ДО меню: всё, что /* menu_erase вернул обе страницы из тени, снятой ДО меню: всё, что
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в * статус-полосы нарисовали за это время (или сообщение, дотикавшее в
-1
View File
@@ -40,6 +40,5 @@ int8_t pop_menu_process(void) __banked;
/* Экран Sprinter build, если он включён в POP.CFG. Вызывается после /* Экран Sprinter build, если он включён в POP.CFG. Вызывается после
* открытия raw-клавиатуры и до первого игрового кадра. */ * открытия raw-клавиатуры и до первого игрового кадра. */
void pop_build_info_show(void) __banked;
#endif #endif
+132 -15
View File
@@ -13,13 +13,81 @@
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include <sprinter_mem.h> #include <sprinter_mem.h>
#include <string.h>
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h" #include "pop_music.h"
#include "pop_music_tbl.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */ #include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h"
uint8_t pop_mus_page[POP_MUS_PAGES]; uint8_t pop_mus_page[POP_MUS_PAGES];
/* --- ИНДЕКС НАБОРА ---------------------------------------------------- *
*
* Страница EMM с прочитанным mus.idx; 0 индекса нет. В W2 от него
* остаются ровно эти два байта, сама таблица (228 Б) лежит в странице.
*
* Данные лежат со смещения POP_MUS_IDX_BASE, а не с начала страницы:
* gfx_w0_page_prepare пишет туда стабы прерываний (0x38 и 0x66), и с
* нуля они попали бы прямо в записи. Со смещением всё расходится, и
* страница подключается штатно без запрета прерываний. */
static uint8_t mus_idx_blk;
static uint8_t mus_idx_page;
/* "MUS\mus.idx" — имя знает сам модуль (тот же приём, что в pop_arc.c:
* литерал лежит в rodata банка 9, и open() читает его, пока в W3 стоит
* этот банк). */
static const char mus_idx_path[] = "MUS\\mus.idx";
int8_t pop_music_index_load(void) __banked
{
uint8_t *w0 = (uint8_t *)POP_MUS_IDX_BASE;
uint8_t blk, page, ok;
int fd;
if (mus_idx_page) return 0;
POP_PATH_CALL(fd, open(mus_idx_path, O_RDONLY));
if (fd < 0) return -1;
blk = mem_alloc_pages(1);
if (!blk) { close(fd); return -1; }
page = mem_get_page(blk, 0);
if (!page ||
bank_read_page(fd, page, POP_MUS_IDX_BASE, POP_MUS_IDX_SIZE) < 0) {
close(fd);
mem_free_block(blk);
return -1;
}
close(fd);
gfx_w0_page_prepare(page); /* стабы лягут вне данных, см. BASE */
gfx_w0_map(page);
ok = (uint8_t)(memcmp(w0, "PMI1", 4) == 0 &&
w0[4] == POP_MUS_COUNT &&
w0[5] == POP_MUS_IDX_REC &&
w0[6] == 7 && /* log2(128) — блок насоса */
w0[7] == 1); /* u8 mono, CBL_FREQ_10K9 */
gfx_w0_unmap();
if (!ok) { mem_free_block(blk); return -1; }
mus_idx_blk = blk;
mus_idx_page = page;
return 0;
}
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked
{
const uint8_t *rec;
const uint8_t *w0 = (const uint8_t *)POP_MUS_IDX_BASE;
out->blocks = 0;
out->ticks = 0;
if (!mus_idx_page || id >= POP_MUS_COUNT) return -1;
gfx_w0_map(mus_idx_page);
rec = w0 + POP_MUS_IDX_HDR + (uint16_t)id * POP_MUS_IDX_REC;
out->blocks = (uint16_t)(rec[0] | ((uint16_t)rec[1] << 8));
out->ticks = (uint16_t)(rec[2] | ((uint16_t)rec[3] << 8));
gfx_w0_unmap();
return out->blocks ? 0 : -1;
}
/* Два слота: играющий и подгружаемый. Своей таблицы страниц слот НЕ /* Два слота: играющий и подгружаемый. Своей таблицы страниц слот НЕ
* держит её и так хранит блок EMM, и mem_get_page отдаёт номер по * держит её и так хранит блок EMM, и mem_get_page отдаёт номер по
* индексу. Резидентный pop_mus_page[] заполняется на pop_music_play, для * индексу. Резидентный pop_mus_page[] заполняется на pop_music_play, для
@@ -49,8 +117,12 @@ static uint8_t ld_busy;
static int ld_fd; static int ld_fd;
static uint8_t ld_slot; static uint8_t ld_slot;
static uint8_t ld_next; /* следующая страница к чтению */ static uint8_t ld_next; /* следующая страница к чтению */
static uint8_t ld_half; /* 0 = впереди первая половина страницы */
static uint8_t ld_pages; static uint8_t ld_pages;
static uint8_t ld_id; static uint8_t ld_id;
/* Длина трека из индекса, снятая при открытии: лезть в страницу индекса
* на каждом шаге загрузки незачем она не меняется. */
static uint16_t ld_blocks;
static void slot_release(uint8_t n) static void slot_release(uint8_t n)
{ {
@@ -110,26 +182,31 @@ void pop_music_free(void) __banked
* ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и * ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и
* следующую надо читать, пока звучит текущая. Трек до 320 КБ, целиком * следующую надо читать, пока звучит текущая. Трек до 320 КБ, целиком
* это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров. * это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров.
* Одна страница стоит 33 мс меньше четверти кадра, и подкачка растворя- * Шаг стоит 16 мс (полстраницы, см. pop_music_load_step) меньше одного
* ется в анимации. Кто может позволить себе паузу (титры, чёрный экран * кадрового интервала, и подкачка растворяется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* между сценами), зовёт pop_music_load тот же цикл, но целиком. */ * между сценами), зовёт pop_music_load тот же цикл, но целиком. */
static void load_abort(void) static void load_abort(void)
{ {
if (ld_busy) close(ld_fd); if (ld_busy) close(ld_fd);
ld_busy = 0; ld_busy = 0;
ld_half = 0;
slot_release(ld_slot); slot_release(ld_slot);
} }
int8_t pop_music_load_begin(uint8_t id) __banked int8_t pop_music_load_begin(uint8_t id) __banked
{ {
char name[12]; char name[12];
pop_mus_info_t info;
uint8_t pages, n; uint8_t pages, n;
if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */ if (ring_on) pop_music_ring_stop(); /* потоковый трек и слоты не дружат */
if (ld_busy) load_abort(); if (ld_busy) load_abort();
if (id >= POP_MUS_COUNT) return -1; if (id >= POP_MUS_COUNT) return -1;
pages = pop_mus_tbl[id].pages; /* Длину и число страниц знает ИНДЕКС НАБОРА, а не EXE: у mt32 те же
if (!pages || pages > POP_MUS_PAGES) return -1; * треки другой длины (docs/music_runtime_index_plan.md). */
if (pop_music_info(id, &info) != 0) return -1;
pages = pop_music_pages(info.blocks);
if (pages > POP_MUS_PAGES) return -1;
/* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы /* Грузим ВСЕГДА в не текущий слот: играющий трек читает свои страницы
* из прерывания, и отобрать их у него нельзя. */ * из прерывания, и отобрать их у него нельзя. */
@@ -139,36 +216,49 @@ int8_t pop_music_load_begin(uint8_t id) __banked
/* Трек лежит ОДНИМ файлом, и fd остаётся открытым до последней /* Трек лежит ОДНИМ файлом, и fd остаётся открытым до последней
* страницы: `open` стоит дороже самого чтения (51,4 мс против 32,6 мс * страницы: `open` стоит дороже самого чтения (51,4 мс против 32,6 мс
* на 16 КБ), и платить его на каждую страницу чистые потери. */ * на 16 КБ), и платить его на каждую страницу чистые потери. */
ld_fd = open(name, O_RDONLY); POP_PATH_CALL(ld_fd, open(name, O_RDONLY));
if (ld_fd < 0) return -1; if (ld_fd < 0) return -1;
slot_blk[n] = mem_alloc_pages(pages); slot_blk[n] = mem_alloc_pages(pages);
if (!slot_blk[n]) { close(ld_fd); return -1; } if (!slot_blk[n]) { close(ld_fd); return -1; }
ld_busy = 1; ld_busy = 1;
ld_slot = n; ld_slot = n;
ld_next = 0; ld_next = 0;
ld_half = 0;
ld_pages = pages; ld_pages = pages;
ld_id = id; ld_id = id;
ld_blocks = info.blocks;
return 0; return 0;
} }
/* ШАГ = ПОЛСТРАНИЦЫ (8 КБ, ~16 мс), а не страница (~33 мс).
*
* Шаг вызывают раз в кадр из циклов, где кадр разложен на блоки по одному
* кадровому интервалу (20 мс): катсцены после перехода на блочную
* отрисовку, см. pop_intro.c/pv_restore_bg. Целая страница в интервал не
* влезала и незаметно растягивала кадр сцены; половина влезает, а цена
* вдвое больше вызовов (11 страниц реплики = 22 кадра вместо 11, то есть
* меньше полусекунды). Игровому циклу это безразлично: там логический
* кадр 80-100 мс. */
int8_t pop_music_load_step(void) __banked int8_t pop_music_load_step(void) __banked
{ {
uint8_t p; uint8_t p;
if (!ld_busy) return 1; /* нечего грузить */ if (!ld_busy) return 1; /* нечего грузить */
p = mem_get_page(slot_blk[ld_slot], ld_next); p = mem_get_page(slot_blk[ld_slot], ld_next);
/* Последняя страница короче — читаем сколько есть; ошибку ловим по /* Последняя страница короче — читаем сколько есть (вторая половина
* отрицательному коду, а не по «не 16384». */ * может дать и ноль байт); ошибку ловим по отрицательному коду. */
if (!p || bank_read_page(ld_fd, p, 0, 16384) < 0) { if (!p || bank_read_page(ld_fd, p, ld_half ? 8192 : 0, 8192) < 0) {
load_abort(); load_abort();
return -1; return -1;
} }
if (!ld_half) { ld_half = 1; return 0; } /* дочитаем в следующий раз */
ld_half = 0;
if (++ld_next < ld_pages) return 0; if (++ld_next < ld_pages) return 0;
close(ld_fd); close(ld_fd);
ld_busy = 0; ld_busy = 0;
slot_pages[ld_slot] = ld_pages; slot_pages[ld_slot] = ld_pages;
slot_blocks[ld_slot] = pop_mus_tbl[ld_id].blocks; slot_blocks[ld_slot] = ld_blocks;
slot_id[ld_slot] = ld_id; slot_id[ld_slot] = ld_id;
mus_ready = (uint8_t)(ld_slot + 1); mus_ready = (uint8_t)(ld_slot + 1);
return 1; return 1;
@@ -214,7 +304,7 @@ int8_t pop_music_stream(uint8_t id) __banked
if (n != mus_cur) slot_release(mus_cur); if (n != mus_cur) slot_release(mus_cur);
mus_cur = n; mus_cur = n;
slot_pages[n] = ld_pages; slot_pages[n] = ld_pages;
slot_blocks[n] = pop_mus_tbl[id].blocks; slot_blocks[n] = ld_blocks;
slot_id[n] = id; slot_id[n] = id;
pop_mus_id = id; pop_mus_id = id;
pop_mus_pg = 0; pop_mus_pg = 0;
@@ -268,19 +358,25 @@ void pop_music_ring_stop(void) __banked
int8_t pop_music_ring_start(uint8_t id) __banked int8_t pop_music_ring_start(uint8_t id) __banked
{ {
char name[12]; char name[12];
pop_mus_info_t info;
uint8_t i, page; uint8_t i, page;
if (id >= POP_MUS_COUNT || !pop_mus_tbl[id].pages) return -1; if (id >= POP_MUS_COUNT || pop_music_info(id, &info) != 0) return -1;
/* Потоковый трек НЕ ограничен POP_MUS_PAGES: он и не помещается в
* память целиком, ради того и кольцо. Предел здесь восьмибитные
* счётчики страниц (у mt32 финальная тема на 82 страницы против 78
* у flac). */
if (pop_music_pages(info.blocks) > 255) return -1;
pop_music_free(); /* обычные слоты кольцу не нужны */ pop_music_free(); /* обычные слоты кольцу не нужны */
pop_music_ring_stop(); pop_music_ring_stop();
mus_name(name, id); mus_name(name, id);
ring_fd = open(name, O_RDONLY); POP_PATH_CALL(ring_fd, open(name, O_RDONLY));
if (ring_fd < 0) return -1; if (ring_fd < 0) return -1;
ring_fd_open = 1; ring_fd_open = 1;
ring_blk = mem_alloc_pages(RING_PAGES); ring_blk = mem_alloc_pages(RING_PAGES);
if (!ring_blk) { ring_close_file(); return -1; } if (!ring_blk) { ring_close_file(); return -1; }
ring_file_pages = pop_mus_tbl[id].pages; ring_file_pages = (uint8_t)pop_music_pages(info.blocks);
ring_total = pop_mus_tbl[id].blocks; ring_total = info.blocks;
/* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд /* Заполняем кольцо целиком: старт с полным буфером даёт девять секунд
* форы, и первая же тяжёлая сцена финала не догонит насос. */ * форы, и первая же тяжёлая сцена финала не догонит насос. */
for (i = 0; i < RING_PAGES; i++) { for (i = 0; i < RING_PAGES; i++) {
@@ -360,3 +456,24 @@ uint8_t pop_music_busy(void) __banked
{ {
return (uint8_t)(pop_mus_left != 0); return (uint8_t)(pop_mus_left != 0);
} }
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
*
* Одного pop_music_busy() там, где ждут КОНЦА мелодии, мало: между
* заявкой (pop_sfx_play кладёт pop_mus_req) и первым звуком проходит
* 190-230 мс открытие файла и чтение первой полустраницы (замер и
* разбор docs/sound_plan.md §9). В этой щели busy ещё ложен, и
* ожидание закончилось бы, не начавшись.
*
* Три признака подряд закрывают её без единой подобранной константы:
* pop_music_service обнуляет req и тут же взводит ld_busy в load_begin,
* а тот держится до самой записи pop_mus_left. Снаружи переход
* «запрошен -> грузится -> звучит» непрерывен, потому что между этими
* операторами главный цикл не выполняется.
*
* И наоборот: если трека нет или память кончилась, все три ложны сразу
* ждать нечего, как и должно быть. */
uint8_t pop_music_active(void) __banked
{
return (uint8_t)(pop_mus_req || ld_busy || pop_mus_left);
}
+45
View File
@@ -28,6 +28,47 @@
* либо резать, либо стримить, и это отдельная задача. */ * либо резать, либо стримить, и это отдельная задача. */
#define POP_MUS_PAGES 20 #define POP_MUS_PAGES 20
/* --- ИНДЕКС НАБОРА (MUS/mus.idx, формат PMI1) ------------------------- *
*
* Длины треков и длительности реплик приезжают С ДИСКА, а не из EXE: так
* набор записей (flac/mp3/ogg/mt32) меняется без перекомпиляции игры.
* Формат пишет tools/pop_idx.py, он же покрыт make test-tools.
*
* Индекс живёт в ОДНОЙ странице EMM, а не в W2: таблица на 57 записей
* 228 байт, а свободной кучи в резиденте порядка двух сотен. Наружу
* указатель на EMM не выдаётся: pop_music_info копирует запись в буфер
* вызывающего, на короткое время подключая страницу в W0. */
#define POP_MUS_COUNT 57 /* пространство id оригинала, 0..56 */
#define POP_MUS_IDX_REC 4 /* blocks (u16) + ticks60 (u16) */
#define POP_MUS_IDX_HDR 16
#define POP_MUS_IDX_SIZE 512 /* сектор DSS */
/* Индекс кладётся в страницу СО СМЕЩЕНИЯ 0x100, а не с нуля. Причина в
* gfx_w0_page_prepare: он делает страницу безопасной в W0, записывая в
* неё переход на IRQ-восстановитель (0x38) и RETN для NMI (0x66). С
* нуля эти байты легли бы прямо в записи (id 10 и 21) и испортили
* индекс; со смещением 0x100 стабы и данные не пересекаются, и работает
* штатная защита от прерываний, а не запрет их. Тот же приём и по той
* же причине в pop_config.c (CFG_BASE). */
#define POP_MUS_IDX_BASE 0x100
typedef struct {
uint16_t blocks; /* длина трека в порциях насоса по 128 байт */
uint16_t ticks; /* та же длина в тиках сцены (60 Гц оригинала) */
} pop_mus_info_t;
/* Поднять индекс (один раз за запуск). 0 — ок, -1 — нет файла либо он
* не той версии; во втором случае музыки не будет, но игра работает. */
int8_t pop_music_index_load(void) __banked;
/* Запись трека в буфер вызывающего. -1 — индекса нет, id вне диапазона
* или трека нет в наборе (тогда поля нулевые). */
int8_t pop_music_info(uint8_t id, pop_mus_info_t *out) __banked;
/* Сколько EMM-страниц занимает трек такой длины. Считается, а НЕ хранится
* в индексе: дублирование однажды разъедется с blocks. */
#define pop_music_pages(blocks) \
(uint16_t)(((blocks) >> 7) + (((blocks) & 127) != 0))
/* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */ /* Номера треков — нумерация оригинала (SDLPoP data/music/names.txt). */
#define POP_MUS_INTRO 54 /* тема заставки, звучит на титрах */ #define POP_MUS_INTRO 54 /* тема заставки, звучит на титрах */
#define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */ #define POP_MUS_STORY_1 55 /* «In the absence of the Sultan...» */
@@ -102,4 +143,8 @@ void pop_music_ring_stop(void) __banked;
/* Трек ещё звучит? 0 — доиграл или не запускался. */ /* Трек ещё звучит? 0 — доиграл или не запускался. */
uint8_t pop_music_busy(void) __banked; uint8_t pop_music_busy(void) __banked;
/* «Трек в работе»: заявка/загрузка/звучание. Для мест, где ждут КОНЦА
* мелодии, busy там ложен в окне запуска (см. pop_music.c). */
uint8_t pop_music_active(void) __banked;
#endif /* POP_MUSIC_H */ #endif /* POP_MUSIC_H */
+6 -1
View File
@@ -11,6 +11,7 @@
#include <stdint.h> #include <stdint.h>
#include <gfx.h> #include <gfx.h>
#include <kbd_raw.h> #include <kbd_raw.h>
#include "pop_state.h" /* pop_idle — общий idle-хук */
#include "pop_pace.h" #include "pop_pace.h"
uint8_t pop_speed_mode; /* crt0 зануляет _DATA → NORMAL */ uint8_t pop_speed_mode; /* crt0 зануляет _DATA → NORMAL */
@@ -77,7 +78,11 @@ void pop_wait_edge(void)
t = pop_frame_tick; t = pop_frame_tick;
do { do {
pop_beam_sample(); pop_beam_sample();
kbd_raw_poll(); /* KBD-1: опрос обязан быть плотным */ /* ТОТ ЖЕ ХУК, что висит на gfx_wait_vsync, а не голый kbd_raw_poll:
* кроме плотного опроса FIFO (KBD-1) он проверяет F10, и без этого
* выход из игры в геймплее не работал пейсинг по лучу через
* gfx_wait_vsync не ходит. */
pop_idle();
} while (pop_frame_tick == t); } while (pop_frame_tick == t);
} }
+8 -6
View File
@@ -14,10 +14,12 @@
#include "pop_guard.h" #include "pop_guard.h"
#include "pop_pal.h" #include "pop_pal.h"
#include "pop_ui.h" /* POP_COL_OUTSIDE */ #include "pop_ui.h" /* POP_COL_OUTSIDE */
#include "_pop_file.h"
static void pal_file_load(const char *path, const char *fallback) static void pal_file_load(const char *path)
{ {
if (gfx_pal_fload(0, path) < 0) gfx_pal_fload(0, fallback); int rc;
POP_PATH_CALL(rc, gfx_pal_fload(0, path));
gfx_pal_sync(); gfx_pal_sync();
pop_ui_palette_snapshot(); pop_ui_palette_snapshot();
} }
@@ -84,7 +86,7 @@ void pop_pal_menu_dim(uint8_t fade) __banked
void pop_pal_game_load(void) __banked void pop_pal_game_load(void) __banked
{ {
pal_file_load("KID\\kid.pal", "a:\\kid.pal"); pal_file_load("KID\\kid.pal");
/* kid.pal содержит все 256 записей и намеренно затирает эти три /* kid.pal содержит все 256 записей и намеренно затирает эти три
* динамических диапазона (тайлсет, Тень, набор соперника). * динамических диапазона (тайлсет, Тень, набор соперника).
* Восстановить их надо ДО snapshot. */ * Восстановить их надо ДО snapshot. */
@@ -110,7 +112,7 @@ void pop_pal_level_load(uint8_t full) __banked
void pop_pal_title_load(void) __banked void pop_pal_title_load(void) __banked
{ {
pal_file_load("TITLE\\title.pal", "a:\\title.pal"); pal_file_load("TITLE\\title.pal");
} }
/* ЕДИНСТВЕННОЕ ОТЛИЧИЕ ФИНАЛА ОТ ИНТРО — цвет фона текстовой рамки. /* ЕДИНСТВЕННОЕ ОТЛИЧИЕ ФИНАЛА ОТ ИНТРО — цвет фона текстовой рамки.
@@ -120,7 +122,7 @@ void pop_pal_title_load(void) __banked
* файла и до нового снимка как тайлсет/Тень в pop_pal_game_load. */ * файла и до нового снимка как тайлсет/Тень в pop_pal_game_load. */
void pop_pal_story_load(uint8_t ending) __banked void pop_pal_story_load(uint8_t ending) __banked
{ {
pal_file_load("PV\\story.pal", "a:\\story.pal"); pal_file_load("PV\\story.pal");
if (!ending) return; if (!ending) return;
gfx_pal_set(0, POP_PAL_STORY_BG, 0x80, 0, 0); gfx_pal_set(0, POP_PAL_STORY_BG, 0x80, 0, 0);
gfx_pal_set(1, POP_PAL_STORY_BG, 0x80, 0, 0); gfx_pal_set(1, POP_PAL_STORY_BG, 0x80, 0, 0);
@@ -129,5 +131,5 @@ void pop_pal_story_load(uint8_t ending) __banked
void pop_pal_pv_load(void) __banked void pop_pal_pv_load(void) __banked
{ {
pal_file_load("PV\\pv.pal", "a:\\pv.pal"); pal_file_load("PV\\pv.pal");
} }
+52
View File
@@ -0,0 +1,52 @@
/*
* pop_path_bank.c каталог приложения и восстановление cwd, BANK10.
*
* APPINFO B=1 возвращает абсолютный каталог запущенного EXE вместе с диском.
* Получаем его один раз при старте. Буфер объявлен const намеренно: у
* Sprinter банковые страницы RAM, а --constseg кладёт объект в BANK10,
* не расходуя дефицитную резидентную _DATA в W2. Записывает его только
* ESTEX во время pop_path_init().
*
* ABI: публичные функции __banked; их вызывает POP_PATH_CALL из банка
* файлового клиента. После возврата трамплин восстанавливает прежнюю W3,
* поэтому переданный самой файловой операции указатель снова доступен.
*/
#include <stdint.h>
#include <unistd.h>
#include "_pop_file.h"
static const char pop_app_dir[256] = { 0 };
/* ESTEX APPINFO ($47), B=1: HL=буфер, CF/A=ошибка. */
static int appinfo_dir(const char *buf) __naked
{
(void)buf;
__asm
push ix
ld b, #1
ld c, #0x47
rst #0x10
pop ix
jr c, _paid_err
ld de, #0
ret
_paid_err:
call __errno_set
ld de, #-1
ret
__endasm;
}
int8_t pop_path_init(void) __banked
{
const char *dir = pop_app_dir;
if (appinfo_dir(dir) != 0 || !dir[0]) return -1;
return (int8_t)chdir(dir);
}
int8_t pop_chdir_home(void) __banked
{
return (int8_t)chdir((const char *)pop_app_dir);
}
+21 -12
View File
@@ -22,6 +22,7 @@
#include "pop_pace.h" #include "pop_pace.h"
#include "pop_timer.h" #include "pop_timer.h"
#include "sprpop_cold.h" #include "sprpop_cold.h"
#include "_pop_file.h"
#define QS_VERSION 4 #define QS_VERSION 4
#define QS_SAVE 1 #define QS_SAVE 1
@@ -154,6 +155,7 @@ static int8_t qs_save(uint8_t page)
uint8_t tail[4]; uint8_t tail[4];
const uint8_t h[7]={'P','O','P','Q',QS_VERSION,pop_current_level,0}; const uint8_t h[7]={'P','O','P','Q',QS_VERSION,pop_current_level,0};
uint16_t size; uint16_t size;
int rc;
qs_init(&io,page,QS_LIMIT,0,0); qs_init(&io,page,QS_LIMIT,0,0);
pop_qs_raw_put(&io,h,sizeof(h)); pop_qs_raw_put(&io,h,sizeof(h));
qs_payload(&io); qs_payload(&io);
@@ -162,14 +164,18 @@ static int8_t qs_save(uint8_t page)
tail[2]=(uint8_t)io.checksum; tail[3]=(uint8_t)(io.checksum >> 8); tail[2]=(uint8_t)io.checksum; tail[3]=(uint8_t)(io.checksum >> 8);
pop_qs_raw_put(&io,tail,4); size=(uint16_t)(io.pos-QS_BASE); pop_qs_raw_put(&io,tail,4); size=(uint16_t)(io.pos-QS_BASE);
if (io.failed) return -1; if (io.failed) return -1;
unlink("POP.NEW"); POP_PATH_CALL(rc, unlink("POP.NEW"));
if (bank_save_file(page,QS_BASE,"POP.NEW",size) != size) { POP_PATH_CALL(rc, bank_save_file(page,QS_BASE,"POP.NEW",size));
unlink("POP.NEW"); return -1; if (rc != size) {
POP_PATH_CALL(rc, unlink("POP.NEW"));
return -1;
} }
unlink("POP.BAK"); POP_PATH_CALL(rc, unlink("POP.BAK"));
(void)rename("POP.SAV", "POP.BAK"); POP_PATH_CALL(rc, rename("POP.SAV", "POP.BAK"));
if (rename("POP.NEW", "POP.SAV") != 0) { POP_PATH_CALL(rc, rename("POP.NEW", "POP.SAV"));
(void)rename("POP.BAK", "POP.SAV"); return -1; if (rc != 0) {
POP_PATH_CALL(rc, rename("POP.BAK", "POP.SAV"));
return -1;
} }
return 1; return 1;
} }
@@ -178,7 +184,8 @@ static int8_t qs_load_one(const char *name, uint8_t page)
{ {
uint8_t level; uint8_t level;
uint16_t payload_size; uint16_t payload_size;
int size=bank_load_file(page,QS_BASE,name,QS_LIMIT); int size;
POP_PATH_CALL(size, bank_load_file(page,QS_BASE,name,QS_LIMIT));
if (size <= 0 || !qs_validate_page(page,(uint16_t)size,&level, if (size <= 0 || !qs_validate_page(page,(uint16_t)size,&level,
&payload_size)) return -1; &payload_size)) return -1;
if (level != pop_current_level) { if (level != pop_current_level) {
@@ -205,11 +212,11 @@ uint8_t pop_qsave_probe(void) __banked
page = mem_get_page(blk, 0); page = mem_get_page(blk, 0);
gfx_w0_page_prepare(page); gfx_w0_page_prepare(page);
size = bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT); POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.SAV", QS_LIMIT));
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level, if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
&payload_size)) &payload_size))
slots |= POP_QS_SLOT_SAVE; slots |= POP_QS_SLOT_SAVE;
size = bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT); POP_PATH_CALL(size, bank_load_file(page, QS_BASE, "POP.BAK", QS_LIMIT));
if (size > 0 && qs_validate_page(page, (uint16_t)size, &level, if (size > 0 && qs_validate_page(page, (uint16_t)size, &level,
&payload_size)) &payload_size))
slots |= POP_QS_SLOT_BACKUP; slots |= POP_QS_SLOT_BACKUP;
@@ -234,14 +241,16 @@ int8_t pop_qsave_process(void) __banked
if (!blk) return -1; if (!blk) return -1;
page=mem_get_page(blk,0); page=mem_get_page(blk,0);
gfx_w0_page_prepare(page); gfx_w0_page_prepare(page);
pop_sfx_pause(); /* Звук не глушим (2026-08-28). Единственное место из снятых, где по
* диску реально едут 16 КБ (~33 мс, три периода насоса): если на F6/F9
* появится скрежет вернуть сюда pop_sfx_pause/pop_sfx_start, это
* лечится точечно и только здесь. */
if (request == QS_SAVE) rc = qs_save(page); if (request == QS_SAVE) rc = qs_save(page);
else if (request == QS_LOAD_BACKUP) rc = qs_load_one("POP.BAK", page); else if (request == QS_LOAD_BACKUP) rc = qs_load_one("POP.BAK", page);
else { else {
rc = qs_load_one("POP.SAV",page); rc = qs_load_one("POP.SAV",page);
if (rc < 0) rc = qs_load_one("POP.BAK",page); if (rc < 0) rc = qs_load_one("POP.BAK",page);
} }
pop_sfx_start();
mem_free_block(blk); mem_free_block(blk);
/* Успех ничего не меняет: нужная строка висит с начала операции. /* Успех ничего не меняет: нужная строка висит с начала операции.
* Отказ переписывает её на NO QUICKSAVE/NO QUICKLOAD обычной * Отказ переписывает её на NO QUICKSAVE/NO QUICKLOAD обычной
+3 -1
View File
@@ -31,6 +31,7 @@
#include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев */ #include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев */
#include "pop_cdraw.h" /* pop_cd — габарит кадра Кида (y-порядок плиты) */ #include "pop_cdraw.h" /* pop_cd — габарит кадра Кида (y-порядок плиты) */
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */ #include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */
#include "_pop_file.h"
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
@@ -83,7 +84,8 @@ static const char *bg_path(const char *name)
static int bg_load_tile_pal(const char *name) static int bg_load_tile_pal(const char *name)
{ {
uint8_t buf[64]; uint8_t buf[64];
int fd = open(bg_path(name), O_RDONLY); int fd;
POP_PATH_CALL(fd, open(bg_path(name), O_RDONLY));
if (fd < 0) return -1; if (fd < 0) return -1;
if (read(fd, buf, 64) != 64) { close(fd); return -1; } if (read(fd, buf, 64) != 64) { close(fd); return -1; }
gfx_pal_load(0, POP_PAL_ENV, 16, buf); gfx_pal_load(0, POP_PAL_ENV, 16, buf);
+1 -3
View File
@@ -23,8 +23,6 @@
#include "pop_music.h" #include "pop_music.h"
#include "_pop_sfx.h" #include "_pop_sfx.h"
/* Физические страницы набора; заполняет холодная половина. */
uint8_t pop_snd_page[POP_SND_PAGES];
uint8_t pop_snd_ok; uint8_t pop_snd_ok;
/* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok. /* ХОЧЕТ ЛИ ЗВУКА ПОЛЬЗОВАТЕЛЬ (Ctrl+S) — отдельно от pop_snd_ok.
@@ -169,7 +167,7 @@ int pop_sfx_fill(uint16_t n)
* целиком в одну). Гард обязателен: без него ошибка в длине увела * целиком в одну). Гард обязателен: без него ошибка в длине увела
* бы насос за pop_snd_page[] и он читал бы чужую память на слух * бы насос за pop_snd_page[] и он читал бы чужую память на слух
* это «играет не тот звук», причём каждый раз другой. */ * это «играет не тот звук», причём каждый раз другой. */
if ((uint8_t)(sfx_pg + 1) < POP_SND_PAGES) sfx_pg++; if ((uint8_t)(sfx_pg + 1) < pop_snd_pages) sfx_pg++;
else sfx_left = n; /* обрываем */ else sfx_left = n; /* обрываем */
} }
sfx_left -= n; sfx_left -= n;
+155 -7
View File
@@ -5,21 +5,163 @@
*/ */
#include <stdint.h> #include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h> #include <sprinter_mem.h>
#include <cbl.h> #include <cbl.h>
#include "pop_sfx.h" #include "pop_sfx.h"
#include "pop_arc.h" #include "pop_arc.h"
#define POP_SND_FILES /* имена файлов нужны ТОЛЬКО здесь, см. pop_sound_tbl.h */
#include "_pop_sfx.h" #include "_pop_sfx.h"
#include "_pop_file.h" /* POP_PATH_CALL — старые DSS и пути */
#include <irq.h> /* IRQ_DISABLE вокруг записи в W0 */
#include <sprite.h> /* gfx_w0_map — залить блок тишины */
static uint8_t snd_blk; /* блок EMM под весь набор */ static uint8_t snd_blk; /* блок EMM под весь набор */
/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
*
* Раскладка эффектов приезжает с диска, а не из EXE: так пара
* snd.idx + snd.arc заменяется целиком (SDLPoP <-> MSDOS) без
* перекомпиляции игры. Писатель формата tools/pop_idx.py, он же
* покрыт тестом; проверки ниже повторяют его один в один, потому что
* доверять содержимому файла нельзя: битый индекс увёл бы насос за
* пределы выделенного блока EMM.
*
* Заголовок: 16 байт, дальше POP_SND_COUNT записей по 5 байт подряд
* ровно в том виде, в каком лежит pop_snd_tbl[], поэтому читаются они
* ОДНИМ read'ом прямо туда, без разбора по полям. */
#define IDX_HDR_SIZE 16
#define IDX_BLOCK_LOG2 7 /* 1 << 7 == POP_SND_BLOCK */
#define IDX_PCM_FMT 1 /* u8 mono, CBL_FREQ_10K9 */
#define IDX_TBL_SIZE (POP_SND_COUNT * (int)sizeof(pop_snd_ent_t))
/* Имя знает сам модуль — литерал лежит в rodata банка 8, и open() читает
* его, пока в W3 стоит этот же банк (тот же приём, что в pop_arc.c). */
static const char idx_path[] = "SND\\snd.idx";
/* Одна запись: ровно те проверки, что делает упаковщик.
*
* СЧИТАЕМ В БЛОКАХ, А НЕ В БАЙТАХ. Байтовый адрес конца эффекта
* (page * 16384 + off + len) не влезает в uint16, а 32-битная арифметика
* на Z80 дорога (memory avoid_32bit_arith_z80). В блоках по 128 весь
* набор не больше POP_SND_MAX_PAGES * 128 = 2048, и всё считается
* шестнадцатью битами. */
static int8_t idx_entry_ok(const pop_snd_ent_t *e, uint8_t pages)
{
uint16_t start, end;
if (e->len == 0) /* нет оцифровки — это музыка */
return (e->page == 0 && e->off == 0) ? 0 : -1;
if (e->page >= pages) return -1;
if (e->off >= 0x4000) return -1;
if ((e->off | e->len) & (POP_SND_BLOCK - 1)) return -1;
/* Блок тишины неприкосновенен: насос льёт его, когда играть нечего. */
if (e->page == POP_SND_SILENCE_PAGE && e->off < POP_SND_BLOCK) return -1;
start = (uint16_t)((uint16_t)e->page << 7) + (uint16_t)(e->off >> 7);
end = (uint16_t)(start + (uint16_t)(e->len >> 7));
/* Длинный эффект переливается на следующие страницы — они обязаны
* быть загружены, иначе насос уйдёт в чужую память. */
if (end > (uint16_t)((uint16_t)pages << 7)) return -1;
return 0;
}
/* Прочитать и проверить индекс. Возвращает число страниц набора либо 0. */
static uint8_t idx_load(void)
{
uint8_t head[IDX_HDR_SIZE];
uint8_t pages, i;
int fd;
POP_PATH_CALL(fd, open(idx_path, O_RDONLY));
if (fd < 0) return 0;
if (read(fd, head, IDX_HDR_SIZE) != IDX_HDR_SIZE ||
memcmp(head, "PSI1", 4) != 0 ||
head[4] != POP_SND_COUNT ||
head[5] != (uint8_t)sizeof(pop_snd_ent_t) ||
head[6] != IDX_BLOCK_LOG2 ||
head[7] != IDX_PCM_FMT) {
close(fd);
return 0;
}
pages = head[8];
if (pages == 0 || pages > POP_SND_MAX_PAGES) { close(fd); return 0; }
/* Записи ложатся прямо в резидентную таблицу: дисковый формат и
* структура совпадают побайтово (проверено статически в
* pop_snd_data.c и полем head[5] выше). */
if (read(fd, pop_snd_tbl, IDX_TBL_SIZE) != IDX_TBL_SIZE) {
close(fd);
return 0;
}
close(fd);
for (i = 0; i < POP_SND_COUNT; i++)
if (idx_entry_ok(&pop_snd_tbl[i], pages) != 0) return 0;
return pages;
}
/* НАБОРА НЕТ — НО НАСОС ВСЁ РАВНО ОБЯЗАН РАБОТАТЬ.
*
* Тишину в паузах насос льёт из ПЕРВОГО БЛОКА НАБОРА, поэтому без набора
* CBL не открывался вовсе. А вместе с ним вставала и МУЗЫКА: её блоки
* отсчитывает тот же насос (pop_mus_left убывает только в pop_sfx_fill).
* Заставка при этом висла намертво ждала конца трека, который не
* начинал играть, и pop_music_busy() не гас никогда (поймано
* пользователем 2026-08-31 на образе без snd.idx).
*
* Поэтому при отказе индекса поднимаем ПУСТОЙ набор: одна страница EMM с
* блоком тишины. Эффектов нет все len в таблице нулевые, и
* pop_sfx_play уходит по ветке «нет оцифровки», а музыка играет.
*
* ЗАЛИВАЕМ ТОЛЬКО ПЕРВЫЙ БЛОК, и это не экономия: gfx_w0_page_prepare
* ставит в страницу IRQ/NMI-стабы, чтобы прерывание при замапленном W0
* нашло валидный код. Заливка ВСЕЙ страницы затирает их, и первое же
* прерывание уводит процессор в мусор чёрный экран на старте (поймано
* при первой попытке этого фикса). Насосу больше 128 байт и не нужно:
* он берёт ровно блок тишины, да и то через W3, а не через W0.
* Запись под DI: 128 байт накрывают стаб RST38, и до unmap прерываний
* быть не должно. */
static int8_t snd_silence_only(void)
{
uint8_t blk, page;
uint8_t *w0 = (uint8_t *)0x0000; /* окно 0 — сюда ляжет страница */
memset(pop_snd_tbl, 0, sizeof(pop_snd_tbl));
blk = mem_alloc_pages(1);
if (!blk) return -1;
page = mem_get_page(blk, 0);
if (!page) { mem_free_block(blk); return -1; }
gfx_w0_page_prepare(page);
IRQ_DISABLE();
gfx_w0_map(page);
memset(w0, 0x80, POP_SND_BLOCK); /* 0x80 — тишина в 8-бит без знака */
gfx_w0_unmap();
IRQ_ENABLE();
pop_snd_page[POP_SND_SILENCE_PAGE] = page;
snd_blk = blk;
pop_snd_pages = 1;
return 0;
}
int pop_sfx_init(void) __banked int pop_sfx_init(void) __banked
{ {
uint8_t i, blk; uint8_t i, blk, pages;
if (pop_snd_ok) return 0; if (pop_snd_ok) return 0;
/* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой
* эффект лёг знает только он. Набора без индекса не бывает:
* держать в EXE запасную таблицу нельзя, иначе привязка бинарника к
* конкретному набору вернётся через чёрный ход. */
pages = idx_load();
if (!pages) {
/* Набор эффектов не поднялся — но звук как таковой поднять надо,
* иначе встанет музыка (см. snd_silence_only). Возвращаем -1:
* эффектов действительно нет, и вызывающий вправе об этом знать. */
if (snd_silence_only() != 0) return -1;
pop_snd_want = 1;
return -1;
}
/* Одним блоком: страницы блока идут подряд по логическому индексу, и /* Одним блоком: страницы блока идут подряд по логическому индексу, и
* таблица звуков адресует их именно так (page = смещение >> 14). * таблица звуков адресует их именно так (page = смещение >> 14).
* *
@@ -29,14 +171,17 @@ int pop_sfx_init(void) __banked
* pop_arc.h. */ * pop_arc.h. */
{ {
pop_arc_t arc; pop_arc_t arc;
uint8_t tbl[POP_SND_PAGES * POP_ARC_REC]; uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_PAGES) != /* Архив обязан содержать РОВНО столько страниц, сколько обещал
POP_SND_PAGES) * индекс: расходящейся паре доверять нельзя она означает, что
* snd.idx и snd.arc из разных наборов. */
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
(int8_t)pages)
return -1; return -1;
blk = mem_alloc_pages(POP_SND_PAGES); blk = mem_alloc_pages(pages);
if (!blk) { pop_arc_close(&arc); return -1; } if (!blk) { pop_arc_close(&arc); return -1; }
for (i = 0; i < POP_SND_PAGES; i++) { for (i = 0; i < pages; i++) {
pop_snd_page[i] = mem_get_page(blk, i); pop_snd_page[i] = mem_get_page(blk, i);
if (!pop_snd_page[i] || if (!pop_snd_page[i] ||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) { pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
@@ -48,6 +193,9 @@ int pop_sfx_init(void) __banked
pop_arc_close(&arc); pop_arc_close(&arc);
} }
snd_blk = blk; snd_blk = blk;
/* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан
* видеть 0 и не пытаться ходить по полузаполненной таблице. */
pop_snd_pages = pages;
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */ pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
return 0; return 0;
} }
+34
View File
@@ -0,0 +1,34 @@
/*
* pop_snd_data.c РЕЗИДЕНТНЫЕ ДАННЫЕ звукового набора.
*
* Отдельным модулем, потому что стороны у этих данных разные: заполняет
* их ХОЛОДНЫЙ загрузчик из банка 8 (pop_sfx_cold.c), а читает ГОРЯЧИЙ
* насос из резидента (pop_sfx.c) в том числе из прерывания. Держать
* их в любой из половин значило бы тянуть чужую в резидент.
*
* Инициализаторов нет намеренно: crt0 зануляет _DATA (memory
* sdcc_static_storage_gotcha), а «набор не загружен» это и есть нули.
*
* ЦЕНА ПЕРЕЕЗДА. Таблица была `static const` в _CODE (285 Б); сняв
* const, мы переносим её в _DATA суммарный резидентный расход не
* меняется, потому что в huge-модели _CODE и данные лежат подряд в одном
* диапазоне 0x4000..0xBFFF. Реально добавилось 8 байт: pop_snd_page
* вырос с размера набора до POP_SND_MAX_PAGES (запас под чужие наборы)
* плюс байт самого счётчика.
*/
#include <stdint.h>
#include "pop_snd_tbl.h"
/* РАСКЛАДКА НАБОРА — приезжает из SND/snd.idx целиком, одним read'ом
* прямо сюда. Поэтому размер записи обязан совпадать с дисковым: если
* ABI когда-нибудь добавит выравнивание, сборка упадёт здесь, а не
* рассыплется звуком в рантайме. */
pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT];
typedef char pop_snd_ent_is_5_bytes[(sizeof(pop_snd_ent_t) == 5) ? 1 : -1];
/* Физические страницы EMM, по индексу из pop_snd_tbl[].page. */
uint8_t pop_snd_page[POP_SND_MAX_PAGES];
/* Сколько страниц у ЭТОГО набора (1..POP_SND_MAX_PAGES). Насос сверяет
* с ним переход длинного эффекта через границу страницы. */
uint8_t pop_snd_pages;
+50
View File
@@ -0,0 +1,50 @@
/*
* pop_snd_tbl.h РАСКЛАДКА ОЦИФРОВАННЫХ ЭФФЕКТОВ: тип и инварианты.
*
* Раньше этот файл ПЕЧАТАЛ упаковщик (gen/pop_sound_tbl.h), и конкретные
* page/off/len становились частью EXE из-за чего замена звукового набора
* (SDLPoP <-> MSDOS) требовала перекомпиляции игры. Теперь значения
* приезжают с диска (SND/snd.idx, формат PSI1 tools/pop_idx.py), а
* здесь остаётся только то, что от набора НЕ зависит: устройство записи и
* пределы, на которые рассчитан загрузчик.
*
* ЗАПИСЬ ЧИТАЕТСЯ С ДИСКА ПОБАЙТОВО, прямо в pop_snd_tbl[]. Значит
* раскладка структуры в ABI SDCC z80 (uint8 + uint16 + uint16 подряд, без
* выравнивания = 5 байт) часть дискового контракта, а не деталь
* компилятора. Расхождение молча разъехало бы весь набор, поэтому оно
* ловится статически (pop_snd_data.c) и проверяется загрузчиком по полю
* размера записи в заголовке индекса.
*/
#ifndef POP_SND_TBL_H
#define POP_SND_TBL_H
#include <stdint.h>
/* Пространство id оригинала: 0..56. От набора не зависит. */
#define POP_SND_COUNT 57
/* Блок запроса CBL при 8 бит; страница EMM кратна ему, поэтому ни один
* блок не пересекает границу страницы (см. docs/sound_plan.md). */
#define POP_SND_BLOCK 128
/* СКОЛЬКО СТРАНИЦ ГОТОВ ПРИНЯТЬ ЗАГРУЗЧИК — вместимость, а НЕ размер
* конкретного набора: тот приходит в индексе (pop_snd_pages). Запас
* ради будущих наборов без перекомпиляции; цена 16 байт таблицы
* страниц вместо 9-10. */
#define POP_SND_MAX_PAGES 16
/* ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 — ТИШИНА. Насос льёт её, когда играть нечего:
* без этого железо доигрывает по кругу хвост своего буфера (жужжание).
* Это инвариант ПРОГРАММЫ упаковщик обязан оставить блок свободным, а
* загрузчик проверяет, что ни одна запись в него не залезла. */
#define POP_SND_SILENCE_PAGE 0
#define POP_SND_SILENCE_OFF 0x0000
/* page — индекс в pop_snd_page[] (физические страницы EMM), off — начало
* внутри страницы, len длина PCM. Оба кратны POP_SND_BLOCK; len == 0
* значит «у этого id нет оцифровки» (это музыка, см. sound_plan.md). */
typedef struct {
uint8_t page;
uint16_t off;
uint16_t len;
} pop_snd_ent_t;
#endif /* POP_SND_TBL_H */
+6
View File
@@ -19,6 +19,12 @@
* отрыв куска, снимает сам обход по факту пустой таблицы. */ * отрыв куска, снимает сам обход по факту пустой таблицы. */
/* F10 нажата — выйти из программы; см. pop_state.c. */ /* F10 нажата — выйти из программы; см. pop_state.c. */
extern uint8_t pop_quit_req; extern uint8_t pop_quit_req;
/* Общий idle-хук: вычерпать клавиатурный FIFO и проверить F10. Живёт в
* sprpop.c (резидент), висит на gfx_wait_vsync и вызывается из цикла
* ожидания луча обе точки ожидания обязаны звать ОДНО И ТО ЖЕ, иначе
* выход работает не везде. */
void pop_idle(void);
extern uint8_t pop_mob_busy; extern uint8_t pop_mob_busy;
/* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе /* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе
+24 -10
View File
@@ -16,6 +16,8 @@
#include "pop_arc.h" #include "pop_arc.h"
#include "pop_ui.h" #include "pop_ui.h"
#include "pop_status.h" #include "pop_status.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца в долгих фейдах */
#include "_pop_file.h"
typedef struct { typedef struct {
uint8_t base; uint8_t base;
@@ -53,8 +55,10 @@ static uint8_t text_index(const pop_font_desc_t *font, uint8_t ch)
int pop_ui_init(void) __banked int pop_ui_init(void) __banked
{ {
int rc;
if (font_ready) return 0; if (font_ready) return 0;
if (atlas_load(&font_at, "FONT\\font.atl") != 0) return -1; POP_PATH_CALL(rc, atlas_load(&font_at, "FONT\\font.atl"));
if (rc != 0) return -1;
font_ready = 1; font_ready = 1;
return 0; return 0;
} }
@@ -71,17 +75,17 @@ uint8_t pop_ui_ready(void)
return font_ready; return font_ready;
} }
void pop_text_map(void) void pop_text_map(void) __banked
{ {
gfx_w0_map(font_at.page); gfx_w0_map(font_at.page);
} }
void pop_text_unmap(void) void pop_text_unmap(void) __banked
{ {
gfx_w0_unmap(); gfx_w0_unmap();
} }
uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) __banked
{ {
const pop_font_desc_t *desc = &font_desc[font]; const pop_font_desc_t *desc = &font_desc[font];
uint8_t width = 0; uint8_t width = 0;
@@ -95,7 +99,7 @@ uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s)
} }
void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline, void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
const char *s) const char *s) __banked
{ {
const pop_font_desc_t *desc = &font_desc[font]; const pop_font_desc_t *desc = &font_desc[font];
@@ -115,14 +119,14 @@ void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
} }
void pop_text_center_mapped(pop_text_font_t font, int center, int baseline, void pop_text_center_mapped(pop_text_font_t font, int center, int baseline,
const char *s) const char *s) __banked
{ {
pop_text_draw_mapped(font, center - pop_text_width_mapped(font, s) / 2, pop_text_draw_mapped(font, center - pop_text_width_mapped(font, s) / 2,
baseline, s); baseline, s);
} }
void pop_text_right_mapped(pop_text_font_t font, int right, int baseline, void pop_text_right_mapped(pop_text_font_t font, int right, int baseline,
const char *s) const char *s) __banked
{ {
pop_text_draw_mapped(font, right - pop_text_width_mapped(font, s), pop_text_draw_mapped(font, right - pop_text_width_mapped(font, s),
baseline, s); baseline, s);
@@ -241,8 +245,10 @@ void pop_ui_palette_black(void) __banked
uint8_t *black = pop_pal_buf; uint8_t *black = pop_pal_buf;
/* BIOS принимает максимум 64 записи за вызов; одним буфером покрываем /* BIOS принимает максимум 64 записи за вызов; одним буфером покрываем
* все четыре четверти палитры, не занимая постоянную память. */ * все четыре четверти палитры, не занимая постоянную память.
for (i = 0; i < sizeof(black); i++) black[i] = 0; * sizeof(black) здесь нельзя: black 16-битный указатель, и тогда
* обнулятся лишь первые два байта, а цвет заливки останется случайным. */
for (i = 0; i < sizeof(pop_pal_buf); i++) black[i] = 0;
for (chunk = 0; chunk < 4; chunk++) { for (chunk = 0; chunk < 4; chunk++) {
uint8_t start = (uint8_t)(chunk << 6); uint8_t start = (uint8_t)(chunk << 6);
gfx_pal_load(0, start, 64, black); gfx_pal_load(0, start, 64, black);
@@ -292,6 +298,11 @@ static uint8_t fade_run(uint8_t frames, uint8_t up, uint8_t skip)
pop_ui_palette_dim(step, 0); pop_ui_palette_dim(step, 0);
shown = step; shown = step;
} }
/* Fade — это 2,13 с, и всё это время главный цикл стоит. Кольцо
* потокового трека дочитывает pop_music_service, иначе музыка
* замолкает прямо посреди перехода (та же причина, что у меню:
* ui_wait_frame в pop_menu.c). */
pop_music_service();
gfx_wait_vsync(); gfx_wait_vsync();
if (skip) { if (skip) {
kbd_raw_sync(); kbd_raw_sync();
@@ -429,6 +440,7 @@ uint8_t pop_screen_present_ltr(void) __banked
stripe.x = x; stripe.x = x;
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4); stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
gfx_copy_page(&stripe, GFX_COPY_DIRECT); gfx_copy_page(&stripe, GFX_COPY_DIRECT);
pop_music_service(); /* 80 кадров перехода — см. fade_run */
gfx_wait_vsync(); gfx_wait_vsync();
} }
return 0; return 0;
@@ -461,8 +473,10 @@ int pop_screen_blit_atlas_strips(const char * const *parts,
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
for (part = 0; part < count; part++) { for (part = 0; part < count; part++) {
atlas_t at; atlas_t at;
int rc;
if (atlas_load(&at, parts[part]) != 0) return -1; POP_PATH_CALL(rc, atlas_load(&at, parts[part]));
if (rc != 0) return -1;
strip_blit(&at, x, &y); strip_blit(&at, x, &y);
} }
return 0; return 0;
+16 -7
View File
@@ -27,16 +27,25 @@ void pop_ui_free(void) __banked;
uint8_t pop_ui_ready(void); uint8_t pop_ui_ready(void);
/* Группа text_*_mapped требует активного pop_text_map() и не вызывает /* Группа text_*_mapped требует активного pop_text_map() и не вызывает
* BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования. */ * BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования.
void pop_text_map(void); *
void pop_text_unmap(void); * ВСЯ ГРУППА __banked, И ЭТО ОБЯЗАТЕЛЬНО (крэш финала, 2026-08-27). Живёт
uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s); * она в pop_ui.c, то есть в банке 9, а зовут её и из ДРУГИХ банков HOF
* лежит в банке 10. Трамплин SDCC выбирает по объявлению, а не по тому,
* где функция реально лежит (memory sdcc_banked_call_rules): без пометки
* получается прямой `call` по адресу банка 9, который в чужом банке
* попадает в пустой хвост (0xFF) процессор ползёт по нему до 0x0000, и
* DSS убивает процесс с кодом 0x27. Собиралось это МОЛЧА, поэтому рядом
* появилась toolchain/check_bank_calls.py она такое ловит на сборке. */
void pop_text_map(void) __banked;
void pop_text_unmap(void) __banked;
uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) __banked;
void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline, void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
const char *s); const char *s) __banked;
void pop_text_center_mapped(pop_text_font_t font, int center, int baseline, void pop_text_center_mapped(pop_text_font_t font, int center, int baseline,
const char *s); const char *s) __banked;
void pop_text_right_mapped(pop_text_font_t font, int right, int baseline, void pop_text_right_mapped(pop_text_font_t font, int right, int baseline,
const char *s); const char *s) __banked;
/* Снимок обеих (идентичных по контракту) палитр хранится в свободном хвосте /* Снимок обеих (идентичных по контракту) палитр хранится в свободном хвосте
* FONT.ATL. dim_step: 0 исходная, POP_PAL_DIM_BLACK чёрная; между ними * FONT.ATL. dim_step: 0 исходная, POP_PAL_DIM_BLACK чёрная; между ними
-9
View File
@@ -40,15 +40,6 @@ int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror)
return 0; return 0;
} }
int pop_vflip_add(const atlas_t *orig, const char *path)
{
atlas_t m;
if (!orig || vf_n >= VF_MAX) return -1;
if (atlas_load(&m, path) != 0) return -1;
return pop_vflip_take(orig, &m);
}
uint8_t pop_vflip_page(uint8_t src) uint8_t pop_vflip_page(uint8_t src)
{ {
uint8_t i; uint8_t i;
+1 -5
View File
@@ -26,11 +26,7 @@
#include <stdint.h> #include <stdint.h>
#include <sprite.h> #include <sprite.h>
/* Загрузить зеркальный файл path и связать его с атласом orig. /* Зеркало УЖЕ загружено из архива: таблица
* 0 OK, -1 файла нет / не хватило EMM / таблица полна. */
int pop_vflip_add(const atlas_t *orig, const char *path);
/* То же, но зеркало УЖЕ загружено (например, прочитано из архива): таблица
* забирает его себе, и дальше он освобождается вместе с остальными. */ * забирает его себе, и дальше он освобождается вместе с остальными. */
int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror); int pop_vflip_take(const atlas_t *orig, const atlas_t *mirror);
+48 -8
View File
@@ -41,6 +41,10 @@
#include "pop_pal.h" /* lifecycle палитры title/intro/level */ #include "pop_pal.h" /* lifecycle палитры title/intro/level */
#include "pop_hof.h" /* persistent Hall of Fame — bank 9 */ #include "pop_hof.h" /* persistent Hall of Fame — bank 9 */
#include "pop_ui.h" /* чёрные обе страницы перед сменой палитры */ #include "pop_ui.h" /* чёрные обе страницы перед сменой палитры */
#include "_pop_file.h" /* cwd приложения для старых DSS */
#include <sprinter_exit.h>
#include <unistd.h>
__sfr __at (0xFE) io_border; __sfr __at (0xFE) io_border;
@@ -125,7 +129,10 @@ uint8_t pop_menu_request;
* проявление полосами, меню, игра. Одна точка вместо десятка. */ * проявление полосами, меню, игра. Одна точка вместо десятка. */
#define KBD_KEY_F10 0x09 #define KBD_KEY_F10 0x09
static void kbd_idle(void) /* НЕ static: этот же хук зовёт цикл ожидания луча (pop_wait_edge), иначе
* в геймплее F10 не работал бы вовсе пейсинг по лучу не ходит через
* gfx_wait_vsync, к которому хук подвешен (поймано 2026-08-27). */
void pop_idle(void)
{ {
kbd_raw_poll(); kbd_raw_poll();
if (kbd_raw_down(KBD_KEY_F10)) pop_quit_req = 1; if (kbd_raw_down(KBD_KEY_F10)) pop_quit_req = 1;
@@ -257,6 +264,11 @@ int main(void)
uint8_t xdec_prev = 0, xinc_prev = 0, upsd_prev = 0; uint8_t xdec_prev = 0, xinc_prev = 0, upsd_prev = 0;
uint8_t failed = 0; uint8_t failed = 0;
/* До первой файловой операции запоминаем абсолютный каталог EXE.
* Без этого старый DSS теряет последний компонент cwd после OPEN пути
* с подкаталогом и следующий файл ищет уровнем выше. */
if (pop_path_init() != 0) return 1;
/* Загрузка ресурсов, палитра, первый экран — В БАНКЕ (sprpop_cold.c): /* Загрузка ресурсов, палитра, первый экран — В БАНКЕ (sprpop_cold.c):
* отрабатывает ровно один раз за игру, а кода там на полкилобайта. */ * отрабатывает ровно один раз за игру, а кода там на полкилобайта. */
pop_app_reset(); pop_app_reset();
@@ -267,14 +279,15 @@ int main(void)
puts("kbd_raw open failed"); puts("kbd_raw open failed");
return 1; return 1;
} }
gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */ gfx_set_idle_hook(pop_idle); /* вычерпывать FIFO, пока ждём кадр */
if (!pop_app_dispatch(POP_APP_EV_BOOT_READY)) { if (!pop_app_dispatch(POP_APP_EV_BOOT_READY)) {
pop_shutdown(); pop_shutdown();
return 1; return 1;
} }
/* MS8: CFG уже применён в pop_boot до первого показа. Экран build /* Экран версии при запуске БОЛЬШЕ НЕ ПОКАЗЫВАЕТСЯ (решение пользователя
* показывается только по явной пользовательской настройке. */ * 2026-08-27): единственная его точка пункт ABOUT в меню паузы.
pop_build_info_show(); * Само состояние BUILD_INFO в автомате осталось: через него проходит
* маршрут BOOT -> TITLE, и убирать его ради одной пустой фазы незачем. */
if (!pop_app_dispatch(POP_APP_EV_BUILD_DONE)) { if (!pop_app_dispatch(POP_APP_EV_BUILD_DONE)) {
pop_shutdown(); pop_shutdown();
return 1; return 1;
@@ -761,9 +774,36 @@ int main(void)
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это /* Смена уровня целиком уехала в банк 8 (pop_level_change): это
* событие раз в несколько минут, а кода на нём было под сотню строк * событие раз в несколько минут, а кода на нём было под сотню строк
* в самом горячем месте программы резидент W1 у нас в дефиците. */ * в самом горячем месте программы резидент W1 у нас в дефиците. */
if (!is_demo && pop_next_level != pop_current_level && if (!is_demo && pop_next_level != pop_current_level) {
pop_level_change(back) != 0) { /* КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ (порт seg003:387, play_level_2):
failed = 1; quit = 1; break; *
* if (next_level == current_level || check_sound_playing())
* draw_game_frame(); ... // экран уровня живёт
* else { stop_sounds(); return next_level; }
*
* То есть пройденный уровень остаётся на экране с анимацией
* факелов, идущим таймером и всем прочим ровно столько,
* сколько звучит трек конца уровня; мы же уходили на смену
* уровня сразу и обрывали мелодию на первых нотах.
*
* «Звучит» это pop_music_active(), а не busy: между заявкой
* на трек и первой нотой проходит 190-230 мс на открытие
* файла и чтение первой полустраницы, и в этой щели busy ещё
* ложен (замер docs/sound_plan.md §9). Раньше щель
* закрывал счётчик по длине записи, но он же тянул за собой
* зависимость EXE от набора музыки и жёсткий делитель `/4`,
* враньё которого вылезало в быстрых режимах (там кадр 3
* луча, а не 4). Состояние «заявка/загрузка/звучание»
* закрывает щель без единой константы.
*
* ЗВУК ВЫКЛЮЧЕН ПАУЗЫ НЕТ, и это тоже верно: pop_sfx_play
* не кладёт заявку при !pop_snd_ok, все три признака ложны, и
* уровень меняется сразу. Ровно так в оригинале, где трек
* ставится под `if (is_sound_on)` (seg006:651), а ожидание
* `check_sound_playing()` (seg003:387). */
if (!pop_music_active() && pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
} }
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */ if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++; dead_frames++;
+53 -13
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */ #include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */ #include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */ #include "pop_sfx.h" /* звуковые эффекты через CBL */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */ #include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */ #include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */ #include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
@@ -99,7 +100,7 @@ static void find_start_level_door(uint8_t room)
int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked 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; static uint8_t esc_prev, qs_save_prev, qs_load_prev;
static uint8_t restart_prev, version_prev; static uint8_t restart_prev;
static uint8_t intro_prev, showtime_prev, timer_prev; static uint8_t intro_prev, showtime_prev, timer_prev;
uint8_t e = (uint8_t)(kbd_raw_down(KBD_MENU) || kbd_raw_down(KBD_MENU_ALT)); uint8_t e = (uint8_t)(kbd_raw_down(KBD_MENU) || kbd_raw_down(KBD_MENU_ALT));
uint8_t ctrl = (uint8_t)(kbd_raw_down(KBD_LCTRL) || kbd_raw_down(KBD_RCTRL)); uint8_t ctrl = (uint8_t)(kbd_raw_down(KBD_LCTRL) || kbd_raw_down(KBD_RCTRL));
@@ -142,14 +143,6 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
if (r && !restart_prev) *restart_level = 1; if (r && !restart_prev) *restart_level = 1;
restart_prev = r; restart_prev = r;
} }
/* Ctrl+V — версия сборки. Функция уже есть, до сих пор её показывал
* только старт программы. */
{
uint8_t v = (uint8_t)(ctrl && kbd_raw_down(KBD_VERSION));
if (v && !version_prev) pop_build_info_show();
version_prev = v;
}
/* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game» /* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game»
* пунктом меню: автомат сам разложит title/intro с первого экрана. */ * пунктом меню: автомат сам разложит title/intro с первого экрана. */
{ {
@@ -234,7 +227,12 @@ int8_t pop_level_change(uint8_t back) __banked
* почти всегда поймано пользователем 2026-08-25. Ветка с * почти всегда поймано пользователем 2026-08-25. Ветка с
* катсценой свободна от этого: pop_pre_cutscene_show глушит * катсценой свободна от этого: pop_pre_cutscene_show глушит
* звук первым действием. */ * звук первым действием. */
pop_sfx_pause(); /* Глушение снято 2026-08-28 (замер: загрузка уровня насос не
* морит 500 вызовов подряд, максимум 245 832 такта при
* дедлайне 251 000). Прежнее обоснование скрежет на палитре
* через BIOS ($A4 с запрещёнными прерываниями), пойманный на
* переходе без катсцены 2026-08-25. Если он вернётся, это
* единственное место, куда его надо возвращать. */
/* Без fade: старый кадр гасим сразу и обязательно даём /* Без fade: старый кадр гасим сразу и обязательно даём
* показать один чёрный растр ДО диска/перерисовки. */ * показать один чёрный растр ДО диска/перерисовки. */
pop_pal_apply(POP_PAL_DIM_BLACK); pop_pal_apply(POP_PAL_DIM_BLACK);
@@ -431,6 +429,13 @@ void pop_start_level(void) __banked
* гасит заставка «LEVEL N», которую этот же переход и пропускает. */ * гасит заставка «LEVEL N», которую этот же переход и пропускает. */
if (pop_seamless) { pop_seamless = 0; pop_kid_hp_carry(); } if (pop_seamless) { pop_seamless = 0; pop_kid_hp_carry(); }
else pop_kid_hp_reset(); else pop_kid_hp_reset();
/* ПОЛОСУ HP — НА ОБЕИХ СТРАНИЦАХ. Старт уровня перерисовал фон, то есть
* стёр её, а сама она обновляется только при ИЗМЕНЕНИИ чисел. После
* Ctrl+A с выросшим за уровень максимумом (большой кувшин, чит Shift+T)
* это давало моргание: одна страница показывала старые деления, вторая
* новые (поймано пользователем 2026-08-27). invalidate ровно для этого
* и заведён см. его шапку в pop_cdraw.c. */
pop_hp_invalidate();
} }
/* ---- ОТЛАДКА: обход комнат по номеру -------------------------------- * /* ---- ОТЛАДКА: обход комнат по номеру -------------------------------- *
@@ -1146,14 +1151,18 @@ int pop_boot(void) __banked
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */ /* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
pop_guard_reset(); pop_guard_reset();
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */ if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
printf("kid.ani load failed\n"); puts("kid.ani load failed\n");
return -1; return -1;
} }
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */ pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */ pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */ pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */ pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
if (pop_kid_load(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */ if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed"); puts("kid atlas load failed");
return -1; return -1;
} }
@@ -1228,7 +1237,10 @@ int pop_level_switch(void) __banked
* дальше, а урон нет (do_startpos дольёт до потолка). */ * дальше, а урон нет (do_startpos дольёт до потолка). */
hitp_beg_lev = hitp_max; hitp_beg_lev = hitp_max;
pop_checkpoint = 0; /* уровень пройден (seg003:413) */ pop_checkpoint = 0; /* уровень пройден (seg003:413) */
pop_sfx_pause(); /* загрузка уровня — ESTEX надолго, звук глушим */ /* ЗАМЕР 2026-08-28: глушение снято ВРЕМЕННО, чтобы измерить
* зазоры насоса на загрузке уровня (дедлайн 251 000 тактов).
* Если зазоры укладываются снятие остаётся, и трек заставки
* доигрывает поверх загрузки, как в оригинале (seg001:690). */
if (pop_level_load_num(pop_next_level) != 0) { if (pop_level_load_num(pop_next_level) != 0) {
pop_next_level = pop_current_level; /* файла нет — остаёмся */ pop_next_level = pop_current_level; /* файла нет — остаёмся */
} else { } else {
@@ -1295,6 +1307,34 @@ int pop_level_switch(void) __banked
/* Атомарный commit на границе растра: впервые показать /* Атомарный commit на границе растра: впервые показать
* готовую комнату уже с окончательной палитрой уровня. * готовую комнату уже с окончательной палитрой уровня.
* Вторую страницу синхронизировать аппаратной копией. */ * Вторую страницу синхронизировать аппаратной копией. */
/* ЖДЁМ КОНЦА ТРЕКА ЗАСТАВКИ НА ЧЁРНОМ ЭКРАНЕ — порт
* seg003:107:
* while (check_sound_playing() && !do_paused()) idle();
* stop_sounds();
* draw_level_first();
* Порядок оригинала важен: загрузка уровня идёт ПОД
* звучащий трек, и только потом чернота до его конца. Тогда
* общее время черноты = max(трек, загрузка), а не их сумма,
* и трек не обрывается на полуслове. Раньше у нас было
* наоборот: pre_cut_finish досиживал трек, потом глушил
* насос и грузил уровень.
*
* ЗАМЕР 2026-08-28: 500 подряд вызовов насоса через всю
* загрузку максимальный зазор 245 832 такта при дедлайне
* 251 000, ни одной пропущенной порции. Поэтому глушение на
* загрузке снято и музыка её переживает.
*
* Счётчик страховка от вечного ожидания (потоковый трек,
* который никогда не кончится): ~20 с и идём дальше. */
{
uint16_t mus_guard = 1000;
while (pop_music_busy() && mus_guard) {
mus_guard--;
pop_wait_edge();
}
pop_music_free(); /* порт stop_sounds() */
}
pop_wait_edge(); pop_wait_edge();
gfx_set_visible_page(level_prepared_page); gfx_set_visible_page(level_prepared_page);
gfx_set_draw_page(old_page); gfx_set_draw_page(old_page);
-1
View File
@@ -110,7 +110,6 @@ int8_t pop_level_change(uint8_t back) __banked;
* из keys.txt. */ * из keys.txt. */
#define KBD_QUIT 0x15 /* Q — вместе с Ctrl */ #define KBD_QUIT 0x15 /* Q — вместе с Ctrl */
#define KBD_RESTART 0x1C /* A — Ctrl+A, рестарт уровня */ #define KBD_RESTART 0x1C /* A — Ctrl+A, рестарт уровня */
#define KBD_VERSION 0x2A /* V — Ctrl+V, показать версию */
#define KBD_INTRO 0x2D /* R — Ctrl+R, вернуться в заставку */ #define KBD_INTRO 0x2D /* R — Ctrl+R, вернуться в заставку */
#define KBD_SHOWTIME 0x29 /* Space — сколько осталось (seg000:612) */ #define KBD_SHOWTIME 0x29 /* Space — сколько осталось (seg000:612) */
#define KBD_TIMER 0x2C /* T — постоянный показ таймера. БЕЗ модификаторов: #define KBD_TIMER 0x2C /* T — постоянный показ таймера. БЕЗ модификаторов:
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""fetch_orig.py — забирает ВНЕШНИЕ исходные данные в assets/orig/.
Репозиторий SprPoP не хранит ни байта чужих данных: `assets/orig/` целиком
в .gitignore. Хранится вместо них ЗНАНИЕ, откуда эти данные берутся,
таблица SOURCES ниже, и этот скрипт, который по ней всё выкачивает.
python3 tools/fetch_orig.py # SDLPoP + музыка (чего нет)
python3 tools/fetch_orig.py --check # что уже на месте, ничего не качать
python3 tools/fetch_orig.py --list # печать таблицы источников
python3 tools/fetch_orig.py sdlpop --force --ref v1.23
Что откуда:
SDLPoP (GPLv3, github.com/NagyD/SDLPoP) распакованные ресурсы DOS-версии
(`data/`: спрайты, палитры, LEVELS/res20xx.bin, DIGISND*.DAT) и исходники
(`src/`), которые нам одновременно ЭТАЛОН МЕХАНИКИ. Тянем архивом ветки
или тега, а не git-клоном: в assets/orig/ не нужен вложенный .git, а
зафиксировать версию всё равно надо пишем её в SDLPoP/.fetch.json.
PoP1_DOS_music (popot.org, 43 МБ) записи саундтрека DOS-версии в четырёх
наборах (flac/mp3/ogg/ogg_MT-32), из них `pop_pack_music.py` делает треки
для CBL. Ссылку берём ИЗ САМОГО SDLPoP (ReadMe.amigaos4, секция «AUDIO IS
SLOW/AWFULL»), чтобы она не протухла молча вместе с нашей копией; если
секцию не нашли падаем на константу MUSIC_URL_FALLBACK.
MSDOS/ оригинальная поставка PoP 1.0 для MS-DOS. НЕ качается: это
копирайтный дистрибутив, качать его неоткуда и незачем. Он НЕОБЯЗАТЕЛЕН
всё, что нужно сборке (уровни, звуки), лежит в SDLPoP. Если папка у вас
есть, её звуковые .dat можно включить явно: `make SND_SRC=msdos ...`
(см. assets/orig/README.md).
"""
import argparse
import io
import json
import os
import re
import shutil
import sys
import tarfile
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py)
# --- источники -------------------------------------------------------------
SDLPOP_REPO = "https://github.com/NagyD/SDLPoP"
SDLPOP_REF = "master" # ветка по умолчанию = «последняя версия»
SDLPOP_API = "https://api.github.com/repos/NagyD/SDLPoP"
# Секция «AUDIO IS SLOW/AWFULL» в этом файле SDLPoP держит ссылку на записи.
MUSIC_README = "ReadMe.amigaos4"
MUSIC_URL_FALLBACK = \
"https://www.popot.org/get_the_games/various/PoP1_DOS_music.zip"
UA = {"User-Agent": "SprPoP-fetch-orig/1"}
CACHE = P.ORIG / ".cache" # скачанные архивы; переживает make clean
SOURCES = [
("sdlpop", P.SDLPOP, SDLPOP_REPO,
"ресурсы DOS-версии (data/) + исходники-эталон (src/)"),
("music", P.MUSIC_ROOT, MUSIC_URL_FALLBACK,
"записи саундтрека DOS-версии, 4 набора (flac/mp3/ogg/ogg_MT-32)"),
("MSDOS (необязательно, вручную)", P.MSDOS, "дистрибутив игры",
"оригинальные .dat; нужен только для SND_SRC=msdos"),
]
# --- мелочи ----------------------------------------------------------------
def say(msg):
print("fetch: " + msg, flush=True)
def fetch_url(url, dest, expect_type=None):
"""Скачать url в dest (через .part), с индикатором на одной строке.
Если dest уже есть и его размер совпал с Content-Length не качаем.
"""
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
total = int(r.headers.get("Content-Length") or 0)
ctype = r.headers.get("Content-Type", "")
if expect_type and expect_type not in ctype:
say(f"ПРЕДУПРЕЖДЕНИЕ: {url} отдал Content-Type {ctype!r}")
if dest.exists() and total and dest.stat().st_size == total:
say(f"уже скачано: {dest.name} ({total} Б)")
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
# Проценты — только в терминал: в перенаправленном выводе (лог сборки,
# CI) \r не схлопывается, и одна закачка даёт полторы сотни строк.
tty = sys.stdout.isatty()
got, t0 = 0, time.time()
with open(tmp, "wb") as f:
while True:
chunk = r.read(256 * 1024)
if not chunk:
break
f.write(chunk)
got += len(chunk)
if total and tty:
print("\r %s: %3d%% (%d/%d КБ)"
% (dest.name, got * 100 // total,
got >> 10, total >> 10), end="", flush=True)
print("%s %s: готово, %d КБ за %.1f с%s"
% ("\r" if tty else "", dest.name, got >> 10,
time.time() - t0, " " * 20 if tty else ""))
tmp.replace(dest)
return dest
def safe_members(names, root):
"""Проверка архива на выход за пределы каталога распаковки."""
for n in names:
p = Path(n)
if p.is_absolute() or ".." in p.parts:
sys.exit(f"архив содержит небезопасный путь {n!r} — распаковка отменена")
return root
def backup_dir(dest):
"""Сложить текущий каталог в .cache архивом — перед заменой."""
CACHE.mkdir(parents=True, exist_ok=True)
tar = CACHE / ("%s-backup-%s.tar.gz" % (dest.name,
time.strftime("%Y%m%d-%H%M%S")))
with tarfile.open(tar, "w:gz") as t:
t.add(dest, arcname=dest.name)
say(f"старая копия сохранена: {tar} ({tar.stat().st_size >> 10} КБ)")
return tar
def swap_dir(new, dest):
"""Поставить свежераспакованный каталог на место старого."""
dest.parent.mkdir(parents=True, exist_ok=True)
old = dest.with_name(dest.name + ".old")
if old.exists():
shutil.rmtree(old)
if dest.exists():
dest.rename(old)
new.rename(dest)
if old.exists():
shutil.rmtree(old)
# --- SDLPoP ----------------------------------------------------------------
def sdlpop_head(ref):
"""(sha, дата) верхушки ref на GitHub; (None, None) если API недоступен."""
try:
req = urllib.request.Request(f"{SDLPOP_API}/commits/{ref}", headers=UA)
with urllib.request.urlopen(req, timeout=30) as r:
d = json.load(r)
return d["sha"], d["commit"]["committer"]["date"]
except (urllib.error.URLError, KeyError, ValueError) as e:
# Лимит запросов GitHub или отсутствие сети: не повод падать —
# просто не сможем сказать «у нас уже последняя».
say(f"GitHub API недоступен ({e}); версию не проверяю")
return None, None
def stamp_path(dest):
return dest / ".fetch.json"
def manifest(root):
"""{относительный путь: sha256} по всему дереву, кроме служебного."""
import hashlib
out = {}
for f in sorted(root.rglob("*")):
if not f.is_file() or f.name == ".fetch.json":
continue
rel = str(f.relative_to(root))
h = hashlib.sha256()
with open(f, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
h.update(chunk)
out[rel] = h.hexdigest()
return out
def local_edits(dest):
"""Файлы, изменённые ПОСЛЕ распаковки: (изменённые, добавленные).
Считается по манифесту из .fetch.json. Нет манифеста вернуть None:
происхождение копии неизвестно, и трогать её нельзя.
"""
base = read_stamp(dest).get("files")
if not base:
return None
now = manifest(dest)
changed = sorted(k for k, v in now.items() if k in base and base[k] != v)
added = sorted(k for k in now if k not in base)
return changed, added
def read_stamp(dest):
try:
return json.loads(stamp_path(dest).read_text())
except (OSError, ValueError):
return {}
def fetch_sdlpop(ref=SDLPOP_REF, force=False):
dest = P.SDLPOP
sha, date = sdlpop_head(ref)
have = read_stamp(dest)
if dest.exists() and (dest / "data").is_dir():
if sha and have.get("sha") == sha and not force:
say(f"SDLPoP уже последний: {ref} {sha[:8]} от {date[:10]}")
return
# ЗАМЕНА КАТАЛОГА — ТОЛЬКО ОСОЗНАННО. Наша копия SDLPoP не просто
# справочник: в неё внесены отладочные врезки (POP_TRACE — покадровая
# трасса Кида для сверки с портом, дампы спрайтов и палитры), и
# перекачка их сотрёт. Поэтому существующее дерево не трогаем без
# --force, а что именно потеряется — говорим заранее.
edits = local_edits(dest)
if edits is None:
note = ("происхождение копии неизвестно (нет манифеста .fetch.json) "
"— возможно, в ней есть ваши правки")
else:
changed, added = edits
if changed or added:
note = "локально изменено: %s%s" % (
", ".join(changed[:8]) + ("" if len(changed) > 8 else ""),
"; добавлено %d файлов" % len(added) if added else "")
else:
note = ""
if not force:
say("SDLPoP уже на месте" + (f"; {note}" if note else ""))
say(" перекачать поверх: make FETCH_ARGS=--force fetch-sdlpop")
say(" (правки будут сохранены в бэкап, см. ниже)")
return
if note:
say("ВНИМАНИЕ: " + note)
backup_dir(dest)
# Тег и ветка лежат по разным путям codeload; пробуем оба.
urls = [f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/heads/{ref}",
f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/tags/{ref}"]
CACHE.mkdir(parents=True, exist_ok=True)
tar = CACHE / f"SDLPoP-{ref.replace('/', '_')}.tar.gz"
last = None
for u in urls:
try:
say(f"качаю SDLPoP {ref} <- {u}")
fetch_url(u, tar)
last = None
break
except urllib.error.HTTPError as e:
last = e
if last is not None:
sys.exit(f"не удалось скачать SDLPoP {ref!r}: {last}")
tmp = P.ORIG / ".SDLPoP.new"
if tmp.exists():
shutil.rmtree(tmp)
tmp.mkdir(parents=True)
with tarfile.open(tar, "r:gz") as t:
names = t.getnames()
safe_members(names, tmp)
# В архиве GitHub всё лежит в одном каталоге SDLPoP-<ref>/ — снимаем его.
top = os.path.commonpath([n for n in names if n]) if names else ""
t.extractall(tmp)
src = tmp / top if top else tmp
inner = P.ORIG / ".SDLPoP.unpacked"
if inner.exists():
shutil.rmtree(inner)
src.rename(inner)
shutil.rmtree(tmp, ignore_errors=True)
if not (inner / "data").is_dir() or not (inner / "src").is_dir():
shutil.rmtree(inner)
sys.exit("в архиве SDLPoP нет data/ или src/ — раскладка репозитория "
"изменилась, чините fetch_orig.py")
# Манифест распакованного дерева: по нему следующий fetch отличит наши
# правки от нетронутых файлов и не сотрёт их молча.
stamp_path(inner).write_text(json.dumps(
{"repo": SDLPOP_REPO, "ref": ref, "sha": sha, "date": date,
"fetched": time.strftime("%Y-%m-%d %H:%M:%S"),
"files": manifest(inner)},
ensure_ascii=False, indent=2) + "\n")
swap_dir(inner, dest)
say(f"SDLPoP: {ref} {(sha or '?')[:8]} -> {dest}")
# --- музыка ----------------------------------------------------------------
def music_url():
"""Ссылка на архив записей: из ReadMe SDLPoP, иначе константа."""
rm = P.SDLPOP / MUSIC_README
if rm.is_file():
text = rm.read_text(errors="replace")
m = re.search(r"https?://\S*[Mm]usic\S*\.zip", text)
if m:
url = m.group(0).rstrip(".,)")
say(f"ссылка на музыку из SDLPoP/{MUSIC_README}: {url}")
return url
say(f"в SDLPoP/{MUSIC_README} ссылки не нашлось — беру запасную")
else:
say(f"нет SDLPoP/{MUSIC_README} (сначала `fetch_orig.py sdlpop`) — "
"беру запасную ссылку")
return MUSIC_URL_FALLBACK
def fetch_music(force=False):
dest = P.MUSIC_ROOT
if dest.is_dir() and not force:
have = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()]
if len(have) == len(P.MUSIC_FORMATS):
say(f"музыка уже на месте: {dest} ({len(have)} набора)")
return
say(f"музыка неполная (наборы: {', '.join(have) or 'нет'}) — перекачиваю")
url = music_url()
CACHE.mkdir(parents=True, exist_ok=True)
zpath = CACHE / os.path.basename(url)
fetch_url(url, zpath, expect_type="zip")
with zipfile.ZipFile(zpath) as z:
names = z.namelist()
safe_members(names, P.ORIG)
top = {Path(n).parts[0] for n in names if n}
if top != {dest.name}:
sys.exit(f"в архиве музыки ожидался один каталог {dest.name}/, "
f"а там {sorted(top)} — чините fetch_orig.py")
tmp = P.ORIG / ".music.new"
if tmp.exists():
shutil.rmtree(tmp)
say(f"распаковываю {len(names)} файлов")
z.extractall(tmp)
swap_dir(tmp / dest.name, dest)
shutil.rmtree(tmp, ignore_errors=True)
got = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()]
say(f"музыка: наборы {', '.join(sorted(got))} -> {dest}")
# --- отчёт -----------------------------------------------------------------
def do_list():
print("Внешние источники SprPoP (assets/orig/ в репозиторий не идёт):\n")
for name, path, where, what in SOURCES:
print(f" {name}")
print(f" куда: {path}")
print(f" откуда: {where}")
print(f" что: {what}\n")
def do_check():
ok = True
st = read_stamp(P.SDLPOP)
if (P.SDLPOP / "data").is_dir() and (P.SDLPOP / "src").is_dir():
v = f"{st.get('ref', '?')} {(st.get('sha') or '?')[:8]}"
print(f" SDLPoP есть ({v}, {st.get('fetched', 'версия неизвестна')})")
else:
print(" SDLPoP НЕТ -> make fetch-sdlpop")
ok = False
have = [d for d, _ in P.MUSIC_FORMATS.values() if (P.MUSIC_ROOT / d).is_dir()]
if len(have) == len(P.MUSIC_FORMATS):
print(f" музыка есть (наборы: {', '.join(sorted(have))})")
elif have:
print(f" музыка ЧАСТЬ (только {', '.join(sorted(have))}) -> make fetch-music")
ok = False
else:
print(" музыка НЕТ -> make fetch-music")
ok = False
if P.MSDOS.is_dir():
print(" MSDOS есть (необязательно; звуки оттуда — make SND_SRC=msdos)")
else:
print(" MSDOS нет (необязательно: звуки и уровни берутся из SDLPoP)")
return 0 if ok else 1
def main():
ap = argparse.ArgumentParser(
description="скачать внешние исходные данные SprPoP в assets/orig/")
ap.add_argument("what", nargs="*", default=["all"],
help="что качать: all (умолчание), sdlpop, music")
ap.add_argument("--force", action="store_true",
help="качать заново, даже если данные на месте")
ap.add_argument("--ref", default=SDLPOP_REF,
help=f"ветка или тег SDLPoP (по умолчанию {SDLPOP_REF})")
ap.add_argument("--check", action="store_true",
help="только показать, что уже на месте")
ap.add_argument("--list", action="store_true",
help="только показать таблицу источников")
a = ap.parse_args()
if a.list:
do_list()
return 0
if a.check:
return do_check()
what = set(a.what) or {"all"}
bad = what - {"all", "sdlpop", "music"}
if bad:
ap.error("не знаю, что такое %s; есть: all, sdlpop, music"
% ", ".join(sorted(bad)))
if "all" in what:
what = {"sdlpop", "music"}
P.ORIG.mkdir(parents=True, exist_ok=True)
if "sdlpop" in what:
fetch_sdlpop(a.ref, a.force)
if "music" in what:
fetch_music(a.force)
say("готово; дальше — make resources")
return 0
if __name__ == "__main__":
sys.exit(main())
+43 -3
View File
@@ -5,9 +5,10 @@ assets/, gen/ и build/.
Раскладка: Раскладка:
assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, MSDOS, записи музыки). assets/orig/ ОРИГИНАЛЬНЫЕ данные (SDLPoP, записи музыки и,
В репозиторий не идут копирайт; откуда их взять, необязательно, MSDOS). В репозиторий не идут
написано в assets/orig/README.md. копирайт; выкачивает их tools/fetch_orig.py
(`make fetch`), см. assets/orig/README.md.
build/atl/<set>/ промежуточный выхлоп упаковщиков: отдельные атласы, build/atl/<set>/ промежуточный выхлоп упаковщиков: отдельные атласы,
из которых потом склеиваются архивы. Пересоздаётся, из которых потом склеиваются архивы. Пересоздаётся,
в репозиторий не идёт. в репозиторий не идёт.
@@ -31,6 +32,7 @@ SDLPOP = ORIG / "SDLPoP"
SDLPOP_DATA = SDLPOP / "data" SDLPOP_DATA = SDLPOP / "data"
SDLPOP_SRC = SDLPOP / "src" SDLPOP_SRC = SDLPOP / "src"
MSDOS = ORIG / "MSDOS" MSDOS = ORIG / "MSDOS"
MUSIC_ROOT = ORIG / "PoP1_DOS_music" MUSIC_ROOT = ORIG / "PoP1_DOS_music"
# Записи DOS-версии лежат в четырёх вариантах. Имена треков во всех наборах # Записи DOS-версии лежат в четырёх вариантах. Имена треков во всех наборах
# одинаковые, различаются каталог и расширение; ogg_MT-32 — та же музыка в # одинаковые, различаются каталог и расширение; ogg_MT-32 — та же музыка в
@@ -52,6 +54,44 @@ def music_src(fmt="flac"):
sub, ext = MUSIC_FORMATS[fmt] sub, ext = MUSIC_FORMATS[fmt]
return MUSIC_ROOT / sub, ext return MUSIC_ROOT / sub, ext
# --- источники звуковых эффектов ------------------------------------------
# Контейнеры digisnd1..3 есть в ДВУХ поставках, и они не одинаковы: в SDLPoP
# лежит версия из репозитория (её и качает fetch_orig.py), в MSDOS/ — из
# оригинального дистрибутива 1.0, если пользователь его себе положил.
# ПО УМОЛЧАНИЮ БЕРЁМ SDLPoP: сборка обязана работать без MSDOS вообще.
# Ключ = значение SND_SRC в Makefile; регистр имён в поставках разный.
SND_SOURCES = {
"sdlpop": (SDLPOP_DATA, "DIGISND%d.DAT"),
"msdos": (MSDOS, "digisnd%d.dat"),
}
def snd_src(name="sdlpop"):
"""(каталог, шаблон имени) контейнеров оцифровки для источника."""
if name not in SND_SOURCES:
raise SystemExit(
"неизвестный источник звука %r; есть: %s"
% (name, ", ".join(sorted(SND_SOURCES))))
return SND_SOURCES[name]
def find_ci(directory, name):
"""Путь к файлу без оглядки на регистр (FAT-имена приезжают по-разному).
Возвращает None, если файла нет. Точное совпадение проверяется первым,
чтобы на case-insensitive ФС не читать каталог зря.
"""
p = directory / name
if p.exists():
return p
if not directory.is_dir():
return None
low = name.lower()
for e in directory.iterdir():
if e.name.lower() == low:
return e
return None
# --- выход ---------------------------------------------------------------- # --- выход ----------------------------------------------------------------
ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
PACKED = APP / "assets" / "packed" # раскладка диска игры PACKED = APP / "assets" / "packed" # раскладка диска игры
+230
View File
@@ -0,0 +1,230 @@
"""pop_idx.py — ДИСКОВЫЕ ИНДЕКСЫ аудио-наборов SprPoP.
Зачем. Раскладка звукового набора (какой эффект в какой странице, по
какому смещению и какой длины) сегодня печатается упаковщиком в
gen/pop_sound_tbl.h и становится частью EXE. Из-за этого замена набора
(SDLPoP <-> MSDOS) требует перекомпиляции игры. Индекс переносит эту
раскладку НА ДИСК: пара `SND/snd.idx` + `SND/snd.arc` меняется целиком,
а бинарник остаётся прежним.
Формат читают ДВЕ стороны этот упаковщик и загрузчик на Z80, поэтому
писатель и разборщик живут в одном месте и покрыты тестом
(tools/tests/test_pop_idx.py). Числа little-endian: у Z80 это родной
порядок, и разбор на нём просто чтение байтов подряд.
PSI1 индекс ЭФФЕКТОВ, ровно один сектор DSS (512 Б):
0..3 'PSI1' магия и версия
4 57 число плотных записей, id 0..56
5 5 размер записи
6 7 log2 размера блока: 1 << 7 = 128
7 1 формат PCM: 8 бит без знака, моно, CBL_FREQ_10K9
8 pages сколько PCM-страниц в снд-архиве (1..16)
9..15 0 резерв
16..300 57 записей по 5 байт: page (u8), off (u16), len (u16)
301..511 0 добивка до сектора
Размер записи 5 это НЕ произвольное число, а sizeof(pop_snd_ent_t) в
ABI SDCC z80 (uint8 + uint16 + uint16 без выравнивания). Загрузчик
читает записи ПРЯМО в резидентную таблицу, поэтому расхождение здесь
молча разъедет всю раскладку; на стороне C это проверяется статически.
Нулевая `len` означает, что у этого id нет оцифровки (это музыка).
PMI1 индекс МУЗЫКИ, тоже один сектор:
0..3 'PMI1' магия и версия
4 57 число плотных записей, id 0..56
5 4 размер записи
6 7 log2 размера блока
7 1 формат PCM
8..15 0 резерв
16..243 57 записей по 4 байта: blocks (u16), ticks60 (u16)
244..511 0 добивка до сектора
Числа страниц здесь НЕТ намеренно: это производная от `blocks`, и
дублирование однажды разъедется с ним. Загрузчик считает сам:
pages = blocks / 128 + ((blocks & 127) != 0)
`ticks60` длительность в тиках сцены (60 Гц оригинала), нужная там, где
сценарий ждёт конца реплики. Считается из blocks отношением 40/57
обратным к тому, которым шкала сцены переводит тики в порции насоса.
Хранится ОТДЕЛЬНО, а не выводится в рантайме: так момент окончания
реплики можно будет уточнить независимо от технического хвоста PCM.
Нулевой `blocks` означает, что трека с таким id в наборе нет.
"""
import struct
SECTOR = 512
# --- PSI1: эффекты ---------------------------------------------------------
SND_MAGIC = b"PSI1"
SND_COUNT = 57 # пространство id оригинала: 0..56
SND_REC = 5 # sizeof(pop_snd_ent_t) в ABI SDCC z80
SND_HDR = 16
SND_MAX_PAGES = 16 # compile-time вместимость загрузчика
BLOCK = 128 # блок запроса CBL при 8 бит
BLOCK_LOG2 = 7
PCM_U8_MONO_10K9 = 1 # единственный формат, который умеет наш насос
PAGE = 16384
class IdxError(Exception):
"""Индекс не разобрался: битый, чужой версии или противоречивый."""
def snd_pack(entries, pages):
"""Собрать PSI1 из 57 записей (page, off, len) и числа страниц."""
if len(entries) != SND_COUNT:
raise IdxError("записей должно быть %d, а не %d"
% (SND_COUNT, len(entries)))
if not 1 <= pages <= SND_MAX_PAGES:
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
out = bytearray(SECTOR)
out[0:4] = SND_MAGIC
out[4] = SND_COUNT
out[5] = SND_REC
out[6] = BLOCK_LOG2
out[7] = PCM_U8_MONO_10K9
out[8] = pages
for i, (page, off, ln) in enumerate(entries):
_check_entry(i, page, off, ln, pages)
struct.pack_into("<BHH", out, SND_HDR + i * SND_REC, page, off, ln)
return bytes(out)
def snd_unpack(data):
"""Разобрать PSI1 -> (записи, pages). Проверки те же, что у Z80."""
if len(data) != SECTOR:
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
if data[0:4] != SND_MAGIC:
raise IdxError("не PSI1: %r" % (bytes(data[0:4]),))
if data[4] != SND_COUNT:
raise IdxError("записей %d, ожидалось %d" % (data[4], SND_COUNT))
if data[5] != SND_REC:
raise IdxError("размер записи %d, ожидался %d" % (data[5], SND_REC))
if data[6] != BLOCK_LOG2:
raise IdxError("log2 блока %d, ожидался %d" % (data[6], BLOCK_LOG2))
if data[7] != PCM_U8_MONO_10K9:
raise IdxError("формат PCM %d — насос умеет только %d"
% (data[7], PCM_U8_MONO_10K9))
pages = data[8]
if not 1 <= pages <= SND_MAX_PAGES:
raise IdxError("страниц %d — вне 1..%d" % (pages, SND_MAX_PAGES))
entries = []
for i in range(SND_COUNT):
page, off, ln = struct.unpack_from("<BHH", data, SND_HDR + i * SND_REC)
_check_entry(i, page, off, ln, pages)
entries.append((page, off, ln))
return entries, pages
def _check_entry(i, page, off, ln, pages):
"""Одна запись: то же, что обязан проверить загрузчик перед EMM.
ПЕРВЫЙ БЛОК СТРАНИЦЫ 0 ТИШИНА. Насос льёт её, когда играть нечего
(POP_SND_SILENCE_PAGE/OFF), поэтому набор не имеет права положить туда
эффект: иначе вместо паузы будет обрывок звука по кругу.
"""
if ln == 0:
if (page, off) != (0, 0):
raise IdxError("запись %d пуста, но page/off не нулевые" % i)
return
if page >= pages:
raise IdxError("запись %d: page %d >= pages %d" % (i, page, pages))
if off >= PAGE:
raise IdxError("запись %d: off %d вне страницы" % (i, off))
if off % BLOCK or ln % BLOCK:
raise IdxError("запись %d: off/len не кратны блоку %d" % (i, BLOCK))
if page == 0 and off < BLOCK:
raise IdxError("запись %d залезает в блок тишины (стр.0, off 0)" % i)
# Длинный эффект переливается на следующие страницы — они обязаны быть.
last = (page * PAGE + off + ln - 1) // PAGE
if last >= pages:
raise IdxError("запись %d выходит за набор: кончается в стр.%d из %d"
% (i, last, pages))
# --- PMI1: музыка ----------------------------------------------------------
MUS_MAGIC = b"PMI1"
MUS_COUNT = 57 # то же пространство id оригинала
MUS_REC = 4
MUS_HDR = 16
# Потоковый трек (финальная тема) заведомо длиннее обычных, и предел ему
# ставят не страницы загрузчика, а восьмибитные счётчики страниц в коде.
MUS_MAX_PAGES = 255
def mus_ticks(blocks):
"""Длительность в тиках сцены (60 Гц) по длине в блоках.
Отношение 40/57 обратное к тому, которым шкала сцены переводит тики
в порции насоса (57/40, pop_intro.c). Берём именно его, а не точное
10937,5/128/60: длина реплики и ход шкалы обязаны считаться ОДНОЙ
арифметикой, иначе они разъедутся на длинных треках. Округление к
ближайшему: отбрасывание вниз теряло по тику на реплику.
"""
return (blocks * 40 + 28) // 57
def mus_pack(entries):
"""Собрать PMI1 из 57 записей (blocks, ticks60)."""
if len(entries) != MUS_COUNT:
raise IdxError("записей должно быть %d, а не %d"
% (MUS_COUNT, len(entries)))
out = bytearray(SECTOR)
out[0:4] = MUS_MAGIC
out[4] = MUS_COUNT
out[5] = MUS_REC
out[6] = BLOCK_LOG2
out[7] = PCM_U8_MONO_10K9
for i, (blocks, ticks) in enumerate(entries):
_check_mus(i, blocks, ticks)
struct.pack_into("<HH", out, MUS_HDR + i * MUS_REC, blocks, ticks)
return bytes(out)
def mus_unpack(data):
"""Разобрать PMI1 -> записи. Проверки те же, что у Z80."""
if len(data) != SECTOR:
raise IdxError("размер %d, а должен быть сектор %d" % (len(data), SECTOR))
if data[0:4] != MUS_MAGIC:
raise IdxError("не PMI1: %r" % (bytes(data[0:4]),))
if data[4] != MUS_COUNT:
raise IdxError("записей %d, ожидалось %d" % (data[4], MUS_COUNT))
if data[5] != MUS_REC:
raise IdxError("размер записи %d, ожидался %d" % (data[5], MUS_REC))
if data[6] != BLOCK_LOG2:
raise IdxError("log2 блока %d, ожидался %d" % (data[6], BLOCK_LOG2))
if data[7] != PCM_U8_MONO_10K9:
raise IdxError("формат PCM %d — насос умеет только %d"
% (data[7], PCM_U8_MONO_10K9))
entries = []
for i in range(MUS_COUNT):
blocks, ticks = struct.unpack_from("<HH", data, MUS_HDR + i * MUS_REC)
_check_mus(i, blocks, ticks)
entries.append((blocks, ticks))
return entries
def mus_pages(blocks):
"""Сколько EMM-страниц занимает трек такой длины."""
return (blocks + 127) // 128
def _check_mus(i, blocks, ticks):
if blocks == 0:
if ticks:
raise IdxError("запись %d: трека нет, но тики ненулевые" % i)
return
if mus_pages(blocks) > MUS_MAX_PAGES:
raise IdxError("запись %d: %d блоков — больше %d страниц, счётчики "
"страниц в коде восьмибитные"
% (i, blocks, MUS_MAX_PAGES))
if ticks == 0:
raise IdxError("запись %d: трек есть, а длительность нулевая" % i)
+3 -19
View File
@@ -450,7 +450,7 @@ def write_header(out_path, env_pages):
файлов и палитра. Поэтому здесь два массива имён и один индекс.""" файлов и палитра. Поэтому здесь два массива имён и один индекс."""
lines = [ lines = [
"/* pop_bg_atlas.h — раскладка атласов статического фона PoP.", "/* pop_bg_atlas.h — раскладка атласов статического фона PoP.",
" * Сгенерировано toolchain/pop_pack_bg.py — НЕ править вручную.", " * Сгенерировано tools/pop_pack_bg.py — НЕ править вручную.",
" *", " *",
" * Прямая адресация (ноль remap-таблиц в W2):", " * Прямая адресация (ноль remap-таблиц в W2):",
" * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK), " * ENV фон id N -> atlas env_bg[N>>%d], idx N&%d" % (ENV_SHIFT, ENV_MASK),
@@ -474,27 +474,12 @@ def write_header(out_path, env_pages):
"#define POP_PAL_ENV 0x50", "#define POP_PAL_ENV 0x50",
"#define POP_PAL_WALL 0x60", "#define POP_PAL_WALL 0x60",
"", "",
"/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы", "/* Палитры пока остаются отдельными файлами рядом с bg.arc. Имена",
" * видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия", " * видит только модуль загрузки, чтобы не размножать строки. */",
" * строк уедет в каждый включивший заголовок модуль. */",
"#ifdef POP_BG_ATLAS_NAMES", "#ifdef POP_BG_ATLAS_NAMES",
"static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {",
]
for prefix, _ in SETS.values():
row = ", ".join(('"%s%s"' % (prefix, nm.split("_", 1)[1]) if nm else "0")
for nm in env_pages)
lines.append(" { %s }," % row)
lines += [
"};",
'static const char *const pop_wall_atl[POP_TILESETS] = { %s };'
% ", ".join('"%swall.atl"' % p for p, _ in SETS.values()),
'static const char *const pop_fore_atl[POP_TILESETS] = { %s };'
% ", ".join('"%sfore.atl"' % p for p, _ in SETS.values()),
'static const char *const pop_tile_pal[POP_TILESETS] = { %s };' 'static const char *const pop_tile_pal[POP_TILESETS] = { %s };'
% ", ".join('"%stile.pal"' % p for p, _ in SETS.values()), % ", ".join('"%stile.pal"' % p for p, _ in SETS.values()),
"#endif /* POP_BG_ATLAS_NAMES */", "#endif /* POP_BG_ATLAS_NAMES */",
'#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */',
"#define POP_PAL_POT 0x%02X" % POT_PAL_BASE,
"/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),", "/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),",
" зелёный (перо/переворот) и синий (вред/открыть) = те же кадры", " зелёный (перо/переворот) и синий (вред/открыть) = те же кадры",
" под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */" " под id %d..%d и %d..%d (draw_tile_anim, seg008:652). */"
@@ -502,7 +487,6 @@ def write_header(out_path, env_pages):
POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1), POT_BUBBLE_BLUE_BASE, POT_BUBBLE_BLUE_BASE + len(POT_BUBBLE_IDS) - 1),
"#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE, "#define POP_POT_BUBB_GREEN %d" % POT_BUBBLE_GREEN_BASE,
"#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_BASE, "#define POP_POT_BUBB_BLUE %d" % POT_BUBBLE_BLUE_BASE,
'#define POP_BG_PAL "pop_bg.pal"',
"", "",
"#endif", "#endif",
"", "",
+2 -16
View File
@@ -11,7 +11,8 @@ Sprinter 0x70-0x7F (env 0x50 / wall 0x60 заняты фоном). Пиксел
фейковые записи (как в фоне). Переиспользует pack_atlas/blob/scale6to8 фейковые записи (как в фоне). Переиспользует pack_atlas/blob/scale6to8
из pop_pack_bg. из pop_pack_bg.
Выход: build/atl/kid/kid0..N.atl + kid.pal + kid_atlas.h Выход: build/atl/kid/kid0..N.atl + зеркальные атласы + kid.pal.
Количество страниц рантайм получает из сгенерированного описания kid_arc.h.
""" """
import os import os
import sys import sys
@@ -200,21 +201,6 @@ def main():
build_palette(os.path.join(OUT_DIR, "kid.pal")) build_palette(os.path.join(OUT_DIR, "kid.pal"))
# C-заголовок
hdr = str(P.GEN / "kid_atlas.h")
with open(hdr, "w") as f:
f.write("/* kid_atlas.h — раскладка атласов Kid. Сгенерировано "
"pop_pack_kid.py. */\n#ifndef KID_ATLAS_H\n#define KID_ATLAS_H\n")
f.write(f"#define KID_SHIFT {SHIFT}\n#define KID_MASK {MASK}\n")
f.write(f"#define KID_PAGES {len(names)}\n#define KID_PAL 0x{PAL_BASE:02X}\n")
f.write("static const char *const kid_atl[KID_PAGES] = {\n")
for nm in names:
f.write(" %s,\n" % ('"%s"' % nm if nm else "0"))
f.write('};\n#define KID_PAL_FILE "kid.pal"\n')
f.write('#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */\n')
f.write(f"#define KID_SWORD_ID0 {SWORD_IDS[0]} /* индекс в атласе = sword_tbl.id - ID0 */\n")
f.write("#endif\n")
print(f"ИТОГО: {len(ids)} спрайтов Kid, {len(names)} EMM-страниц, " print(f"ИТОГО: {len(ids)} спрайтов Kid, {len(names)} EMM-страниц, "
f"{total} Б диск (+1КБ палитра) -> {OUT_DIR}") f"{total} Б диск (+1КБ палитра) -> {OUT_DIR}")
+15 -94
View File
@@ -23,7 +23,7 @@ open 51,4 мс против 32,6 мс на чтение 16 КБ. Этот же
блоками по 128 байт, и остаток «не полного блока» ему пришлось бы блоками по 128 байт, и остаток «не полного блока» ему пришлось бы
досчитывать в прерывании. досчитывать в прерывании.
Длина трека уходит в pop_music_tbl.h блоками по 128 байт, а не байтами: Длина трека уходит в MUS/mus.idx блоками по 128 байт, а не байтами:
самый длинный трек (won, 115 с) это 1,2 МБ, что не влезает в uint16, а самый длинный трек (won, 115 с) это 1,2 МБ, что не влезает в uint16, а
в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory в блоках даёт 9855. 32-битной арифметики на Z80 избегаем (см. memory
avoid_32bit_arith_z80). avoid_32bit_arith_z80).
@@ -35,14 +35,14 @@ from pathlib import Path
import sys as _sys, os as _os import sys as _sys, os as _os
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py) import paths as P # раскладка каталогов SprPoP (tools/paths.py)
import pop_idx # формат дискового индекса PMI1 (tools/pop_idx.py)
# Формат источника выбирается ключом --format (по умолчанию flac — он же # Формат источника выбирается ключом --format (по умолчанию flac — он же
# лучший по качеству исходник). Каталог и расширение знает tools/paths.py. # лучший по качеству исходник). Каталог и расширение знает tools/paths.py.
FMT = "flac" FMT = "flac"
SRC_DIR, SRC_EXT = P.music_src(FMT) SRC_DIR, SRC_EXT = P.music_src(FMT)
OUT_DIR = P.atl("music") OUT_DIR = P.atl("music")
OUT_H = P.GEN / "pop_music_tbl.h" OUT_IDX = "mus.idx" # дисковый индекс набора, ложится в MUS/ рядом с треками
OUT_TICKS = P.GEN / "pop_music_ticks.h"
CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000 CBL_RATE = 10937.5 # CBL_FREQ_10K9 — частота железа, не 11 000
PAGE = 16384 # EMM-страница PAGE = 16384 # EMM-страница
@@ -125,98 +125,19 @@ def main() -> None:
print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, " print(f" {tid:2d} {TRACKS[tid]:<40} {chunks:2d} стр, "
f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с") f"{blocks * BLOCK / 1024:7.1f} КБ, {blocks * BLOCK / CBL_RATE:5.1f} с")
max_id = max(t for t, *_ in rows) # ДИСКОВЫЙ ИНДЕКС набора: длины треков и длительности реплик. Пока
lines = [ # печатаются и заголовки выше — переход на runtime идёт по шагам, и
"/*", # индекс обязан описывать ровно то же, что они (docs/music_runtime_index_plan.md).
" * pop_music_tbl.h — каталог музыкальных треков, СГЕНЕРИРОВАН.", # Тики считает pop_idx.mus_ticks — общая формула для обеих сторон.
f" * Источник: assets/orig/PoP1_DOS_music/{P.MUSIC_FORMATS[FMT][0]}" if len(rows) != len(TRACKS):
" (записи DOS-версии).", print(f" ВНИМАНИЕ: упаковано {len(rows)} треков из {len(TRACKS)}"
" * Генератор: tools/pop_pack_music.py. Руками не править.", f"индекс описывает только их")
" *", entries = [(0, 0)] * pop_idx.MUS_COUNT
" * Нумерация id — оригинала (SDLPoP data/music/names.txt). pages —", for tid, _name, _chunks, blocks in rows:
" * сколько кусков m<id>_<nn>.bin лежит на диске, blocks — длина трека", entries[tid] = (blocks, pop_idx.mus_ticks(blocks))
" * в порциях насоса по 128 байт (в байтах не влезло бы в uint16).", (OUT_DIR / OUT_IDX).write_bytes(pop_idx.mus_pack(entries))
" *", print(f"индекс: {OUT_DIR / OUT_IDX}")
" * Длительности реплик в тиках вынесены в pop_music_ticks.h: таблица",
" * ниже — 171 байт ДАННЫХ, и модулю, которому нужна одна константа,",
" * платить за неё копией таблицы незачем (поймано на pop_intro.c:",
" * +172 байта в банк 11).",
" *",
" * POP_MUS_TICKS_<id> — та же длина в ТИКАХ СЦЕНЫ (60 Гц оригинала).",
" * Нужна там, где сценарий ждёт окончания реплики: у оригинала это",
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза",
" * длиной в нашу запись. Считается ЗДЕСЬ, а не в C, по двум",
" * причинам: blocks*40 переполняет uint16 на won (9855 порций), и",
" * число обязано соответствовать НАБОРУ, которым собрали (flac и",
" * mt32 — разные исполнения разной длины).",
" *",
" * Отношение 40/57 — обратное к тому, которым шкала сцены переводит",
" * тики в порции насоса (57/40, см. pop_intro.c). Берём именно его,",
" * а не точное 10937,5/128/60, чтобы длина реплики и ход шкалы",
" * считались одной и той же арифметикой. Округление — к ближайшему",
" * (+28 перед делением): отбрасывание вниз теряло по тику на реплику.",
" */",
"#ifndef POP_MUSIC_TBL_H",
"#define POP_MUSIC_TBL_H",
"",
"#include <stdint.h>",
"",
f"#define POP_MUS_COUNT {max_id + 1}",
f"#define POP_MUS_BLOCK {BLOCK}",
f"#define POP_MUS_MAX_PAGES {max(c for _, _, c, _ in rows)}",
"",
]
lines += [
"",
"typedef struct {",
" uint8_t pages; /* 0 — трека нет */",
" uint16_t blocks; /* длина в порциях по 128 байт */",
"} pop_mus_ent_t;",
"",
"static const pop_mus_ent_t pop_mus_tbl[POP_MUS_COUNT] = {",
]
by_id = {t: (c, b, n) for t, n, c, b in rows}
for i in range(max_id + 1):
if i in by_id:
c, b, n = by_id[i]
lines.append(f" {{ {c:2d}, {b:5d} }}, /* {i:2d} {n} */")
else:
lines.append(f" {{ 0, 0 }}, /* {i:2d} */")
lines += ["};", "", "#endif /* POP_MUSIC_TBL_H */", ""]
OUT_H.write_text("\n".join(lines))
# Отдельный заголовок ТОЛЬКО с длительностями реплик. Данных в нём нет,
# поэтому его может включать кто угодно, ничего не утаскивая в свой банк.
tk = [
"/*",
" * pop_music_ticks.h — длительности треков в ТИКАХ СЦЕНЫ, СГЕНЕРИРОВАН.",
f" * Набор: {P.MUSIC_FORMATS[FMT][0]}. Генератор: tools/pop_pack_music.py.",
" * Руками не править.",
" *",
" * Нужны там, где сценарий ждёт окончания реплики: у оригинала это",
" * `do { frame(1) } while (check_sound_playing())`, у нас — пауза длиной",
" * в НАШУ запись. Число обязано соответствовать набору, которым собрана",
" * сборка: одна и та же реплика на flac и mt32 разной длины (m53 — 985",
" * тиков против 1044), и захардкоженная константа молча ломает сцену.",
" *",
" * Считается здесь, а не в C: blocks*40 переполняет uint16 на won",
" * (9855 порций). Отношение 40/57 — обратное к тому, которым шкала",
" * сцены переводит тики в порции насоса (57/40, pop_intro.c); берём",
" * именно его, чтобы длина реплики и ход шкалы считались одной",
" * арифметикой. Округление к ближайшему: вниз теряло по тику.",
" */",
"#ifndef POP_MUSIC_TICKS_H",
"#define POP_MUSIC_TICKS_H",
"",
]
for tid, name, _c, blocks in rows:
tk.append(f"#define POP_MUS_TICKS_{tid:<3d} {(blocks * 40 + 28) // 57:5d}"
f" /* {name} */")
tk += ["", "#endif /* POP_MUSIC_TICKS_H */", ""]
OUT_TICKS.write_text("\n".join(tk))
print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}") print(f"итого {total / 1024:.0f} КБ в {OUT_DIR}")
print(f"каталог: {OUT_H}")
print(f"тики: {OUT_TICKS}")
if __name__ == "__main__": if __name__ == "__main__":
+1 -13
View File
@@ -214,7 +214,7 @@ def write_header(path, counts, pal):
vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0 vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
lines = [ lines = [
"/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.", "/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
" * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.", " * СГЕНЕРЕНО tools/pop_pack_shadow.py — НЕ править вручную.",
" *", " *",
" * Две половины, потому что и у оригинала их две (см. шапку", " * Две половины, потому что и у оригинала их две (см. шапку",
" * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),", " * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
@@ -240,18 +240,6 @@ def write_header(path, counts, pal):
"/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */", "/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
"#define POP_SHADOW_FGT_PAGES %d" % counts["sf"], "#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
"", "",
"static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
]
lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
lines += [
"};",
"",
"static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
]
lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
lines += [
"};",
"",
"/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */", "/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
"static const uint8_t pop_shadow_pal[64] = {", "static const uint8_t pop_shadow_pal[64] = {",
" " + ",".join("0x%02X" % v for v in vals) + ",", " " + ",".join("0x%02X" % v for v in vals) + ",",
+67 -58
View File
@@ -2,7 +2,11 @@
""" """
pop_pack_sound.py упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет). pop_pack_sound.py упаковщик ЗВУКОВЫХ ЭФФЕКТОВ (оцифровка, chtab нет).
ИСТОЧНИК. `MSDOS/digisnd1..3.dat` оригинальные контейнеры. Разбор: ИСТОЧНИК. Контейнеры `digisnd1..3` по умолчанию из `SDLPoP/data/`
(`--source sdlpop`), потому что SDLPoP скачивается автоматически и без него
сборки всё равно нет. У кого лежит оригинальный дистрибутив DOS, может
взять оцифровку оттуда: `--source msdos` (= `make SND_SRC=msdos`); наборы
НЕ одинаковы версии данных разные. Разбор:
индекс по 8 байт (id, offset, size), **первый байт ресурса контрольная индекс по 8 байт (id, offset, size), **первый байт ресурса контрольная
сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`, сумма**, тело за ней (спецификация `docs/POP-DAT-FormatSpecifications`,
§3.1.2). Тело один из двух вариантов заголовка: §3.1.2). Тело один из двух вариантов заголовка:
@@ -29,8 +33,12 @@ CBL — конвертировать нечего.
знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель. знает слова «стык»: замапил страницу, отдал 128 байт, сдвинул указатель.
Плата до 127 байт на звук, всего ~2 КБ. Плата до 127 байт на звук, всего ~2 КБ.
Выход: build/atl/sound/s0..sN.bin (по 16 384 Б) + gen/pop_sound_tbl.h ВЫХОД: build/atl/sound/s0..sN.bin (страницы по 16 384 Б) + snd.idx
дисковая раскладка набора (формат PSI1, tools/pop_idx.py). C-заголовка
БОЛЬШЕ НЕТ: раскладка уехала на диск, чтобы замена набора не требовала
перекомпиляции игры.
""" """
import argparse
import os import os
import struct import struct
import sys import sys
@@ -38,11 +46,11 @@ import sys
import sys as _sys, os as _os import sys as _sys, os as _os
_sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__))) _sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
import paths as P # раскладка каталогов SprPoP (tools/paths.py) import paths as P # раскладка каталогов SprPoP (tools/paths.py)
import pop_idx # формат дискового индекса PSI1 (tools/pop_idx.py)
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
DAT_DIR = str(P.MSDOS)
OUT_DIR = str(P.atl("sound")) OUT_DIR = str(P.atl("sound"))
HDR = str(P.GEN / "pop_sound_tbl.h") IDX = "snd.idx" # дисковый индекс набора, рядом с snd.arc
PAGE = 16384 PAGE = 16384
BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h) BLOCK = 128 # блок запроса CBL при 8 бит (см. cbl.h)
@@ -101,12 +109,35 @@ def resample(src, src_rate, dst_rate):
def main(): def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES),
help="откуда брать digisnd1..3 (по умолчанию sdlpop)")
ap.add_argument("--order", default="id", choices=("id", "reverse"),
help="порядок укладки эффектов в страницах: id (штатный) "
"или reverse — ОТЛАДОЧНЫЙ, для проверки того, что "
"игра берёт раскладку из snd.idx, а не из своей "
"памяти: смещения расходятся у ВСЕХ эффектов")
args = ap.parse_args()
# Имена контейнеров в поставках отличаются регистром, и на Linux это не
# мелочь: ищем без оглядки на него (P.find_ci).
dat_dir, pattern = P.snd_src(args.source)
res = {} res = {}
for i in (1, 2, 3): for i in (1, 2, 3):
p = os.path.join(DAT_DIR, f"digisnd{i}.dat") p = P.find_ci(dat_dir, pattern % i)
if not os.path.exists(p): if p is None:
sys.exit(f"нет файла {p}") sys.exit(f"нет файла {dat_dir / (pattern % i)} "
f"(источник {args.source}); см. assets/orig/README.md")
res.update(dat_resources(p)) res.update(dat_resources(p))
print(f" источник оцифровки: {args.source} ({dat_dir})")
# У набора SDLPoP оцифровка беднее: `spiked` (48) там длиной 7 сэмплов,
# то есть звука пик фактически нет (разбор — docs/sound_plan.md). Если
# у пользователя лежит дистрибутив DOS, сказать об этом стоит вслух —
# молча отдавать тишину вместо эффекта нельзя.
if args.source == "sdlpop" and P.MSDOS.is_dir():
print(" ВНИМАНИЕ: у вас есть assets/orig/MSDOS — там оцифровка полнее"
" (в наборе SDLPoP звук 48 `spiked` пустой).")
print(" собрать с ней: make SND_SRC=msdos")
sounds = {} sounds = {}
for sid, body in sorted(res.items()): for sid, body in sorted(res.items()):
@@ -128,7 +159,15 @@ def main():
# тесная и её отказ ронял бы весь звук. # тесная и её отказ ронял бы весь звук.
blob = bytearray([0x80] * BLOCK) blob = bytearray([0x80] * BLOCK)
table = {} table = {}
for sid in sorted(sounds): # ПОРЯДОК УКЛАДКИ. Штатно — по возрастанию id; `--order reverse` кладёт
# те же эффекты задом наперёд. Данные и таблица при этом остаются
# согласованными (индекс печатается из table), но НИ ОДНО смещение не
# совпадает со штатным набором — только так и проверяется, что игра
# читает раскладку с диска, а не помнит её с компиляции: у двух
# реальных наборов совпадают 23 записи из 31, и подмена на них видна
# слабо.
order = sorted(sounds, reverse=(args.order == "reverse"))
for sid in order:
pad = (-len(blob)) % BLOCK pad = (-len(blob)) % BLOCK
blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит blob += bytes([0x80]) * pad # 0x80 — тишина в беззнаковом 8-бит
# Длину округляем ВВЕРХ до блока: хвост последнего блока — это # Длину округляем ВВЕРХ до блока: хвост последнего блока — это
@@ -148,64 +187,34 @@ def main():
blob += bytes([0x80]) * ((-len(blob)) % PAGE) blob += bytes([0x80]) * ((-len(blob)) % PAGE)
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
# Старые страницы СНОСИМ: у разных источников их разное число (SDLPoP 9,
# MSDOS 10), и лишняя s9.bin от прошлого прогона уехала бы в архив
# хвостом, которого нет в таблице.
for old in os.listdir(OUT_DIR):
if old.startswith("s") and old.endswith(".bin"):
os.remove(os.path.join(OUT_DIR, old))
npages = len(blob) // PAGE npages = len(blob) // PAGE
for i in range(npages): for i in range(npages):
with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f: with open(os.path.join(OUT_DIR, f"s{i}.bin"), "wb") as f:
f.write(blob[i * PAGE:(i + 1) * PAGE]) f.write(blob[i * PAGE:(i + 1) * PAGE])
lines = [ # ДИСКОВЫЙ ИНДЕКС. Та же раскладка, что и в заголовке ниже, но на
"/* pop_sound_tbl.h — раскладка оцифрованных эффектов PoP.", # диске: с ним пара snd.idx + snd.arc заменяется целиком, без
" * СГЕНЕРЕНО toolchain/pop_pack_sound.py — НЕ править вручную.", # перекомпиляции игры (docs/music_runtime_index_plan.md, часть II).
" *", # Проверки внутри snd_pack — ровно те, что делает загрузчик на Z80:
" * Данные: 8 бит беззнаковые, МОНО, %g Гц (CBL_FREQ_10K9) — формат" % RATE, # набор, который не пройдёт здесь, не пройдёт и там.
" * CBL один в один, конвертировать в рантайме нечего.", if npages > pop_idx.SND_MAX_PAGES:
" *", sys.exit("набор занял %d страниц, а загрузчик берёт не больше %d"
" * Начало каждого звука кратно %d — размеру блока запроса CBL, а" % BLOCK, % (npages, pop_idx.SND_MAX_PAGES))
" * страница (%d) кратна ему же. Поэтому НИ ОДИН блок не пересекает" % PAGE, entries = [table.get(sid, (0, 0)) for sid in range(NSOUND)]
" * границу страницы. Больше того: с 2026-08-25 каждый звук лежит\n * ЦЕЛИКОМ в одной странице — не влезает в остаток, значит начинаем\n * новую (страницы EMM у нас с запасом). Единственное исключение —\n * эффект, который сам ДЛИННЕЕ страницы: для него насос переключает\n * страницу на ходу, и эта ветка в pop_sfx_fill остаётся ради него.", entries = [(off >> 14, off & 0x3FFF, ln) for off, ln in entries]
" *", with open(os.path.join(OUT_DIR, IDX), "wb") as f:
" * Смещение хранится РАЗОБРАННЫМ на страницу и адрес в окне: на Z80", f.write(pop_idx.snd_pack(entries, npages))
" * 32-битная арифметика дорога, а так всё влезает в 8/16 бит.", print(" индекс -> %s (%d страниц)" % (os.path.join(OUT_DIR, IDX),
" * Длина округлена вверх до блока — неполных блоков не бывает.", npages))
" */",
"#ifndef POP_SOUND_TBL_H",
"#define POP_SOUND_TBL_H",
"",
"#include <stdint.h>",
"",
"#define POP_SND_PAGES %d" % npages,
"#define POP_SND_COUNT %d" % NSOUND,
"#define POP_SND_BLOCK %d" % BLOCK,
"/* Первый блок страницы 0 — тишина для насоса (см. упаковщик). */",
"#define POP_SND_SILENCE_PAGE 0",
"#define POP_SND_SILENCE_OFF 0x0000",
"",
"typedef struct { uint8_t page; uint16_t off; uint16_t len; } pop_snd_ent_t;",
"",
"/* len == 0 — у звука нет оцифровки (это музыка, см. sound_plan.md). */",
"static const pop_snd_ent_t pop_snd_tbl[POP_SND_COUNT] = {",
]
for sid in range(NSOUND):
off, ln = table.get(sid, (0, 0))
lines.append(" { %d, 0x%04X, %5d }, /* %2d */" % (off >> 14, off & 0x3FFF, ln, sid))
lines += [
"};",
"",
"/* Имена файлов видит ТОЛЬКО тот, кто попросил POP_SND_FILES (холодная",
" * половина): иначе копия строк уезжает в каждый включивший заголовок",
" * модуль, а в резиденте W1/W2 каждая сотня байт на счету. Тот же",
" * приём, что у POP_BG_ATLAS_NAMES в pop_bg_atlas.h. */",
"#ifdef POP_SND_FILES",
"static const char *const pop_snd_files[POP_SND_PAGES] = {",
]
lines += [' "SND\\\\s%d.bin",' % i for i in range(npages)]
lines += ["};", "#endif", "", "#endif", ""]
with open(HDR, "w") as f:
f.write("\n".join(lines))
have = sum(1 for s in sounds.values() if s) have = sum(1 for s in sounds.values() if s)
print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}") print(f"ИТОГО: {have} эффектов, {len(blob)} Б = {npages} EMM-страниц -> {OUT_DIR}")
print(f" заголовок -> {HDR}")
if __name__ == "__main__": if __name__ == "__main__":
@@ -0,0 +1,244 @@
"""test_pop_idx — формат дисковых индексов аудио (tools/pop_idx.py).
Индекс читают ДВЕ стороны: упаковщик на хосте и загрузчик на Z80. Тест
закрепляет ровно то, на что рассчитывает вторая сторона: точные смещения
полей, little-endian, размер сектора и НАБОР ОТКАЗОВ потому что битый
индекс обязан выключить звук, а не увести насос в чужую память.
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
"""
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pop_idx as X
def empty():
return [(0, 0, 0)] * X.SND_COUNT
class SndFormat(unittest.TestCase):
"""PSI1: раскладка байтов, на которую смотрит Z80."""
def test_size_is_one_sector(self):
self.assertEqual(len(X.snd_pack(empty(), 1)), X.SECTOR)
def test_header_fields(self):
d = X.snd_pack(empty(), 9)
self.assertEqual(d[0:4], b"PSI1")
self.assertEqual(d[4], 57) # число записей
self.assertEqual(d[5], 5) # sizeof(pop_snd_ent_t) в SDCC z80
self.assertEqual(d[6], 7) # log2(128)
self.assertEqual(d[7], 1) # u8 mono 10937.5
self.assertEqual(d[8], 9) # фактических страниц
self.assertEqual(d[9:16], b"\0" * 7)
def test_entry_is_little_endian_at_fixed_offset(self):
"""Смещение записи и порядок байтов — контракт с загрузчиком."""
e = empty()
e[3] = (2, 0x1500, 0x0480)
d = X.snd_pack(e, 9)
at = X.SND_HDR + 3 * X.SND_REC
self.assertEqual(d[at], 2)
self.assertEqual(d[at + 1:at + 3], b"\x00\x15") # off LE
self.assertEqual(d[at + 3:at + 5], b"\x80\x04") # len LE
self.assertEqual(struct.unpack_from("<BHH", d, at), (2, 0x1500, 0x0480))
def test_tail_is_zero_padded(self):
d = X.snd_pack(empty(), 1)
self.assertEqual(d[X.SND_HDR + X.SND_COUNT * X.SND_REC:], b"\0" * 211)
def test_roundtrip(self):
e = empty()
e[10] = (0, 128, 3584)
e[48] = (8, 0x1400, 128)
e[56] = (8, 0x2600, 4864)
d = X.snd_pack(e, 9)
back, pages = X.snd_unpack(d)
self.assertEqual(pages, 9)
self.assertEqual(back, e)
class SndRejects(unittest.TestCase):
"""Битый индекс обязан отвергаться, а не молча разъезжать раскладку."""
def bad(self, mutate):
e = empty()
e[1] = (0, 128, 256)
d = bytearray(X.snd_pack(e, 4))
mutate(d)
with self.assertRaises(X.IdxError):
X.snd_unpack(bytes(d))
def test_wrong_magic(self):
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI2"))
def test_wrong_count(self):
self.bad(lambda d: d.__setitem__(4, 56))
def test_wrong_record_size(self):
self.bad(lambda d: d.__setitem__(5, 4))
def test_wrong_block_log2(self):
self.bad(lambda d: d.__setitem__(6, 8))
def test_unknown_pcm_format(self):
self.bad(lambda d: d.__setitem__(7, 2))
def test_pages_zero(self):
self.bad(lambda d: d.__setitem__(8, 0))
def test_pages_over_capacity(self):
self.bad(lambda d: d.__setitem__(8, X.SND_MAX_PAGES + 1))
def test_truncated(self):
e = empty()
d = X.snd_pack(e, 1)
with self.assertRaises(X.IdxError):
X.snd_unpack(d[:-1])
def test_page_beyond_set(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 9, 0, 128))
def test_offset_not_block_aligned(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 100, 128))
def test_length_not_block_aligned(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 1, 128, 100))
def test_entry_runs_past_last_page(self):
"""Эффект длиннее остатка набора увёл бы насос в чужую память."""
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5,
3, 0x3F80, 16384))
def test_entry_in_silence_block(self):
"""Первый блок страницы 0 занят тишиной насоса — он неприкосновенен."""
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 0, 0, 128))
def test_empty_entry_must_be_all_zero(self):
self.bad(lambda d: struct.pack_into("<BHH", d, X.SND_HDR + 5, 2, 256, 0))
class SndPackGuards(unittest.TestCase):
"""Упаковщик не имеет права выпустить набор, который отвергнет Z80."""
def test_rejects_too_many_pages(self):
with self.assertRaises(X.IdxError):
X.snd_pack(empty(), X.SND_MAX_PAGES + 1)
def test_rejects_wrong_entry_count(self):
with self.assertRaises(X.IdxError):
X.snd_pack([(0, 0, 0)] * 56, 1)
def test_rejects_entry_in_silence_block(self):
e = empty()
e[0] = (0, 0, 128)
with self.assertRaises(X.IdxError):
X.snd_pack(e, 1)
def mus_empty():
return [(0, 0)] * X.MUS_COUNT
class MusFormat(unittest.TestCase):
"""PMI1: раскладка байтов индекса музыки."""
def test_size_is_one_sector(self):
self.assertEqual(len(X.mus_pack(mus_empty())), X.SECTOR)
def test_header_fields(self):
d = X.mus_pack(mus_empty())
self.assertEqual(d[0:4], b"PMI1")
self.assertEqual(d[4], 57)
self.assertEqual(d[5], 4)
self.assertEqual(d[6], 7)
self.assertEqual(d[7], 1)
self.assertEqual(d[8:16], b"\0" * 8)
def test_entry_is_little_endian_at_fixed_offset(self):
e = mus_empty()
e[41] = (1043, 732)
d = X.mus_pack(e)
at = X.MUS_HDR + 41 * X.MUS_REC
self.assertEqual(d[at:at + 2], b"\x13\x04") # blocks LE
self.assertEqual(d[at + 2:at + 4], b"\xdc\x02") # ticks60 LE
self.assertEqual(struct.unpack_from("<HH", d, at), (1043, 732))
def test_roundtrip(self):
e = mus_empty()
e[24] = (305, X.mus_ticks(305))
e[56] = (9865, X.mus_ticks(9865))
self.assertEqual(X.mus_unpack(X.mus_pack(e)), e)
class MusTicks(unittest.TestCase):
"""Формула тиков закреплена ЗНАЧЕНИЯМИ нынешней сборки (набор flac).
Она переехала из упаковщика в общий модуль, и разойтись ей нельзя:
по этим числам сцены отмеряют паузы, пока они не перешли на ожидание
звука. Слева blocks из gen/pop_music_tbl.h, справа тики из
gen/pop_music_ticks.h.
"""
CASES = ((624, 438), # m32 end_level_4
(1043, 732), # m41 end_level
(1184, 831), # m50 princess
(1403, 985), # m53 Jaffar
(9865, 6923), # m56 won — самый длинный
(305, 214), # m24 regular_death — самый короткий из игровых
(196, 138)) # m25
def test_matches_current_build(self):
for blocks, ticks in self.CASES:
self.assertEqual(X.mus_ticks(blocks), ticks, "blocks=%d" % blocks)
def test_no_uint16_overflow_on_longest(self):
"""blocks*40 переполнил бы uint16 — формула обязана это переживать."""
self.assertLess(X.mus_ticks(65535), 65536)
def test_pages_rounds_up(self):
self.assertEqual(X.mus_pages(128), 1)
self.assertEqual(X.mus_pages(129), 2)
self.assertEqual(X.mus_pages(9865), 78) # m56 flac
self.assertEqual(X.mus_pages(10462), 82) # m56 mt32 — длиннее
class MusRejects(unittest.TestCase):
def bad(self, mutate):
e = mus_empty()
e[41] = (1043, 732)
d = bytearray(X.mus_pack(e))
mutate(d)
with self.assertRaises(X.IdxError):
X.mus_unpack(bytes(d))
def test_wrong_magic(self):
self.bad(lambda d: d.__setitem__(slice(0, 4), b"PSI1"))
def test_wrong_count(self):
self.bad(lambda d: d.__setitem__(4, 56))
def test_wrong_record_size(self):
self.bad(lambda d: d.__setitem__(5, 5))
def test_unknown_pcm_format(self):
self.bad(lambda d: d.__setitem__(7, 3))
def test_ticks_without_track(self):
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 0, 100))
def test_track_without_ticks(self):
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4, 500, 0))
def test_track_longer_than_page_counters(self):
"""Больше 255 страниц не переживут восьмибитные счётчики в коде."""
self.bad(lambda d: struct.pack_into("<HH", d, X.MUS_HDR + 5 * 4,
65535, 1000))
if __name__ == "__main__":
unittest.main()
+11 -12
View File
@@ -1,5 +1,5 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3786 accfill 3783
accop 7067 accop 7067
argv 3431 argv 3431
assrtest 3847 assrtest 3847
@@ -9,15 +9,15 @@ banked 1056
bankedbg 1067 bankedbg 1067
banklocl 4683 banklocl 4683
banktest 3767 banktest 3767
bgi_img 8680 bgi_img 8677
bgitest 3751 bgitest 3748
bios_text 4461 bios_text 4461
blitperf 5860 blitperf 5860
blitw 4939 blitw 4939
cat 913 cat 913
cblstream 5850 cblstream 5860
cbltest 5996 cbltest 6006
cblwav 6089 cblwav 6099
conio 4605 conio 4605
conio2 3929 conio2 3929
convbench 3508 convbench 3508
@@ -28,12 +28,12 @@ fdmax 6023
filetest 10547 filetest 10547
fpsdiv 4870 fpsdiv 4870
gets 509 gets 509
gfx_dbuf 5134 gfx_dbuf 5131
gfx_demo 4117 gfx_demo 4114
gfxbanks 6044 gfxbanks 6044
hello 4167 hello 4167
hello2 4244 hello2 4244
irqtest 6680 irqtest 6690
kbdpoll 1282 kbdpoll 1282
kbdraw 4911 kbdraw 4911
ls 4835 ls 4835
@@ -44,7 +44,7 @@ openenv 6124
pageflip 6334 pageflip 6334
palfile 5406 palfile 5406
ptime 5744 ptime 5744
rt_test 5187 rt_test 5197
scroll 6744 scroll 6744
seek 4188 seek 4188
simple 955 simple 955
@@ -59,8 +59,7 @@ strtest 1340
text_palette 5024 text_palette 5024
timedir 5454 timedir 5454
w0page 8831 w0page 8831
w3bankgfx 5240 w3bgfx 5243
w3bgfx 5246
w3big 3673 w3big 3673
w3huge 3700 w3huge 3700
w3probe 3556 w3probe 3556
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
2 accfill
3 accop
4 argv
5 assrtest
9 bankedbg
10 banklocl
11 banktest
12 bgi_img
13 bgitest
14 bios_text
15 blitperf
16 blitw
17 cat
18 cblstream
19 cbltest
20 cblwav
21 conio
22 conio2
23 convbench
28 filetest
29 fpsdiv
30 gets
31 gfx_dbuf
32 gfx_demo
33 gfxbanks
34 hello
35 hello2
36 irqtest
37 kbdpoll
38 kbdraw
39 ls
44 pageflip
45 palfile
46 ptime
47 rt_test
48 scroll
49 seek
50 simple
59 text_palette
60 timedir
61 w0page
62 w3bankgfx w3bgfx
w3bgfx
63 w3big
64 w3huge
65 w3probe
+35
View File
@@ -23,6 +23,16 @@
* cbl_open()/cbl_close() (<cbl.h>) тот же приватный IM2-хук, не * cbl_open()/cbl_close() (<cbl.h>) тот же приватный IM2-хук, не
* чейнящийся к DSS. * чейнящийся к DSS.
* *
* КАДРОВОЕ ОБСЛУЖИВАНИЕ DSS НА ЭТО ВРЕМЯ ОСТАНОВЛЕНО. Пока raw открыт,
* трамплин НЕ вызывает обработчик прерывания DSS вообще: тот первым делом
* зовёт свой KEYSCAN, который вычерпывает FIFO SIO и уводит наши байты
* (потерянный make = несработавшее нажатие, потерянный break = залипшая
* клавиша; поймано в MAME 2026-08-28). Вместе с KEYSCAN замирают и
* остальные его кадровые дела опрос мыши (Dss.Mouse.GetPackets) и
* мигание текстового курсора. ЗНАЧИТ: приложению, которому нужна мышь
* через DSS, raw-канал открывать нельзя (мышь встанет); клавиатурные
* события всё равно берутся из kbd_raw_down().
*
* КОДЫ КЛАВИШ: значения ниже общеизвестный стандарт AT/PS-2 Scan * КОДЫ КЛАВИШ: значения ниже общеизвестный стандарт AT/PS-2 Scan
* Code Set 2 (не специфика Sprinter). TODO до использования в PoC: * Code Set 2 (не специфика Sprinter). TODO до использования в PoC:
* подтвердить в MAME, что реальный поток байт с SIO-A клавиатуры * подтвердить в MAME, что реальный поток байт с SIO-A клавиатуры
@@ -46,6 +56,31 @@ void kbd_raw_close(void);
* на проводе) прибавить KBD_EXT к базовому коду. */ * на проводе) прибавить KBD_EXT к базовому коду. */
uint8_t kbd_raw_down(uint16_t code); uint8_t kbd_raw_down(uint16_t code);
/* Считать ГОЛЫЕ (без префикса 0xE0) коды нумпада ТЕМИ ЖЕ клавишами, что и
* расширенные: 0x69..0x7D, кроме ESC 0x76 и F11 0x78. По умолчанию
* выключено нумпад остаётся отдельным набором клавиш.
*
* В PS/2 это и есть одни и те же физические клавиши: навигационный блок
* шлёт код с префиксом 0xE0, нумпад тот же код голым (KP4 = Left,
* KP8 = Up, KP7 = Home, KP9 = PgUp и т.д.). Флаг восстанавливает это
* равенство: kbd_raw_sync() переносит биты голых кодов в EXT-половину
* карты и гасит их в PLAIN.
*
* ЗАЧЕМ ЭТО НУЖНО (а не только «нумпад тоже работает»). При переполнении
* 3-байтового FIFO SIO теряется префикс, и make стрелки садится в PLAIN
* как код нумпада а break придёт уже с префиксом и снимет бит в ДРУГОЙ
* половине. Клавиша остаётся зажатой НАВСЕГДА: kbd_raw_any_down()
* отвечает «да», и любое ожидание «пока ничего не нажато» виснет насмерть.
* После перекладки make и break работают с одним и тем же битом.
*
* Чего флаг НЕ лечит: потерю префикса у BREAK (`E0 F0 6B` -> `F0 6B`)
* тогда бит стоит уже в EXT-половине и выглядит как реально зажатая
* клавиша; снимается перенажатием или typematic-повтором.
*
* Перекладку делает kbd_raw_sync(), то есть приложение обязано звать его
* раз в кадр (оно и так обязано см. ниже). */
void kbd_raw_keypad_as_ext(uint8_t on);
/* Есть ли хотя бы одна зажатая клавиша (0/1). Полезно для экранов с /* Есть ли хотя бы одна зажатая клавиша (0/1). Полезно для экранов с
* семантикой «продолжить любой клавишей»; один вызов читает 64-байтную * семантикой «продолжить любой клавишей»; один вызов читает 64-байтную
* bitmap, а не перебирает все 512 scan-кодов через kbd_raw_down(). */ * bitmap, а не перебирает все 512 scan-кодов через kbd_raw_down(). */
+5 -4
View File
@@ -30,10 +30,11 @@
#define _IRQ_VEC_BUF_SIZE (256 + 257) #define _IRQ_VEC_BUF_SIZE (256 + 257)
/* Буфер под W2-копию кадрового трамплина (small/huge — _irq_tramp в /* Буфер под W2-копию кадрового трамплина (small/huge — _irq_tramp в
* W1). С запасом над реальным размером трамплина (267 Б после * W1). С запасом над реальным размером трамплина (310 Б после отказа
* упаковки held-карты клавиатуры в биты, 2026-07-30 проверять * звать обработчик DSS, пока открыт raw-канал, 2026-08-28 проверять
* __irq_tramp_end при правках, запас держать >= 60 Б). */ * `grep 'A _CODE' libc/build/fast/irq/_irq_tramp.rel` при правках,
#define _IRQ_TRAMP_BUF_SIZE 336 * запас держать >= 60 Б). */
#define _IRQ_TRAMP_BUF_SIZE 384
extern uint8_t _irq_vec_buf[_IRQ_VEC_BUF_SIZE]; extern uint8_t _irq_vec_buf[_IRQ_VEC_BUF_SIZE];
+36 -1
View File
@@ -13,7 +13,9 @@
* клавиши) в битовую карту `_kbdraw_down[]` (бит на код, 64 Б), * клавиши) в битовую карту `_kbdraw_down[]` (бит на код, 64 Б),
* и к DSS НЕ идём * и к DSS НЕ идём
* (private RETI тот же паттерн, что и у CBL ниже). Пока raw * (private RETI тот же паттерн, что и у CBL ниже). Пока raw
* открыт, DSS клавиатуру не видит вообще (см. <kbd_raw.h>). * открыт, DSS клавиатуру не видит вообще (см. <kbd_raw.h>) и на
* кадровом пути тоже: пока raw открыт, обработчик DSS НЕ вызывается
* совсем (иначе его KEYSCAN крадёт наши байты см. tr_frame_exit).
* - CBL (бит 7 порта 0xFE) только при зарегистрированном хуке * - CBL (бит 7 порта 0xFE) только при зарегистрированном хуке
* _irq_cbl_hook (иначе бит подтянут к 1); личное прерывание, RETI * _irq_cbl_hook (иначе бит подтянут к 1); личное прерывание, RETI
* без чейна к DSS; * без чейна к DSS;
@@ -312,6 +314,39 @@ void _irq_tramp(void) __naked
ld a, (__irq_saved_w1) ; вернуть прерванную W1 перед chain ld a, (__irq_saved_w1) ; вернуть прерванную W1 перед chain
out (#_IRQ_W1_PORT), a out (#_IRQ_W1_PORT), a
tr_frame_exit: tr_frame_exit:
;; --- ПОКА RAW ОТКРЫТ, В DSS НЕ ХОДИМ ВООБЩЕ ---
;; Хвост кадрового пути уходил в DSS (0x0038), а его обработчик
;; INTx38_Handler ПЕРВЫМ ДЕЛОМ зовёт KEYSCAN, и тот вычерпывает FIFO
;; SIO досуха (docs/sources/Estex-DSS/DSS/DSS-MAIN.ASM,
;; KEYINTER.ASM:KEYSCAN.RESCAN). Наша проверка «есть ли байт» стоит в
;; НАЧАЛЕ трамплина, поэтому скан-код, прилетевший позже, доставался
;; DSS и уезжал в ЕГО буфер БЕЗ Rx-overrun. Украденный make =
;; несработавшее нажатие, украденный break = залипшая навсегда
;; клавиша при `_kbdraw_overrun == 0` (BUGS_OPEN KBD-STUCK-WAIT,
;; GRAB-KBD-TIMING). Поймано в MAME 2026-08-28: DSS прочитал 0x74
;; (make стрелки «вправо») при `_kbdraw_pending = EXT` посылка
;; `E0 74` разорвана пополам между двумя владельцами канала.
;;
;; Окно измерено брейками + totalcycles: от входа трамплина до
;; чтения DSS 738 тактов (~34 мкс), и открыто оно на КАЖДОМ
;; кадровом прерывании (50/с). Из них 481 такт пролог самого DSS
;; (jp 0x38 jp .Handler call INTx38_Handler 11 push call
;; KEYSCAN), то есть закрыть окно проверкой FIFO перед chain'ом
;; нельзя: она снимает лишь треть (проверено там же).
;;
;; Поэтому пока raw открыт, кадровый путь заканчивается ПРИВАТНЫМ
;; RETI и обработчик DSS не вызывается совсем окно ровно ноль.
;; ЦЕНА: на это время замирает кадровое обслуживание DSS
;; Dss.Mouse.GetPackets и мигание текстового курсора. Приложению с
;; raw-клавиатурой (игре) не нужно ни то, ни другое; кому нужна мышь
;; DSS одновременно с raw не открывать raw (см. <kbd_raw.h>).
ld a, (__kbdraw_active)
or a, a
jr Z, tr_chain_dss
pop af
ei
reti
tr_chain_dss:
pop af pop af
.db 0xC3 ; jp 0x0038 (литерал copy-safe) .db 0xC3 ; jp 0x0038 (литерал copy-safe)
.dw 0x0038 .dw 0x0038
+24
View File
@@ -57,4 +57,28 @@ extern uint8_t _kbdraw_overrun; /* 1 = ISR поймал Rx-overrun SIO (по
* читает kbd_raw_down(). */ * читает kbd_raw_down(). */
extern uint8_t _kbdraw_down[64]; extern uint8_t _kbdraw_down[64];
/* ГОЛЫЕ КОДЫ НУМПАДА = ТЕ ЖЕ КЛАВИШИ, ЧТО И РАСШИРЕННЫЕ
* (взводит kbd_raw_keypad_as_ext).
*
* Стрелки приходят как E0 6B / E0 72 / E0 74 / E0 75, то есть в EXT-половину
* карты. Если префикс E0 потерян (переполнение 3-байтового FIFO SIO в
* длинном DI-окне), make садится в PLAIN-половину как код нумпада, а его
* break приходит уже с префиксом и снимает бит в EXT-половине PLAIN-бит
* остаётся зажатым НАВСЕГДА (BUG-KBD: 0x72 2026-08-24, 0x6B 2026-08-27).
*
* Лечится перекладкой: kbd_raw_sync() переносит биты голых кодов нумпада
* в EXT-половину и гасит их в PLAIN. Тогда make без префикса ставит ТОТ ЖЕ
* бит, который потом снимет обычный break `E0 F0 xx`, залипание исчезает,
* а нумпад заодно работает как навигационные клавиши (в PS/2 это и есть
* одни и те же физические клавиши). Позиции бит совпадают: EXT те же
* байты карты плюс 32.
*
* Диапазон Set 2 0x69..0x7D, но ESC (0x76) и F11 (0x78) лежат внутри и
* перекладке НЕ подлежат.
*
* Чего это не лечит: потерю префикса у BREAK (`E0 F0 6B` -> `F0 6B`). Тогда
* бит остаётся стоять уже в EXT-половине, то есть выглядит как реально
* зажатая клавиша; снимается перенажатием или typematic-повтором. */
extern uint8_t _kbdraw_keypad_ext;
#endif #endif
+3
View File
@@ -10,3 +10,6 @@ uint8_t _kbdraw_pending;
uint8_t _kbdraw_overrun; /* ISR взвёл при Rx-overrun SIO (потерян uint8_t _kbdraw_overrun; /* ISR взвёл при Rx-overrun SIO (потерян
* break) kbd_raw_sync снимет залипшее */ * break) kbd_raw_sync снимет залипшее */
uint8_t _kbdraw_down[64]; /* БИТ на скан-код (см. _kbdraw.h) */ uint8_t _kbdraw_down[64]; /* БИТ на скан-код (см. _kbdraw.h) */
uint8_t _kbdraw_keypad_ext; /* 1 = голые коды нумпада считать теми же
* клавишами, что и расширенные
* (kbd_raw_keypad_as_ext; см. _kbdraw.h) */
+17
View File
@@ -0,0 +1,17 @@
/*
* kbd_raw_keypad_as_ext голые коды нумпада = те же клавиши, что и
* расширенные (стрелки и навигация).
*
* Мотивация и разбор залипания в шапке _kbdraw.h рядом с
* _kbdraw_keypad_ext; публичный контракт в <kbd_raw.h>. Саму перекладку
* делает kbd_raw_sync(): в декодерах ей места нет клавиатурный блок
* трамплина уже длиннее 127 байт, а `jp` внутри него запрещён (копия в W2),
* и вставка рвала относительные переходы.
*/
#include <kbd_raw.h>
#include "_kbdraw.h"
void kbd_raw_keypad_as_ext(uint8_t on)
{
_kbdraw_keypad_ext = (uint8_t)(on ? 1 : 0);
}
+31
View File
@@ -47,11 +47,42 @@ static const uint16_t _kbd_mod_codes[6] = {
0x12, 0x59, 0x14, 0x11, 0x0114, 0x0111 0x12, 0x59, 0x14, 0x11, 0x0114, 0x0111
}; };
/* Голые коды нумпада (Set 2 0x69..0x7D) — по БИТАМ карты: байт 13 держит
* 0x68..0x6F, байт 14 0x70..0x77, байт 15 0x78..0x7F. Гасим только
* клавиши нумпада: ESC (0x76, байт 14 бит 6) и F11 (0x78, байт 15 бит 0)
* обязаны проходить, поэтому маски именно такие, а не 0xFF. */
#define KP_BYTE0 13
#define KP_MASK0 0x1A /* 0x69 KP1, 0x6B KP4, 0x6C KP7 */
#define KP_BYTE1 14
#define KP_MASK1 0xBF /* 0x70..0x75, 0x77 — всё, кроме ESC 0x76 */
#define KP_BYTE2 15
#define KP_MASK2 0x3E /* 0x79..0x7D — всё, кроме F11 0x78 */
void kbd_raw_sync(void) void kbd_raw_sync(void)
{ {
uint8_t save[6]; uint8_t save[6];
uint8_t i; uint8_t i;
/* ПЕРЕКЛАДЫВАЕМ ГОЛЫЕ КОДЫ НУМПАДА В EXT-ПОЛОВИНУ, если приложение
* попросило. Делается здесь, а не в декодерах: клавиатурный блок
* трамплина и так длиннее 127 байт (внутри него `jp` запрещён
* трамплин копируется в W2), и вставка рвала относительные переходы.
* Цена шесть операций раз в кадр вместо проверки на каждый байт.
*
* Почему это лечит залипание: стрелка приходит как E0 6B, и при потере
* префикса make садится в PLAIN-половину как код нумпада, а break
* придёт уже с префиксом и снимет бит в EXT plain-бит остался бы
* зажатым навсегда. После перекладки make и break работают с ОДНИМ и
* тем же битом. Позиции бит совпадают: EXT те же байты плюс 32. */
if (_kbdraw_keypad_ext) {
_kbdraw_down[KP_BYTE0 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE0] & KP_MASK0);
_kbdraw_down[KP_BYTE1 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE1] & KP_MASK1);
_kbdraw_down[KP_BYTE2 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE2] & KP_MASK2);
_kbdraw_down[KP_BYTE0] &= (uint8_t)~KP_MASK0;
_kbdraw_down[KP_BYTE1] &= (uint8_t)~KP_MASK1;
_kbdraw_down[KP_BYTE2] &= (uint8_t)~KP_MASK2;
}
if (!_kbdraw_overrun) return; if (!_kbdraw_overrun) return;
_kbdraw_overrun = 0; _kbdraw_overrun = 0;
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
check_bank_calls.py две проверки банкового кода, которых нет ни у
компилятора, ни у линкера.
ПРОВЕРКА 1 ПРЯМОЙ вызов в ЧУЖОЙ банк.
Зачем. Трамплин у SDCC выбирается по `__banked` в ОБЪЯВЛЕНИИ, а не по
тому, в каком банке функция реально лежит (memory sdcc_banked_call_rules).
Поэтому функция, объявленная без `__banked`, зовётся прямым `call`, и это
верно ровно до тех пор, пока вызывающий и вызываемый лежат в ОДНОМ банке.
Стоит перенести модуль в другой банк ради разгрузки и тот же `call`
уходит по адресу, где у нового банка пустой хвост (0xFF). Процессор
ползёт по нему вверх, переваливает на 0x0000, и DSS убивает процесс.
Собирается это МОЛЧА: ни компилятор, ни линкер такой вызов не ловят.
Поймано вживую 2026-08-27 `hof_draw_rows` (банк 10) звал
`pop_text_map` (банк 9): `call $E503` в пустоту, крэш финала.
Как проверяем. По .map берём банк каждого глобального символа (старшее
слово адреса: 0 = резидент, N = банк N), по сгенерированным bankN_*.asm
все прямые `call`/`jp` по ИМЕНИ символа. Вызов законен, если цель
резидентная или лежит в том же банке; иначе это мина.
ПРОВЕРКА 2 УКАЗАТЕЛЬ НА ДАННЫЕ СВОЕГО БАНКА, ОТДАННЫЙ В ЧУЖОЙ.
Обратная сторона той же медали. Пока вызывающий и вызываемый лежали в
одном банке, указатель на литерал или const-таблицу банка работал. Стоит
пометить вызываемую функцию `__banked` и трамплин на время вызова
переключает W3: тот же указатель показывает уже в чужой банк, и функция
читает оттуда мусор. Поймано вживую 2026-08-27: `hof_edit_draw` (банк 10)
передавал литерал "_" в `pop_text_draw_mapped` (банк 9), и экран заливало
знаками вопроса на каждое мигание курсора.
Ловим ФОРМУ, а не поток данных: аргументы банкового вызова SDCC кладёт
непосредственно перед ним, поэтому идём от каждого `call ___sdcc_bcall_ehl`
назад по блоку подготовки и смотрим, не грузился ли там адрес метки из
области _BANKn этого же модуля.
НЕ ПРОВЕРЕНО: режим, в котором const/статики банковых модулей размещаются
в РЕЗИДЕНТНОЙ памяти, а не в области банка (SprPoP им не пользуется). По
построению там всё должно быть тихо «данными банка» считаются только
метки из `.area _BANKn`, а резидентные в этот список не попадут, и
передавать такой указатель в чужой банк законно. Но живьём это не
гонялось: если режим включат, проверку надо прогнать и убедиться, что она
не начала молчать там, где не должна.
ЧЕГО ЭТА ПРОВЕРКА НЕ ВИДИТ (и не может): указатель, положенный в
переменную и переданный позже; адрес, посчитанный арифметикой; указатель,
отданный резидентной функции, которая сохранит его на будущее. Для этого
нужен анализ потока данных. Правило остаётся за человеком: всё, что
уходит указателем в другой банк, обязано лежать в W2 (стек, _DATA, копия).
Использование:
check_bank_calls.py <build-dir> # каталог с .map и bankN_*.asm
Возврат: 0 чисто, 1 найдено (печатается списком).
"""
import re
import sys
from pathlib import Path
# `call _foo`, `jp _foo`, с условием и без: `call NZ,_foo` / `jp C,_foo`.
CALL_RE = re.compile(
r'^\s*(call|jp)\s+(?:[A-Za-z]{1,2}\s*,\s*)?(_[A-Za-z_0-9]+)\s*(?:;.*)?$',
re.IGNORECASE)
# Строка карты: " 0009E503 _pop_text_map pop_ui"
MAP_RE = re.compile(r'^\s+([0-9A-F]{8})\s+(\S+)\s')
BANK_ASM_RE = re.compile(r'^bank(\d+)_')
# Механика самих банковых вызовов — не наша забота.
IGNORE = {'___sdcc_bcall_ehl', '___sdcc_bcall'}
# `ld hl,#_метка` / `ld de,#___str_3` — материализация адреса.
LOADADDR_RE = re.compile(
r'^\s*ld\s+(hl|de|bc|iy|ix)\s*,\s*#(_{1,3}[A-Za-z_0-9]+)'
r'(?:\s*[+-]\s*\w+)?\s*(?:;.*)?$', re.IGNORECASE)
PUSH_RE = re.compile(r'^\s*push\s+(hl|de|bc|iy|ix)\s*(?:;.*)?$',
re.IGNORECASE)
# `ld e,#b_foo` перед трамплином называет банк цели.
BANKARG_RE = re.compile(r'^\s*ld\s+e\s*,\s*#(b_[A-Za-z_0-9]+)\s*(?:;.*)?$',
re.IGNORECASE)
AREA_RE = re.compile(r'^\s*\.area\s+(\S+)')
LABEL_RE = re.compile(r'^([A-Za-z_][A-Za-z_0-9]*)::?')
# Директивы данных: по ним отличаем метку ДАННЫХ от метки функции. Без
# этого проверка принимала бы за указатель саму механику трамплина
# (`ld hl,#_функция` перед `call ___sdcc_bcall_ehl`).
DATA_DIR_RE = re.compile(r'^\s*\.(ascii|asciz|str|db|byte|dw|word|ds|blkb)\b',
re.IGNORECASE)
# Сколько строк назад от трамплина считаем «подготовкой аргументов».
ARG_WINDOW = 40
def load_symbol_banks(map_path):
"""symbol -> номер банка (0 = резидент). Берём ПЕРВОЕ определение:
карта повторяет один и тот же список в каждой секции Area."""
banks = {}
for line in map_path.read_text(errors='replace').splitlines():
m = MAP_RE.match(line)
if not m:
continue
value, name = m.group(1), m.group(2)
if name.startswith('l__') or name.startswith('s__') or name == '.__.ABS.':
continue
banks.setdefault(name, int(value[:4], 16))
return banks
def main(argv):
if len(argv) != 2:
print(__doc__.strip())
return 2
build = Path(argv[1])
maps = list(build.glob('*.map'))
if not maps:
print(f'check_bank_calls: нет .map в {build}', file=sys.stderr)
return 2
banks = load_symbol_banks(maps[0])
bad = []
for asm in sorted(build.glob('bank*_*.asm')):
m = BANK_ASM_RE.match(asm.name)
if not m:
continue
self_bank = int(m.group(1))
for n, line in enumerate(asm.read_text(errors='replace').splitlines(), 1):
c = CALL_RE.match(line)
if not c:
continue
target = c.group(2)
if target in IGNORE:
continue
tb = banks.get(target)
if tb is None or tb == 0 or tb == self_bank:
continue
bad.append((asm.name, n, self_bank, target, tb, line.strip()))
bad_ptr = check_bank_local_pointers(build, banks)
if not bad and not bad_ptr:
print('check_bank_calls: чисто — ни прямых вызовов в чужой банк, '
'ни указателей на данные своего банка наружу')
return 0
if bad:
print('check_bank_calls: ПРЯМОЙ ВЫЗОВ В ЧУЖОЙ БАНК '
'(объявить цель __banked либо свести модули в один банк):',
file=sys.stderr)
for name, line_no, sb, target, tb, text in bad:
print(f' {name}:{line_no}: банк {sb} -> {target} (банк {tb}): '
f'{text}', file=sys.stderr)
if bad_ptr:
print('check_bank_calls: ВНИМАНИЕ — похоже, указатель на данные '
'своего банка уходит в чужой (проверить глазами; лечится '
'переносом данных в W2: стек, _DATA или копия):',
file=sys.stderr)
for name, line_no, sb, label, target, tb in bad_ptr:
print(f' {name}:{line_no}: банк {sb}: #{label} -> {target} '
f'(банк {tb})', file=sys.stderr)
# Сборку валит ТОЛЬКО проверка 1: она доказательна. Проверка 2 —
# эвристика по форме кода, её дело предупредить, а не останавливать.
return 1 if bad else 0
def bank_local_labels(lines):
"""Метки ДАННЫХ, определённые в области _BANKn этого модуля: их адреса
видны только пока банк подключён в W3. Метки функций сюда не берём
их адрес законно кладут в HL для самого трамплина."""
labels, area = set(), None
for i, line in enumerate(lines):
a = AREA_RE.match(line)
if a:
area = a.group(1)
continue
m = LABEL_RE.match(line)
if not (m and area and area.upper().startswith('_BANK')):
continue
for nxt in lines[i + 1:i + 4]: # пропускаем пустые/метки
if not nxt.strip() or LABEL_RE.match(nxt):
continue
if DATA_DIR_RE.match(nxt):
labels.add(m.group(1))
break
return labels
def check_bank_local_pointers(build, banks):
"""Адрес метки своего банка, положенный в аргументы банкового вызова
в ЧУЖОЙ банк. Ищем форму, а не поток данных (см. шапку файла)."""
bad = []
for asm in sorted(build.glob('bank*_*.asm')):
m = BANK_ASM_RE.match(asm.name)
if not m:
continue
self_bank = int(m.group(1))
lines = asm.read_text(errors='replace').splitlines()
local = bank_local_labels(lines)
if not local:
continue
for i, line in enumerate(lines):
if '___sdcc_bcall_ehl' not in line or 'call' not in line.lower():
continue
# Банк цели: ближайший `ld e,#b_...` выше вызова.
target, tbank = None, None
for j in range(i - 1, max(-1, i - 6), -1):
b = BANKARG_RE.match(lines[j])
if b:
target = b.group(1)[2:] # b_foo -> foo
tbank = banks.get('_' + target)
break
if tbank is None or tbank == self_bank:
continue
# Назад по блоку подготовки аргументов. Останавливаемся на
# первом же `call`: аргументы НАШЕГО вызова не могут готовиться
# раньше предыдущего — иначе тот бы их и потребил.
#
# Признак «указатель ушёл наружу» — адрес метки данных банка,
# который затем ПОЛОЖЕН В СТЕК как аргумент. Для банкового
# вызова это единственный способ передать указатель: HL занят
# самим трамплином. Адрес, загруженный ради индексной
# арифметики (`ld de,#tbl` + `add hl,de` + `ld a,(hl)`), в стек
# не кладётся — так отсеиваются чтения таблиц своего банка.
pushed = {} # регистр -> строка push
for j in range(i - 1, max(-1, i - ARG_WINDOW), -1):
if LABEL_RE.match(lines[j]): # начало другого блока
break
if re.match(r'^\s*call\b', lines[j], re.IGNORECASE):
break
pu = PUSH_RE.match(lines[j])
if pu:
pushed.setdefault(pu.group(1).lower(), j)
continue
a = LOADADDR_RE.match(lines[j])
if not (a and a.group(2) in local):
continue
reg = a.group(1).lower()
if reg not in pushed:
continue
# Между загрузкой адреса и push регистр не должен меняться:
# иначе в стек уходит не адрес, а посчитанное по нему
# значение (индексная арифметика по своей таблице).
halves = {'hl': 'hl', 'de': 'de', 'bc': 'bc'}.get(reg, reg)
touched = re.compile(
r'^\s*(?:ld\s+[' + halves[0] + halves[1] + r']\s*,'
r'|ld\s+' + reg + r'\s*,'
r'|(?:inc|dec)\s+(?:' + reg + r'|[' + halves[0] +
halves[1] + r'])\b'
r'|add\s+' + reg + r'\b'
r'|ex\s+de\s*,\s*hl)', re.IGNORECASE)
if any(touched.match(t) for t in lines[j + 1:pushed[reg]]):
break
bad.append((asm.name, j + 1, self_bank, a.group(2),
target, tbank))
break
return bad
if __name__ == '__main__':
sys.exit(main(sys.argv))
+67 -12
View File
@@ -1,28 +1,64 @@
#!/bin/bash #!/bin/bash
# make_hdd.sh OUT.chd FILE1 [FILE2 ...] # make_hdd.sh [--dest DIR] OUT.chd FILE1 [FILE2 ...]
# #
# Собирает 32 МБ RAW-образ жёсткого диска (MBR + один раздел FAT16) с # Собирает 32 МБ RAW-образ жёсткого диска (MBR + один раздел FAT16) с
# указанными файлами в корне и конвертит его в CHD для MAME (монтируется # указанными файлами и конвертит его в CHD для MAME (монтируется как -hard2).
# как -hard2). Быстрая альтернатива floppy (mc.img): MAME эмулирует FDD # По умолчанию файлы кладутся в корень. --dest DIR (или HDD_DEST_DIR)
# с реальной черепашьей скоростью, HDD — в разы быстрее. # переносит всё дерево под DIR, например games/sprpop.
# #
# Требует: mtools (mpartition/mformat/mcopy) + chdman (из MAME). # Требует: mtools (mpartition/mformat/mcopy) + chdman (из MAME).
# Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено). # Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено).
# #
# Использование: # Использование:
# toolchain/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ... # toolchain/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ...
# toolchain/make_hdd.sh --dest games/sprpop out.chd prog.exe data1 ...
# toolchain/make_hdd.sh out.chd prog.exe KID:kid0.atl GUARD:g0.atl ... # toolchain/make_hdd.sh out.chd prog.exe KID:kid0.atl GUARD:g0.atl ...
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог образа # — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог относительно
# (каталог создаётся); без префикса файл идёт в корень. # DIR; без префикса файл идёт непосредственно в DIR.
set -e set -e
DEST_DIR=${HDD_DEST_DIR:-}
while [ $# -gt 0 ]; do
case "$1" in
--dest)
if [ $# -lt 2 ]; then
echo "make_hdd.sh: --dest требует путь" >&2
exit 1
fi
DEST_DIR="$2"
shift 2
;;
--dest=*)
DEST_DIR="${1#--dest=}"
shift
;;
--)
shift
break
;;
*) break ;;
esac
done
if [ $# -lt 2 ]; then if [ $# -lt 2 ]; then
echo "usage: $0 OUT.chd FILE1 [FILE2 ...]" >&2 echo "usage: $0 [--dest DIR] OUT.chd FILE1 [FILE2 ...]" >&2
exit 1 exit 1
fi fi
OUT_CHD="$1"; shift OUT_CHD="$1"; shift
# DSS использует обратный слэш, но mtools — прямой. Ведущие/замыкающие
# разделители не несут смысла: DIR всегда задаётся относительно корня HDD.
DEST_DIR="${DEST_DIR//\\//}"
while [ "${DEST_DIR#/}" != "$DEST_DIR" ]; do DEST_DIR="${DEST_DIR#/}"; done
while [ "${DEST_DIR%/}" != "$DEST_DIR" ]; do DEST_DIR="${DEST_DIR%/}"; done
case "/$DEST_DIR/" in
*/./*|*/../*|*:* )
echo "make_hdd.sh: недопустимый каталог назначения: $DEST_DIR" >&2
exit 1
;;
esac
# Геометрия: 128*16*32*512 = 32 МБ. BPS=512. # Геометрия: 128*16*32*512 = 32 МБ. BPS=512.
CYLS=128; HEADS=16; SECS=32 CYLS=128; HEADS=16; SECS=32
SECTORS=$((CYLS * HEADS * SECS)) # 65536 SECTORS=$((CYLS * HEADS * SECS)) # 65536
@@ -57,6 +93,21 @@ mt() {
"$@" < /dev/null "$@" < /dev/null
} }
# mmd не создаёт родителей сам: строим путь по одному компоненту. Пустой
# путь означает корень и сохраняет прежнее поведение скрипта.
make_dir_tree() {
local path="$1" part current="" old_ifs="$IFS"
[ -z "$path" ] && return
IFS='/'
for part in $path; do
[ -z "$part" ] && continue
current="${current:+$current/}$part"
mt mmd "z:/$current" >/dev/null 2>&1 || true
done
IFS="$old_ifs"
}
# MBR + раздел 1 на весь диск (тип FAT16), затем формат FAT16. # MBR + раздел 1 на весь диск (тип FAT16), затем формат FAT16.
echo "--- разметка и формат образа ---" echo "--- разметка и формат образа ---"
mt mpartition -I -c -t $CYLS -h $HEADS -s $SECS z: mt mpartition -I -c -t $CYLS -h $HEADS -s $SECS z:
@@ -64,17 +115,21 @@ mt mformat z:
echo "--- копирование файлов ---" echo "--- копирование файлов ---"
make_dir_tree "$DEST_DIR"
DEST_ROOT="z:${DEST_DIR:+/$DEST_DIR}"
# Аргумент может быть либо файлом (кладём в корень), либо парой # Аргумент может быть либо файлом (кладём в корень), либо парой
# КАТАЛОГ:файл — тогда каталог создаётся на образе и файл кладётся туда. # КАТАЛОГ:файл — тогда каталог создаётся относительно DEST_ROOT и файл
# Имена на Sprinter/DSS — 8.3, каталоги тоже (KID, GUARD, BG, LEVELS...). # кладётся туда. Имена на Sprinter/DSS — 8.3.
for f in "$@"; do for f in "$@"; do
case "$f" in case "$f" in
*:*) dir="${f%%:*}"; src="${f#*:}" *:*) dir="${f%%:*}"; src="${f#*:}"
target_dir="${DEST_DIR:+$DEST_DIR/}$dir"
# Каталог может уже быть — это не ошибка; stderr НЕ прячем, иначе # Каталог может уже быть — это не ошибка; stderr НЕ прячем, иначе
# непрошедший вопрос mtools снова окажется невидимым. # непрошедший вопрос mtools снова окажется невидимым.
mt mmd "z:/$dir" >/dev/null 2>&1 || true make_dir_tree "$target_dir"
mt mcopy -o "$src" "z:/$dir/" ;; mt mcopy -o "$src" "z:/$target_dir/" ;;
*) mt mcopy -o "$f" z: ;; *) mt mcopy -o "$f" "$DEST_ROOT/" ;;
esac esac
done done