SprPoP: звук мигания, равномерные часы, полоса HP после рестарта, надпись ждёт мелодию

Четыре правки прогона 2026-08-31.  Две ПРОВЕРЕНЫ пользователем в MAME
(звук мигания, порядок «мелодия -> надпись»), две ждут проверки — образ
собран.

ЗВУК МИГАНИЯ (проверено).  Упаковщик научился синтезировать ноты PC-
спикера в обычный сэмпл: заголовок с темпом, тройки «частота + длина»,
меандр на нашей частоте вывода.  Берём из нот ТОЛЬКО номера, которых нет
ни в оцифровке, ни среди мелодий, — иначе синтез перекрыл бы музыку,
которую мы играем из MUS/.  На поставке SDLPoP это ровно один номер: 38,
сигнал под мигание «Press Button»; 31, 34 и 42 там пустые заглушки.
Громкость по слуховой проверке снижена вдвое (44 -> 22): на полном
размахе сигнал перекрикивал игру.  EXE не меняется — раскладка читается с
диска, набор занял те же 9 страниц.

НАДПИСЬ ЖДЁТ МЕЛОДИЮ (проверено).  Порядок оригинала: ветка мёртвого
(seg006:1351) на седьмом шаге выходит, пока звук играет, и «Press Button»
появляется только после музыки смерти.  Чтобы ожидание не было
принудительным, три быстрых пути (Ctrl+A, обе быстрые загрузки, пункты
меню) музыку глушат — оригинал при Ctrl+A делает то же (seg000:0617).
Обычная кнопка во время мелодии не действует: она ответ НА надпись.

ЧАСЫ (ждёт проверки).  Тик стоит столько кадров ЛУЧА, сколько их в кадре
режима NORMAL, поэтому FAST/FASTEST больше не ускоряют время.  Считаем
ФАКТИЧЕСКИ прошедшие кадры луча, а не ожидаемый делитель: логический кадр
не всегда укладывается в бюджет, и часы «по делителю» шли рывками (первый
прогон это показал — «несколько секунд быстро, потом притормаживание»).
Вклад одного вызова ограничен, иначе пауза и меню прыгнули бы вперёд.
Четыре новых теста: NORMAL не сдвинулся ни на тик (и вне боя, и в бою),
FAST и FASTEST держат реальное время.

ПОЛОСА HP ПОСЛЕ Ctrl+A (ждёт проверки).  Корень: счётчик считает
СТРАНИЦЫ, а тратился по КАДРАМ — между двумя вызовами переворота может не
быть, и оба прохода уходили в одну страницу, вторая оставалась с
делениями прошлого боя.  Теперь проход тратится только при смене
gfx_get_draw_page().  Плюс полная чистка всей ширины при инвалидации:
старая полоса могла заходить под статус-текст, где щадящая чистка её не
трогала; текст сразу перезапрашивается.

