Compare commits

...

16 Commits

Author SHA1 Message Date
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
59 changed files with 1499 additions and 516 deletions
+10 -1
View File
@@ -40,7 +40,7 @@ FLOPPY_IMG := $(MAME_DIR)/IMG/mc.img
# образ у себя и связывает его с MAME символьной ссылкой.
HDD_IMG ?= $(MAME_DIR)/IMG/test_hdd.chd
MAKE_DISK := $(MAME_DIR)/make_disk.py
MAKE_HDD := $(PROJ_ROOT)/toolchain/make_hdd.sh
MAKE_HDD ?= $(PROJ_ROOT)/toolchain/make_hdd.sh
RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file.
@@ -81,9 +81,18 @@ all: $(EXE)
# уже собранный exe (кусало: фикс bank.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)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
+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` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`.
---
<a id="final-bankcall"></a>
## FINAL-BANKCALL. Финал убивал программу: прямой вызов в чужой банк
**Симптом (пользователь, 2026-08-27).** Пройденная игра доходила до
таблицы рекордов и умирала: программа исчезала, а машина следом вставала
намертво (`di; halt` по адресу 0x0000) либо уходила в reset. Из Flex
Navigator и из голого DSS — одинаково.
**Что оказалось не при чём** (проверено, чтобы не искать заново): Flex
Navigator, звук и CBL (с выключенным звуком падало так же), потоковое
чтение победной темы, второй `open` поверх открытого файла, фейды с
клавиатурным диспетчером, чтение архива PV, межбанковый вызов
банк 10 → банк 11, переполнение банка (самый полный — BANK8, 15376 из
16384), утечка манипуляторов (`_fd_open_count` = 1) и утечка ссылок на
IM2-таблицу (`_irq_refs` = 2, как задумано).
**Как искали.** Бисекция ключом `HOF=0/2/4/5` (пропустить таблицу целиком
/ только `hof_load` / без фона / без фейдов) сузила место до
`hof_draw_rows`, а точную инструкцию дала ТРАССИРОВКА MAME на узком
участке: `trace` включалась брейкпоинтом на входе в `pop_hof_show`
(0x44AB) и выключалась на процедуре завершения процесса DSS (0x1E56).
`history` для этого не годится — её 250 записей забиваются обработчиком
прерываний, пока процессор ползёт по мусору.
**Корень.**
```
CD42: ld hl,$B8AB ← _gfx_bank
CD45: ld (hl),$58 ← gfx_set_bank(GFX_BANK_TRANSPARENT)
CD47: call $E503 ← ПРЯМОЙ вызов; по карте 0009E503 = _pop_text_map, банк 9
E503: rst $38 ← а в W3 стоит банк 10, там пустой хвост (0xFF)
```
`pop_ui.h` объявлял группу `pop_text_*_mapped` БЕЗ `__banked`. Пока
`pop_hof.c` лежал в банке 9 рядом с `pop_ui.c`, прямой `call` был верен.
Когда `pop_hof`/`pop_config`/`pop_pal` перенесли в банк 10 ради разгрузки
банка 9, тот же `call` стал уходить в пустоту. Процессор полз по 0xFF до
0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс: закрывает его
файлы, возвращает контекст (байт 0x1E48: 2 = шелл, 3 = наша программа) и
восстанавливает страницы родителя. Именно это выглядело как «W2 подменили
у нас под ногами» — на самом деле подмена была уже уборкой трупа.
**Фикс.** Группа помечена `__banked`; появилась
`toolchain/check_bank_calls.py`, встроенная в `app.mk` и валящая сборку
(проверено намеренной поломкой). Правило записано в `CLAUDE.md`.
---
<a id="final-hof-garbage"></a>
## FINAL-HOF-GARBAGE. Таблица рекордов заливалась знаками вопроса
**Симптом.** После фикса FINAL-BANKCALL экран HOF рисовался правильно, но
с началом ввода имени строку заливало `?` во всю ширину.
**Корень — вторая мина того же класса, созданная первым фиксом.** Курсор
рисовался литералом:
```c
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); /* ___str_3 в _BANK10 */
```
Пока `pop_text_draw_mapped` был в том же банке, литерал был виден. После
пометки `__banked` трамплин на время вызова переключает W3 на банк 9 —
указатель показывает в чужой банк, функция читает оттуда байты до первого
нуля и рисует их знаками вопроса. Сходится и с наблюдением пользователя
«сначала рисуется правильно, потом мусор»: строка времени берётся из
локального массива (стек, W2 — виден всем), а мусор начинается с мигания
курсора.
**Фикс.** Курсор строится на стеке. Вторая проверка
`check_bank_calls.py` предупреждает о таких случаях (проверено: на сборке
с возвращённым литералом даёт ровно одно срабатывание).
---
<a id="kbd-arrow-phantom"></a>
## KBD-ARROW-PHANTOM. Залипшая стрелка вешала ожидания насмерть
**Симптом.** После игры (особенно 14-й уровень — он почти весь проходится
удержанием Left) текстовые экраны переставали прерываться, а «новая игра»
вставала намертво: `pop_new_game_load` начинается с
`while (kbd_raw_any_down())`. При этом ввод имени в HOF работал —
он опрашивает конкретные коды, а не всю карту.
**Корень (подтверждён дважды: 0x72 24.08, 0x6B 27.08).** Стрелка идёт по
проводу как `E0 6B`. При переполнении 3-байтового FIFO SIO теряется
префикс, make садится в PLAIN-половину карты как код нумпада (0x6B = KP4),
а break приходит уже с префиксом и снимает бит в EXT-половине. PLAIN-бит
остаётся зажатым навсегда, `kbd_raw_any_down()` отвечает «да» вечно.
Доказано вмешательством: запись 0 в этот байт отладчиком мгновенно
перевела состояние 5 (LEVEL_LOAD) → 6 (PLAYING).
**Фикс.** `kbd_raw_keypad_as_ext()` в libc: `kbd_raw_sync()` переносит
биты голых кодов нумпада в EXT-половину и гасит в PLAIN — make и break
начинают работать с одним битом. В PS/2 это и есть одни и те же
физические клавиши, поэтому нумпад заодно стал управлением (7/8/9, 4/6,
2 и 5 — вниз). НЕ лечит обратный случай (префикс потерян у break) — там
бит стоит уже в EXT и выглядит как реально зажатая клавиша.
---
<a id="f10-gameplay"></a>
## F10-GAMEPLAY. Выход по F10 не работал в игре
F10 проверяется в idle-хуке, а хук висит на `gfx_wait_vsync`. Когда
игровой цикл перевели на пейсинг по лучу, он стал ждать через
`pop_wait_edge`, который звал `kbd_raw_poll` напрямую — хук не вызывался,
и F10 в геймплее умер (в заставках и меню работал). Фикс: `pop_wait_edge`
зовёт тот же `pop_idle`.
+3 -1
View File
@@ -3,6 +3,8 @@
PROJ_ROOT := $(abspath $(CURDIR)/../../..)
EXAMPLE := roomtest
HDD_IMG := $(CURDIR)/build/hdd/roomtest.chd
# small: W1+W2 как единое 32КБ-пространство, DATA сразу за CODE (иначе
# CODE ~23КБ переполняет W1 и лезет в фиксированный DATA@0x8000 в huge).
MEMORY ?= huge # small-раскладка + банки кода в W3 (стражи)
@@ -88,7 +90,7 @@ EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) \
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
# MAME = время фазы в растровых строках.
PROF ?= 1
PROF ?= 0
PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого.
# Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1.
-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 - кнопка ломается (превращается в щебень)
/// но двери которые она должна открыть остаются закрытыми
+26
View File
@@ -155,6 +155,32 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
| `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх `<kbd_raw.h>`. |
| `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). |
## Банки: две мины, которых компилятор не видит
Обе стреляют молча, обе поймали нас 2026-08-27 (разбор — в
`../PoP/roomtest/BUGS_CLOSED.md`, поиск «FINAL-BANKCALL»).
**1. Прямой вызов в чужой банк.** Трамплин SDCC выбирает по `__banked`
в ОБЪЯВЛЕНИИ, а не по тому, где функция лежит. Функция без пометки
зовётся прямым `call` — верно ровно пока вызывающий и вызываемый в одном
банке. Перенесли модуль ради разгрузки банка — и тот же `call` уходит в
пустой хвост чужого банка, процессор ползёт по 0xFF до 0x0000, DSS убивает
процесс. Ловит `toolchain/check_bank_calls.py`, она встроена в сборку и
ВАЛИТ её (проверено намеренной поломкой).
**2. Указатель на данные своего банка, отданный в чужой.** Обратная
сторона: пока обе стороны в одном банке, указатель на литерал или
const-таблицу работает; пометили функцию `__banked` — и трамплин на время
вызова переключает W3, а указатель показывает уже в чужой банк. Так экран
таблицы рекордов залило знаками вопроса: в `pop_text_draw_mapped` (банк 9)
уходил литерал `"_"` из банка 10.
**Правило:** всё, что уходит указателем в другой банк, обязано лежать в
W2 — стек, `_DATA` или копия. `const`-таблицы и строковые литералы
банкового модуля наружу отдавать нельзя (то же, что уже записано про
`pop_tile.c`). Вторая проверка скрипта предупреждает о явных случаях, но
она эвристическая и гарантией не является.
## Порядок слоёв в кадре (важно для окклюзии)
`sprpop.c` каждый тик рисует в СКРЫТУЮ страницу: `pop_char_heal` (стереть
+20 -9
View File
@@ -34,6 +34,8 @@ EXAMPLE := sprpop
SRC_DIR := src
BUILD_DIR := build
MAKE_HDD := tools/make_hdd.sh
# huge: small-раскладка (CODE в W1, DATA в W2) + банки кода в W3.
MEMORY ?= huge
# --max-allocs-per-node: во сколько вариантов размещения регистров упирается
@@ -91,7 +93,7 @@ BANK9_SRCS := pop_menu.c pop_status.c pop_music.c pop_app.c pop_timer.c \
# стоит 51 мс, так что трамплин банк→банк на их фоне не существует.
# Отдельный банк для этого не нужен: в десятом лежал один pop_pal.c на
# 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
BANK_NUMS := 1 2 3 4 5 6 7 8 9 10 11
BANKED_SRCS := $(foreach n,$(BANK_NUMS),$(BANK$(n)_SRCS))
@@ -200,10 +202,12 @@ LVL_NUMS := 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
# три секунды. Четыре архива актёров, а не один: имена внутри наборов
# совпадают (g0.atl есть и у стража, и у скелета, и у Джафара), а константы
# индексов упаковщик делает из имени файла.
# README для игрока идёт НА ДИСК рядом с программой. Русский обязан быть в
# CP866: DSS и любой местный просмотрщик читают именно её, UTF-8 покажется
# кракозябрами. Имена в 8.3: README.TXT и README.RUS.
DISK := README.TXT README.RUS \
# README для игрока идёт НА ДИСК рядом с программой, В КОРЕНЬ. Русский
# обязан быть в CP866: DSS и любой местный просмотрщик читают именно её,
# UTF-8 покажется кракозябрами. Имена по 8.3, язык — суффиксом, а не
# расширением: расширение .TXT должно остаться, чтобы файл открывался
# просмотрщиком как текст.
DISK := README_E.TXT README_R.TXT \
BG/bg.arc BG/pop_tile.pal BG/pal_tile.pal \
KID/kid.arc KID/kid.pal KID/kid.ani \
GUARD/guard.arc SKEL/skel.arc VIZIER/vizier.arc SHADOW/shadow.arc \
@@ -226,8 +230,15 @@ PACKED_FILES := $(addprefix $(PACKED)/,$(DISK))
STAGED := $(addprefix $(BUILD_DIR)/,$(DISK))
EXTRA_DATA := $(STAGED)
# Запись вида КАТАЛОГ:файл кладёт файл в подкаталог образа, голый путь — в
# корень. Проверка на слэш ОБЯЗАТЕЛЬНА: без неё `README_E.TXT` превращался
# в `README_E.TXT:build/README_E.TXT`, то есть упаковщик заводил каталог с
# именем файла и клал файл внутрь него (поймано пользователем на готовом
# образе).
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)))
# Образ живёт в build/, а не в mame/v306/IMG — приложение автономно. Связь с
# MAME — одна символьная ссылка, ставится однократно: make mame-link.
HDD_IMG := $(CURDIR)/$(BUILD_DIR)/hdd/$(EXAMPLE).chd
@@ -417,18 +428,18 @@ $(foreach n,$(LVL_NUMS),$(eval $(call lvl_rule,$(n))))
# русского, перекодировка в CP866. iconv БЕЗ -c намеренно: потеря символа
# должна ломать сборку, а не молча портить текст (memory
# iconv_cp866_workflow; //TRANSLIT на macOS падает).
$(PACKED)/README.TXT: dist/README.txt
$(PACKED)/README_E.TXT: dist/README.txt
@mkdir -p $(dir $@)
@sed 's/$$/\r/' $< > $@
# ПОРЯДОК ВАЖЕН: CRLF ставится ДО перекодировки. BSD sed в UTF-8 локали
# отказывается работать с байтами CP866 («RE error: illegal byte sequence»),
# а с валидным UTF-8 — работает.
$(PACKED)/README.RUS: dist/README.ru.txt
$(PACKED)/README_R.TXT: dist/README.ru.txt
@mkdir -p $(dir $@)
@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-kid: $(PACKED)/KID/kid.arc $(PACKED)/KID/kid.pal $(PACKED)/KID/kid.ani
+92 -8
View File
@@ -21,7 +21,7 @@
| [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт |
| [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается |
| [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** |
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
| [PAL-L1-AFTER-INTRO](#pal-l1-after-intro) | вход в игру на уровень 1 после интро — игровая палитра не установлена (чёрный экран) | палитра/fade | открыт: воспроизведение нестабильно, корень не установлен |
@@ -32,11 +32,11 @@
| [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 |
| [MID-OVERLAY-LAYER](#mid-overlay-layer) | оверлей кромки всегда поверх плит и персонажей (у оригинала — midtable с сортировкой) | слои | открыт: нужен разбор архитектуры |
| [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) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) |
| [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | открыт: диагноз полный, симптом обойдён подгонкой (2026-08-27) |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | открыт: замер есть, лечение — удешевить кадр |
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [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>
## 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 (чит «следующий
уровень») — игра встала намертво: картинка прежнего уровня, реакции нет.
@@ -169,6 +228,13 @@ Rx-overrun не срабатывала. Гашение бита отладчи
> **Решение пользователя (2026-08-12): отложено до готовности всех уровней.**
> Механика работает, это вопрос ощущения, а не проходимости.
> **Обновление 2026-08-28.** Гипотеза «дело в клавиатурном модуле, а не в
> физике» подтвердилась ЧАСТИЧНО: найдена и устранена кража скан-кодов
> обработчиком DSS (разбор — [KBD-STUCK-WAIT](#kbd-stuck-wait)). Украденный
> make Shift'а или стрелки — это ровно «нажал, а не сработало», и на
> железе при удержании клавиши такое случалось раз в ~10 с. Перепроверить
> ощущение зацепа на новой сборке ДО того, как лезть в физику.
**Наблюдение (пользователь).** Вход на уровень 7: Кид влетает в комнату сверху
и, пролетая мимо края пола, МОЖЕТ за него зацепиться — но получается заметно
реже, чем в оригинале. «Похоже, это проблемы нашего клавиатурного модуля».
@@ -1140,7 +1206,15 @@ fade_in по построению пишет все 256 записей из сн
---
<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()` зонды показали
`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>
## 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 порции насоса,
то есть 16,42 с реального времени. За это время шкала сцены прошла
+44
View File
@@ -89,3 +89,47 @@ raw-клавиатура (`<kbd_raw.h>`), которую мы и так заби
после выхода — заметный. Делать после того, как закроем стражей и
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
возможный возврат `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`),
и трогать его ради экономии секунды стоит только на спокойную голову.
**Выигрыш.** Одна-две секунды один раз на уровень.
+37
View File
@@ -647,3 +647,40 @@ Enter/Shift — либо оставить как есть уже осознан
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
стрелка перезапускает уровень мгновенно), и не раньше `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+S: Sound on/off.
* Ctrl+M: Music on/off.
* Ctrl+V: Show version of SprPoP.
* Ctrl+Q / F10: Quit game.
* F6: Quicksave: Save the exact state of the game.
+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
+2 -1
View File
@@ -12,6 +12,7 @@
#include <string.h>
#include <sprinter_mem.h>
#include "pop_arc.h"
#include "_pop_file.h"
/* Пути 8.3, как требует DSS. Держим их ЗДЕСЬ: строка обязана лежать в том
* же банке, что и код, который её читает (см. pop_arc.h). */
@@ -42,7 +43,7 @@ int8_t pop_arc_open(pop_arc_t *a, const char *path,
a->fd = -1;
a->count = 0;
fd = open(path, O_RDONLY);
POP_PATH_CALL(fd, open(path, O_RDONLY));
if (fd < 0) return -1;
if (read(fd, head, sizeof(head)) != (int)sizeof(head) ||
memcmp(head, "PBA1", 4) != 0 ||
+4 -3
View File
@@ -29,6 +29,7 @@
#include "pop_cheat.h"
#include "pop_sfx.h"
#include "_pop_config.h"
#include "_pop_file.h"
#define CFG_BASE 0x100
@@ -151,7 +152,7 @@ static uint8_t cfg_read_file(uint8_t *buf, uint16_t *size)
int fd, got;
long n;
fd = open("POP.CFG", O_RDONLY);
POP_PATH_CALL(fd, open("POP.CFG", O_RDONLY));
if (fd < 0) return 0;
n = lseek(fd, 0, SEEK_END);
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 (buf[9] != POP_PROFILE_VANILLA)
printf("POP.CFG: ENHANCED profile -> VANILLA\n");
puts("POP.CFG: ENHANCED profile -> VANILLA\n");
return 1;
}
@@ -192,7 +193,7 @@ int8_t pop_cfg_save(void) __banked
gfx_w0_unmap();
/* Не unlink: bank_save_file сам делает O_TRUNC и не создаёт лишнего
* окна, когда 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);
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_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)
{
@@ -673,7 +679,7 @@ static void read_input(void)
uint8_t l = (uint8_t)(kbd_raw_down(KBD_LEFT) || home);
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 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_y = d ? CONTROL_HELD_DOWN : (u ? CONTROL_HELD_UP : CONTROL_RELEASED);
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_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();
}
+41 -12
View File
@@ -31,6 +31,7 @@
#include "pop_title.h" /* титульная картинка после ввода имени */
#include "pop_music.h" /* подкачка потоковой темы победы */
#include "pop_hof.h"
#include "_pop_file.h"
/* MAX_HOF_COUNT оригинала — шесть строк. */
#define HOF_COUNT 6
@@ -162,7 +163,7 @@ static void hof_load(void)
if (!blk) return;
page = mem_get_page(blk, 0);
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) {
gfx_w0_map(page);
for (i = 0; i < HOF_SIZE; i++)
@@ -187,7 +188,11 @@ static void hof_save(void)
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);
}
@@ -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;
}
/* 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();
}
@@ -409,8 +428,8 @@ static const uint8_t hof_key_code[30] = {
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,
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;
} else if (i == 28 || i == 29) {
previous[i] = down;
return 1;
return (uint8_t)(i == 28 ? 1 : 2); /* Enter : Esc */
}
}
previous[i] = down;
@@ -446,7 +465,8 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
* input_str) — в кадрах луча это HOF_BLINK. */
#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 cursor = 1, phase = 0;
@@ -459,10 +479,14 @@ static void hof_edit(uint8_t row)
for (i = 0; i < sizeof(previous); i++)
previous[i] = kbd_raw_down(hof_key_code[i]);
for (;;) {
uint8_t done;
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");
return;
return 0;
}
if (changed) { cursor = 1; phase = 0; }
if (++phase >= HOF_BLINK) {
@@ -496,8 +520,13 @@ void pop_hof_show(void) __banked
if (hof_page_build(page, row) == 0) {
pop_screen_present(page);
(void)pop_pal_fade_in_skip(HOF_FADE);
hof_edit(row);
hof_save();
/* ОТКАЗ ПО Esc — РАСХОЖДЕНИЕ С ОРИГИНАЛОМ (docs/impl_diff.md).
* SDLPoP крутит `while (input_str(...) <= 0)`, то есть уйти без
* имени нельзя вовсе. Мы разрешаем: тогда таблицу перечитываем
* с диска (hof_insert_current уже сдвинул строки, и откатывать
* их дешевле всего именно так) и НЕ сохраняем. */
if (hof_edit(row)) hof_load();
else hof_save();
/* Имя вписано: строку показываем уже обычным золотым текстом,
* без полосы (restore_peel + show_hof_text оригинала). */
page = pop_screen_begin();
+144 -111
View File
@@ -83,26 +83,24 @@ enum {
PV_MUS_4_LOAD = PV_DIALOG1_START + 54, /* подкачка m52 */
PV_WALK2_START = PV_DIALOG1_START + 176,
PV_DIALOG2_START = PV_DIALOG1_START + 416,
/* ЗАКЛИНАНИЕ СДВИНУТО РАНЬШЕ СЦЕНАРИЯ — ПОДГОНКА ПОД МУЗЫКУ.
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* Сцена render-bound: кадр рисуется дороже своего бюджета, кадровая
* ветка пейсинга догона не имеет, и анимация идёт ~49 тиков в секунду
* вместо 60. Реплика Джафара при этом играет по РЕАЛЬНОМУ времени и
* успевает кончиться, когда шкала стоит на 1854 вместо 2028 (замер в
* MAME 2026-08-27 зондом на конец трека: длина насосу отдана полная,
* 1403 порции, курсор дошёл до последней страницы — трек доигрывает
* целиком, отстаёт именно картинка).
* История: сцена была render-bound кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Из-за этого кода реплики приходила примерно на секунду раньше
* молнии. Честное лечение — удешевить кадр сцены; до тех пор двигаем
* жест целиком (замах, шаг назад, вспышка), чтобы он не сломался
* внутри себя. Величина подобрана НА СЛУХ и уточняется прогоном:
* расчётные полсотни тиков (секунда отставания при ~49 тиках/с) на
* слух почти не изменили картины, поэтому взято вдвое больше.
*
* Эффектов дверей это не касается: у них и звук, и картинка идут по
* одной и той же шкале и разъехаться не могут. */
PV_MAGIC_LEAD = 100,
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам — кадрам луча, —
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте — если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
PV_MAGIC_LEAD = 0,
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,
@@ -430,6 +428,44 @@ static int intro_pv_draw_base(void)
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)
{
const uint8_t *img;
@@ -701,12 +737,10 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
uint8_t sand_frame, uint8_t flash,
uint16_t tick)
{
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
/* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */
pv_blit_floor(torch, torch_left, 93, 116);
@@ -730,7 +764,7 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
* его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед
* правой передней колонной. */
pv_blit_floor(hourglass, 0, 240, 167);
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back);
if (flash) {
uint8_t pulse;
@@ -743,9 +777,9 @@ static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
pop_pal_flash_white(0);
gfx_wait_vsync();
}
return 11;
return 13; /* три интервала кадра + десять на молнию */
}
return 1;
return 3; /* сколько кадровых интервалов съели */
}
/* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот,
@@ -759,12 +793,7 @@ static int intro_pv_animated(void)
uint16_t tick;
uint16_t frame_end;
uint16_t render_tick;
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;
uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
uint8_t period;
uint8_t anim_step = 0;
uint8_t torch_left = 1;
@@ -813,18 +842,22 @@ static int intro_pv_animated(void)
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
* бандами по 16 строк. */
(void)pop_sfx_start();
/* ЧЕМ МЕРИТЬ ВРЕМЯ. Пока сцена была немой, кадры просто считали
* ожидания vsync — и этого хватало. С музыкой выяснилось, что так
* считаются НАШИ ожидания, а не прошедшее время: полноэкранная копия
* страницы (~0,55 млн тактов) съедает больше кадра луча, и сцена
* незаметно отстаёт — в MAME молния била уже ПОСЛЕ конца реплики.
* Часы берём у насоса CBL: он идёт от расхода буфера железом, 85,4 Гц,
* и ему безразлично, чем занят главный цикл. Если звук выключен,
* часы стоят — тогда работает прежний путь по кадрам луча. */
snd_prev = pop_snd_tick;
gfx_wait_vsync();
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */
snd_prev = pop_snd_tick;
/* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
*
* Была вторая шкала — тики насоса CBL, и выбор между ними делался
* гонкой на старте сцены (одна выборка `pop_snd_tick` через кадр:
* успел ли диск раскрутить звук). Значит от прогона к прогону сцена
* шла то по одним часам, то по другим, и кода реплики приходилась
* каждый раз на другое место картинки (наблюдение пользователя
* 2026-08-28; баг SND-PACE-DEAD). Насос как часы был нужен потому,
* что кадр сцены рисовался ДОЛЬШЕ своего интервала и счёт наших
* ожиданий врал; теперь отрисовка разложена на блоки по кадровому
* интервалу (см. pv_restore_bg), и счёт кадров луча честен.
*
* Осталось учесть единственную работу, которая в интервал не влезала
* сама, — подкачку страницы трека: шаг читает ПОЛСТРАНИЦЫ (~16 мс,
* pop_music_load_step) и оплачивается ровно одним интервалом, как
* любой другой блок кадра. */
result = 0;
princess_actor.x = 120;
princess_actor.frame = 11; /* Pstand, facing left */
@@ -892,59 +925,42 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
*
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это
* Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем»
* (snd_want подтягивается к snd_done), и цикл ожидания выходит
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд,
* то есть одиннадцать страниц m53 не успевали за всю сцену —
* pop_music_play() на своём тике не находил готового трека, и
* реплики Джафара пропадали совсем.
* не было: кадр дорогой, шкала работает по принципу «долг не
* догоняем», и цикл ожидания выходил сразу. Замер в MAME
* 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
* страниц m53 не успевали за всю сцену — pop_music_play() на своём
* тике не находил готового трека, и реплики Джафара пропадали
* совсем (memory pv_music_stall_regression).
*
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому
* один безусловный шаг в кадр сцену не ломает: он лишь тратит
* часть той же паузы, а если паузы не было — добавляет к кадру
* четверть его длины, и шкала это учтёт сама. */
if (pop_music_loading()) (void)pop_music_load_step();
* Поэтому шаг безусловный — и оплачен как обычный блок кадра: он
* читает полстраницы (~16 мс), а следующий gfx_wait_vsync ставит
* нас на край интервала, который мы этим и заняли. */
if (pop_music_loading()) {
(void)pop_music_load_step();
gfx_wait_vsync();
beam++;
}
/* Первый кадр собран под чёрной палитрой — теперь проявляем его
* целиком, вместе с принцессой и факелами. */
if (!faded_in) {
faded_in = 1;
if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
}
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без
* деления в кадре и без 32-битной арифметики. */
if (snd_pace) {
snd_acc += (uint16_t)period * 57u;
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 {
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 6/8/7 тиков по
* 1/60 с (pv_seq_period), в кадры луча переводит POP_T60, и цель
* КУМУЛЯТИВНА: нецелые 6,67 и 5,83 не копят ошибку.
*
* ДОЛГ НЕ ДОГОНЯЕМ: если кадр вышел дороже своего бюджета, цикл
* ожидания просто не сработает, но beam уже учёл потраченное — и
* следующая цель считается от той же общей шкалы. Наверстывать
* пропущенное нельзя: события соседних тиков (створка ворот, дверь
* покоев) слиплись бы в один кадр, и один из звуков пропал бы. */
{
uint16_t target = POP_T60(tick);
while (beam < target) { gfx_wait_vsync(); beam++; }
@@ -995,7 +1011,6 @@ static uint8_t pre_hourglass_state(void)
static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
{
atlas_t hourglass, torch;
gfx_rect_t full;
uint8_t torch_left = 1, torch_right = 6, sand = 0, frame;
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
intro_skip_t skip;
@@ -1025,7 +1040,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
pop_screen_present(1);
result = 0;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
intro_skip_begin(&skip);
for (frame = 0; frame < frames; frame++) {
uint8_t back, hold, star;
@@ -1033,7 +1047,7 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
if (intro_skip_requested(&skip)) { result = 1; break; }
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
gfx_copy_page(&full, GFX_COPY_DIRECT);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&torch, torch_left, 93, 116);
pv_blit_floor(&torch, torch_right, 211, 116);
@@ -1046,10 +1060,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, 0, 240, 167); /* передняя колонна */
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: живой фон */
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 идут по
* пустой комнате, а часы и пламя возникают уже на свету. */
@@ -1128,8 +1143,14 @@ static void intro_restore_game_palette(void)
/* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1,
* 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */
#define CUT_PV2_SPLIT 17
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен
* (cutscene_frame_time SDLPoP; см. pre_room_animated). */
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен: у
* оригинала `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
static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch;
@@ -1290,11 +1311,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)
{
gfx_rect_t full;
uint16_t i;
uint8_t hold;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
for (i = 0; i < frames; i++) {
uint8_t back;
kframe kid_fr, pr_fr;
@@ -1316,8 +1335,9 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
/* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются
* банком спрайтов и потому в неё не оседают. */
gfx_copy_page(&full, GFX_COPY_DIRECT);
* банком спрайтов и потому в неё не оседают. Двумя блоками по
* кадровому интервалу — см. pv_restore_bg. */
pv_restore_bg(); /* блоки 1-2 */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&cut_torch, cut_torch_l, 93, 116);
pv_blit_floor(&cut_torch, cut_torch_r, 211, 116);
@@ -1339,12 +1359,17 @@ static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
* сквозь неё — поймано пользователем 2026-08-25). */
pv_blit_floor(&cut_hg, 0, 240, 167);
gfx_set_bank(GFX_BANK_NORMAL);
gfx_wait_vsync();
gfx_wait_vsync(); /* блок 3: актёры и декорации */
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_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_r = (uint8_t)((cut_torch_r + 1) % 9);
cut_sand = (uint8_t)((cut_sand + 1) % 3);
@@ -1662,7 +1687,9 @@ uint8_t pop_intro_show(void) __banked
/* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине:
* дальше внутри сцены остаётся только пуск, а следующие две реплики
* подкачиваются по странице за кадр. */
pop_sfx_pause();
/* Глушение снято 2026-08-28: массовое чтение страниц насос не морит
* (замер на загрузке уровня — ни одной пропущенной порции), а трек
* титров может и должен доигрывать. */
(void)pop_music_load(POP_MUS_STORY_2);
pv_result = intro_pv_animated();
if (pv_result > 0) {
@@ -1740,15 +1767,21 @@ uint8_t pop_intro_show(void) __banked
* дальше, а не чтобы досидеть до конца трека. */
static void pre_cut_finish(uint8_t aborted)
{
if (!aborted) {
intro_skip_t tail;
intro_skip_begin(&tail);
while (pop_music_busy() && !intro_skip_requested(&tail))
pop_wait_edge();
/* ТРЕК ПЕРЕЖИВАЕТ СЦЕНУ — как в оригинале. load_intro (seg001:690)
* ничего не ждёт: гасит экран и возвращает управление, а звук играет
* дальше поверх загрузки уровня и первых секунд игры. Мы вместо этого
* ДОСИЖИВАЛИ трек на чёрном экране (27 — 10,7 с против 2,6 с картинки),
* то есть дарили игроку восемь секунд пустого экрана, и только потом
* глушили. ЗАМЕР 2026-08-28: снято вместе с глушением на загрузке
* уровня; если загрузка не укладывается в дедлайн насоса (251 000
* тактов), вернуть оба места разом.
*
* Пропуск сцены по-прежнему обрывает и музыку: игрок нажал клавишу,
* чтобы идти дальше, а не чтобы дослушать. */
if (aborted) {
pop_music_free();
pop_sfx_pause();
}
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
}
@@ -1756,7 +1789,7 @@ void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
{
const pop_cs_cmd_t *script = 0;
pop_sfx_pause();
/* Глушение перед чтением трека снято 2026-08-28 (см. замер насоса). */
/* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих
* сцен (27 — 10,7 с против 2,6 с картинки), и оригинал их не обрывает:
* load_intro просто гасит экран и возвращает управление, а звук
@@ -1824,7 +1857,7 @@ int pop_intro_hof_bg(uint8_t page) __banked
void pop_time_expired_show(void) __banked
{
pop_sfx_pause();
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* time_expired (seg001:04D3) начинается с play_sound(sound_36). */
if (pop_music_load(36) == 0) {
(void)pop_sfx_start();
@@ -1853,7 +1886,7 @@ void pop_time_expired_show(void) __banked
void pop_ending_show(void) __banked
{
pop_sfx_pause();
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который
* начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка
* ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает»
+3 -1
View File
@@ -18,6 +18,7 @@
#include "pop_guard.h" /* pop_guard_vflip_load — зеркала соперника */
#include "pop_arc.h"
#include "kid_arc.h"
#include "_pop_file.h"
/* Серии в архиве обязаны лежать подряд — так их кладёт список в Makefile,
* и только поэтому индекс считается сложением. Если порядок собьётся,
@@ -75,7 +76,8 @@ int pop_kid_data_load(const char *path) __banked
blk=mem_alloc_pages(1);
if (!blk) return -1;
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)) {
mem_free_block(blk); return -1;
}
+21 -3
View File
@@ -18,6 +18,8 @@
#include "pop_map.h" /* pop_clip_char_top (clip_char) */
#include "pop_geom.h"
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include "pop_music_ticks.h" /* длина мелодии конца уровня */
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
@@ -36,6 +38,14 @@ int8_t knock;
* оригинале, чтобы читы «следующий/предыдущий уровень» (levels_plan.md §4)
* легли сюда же без переделки. */
uint8_t pop_next_level;
/* СКОЛЬКО ЛОГИЧЕСКИХ КАДРОВ ЕЩЁ ДЕРЖАТЬ УРОВЕНЬ, пока играет мелодия конца.
* У оригинала (seg003:387, play_level_2) уровень не сменяется, пока
* `check_sound_playing()`: экран пройденного уровня живёт с анимацией
* факелов ровно столько, сколько звучит трек. У нас признак «звучит» —
* pop_music_busy(), но он ложен при ВЫКЛЮЧЕННОЙ музыке, а оригинал
* выдерживает паузу и молча. Поэтому здесь взводится счётчик по длине
* записи (gen/pop_music_ticks.h), а главный цикл ждёт большего из двух. */
uint8_t pop_endmus_left;
/* ---- Атласы Kid (прямая адресация image>>3) ------------------------ *
@@ -358,9 +368,17 @@ void play_seq(void)
else if (ev == 4) {
/* seg006:646. На четвёртом уровне уход за дверь — это
* встреча с тенью, и трек там свой; на 13-м (Джафар) и
* 15-м оригинал молчит. */
if (pop_current_level == 4) pop_sfx_play(32);
else if (pop_current_level != 13) pop_sfx_play(41);
* 15-м оригинал молчит. Вместе с треком взводим
* задержку смены уровня (см. pop_endmus_left): кадров
* = длина записи в тиках -> кадры луча -> делённое на
* длину логического кадра (4 кадра луча вне боя). */
if (pop_current_level == 4) {
pop_sfx_play(32);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_32) / 4);
} else if (pop_current_level != 13) {
pop_sfx_play(41);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_41) / 4);
}
}
}
break;
+2
View File
@@ -26,6 +26,8 @@ extern int8_t knock;
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
* там рестарт того же уровня; загрузка следующего — levels_plan.md. */
extern uint8_t pop_next_level;
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
extern uint8_t pop_endmus_left;
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 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);
/* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять
* `LEVELS\res20NN.bin` (fallback `a:\res20NN.bin`) и выставить
* `LEVELS\res20NN.bin` относительно каталога EXE и выставить
* pop_current_level. 0 — OK, -1 — файла нет / не влез (тогда
* pop_current_level и страница НЕ тронуты — играем дальше на старом).
* Порт связки 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_geom.h" /* pop_x_bump / FIRST_ONSCREEN_COLUMN — pos_guards */
#include "_pop_file.h"
/* Низкоуровневое чтение через W3 выполняет резидентный bank_load_file();
* остальная одноразовая подготовка уровня безопасно остаётся в банке 8. */
@@ -58,7 +59,8 @@ static int level_read_file(const char *path)
blk=mem_alloc_pages(1);
if (!blk) return -1;
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; }
gfx_w0_page_prepare(page);
gfx_w0_map(page);
@@ -80,18 +82,17 @@ int pop_level_load_num(uint8_t n) __banked
/* Имена патчим на месте, а не через sprintf: printf-семейство тянет в
* резидент W1 форматтер, которого здесь больше негде применить. */
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;
if (n > POP_LEVEL_LAST) return -1;
pri[12] = alt[8] = (char)('0' + n / 10);
pri[13] = alt[9] = (char)('0' + n % 10);
pri[12] = (char)('0' + n / 10);
pri[13] = (char)('0' + n % 10);
/* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой:
* level_read_file сам аллоцирует страницу, и если файла нет — остаёмся
* на текущем уровне вместо падения в пустой уровень. */
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;
return -1;
}
+62 -68
View File
@@ -27,6 +27,7 @@
#include "pop_settings.h"
#include "pop_cfg.h"
#include "pop_qsave.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца, см. ui_wait_frame */
#include "pop_status.h"
#include "pop_sfx.h"
@@ -196,10 +197,21 @@ static uint8_t ui_begin(void)
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
/* Полностраничная accel-копия держит DI дольше половины CBL-буфера.
* Закрываем вывод, иначе железо повторяет старые 128 байт (скрежет).
* ui_end откроет его уже с заново заполненной тишиной. */
pop_sfx_pause();
/* ЗВУК ЗДЕСЬ БОЛЬШЕ НЕ ГЛУШИМ (снято 2026-08-28).
*
* Был вызов pop_sfx_pause() с обоснованием «полностраничная accel-копия
* держит 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);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
@@ -207,10 +219,32 @@ static uint8_t ui_begin(void)
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)
{
pop_wait_edge(); /* готовый кадр показать только на фронте */
ui_wait_frame(); /* готовый кадр показать только на фронте */
gfx_set_visible_page(back);
/* Парный к снятому pop_sfx_pause (см. ui_begin) вызов оставлен: он
* идемпотентен (открытый CBL возвращает 0 сразу) и чинит случай, когда
* в меню вошли при закрытом выводе например сразу после загрузки
* уровня, которая глушит звук намеренно. */
(void)pop_sfx_start();
}
@@ -227,7 +261,7 @@ static void ui_wait_release(void)
kbd_raw_down(KBD_SPACE) ||
kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) ||
kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT))
pop_wait_edge();
ui_wait_frame();
}
/* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и
@@ -250,7 +284,7 @@ static void ui_wait_ack(void)
return;
}
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);
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;
if (!changed) return 0;
pop_settings_apply(1);
/* ESTEX может уйти в диск на много CBL-периодов. */
pop_sfx_pause();
/* Звук не глушим: POP.CFG — десятки байт, а на практике HOF пишется
* под звучащий «won» и насос это переживает (проверка пользователя
* 2026-08-28). Прежняя осторожность «ESTEX может уйти в диск на много
* CBL-периодов» относилась к загрузке НАБОРА страниц, а не к записи
* одного мелкого файла. */
rc = pop_cfg_save();
(void)pop_sfx_start();
ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR");
return 1;
}
@@ -609,17 +645,16 @@ static uint8_t settings_process(void)
settings_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
ui_wait_frame();
}
}
static int8_t menu_quickload(void)
{
uint8_t slots;
/* probe делает open/close на D:, поэтому насос в это время выключен. */
pop_sfx_pause();
/* probe — это только open/close на D:, данных не читает; глушить насос
* ради него незачем (см. settings_finish). */
slots = pop_qsave_probe();
(void)pop_sfx_start();
if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD;
if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?"))
return POP_MENU_QUICKLOAD_BACKUP;
@@ -641,65 +676,18 @@ static void menu_erase(uint8_t entry_page)
/* Из shadow видимой страницы берём кадр без текстов. */
gfx_set_draw_page(hidden);
gfx_copy_page(&full, GFX_COPY_DIRECT);
pop_wait_edge();
ui_wait_frame();
gfx_set_visible_page(hidden);
/* Теперь чистая hidden — источник для второй страницы. */
gfx_set_draw_page(visible);
gfx_copy_page(&full, GFX_COPY_DIRECT);
if (entry_page != hidden) {
pop_wait_edge();
ui_wait_frame();
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
{
@@ -726,13 +714,18 @@ int8_t pop_menu_process(void) __banked
/* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие
* закроет меню раньше первого кадра. 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();
saved_page = gfx_get_draw_page();
saved_bank = gfx_get_bank();
getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
/* Звук НЕ глушим (снято 2026-08-28, вторая половина той же заплатки —
* см. ui_begin). Здесь под ней были снимок палитры, затемнение фона и
* первая полная перерисовка: копия режет DI бандами по 16 строк, а
* палитровое затемнение идёт и в титрах с катсценами, где музыку оно не
* рвёт. Цена глушения была слышна как пауза в мелодии ровно на
* появление меню. */
pop_pal_snapshot();
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
@@ -789,11 +782,12 @@ int8_t pop_menu_process(void) __banked
menu_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
ui_wait_frame();
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 вернул обе страницы из тени, снятой ДО меню: всё, что
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в
-1
View File
@@ -40,6 +40,5 @@ int8_t pop_menu_process(void) __banked;
/* Экран Sprinter build, если он включён в POP.CFG. Вызывается после
* открытия raw-клавиатуры и до первого игрового кадра. */
void pop_build_info_show(void) __banked;
#endif
+22 -7
View File
@@ -17,6 +17,7 @@
#include "pop_music_tbl.h"
#include "pop_sfx.h"
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h"
uint8_t pop_mus_page[POP_MUS_PAGES];
@@ -49,6 +50,7 @@ static uint8_t ld_busy;
static int ld_fd;
static uint8_t ld_slot;
static uint8_t ld_next; /* следующая страница к чтению */
static uint8_t ld_half; /* 0 = впереди первая половина страницы */
static uint8_t ld_pages;
static uint8_t ld_id;
@@ -110,13 +112,14 @@ void pop_music_free(void) __banked
* ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и
* следующую надо читать, пока звучит текущая. Трек до 320 КБ, целиком
* это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров.
* Одна страница стоит 33 мс меньше четверти кадра, и подкачка растворя-
* ется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* Шаг стоит 16 мс (полстраницы, см. pop_music_load_step) меньше одного
* кадрового интервала, и подкачка растворяется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* между сценами), зовёт pop_music_load тот же цикл, но целиком. */
static void load_abort(void)
{
if (ld_busy) close(ld_fd);
ld_busy = 0;
ld_half = 0;
slot_release(ld_slot);
}
@@ -139,30 +142,42 @@ int8_t pop_music_load_begin(uint8_t id) __banked
/* Трек лежит ОДНИМ файлом, и fd остаётся открытым до последней
* страницы: `open` стоит дороже самого чтения (51,4 мс против 32,6 мс
* на 16 КБ), и платить его на каждую страницу чистые потери. */
ld_fd = open(name, O_RDONLY);
POP_PATH_CALL(ld_fd, open(name, O_RDONLY));
if (ld_fd < 0) return -1;
slot_blk[n] = mem_alloc_pages(pages);
if (!slot_blk[n]) { close(ld_fd); return -1; }
ld_busy = 1;
ld_slot = n;
ld_next = 0;
ld_half = 0;
ld_pages = pages;
ld_id = id;
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
{
uint8_t p;
if (!ld_busy) return 1; /* нечего грузить */
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();
return -1;
}
if (!ld_half) { ld_half = 1; return 0; } /* дочитаем в следующий раз */
ld_half = 0;
if (++ld_next < ld_pages) return 0;
close(ld_fd);
@@ -274,7 +289,7 @@ int8_t pop_music_ring_start(uint8_t id) __banked
pop_music_free(); /* обычные слоты кольцу не нужны */
pop_music_ring_stop();
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;
ring_fd_open = 1;
ring_blk = mem_alloc_pages(RING_PAGES);
+6 -1
View File
@@ -11,6 +11,7 @@
#include <stdint.h>
#include <gfx.h>
#include <kbd_raw.h>
#include "pop_state.h" /* pop_idle — общий idle-хук */
#include "pop_pace.h"
uint8_t pop_speed_mode; /* crt0 зануляет _DATA → NORMAL */
@@ -77,7 +78,11 @@ void pop_wait_edge(void)
t = pop_frame_tick;
do {
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);
}
+8 -6
View File
@@ -14,10 +14,12 @@
#include "pop_guard.h"
#include "pop_pal.h"
#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();
pop_ui_palette_snapshot();
}
@@ -84,7 +86,7 @@ void pop_pal_menu_dim(uint8_t fade) __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 записей и намеренно затирает эти три
* динамических диапазона (тайлсет, Тень, набор соперника).
* Восстановить их надо ДО snapshot. */
@@ -110,7 +112,7 @@ void pop_pal_level_load(uint8_t full) __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. */
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;
gfx_pal_set(0, 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
{
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_timer.h"
#include "sprpop_cold.h"
#include "_pop_file.h"
#define QS_VERSION 4
#define QS_SAVE 1
@@ -154,6 +155,7 @@ static int8_t qs_save(uint8_t page)
uint8_t tail[4];
const uint8_t h[7]={'P','O','P','Q',QS_VERSION,pop_current_level,0};
uint16_t size;
int rc;
qs_init(&io,page,QS_LIMIT,0,0);
pop_qs_raw_put(&io,h,sizeof(h));
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);
pop_qs_raw_put(&io,tail,4); size=(uint16_t)(io.pos-QS_BASE);
if (io.failed) return -1;
unlink("POP.NEW");
if (bank_save_file(page,QS_BASE,"POP.NEW",size) != size) {
unlink("POP.NEW"); return -1;
POP_PATH_CALL(rc, unlink("POP.NEW"));
POP_PATH_CALL(rc, bank_save_file(page,QS_BASE,"POP.NEW",size));
if (rc != size) {
POP_PATH_CALL(rc, unlink("POP.NEW"));
return -1;
}
unlink("POP.BAK");
(void)rename("POP.SAV", "POP.BAK");
if (rename("POP.NEW", "POP.SAV") != 0) {
(void)rename("POP.BAK", "POP.SAV"); return -1;
POP_PATH_CALL(rc, unlink("POP.BAK"));
POP_PATH_CALL(rc, rename("POP.SAV", "POP.BAK"));
POP_PATH_CALL(rc, rename("POP.NEW", "POP.SAV"));
if (rc != 0) {
POP_PATH_CALL(rc, rename("POP.BAK", "POP.SAV"));
return -1;
}
return 1;
}
@@ -178,7 +184,8 @@ static int8_t qs_load_one(const char *name, uint8_t page)
{
uint8_t level;
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,
&payload_size)) return -1;
if (level != pop_current_level) {
@@ -205,11 +212,11 @@ uint8_t pop_qsave_probe(void) __banked
page = mem_get_page(blk, 0);
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,
&payload_size))
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,
&payload_size))
slots |= POP_QS_SLOT_BACKUP;
@@ -234,14 +241,16 @@ int8_t pop_qsave_process(void) __banked
if (!blk) return -1;
page=mem_get_page(blk,0);
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);
else if (request == QS_LOAD_BACKUP) rc = qs_load_one("POP.BAK", page);
else {
rc = qs_load_one("POP.SAV",page);
if (rc < 0) rc = qs_load_one("POP.BAK",page);
}
pop_sfx_start();
mem_free_block(blk);
/* Успех ничего не меняет: нужная строка висит с начала операции.
* Отказ переписывает её на NO QUICKSAVE/NO QUICKLOAD обычной
+3 -1
View File
@@ -31,6 +31,7 @@
#include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев */
#include "pop_cdraw.h" /* pop_cd — габарит кадра Кида (y-порядок плиты) */
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */
#include "_pop_file.h"
#include <fcntl.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)
{
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 (read(fd, buf, 64) != 64) { close(fd); return -1; }
gfx_pal_load(0, POP_PAL_ENV, 16, buf);
+6
View File
@@ -19,6 +19,12 @@
* отрыв куска, снимает сам обход по факту пустой таблицы. */
/* F10 нажата — выйти из программы; см. pop_state.c. */
extern uint8_t pop_quit_req;
/* Общий idle-хук: вычерпать клавиатурный FIFO и проверить F10. Живёт в
* sprpop.c (резидент), висит на gfx_wait_vsync и вызывается из цикла
* ожидания луча обе точки ожидания обязаны звать ОДНО И ТО ЖЕ, иначе
* выход работает не везде. */
void pop_idle(void);
extern uint8_t pop_mob_busy;
/* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе
+24 -10
View File
@@ -16,6 +16,8 @@
#include "pop_arc.h"
#include "pop_ui.h"
#include "pop_status.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца в долгих фейдах */
#include "_pop_file.h"
typedef struct {
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 rc;
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;
return 0;
}
@@ -71,17 +75,17 @@ uint8_t pop_ui_ready(void)
return font_ready;
}
void pop_text_map(void)
void pop_text_map(void) __banked
{
gfx_w0_map(font_at.page);
}
void pop_text_unmap(void)
void pop_text_unmap(void) __banked
{
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];
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,
const char *s)
const char *s) __banked
{
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,
const char *s)
const char *s) __banked
{
pop_text_draw_mapped(font, center - pop_text_width_mapped(font, s) / 2,
baseline, s);
}
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),
baseline, s);
@@ -241,8 +245,10 @@ void pop_ui_palette_black(void) __banked
uint8_t *black = pop_pal_buf;
/* 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++) {
uint8_t start = (uint8_t)(chunk << 6);
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);
shown = step;
}
/* Fade — это 2,13 с, и всё это время главный цикл стоит. Кольцо
* потокового трека дочитывает pop_music_service, иначе музыка
* замолкает прямо посреди перехода (та же причина, что у меню:
* ui_wait_frame в pop_menu.c). */
pop_music_service();
gfx_wait_vsync();
if (skip) {
kbd_raw_sync();
@@ -429,6 +440,7 @@ uint8_t pop_screen_present_ltr(void) __banked
stripe.x = x;
stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4);
gfx_copy_page(&stripe, GFX_COPY_DIRECT);
pop_music_service(); /* 80 кадров перехода — см. fade_run */
gfx_wait_vsync();
}
return 0;
@@ -461,8 +473,10 @@ int pop_screen_blit_atlas_strips(const char * const *parts,
gfx_set_bank(GFX_BANK_NORMAL);
for (part = 0; part < count; part++) {
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);
}
return 0;
+16 -7
View File
@@ -27,16 +27,25 @@ void pop_ui_free(void) __banked;
uint8_t pop_ui_ready(void);
/* Группа text_*_mapped требует активного pop_text_map() и не вызывает
* BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования. */
void pop_text_map(void);
void pop_text_unmap(void);
uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s);
* BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования.
*
* ВСЯ ГРУППА __banked, И ЭТО ОБЯЗАТЕЛЬНО (крэш финала, 2026-08-27). Живёт
* она в 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,
const char *s);
const char *s) __banked;
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,
const char *s);
const char *s) __banked;
/* Снимок обеих (идентичных по контракту) палитр хранится в свободном хвосте
* 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;
}
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 i;
+1 -5
View File
@@ -26,11 +26,7 @@
#include <stdint.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);
+41 -8
View File
@@ -41,6 +41,10 @@
#include "pop_pal.h" /* lifecycle палитры title/intro/level */
#include "pop_hof.h" /* persistent Hall of Fame — bank 9 */
#include "pop_ui.h" /* чёрные обе страницы перед сменой палитры */
#include "_pop_file.h" /* cwd приложения для старых DSS */
#include <sprinter_exit.h>
#include <unistd.h>
__sfr __at (0xFE) io_border;
@@ -125,7 +129,10 @@ uint8_t pop_menu_request;
* проявление полосами, меню, игра. Одна точка вместо десятка. */
#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();
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 failed = 0;
/* До первой файловой операции запоминаем абсолютный каталог EXE.
* Без этого старый DSS теряет последний компонент cwd после OPEN пути
* с подкаталогом и следующий файл ищет уровнем выше. */
if (pop_path_init() != 0) return 1;
/* Загрузка ресурсов, палитра, первый экран — В БАНКЕ (sprpop_cold.c):
* отрабатывает ровно один раз за игру, а кода там на полкилобайта. */
pop_app_reset();
@@ -267,14 +279,15 @@ int main(void)
puts("kbd_raw open failed");
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)) {
pop_shutdown();
return 1;
}
/* MS8: CFG уже применён в pop_boot до первого показа. Экран build
* показывается только по явной пользовательской настройке. */
pop_build_info_show();
/* Экран версии при запуске БОЛЬШЕ НЕ ПОКАЗЫВАЕТСЯ (решение пользователя
* 2026-08-27): единственная его точка пункт ABOUT в меню паузы.
* Само состояние BUILD_INFO в автомате осталось: через него проходит
* маршрут BOOT -> TITLE, и убирать его ради одной пустой фазы незачем. */
if (!pop_app_dispatch(POP_APP_EV_BUILD_DONE)) {
pop_shutdown();
return 1;
@@ -353,6 +366,7 @@ int main(void)
* девелоперская сборка может до title держать дворец LEVEL=11. */
if (pop_app_state == POP_APP_DEMO) {
pop_next_level = 0;
pop_endmus_left = 0;
pop_seamless = 0;
pop_checkpoint = 0;
if (pop_level_switch() != 0) {
@@ -761,9 +775,28 @@ int main(void)
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это
* событие раз в несколько минут, а кода на нём было под сотню строк
* в самом горячем месте программы резидент W1 у нас в дефиците. */
if (!is_demo && pop_next_level != pop_current_level &&
pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
if (!is_demo && pop_next_level != pop_current_level) {
/* КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ (порт seg003:387, play_level_2):
*
* if (next_level == current_level || check_sound_playing())
* draw_game_frame(); ... // экран уровня живёт
* else { stop_sounds(); return next_level; }
*
* То есть пройденный уровень остаётся на экране с анимацией
* факелов, идущим таймером и всем прочим ровно столько,
* сколько звучит трек конца уровня; мы же уходили на смену
* уровня сразу и обрывали мелодию на первых нотах.
*
* «Звучит» проверяем двумя способами: pop_music_busy() пока
* трек играет и счётчик pop_endmus_left по длине записи (его
* взводит play_seq вместе с самим треком) иначе с
* ВЫКЛЮЧЕННОЙ музыкой паузы не было бы вовсе, а оригинал
* выдерживает её и молча. */
if (pop_endmus_left) pop_endmus_left--;
if (!pop_endmus_left && !pop_music_busy() &&
pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
}
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++;
+48 -12
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
#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
{
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;
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));
@@ -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;
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»
* пунктом меню: автомат сам разложит title/intro с первого экрана. */
{
@@ -234,7 +227,12 @@ int8_t pop_level_change(uint8_t back) __banked
* почти всегда поймано пользователем 2026-08-25. Ветка с
* катсценой свободна от этого: pop_pre_cutscene_show глушит
* звук первым действием. */
pop_sfx_pause();
/* Глушение снято 2026-08-28 (замер: загрузка уровня насос не
* морит 500 вызовов подряд, максимум 245 832 такта при
* дедлайне 251 000). Прежнее обоснование скрежет на палитре
* через BIOS ($A4 с запрещёнными прерываниями), пойманный на
* переходе без катсцены 2026-08-25. Если он вернётся, это
* единственное место, куда его надо возвращать. */
/* Без fade: старый кадр гасим сразу и обязательно даём
* показать один чёрный растр ДО диска/перерисовки. */
pop_pal_apply(POP_PAL_DIM_BLACK);
@@ -431,6 +429,13 @@ void pop_start_level(void) __banked
* гасит заставка «LEVEL N», которую этот же переход и пропускает. */
if (pop_seamless) { pop_seamless = 0; pop_kid_hp_carry(); }
else pop_kid_hp_reset();
/* ПОЛОСУ HP — НА ОБЕИХ СТРАНИЦАХ. Старт уровня перерисовал фон, то есть
* стёр её, а сама она обновляется только при ИЗМЕНЕНИИ чисел. После
* Ctrl+A с выросшим за уровень максимумом (большой кувшин, чит Shift+T)
* это давало моргание: одна страница показывала старые деления, вторая
* новые (поймано пользователем 2026-08-27). invalidate ровно для этого
* и заведён см. его шапку в pop_cdraw.c. */
pop_hp_invalidate();
}
/* ---- ОТЛАДКА: обход комнат по номеру -------------------------------- *
@@ -1146,7 +1151,7 @@ int pop_boot(void) __banked
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
pop_guard_reset();
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;
}
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
@@ -1228,7 +1233,10 @@ int pop_level_switch(void) __banked
* дальше, а урон нет (do_startpos дольёт до потолка). */
hitp_beg_lev = hitp_max;
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) {
pop_next_level = pop_current_level; /* файла нет — остаёмся */
} else {
@@ -1295,6 +1303,34 @@ int pop_level_switch(void) __banked
/* Атомарный 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();
gfx_set_visible_page(level_prepared_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. */
#define KBD_QUIT 0x15 /* Q — вместе с Ctrl */
#define KBD_RESTART 0x1C /* A — Ctrl+A, рестарт уровня */
#define KBD_VERSION 0x2A /* V — Ctrl+V, показать версию */
#define KBD_INTRO 0x2D /* R — Ctrl+R, вернуться в заставку */
#define KBD_SHOWTIME 0x29 /* Space — сколько осталось (seg000:612) */
#define KBD_TIMER 0x2C /* T — постоянный показ таймера. БЕЗ модификаторов:
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# make_hdd.sh OUT.chd FILE1 [FILE2 ...]
#
# Собирает 32 МБ RAW-образ жёсткого диска (MBR + один раздел FAT16) с
# указанными файлами в корне и конвертит его в CHD для MAME (монтируется
# как -hard2). Быстрая альтернатива floppy (mc.img): MAME эмулирует FDD
# с реальной черепашьей скоростью, HDD — в разы быстрее.
#
# Требует: mtools (mpartition/mformat/mcopy) + chdman (из MAME).
# Sprinter DSS штатно монтирует стандартный MBR+FAT16 (подтверждено).
#
# Использование:
# tools/make_hdd.sh mame/v306/IMG/test_hdd.chd prog.exe data1 data2 ...
# tools/make_hdd.sh out.chd prog.exe KID:kid0.atl GUARD:g0.atl ...
# — аргумент вида КАТАЛОГ:файл кладёт файл в подкаталог образа
# (каталог создаётся); без префикса файл идёт в корень.
set -e
if [ $# -lt 2 ]; then
echo "usage: $0 OUT.chd FILE1 [FILE2 ...]" >&2
exit 1
fi
OUT_CHD="$1"; shift
# Геометрия: 128*16*32*512 = 32 МБ. BPS=512.
CYLS=128; HEADS=16; SECS=32
SECTORS=$((CYLS * HEADS * SECS)) # 65536
# macOS mktemp требует X'ы в КОНЦЕ шаблона.
RAW=$(mktemp /tmp/sphdd.raw.XXXXXX)
MTC=$(mktemp /tmp/sphdd.mtc.XXXXXX)
trap 'rm -f "$RAW" "$MTC"' EXIT
dd if=/dev/zero of="$RAW" bs=512 count=$SECTORS status=none
# mtools-диск z: = раздел 1 нашего RAW-образа.
printf 'drive z: file="%s" partition=1\n' "$RAW" > "$MTC"
export MTOOLSRC="$MTC" MTOOLS_SKIP_CHECK=1
# mtools собран с enable-raw-term и, когда ему нужно что-то спросить,
# открывает УПРАВЛЯЮЩИЙ ТЕРМИНАЛ напрямую (/dev/tty) — в обход stdin.
# Поэтому ни `< /dev/null`, ни перенаправление stdio не спасают: из
# терминала сборка молча вставала навсегда на `mmd z:/BG` (подтверждено
# lsof: fd 0 = /dev/null, fd 4 = /dev/tty, процесс спит). Лечится только
# отсутствием управляющего терминала — запускаем mtools в новой сессии,
# тогда открыть /dev/tty нечего и mtools идёт неинтерактивным путём.
#
# setsid(1) в macOS нет, поэтому делаем то же самое питоном.
# Каналов, откуда mtools может ждать ответ, ДВА, и закрывать надо оба:
# /dev/tty — убирается новой сессией (os.setsid);
# stdin — убирается перенаправлением из /dev/null.
# Закрыть только один недостаточно: с одним лишь setsid mmd переключался на
# stdin и вис уже на нём (lsof: fd 0 = /dev/ttys002).
mt() {
python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \
"$@" < /dev/null
}
# MBR + раздел 1 на весь диск (тип FAT16), затем формат FAT16.
echo "--- разметка и формат образа ---"
mt mpartition -I -c -t $CYLS -h $HEADS -s $SECS z:
mt mformat z:
echo "--- копирование файлов ---"
# Аргумент может быть либо файлом (кладём в корень), либо парой
# КАТАЛОГ:файл — тогда каталог создаётся на образе и файл кладётся туда.
# Имена на Sprinter/DSS — 8.3, каталоги тоже (KID, GUARD, BG, LEVELS...).
mt mmd "z:/games" >/dev/null 2>&1 || true
mt mmd "z:/games/sprpop" >/dev/null 2>&1 || true
for f in "$@"; do
case "$f" in
*:*) dir="${f%%:*}"; src="${f#*:}"
# Каталог может уже быть — это не ошибка; stderr НЕ прячем, иначе
# непрошедший вопрос mtools снова окажется невидимым.
mt mmd "z:/games/sprpop/$dir" >/dev/null 2>&1 || true
echo "COPY $src to z:/games/sprpop/$dir/"
mt mcopy -o "$src" "z:/games/sprpop/$dir/" ;;
*) mt mcopy -o "$f" "z:/games/sprpop/" ;;
esac
done
echo "--- содержимое образа ---"
mt mdir -/ z:
# RAW -> CHD (без сжатия = быстро; та же геометрия).
echo "--- конвертация RAW -> CHD ---"
chdman createhd -i "$RAW" -o "$OUT_CHD" -c none \
-chs $CYLS,$HEADS,$SECS -ss 512 -f
echo "готово: $OUT_CHD"
+11 -12
View File
@@ -1,5 +1,5 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3786
accfill 3783
accop 7067
argv 3431
assrtest 3847
@@ -9,15 +9,15 @@ banked 1056
bankedbg 1067
banklocl 4683
banktest 3767
bgi_img 8680
bgitest 3751
bgi_img 8677
bgitest 3748
bios_text 4461
blitperf 5860
blitw 4939
cat 913
cblstream 5850
cbltest 5996
cblwav 6089
cblstream 5860
cbltest 6006
cblwav 6099
conio 4605
conio2 3929
convbench 3508
@@ -28,12 +28,12 @@ fdmax 6023
filetest 10547
fpsdiv 4870
gets 509
gfx_dbuf 5134
gfx_demo 4117
gfx_dbuf 5131
gfx_demo 4114
gfxbanks 6044
hello 4167
hello2 4244
irqtest 6680
irqtest 6690
kbdpoll 1282
kbdraw 4911
ls 4835
@@ -44,7 +44,7 @@ openenv 6124
pageflip 6334
palfile 5406
ptime 5744
rt_test 5187
rt_test 5197
scroll 6744
seek 4188
simple 955
@@ -59,8 +59,7 @@ strtest 1340
text_palette 5024
timedir 5454
w0page 8831
w3bankgfx 5240
w3bgfx 5246
w3bgfx 5243
w3big 3673
w3huge 3700
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-хук, не
* чейнящийся к 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
* Code Set 2 (не специфика Sprinter). TODO до использования в PoC:
* подтвердить в MAME, что реальный поток байт с SIO-A клавиатуры
@@ -46,6 +56,31 @@ void kbd_raw_close(void);
* на проводе) прибавить KBD_EXT к базовому коду. */
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). Полезно для экранов с
* семантикой «продолжить любой клавишей»; один вызов читает 64-байтную
* bitmap, а не перебирает все 512 scan-кодов через kbd_raw_down(). */
+5 -4
View File
@@ -30,10 +30,11 @@
#define _IRQ_VEC_BUF_SIZE (256 + 257)
/* Буфер под W2-копию кадрового трамплина (small/huge — _irq_tramp в
* W1). С запасом над реальным размером трамплина (267 Б после
* упаковки held-карты клавиатуры в биты, 2026-07-30 проверять
* __irq_tramp_end при правках, запас держать >= 60 Б). */
#define _IRQ_TRAMP_BUF_SIZE 336
* W1). С запасом над реальным размером трамплина (310 Б после отказа
* звать обработчик DSS, пока открыт raw-канал, 2026-08-28 проверять
* `grep 'A _CODE' libc/build/fast/irq/_irq_tramp.rel` при правках,
* запас держать >= 60 Б). */
#define _IRQ_TRAMP_BUF_SIZE 384
extern uint8_t _irq_vec_buf[_IRQ_VEC_BUF_SIZE];
+36 -1
View File
@@ -13,7 +13,9 @@
* клавиши) в битовую карту `_kbdraw_down[]` (бит на код, 64 Б),
* и к DSS НЕ идём
* (private RETI тот же паттерн, что и у CBL ниже). Пока raw
* открыт, DSS клавиатуру не видит вообще (см. <kbd_raw.h>).
* открыт, DSS клавиатуру не видит вообще (см. <kbd_raw.h>) и на
* кадровом пути тоже: пока raw открыт, обработчик DSS НЕ вызывается
* совсем (иначе его KEYSCAN крадёт наши байты см. tr_frame_exit).
* - CBL (бит 7 порта 0xFE) только при зарегистрированном хуке
* _irq_cbl_hook (иначе бит подтянут к 1); личное прерывание, RETI
* без чейна к DSS;
@@ -312,6 +314,39 @@ void _irq_tramp(void) __naked
ld a, (__irq_saved_w1) ; вернуть прерванную W1 перед chain
out (#_IRQ_W1_PORT), a
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
.db 0xC3 ; jp 0x0038 (литерал copy-safe)
.dw 0x0038
+24
View File
@@ -57,4 +57,28 @@ extern uint8_t _kbdraw_overrun; /* 1 = ISR поймал Rx-overrun SIO (по
* читает kbd_raw_down(). */
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
+3
View File
@@ -10,3 +10,6 @@ uint8_t _kbdraw_pending;
uint8_t _kbdraw_overrun; /* ISR взвёл при Rx-overrun SIO (потерян
* break) kbd_raw_sync снимет залипшее */
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
};
/* Голые коды нумпада (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)
{
uint8_t save[6];
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;
_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))