Compare commits

..

8 Commits

Author SHA1 Message Date
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
13 changed files with 416 additions and 158 deletions
+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 с реального времени. За это время шкала сцены прошла
+124 -99
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);
+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 — ошибка. */
+62 -21
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,14 +676,14 @@ 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);
}
}
@@ -679,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» означала бы
@@ -742,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 вернул обе страницы из тени, снятой ДО меню: всё, что
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в
+19 -5
View File
@@ -49,6 +49,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 +111,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);
}
@@ -146,23 +148,35 @@ int8_t pop_music_load_begin(uint8_t id) __banked
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);
+4 -2
View File
@@ -234,14 +234,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 — обычной
+7
View File
@@ -16,6 +16,7 @@
#include "pop_arc.h"
#include "pop_ui.h"
#include "pop_status.h"
#include "pop_music.h" /* pop_music_service — подкачка кольца в долгих фейдах */
typedef struct {
uint8_t base;
@@ -292,6 +293,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 +435,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;
+23 -3
View File
@@ -357,6 +357,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) {
@@ -765,9 +766,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++;
+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
+10
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 клавиатуры
+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