Все 16 наборов host-тестов зелёные, check_bank_calls чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
This commit is contained in:
2026-08-31 23:10:35 +03:00
parent decbec79de
commit 63bfd997a9
14 changed files with 326 additions and 26 deletions
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -39,7 +39,7 @@
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось | | [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился | | [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) | | [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | открыт: разбор есть (2026-08-31) | | [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
--- ---
@@ -1423,6 +1423,15 @@ Ctrl+A у нас — РЕСТАРТ УРОВНЯ (`sprpop_cold.c`, тот же
середину, там и остаётся. Полоса стража рисуется справа налево от 314 и середину, там и остаётся. Полоса стража рисуется справа налево от 314 и
при большом запасе HP заходит именно в эту незачищаемую зону. при большом запасе HP заходит именно в эту незачищаемую зону.
**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ,
а это не одно и то же: между двумя вызовами отрисовки переворота может не
быть, и оба прохода уходили в одну страницу — вторая оставалась с
делениями прошлого боя. Фикс: проход тратится только когда
`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый
проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена
там же — старая полоса могла заходить под статус-текст, где щадящая чистка
её не трогала; текст сразу перезапрашивается, чтобы не пропал.
**Что проверить при взятии в работу.** **Что проверить при взятии в работу.**
1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос: 1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос:
+30 -5
View File
@@ -1009,7 +1009,7 @@ tp/10 у факелов таблицей, пустой слот соперник
## P1 — берётся в любой момент ## P1 — берётся в любой момент
### <a id="time-speed-modes"></a>TIME-SPEED. Часы бегут быстрее в FAST/FASTEST ### <a id="time-speed-modes"></a>TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC)
**Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время **Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время
начинает бежать быстрее — похоже, время мы считаем в наших логических начинает бежать быстрее — похоже, время мы считаем в наших логических
@@ -1040,12 +1040,37 @@ tp/10 у факелов таблицей, пустой слот соперник
минутой в любом режиме, а в бою часы по-прежнему замедляются, как в минутой в любом режиме, а в бою часы по-прежнему замедляются, как в
оригинале. Цена — счётчик-накопитель в `pop_timer_tick`. оригинале. Цена — счётчик-накопитель в `pop_timer_tick`.
**Решать пользователю; по умолчанию — вариант 1** (режимы скорости 3. **Часы от RTC** (идея пользователя, 2026-08-31). Брать время из
отладочные, а вариант 2 разводит наш таймер с оригинальным на уровне часов реального времени, а не считать кадры вовсе.
механики).
**Почему третий вариант интереснее, чем кажется.** Погрешность есть уже
СЕЙЧАС и без всяких режимов: логический кадр NORMAL — 4 кадра луча, а это
81,93 мс, то есть 12,2 кадра в секунду вместо ровных 12. Оригинальная
минута из 720 тиков проходит у нас за 58,99 с — почти на секунду быстрее.
За час игры набегает около минуты. Ни один из первых двух вариантов этого
не лечит: они выравнивают режимы между собой, но обе шкалы остаются
привязанными к лучу, а луч не кратен игровой секунде.
RTC (`ESTEX $21 SYSTIME`, memory `sprinter_systime_dow`) даёт абсолютную
шкалу и снимает накопление полностью. Подводные камни, которые надо
решить при взятии в работу:
- вызов ESTEX стоит дорого и клобберит регистры — читать раз в тик, не в
кадре, и не из горячего пути (memory `estex_bios_abi`);
- разрешение RTC — секунда, а тик игры — 1/12 секунды: нужен гибрид
«кадры внутри секунды, синхронизация по RTC на границе», иначе часы
задёргаются;
- пауза, меню и загрузка НЕ должны съедать игровое время — при часах от
RTC это перестаёт получаться само собой и требует явного вычитания;
- быстрая загрузка/сохранение обязаны сохранять смещение, а не абсолютное
время.
**Решать пользователю.** Порядок по цене: вариант 1 (ничего), вариант 2
(счётчик кадров луча, лечит только разбег режимов), вариант 3 (RTC, лечит
и накопление — но требует разобраться с паузами).
### <a id="snd-speaker-38"></a>SND-SPEAKER-38. Звук мигания надписи: синтезировать ноты спикера в PCM ### <a id="snd-speaker-38"></a>SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31
**Постановка (пользователь, 2026-08-31):** «когда надпись Press Button **Постановка (пользователь, 2026-08-31):** «когда надпись Press Button
начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас
+29 -2
View File
@@ -989,11 +989,24 @@ void pop_mirror_draw(int clip_top) __banked
* собирает неверно — SUB затирает A, и в s уезжает разность (memory * собирает неверно — SUB затирает A, и в s уезжает разность (memory
* sdcc_z80_cmp_store_a_bug). */ * sdcc_z80_cmp_store_a_bug). */
static uint8_t hp_todo; /* сколько страниц ещё обновить */ static uint8_t hp_todo; /* сколько страниц ещё обновить */
/* Сколько страниц ещё стереть ЦЕЛИКОМ. Обычное обновление щадит зону
* статус-текста (иначе он мигал бы на каждом изменении жизней), но после
* рестарта уровня в этой зоне остаются деления ПРОШЛОГО боя: полоса стража
* при большом запасе HP заходит под текст, и щадящая чистка их не трогает.
* Симптом — «после гибели и Ctrl+A на одной из страниц осталась полоса по
* результатам боя» (BUGS_OPEN, HP-BAR-RESTART). */
static uint8_t hp_wipe; /* сколько страниц ещё стереть */
static uint8_t hp_kid_prev, hp_kidmax_prev, hp_gd_prev, hp_gdmax_prev; static uint8_t hp_kid_prev, hp_kidmax_prev, hp_gd_prev, hp_gdmax_prev;
/* Страница, в которую лёг прошлый проход. Счётчики выше считают СТРАНИЦЫ,
* но кадр и страница — не одно и то же: между двумя вызовами переворота
* может не быть, и тогда оба прохода уходили в ОДНУ страницу, а вторая
* оставалась с делениями прошлого боя. Ровно этим полоса и переживала
* Ctrl+A (BUGS_OPEN, HP-BAR-RESTART). */
static uint8_t hp_page_prev = 0xFF;
/* Заставить перерисовать полосу на обеих страницах: звать при входе в /* Заставить перерисовать полосу на обеих страницах: звать при входе в
* комнату (фон перерисован целиком и стёр её) и при старте. */ * комнату (фон перерисован целиком и стёр её) и при старте. */
void pop_hp_invalidate(void) __banked { hp_todo = 2; } void pop_hp_invalidate(void) __banked { hp_todo = 2; hp_wipe = 2; }
void pop_hp_draw(void) __banked void pop_hp_draw(void) __banked
{ {
@@ -1010,6 +1023,13 @@ void pop_hp_draw(void) __banked
if (changed) hp_todo = 2; if (changed) hp_todo = 2;
} }
if (hp_todo == 0) return; if (hp_todo == 0) return;
{ /* Пока страница та же, что у прошлого прохода, — ждём переворота:
* иначе потратим оба прохода на одну страницу. Первый проход
* (hp_todo == 2) идёт всегда, ему сравнивать не с чем. */
uint8_t pg = gfx_get_draw_page();
if (hp_todo < 2 && pg == hp_page_prev) return;
hp_page_prev = pg;
}
hp_todo--; hp_todo--;
/* Стереть прошлую полосу. Фон под ней — НЕ цвет 0, а POP_COL_OUTSIDE /* Стереть прошлую полосу. Фон под ней — НЕ цвет 0, а POP_COL_OUTSIDE
@@ -1021,7 +1041,14 @@ void pop_hp_draw(void) __banked
* текст, его зону не трогаем — иначе он мигал бы при каждом изменении * текст, его зону не трогаем — иначе он мигал бы при каждом изменении
* жизней. Деления Кида левее POP_STATUS_L, стража — правее POP_STATUS_R, * жизней. Деления Кида левее POP_STATUS_L, стража — правее POP_STATUS_R,
* так что чистить края по отдельности достаточно. */ * так что чистить края по отдельности достаточно. */
if (pop_status_ticks) { if (hp_wipe) {
/* Полная чистка обеих страниц: старая полоса могла заходить под
* текст. Текст при этом стирается тоже, поэтому сразу просим
* перерисовать и его — иначе строка уровня пропала бы. */
hp_wipe--;
bar(0, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
if (pop_status_ticks) pop_status_invalidate();
} else if (pop_status_ticks) {
bar(0, HP_Y + POP_YOFF, POP_STATUS_L - 1, HP_Y + POP_YOFF + 6); bar(0, HP_Y + POP_YOFF, POP_STATUS_L - 1, HP_Y + POP_YOFF + 6);
bar(POP_STATUS_R + 1, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6); bar(POP_STATUS_R + 1, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
} else { } else {
+6
View File
@@ -71,6 +71,12 @@ extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */
/* Делитель для текущего режима. fight = «у Кида вынут меч». */ /* Делитель для текущего режима. fight = «у Кида вынут меч». */
uint8_t pop_pace_n(uint8_t fight); uint8_t pop_pace_n(uint8_t fight);
/* ЭТАЛОН ХОДА ЧАСОВ — делитель РЕЖИМА NORMAL для того же признака боя.
* Игровое время меряется им, а не фактическим темпом: иначе FAST/FASTEST
* ускоряли бы и часы (минута проходила за две трети минуты). При NORMAL
* фактический делитель равен эталону, поэтому его ход не меняется вовсе —
* включая замедление в бою, которое есть и в оригинале. */
#define POP_PACE_BASE(fight) ((uint8_t)((fight) ? 5 : 4))
/* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы /* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы
* ожидания — перечитывать обязаны каждый оборот. */ * ожидания — перечитывать обязаны каждый оборот. */
+17 -3
View File
@@ -40,6 +40,7 @@
#include <gfx.h> #include <gfx.h>
#include <kbd_raw.h> #include <kbd_raw.h>
#include "pop_status.h" #include "pop_status.h"
#include "pop_music.h" /* ждём конец музыки смерти и глушим её по ответу */
#include "pop_ui.h" #include "pop_ui.h"
#include "pop_font.h" #include "pop_font.h"
#include "pop_bg.h" /* POP_YOFF */ #include "pop_bg.h" /* POP_YOFF */
@@ -461,14 +462,25 @@ static void dbg_scan(uint8_t room)
#define DEAD_BLINK 72 /* последние 72 тика строка мигает */ #define DEAD_BLINK 72 /* последние 72 тика строка мигает */
#define SND_BLINK 38 /* sound_38_blink на каждом появлении */ #define SND_BLINK 38 /* sound_38_blink на каждом появлении */
/* Кадр, с которого пошёл отсчёт надписи. НЕ равен DEAD_SETTLE: оригинал
* не показывает «Press Button», пока звучит музыка смерти — ветка мёртвого
* (seg006:1351) на седьмом шаге просто выходит, если звук ещё играет, и
* надпись появляется только после него. Поэтому момент старта отсчёта
* заранее не известен и запоминается здесь. */
static uint16_t dead_base;
static uint8_t dead_armed; /* всё отпущено — можно принимать нажатие */ static uint8_t dead_armed; /* всё отпущено — можно принимать нажатие */
uint8_t pop_dead_prompt(uint16_t frames) __banked uint8_t pop_dead_prompt(uint16_t frames) __banked
{ {
uint16_t rem; uint16_t rem;
if (frames < DEAD_SETTLE) return 0; if (frames < DEAD_SETTLE) { dead_base = 0; return 0; }
if (frames == DEAD_SETTLE) { if (dead_base == 0) {
/* Ждём, пока домолчит музыка смерти — порядок оригинала. Пока она
* играет, надписи нет и отсчёт 288 не идёт; прервать ожидание можно
* Ctrl+A или быстрой загрузкой, они музыку глушат. */
if (pop_music_active()) return 0;
dead_base = frames;
msg_set(POP_MSG_PRESS_BUTTON, 0, MSG_HOLD); msg_set(POP_MSG_PRESS_BUTTON, 0, MSG_HOLD);
pop_show_time = 0; /* иначе поверх ляжет время (seg006:1365) */ pop_show_time = 0; /* иначе поверх ляжет время (seg006:1365) */
dead_armed = 0; dead_armed = 0;
@@ -477,7 +489,7 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
/* Свой отсчёт, а не pop_status_ticks: тот 8-битный, а здесь нужно 288. /* Свой отсчёт, а не pop_status_ticks: тот 8-битный, а здесь нужно 288.
* Кадры смерти считает вызывающий, так что хватает вычитания. */ * Кадры смерти считает вызывающий, так что хватает вычитания. */
rem = (uint16_t)(frames - DEAD_SETTLE); rem = (uint16_t)(frames - dead_base);
rem = (uint16_t)(rem >= DEAD_TICKS ? 0 : DEAD_TICKS - rem); rem = (uint16_t)(rem >= DEAD_TICKS ? 0 : DEAD_TICKS - rem);
if (rem == 0) { if (rem == 0) {
/* Игрок промолчал все 24 секунды — оригинал зовёт start_game(), /* Игрок промолчал все 24 секунды — оригинал зовёт start_game(),
@@ -519,6 +531,8 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
if (!any) return 0; if (!any) return 0;
} }
dead_armed = 0; dead_armed = 0;
dead_base = 0;
pop_music_stop(); /* игрок ответил — доигрывать не заставляем */
msg_set(POP_MSG_NONE, 0, 0); msg_set(POP_MSG_NONE, 0, 0);
return 1; return 1;
} }
+20 -1
View File
@@ -14,16 +14,35 @@ uint8_t pop_timer_minutes;
uint16_t pop_timer_ticks; uint16_t pop_timer_ticks;
uint8_t pop_show_time; uint8_t pop_show_time;
/* Накопленные кадры луча, ещё не сложившиеся в тик. Живёт между кадрами:
* при FAST логический кадр короче эталона, и остаток переносится вперёд. */
static uint8_t tick_acc;
void pop_timer_new_game(void) __banked void pop_timer_new_game(void) __banked
{ {
pop_timer_minutes = POP_TIMER_START_MINUTES; pop_timer_minutes = POP_TIMER_START_MINUTES;
pop_timer_ticks = POP_TIMER_START_TICKS; pop_timer_ticks = POP_TIMER_START_TICKS;
pop_show_time = 0; pop_show_time = 0;
tick_acc = 0;
} }
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
uint8_t spent, uint8_t base) __banked
{ {
if (!enabled || !may_run || pop_timer_minutes == 0) return 0; if (!enabled || !may_run || pop_timer_minutes == 0) return 0;
/* ХОД ЧАСОВ ОТВЯЗАН ОТ ТЕМПА. Тик стоит `base` кадров луча — столько,
* сколько их в кадре режима NORMAL. При NORMAL spent == base, и тик
* приходится ровно на кадр, как было всегда; в быстрых режимах кадр
* короче, остаток копится, и за то же РЕАЛЬНОЕ время выходит столько
* же тиков. Цикла не нужно: spent никогда не больше base (быстрые
* режимы кадр только УКОРАЧИВАЮТ) — значит не больше тика за вызов. */
/* Пауза, меню и загрузка сюда не заходят вовсе, поэтому за время их
* работы кадры луча накапливаются мимо нас. Ограничиваем вклад одного
* вызова: иначе после меню часы прыгнули бы вперёд на всю паузу. */
if (spent > (uint8_t)(base + base)) spent = base;
tick_acc = (uint8_t)(tick_acc + spent);
if (tick_acc < base) return 0;
tick_acc = (uint8_t)(tick_acc - base);
--pop_timer_ticks; --pop_timer_ticks;
if (pop_timer_ticks != 0) { if (pop_timer_ticks != 0) {
+5 -1
View File
@@ -35,7 +35,11 @@ void pop_timer_new_game(void) __banked;
* may_run — правила текущей сцены/уровня. Возвращает 1 ОДИН раз, когда * may_run — правила текущей сцены/уровня. Возвращает 1 ОДИН раз, когда
* отсчёт дошёл до нуля; на паузе, HDD/QuickSave и при выключенном лимите * отсчёт дошёл до нуля; на паузе, HDD/QuickSave и при выключенном лимите
* вызывать можно — состояние останется неизменным. */ * вызывать можно — состояние останется неизменным. */
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked; /* spent — сколько кадров ЛУЧА стоил этот логический кадр (pop_pace_n),
* base — сколько их было бы при NORMAL (POP_PACE_BASE). Тик отсчитывается
* по base, поэтому режим скорости на ход часов не влияет. */
uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
uint8_t spent, uint8_t base) __banked;
/* Читы, повторяющие +/- SDLPoP: минус не даёт искусственно поставить 0, /* Читы, повторяющие +/- SDLPoP: минус не даёт искусственно поставить 0,
* плюс добавляет минуту. Они меняют счётчик и при выключенном лимите — * плюс добавляет минуту. Они меняют счётчик и при выключенном лимите —
+20 -1
View File
@@ -240,6 +240,21 @@ static uint8_t item_pos, item_bake, flash_on;
* Через функцию с параметрами, а не пятью литералами в месте вызова: на * Через функцию с параметрами, а не пятью литералами в месте вызова: на
* gfx_pal_set(0,0,0,0,0) SDCC 4.5 выдаёт невалидный `ld hl, a` * gfx_pal_set(0,0,0,0,0) SDCC 4.5 выдаёт невалидный `ld hl, a`
* (см. build/obj/SprPoP.asm) и ассемблер падает. */ * (см. build/obj/SprPoP.asm) и ассемблер падает. */
/* Сколько кадров ЛУЧА прошло с прошлого игрового кадра. Часы считают
* именно их, а не ожидаемый делитель темпа: логический кадр не всегда
* укладывается в свой бюджет (дорогая сцена, загрузка звука), и часы,
* считавшие «по делителю», шли рывками — несколько секунд быстро, потом
* притормаживание (наблюдение пользователя 2026-08-31). Луч же идёт
* ровно, поэтому по нему время течёт равномерно. */
static uint8_t timer_beam_delta(void)
{
static uint8_t prev;
uint8_t now = pop_frame_tick;
uint8_t d = (uint8_t)(now - prev);
prev = now;
return d;
}
static void flash_bg(uint8_t r, uint8_t g, uint8_t b) static void flash_bg(uint8_t r, uint8_t g, uint8_t b)
{ {
gfx_pal_set(0, 0, r, g, b); gfx_pal_set(0, 0, r, g, b);
@@ -608,7 +623,11 @@ int main(void)
if (!is_demo && pop_timer_tick(pop_settings.time_limit_enabled, if (!is_demo && pop_timer_tick(pop_settings.time_limit_enabled,
(uint8_t)(Kid.alive < 0 && (uint8_t)(Kid.alive < 0 &&
(pop_current_level < 13 || (pop_current_level < 13 ||
(pop_current_level == 13 && pop_leveldoor_open == 0)))) && (pop_current_level == 13 && pop_leveldoor_open == 0))),
/* ХОД ЧАСОВ — по эталону NORMAL, а не по фактическому
* темпу: иначе FAST/FASTEST ускоряли бы и время. */
timer_beam_delta(),
POP_PACE_BASE(Kid.sword == SWORD_2_DRAWN)) &&
pop_current_level < 13) { pop_current_level < 13) {
/* Время ИДЁТ и на уровне Джафара (до открытия двери), но /* Время ИДЁТ и на уровне Джафара (до открытия двери), но
* КОНЧИТЬСЯ игра там уже не может: expired() у оригинала стоит * КОНЧИТЬСЯ игра там уже не может: expired() у оригинала стоит
+10 -5
View File
@@ -115,10 +115,10 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
mrc = pop_menu_process(); mrc = pop_menu_process();
} }
if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save(); if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save();
else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load(); else if (mrc == POP_MENU_QUICKLOAD) { pop_music_stop(); pop_qsave_request_load(); }
else if (mrc == POP_MENU_QUICKLOAD_BACKUP) else if (mrc == POP_MENU_QUICKLOAD_BACKUP)
pop_qsave_request_load_backup(); { pop_music_stop(); pop_qsave_request_load_backup(); }
else if (mrc == POP_MENU_RESTART_LEVEL) *restart_level = 1; else if (mrc == POP_MENU_RESTART_LEVEL) { pop_music_stop(); *restart_level = 1; }
else if (mrc == POP_MENU_RESTART_GAME) else if (mrc == POP_MENU_RESTART_GAME)
return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1); return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1);
else if (mrc == POP_MENU_QUIT) else if (mrc == POP_MENU_QUIT)
@@ -137,10 +137,15 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
if (ctrl && kbd_raw_down(KBD_QUIT)) if (ctrl && kbd_raw_down(KBD_QUIT))
return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1); return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
/* Ctrl+A — рестарт уровня: тот же путь, что и пунктом меню. */ /* Ctrl+A — рестарт уровня: тот же путь, что и пунктом меню. */
/* БЫСТРЫЙ ВЫХОД ИЗ СМЕРТИ. Надпись «Press Button» у оригинала ждёт,
* пока домолчит музыка смерти (seg006:1351), — так же и у нас. Чтобы
* ожидание не было принудительным, три быстрых пути (Ctrl+A, обе
* быстрые загрузки и пункты меню) музыку ГЛУШАТ: оригинал при Ctrl+A
* делает то же самое (seg000:0617, stop_sounds). */
{ {
uint8_t r = (uint8_t)(ctrl && kbd_raw_down(KBD_RESTART)); uint8_t r = (uint8_t)(ctrl && kbd_raw_down(KBD_RESTART));
if (r && !restart_prev) *restart_level = 1; if (r && !restart_prev) { pop_music_stop(); *restart_level = 1; }
restart_prev = r; restart_prev = r;
} }
/* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game» /* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game»
@@ -181,7 +186,7 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
uint8_t l = kbd_raw_down(KBD_QUICKLOAD); uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
if (s && !qs_save_prev) pop_qsave_request_save(); if (s && !qs_save_prev) pop_qsave_request_save();
if (l && !qs_load_prev) pop_qsave_request_load(); if (l && !qs_load_prev) { pop_music_stop(); pop_qsave_request_load(); }
qs_save_prev = s; qs_load_prev = l; qs_save_prev = s; qs_load_prev = l;
if (pop_qsave_process() > 0) *dead_reset = 1; if (pop_qsave_process() > 0) *dead_reset = 1;
} }
+60 -7
View File
@@ -35,17 +35,17 @@ TC_TEST(timer_starts_and_only_runs_when_allowed)
pop_timer_new_game(); pop_timer_new_game();
TC_EQ(pop_timer_minutes, POP_TIMER_START_MINUTES); TC_EQ(pop_timer_minutes, POP_TIMER_START_MINUTES);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS); TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(0, 1)); /* Settings: unlimited */ TC_FALSE(pop_timer_tick(0, 1, 4, 4)); /* Settings: unlimited */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS); TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(1, 0)); /* pause/HDD/cutscene */ TC_FALSE(pop_timer_tick(1, 0, 4, 4)); /* pause/HDD/cutscene */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS); TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
pop_timer_minutes = 2; pop_timer_minutes = 2;
pop_timer_ticks = 2; pop_timer_ticks = 2;
TC_FALSE(pop_timer_tick(1, 1)); TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 2); TC_EQ(pop_timer_minutes, 2);
TC_EQ(pop_timer_ticks, 1); TC_EQ(pop_timer_ticks, 1);
TC_FALSE(pop_timer_tick(1, 1)); TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 1); TC_EQ(pop_timer_minutes, 1);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS); TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
} }
@@ -54,11 +54,11 @@ TC_TEST(timer_expiry_is_one_shot)
{ {
pop_timer_minutes = 1; pop_timer_minutes = 1;
pop_timer_ticks = 1; pop_timer_ticks = 1;
TC_TRUE(pop_timer_tick(1, 1)); TC_TRUE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 0); TC_EQ(pop_timer_minutes, 0);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS); TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(1, 1)); TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_FALSE(pop_timer_tick(0, 1)); TC_FALSE(pop_timer_tick(0, 1, 4, 4));
} }
TC_TEST(timer_cheats_match_sdlpop_rules) TC_TEST(timer_cheats_match_sdlpop_rules)
@@ -107,10 +107,63 @@ TC_TEST(timer_qsave_restores_exact_state_and_rejects_bad_tick)
TC_EQ(pop_timer_ticks, 444); TC_EQ(pop_timer_ticks, 444);
} }
/* --- ХОД ЧАСОВ НЕ ЗАВИСИТ ОТ РЕЖИМА СКОРОСТИ ------------------------- *
*
* Тик стоит `base` кадров луча столько, сколько их в кадре режима
* NORMAL. Быстрые режимы только УКОРАЧИВАЮТ кадр (spent < base), поэтому
* за одно и то же реальное время выходит одинаковое число тиков.
* Наблюдение пользователя, из которого выросла правка: «при FAST/FASTEST
* время начинает бежать быстрее». */
/* Сколько тиков насчитается за `frames` логических кадров темпа spent/base. */
static uint16_t ticks_over(uint8_t frames, uint8_t spent, uint8_t base)
{
uint16_t before;
uint8_t i;
pop_timer_new_game();
before = pop_timer_ticks;
for (i = 0; i < frames; i++) pop_timer_tick(1, 1, spent, base);
return (uint16_t)(before - pop_timer_ticks);
}
TC_TEST(timer_normal_ticks_every_frame)
{
/* NORMAL вне боя: кадр равен эталону — тик на каждом кадре, как было до
* правки. Страховка, что NORMAL не изменился ни на тик. */
TC_EQ(ticks_over(12, 4, 4), 12);
}
TC_TEST(timer_normal_fight_ticks_every_frame)
{
/* NORMAL в бою: кадр длиннее (5), но и эталон 5 — часы по-прежнему идут
* по кадру. В РЕАЛЬНОМ времени это медленнее, и ровно так же ведёт себя
* оригинал, поэтому трогать нечего. */
TC_EQ(ticks_over(12, 5, 5), 12);
}
TC_TEST(timer_fast_keeps_real_time)
{
/* FAST вне боя: кадр 3 при эталоне 4. За 12 кадров (36 кадров луча) —
* 9 тиков, ровно столько же, сколько NORMAL насчитает за те же 36 кадров
* луча. До правки было бы 12: часы бежали на треть быстрее. */
TC_EQ(ticks_over(12, 3, 4), 9);
}
TC_TEST(timer_fastest_fight_keeps_real_time)
{
/* FASTEST в бою — самый короткий кадр против самого длинного эталона: 3 против
* 5. За 20 кадров ждём 12 тиков. */
TC_EQ(ticks_over(20, 3, 5), 12);
}
void main(void) void main(void)
{ {
TC_RUN(timer_starts_and_only_runs_when_allowed); TC_RUN(timer_starts_and_only_runs_when_allowed);
TC_RUN(timer_expiry_is_one_shot); TC_RUN(timer_expiry_is_one_shot);
TC_RUN(timer_cheats_match_sdlpop_rules); TC_RUN(timer_cheats_match_sdlpop_rules);
TC_RUN(timer_qsave_restores_exact_state_and_rejects_bad_tick); TC_RUN(timer_qsave_restores_exact_state_and_rejects_bad_tick);
TC_RUN(timer_normal_ticks_every_frame);
TC_RUN(timer_normal_fight_ticks_every_frame);
TC_RUN(timer_fast_keeps_real_time);
TC_RUN(timer_fastest_fight_keeps_real_time);
} }
+29
View File
@@ -66,6 +66,35 @@ SND_SOURCES = {
} }
# Ноты PC-СПИКЕРА и МЕЛОДИИ — два других набора того же движка. Нужны нам
# по разным причинам: мелодии мы играем отдельно (MUS/), а из нот спикера
# добираем те номера, которых нет НИ в оцифровке, НИ среди мелодий — сейчас
# это ровно звук 38 (мигание надписи «Press Button»). В поставке SDLPoP
# наборы лежат распакованными каталогами res1XXXX.bin, в дистрибутиве DOS —
# контейнерами .DAT; поддерживаем оба вида.
SND_MIDI = {
"sdlpop": (SDLPOP_DATA, "MIDISND%d.DAT", 2),
"msdos": (MSDOS, "midisnd%d.dat", 2),
}
SND_SPEAKER = {
"sdlpop": (SDLPOP_DATA, "IBM_SND%d", 2),
"msdos": (MSDOS, "ibm_snd%d.dat", 2),
}
def snd_extra(kind, name="sdlpop"):
"""(каталог, шаблон, сколько контейнеров) для набора нот или мелодий.
kind "midi" или "speaker". Набора может не быть вовсе (у пользователя
нет MSDOS, поставка неполная) это НЕ ошибка: вызывающий просто
пропускает добор. Возвращает None, если источник неизвестен.
"""
table = {"midi": SND_MIDI, "speaker": SND_SPEAKER}.get(kind)
if table is None:
raise SystemExit("неизвестный набор %r; есть: midi, speaker" % kind)
return table.get(name)
def snd_src(name="sdlpop"): def snd_src(name="sdlpop"):
"""(каталог, шаблон имени) контейнеров оцифровки для источника.""" """(каталог, шаблон имени) контейнеров оцифровки для источника."""
if name not in SND_SOURCES: if name not in SND_SOURCES:
@@ -108,6 +108,69 @@ def resample(src, src_rate, dst_rate):
return bytes(out) return bytes(out)
def load_bundle(dat_dir, pattern, count):
"""id -> тело ресурса из набора любого вида.
Наборы приезжают в двух видах: контейнером `.DAT` (дистрибутив DOS) и
распакованным каталогом `res1XXXX.bin` (поставка SDLPoP). Тело в обоих
случаях начинается с байта типа, так что дальше разбор общий.
"""
out = {}
for i in range(1, count + 1):
p = dat_dir / (pattern % i)
if p.is_dir():
for f in sorted(p.glob("res1*.bin")):
try:
rid = int(f.stem[3:]) - 10000
except ValueError:
continue
out[rid] = f.read_bytes()
elif p.is_file():
out.update(dat_resources(p))
return out
def speaker(body):
"""(темп, [(частота Гц, длина в долях), ...]) или None — если это не ноты.
Формат канон Princed, «Internal PC Speaker»: заголовок 3 байта, из них
байт 1 доли на ДВЕ секунды; дальше тройки «частота (2 байта) + длина
(1 байт)»; нулевая частота = пауза, 1 и 2 маркеры; в конце маркер
`12 00`, который сам отсекается недобором до целой тройки.
"""
if not body or (body[0] & 7) != 0 or len(body) < 6:
return None
bps = body[1] or 1
notes = []
i = 3
while i + 3 <= len(body):
notes.append((struct.unpack("<H", body[i:i + 2])[0], body[i + 2]))
i += 3
return bps, notes
def speaker_pcm(bps, notes, rate, amp=22):
"""Меандр по нотам -> те же 8 бит без знака, что и оцифровка.
Амплитуда НАМЕРЕННО маленькая: сигналы спикера короткие и резкие, и на
полном размахе они колют ухо рядом с оцифровкой, которая заметно тише.
Значение снижено вдвое по слуховой проверке в MAME (решение пользователя
2026-08-31): на 44 сигнал мигания перекрикивал игру.
"""
out = bytearray()
for freq, beats in notes:
n = int(round(beats * (2.0 / bps) * rate))
if n <= 0:
continue
if freq <= 2: # 0 — пауза, 1 и 2 — маркеры
out += bytes([0x80]) * n
continue
period = rate / freq
for i in range(n):
out.append(0x80 + amp if (i % period) < period / 2 else 0x80 - amp)
return bytes(out)
def main(): def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES), ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES),
@@ -151,6 +214,33 @@ def main():
if rate != 11000: if rate != 11000:
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов") print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
# ДОБОР ИЗ НОТ PC-СПИКЕРА (docs/TASKS_OPEN.md, SND-SPEAKER-38).
# У оригинала три параллельных набора звука, и номера разложены по ним
# не подряд: оцифровка, мелодии и ноты спикера. Берём из нот ТОЛЬКО те
# номера, которых нет ни там, ни там — иначе синтез перекрыл бы собой
# мелодии, которые мы играем отдельно, из MUS/. На поставке SDLPoP это
# ровно один номер: 38, сигнал под мигание надписи «Press Button»
# (31, 34 и 42 в наборе есть, но они пустые заглушки без нот).
midi_cfg = P.snd_extra("midi", args.source)
spk_cfg = P.snd_extra("speaker", args.source)
midi_ids = set(load_bundle(*midi_cfg)) if midi_cfg else set()
added = []
for sid, body in sorted((load_bundle(*spk_cfg) if spk_cfg else {}).items()):
if sid in sounds or sid in midi_ids:
continue
sp = speaker(body)
if sp is None or not sp[1]:
continue
pcm = speaker_pcm(sp[0], sp[1], RATE)
if not pcm:
continue
sounds[sid] = pcm
added.append("%d (%d нот, %d Б)" % (sid, len(sp[1]), len(pcm)))
if added:
print(" из нот спикера: " + ", ".join(added))
elif spk_cfg:
print(" из нот спикера: добирать нечего")
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE. # Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL # ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост # всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост