diff --git a/applications/SprPoP/assets/packed/SND/snd.arc b/applications/SprPoP/assets/packed/SND/snd.arc index c44887f..a99ce44 100644 Binary files a/applications/SprPoP/assets/packed/SND/snd.arc and b/applications/SprPoP/assets/packed/SND/snd.arc differ diff --git a/applications/SprPoP/assets/packed/SND/snd.idx b/applications/SprPoP/assets/packed/SND/snd.idx index 3a24b52..81739f7 100644 Binary files a/applications/SprPoP/assets/packed/SND/snd.idx and b/applications/SprPoP/assets/packed/SND/snd.idx differ diff --git a/applications/SprPoP/docs/BUGS_OPEN.md b/applications/SprPoP/docs/BUGS_OPEN.md index 356e6bf..a17ae37 100644 --- a/applications/SprPoP/docs/BUGS_OPEN.md +++ b/applications/SprPoP/docs/BUGS_OPEN.md @@ -39,7 +39,7 @@ | [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось | | [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился | | [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 и при большом запасе HP заходит именно в эту незачищаемую зону. +**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ, +а это не одно и то же: между двумя вызовами отрисовки переворота может не +быть, и оба прохода уходили в одну страницу — вторая оставалась с +делениями прошлого боя. Фикс: проход тратится только когда +`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый +проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена +там же — старая полоса могла заходить под статус-текст, где щадящая чистка +её не трогала; текст сразу перезапрашивается, чтобы не пропал. + **Что проверить при взятии в работу.** 1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос: diff --git a/applications/SprPoP/docs/TASKS_OPEN.md b/applications/SprPoP/docs/TASKS_OPEN.md index eb99bc0..c4e2dbd 100644 --- a/applications/SprPoP/docs/TASKS_OPEN.md +++ b/applications/SprPoP/docs/TASKS_OPEN.md @@ -1009,7 +1009,7 @@ tp/10 у факелов таблицей, пустой слот соперник ## P1 — берётся в любой момент -### TIME-SPEED. Часы бегут быстрее в FAST/FASTEST +### TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC) **Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время начинает бежать быстрее — похоже, время мы считаем в наших логических @@ -1040,12 +1040,37 @@ tp/10 у факелов таблицей, пустой слот соперник минутой в любом режиме, а в бою часы по-прежнему замедляются, как в оригинале. Цена — счётчик-накопитель в `pop_timer_tick`. -**Решать пользователю; по умолчанию — вариант 1** (режимы скорости -отладочные, а вариант 2 разводит наш таймер с оригинальным на уровне -механики). +3. **Часы от RTC** (идея пользователя, 2026-08-31). Брать время из + часов реального времени, а не считать кадры вовсе. + +**Почему третий вариант интереснее, чем кажется.** Погрешность есть уже +СЕЙЧАС и без всяких режимов: логический кадр 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, лечит +и накопление — но требует разобраться с паузами). -### SND-SPEAKER-38. Звук мигания надписи: синтезировать ноты спикера в PCM +### SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31 **Постановка (пользователь, 2026-08-31):** «когда надпись Press Button начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас diff --git a/applications/SprPoP/src/pop_cdraw.c b/applications/SprPoP/src/pop_cdraw.c index 9f9ec6f..1f1ecc3 100644 --- a/applications/SprPoP/src/pop_cdraw.c +++ b/applications/SprPoP/src/pop_cdraw.c @@ -989,11 +989,24 @@ void pop_mirror_draw(int clip_top) __banked * собирает неверно — SUB затирает A, и в s уезжает разность (memory * sdcc_z80_cmp_store_a_bug). */ 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; +/* Страница, в которую лёг прошлый проход. Счётчики выше считают СТРАНИЦЫ, + * но кадр и страница — не одно и то же: между двумя вызовами переворота + * может не быть, и тогда оба прохода уходили в ОДНУ страницу, а вторая + * оставалась с делениями прошлого боя. Ровно этим полоса и переживала + * 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 { @@ -1010,6 +1023,13 @@ void pop_hp_draw(void) __banked if (changed) hp_todo = 2; } 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--; /* Стереть прошлую полосу. Фон под ней — НЕ цвет 0, а POP_COL_OUTSIDE @@ -1021,7 +1041,14 @@ void pop_hp_draw(void) __banked * текст, его зону не трогаем — иначе он мигал бы при каждом изменении * жизней. Деления Кида левее 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(POP_STATUS_R + 1, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6); } else { diff --git a/applications/SprPoP/src/pop_pace.h b/applications/SprPoP/src/pop_pace.h index be70a04..2daa201 100644 --- a/applications/SprPoP/src/pop_pace.h +++ b/applications/SprPoP/src/pop_pace.h @@ -71,6 +71,12 @@ extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */ /* Делитель для текущего режима. 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, а читают циклы * ожидания — перечитывать обязаны каждый оборот. */ diff --git a/applications/SprPoP/src/pop_status.c b/applications/SprPoP/src/pop_status.c index 0a83aaf..55cd777 100644 --- a/applications/SprPoP/src/pop_status.c +++ b/applications/SprPoP/src/pop_status.c @@ -40,6 +40,7 @@ #include #include #include "pop_status.h" +#include "pop_music.h" /* ждём конец музыки смерти и глушим её по ответу */ #include "pop_ui.h" #include "pop_font.h" #include "pop_bg.h" /* POP_YOFF */ @@ -461,14 +462,25 @@ static void dbg_scan(uint8_t room) #define DEAD_BLINK 72 /* последние 72 тика строка мигает */ #define SND_BLINK 38 /* sound_38_blink на каждом появлении */ +/* Кадр, с которого пошёл отсчёт надписи. НЕ равен DEAD_SETTLE: оригинал + * не показывает «Press Button», пока звучит музыка смерти — ветка мёртвого + * (seg006:1351) на седьмом шаге просто выходит, если звук ещё играет, и + * надпись появляется только после него. Поэтому момент старта отсчёта + * заранее не известен и запоминается здесь. */ +static uint16_t dead_base; static uint8_t dead_armed; /* всё отпущено — можно принимать нажатие */ uint8_t pop_dead_prompt(uint16_t frames) __banked { uint16_t rem; - if (frames < DEAD_SETTLE) return 0; - if (frames == DEAD_SETTLE) { + if (frames < DEAD_SETTLE) { dead_base = 0; return 0; } + 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); pop_show_time = 0; /* иначе поверх ляжет время (seg006:1365) */ dead_armed = 0; @@ -477,7 +489,7 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked /* Свой отсчёт, а не 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); if (rem == 0) { /* Игрок промолчал все 24 секунды — оригинал зовёт start_game(), @@ -519,6 +531,8 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked if (!any) return 0; } dead_armed = 0; + dead_base = 0; + pop_music_stop(); /* игрок ответил — доигрывать не заставляем */ msg_set(POP_MSG_NONE, 0, 0); return 1; } diff --git a/applications/SprPoP/src/pop_timer.c b/applications/SprPoP/src/pop_timer.c index 58ed531..1890fea 100644 --- a/applications/SprPoP/src/pop_timer.c +++ b/applications/SprPoP/src/pop_timer.c @@ -14,16 +14,35 @@ uint8_t pop_timer_minutes; uint16_t pop_timer_ticks; uint8_t pop_show_time; +/* Накопленные кадры луча, ещё не сложившиеся в тик. Живёт между кадрами: + * при FAST логический кадр короче эталона, и остаток переносится вперёд. */ +static uint8_t tick_acc; + void pop_timer_new_game(void) __banked { pop_timer_minutes = POP_TIMER_START_MINUTES; pop_timer_ticks = POP_TIMER_START_TICKS; 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; + /* ХОД ЧАСОВ ОТВЯЗАН ОТ ТЕМПА. Тик стоит `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; if (pop_timer_ticks != 0) { diff --git a/applications/SprPoP/src/pop_timer.h b/applications/SprPoP/src/pop_timer.h index b17c97b..72ce854 100644 --- a/applications/SprPoP/src/pop_timer.h +++ b/applications/SprPoP/src/pop_timer.h @@ -35,7 +35,11 @@ void pop_timer_new_game(void) __banked; * may_run — правила текущей сцены/уровня. Возвращает 1 ОДИН раз, когда * отсчёт дошёл до нуля; на паузе, 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, * плюс добавляет минуту. Они меняют счётчик и при выключенном лимите — diff --git a/applications/SprPoP/src/sprpop.c b/applications/SprPoP/src/sprpop.c index 4f09a00..a3216ab 100644 --- a/applications/SprPoP/src/sprpop.c +++ b/applications/SprPoP/src/sprpop.c @@ -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` * (см. 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) { 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, (uint8_t)(Kid.alive < 0 && (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) { /* Время ИДЁТ и на уровне Джафара (до открытия двери), но * КОНЧИТЬСЯ игра там уже не может: expired() у оригинала стоит diff --git a/applications/SprPoP/src/sprpop_cold.c b/applications/SprPoP/src/sprpop_cold.c index 422d69f..5971e3f 100644 --- a/applications/SprPoP/src/sprpop_cold.c +++ b/applications/SprPoP/src/sprpop_cold.c @@ -115,10 +115,10 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked mrc = pop_menu_process(); } if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save(); - else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load(); + else if (mrc == POP_MENU_QUICKLOAD) { pop_music_stop(); pop_qsave_request_load(); } else if (mrc == POP_MENU_QUICKLOAD_BACKUP) - pop_qsave_request_load_backup(); - else if (mrc == POP_MENU_RESTART_LEVEL) *restart_level = 1; + { pop_music_stop(); pop_qsave_request_load_backup(); } + else if (mrc == POP_MENU_RESTART_LEVEL) { pop_music_stop(); *restart_level = 1; } else if (mrc == POP_MENU_RESTART_GAME) return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1); else if (mrc == POP_MENU_QUIT) @@ -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)) return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1); /* 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)); - if (r && !restart_prev) *restart_level = 1; + if (r && !restart_prev) { pop_music_stop(); *restart_level = 1; } restart_prev = r; } /* 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); 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; if (pop_qsave_process() > 0) *dead_reset = 1; } diff --git a/applications/SprPoP/tests/host/t_timer.c b/applications/SprPoP/tests/host/t_timer.c index b28ad27..cd9e904 100644 --- a/applications/SprPoP/tests/host/t_timer.c +++ b/applications/SprPoP/tests/host/t_timer.c @@ -35,17 +35,17 @@ TC_TEST(timer_starts_and_only_runs_when_allowed) pop_timer_new_game(); TC_EQ(pop_timer_minutes, POP_TIMER_START_MINUTES); 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_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); pop_timer_minutes = 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_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_ticks, POP_TIMER_START_TICKS); } @@ -54,11 +54,11 @@ TC_TEST(timer_expiry_is_one_shot) { pop_timer_minutes = 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_ticks, POP_TIMER_START_TICKS); - TC_FALSE(pop_timer_tick(1, 1)); - TC_FALSE(pop_timer_tick(0, 1)); + TC_FALSE(pop_timer_tick(1, 1, 4, 4)); + TC_FALSE(pop_timer_tick(0, 1, 4, 4)); } 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); } +/* --- ХОД ЧАСОВ НЕ ЗАВИСИТ ОТ РЕЖИМА СКОРОСТИ ------------------------- * + * + * Тик стоит `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) { TC_RUN(timer_starts_and_only_runs_when_allowed); TC_RUN(timer_expiry_is_one_shot); TC_RUN(timer_cheats_match_sdlpop_rules); 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); } diff --git a/applications/SprPoP/tools/paths.py b/applications/SprPoP/tools/paths.py index 0638cf1..b040b18 100644 --- a/applications/SprPoP/tools/paths.py +++ b/applications/SprPoP/tools/paths.py @@ -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"): """(каталог, шаблон имени) контейнеров оцифровки для источника.""" if name not in SND_SOURCES: diff --git a/applications/SprPoP/tools/pop_pack_sound.py b/applications/SprPoP/tools/pop_pack_sound.py index 231470f..c91a1fa 100644 --- a/applications/SprPoP/tools/pop_pack_sound.py +++ b/applications/SprPoP/tools/pop_pack_sound.py @@ -108,6 +108,69 @@ def resample(src, src_rate, dst_rate): 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(" те же 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(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES), @@ -151,6 +214,33 @@ def main(): if rate != 11000: 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. # ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL # всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост