Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e8ed4676 | |||
| 8490288d79 | |||
| 31d0075090 | |||
| 6176f6da31 | |||
| 7377bf6a2c | |||
| b34997073e | |||
| 63bfd997a9 | |||
| decbec79de | |||
| b0e7130d0b | |||
| 4e12aa50d1 | |||
| c218e8b983 | |||
| a3aaa30e53 | |||
| 90e304f071 | |||
| b55d4d11e3 | |||
| ca67895667 | |||
| a5252b1c60 | |||
| 3a0e847353 |
Binary file not shown.
Binary file not shown.
@@ -38,6 +38,8 @@
|
||||
| [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 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
|
||||
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
|
||||
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1313,3 +1315,130 @@ uint16_t pop_music_left(void) { uint16_t a, b;
|
||||
раз в кадр, задержка прерывания ничтожна против 11,7 мс периода насоса),
|
||||
но двойное чтение не трогает состояние прерываний вовсе — на фоне
|
||||
[cbl_w0_bios_conflict](../../PoP/roomtest/BUGS_CLOSED.md) это плюс.
|
||||
|
||||
|
||||
## CLIMB-VS-GUARD
|
||||
|
||||
Кид стоит рядом 2,6, страж — этажом выше на 1,6. Кид тянется подтянуться
|
||||
на 1,6.
|
||||
|
||||
**Оригинал (SDLPoP).** Страж машет мечом, Кид срывается обратно на 2,6
|
||||
**без потери HP**.
|
||||
|
||||
**У нас.** Тот же замах убивает Кида на месте. Если Кид безоружен, это
|
||||
мгновенная смерть (ветка «заколот без меча», HP разом в ноль) — и дальше
|
||||
тело падает, что до 2026-08-31 давало отдельный симптом «мёртвый
|
||||
вприсядку» (закрыт развилкой в `land()`, см. коммит того же дня).
|
||||
|
||||
**Наблюдение сверх того (пользователь, 2026-08-31).** В этой же связке
|
||||
поведение расходится сильнее: страж провалился с ряда 1 на ряд 2 ВМЕСТЕ с
|
||||
Кидом — причём Кид ушёл в провал в колонке 7 (там дыра), а страж между
|
||||
колонками 5 и 6, где на ряду 1 пол ЕСТЬ. То есть страж проваливается
|
||||
сквозь целый пол. Это может быть тем же корнем, что и удар: обоим нужен
|
||||
корректный ряд/колонка персонажа в момент подтягивания.
|
||||
|
||||
**Что уже проверено статически (2026-08-31).** Вся цепочка засчитывания
|
||||
удара сверена с оригиналом и совпадает ДОСЛОВНО:
|
||||
|
||||
- гейт по кадру Кида (нулевой кадр и кадры выхода по лестнице пропускают
|
||||
разбор целиком) — `pop_check_sword_hurting`;
|
||||
- приоритет стража при встречном попадании (отметка «ранен» у Кида
|
||||
снимается) — `pop_check_sword_hurt`;
|
||||
- требование ОДНОГО ряда у бьющего и жертвы — `check_hurting`;
|
||||
- кадры удара (укол/третий удар), диапазон дистанции (8 для безоружной
|
||||
жертвы, 12 для вооружённой, верхняя граница 29);
|
||||
- формула расстояния `pop_char_opp_dist` — совпадает с `char_opp_dist`.
|
||||
|
||||
Значит расходятся не правила, а ВХОДНЫЕ данные: ряд, колонка или X
|
||||
персонажа во время виса/подтягивания. Наиболее вероятный кандидат —
|
||||
дистанция: висящий вплотную под кромкой в оригинале не дотягивает до
|
||||
нижнего порога 8, а у нас пара пикселей переводит его через порог.
|
||||
|
||||
**Как чинить.** Не гадать — замерить на живой сцене: в момент, когда
|
||||
`Opp.action` становится «ранен», снять у обоих `curr_row`, `curr_col`, `x`,
|
||||
`direction` и сравнить с теми же величинами в SDLPoP на том же кадре
|
||||
(метод — lldb-трасса живого SDLPoP, как в
|
||||
[[sdlpop_odd_pixel_char_x]]). Отдельно снять, почему страж теряет опору:
|
||||
`get_tile_at_char` под ним в кадре провала.
|
||||
|
||||
**Уточнение пользователя (2026-08-31, важное).** Оригинальное поведение —
|
||||
Кид сорвался без потери HP — ВОСПРОИЗВОДИТСЯ и у нас: исход сильно зависит
|
||||
от того, где именно стоит страж. То есть расхождение не абсолютное, а
|
||||
пороговое, и это прямо подкрепляет версию про ДИСТАНЦИЮ: пара пикселей
|
||||
переводит расстояние через нижний порог засчитывания удара (8 для
|
||||
безоружной жертвы), и удар из «мимо» становится смертельным. Значит
|
||||
искать надо не потерянную ветку, а сдвиг координаты/порога — сравнивать
|
||||
`pop_char_opp_dist` в момент замаха при ОДИНАКОВОЙ расстановке.
|
||||
|
||||
**ЗВУК КАК ИНДИКАТОР ПУТИ (пользователь, 2026-08-31) — лучшая зацепка.**
|
||||
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
|
||||
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
|
||||
|
||||
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
|
||||
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
|
||||
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
|
||||
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
|
||||
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
|
||||
А звучащий вместо него упор в стену говорит, что у нас сработало
|
||||
СТОЛКНОВЕНИЕ, а не атака.
|
||||
|
||||
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
|
||||
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
|
||||
(1664 Б). Раскладка не сдвинута.
|
||||
|
||||
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
|
||||
«атака с промахом», а мы — по ветке «столкновение с персонажем»
|
||||
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
|
||||
сквозь пол стража: столкновение двигает его координату, а не атака.
|
||||
Проверять надо ветку выбора действия стража, а не только дистанцию.
|
||||
|
||||
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
|
||||
этой связке расходится широко, чинить нужно целиком, а не по одному
|
||||
симптому.
|
||||
|
||||
|
||||
## HP-BAR-RESTART
|
||||
|
||||
**Симптом (пользователь, 2026-08-31).** Гибель на 2-м уровне, возврат к
|
||||
началу по Ctrl+A: строка HP на одном из двух экранов остаётся отрисованной
|
||||
по результатам боя. Замечание там же: «в начале уровня она отрисовывается
|
||||
только в один экран».
|
||||
|
||||
**Что уже известно.**
|
||||
|
||||
Ctrl+A у нас — РЕСТАРТ УРОВНЯ (`sprpop_cold.c`, тот же путь, что пункт меню
|
||||
`POP_MENU_RESTART_LEVEL`), а не возврат в заставку.
|
||||
|
||||
Перерисовка полосы устроена счётчиком страниц, а не флагом:
|
||||
`pop_hp_invalidate` ставит `hp_todo = 2`, и `pop_hp_draw` тратит по одной
|
||||
странице за кадр (`src/pop_cdraw.c`). Все четыре холодных пути
|
||||
(старт уровня, вход в комнату, быстрая загрузка, возврат из меню)
|
||||
инвалидацию зовут — механизм на месте.
|
||||
|
||||
**Главный подозреваемый — зона статус-строки.** Полоса HP делит строку со
|
||||
статус-текстом, и пока текст висит (`pop_status_ticks != 0`), стирание
|
||||
чистит ТОЛЬКО края — левее `POP_STATUS_L` и правее `POP_STATUS_R`, —
|
||||
а середину не трогает, чтобы текст не мигал. При старте уровня текст как
|
||||
раз висит («LEVEL 2»), поэтому всё, что от прошлой полосы попало в
|
||||
середину, там и остаётся. Полоса стража рисуется справа налево от 314 и
|
||||
при большом запасе HP заходит именно в эту незачищаемую зону.
|
||||
|
||||
**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ,
|
||||
а это не одно и то же: между двумя вызовами отрисовки переворота может не
|
||||
быть, и оба прохода уходили в одну страницу — вторая оставалась с
|
||||
делениями прошлого боя. Фикс: проход тратится только когда
|
||||
`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый
|
||||
проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена
|
||||
там же — старая полоса могла заходить под статус-текст, где щадящая чистка
|
||||
её не трогала; текст сразу перезапрашивается, чтобы не пропал.
|
||||
|
||||
**Что проверить при взятии в работу.**
|
||||
|
||||
1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос:
|
||||
при скольких делениях полоса стража (или Кида) заходит под текст.
|
||||
2. Уходят ли оба прохода `hp_todo` в РАЗНЫЕ страницы на старте уровня —
|
||||
если между ними нет переворота, обе перерисовки лягут в одну.
|
||||
3. Возможное решение: на старте уровня чистить полосу во всю ширину
|
||||
независимо от статус-текста (текст всё равно перерисовывается заново
|
||||
через `pop_status_invalidate`), либо запоминать максимальную ширину
|
||||
прошлой полосы и стирать по ней.
|
||||
|
||||
@@ -1009,6 +1009,124 @@ tp/10 у факелов таблицей, пустой слот соперник
|
||||
|
||||
## P1 — берётся в любой момент
|
||||
|
||||
### <a id="time-speed-modes"></a>TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC)
|
||||
|
||||
**Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время
|
||||
начинает бежать быстрее — похоже, время мы считаем в наших логических
|
||||
кадрах, и если они отрисовываются чаще, то и время быстрее».
|
||||
|
||||
Так и есть. `pop_timer_tick` (`src/pop_timer.c`) уменьшает счётчик на
|
||||
КАЖДОМ логическом кадре — ровно как оригинал. Но длина логического кадра
|
||||
у нас зависит от режима скорости (`src/pop_pace.h`):
|
||||
|
||||
| режим | обычный кадр | бой |
|
||||
|---|---|---|
|
||||
| NORMAL | 4 кадра луча (81,9 мс) | 5 (102,4 мс) |
|
||||
| FAST | 3 (61,4 мс) | 4 (81,9 мс) |
|
||||
| FASTEST | 3 (61,4 мс) | 3 (61,4 мс) |
|
||||
|
||||
Разная длина кадра в игре и в бою — ПОВЕДЕНИЕ ОРИГИНАЛА (подтверждено
|
||||
пользователем), и часы, идущие в бою медленнее, трогать не нужно.
|
||||
Расхождение только в наших добавочных режимах: NORMAL повторяет оригинал,
|
||||
а FAST/FASTEST ускоряют всё разом, включая ход часов — минута игрового
|
||||
времени проходит примерно на треть быстрее реальной.
|
||||
|
||||
**Варианты.**
|
||||
|
||||
1. Оставить как есть: быстрый режим ускоряет игру целиком, это честно и
|
||||
предсказуемо. Ноль работы и ноль риска.
|
||||
2. Развязать часы от темпа: тикать не по логическому кадру, а по
|
||||
накопленным кадрам ЛУЧА (4 кадра луча = 1 тик). Тогда минута остаётся
|
||||
минутой в любом режиме, а в бою часы по-прежнему замедляются, как в
|
||||
оригинале. Цена — счётчик-накопитель в `pop_timer_tick`.
|
||||
|
||||
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, лечит
|
||||
и накопление — но требует разобраться с паузами).
|
||||
|
||||
|
||||
### <a id="snd-speaker-38"></a>SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31
|
||||
|
||||
**Постановка (пользователь, 2026-08-31):** «когда надпись Press Button
|
||||
начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас
|
||||
тишина». Решение выбрано там же: «проще синтезировать как PCM и добавить
|
||||
в наш SND атлас».
|
||||
|
||||
**Наш код НЕ виноват и правки не требует.** `pop_dead_prompt`
|
||||
(`src/pop_status.c`) уже зовёт звук 38 на каждом появлении строки — ровно
|
||||
как оригинал. Пусто в НАБОРЕ: в `assets/packed/SND/snd.idx` слот 38 имеет
|
||||
длину 0, играть нечего.
|
||||
|
||||
**Почему его нет.** У оригинала три параллельных набора звука, и номера
|
||||
разложены по ним не подряд:
|
||||
|
||||
| набор | что | номера | у нас |
|
||||
|---|---|---|---|
|
||||
| `DIGISND1..3.DAT` | оцифровка | 0–23, 44–49, 51 | берём, это и есть наш SND |
|
||||
| `MIDISND1..2.DAT` | мелодии | 24–30, 32, 33, 35–37, 39–41, 43, 50, 52–56 | берём отдельно, как музыку в `MUS/` |
|
||||
| `IBM_SND1..2` | ноты PC-спикера | 0–56 (весь диапазон) | НЕ берём вовсе |
|
||||
|
||||
Звук 38 есть ТОЛЬКО в наборе спикера — ни оцифровки, ни мелодии для него
|
||||
не существует, поэтому он и провалился между двумя нашими конвейерами.
|
||||
|
||||
**Полная ревизия недостающего (сделана 2026-08-31).** Номера, которых нет
|
||||
ни в оцифровке, ни среди мелодий: **31, 34, 38, 42**. Из них 31, 34 и 42 —
|
||||
пустые заглушки в один байт, нот внутри нет. **Реально звучит ровно один
|
||||
номер — 38.** То есть задача закрывает единственную дыру в наборе, а не
|
||||
открывает семейство.
|
||||
|
||||
**Формат ресурса** (канон — `docs/PoP/POP-DAT-FormatSpecifications.pdf`,
|
||||
раздел «Internal PC Speaker»): заголовок 3 байта, из них байт 1 — темп в
|
||||
долях на две секунды; далее тройки «частота в герцах (2 байта) + длина в
|
||||
долях (1 байт)», нулевая частота = пауза; в конце маркер `12 00`.
|
||||
|
||||
Звук 38 (`IBM_SND1/res10038.bin`, 17 байт) — нисходящий сигнал из четырёх
|
||||
нот: 2500, 2000, 1500, 1000 Гц по одной доле, темп 72 → около 110 мс.
|
||||
Для сверки разбора: звук 17 (мягкое приземление) — одна нота 49 Гц на три
|
||||
доли.
|
||||
|
||||
**Что делать.**
|
||||
|
||||
1. В `tools/pop_pack_sound.py` — генератор PCM из нот: меандр на нашей
|
||||
частоте вывода (`RATE`), амплитуда умеренная (сигнал короткий и резкий,
|
||||
полный размах будет колоть ухо), пауза = уровень тишины `0x80`.
|
||||
2. Брать из спикера ТОЛЬКО те номера, которых нет ни в оцифровке, ни в
|
||||
мелодиях — сейчас это ровно 38. Правило важнее списка: если брать всё
|
||||
подряд, синтез перекроет собой мелодии, которые мы играем из `MUS/`.
|
||||
3. Дальше всё уже готово: звук ложится в атлас и индекс общим путём,
|
||||
`snd.idx` пересобирается, EXE не меняется (раскладка читается с диска).
|
||||
4. Проверка: `make resources` → слот 38 в индексе получил ненулевую длину;
|
||||
в MAME дождаться мигания надписи после смерти — должен звучать сигнал.
|
||||
|
||||
**Цена.** Один короткий звук в наборе (~2 КБ после выравнивания на блок),
|
||||
кода в игре — ноль.
|
||||
|
||||
|
||||
### <a id="perf-sweep"></a>PERF-SWEEP. Поиск узких мест по ВСЕЙ игре, а не в одной сцене
|
||||
|
||||
**Постановка (пользователь, 2026-08-18):** «пока мы тестируем на регресс
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
# Аудит расхождений с SDLPoP: Кид, стражи, seqtbl, отрисовка
|
||||
|
||||
> Начат 2026-08-31. КОД НЕ МЕНЯЛСЯ — это только разбор. Задача: найти
|
||||
> места, где наш движок может вести себя иначе, чем оригинал, и оценить
|
||||
> вероятность того, что расхождение реально.
|
||||
|
||||
## Как читать
|
||||
|
||||
Ранги вероятности того, что расхождение ЕСТЬ и проявляется в игре:
|
||||
|
||||
| ранг | смысл |
|
||||
|---|---|
|
||||
| **А** | гарантированное различие: код объективно разный, эффект понятен |
|
||||
| **Б** | весьма вероятное: код разный, эффект вероятен, но не доказан |
|
||||
| **В** | средневероятное: код разный, но эффект может гаситься другим местом |
|
||||
| **Г** | маловероятное: различие есть в форме, эффект скорее отсутствует |
|
||||
| **Д** | почти невероятное: сходство подтверждено, остаётся крайний случай |
|
||||
|
||||
Ссылки вида `seg005:114` — строка в `assets/orig/SDLPoP/src/`. Наши
|
||||
ссылки — `файл:строка` в `src/`.
|
||||
|
||||
## Метод и охват
|
||||
|
||||
Сравниваются НАШИ реализации с оригиналом построчно по функциям. Первый
|
||||
проход (2026-08-31) охватил:
|
||||
|
||||
* диспетчер `control()` (seg005:251) — целиком;
|
||||
* `land()` (seg005:114) и `start_fall()` (seg006:1099);
|
||||
* цепочку смерти: `control_kid` (seg006:1390), `play_kid` (seg006:1348),
|
||||
`take_hp` (seg006:986), `control()` ветка `alive >= 0`.
|
||||
|
||||
НЕ охвачено первым проходом (список для следующих):
|
||||
|
||||
* интерпретатор `play_seq` и полный набор опкодов seqtbl;
|
||||
* `frame_table` и модификаторы кадров;
|
||||
* бой целиком: `control_with_sword`, парирование, `strike`, `hurt_by_sword`;
|
||||
* ИИ стражей (`guard_ai`), особенности скелета, Тени, Джафара;
|
||||
* `add_kid_to_objtable`/`add_guard_to_objtable`, порядок слоёв, `clip_char`;
|
||||
* `check_bumped` — сверен ЧАСТИЧНО (находки 14, 15); `check_grab`,
|
||||
`in_wall`, `check_bumped_look_left` — нет;
|
||||
* `do_fall` целиком (проверен только вход).
|
||||
|
||||
---
|
||||
|
||||
## Симптом, с которого начат аудит
|
||||
|
||||
**Наблюдение (пользователь, 2026-08-31):** идёт бой, за Кидом провал на
|
||||
этаж. Страж колет на последнем HP, Кид отшатывается назад и падает.
|
||||
Кид умирает — но на экране он этажом ниже В ПРИСЕДЕ, как после мягкого
|
||||
приземления.
|
||||
|
||||
**Что говорит код SDLPoP.** Разбор цепочки:
|
||||
|
||||
1. `start_fall` (seg006:1099) ПЕРВЫМ ДЕЛОМ убирает меч
|
||||
(`Char.sword = sword_0_sheathed`) — для любого падения, независимо от
|
||||
здоровья. Значит к моменту приземления меч уже в ножнах.
|
||||
2. `land` (seg005:114) при падении на один ряд выбирает
|
||||
`seq_63_guard_active_after_fall`, только если `charid >= guard` ИЛИ
|
||||
меч вынут; иначе — `seq_17_soft_land`, то есть ПРИСЕД. Из-за п.1 для
|
||||
Кида это всегда присед.
|
||||
3. `land` НЕ смотрит ни на `alive`, ни на `hitp_curr` вовсе.
|
||||
4. `control` (seg005:251) при мёртвом персонаже (`alive >= 0`) не
|
||||
диспетчеризует ничего; он переводит в `seq_71_dying` ТОЛЬКО из
|
||||
четырёх кадров стойки (15, 166, 158, 171). Присед в этот список не
|
||||
входит.
|
||||
|
||||
**Вывод:** по букве оригинала мёртвый Кид, застигнутый смертью в полёте,
|
||||
тоже долетает, приземляется в присед и остаётся в нём — `control` его не
|
||||
трогает. То есть наблюдаемое, СКОРЕЕ ВСЕГО, воспроизводится и в SDLPoP.
|
||||
|
||||
Гипотеза «hp стал нулевым, поэтому спрятали меч» кодом НЕ подтверждается:
|
||||
единственное место, где SDLPoP связывает `hitp_curr == 0` с чем-либо, —
|
||||
`control_kid` (seg006:1395), и там взводится только `Char.alive = 0`.
|
||||
|
||||
**Как проверить окончательно:** прогнать сцену на живом SDLPoP (он
|
||||
собирается в `assets/orig/SDLPoP/`), где для этого уже есть наши врезки
|
||||
`POP_TRACE`. До проверки считаю симптом НЕ доказанным расхождением —
|
||||
ранг **В** (см. находку 5).
|
||||
|
||||
---
|
||||
|
||||
## Находки первого прохода
|
||||
|
||||
### 1. `land()`: лишний `determine_col()` — ранг **А**
|
||||
|
||||
*Оригинал:* `land` (seg005:114) заканчивается тремя действиями:
|
||||
`seqtbl_offset_char(seq_id)`, `play_seq()`, `Char.fall_y = 0`. Никакого
|
||||
пересчёта колонки.
|
||||
|
||||
*У нас:* `pop_map.c:715` (и в ветке смерти `:705`) после `play_seq()`
|
||||
дополнительно вызывается `determine_col()`.
|
||||
|
||||
*Чем грозит:* `determine_col` пересчитывает `Char.curr_col` из `Char.x`.
|
||||
Оригинал делает это в другом месте и в другой момент —
|
||||
`load_fram_det_col` (seg006:0144) перед разбором кадра. Лишний пересчёт
|
||||
сразу после приземления может дать другую колонку, если `play_seq`
|
||||
успел сдвинуть `x` первым же `dx`. Колонка — вход для проверок тайла,
|
||||
пик и коллизий.
|
||||
|
||||
*Замечание:* приём применён у нас системно (`pop_map.c` — 8 вызовов), то
|
||||
есть это, вероятно, осознанная адаптация, но в `docs/impl_diff.md` она НЕ
|
||||
записана. Нужно либо обосновать и записать, либо снять.
|
||||
|
||||
### 2. `land()`: обнуляется ещё и `fall_x` — ранг **Б**
|
||||
|
||||
*Оригинал:* в `land` обнуляется ТОЛЬКО `fall_y`, и притом в самом конце,
|
||||
ПОСЛЕ `play_seq()`.
|
||||
|
||||
*У нас:* `pop_map.c:697` и `:713` обнуляют пару `Char.fall_x = Char.fall_y = 0`,
|
||||
причём ДО `pop_char_set_seq`/`play_seq`.
|
||||
|
||||
*Чем грозит:* два отличия сразу. Во-первых, `fall_x` в оригинале
|
||||
переживает приземление — если наш сброс лишний, теряется горизонтальный
|
||||
импульс, влияющий на последующие кадры. Во-вторых, момент: если
|
||||
`play_seq` читает `fall_y` (а он читает при обработке своих опкодов
|
||||
движения), оригинал видит ещё НЕ обнулённое значение, а мы — уже ноль.
|
||||
|
||||
### 3. `land()`: проверка пик до коррекции X — ранг **Б**
|
||||
|
||||
*Оригинал:* сначала (внутри ветки «тайл под ногами не пика») делается
|
||||
коррекция `Char.x = char_dx_forward(-3)` при `distance_to_edge_weight() < 3`,
|
||||
и лишь ПОТОМ проверяется падение на пики, причём условие для пики ПОЗАДИ
|
||||
использует `distance_to_edge_weight() >= 12`.
|
||||
|
||||
*У нас:* `pop_map.c:664` — `fell_on_spikes()` вызывается ПЕРВЫМ, до
|
||||
коррекции X.
|
||||
|
||||
*Чем грозит:* `distance_to_edge_weight()` считается от `Char.x`, а
|
||||
коррекция этот `x` меняет на 3 пикселя. У края тайла порядок решает,
|
||||
попадёт ли персонаж в ветку «пика позади» — то есть умрёт он или нет.
|
||||
|
||||
### 4. Смерть: у нас свой флаг вместо счётчика — ранг **В**
|
||||
|
||||
*Оригинал:* `play_kid` (seg006:1348) после смерти ведёт СЧЁТЧИК
|
||||
`Char.alive`, и по его значениям запускает музыку смерти (`alive == 6`) и
|
||||
надпись «Press Button to Continue» (`alive == 7`), причём переход
|
||||
задерживается, пока играет звук (`check_sound_playing`).
|
||||
|
||||
*У нас:* `pop_ctrl.c` (`ctrl_kid_death`) взводит `pop_kid_dead` — сигнал
|
||||
главному циклу на респавн; счётчика стадий нет.
|
||||
|
||||
*Чем грозит:* момент респавна и порядок «музыка смерти → сообщение →
|
||||
рестарт» могут отличаться, особенно если смерть застала персонажа в
|
||||
длинной анимации (падение). Сюда же относится симптом выше: в оригинале
|
||||
поза сохраняется, пока крутится счётчик.
|
||||
|
||||
### 5. Мёртвый доигрывает приземление — ранг **В**
|
||||
|
||||
Разобрано выше. Код у нас и в оригинале В ЭТОМ МЕСТЕ совпадает, поэтому
|
||||
ранг не выше среднего: расхождение может сидеть не в `land`, а в моменте
|
||||
взведения смерти (находка 4) — тогда оригинал успевает поставить кадр
|
||||
смерти до приземления, а мы нет. Проверяется прогоном на живом SDLPoP.
|
||||
|
||||
### 6. Диспетчер `control()` — ранг **Д**
|
||||
|
||||
Сверен ветка в ветку (seg005:251 против `pop_ctrl.c:618`): совпадают и
|
||||
порядок проверок (`bumped/freefall` → меч → charid → кадры), и границы
|
||||
диапазонов кадров, и обработка мёртвого. Расхождений не видно; остаются
|
||||
только опциональные `#ifdef`-фиксы SDLPoP, которых у нас нет намеренно.
|
||||
|
||||
### 7. `JMP_IF_FEATHER`: у нас эффект только для Кида — ранг **Б**
|
||||
|
||||
*Оригинал:* опкод `SEQ_JMP_IF_FEATHER` (seg006, play_seq) смотрит ТОЛЬКО
|
||||
на глобальный `is_feather_fall`. Кто именно проигрывает последовательность,
|
||||
роли не играет.
|
||||
|
||||
*У нас:* `pop_kid.c:306` добавляет условие `Char.charid != CHARID_0_KID` —
|
||||
для всех, кроме Кида, ветка «пера» не берётся никогда.
|
||||
|
||||
*Чем грозит:* под зельем медленного падения любой НЕ-Кид, попавший в
|
||||
последовательности `stepfloat`/`bumpfloat`, у нас пойдёт по обычной ветке
|
||||
(с уроном), а в оригинале — по парящей. Практически это Тень (charid 1) на
|
||||
уровне 4-6 и скелет; страж в эти seq попадает редко, но попадает через
|
||||
`bumpfloat` при отскоке.
|
||||
|
||||
*Замечание:* отличие ОСОЗНАННОЕ (в комментарии сказано «эффект достаётся
|
||||
только Киду — как и сама физика пера»), но в `docs/impl_diff.md` не
|
||||
записано, хотя правило проекта этого требует.
|
||||
|
||||
### 8. `start_chompers` отложен до конца `play_seq` — ранг **Б**
|
||||
|
||||
*Оригинал:* опкоды `SEQ_UP`/`SEQ_DOWN` меняют ряд и ТУТ ЖЕ зовут
|
||||
`start_chompers()` — то есть внутри цикла интерпретатора, до разбора
|
||||
следующих опкодов.
|
||||
|
||||
*У нас:* `pop_kid.c:318-325` только взводит `chomp_pending`, а сам вызов
|
||||
происходит после выхода из цикла (`:396`). Причина архитектурная и
|
||||
описана в коде: seqtbl читается через окно W0, а `start_chompers` лезет в
|
||||
другое окно.
|
||||
|
||||
*Чем грозит:* два следствия. Во-первых, последовательность с ДВУМЯ
|
||||
сменами ряда (`UP` `UP`, спуск/подъём по лестнице) в оригинале будит
|
||||
чомперов в обоих рядах, у нас — только в конечном. Во-вторых, между
|
||||
`SEQ_UP` и концом цикла успевают отработать `dx`/`dy`/`action`, то есть
|
||||
оригинал будит чомперов с ДРУГИМИ координатами персонажа.
|
||||
|
||||
Симптом «челюсти не заводятся» уже ловился в этом проекте (memory
|
||||
`pop_chomper_needs_trigger`), и это место — кандидат в его причины.
|
||||
|
||||
### 9. Набор опкодов seqtbl — ранг **Д**
|
||||
|
||||
Сверены все пятнадцать кодов (`0xF1`..`0xFF`): совпадают и значения, и
|
||||
семантика, включая проваливание `JMP_IF_FEATHER` в `JMP` и то, что
|
||||
`SEQ_DIE` — пустышка в обоих движках. Отличия только в находках 7 и 8.
|
||||
|
||||
### 10. Отрисовка: шаги те же, но разнесены — ранг **В**
|
||||
|
||||
*Оригинал:* `add_kid_to_objtable` (seg008:1667) и его двойник для стража —
|
||||
это строго упорядоченная цепочка: `loadkid`/`loadshad` →
|
||||
`load_fram_det_col` → `load_frame_to_obj` → `stuck_lower` →
|
||||
`set_char_collision` → `set_objtile_at_char` → `redraw_at_char` →
|
||||
`redraw_at_char2` → `clip_char` → `add_objtable`.
|
||||
|
||||
*У нас:* все звенья присутствуют, но распределены по слоям: `clip_char`,
|
||||
`load_frame_to_obj`, `check_mirror`, брызги — в `pop_cdraw.c`;
|
||||
`redraw_at_char`/`set_objtile_at_char`/`set_char_collision` — в `pop_bg.c`
|
||||
(единый проход на всех Char, см. CLAUDE.md).
|
||||
|
||||
*Чем грозит:* сам по себе перенос не ошибка, но ПОРЯДОК внутри цепочки
|
||||
влияет на результат: `set_char_collision` и `set_objtile_at_char` готовят
|
||||
данные, которыми пользуются `redraw_at_char` и `clip_char`. Если наш
|
||||
общий проход выполняет их для ОБОИХ персонажей до отрисовки, а оригинал —
|
||||
для каждого непосредственно перед его выводом, то при наложении Кида и
|
||||
стража состояние на момент клипа будет разным.
|
||||
|
||||
*Отдельно:* `stuck_lower` найден только в `pop_cdraw.h` — надо убедиться,
|
||||
что он реализован, а не только объявлен. Если его нет, персонаж,
|
||||
застрявший на границе тайла, будет рисоваться на пиксель выше.
|
||||
|
||||
### 11. Порядок вывода Кида и стража — ранг **В**
|
||||
|
||||
*Оригинал:* `draw_people` (seg008:1635) всегда ставит сначала Кида
|
||||
(`draw_kid`), затем стража (`draw_guard`), а КТО ОКАЖЕТСЯ СВЕРХУ решает
|
||||
`add_objtable` — таблица объектов упорядочена по позиции тайла.
|
||||
|
||||
*У нас:* по CLAUDE.md порядок задаёт обход тайлов, «кто позже — тот
|
||||
поверх». Это близко по смыслу, но не тождественно сортировке objtable.
|
||||
|
||||
*Чем грозит:* при наложении персонажей (бой вплотную, страж перед Кидом)
|
||||
верхний может оказаться другим. Проверять сравнением кадров боя вплотную
|
||||
с эталонным SDLPoP (метод — memory `pop_pixel_diff_vs_sdlpop`).
|
||||
|
||||
### 12. `hurt_by_sword`: ветка «сбит с уступа» не портирована — ранг **А**
|
||||
|
||||
*Оригинал:* `hurt_by_sword` (seg002:911) при уколе ВООРУЖЁННОГО персонажа
|
||||
выбирает одну из двух смертей по обстановке ПОЗАДИ:
|
||||
|
||||
* тайл позади не пустой ИЛИ до кромки меньше 4 → `seq_85_stabbed_to_death`
|
||||
(заколот на месте);
|
||||
* иначе → `seq_81_kid_pushed_off_ledge` — отдельная последовательность
|
||||
«убит и сброшен с уступа», которая сама отыгрывает падение замертво.
|
||||
|
||||
*У нас:* `guards.c:1015` — ветки `seq_81` НЕТ вовсе, всегда `seq_85`.
|
||||
Упрощение ЗАДОКУМЕНТИРОВАНО в комментарии (`guards.c:1011`): ей нужны
|
||||
тайловые запросы от `Char`, а `pop_map` умеет их только от `Kid`.
|
||||
|
||||
*Чем грозит:* именно тем, что наблюдал пользователь. Заколотый на краю
|
||||
обрыва Кид в оригинале уходит в собственную анимацию падения с уступа; у
|
||||
нас он получает «смерть на месте», продолжая при этом висеть в воздухе —
|
||||
дальше им распоряжается обычная физика падения, и он приземляется этажом
|
||||
ниже по общим правилам (а с убранным в `start_fall` мечом — в присед,
|
||||
находка 5).
|
||||
|
||||
*Как проверить:* поставить Кида спиной к обрыву с 1 HP и дать стражу
|
||||
уколоть. В оригинале — падение замертво (кадры seq_81), у нас — смерть
|
||||
на месте с последующим отдельным падением.
|
||||
|
||||
### 13. `hurt_by_sword`: прижатие к полу стало безусловным — ранг **А**
|
||||
|
||||
*Оригинал:* `Char.y = y_land[Char.curr_row + 1]` и `Char.fall_y = 0`
|
||||
выполняются ТОЛЬКО в ветке выжившего удара (seg002:962, рядом с
|
||||
`seq_74_hit_by_sword`). Смертельные ветки координату не трогают.
|
||||
|
||||
*У нас:* `guards.c` — те же две строки стоят ПОСЛЕ всего `if/else`, то
|
||||
есть выполняются и при смерти тоже.
|
||||
|
||||
*Чем грозит:* персонажа, убитого в воздухе, мы принудительно ставим на
|
||||
пол текущего ряда и обнуляем накопленную скорость падения. Дальше физика
|
||||
обнаруживает, что пола под ним нет, и запускает падение ЗАНОВО — уже без
|
||||
`fall_y`, то есть с другой высотой и другим исходом приземления. Это
|
||||
вторая половина механизма из находки 12 и вероятная причина того, что
|
||||
мёртвый Кид доезжает до нижнего этажа «своим ходом».
|
||||
|
||||
*Как проверить:* тот же сценарий; в отладчике смотреть `Char.y` и
|
||||
`fall_y` сразу после попадания — оригинал их не меняет.
|
||||
|
||||
### 14. Отскок с мечом: нет `seq_64` — ранг **Б**
|
||||
|
||||
*Оригинал:* при отскоке (`bumped`, seg004:328) живой персонаж с вынутым
|
||||
мечом получает ОДНУ ИЗ ДВУХ последовательностей по направлению толчка:
|
||||
толкнули вперёд — `seq_65_bump_forward_with_sword`, отбросило назад —
|
||||
`seq_64_pushed_back_with_sword`.
|
||||
|
||||
*У нас:* `pop_map.c` знает только `SEQ_65_BUMP_FWD_SWORD` (объявлен на
|
||||
`:103`, используется на `:2231`); константы и ветки `seq_64` нет вовсе.
|
||||
|
||||
*Чем грозит:* персонаж, отброшенный назад с мечом (страж у стены, Кид в
|
||||
тесной комнате), проигрывает не ту анимацию — либо ветку без меча, либо
|
||||
`seq_65`. Кадры разные, а вместе с ними расходятся и смещения `dx` в
|
||||
последовательности, то есть итоговая позиция после отскока.
|
||||
|
||||
*Как проверить:* бой вплотную к стене, толчок в сторону стены и от неё;
|
||||
сверять кадры с эталонным прогоном SDLPoP.
|
||||
|
||||
### 15. `check_bumped_look_right`: гейт по направлению — ранг **В**
|
||||
|
||||
В нашей реализации (`pop_map.c:2148`) стоит ранний выход по
|
||||
`Char.direction` с пометкой «(меча в руке у нас нет)». Пометка означает,
|
||||
что ветка писалась до появления боя, а оригинал в этом месте учитывает и
|
||||
меч, и `push_direction` (находка 14). Область `check_bumped_look_left`
|
||||
не сверялась вовсе — её надо пройти отдельно.
|
||||
|
||||
### 16. `control_with_sword` — ранг **Д**
|
||||
|
||||
Сверен целиком (seg005:964 против `pop_ctrl.c:587`): гейт по `action`,
|
||||
условие «пол под ногами loose ИЛИ страж видит Кида», пороги дистанции
|
||||
(90 и −4), `seq_60_turn_with_sword`, ветка «соперник умер» с
|
||||
`seq_92_put_sword_away`, разделение по `charid`. Совпадает.
|
||||
|
||||
Отдельно отмечу: в оригинале сравнение дистанции сделано ЗНАКОВО-НЕЯВНО
|
||||
(приведением к `word`), из-за чего ветка «соперник за спиной» вообще
|
||||
достижима. У нас то же самое выражено явными знаковыми сравнениями — и
|
||||
диапазоны совпадают, включая «вплотную за спиной» (−4..−1), где обе
|
||||
реализации ведут бой, а не разворачиваются.
|
||||
|
||||
### 17. `parry` — ранг **Д**
|
||||
|
||||
Сверен целиком (seg005:1064 против `pop_ctrl.c:513`): список кадров
|
||||
стойки, порог 32 для не-Кида, обработка кадров соперника (151/152/162,
|
||||
особый случай 153 с отложенным `play_seq`), ветка стража по кадру 152,
|
||||
ветка `frame_167_blocked` с `seq_61`, сброс автоповтора `control_up`.
|
||||
Совпадает вплоть до порядка условий.
|
||||
|
||||
### 18. `check_hurting`: звук «меч в движении» в других условиях — ранг **Б**
|
||||
|
||||
*Оригинал:* звук 11 играется в САМОМ КОНЦЕ `check_hurting` (seg002) и
|
||||
только при трёх условиях сразу: направление персонажа не `none`, его кадр
|
||||
— укол (154), а соперник при этом НЕ парирует и НЕ ранен. Первое условие
|
||||
добавлено в SDLPoP специально против зацикливания звука.
|
||||
|
||||
*У нас:* `guards.c:1068` — звук играется в начале ветки укола,
|
||||
безусловно, ещё до того, как определено попадание.
|
||||
|
||||
*Чем грозит:* лишние срабатывания в двух ситуациях, где оригинал молчит —
|
||||
когда удар парирован и когда он попал. То есть в самой частой части боя
|
||||
звук звучит чаще, чем должен. Плюс отсутствует защита от зацикливания
|
||||
при `direction == none`.
|
||||
|
||||
*Как проверить:* бой с парирующим стражем; считать срабатывания звука 11
|
||||
на серии ударов и сравнить с эталонным прогоном SDLPoP.
|
||||
|
||||
### 19. Остальной бой сверен — ранг **Д**
|
||||
|
||||
Прочитаны целиком и совпадают:
|
||||
|
||||
* `swordfight` (seg005:998) — включая ветку кадра 161, `sword_strike`,
|
||||
побочные эффекты уборки меча (`offguard`, `guard_refrac`,
|
||||
`holding_sword`), разделение `seq_93`/`seq_92`/`seq_87` по `charid` и
|
||||
хвост (`parry` / `forward_with_sword` / `back_with_sword`);
|
||||
* `sword_strike` (seg005:1037) — список кадров, выбор `seq_75`/`seq_58`,
|
||||
`seq_66` после парирования, сброс автоповтора;
|
||||
* `check_sword_hurt` (seg002:971) — включая ПРИОРИТЕТ СТРАЖА при
|
||||
одновременном ранении и сброс `Kid.action` в бег, а также
|
||||
`refractimer` по навыку;
|
||||
* `check_hurting` в основной части — гейты по мечу, ряду и кадрам, пороги
|
||||
дистанции (29), `min_hurt_range` 8/12 по мечу соперника, ветка
|
||||
парирования с `justblocked` и `seq_69`. Единственное расхождение —
|
||||
звук, находка 18.
|
||||
|
||||
### 20. Стражи: «стена впереди» сужена до одного тайла — ранг **Б**
|
||||
|
||||
*Оригинал:* `guard_follows_kid_down` (seg002:811) и соседние ветки ИИ
|
||||
спрашивают `wall_type(tile) != 0`. Эта функция (seg006:1626) считает
|
||||
преградой ПЯТЬ видов тайлов: ворота, верх двери с полом, верх двери,
|
||||
зеркало, чомпер и собственно стену — с разной стороной блокировки.
|
||||
|
||||
*У нас:* `guards.c:683` и `:685` сравнивают тайл напрямую с `TILE_WALL`
|
||||
(тип 20). Ворота, верх двери, зеркало и чомпер преградой не считаются.
|
||||
|
||||
*Причина:* `wall_type` реализована у нас (`pop_map.c:504`, таблица на
|
||||
`:498`), но НЕ экспортирована — в `pop_map.h` её нет, поэтому `guards.c`
|
||||
до неё не дотягивается. То есть это не пробел в портировании логики, а
|
||||
следствие границы модулей.
|
||||
|
||||
*Чем грозит:* страж, преследующий упавшего Кида, у нас шагнёт вперёд там,
|
||||
где оригинал отступает — перед закрытыми воротами, верхом двери,
|
||||
зеркалом и чомпером. Отсюда возможны и проход стража сквозь препятствие,
|
||||
и падение туда, куда оригинал его не пускает.
|
||||
|
||||
*Как проверить:* уровень с воротами (например, 3-й) — заманить стража к
|
||||
закрытым воротам после падения Кида и сравнить, отступает ли он.
|
||||
|
||||
*Замечание:* в `guards.c` таких мест ЧЕТЫРЕ (`:148`, `:683`, `:685`);
|
||||
одно из них (`:148`) уже перечисляет три тайла вручную, то есть
|
||||
расхождение частично компенсировано, но не везде одинаково.
|
||||
|
||||
---
|
||||
|
||||
## Область: столкновение со стенами и падение внутри стены
|
||||
|
||||
Заведена по наблюдению пользователя (2026-08-31): разбег, прыжок сделан
|
||||
рано, Кид не долетел, врезался в стену и начал падать — но по X он
|
||||
оказался ВНУТРИ стены и падал частично в ней.
|
||||
|
||||
### 21. Фикс «скольжения сквозь стену» — ПОРТИРОВАН 2026-08-31
|
||||
|
||||
> Решением пользователя исправление взято в ТЕКУЩИЙ билд: играбельность
|
||||
> важнее буквальности. Реализация — `glide_through_wall_guard()` в
|
||||
> `pop_map.c`, вызывается из `do_fall` в ветке «ещё летим». Цена: +57
|
||||
> байт в банке 3, резидент и куча не изменились.
|
||||
>
|
||||
> Подтверждено host-тестом: в наборе `t_wall` число заходов в кладку
|
||||
> упало с двух до нуля, остальные 15 наборов остались зелёными.
|
||||
>
|
||||
> Ниже — исходный разбор, по которому принималось решение.
|
||||
|
||||
### 21a. Исходный разбор: было соответствие ванили — ранг **Г**
|
||||
|
||||
*Оригинал:* в `do_fall` (seg005:37) есть блок `FIX_GLIDE_THROUGH_WALL` с
|
||||
собственным комментарием SDLPoP: «Кид падает сквозь стены после разворота
|
||||
в беге, особенно в невесомости». Блок опциональный — то есть в ВАНИЛЬНОЙ
|
||||
игре этот баг ЕСТЬ, а SDLPoP его чинит по желанию. Рядом такие же
|
||||
опциональные `FIX_JUMP_THROUGH_WALL_ABOVE_GATE` и `FIX_DROP_THROUGH_TAPESTRY`.
|
||||
|
||||
*У нас:* ни один из трёх не портирован — мы намеренно повторяем ваниль.
|
||||
|
||||
*Вывод по симптому:* «падение частично в стене» — с большой вероятностью
|
||||
ОРИГИНАЛЬНОЕ поведение PoP, а не наша ошибка. Ранг Г означает: различия
|
||||
с ванилью, скорее всего, нет. Но проверить стоит другое — не ХУЖЕ ли у
|
||||
нас, чем в ванили (см. находки 22 и 15).
|
||||
|
||||
*Как проверить:* повторить сцену на живом SDLPoP с выключенными фиксами
|
||||
(они выключаемы в его настройках) и сравнить глубину захода в стену.
|
||||
|
||||
### 22. `do_fall`: наш гард `curr_row <= 2` — ранг **В**
|
||||
|
||||
*Оригинал:* в `do_fall` ветка «достигли нового ряда» выполняется БЕЗ
|
||||
условия на номер ряда: проверка тайла стены с вызовом выталкивания, затем
|
||||
`land()` либо переход на ряд ниже.
|
||||
|
||||
*У нас:* `pop_map.c:909` — вся ветка обёрнута в `if (Char.curr_row <= 2)`.
|
||||
Причина задокументирована (`:768`): наш `get_tile` за нижней кромкой
|
||||
комнаты отдаёт СТЕНУ как сентинель, тогда как в оригинале там комната
|
||||
снизу, и без гарда выталкивание срабатывало ложно, смещая падение на тайл.
|
||||
|
||||
*Чем грозит:* гард гасит не только ложные срабатывания. Если персонаж
|
||||
достиг `curr_row == 3` легитимно (падение между комнатами по вертикали),
|
||||
у нас не выполнится ни выталкивание из стены, ни `land()`, ни переход
|
||||
ряда — всё это ляжет на следующий кадр и другую ветку. Именно такая
|
||||
комбинация (падение у границы комнаты рядом со стеной) даёт кандидата в
|
||||
причины наблюдения пользователя.
|
||||
|
||||
*Как проверить:* падение вдоль стены точно на стыке комнат по вертикали;
|
||||
в отладчике смотреть `curr_row`, `Char.x` и факт вызова выталкивания.
|
||||
|
||||
### 23. `bumped_fall` — ранг **Д**
|
||||
|
||||
Сверен (seg004 против `pop_map.c`): откат X на 4 пикселя назад, обнуление
|
||||
горизонтальной скорости в свободном падении, иначе `seq_45_bumpfall` с
|
||||
проигрыванием, звук удара. Совпадает; у нас добавлен только флаг «стражи
|
||||
услышали», который в оригинале ставится внутри звуковой функции.
|
||||
|
||||
**Замечание по области:** глубина отката при столкновении — ровно 4
|
||||
пикселя в обоих движках. Если Кид вошёл в стену глубже (а при
|
||||
недолёте с разбега скорость по X велика), одного отката не хватит ни там,
|
||||
ни у нас — и дальше всё зависит от того, сработает ли выталкивание из
|
||||
стены на следующем кадре. У нас его может съесть гард из находки 22.
|
||||
Это главная зацепка по симптому.
|
||||
|
||||
### 24. `in_wall`: не перезагружается кадр — ранг **Б**, ОТЛОЖЕНА
|
||||
|
||||
> **Правка сделана и откачена 2026-08-31.** По букве оригинала находка
|
||||
> верна, но практического эффекта показать не удалось: все 15 наборов
|
||||
> host-тестов дали одинаковый результат до и после, включая специально
|
||||
> написанный тест на заход в кладку (`t_wall`). При этом правка не
|
||||
> бесплатна — добавляет маппинг окна и перезагрузку кадра на каждое
|
||||
> выталкивание из стены. Платить за недоказанное не стали.
|
||||
>
|
||||
> Задача переехала в `docs/vanilla_vs_bugfixed.md`: вернуться к ней при
|
||||
> работе над двумя режимами поведения, где появится сценарий, в котором
|
||||
> кадр меняется перед выталкиванием.
|
||||
|
||||
|
||||
*Оригинал:* `in_wall` (seg006) после выталкивания персонажа из стены
|
||||
делает `load_fram_det_col()` — ЗАГРУЖАЕТ КАДР и следом определяет колонку,
|
||||
затем перечитывает тайл.
|
||||
|
||||
*У нас:* `pop_map.c` (`in_wall`) вызывает только `determine_col()`.
|
||||
Пороги (`>= 8`), формулы смещения (`6 - d` и `d + 4`), условие по тайлу
|
||||
впереди и финальное чтение тайла совпадают — расходится только этот шаг.
|
||||
|
||||
*Чем грозит:* после выталкивания данные кадра (картинка, смещения, флаги
|
||||
— включая «нужен пол» и «чётный пиксель») остаются от позиции ДО
|
||||
коррекции, а ими пользуются проверки того же кадра: падение, клип,
|
||||
коллизия. Это ровно область, где наблюдалось падение внутри стены
|
||||
(находки 21, 22).
|
||||
|
||||
*Как проверить:* недолёт с разбега в стену; в отладчике сравнить `Char.x`,
|
||||
колонку и поля текущего кадра сразу после выталкивания.
|
||||
|
||||
### 25. Таблицы кадров и seqtbl — ранг **Д**
|
||||
|
||||
`frame_table_kid`, `original_seqtbl` и таблица смещений извлекаются
|
||||
АВТОМАТИЧЕСКИ из исходников SDLPoP (`tools/pop_extract_kid_data.py` →
|
||||
`gen/kid_data.h`), поэтому расхождение в данных маловероятно по
|
||||
построению. Применение тоже сверено: используются все четыре флага кадра
|
||||
(«нужен пол» 0x40, вес по X 0x1F, «тонкий» 0x20, чётный пиксель 0x80), а
|
||||
байт клинка маскируется как в оригинале (`& 0x3F`, `pop_kdraw.c:31`).
|
||||
|
||||
Не сверено: старшие два бита байта клинка (номер набора спрайтов) — у
|
||||
Кида он нулевой, у прочих персонажей стоит проверить отдельно.
|
||||
|
||||
### 26. Полнота автоуправления — ранг **Д**
|
||||
|
||||
Из двенадцати функций `autocontrol_*` оригинала у нас есть одиннадцать.
|
||||
Отсутствующая — тривиальная обёртка над общей логикой стража; у нас она
|
||||
встроена в вызывающего. Расхождения нет.
|
||||
|
||||
Не сверены ПОСТРОЧНО тела: `autocontrol_guard_kid_armed`,
|
||||
`autocontrol_guard_kid_far`, `autocontrol_shadow*`, `autocontrol_skeleton`,
|
||||
`check_grab`, `check_bumped_look_left`, `back_with_sword`,
|
||||
`forward_with_sword`.
|
||||
|
||||
---
|
||||
|
||||
## ИТОГ АУДИТА
|
||||
|
||||
Проверено 26 позиций за пять проходов.
|
||||
|
||||
| ранг | находки | суть |
|
||||
|---|---|---|
|
||||
| **А** | 1, 12, 13 | лишний пересчёт колонки в `land`; нет ветки «убит и сброшен с уступа»; безусловное прижатие к полу при смерти |
|
||||
| **Б** | 2, 3, 7, 8, 14, 18, 20, 24 | `fall_x` и момент сброса; порядок «пики / коррекция X»; перо только для Кида; отложенные чомперы; нет `seq_64`; звук удара; «стена» сужена до одного тайла; нет перезагрузки кадра в `in_wall` |
|
||||
| **В** | 4, 5, 10, 11, 15, 22 | флаг смерти вместо счётчика стадий; мёртвый доигрывает приземление; разнесённая цепочка отрисовки; порядок Кид/страж; гейт в `check_bumped_look_right`; гард `curr_row <= 2` в `do_fall` |
|
||||
| **Г** | 21 | опциональные фиксы SDLPoP не портированы — соответствие ванили |
|
||||
| **Д** | 6, 9, 16, 17, 19, 23, 25, 26 | сверено и совпадает |
|
||||
|
||||
### Три узла, вокруг которых группируются расхождения
|
||||
|
||||
1. **Смерть при активной физике** (1, 12, 13, 4, 5). Здесь все находки
|
||||
ранга А. Общая причина: у нас смерть — это флаг, а физика продолжает
|
||||
работать с персонажем как с живым.
|
||||
2. **Границы модулей** (12, 20, 24). `pop_map` не отдаёт наружу то, что
|
||||
нужно `guards.c` и работе с произвольным `Char`: тайловые запросы от
|
||||
`Char`, `wall_type`, загрузку кадра. Ветки упрощались не по логике, а
|
||||
по доступности функций.
|
||||
3. **Момент побочных действий** (2, 8, 18, 24). Делаем то же самое, но
|
||||
раньше или позже оригинала: сброс скорости, побудка чомперов, звук,
|
||||
перезагрузка кадра. По отдельности мелочь, вместе — сдвиг состояния
|
||||
на кадр.
|
||||
|
||||
### Что делать дальше
|
||||
|
||||
1. Проверить находки А и Б в MAME по сценариям из их описаний — начиная с
|
||||
12/13 (смерть на краю) и 24 (выталкивание из стены).
|
||||
2. Те же сцены прогнать на живом SDLPoP: часть наблюдений может оказаться
|
||||
ванильным поведением (как находка 21).
|
||||
3. Подтверждённые осознанные отличия записать в `docs/impl_diff.md` —
|
||||
сейчас там нет ни одного из найденных, хотя правило проекта требует.
|
||||
|
||||
|
||||
Порядок по ожидаемой отдаче:
|
||||
|
||||
1. **Бой** — ПРОЙДЕН. Находки: 12, 13 (ранг А), 18 (Б); совпадают
|
||||
`control_with_sword`, `parry`, `swordfight`, `sword_strike`,
|
||||
`check_sword_hurt`, `check_hurting` (кроме звука). Не сверены мелочи:
|
||||
`back_with_sword`, `forward_with_sword`, `check_skel`.
|
||||
2. **`play_seq` и опкоды** — самая опасная область: ошибка в одном опкоде
|
||||
меняет все последовательности разом.
|
||||
3. **Отрисовка** — `add_kid_to_objtable`/`add_guard_to_objtable`, порядок
|
||||
слоёв, `clip_char`.
|
||||
4. **`do_fall`/`check_bumped`/`check_grab`** — остаток физики.
|
||||
5. **ИИ стражей** и особенности скелета/Тени/Джафара.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Глубокое ревью находок А и Б: можно ли починить и чем платим
|
||||
|
||||
> 2026-08-31. КОД ПО-ПРЕЖНЕМУ НЕ МЕНЯЛСЯ. Здесь только оценка.
|
||||
>
|
||||
> **Главное ограничение (требование пользователя): фикс не должен заметно
|
||||
> замедлять игру.** Поэтому у каждой находки первым делом указана ЧАСТОТА
|
||||
> вызова места, а уже потом сама правка.
|
||||
>
|
||||
> **Оговорка к ограничению:** для КРИТИЧНЫХ фиксов скорость — не вето.
|
||||
> Если такой фикс всерьёз бьёт по производительности, он выносится в
|
||||
> отдельный разбор, где ищется способ получить правильное поведение
|
||||
> дёшево (иной момент вызова, кэш, предвычисление, перенос в холодный
|
||||
> путь). То есть порядок такой: сначала решаем, критично ли поведение, и
|
||||
> только потом — какой ценой его добиться.
|
||||
|
||||
## Снятое препятствие
|
||||
|
||||
Обоснование сразу двух упрощений (`guards.c:1011` — «нужны тайловые
|
||||
запросы ОТ Char, а pop_map умеет только от Kid») **устарело**. Проверено:
|
||||
`get_tile_at_char`, `get_tile_infrontof_char`, `get_tile_behind_char` и
|
||||
`distance_to_edge_weight` в `pop_map.c` УЖЕ работают от `Char`
|
||||
(строки 460, 465, 477, 561). Они лишь не выведены в `pop_map.h`.
|
||||
|
||||
Так же обстоит с данными: `pop_char_set_seq()` ставит любую из 115
|
||||
последовательностей по индексу, то есть `seq_81` и `seq_64` доступны без
|
||||
единого нового байта данных — таблица генерируется из оригинала целиком.
|
||||
|
||||
То есть три находки (12, 14, 20) упираются не в архитектуру, а в четыре
|
||||
строки объявлений.
|
||||
|
||||
## Классификация мест по частоте вызова
|
||||
|
||||
| место | частота | вывод |
|
||||
|---|---|---|
|
||||
| `play_seq` (находки 7, 8) | КАЖДЫЙ кадр каждого персонажа | правка обязана быть бесплатной |
|
||||
| `check_hurting` (18) | каждый кадр боя, дважды | почти горячий |
|
||||
| ИИ стража (20) | каждый кадр, пока страж активен | почти горячий |
|
||||
| `land`, `in_wall`, `bumped` (1, 2, 3, 14, 24) | событие раз в несколько секунд | холодный, цена не важна |
|
||||
| `hurt_by_sword` (12, 13) | момент попадания | холодный |
|
||||
|
||||
## Разбор по находкам
|
||||
|
||||
### 12 + 13 (ранг А) — смерть на краю. ТОЛЬКО ВМЕСТЕ, НЕ ПООТДЕЛЬНОСТИ
|
||||
|
||||
> **Проверено на живой машине 2026-08-31 и провалилось.** Правка 13 была
|
||||
> сделана в изоляции (прижатие к полу перенесено в ветки пережитого
|
||||
> удара) — и сломала смерть: страж бьёт Кида, тот погибает, а вместо
|
||||
> нормальной смерти идут вспышка, стопкадр и немедленный выход в
|
||||
> заставку.
|
||||
>
|
||||
> Причина: у нас прижатие к полу работало КОМПЕНСАЦИЕЙ отсутствующей
|
||||
> ветки «убит и сброшен с уступа» (находка 12). Убрав компенсацию и не
|
||||
> добавив то, что она компенсировала, мы оставляем мёртвого персонажа с
|
||||
> ненулевой `fall_y` и незакреплённой `Char.y` — физика продолжает вести
|
||||
> его вниз, он проваливается за пределы уровня, и срабатывает аварийный
|
||||
> путь.
|
||||
>
|
||||
> Вывод: обе находки — ОДНА правка. Оценка «чистое перемещение строк,
|
||||
> риск низкий» была неверной; риск ВЫСОКИЙ, пока ветка 12 отсутствует.
|
||||
> Порядок внутри правки: сперва добавить ветку `seq_81` (с экспортом
|
||||
> тайловых запросов), убедиться, что смерть на краю отыгрывается ею, и
|
||||
> только затем убирать безусловное прижатие.
|
||||
|
||||
|
||||
*Место:* `guards.c`, `hurt_by_sword` — холодный путь.
|
||||
|
||||
*Правка:* (а) перенести две строки прижатия к полу внутрь ветки
|
||||
выжившего удара — это чистое перемещение, минус ноль байт; (б) добавить
|
||||
ветку выбора `seq_81` по тайлу позади и расстоянию до кромки.
|
||||
|
||||
*Что нужно:* экспорт `get_tile_behind_char()` и `distance_to_edge_weight()`
|
||||
из `pop_map.c` в `pop_map.h` как `__banked`.
|
||||
|
||||
*Цена скорости:* два межбанковых вызова (`guards.c` — банк 1, `pop_map.c`
|
||||
— банк 3) в момент попадания мечом, то есть несколько раз за бой.
|
||||
Незаметно.
|
||||
|
||||
*Цена памяти:* банк 1 занят на 19,8 % (13 142 Б свободно) — места вдоволь;
|
||||
банк 3 занят на 81,1 % (3 100 Б), но там прибавятся только две обёртки.
|
||||
|
||||
*Риск:* низкий. Ветка симметрична существующей, данные есть.
|
||||
|
||||
### 1 (ранг А) — лишний `determine_col()` в `land`
|
||||
|
||||
*Место:* холодный путь. *Правка:* убрать вызов и проверить, не
|
||||
понадобился ли он нам вместо оригинального `load_fram_det_col`, который
|
||||
оригинал делает в другом месте цепочки. *Цена:* отрицательная (кода
|
||||
меньше). *Риск:* СРЕДНИЙ — вызов мог компенсировать наш иной порядок
|
||||
загрузки кадра; убирать только с прогоном сцен падения и приземления.
|
||||
|
||||
### 2, 3 (ранг Б) — `land`: `fall_x` и порядок проверки пик
|
||||
|
||||
*Место:* холодный. *Правка 2:* сбрасывать только `fall_y` и после
|
||||
`play_seq`, как оригинал. *Правка 3:* перенести проверку пик после
|
||||
коррекции X. *Цена:* нулевая, это перестановка строк. *Риск:* низкий,
|
||||
но обе меняют поведение на краю тайла — нужны прогоны с пиками.
|
||||
|
||||
### 24 (ранг Б) — `in_wall` не перезагружает кадр. Одна строка
|
||||
|
||||
*Место:* холодный. *Правка:* заменить `determine_col()` на
|
||||
`pop_load_fram_det_col()` — он УЖЕ экспортирован (`pop_kid.h:109`) и, что
|
||||
важно, НЕ банковый, то есть вызов прямой. *Цена скорости:* одна
|
||||
перезагрузка кадра при выталкивании из стены — доли процента кадра.
|
||||
*Риск:* низкий; это возврат к оригиналу.
|
||||
|
||||
### 14 (ранг Б) — нет `seq_64`
|
||||
|
||||
*Место:* `bumped`, холодный. *Правка:* добавить выбор между 64 и 65 по
|
||||
направлению толчка. *Цена:* нулевая. *Риск:* низкий.
|
||||
|
||||
### 18 (ранг Б) — звук удара
|
||||
|
||||
*Место:* `check_hurting` — дважды за кадр боя. *Правка:* перенести звук
|
||||
в конец функции и обвесить тремя условиями оригинала. *Цена:*
|
||||
ОТРИЦАТЕЛЬНАЯ — звук перестанет играть в двух случаях из трёх, то есть
|
||||
уменьшится и число обращений к звуковой очереди. *Риск:* низкий.
|
||||
|
||||
### 20 (ранг Б) — «стена» у стражей. Требует осторожности со скоростью
|
||||
|
||||
*Место:* ИИ стража — вызывается каждый кадр, пока страж активен.
|
||||
|
||||
*Плохой вариант:* экспортировать `wall_type` из `pop_map.c` и звать из
|
||||
`guards.c`. Это МЕЖБАНКОВЫЙ вызов (банк 1 → банк 3) в почти горячем
|
||||
пути — трамплин с переключением W3 на каждый шаг ИИ. Против требования
|
||||
по скорости.
|
||||
|
||||
*Хороший вариант:* завести копию таблицы `wall_type_tbl` (32 байта) в
|
||||
rodata банка 1 и обращаться к ней напрямую — стоимость чтения байта,
|
||||
ноль переключений банка. Дублирование данных здесь оправдано: таблица
|
||||
константная и вшита в формат уровней.
|
||||
|
||||
*Риск:* низкий, но нужно следить, чтобы копия не разошлась с оригиналом —
|
||||
лучше генерировать обе из одного места или снабдить перекрёстным
|
||||
комментарием.
|
||||
|
||||
### 7 (ранг Б) — перо только для Кида. Правка ускоряет
|
||||
|
||||
*Место:* `play_seq`, самый горячий путь. *Правка:* убрать лишнее условие
|
||||
по `charid`. *Цена:* ОТРИЦАТЕЛЬНАЯ — из горячего цикла уходит сравнение.
|
||||
*Риск:* средний: надо убедиться, что физика пера у нас применяется к
|
||||
любому персонажу так же, как в оригинале, иначе анимация разойдётся с
|
||||
физикой.
|
||||
|
||||
### 8 (ранг Б) — отложенные чомперы. Чинить ДЕШЁВЫМ способом
|
||||
|
||||
*Место:* `play_seq`, горячий путь.
|
||||
|
||||
*Почему отложено:* `play_seq` маппит страницу байткода в окно W0 ОДИН раз
|
||||
перед циклом (`pop_kid.c:284`) и снимает после (`:389`). Вызвать
|
||||
`start_chompers` внутри цикла — значит снять окно, позвать, вернуть окно,
|
||||
и так на каждый переход ряда. Это прямая деградация горячего пути и
|
||||
против требования по скорости.
|
||||
|
||||
*Дешёвая замена:* сейчас копится ОДИН флаг, из-за чего теряются
|
||||
промежуточные ряды. Достаточно копить не флаг, а НОМЕРА рядов — один
|
||||
байт-битовую маску (рядов всего 0..3) плюс запомненную колонку. После
|
||||
цикла пройти по взведённым битам и разбудить чомперов в каждом. Цена в
|
||||
цикле: одна операция «выставить бит» вместо присваивания флага, то есть
|
||||
ноль. Разница с оригиналом останется только в МОМЕНТЕ побудки (после
|
||||
цикла, а не внутри), но ряды перестанут теряться.
|
||||
|
||||
*Риск:* низкий. Полное совпадение с оригиналом здесь недостижимо без
|
||||
потери скорости — это осознанный компромисс, который надо записать в
|
||||
`docs/impl_diff.md`.
|
||||
|
||||
## Сводка: что делать в каком порядке
|
||||
|
||||
| приоритет | находки | почему |
|
||||
|---|---|---|
|
||||
| 1 | 24 | одна строка, риск низкий, готовая экспортированная функция |
|
||||
| 2 | 12+13 ВМЕСТЕ | порознь ломают смерть (проверено); нужен экспорт двух функций |
|
||||
| 3 | 18, 8 | обе УСКОРЯЮТ или бесплатны; 8 — по дешёвому варианту |
|
||||
| 4 | 2, 3, 20 | перестановки и копия таблицы; нужны прогоны |
|
||||
| 5 | 1, 7 | риск средний: обе могут компенсировать наши отличия в другом месте |
|
||||
|
||||
**Ни один фикс не требует переделки архитектуры и ни один не ложится на
|
||||
горячий путь с накладными расходами** — при условии, что находка 20
|
||||
делается копией таблицы, а находка 8 — битовой маской рядов.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Два поведения: VANILLA и BUGFIXED
|
||||
|
||||
> Заведено 2026-08-31. Задача поставлена, работа НЕ начата.
|
||||
|
||||
## Зачем
|
||||
|
||||
У оригинального PoP есть баги, которые игроки знают наизусть и на
|
||||
которых построены известные трюки. SDLPoP чинит их не молча, а
|
||||
ОПЦИОНАЛЬНО: каждое исправление отдельным переключателем, по умолчанию
|
||||
часть включена, часть нет. Мы до сих пор повторяли ваниль — сознательно,
|
||||
чтобы порт вёл себя как оригинал.
|
||||
|
||||
Задача: дать ДВА поведения на выбор, не размазывая условия по всему коду:
|
||||
|
||||
* **VANILLA** — как в оригинале 1989 года, со всеми его багами;
|
||||
* **BUGFIXED** — с портированными исправлениями SDLPoP.
|
||||
|
||||
## Что уже известно (из аудита)
|
||||
|
||||
Разбор расхождений — `docs/sdlpop_audit.md`. Оттуда прямо в эту задачу
|
||||
переезжает следующее.
|
||||
|
||||
### Опциональные фиксы SDLPoP, которых у нас НЕТ
|
||||
|
||||
Все три относятся к прохождению сквозь препятствия и живут в `do_fall`
|
||||
(seg005) и рядом:
|
||||
|
||||
| фикс SDLPoP | что чинит |
|
||||
|---|---|
|
||||
| «скольжение сквозь стену» | Кид падает сквозь стены после разворота в беге, особенно под зельем медленного падения |
|
||||
| «прыжок сквозь стену над воротами» | пролёт в тайл над воротами |
|
||||
| «проваливание сквозь гобелен» | падение сквозь тайл гобелена |
|
||||
|
||||
Ни один не портирован — это и есть наше нынешнее VANILLA.
|
||||
|
||||
### Находка 24 — отложена сюда
|
||||
|
||||
`in_wall` у нас пересчитывает только колонку, а оригинал перезагружает
|
||||
ещё и данные кадра (`load_fram_det_col`, seg006). Правка сделана и
|
||||
ОТКАЧЕНА 2026-08-31 по такой причине:
|
||||
|
||||
* по букве оригинала находка верна;
|
||||
* но практического эффекта показать НЕ УДАЛОСЬ — все 15 наборов
|
||||
host-тестов дают одинаковый результат до и после, включая специально
|
||||
написанный тест на заход в кладку;
|
||||
* при этом правка не бесплатна: добавляет маппинг окна и перезагрузку
|
||||
кадра на каждое выталкивание из стены.
|
||||
|
||||
Платить за недоказанное не стали. Вернуться к ней имеет смысл именно
|
||||
здесь: при работе над BUGFIXED появится сценарий, где кадр меняется перед
|
||||
выталкиванием, и тогда эффект станет наблюдаемым.
|
||||
|
||||
### Готовый детектор поведения
|
||||
|
||||
`tests/host/t_wall.c` расширен с одной проверки до трёх. Ключевая —
|
||||
`wall_stops_jump_from_left_side`: она НЕ требует нуля заходов в кладку, а
|
||||
сторожит их ЧИСЛО (сейчас ровно два случая из четырнадцати стартовых
|
||||
позиций).
|
||||
|
||||
Это и есть переключатель ожиданий между режимами:
|
||||
|
||||
* больше двух — правка сделала нас хуже ванили, регресс;
|
||||
* ровно два — ведём себя как оригинал (режим VANILLA);
|
||||
* меньше двух — кто-то портировал фикс; в режиме BUGFIXED тест должен
|
||||
ждать нуля.
|
||||
|
||||
То есть когда появится BUGFIXED, этому тесту понадобится ожидание,
|
||||
зависящее от режима, — и он готов стать первым таким.
|
||||
|
||||
## Переключатель уже есть — второго не нужно
|
||||
|
||||
Уточнено 2026-08-31: в настройках игры ПЕРЕКЛЮЧАТЕЛЬ VANILLA/ENHANCED
|
||||
СУЩЕСТВУЕТ (`docs/menu_settings_plan.md`), просто сейчас он жёстко
|
||||
зафиксирован в положении VANILLA. Отдельную сущность заводить не надо —
|
||||
эта задача про то, чтобы наполнить смыслом уже имеющееся положение
|
||||
ENHANCED.
|
||||
|
||||
**Наш «ванильный» билд уже не чистая ваниль.** Часть ванильных багов у
|
||||
нас пофикшена по ходу портирования. Значит:
|
||||
|
||||
* нельзя считать текущее поведение эталоном ванили — оно смешанное;
|
||||
* при разделении режимов придётся пройтись по уже сделанным фиксам и
|
||||
решить по каждому, остаётся он в VANILLA или уезжает в ENHANCED;
|
||||
* и наоборот: отдельные исправления (например, падение сквозь стену)
|
||||
вполне могут быть сделаны прямо в нынешнем «ванильном» билде, если
|
||||
сочтём, что играбельность важнее буквальности.
|
||||
|
||||
## Что предстоит решить
|
||||
|
||||
1. **Что считать ванилью на практике.** Составить список уже сделанных
|
||||
отступлений от оригинала и распределить их между режимами.
|
||||
2. **Цена рантайм-проверки.** Условия попадают в физику и `play_seq`, то
|
||||
есть в горячий путь. Если переключать в рантайме, проверка должна
|
||||
быть дешевле самого фикса: флаг в резиденте, а не вызов через банк.
|
||||
3. **Что считать умолчанием.** Оригинальное поведение честнее для порта,
|
||||
но часть фиксов SDLPoP включает по умолчанию.
|
||||
4. **Как тестировать оба режима.** Host-тесты гоняются одним прогоном;
|
||||
для двух режимов нужен либо параметр сборки тестов, либо ожидания,
|
||||
зависящие от флага.
|
||||
|
||||
## Инвентаризация: что уже решено по каждому фиксу SDLPoP
|
||||
|
||||
Составлено 2026-08-31 обходом кода. В движке эти решения УЖЕ приняты и
|
||||
задокументированы прямо в комментариях — таблица лишь сводит их в одно
|
||||
место, чтобы при разделении режимов не перечитывать исходники.
|
||||
|
||||
| фикс SDLPoP | где у нас | что взято |
|
||||
|---|---|---|
|
||||
| `fix_feather_fall_affects_guards` | `pop_map.c:941` | **ФИКС** — перо действует только на Кида |
|
||||
| `fix_exit_door` | `pop_map.c:1212` | **ФИКС** — ветка фикса вместо ванильного глобала |
|
||||
| `FIX_GATE_SOUNDS` | `pop_trob.c:579` | **ФИКС** — условия через ИЛИ |
|
||||
| `fix_sound_priorities` | `pop_sfx.c:196` | **ФИКС** — в SDLPoP включён безусловно, сравниваемся с исправленным |
|
||||
| `FIX_STAND_ON_THIN_AIR` | `pop_map.c:1499` | **ВАНИЛЬ** — взяты 2 части из 3, третья ждёт патча `seqtbl` |
|
||||
| `fix_painless_fall_on_guard` | `pop_map.c:1611` | **ВАНИЛЬ** — намеренно |
|
||||
| `fix_jumping_over_guard` | `pop_map.c:1612` | **ВАНИЛЬ** — намеренно |
|
||||
| `FIX_RETREAT_WITHOUT_LEAVING_ROOM` | `pop_map.c:3036` | **ВАНИЛЬ** — в SDLPoP выключен по умолчанию; трюк 35 сохраняем |
|
||||
| `fix_skeleton_chomper_blood` | `pop_map.c:3177` | **ВАНИЛЬ** — кровь скелета в ванили есть |
|
||||
| потеря HP скелетом при падении с двух этажей | `pop_map.c:676` | **ВАНИЛЬ** — баг оригинала, сохраняем сознательно |
|
||||
|
||||
### Фиксы, которым нужна правка байткода
|
||||
|
||||
`FIX_STAND_ON_THIN_AIR` не взят НЕ потому, что мы выбрали ваниль, а
|
||||
потому что его нельзя применить наполовину: он состоит из трёх частей, и
|
||||
третья — правка самого байткода `seqtbl` (смещения в последовательности
|
||||
вставания). Взяв только первые две, мы получим вставание, уносящее
|
||||
весовую точку в стену, то есть ХУЖЕ ванили.
|
||||
|
||||
**Это выполнимо, и раньше здесь стояла неверная оценка** (уточнено
|
||||
2026-08-31): байткод можно менять и у нас. Способов три:
|
||||
|
||||
1. **Патч в рантайме** — после загрузки `kid.ani` в EMM-страницу
|
||||
пропатчить нужные байты прямо там. Речь о единицах байт, страница уже
|
||||
наша, и патч обратим — то есть режим переключается без пересборки
|
||||
ресурсов. Это и делает фикс пригодным для VANILLA/ENHANCED.
|
||||
2. **Патч в упаковщике** — готовить два варианта `kid.ani`. Привязывает
|
||||
режим к файлам на диске, поэтому хуже: переключатель в меню перестаёт
|
||||
быть чисто кодовым.
|
||||
3. **Две копии в одной странице** — и это, пожалуй, лучший вариант
|
||||
(решено 2026-08-31). `kid.ani` целиком около 4 КБ, а страница EMM —
|
||||
16 КБ, то есть обе версии байткода спокойно помещаются рядом в уже
|
||||
выделенной странице. Переключение режима сводится к смене базового
|
||||
смещения, патчить ничего не нужно, откат мгновенный.
|
||||
|
||||
Для сравнения: у SDLPoP рабочая таблица и неизменная копия оригинала
|
||||
существуют раздельно (`seqtbl` и `original_seqtbl`), причём вторая нужна
|
||||
для сверки — то есть сама идея «оригинальный байткод отдельно, рабочий
|
||||
отдельно» там уже заложена.
|
||||
|
||||
Общее правило для BUGFIXED: фиксы, требующие правки `seqtbl`, доступны
|
||||
через рантайм-патч страницы; закладывать это стоит сразу, чтобы не
|
||||
упереться при первом же таком фиксе.
|
||||
|
||||
### Вывод для разделения режимов
|
||||
|
||||
Четыре фикса уже взяты, шесть позиций оставлены ванильными. Значит
|
||||
нынешний билд — это не VANILLA, а «ваниль плюс четыре исправления». При
|
||||
разделении:
|
||||
|
||||
* взятые четыре надо либо оставить в обоих режимах (если считаем их
|
||||
безусловными улучшениями), либо увести в ENHANCED и вернуть ванильное
|
||||
поведение в VANILLA — второе честнее, но потребует обратной работы;
|
||||
* ванильные шесть — кандидаты в ENHANCED; `FIX_STAND_ON_THIN_AIR` тоже,
|
||||
но ему дополнительно нужен рантайм-патч байткода.
|
||||
|
||||
## ВСЕ исправления SDLPoP и их статус у нас
|
||||
|
||||
Полный перечень опциональных исправлений оригинала, какие есть в SDLPoP
|
||||
(43 позиции), со статусом в нашем порте. Названия — идентификаторы
|
||||
опций SDLPoP, описание — своими словами.
|
||||
|
||||
Статусы: **ВЗЯТ** — портирован; **ВАНИЛЬ** — сознательно не берём, держим
|
||||
поведение оригинала; **НЕТ** — не реализован, кандидат в ENHANCED;
|
||||
**НЕДОСТУПЕН** — требует правки байткода `seqtbl` (см. ограничение выше);
|
||||
**В РАБОТЕ** — решено делать сейчас.
|
||||
|
||||
### Стены и препятствия
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_glide_through_wall` | проход сквозь стену при падении после разворота в беге | **ВЗЯТ** 2026-08-31 — `glide_through_wall_guard()` в `pop_map.c`, точка отвязки для VANILLA |
|
||||
| `fix_jump_through_wall_above_gate` | прыжок в тайл над воротами | НЕТ |
|
||||
| `fix_drop_through_tapestry` | проваливание сквозь гобелен | НЕТ |
|
||||
| `fix_running_jump_through_tapestry` | прыжок с разбега сквозь гобелен | НЕТ |
|
||||
| `fix_turn_running_near_wall` | разворот в беге вплотную к стене | НЕТ |
|
||||
| `fix_wall_bump_triggers_tile_below` | удар о стену срабатывает на тайл ниже | НЕТ |
|
||||
| `fix_bigpillar_climb` | подъём на большую колонну | НЕТ |
|
||||
| `fix_land_against_gate_or_tapestry` | приземление вплотную к воротам или гобелену | НЕТ |
|
||||
| `fix_caped_prince_sliding_through_gate` | проскальзывание сквозь ворота | НЕТ |
|
||||
|
||||
### Падение, прыжки, зацепы
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_stand_on_thin_air` | стояние на воздухе после отмены падения | НЕТ — нужен рантайм-патч `seqtbl` (см. выше) |
|
||||
| `fix_jump_distance_at_edge` | дальность прыжка у самой кромки | НЕТ |
|
||||
| `fix_edge_distance_check_when_climbing` | проверка расстояния до кромки при подъёме | НЕТ |
|
||||
| `fix_grab_falling_speed` | зацеп на слишком большой скорости падения | НЕТ |
|
||||
| `fix_drop_2_rooms_climbing_loose_tile` | провал через две комнаты при подъёме на шаткой плите | НЕТ |
|
||||
| `fix_infinite_down_bug` | бесконечное падение вниз | НЕТ |
|
||||
| `fix_falling_through_floor_during_sword_strike` | провал сквозь пол во время удара мечом | НЕТ |
|
||||
| `fix_safe_landing_on_spikes` | безопасное приземление на невыдвинутые пики | НЕТ |
|
||||
| `fix_dead_floating_in_air` | мёртвый зависает в воздухе | НЕТ |
|
||||
|
||||
### Бой и стражи
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_painless_fall_on_guard` | падение на стража с высоты без урона | **ВАНИЛЬ** |
|
||||
| `fix_jumping_over_guard` | перепрыгивание через стража | **ВАНИЛЬ** |
|
||||
| `fix_skeleton_chomper_blood` | кровь скелета в челюстях | **ВАНИЛЬ** |
|
||||
| `fix_push_guard_into_wall` | вталкивание стража в стену | НЕТ |
|
||||
| `fix_guard_following_through_closed_gates` | страж идёт сквозь закрытые ворота | НЕТ |
|
||||
| `fix_doortop_disabling_guard` | верх двери отключает стража | НЕТ |
|
||||
| `fix_offscreen_guards_disappearing` | стражи пропадают за краем экрана | НЕТ |
|
||||
| `fix_unintended_sword_strike` | непреднамеренный удар мечом | НЕТ |
|
||||
| `fix_two_coll_bug` | двойная проверка столкновения | НЕТ |
|
||||
| `fix_move_after_sheathe` | движение сразу после убирания меча | НЕТ |
|
||||
|
||||
### Ворота, двери, плиты
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_exit_door` | дверь выхода с уровня | **ВЗЯТ** |
|
||||
| `fix_gate_sounds` | звуки ворот | **ВЗЯТ** |
|
||||
| `fix_gate_drawing_bug` | отрисовка ворот | НЕТ |
|
||||
| `fix_press_through_closed_gates` | нажатие плиты сквозь закрытые ворота | НЕТ |
|
||||
| `fix_chompers_not_starting` | челюсти не заводятся | НЕТ |
|
||||
| `fix_loose_left_of_potion` | шаткая плита слева от зелья | НЕТ |
|
||||
| `fix_hidden_floors_during_flashing` | скрытые полы во время вспышки | НЕТ |
|
||||
| `fix_retreat_without_leaving_room` | отступление без смены комнаты (трюк 35) | **ВАНИЛЬ** |
|
||||
|
||||
### Зелья, перо, спецэффекты
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_feather_fall_affects_guards` | перо действует и на стражей | **ВЗЯТ** |
|
||||
| `fix_feather_interrupted_by_leveldoor` | перо прерывается дверью уровня | НЕТ |
|
||||
| `fix_move_after_drink` | движение сразу после питья | НЕТ |
|
||||
| `fix_quicksave_during_feather` | быстрое сохранение под пером | НЕТ |
|
||||
| `fix_hang_on_teleport` | зависание при телепорте | НЕТ (телепортов у нас нет) |
|
||||
|
||||
### Интерфейс и ввод
|
||||
|
||||
| опция | что чинит | статус |
|
||||
|---|---|---|
|
||||
| `fix_one_hp_stops_blinking` | индикатор перестаёт мигать на одном HP | НЕТ |
|
||||
| `fix_register_quick_input` | учёт быстрого ввода | НЕТ |
|
||||
|
||||
### Отдельно: приоритеты звуков
|
||||
|
||||
`fix_sound_priorities` в SDLPoP включён БЕЗУСЛОВНО (не опция), и мы
|
||||
сравниваемся с исправленным вариантом — `pop_sfx.c:196`. Статус:
|
||||
**ВЗЯТ**, вернуть ванильное поведение отдельным режимом было бы
|
||||
дополнительной работой.
|
||||
|
||||
### Сводка
|
||||
|
||||
| статус | сколько |
|
||||
|---|---:|
|
||||
| ВЗЯТ | 5 |
|
||||
| ВАНИЛЬ (сознательно) | 4 |
|
||||
| требует патча `seqtbl` (выполнимо) | 1 |
|
||||
| НЕТ (кандидаты в ENHANCED) | 32 |
|
||||
|
||||
## Список кандидатов на BUGFIXED
|
||||
|
||||
Пополняется по мере аудита. Пока:
|
||||
|
||||
* три опциональных фикса SDLPoP выше;
|
||||
* находка 24 (перезагрузка кадра в `in_wall`);
|
||||
* находка 21 из аудита — общая рамка «мы намеренно повторяем ваниль».
|
||||
|
||||
Не относятся сюда находки, где мы расходимся с оригиналом НЕ в его
|
||||
пользу (ранги А и Б аудита): их надо чинить в обоих режимах, потому что
|
||||
это не баги оригинала, а наши.
|
||||
@@ -999,6 +999,7 @@ uint8_t pop_demo_kid_ai(void) __banked
|
||||
#define FRAME_154_POKING 154
|
||||
#define SEQ_69_ATTACK_WAS_PARRIED 69
|
||||
#define SEQ_74_HIT_BY_SWORD 74
|
||||
#define SEQ_81_PUSHED_OFF_LEDGE 81 /* заколот у обрыва — падает замертво */
|
||||
#define SEQ_85_STABBED_TO_DEATH 85
|
||||
|
||||
/* refractimer (seg002:36) — «отдышка» стража после того, как его ранили. */
|
||||
@@ -1008,10 +1009,40 @@ static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] =
|
||||
/* hurt_by_sword (seg002): применить попадание к АКТИВНОМУ персонажу.
|
||||
* Без меча в руке любое попадание смертельно; с мечом — минус 1 HP и кадр
|
||||
* «получил удар».
|
||||
* УПРОЩЕНИЕ: ветку «сбит с уступа» (seq_81, когда сзади пусто и до кромки
|
||||
* меньше 4) не портируем — ей нужны тайловые запросы ОТ Char, а pop_map
|
||||
* пока умеет только от Kid. На ровном полу (тайл сзади не пустой) оригинал
|
||||
* идёт ровно нашей веткой. */
|
||||
*
|
||||
* СМЕРТЬ БЕЗОРУЖНОГО БЫВАЕТ ДВУХ ВИДОВ, и выбор между ними делается по
|
||||
* обстановке ПОЗАДИ (seg002): если сзади есть опора или до кромки меньше
|
||||
* четырёх — «заколот на месте» (seq_85); если сзади пусто и от кромки
|
||||
* далеко — «сброшен с уступа» (seq_81), отдельная последовательность,
|
||||
* которая сама отыгрывает падение замертво.
|
||||
*
|
||||
* Вторая ветка появилась 2026-08-31 (docs/sdlpop_audit.md, находка 12):
|
||||
* раньше её не было, потому что считалось, будто тайловые запросы от Char
|
||||
* недоступны — на деле они давно работают, не хватало объявлений.
|
||||
*
|
||||
* Замечание к сцене: падение первым делом убирает меч (start_fall), так
|
||||
* что персонаж, сбитый в пропасть во время боя, к следующему удару уже
|
||||
* безоружен — и попадает сюда же. */
|
||||
/* ПРИЖАТЬ К ПОЛУ СВОЕГО РЯДА — только для ПЕРЕЖИВШЕГО удар.
|
||||
*
|
||||
* В оригинале (seg002, ветка seq_74_hit_by_sword) эти две строки стоят
|
||||
* ВНУТРИ ветки выжившего; смертельные ветки координату не трогают.
|
||||
*
|
||||
* У нас они долго выполнялись безусловно и работали СТРАХОВКОЙ за
|
||||
* отсутствующую ветку «сброшен с уступа»: убитого в воздухе прижимали к
|
||||
* полу, иначе он продолжал падать и выпадал за нижнюю границу, а игра
|
||||
* уходила на рестарт, не показав тела (проверено на живой машине
|
||||
* 2026-08-31 — правка в одиночку ломала смерть).
|
||||
*
|
||||
* Снято ПОСЛЕ появления ветки seq_81: теперь смерть у обрыва отыгрывает
|
||||
* своя последовательность, и страховка больше не нужна. Порядок именно
|
||||
* такой и важен — сперва ветка, потом снятие. */
|
||||
static void hurt_stand_on_floor(void)
|
||||
{
|
||||
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
|
||||
Char.fall_y = 0;
|
||||
}
|
||||
|
||||
static void hurt_by_sword(void)
|
||||
{
|
||||
if (Char.alive >= 0) return;
|
||||
@@ -1032,19 +1063,22 @@ static void hurt_by_sword(void)
|
||||
* Костыля «снять бессмертие на время вызова» здесь БОЛЬШЕ НЕТ:
|
||||
* pop_take_hp гасит только урон меньше 100, а тут ровно 100. */
|
||||
pop_take_hp(100);
|
||||
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
|
||||
if (pop_tile_behind_char() != 0 || pop_dist_to_edge_weight() < 4)
|
||||
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* есть опора сзади */
|
||||
else
|
||||
pop_char_set_seq(SEQ_81_PUSHED_OFF_LEDGE); /* сзади обрыв */
|
||||
} else if (Char.charid == CHARID_0_KID && pop_immortal) {
|
||||
/* ЧИТ, уровень 1 и выше: в боевой стойке удары не отнимают HP.
|
||||
* Кадр «получил удар» оставляем — иначе бой перестаёт читаться,
|
||||
* да и оригинал на выживший удар ставит ровно его. */
|
||||
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
|
||||
hurt_stand_on_floor();
|
||||
} else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) {
|
||||
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */
|
||||
} else {
|
||||
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
|
||||
hurt_stand_on_floor();
|
||||
}
|
||||
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
|
||||
Char.fall_y = 0;
|
||||
/* seg002:0C1F: у Кида свой звук боли, у соперника свой. */
|
||||
pop_sfx_play((uint8_t)(Char.charid == CHARID_0_KID ? 13 : 12));
|
||||
play_seq();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -549,6 +549,18 @@ static void determine_col(void)
|
||||
* control(). См. разбор там. */
|
||||
void pop_determine_col(void) __banked { determine_col(); }
|
||||
|
||||
/* Тайл ПОЗАДИ персонажа и расстояние до кромки — наружу, для боевой
|
||||
* половины (guards.c): по ним оригинал выбирает, какой смертью умирает
|
||||
* заколотый — на месте или сброшенным с уступа (seg002, hurt_by_sword).
|
||||
* Обе давно работают от Char, а не от Kid; не хватало только объявлений
|
||||
* (docs/sdlpop_audit.md, находка 12). */
|
||||
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); }
|
||||
int8_t pop_dist_to_edge_weight(void) __banked
|
||||
{
|
||||
int d = distance_to_edge_weight();
|
||||
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
|
||||
}
|
||||
|
||||
/* расстояние до края тайла (для in_wall). */
|
||||
static int distance_to_edge(int xpos)
|
||||
{
|
||||
@@ -682,6 +694,19 @@ static void land(void)
|
||||
* control_with_sword и до него не доходит — Кид садится в присед
|
||||
* НАВСЕГДА (BUG-LAND-SWORD-1). */
|
||||
is_screaming = 0; /* seg005:116 */
|
||||
/* seg005:0173 — ВСЯ развязка приземления (мягко/средне/разбиться) у
|
||||
* оригинала заперта за `if (Char.alive < 0)`, то есть выполняется
|
||||
* только для ЖИВОГО. Мёртвому телу отведена своя дорога (seg005 ветка
|
||||
* else, loc_5F6C): добить HP, звук падения насмерть и seq_22.
|
||||
*
|
||||
* Развилки у нас не было, и труп шёл по живой дороге. Смертельно это
|
||||
* не выглядело только из-за высоты: тело, сброшенное ударом с ОДНОГО
|
||||
* ряда, набирает fall_y < 22 — урона нет, «последнее HP» не тратится,
|
||||
* ветка «разбился» не выбирается никогда, и мёртвый Кид приземлялся
|
||||
* в ПРИСЕД (кадр 109, seq_17). Отсюда симптом «убитого Кида уронили,
|
||||
* а он сел этажом ниже, и только потом обнаружилось, что он мёртв».
|
||||
* Цена проверки — один тест байта на приземление. */
|
||||
if (Char.alive >= 0) goto crushed;
|
||||
if (Char.fall_y < 22) {
|
||||
soft_land:
|
||||
if (Char.charid >= CHARID_2_GUARD || Char.sword == SWORD_2_DRAWN) {
|
||||
@@ -698,6 +723,10 @@ static void land(void)
|
||||
if (!deadly && Char.charid == CHARID_2_GUARD) deadly = 1; /* seg005:190 */
|
||||
Char.fall_x = Char.fall_y = 0;
|
||||
if (deadly || pop_take_hp(1)) { /* 3+ этажа или последнее HP */
|
||||
crushed:
|
||||
/* Сюда же приходит УЖЕ мёртвое тело (см. развилку выше); для
|
||||
* него обнуление ниже — первое, живой путь его уже сделал. */
|
||||
Char.fall_x = Char.fall_y = 0;
|
||||
pop_take_hp(100);
|
||||
if (Char.charid == CHARID_0_KID) pop_sfx_play(0); /* разбился */
|
||||
pop_char_set_seq(SEQ_22_CRUSHED);
|
||||
@@ -898,6 +927,40 @@ static uint8_t check_grab_run_jump(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* НЕ ДАТЬ ПРОЛЕТЕТЬ СКВОЗЬ СТЕНУ В ПАДЕНИИ.
|
||||
*
|
||||
* Порт опционального исправления SDLPoP (fix_glide_through_wall, seg005 в
|
||||
* do_fall). В ванили personаж, падающий после разворота в беге, может
|
||||
* оказаться внутри кладки и лететь «в стене» — баг оригинала, известный и
|
||||
* воспроизводимый; у нас он ловится host-тестом (tests/host/t_wall.c,
|
||||
* набор wall_stops_jump_from_left_side).
|
||||
*
|
||||
* ВЗЯТО В ТЕКУЩИЙ БИЛД по решению 2026-08-31: играбельность важнее
|
||||
* буквальности. При разделении VANILLA/ENHANCED эта функция — готовая
|
||||
* точка отвязки: достаточно не звать её в ванильном режиме
|
||||
* (docs/vanilla_vs_bugfixed.md).
|
||||
*
|
||||
* Условие оригинала: персонаж внутри тайла стены, либо внутри верха двери
|
||||
* (в обоих вариантах) при движении ВЛЕВО. Порог 8 и сдвиг на 15 назад —
|
||||
* из исправления; они выбраны так, чтобы вытолкнуть на ту же дистанцию,
|
||||
* что даёт выталкивание из стены при приземлении. Горизонтальную
|
||||
* скорость гасим: иначе следующий кадр внесёт персонажа обратно. */
|
||||
static void glide_through_wall_guard(void)
|
||||
{
|
||||
uint8_t t;
|
||||
int d;
|
||||
|
||||
determine_col();
|
||||
t = get_tile_at_char();
|
||||
if (t != TILE_WALL &&
|
||||
!((t == TILE_DOORTOP || t == TILE_DOORTOP_FLOOR) && Char.direction < 0))
|
||||
return;
|
||||
d = distance_to_edge_weight();
|
||||
if (d < 8) return;
|
||||
Char.x = (uint8_t)char_dx_forward((int8_t)(d - 15));
|
||||
Char.fall_x = 0;
|
||||
}
|
||||
|
||||
static void do_fall(void)
|
||||
{
|
||||
uint8_t nrow = (uint8_t)(Char.curr_row + 1);
|
||||
@@ -908,6 +971,7 @@ static void do_fall(void)
|
||||
if (nrow > 4) nrow = 4; /* защита pop_y_land[] от выхода */
|
||||
if ((uint16_t)pop_y_land[nrow] > (uint16_t)Char.y) {
|
||||
check_grab(); /* ещё летит — попытка зацепа */
|
||||
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
|
||||
} else if (Char.curr_row <= 2) {
|
||||
if (get_tile_at_char() == TILE_WALL)
|
||||
in_wall();
|
||||
|
||||
@@ -67,6 +67,11 @@ void pop_row_tiles(int8_t row, int8_t c0, int8_t c1, uint8_t *out) __banked;
|
||||
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
|
||||
void pop_determine_col(void) __banked;
|
||||
|
||||
/* Тайл позади АКТИВНОГО персонажа и расстояние до кромки его тайла.
|
||||
* Нужны боевой половине (guards.c) для выбора смерти у обрыва. */
|
||||
uint8_t pop_tile_behind_char(void) __banked;
|
||||
int8_t pop_dist_to_edge_weight(void) __banked;
|
||||
|
||||
/* HP/смерть. pop_kid_dead=1 когда Kid убит (пики); hitp_curr — текущее HP.
|
||||
* pop_kid_hp_reset() ставит старт HP и снимает смерть (звать в kid_init/
|
||||
* респавн). */
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
|
||||
#include "pop_music.h"
|
||||
#include "pop_sfx.h"
|
||||
#include "_pop_sfx.h" /* pop_snd_ok — открыт ли вывод (см. pop_music_busy) */
|
||||
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
|
||||
#include "_pop_file.h"
|
||||
|
||||
@@ -454,7 +455,15 @@ void pop_music_service(void) __banked
|
||||
|
||||
uint8_t pop_music_busy(void) __banked
|
||||
{
|
||||
return (uint8_t)(pop_mus_left != 0);
|
||||
/* «ЗВУЧИТ», А НЕ «ЕСТЬ КУРСОР». Курсор трека двигает насос CBL, и пока
|
||||
* вывод закрыт (pop_sfx_pause на загрузке уровня, Ctrl+S, показ заставки
|
||||
* после Restart Game) pop_mus_left не убывает НИКОГДА. Вызывающий,
|
||||
* который ждёт конца мелодии, в этом состоянии висит вечно — ровно так
|
||||
* заставка после Restart Game вставала на экране Prince of Persia
|
||||
* (поймано пользователем 2026-09-02). Корень того случая был в другом
|
||||
* (CBL не открывался заново, см. pop_boot_sound), но ждать неиграющую
|
||||
* музыку нельзя в принципе, поэтому признак спрашивает и про вывод. */
|
||||
return (uint8_t)(pop_snd_ok && pop_mus_left != 0);
|
||||
}
|
||||
|
||||
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
|
||||
|
||||
@@ -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, а читают циклы
|
||||
* ожидания — перечитывать обязаны каждый оборот. */
|
||||
|
||||
@@ -263,6 +263,13 @@ void pop_sfx_play(uint8_t id)
|
||||
if (pop_mus_want && id >= 24 && id <= 43) pop_mus_req = id;
|
||||
return;
|
||||
}
|
||||
/* НАБОР ЕЩЁ ДОЧИТЫВАЕТСЯ. На старте программы звук открывается после
|
||||
* ОДНОЙ страницы (в ней блок тишины), чтобы музыка заставки заиграла
|
||||
* почти сразу; остальные приезжают позже (pop_sfx_init_begin/finish).
|
||||
* Страницы под них уже выделены, но не прочитаны — эффект из такой
|
||||
* страницы был бы шумом из чужих данных. Заявку на МУЗЫКУ выше это
|
||||
* не трогает: её данные лежат отдельно. */
|
||||
if (!pop_snd_pages) return;
|
||||
if (!snd_next1 || snd_prio[id] <= snd_prio[snd_next1 - 1])
|
||||
snd_next1 = (uint8_t)(id + 1);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,28 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Поднять набор: занять 8 EMM-страниц и вычитать в них данные. ВЫВОД НЕ
|
||||
* ВКЛЮЧАЕТ — см. pop_sfx_start. 0 — OK, -1 — не получилось (тогда всё
|
||||
* дальнейшее молча ничего не делает, игра идёт без звука). */
|
||||
int pop_sfx_init(void) __banked;
|
||||
/* ПОДЪЁМ НАБОРА РАЗБИТ НАДВОЕ — ради быстрого старта программы.
|
||||
*
|
||||
* Насосу, чтобы открыть CBL и заиграть МУЗЫКУ, нужен из всего набора
|
||||
* ровно один блок — тишина, и упаковщик обязан класть её первым блоком
|
||||
* СТРАНИЦЫ 0 (tools/pop_idx.py, проверка `_check_entry`). Поэтому:
|
||||
*
|
||||
* pop_sfx_init_begin() — индекс (snd.idx) + ОДНА страница (33 мс).
|
||||
* После него можно звать pop_sfx_start() и
|
||||
* играть музыку; эффектов ещё нет.
|
||||
* pop_sfx_init_finish() — остальные страницы (~230 мс) и публикация
|
||||
* pop_snd_pages: с этого момента есть эффекты.
|
||||
*
|
||||
* Пока набор неполон, pop_sfx_play() молча игнорирует эффекты (заявку на
|
||||
* музыку принимает по-прежнему): страницы под них выделены, но ещё не
|
||||
* прочитаны, и играть из них — это шум из чужих данных.
|
||||
*
|
||||
* ВЫВОД НИ ТА, НИ ДРУГАЯ НЕ ВКЛЮЧАЕТ — см. pop_sfx_start. 0 — OK, -1 —
|
||||
* не получилось (тогда всё дальнейшее молча ничего не делает, игра идёт
|
||||
* без звука; begin при отсутствии индекса поднимает набор из одной
|
||||
* страницы тишины, чтобы не встала музыка). */
|
||||
int pop_sfx_init_begin(void) __banked;
|
||||
int pop_sfx_init_finish(void) __banked;
|
||||
|
||||
/* Включить вывод (открыть CBL). Звать ПОСЛЕ всей загрузки: пока идёт
|
||||
* чтение файлов, ESTEX уходит в диск надолго, насос не успевает долить
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
#include <sprite.h> /* gfx_w0_map — залить блок тишины */
|
||||
|
||||
static uint8_t snd_blk; /* блок EMM под весь набор */
|
||||
/* Раздельная загрузка (см. шапку pop_sfx.h): сколько страниц обещал индекс
|
||||
* и сколько уже прочитано. 0 в want — begin не проходил либо поднялась
|
||||
* только страница тишины (набора нет), и дочитывать нечего. */
|
||||
static uint8_t snd_want_pages;
|
||||
static uint8_t snd_done_pages;
|
||||
|
||||
/* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
|
||||
*
|
||||
@@ -142,11 +147,13 @@ static int8_t snd_silence_only(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pop_sfx_init(void) __banked
|
||||
/* ПЕРВАЯ ПОЛОВИНА: индекс + страница 0 (в ней блок тишины). После неё
|
||||
* звук можно открывать, и музыка играет; эффекты — после finish. */
|
||||
int pop_sfx_init_begin(void) __banked
|
||||
{
|
||||
uint8_t i, blk, pages;
|
||||
uint8_t blk, pages;
|
||||
|
||||
if (pop_snd_ok) return 0;
|
||||
if (snd_blk) return 0; /* набор уже поднимали */
|
||||
|
||||
/* СНАЧАЛА ИНДЕКС, ПОТОМ ДАННЫЕ: сколько страниц читать и куда какой
|
||||
* эффект лёг — знает только он. Набора без индекса не бывает:
|
||||
@@ -179,24 +186,58 @@ int pop_sfx_init(void) __banked
|
||||
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
|
||||
(int8_t)pages)
|
||||
return -1;
|
||||
/* Блок выделяем СРАЗУ на весь набор: номера страниц отдаёт
|
||||
* mem_get_page по индексу, и finish дочитает в те же самые. */
|
||||
blk = mem_alloc_pages(pages);
|
||||
if (!blk) { pop_arc_close(&arc); return -1; }
|
||||
for (i = 0; i < pages; i++) {
|
||||
pop_snd_page[i] = mem_get_page(blk, i);
|
||||
if (!pop_snd_page[i] ||
|
||||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
|
||||
pop_arc_close(&arc);
|
||||
mem_free_block(blk);
|
||||
return -1;
|
||||
}
|
||||
pop_snd_page[POP_SND_SILENCE_PAGE] = mem_get_page(blk, 0);
|
||||
if (!pop_snd_page[POP_SND_SILENCE_PAGE] ||
|
||||
pop_arc_read(&arc, tbl, 0, 0,
|
||||
pop_snd_page[POP_SND_SILENCE_PAGE], 0, 0) != 16384) {
|
||||
pop_arc_close(&arc);
|
||||
mem_free_block(blk);
|
||||
return -1;
|
||||
}
|
||||
pop_arc_close(&arc);
|
||||
}
|
||||
snd_blk = blk;
|
||||
snd_want_pages = pages;
|
||||
snd_done_pages = 1;
|
||||
/* pop_snd_pages НЕ ставим: набор ещё неполон, и до finish эффекты
|
||||
* запускать нельзя (страницы выделены, но не прочитаны). */
|
||||
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ВТОРАЯ ПОЛОВИНА: дочитать остальные страницы и разрешить эффекты.
|
||||
* Идемпотентна; архив открывается второй раз (51 мс) — это дешевле, чем
|
||||
* держать открытый манипулятор через всю заставку при лимите в восемь. */
|
||||
int pop_sfx_init_finish(void) __banked
|
||||
{
|
||||
pop_arc_t arc;
|
||||
uint8_t tbl[POP_SND_MAX_PAGES * POP_ARC_REC];
|
||||
uint8_t i;
|
||||
|
||||
if (pop_snd_pages) return 0; /* набор уже полный */
|
||||
if (!snd_want_pages) return -1; /* begin не проходил либо только тишина */
|
||||
if (snd_done_pages < snd_want_pages) {
|
||||
if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
|
||||
(int8_t)snd_want_pages)
|
||||
return -1;
|
||||
for (i = snd_done_pages; i < snd_want_pages; i++) {
|
||||
pop_snd_page[i] = mem_get_page(snd_blk, i);
|
||||
if (!pop_snd_page[i] ||
|
||||
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) {
|
||||
pop_arc_close(&arc);
|
||||
return -1; /* страницы не отдаём: тишина в стр.0 жива */
|
||||
}
|
||||
snd_done_pages = (uint8_t)(i + 1);
|
||||
}
|
||||
pop_arc_close(&arc);
|
||||
}
|
||||
/* Число страниц ставим ПОСЛЕДНИМ: до этого момента насос обязан
|
||||
* видеть 0 и не пытаться ходить по полузаполненной таблице. */
|
||||
pop_snd_pages = pages;
|
||||
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
|
||||
pop_snd_pages = snd_want_pages;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
#include <gfx.h>
|
||||
#include <kbd_raw.h>
|
||||
#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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
* плюс добавляет минуту. Они меняют счётчик и при выключенном лимите —
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "pop_pal.h"
|
||||
#include "pop_title.h"
|
||||
#include "pop_ui.h"
|
||||
#include "sprpop_cold.h" /* pop_boot_sound/pop_boot_rest — фазы старта */
|
||||
|
||||
#define TITLE_W 320
|
||||
#define TITLE_H 200
|
||||
@@ -110,10 +111,14 @@ int pop_title_logo_ltr(void) __banked
|
||||
return pop_screen_present_ltr() ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Ждать ticks кадров луча, доливая трек. Тема заставки играет с ПЕРВОЙ
|
||||
* страницы (pop_music_stream), остальные обязаны приезжать по ходу — шаг
|
||||
* стоит 16 мс и укладывается в кадровый интервал. */
|
||||
static uint8_t title_wait(uint16_t ticks)
|
||||
{
|
||||
while (ticks--) {
|
||||
if (title_skip_pressed()) return 1;
|
||||
if (pop_music_loading()) (void)pop_music_load_step();
|
||||
gfx_wait_vsync();
|
||||
}
|
||||
return 0;
|
||||
@@ -130,7 +135,8 @@ static void title_load_palette_dimmed(void)
|
||||
uint8_t pop_title_show(void) __banked
|
||||
{
|
||||
uint8_t step;
|
||||
uint8_t skipped = 0, music_ok = 0;
|
||||
uint8_t skipped = 0;
|
||||
uint16_t first_wait = 0; /* такт 0 за вычетом времени загрузки */
|
||||
int prepared;
|
||||
uint8_t cold_start = (uint8_t)(!title_shown &&
|
||||
!pop_settings.show_build_info);
|
||||
@@ -154,22 +160,50 @@ uint8_t pop_title_show(void) __banked
|
||||
pop_screen_present((uint8_t)prepared);
|
||||
title_shown = 1;
|
||||
|
||||
/* Тему читаем с диска ЗАРАНЕЕ (пока экран ещё тёмный), а пускаем ниже —
|
||||
* после fade_in, ровно как show_title: fade_in_2, затем
|
||||
* play_sound(sound_54). */
|
||||
music_ok = (uint8_t)(pop_music_load(POP_MUS_INTRO) == 0);
|
||||
|
||||
/* ЗДЕСЬ ЖЕ ДОГРУЖАЕТСЯ ВСЯ ИГРА — и это не случайное место.
|
||||
*
|
||||
* До 2026-09-02 pop_boot читал ВСЕ ресурсы перед первым пикселем: от
|
||||
* запуска до проявления титула проходило несколько секунд чёрного
|
||||
* экрана в тишине, потому что перед fade_in ещё и вычитывался целиком
|
||||
* трек заставки (248 КБ). Теперь порядок обратный и совпадает с
|
||||
* оригиналом (init_game_main грузит до заставки только мелочь,
|
||||
* seg000.c:156):
|
||||
* 1) картинка титула и fade_in — первое, что происходит;
|
||||
* 2) звук: индекс + ОДНА страница набора, CBL (pop_boot_sound);
|
||||
* 3) тема — с первой же страницы (pop_music_stream), не дожидаясь
|
||||
* всех шестнадцати;
|
||||
* 4) остальные ресурсы — под стоящим титулом и звучащей темой.
|
||||
*
|
||||
* Порядок «fade_in, потом тема» сохранён как у show_title (fade_in_2,
|
||||
* затем play_sound(sound_54)). */
|
||||
for (step = 0; step < sizeof(title_scene); step++) {
|
||||
if (step && title_draw(title_scene[step], 1) < 0)
|
||||
break;
|
||||
if (step == 0) {
|
||||
uint8_t t0;
|
||||
|
||||
if (pop_pal_fade_in_skip(TITLE_FADE)) { skipped = 1; break; }
|
||||
if (music_ok) {
|
||||
(void)pop_sfx_start();
|
||||
pop_music_play();
|
||||
(void)pop_boot_sound();
|
||||
(void)pop_music_stream(POP_MUS_INTRO);
|
||||
/* ЧЕМ ЗАПЛАЧЕН ПЕРВЫЙ ТАКТ. Загрузка занимает около секунды, и
|
||||
* без поправки титул простоял бы на первом кадре дольше
|
||||
* оригинала, а сцена уехала бы относительно темы. Считаем
|
||||
* потраченное часами насоса (85,4 Гц, идут от расхода буфера
|
||||
* CBL — единственные, кому не мешает диск) и переводим в кадры
|
||||
* луча сдвигами: 48,83/85,4 = 0,572 ~ 1/2 + 1/16. Без звука
|
||||
* часы стоят, поправка выходит нулевой — и правильно, тогда и
|
||||
* синхронизировать не с чем. */
|
||||
t0 = pop_snd_tick;
|
||||
(void)pop_boot_rest();
|
||||
{
|
||||
uint16_t spent = (uint8_t)(pop_snd_tick - t0);
|
||||
|
||||
spent = (uint16_t)((spent >> 1) + (spent >> 4));
|
||||
first_wait = (spent >= title_wait_ticks[0])
|
||||
? 0 : (uint16_t)(title_wait_ticks[0] - spent);
|
||||
}
|
||||
}
|
||||
if (title_wait(title_wait_ticks[step])) {
|
||||
if (title_wait(step ? title_wait_ticks[step] : first_wait)) {
|
||||
skipped = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -391,6 +406,15 @@ int main(void)
|
||||
if (!is_demo && pop_app_state != POP_APP_PLAYING) break;
|
||||
demo_new_game = 0;
|
||||
demo_finished = 0;
|
||||
/* Счётчик кадров смерти живёт СНАРУЖИ витка, поэтому переживает возврат
|
||||
* на заставку. Ответ игрока кнопкой его обнуляет, а вот выход по
|
||||
* таймауту (24 с молчания -> title, pop_dead_prompt вернул 2) уходит
|
||||
* мимо этого сброса. Без строки ниже счётчик оставался израсходованным
|
||||
* на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила
|
||||
* в title, не показав «Press Button to Continue». Сбрасываем на входе
|
||||
* в игровой маршрут — это закрывает и любую другую боковую дорогу
|
||||
* (выпадение за нижнюю границу, смена уровня), а не только таймаут. */
|
||||
dead_frames = 0;
|
||||
|
||||
while (!quit && !pop_quit_req && (pop_app_state == POP_APP_PLAYING ||
|
||||
pop_app_state == POP_APP_DEMO)) {
|
||||
@@ -599,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() у оригинала стоит
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
|
||||
#include "pop_shadow.h" /* запечённый набор Тени */
|
||||
#include "pop_sfx.h" /* звуковые эффекты через CBL */
|
||||
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
|
||||
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
|
||||
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
|
||||
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
|
||||
@@ -115,10 +116,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 +138,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 +187,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;
|
||||
}
|
||||
@@ -1109,6 +1115,36 @@ void pop_flip_screen(void) __banked
|
||||
* уровень). В резиденте от них оставались только вызовы — сам код здесь.
|
||||
*/
|
||||
|
||||
/* Фазы старта (см. шапку pop_boot): обе половины идемпотентны, а
|
||||
* boot_rest_fail нужен, чтобы повторный вызов не пытался читать заново
|
||||
* то, что уже не прочиталось. */
|
||||
static uint8_t boot_sound_done;
|
||||
static uint8_t boot_rest_done;
|
||||
static uint8_t boot_rest_fail;
|
||||
|
||||
/* СТАРТ РАЗБИТ НА ТРИ ФАЗЫ — чтобы игрок увидел заставку и услышал музыку
|
||||
* сразу, а не через несколько секунд чёрного экрана в тишине.
|
||||
*
|
||||
* pop_boot() — только настройки и графика: чёрный экран за ~0,1 с.
|
||||
* pop_boot_sound() — индекс+первая страница звука, индекс музыки, CBL:
|
||||
* после него титул уже показан, и тема заставки может
|
||||
* начать играть, не дожидаясь остальных ресурсов.
|
||||
* pop_boot_rest() — всё тяжёлое (эффекты, Кид, Тень, шрифт) ПОД стоящим
|
||||
* титулом и звучащей темой; идемпотентно.
|
||||
*
|
||||
* ЧЕТЫРЕ ЗАГРУЗКИ ОТСЮДА ПРОСТО УБРАНЫ, а не отложены: атлас фона, страж,
|
||||
* уровень 1 и игровая палитра — это дубликаты того, что и так делает
|
||||
* pop_level_switch() на входе в ЛЮБОЙ уровень (а pop_new_game_load ещё и
|
||||
* pop_pal_game_load перед ним). Оба маршрута в игру, LEVEL_LOAD и DEMO,
|
||||
* упираются в pop_level_switch, так что второй раз читать их до заставки
|
||||
* незачем — это ~0,77 с на ровном месте. Так же устроен и оригинал: у
|
||||
* SDLPoP chtab_5/6/7 грузит load_lev_spr() на старте уровня, а init_game_main
|
||||
* до заставки читает только меч, пламя, звуки и палитры (seg000.c:156).
|
||||
*
|
||||
* ПАЛИТРУ ТРОГАТЬ НЕЛЬЗЯ, пока на экране титул: и kid.pal (все 256 записей),
|
||||
* и палитра тайлсета пишутся прямо в железо и перекрасили бы заставку.
|
||||
* Поэтому в отложенной части нет ни одной палитровой операции — они все
|
||||
* живут на маршруте входа в уровень, за чёрным экраном. */
|
||||
int pop_boot(void) __banked
|
||||
{
|
||||
/* Настройки: дефолты, затем POP.CFG, если он есть и корректен. */
|
||||
@@ -1136,77 +1172,114 @@ int pop_boot(void) __banked
|
||||
gfx_set_visible_page(0);
|
||||
pop_pal_black();
|
||||
|
||||
/* Набор фона — по типу СТАРТОВОГО уровня; atlas_load безопасен и после
|
||||
* initgraph: при первом вызове он сохраняет страницу DSS W0. Уровень к
|
||||
* этому моменту ещё не прочитан, поэтому тип берём напрямую от номера. */
|
||||
pop_current_level = FIRST_LEVEL;
|
||||
if (pop_bg_load(pop_level_type()) != 0) {
|
||||
puts("pop_bg_load failed");
|
||||
return -1;
|
||||
}
|
||||
/* pop_bg_load установил только свои слоты палитры. До конца загрузки
|
||||
* всё равно держим экран чёрным. */
|
||||
pop_pal_black();
|
||||
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
|
||||
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
|
||||
pop_guard_reset();
|
||||
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
|
||||
puts("kid.ani load failed\n");
|
||||
return -1;
|
||||
}
|
||||
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
|
||||
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
|
||||
pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */
|
||||
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
|
||||
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
|
||||
* это необязательный ресурс, как и сами треки. */
|
||||
(void)pop_music_index_load();
|
||||
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
|
||||
if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
|
||||
puts("kid atlas load failed");
|
||||
return -1;
|
||||
}
|
||||
if (pop_level_load_num(FIRST_LEVEL) != 0) { /* сырой уровень в EMM-страницу */
|
||||
puts("level load failed");
|
||||
return -1;
|
||||
}
|
||||
pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
|
||||
pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
|
||||
pop_checkpoint = 0; /* init_game (seg003:37) */
|
||||
|
||||
/* FONT.ATL нужен palette snapshot/dim; загрузка идёт ещё на чёрной
|
||||
* палитре и поэтому не открывает игроку старое содержимое VRAM. */
|
||||
/* FONT.ATL — В МИНИМАЛЬНОЙ ФАЗЕ, И ОТКЛАДЫВАТЬ ЕГО НЕЛЬЗЯ.
|
||||
*
|
||||
* Дело не в шрифте: СНИМОК ПАЛИТРЫ физически живёт в хвосте страницы
|
||||
* шрифта (pop_ui.c, POP_FONT_SNAPSHOT_OFF — в W2 килобайта нет), и вся
|
||||
* машинерия яркости начинается с `if (!font_ready) return`. Без него
|
||||
* pop_pal_apply(DIM_BLACK) не гасит, а fade_in нечего проявлять —
|
||||
* заставка возникает разом на полной яркости (поймано пользователем
|
||||
* сразу после первой версии этой перестановки, 2026-09-02).
|
||||
* Цена вопроса — 12 КБ, около 75 мс. */
|
||||
if (pop_menu_init() != 0) {
|
||||
puts("menu font atlas load failed");
|
||||
return -1;
|
||||
}
|
||||
/* kid.pal + динамические slots tileset/Shadow — один логический
|
||||
* источник. pop_pal_game_load делает снимок ПОСЛЕ всех доплат. */
|
||||
pop_pal_game_load();
|
||||
/* Сохраняем ИМЕННО игровую палитру: после стартового title/intro она
|
||||
* понадобится как источник обычного fade-in. Для build-info экран
|
||||
* остаётся штатный яркий игровой фон. */
|
||||
if (!pop_settings.show_build_info) {
|
||||
pop_pal_apply(POP_PAL_DIM_BLACK);
|
||||
}
|
||||
|
||||
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно заново
|
||||
* выполняет pop_level_switch. Если нарисовать её сейчас в обе страницы,
|
||||
* она становится сохранённой VRAM и может мелькнуть перед первым title
|
||||
* при следующем запуске приложения. Единственное исключение — явно
|
||||
* включённый build-info: его фон по контракту является игровой сценой. */
|
||||
pop_current_level = FIRST_LEVEL;
|
||||
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
|
||||
pop_guard_reset();
|
||||
pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
|
||||
pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
|
||||
pop_checkpoint = 0; /* init_game (seg003:37) */
|
||||
if (!pop_settings.show_build_info) pop_pal_apply(POP_PAL_DIM_BLACK);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ЗВУК ВПЕРЁД ОСТАЛЬНЫХ РЕСУРСОВ. Насосу для тишины (а значит и для того,
|
||||
* чтобы CBL вообще открылся) нужен ровно ОДИН блок набора, и упаковщик
|
||||
* обязан класть его первым блоком страницы 0 — поэтому здесь читается одна
|
||||
* страница вместо восьми (33 мс против 440). Остальные приезжают в
|
||||
* pop_boot_rest, а до тех пор pop_sfx_play молча пропускает эффекты.
|
||||
*
|
||||
* Звать ПОСЛЕ того, как титул уже проявлен: чтение всё равно занимает
|
||||
* полсекунды, и лучше, чтобы она прошла под картинкой. */
|
||||
int pop_boot_sound(void) __banked
|
||||
{
|
||||
if (!boot_sound_done) {
|
||||
boot_sound_done = 1;
|
||||
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
|
||||
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
|
||||
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
|
||||
* это необязательный ресурс, как и сами треки. */
|
||||
(void)pop_music_index_load();
|
||||
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
|
||||
}
|
||||
/* ОТКРЫТИЕ CBL — ВНЕ РАЗОВОГО ГАРДА, и это не мелочь. Загрузка нужна
|
||||
* один раз за запуск, а вот вывод к моменту следующего показа заставки
|
||||
* почти наверняка закрыт: pop_title_show начинается с pop_sfx_pause, да
|
||||
* и уровень оставляет CBL закрытым. Пока start стоял под флагом,
|
||||
* Restart Game приводил к заставке БЕЗ музыки (поймано пользователем
|
||||
* 2026-09-02), а заодно подвешивал ожидания «дослушать тему»: с
|
||||
* выключенным насосом pop_mus_left не убывает, и pop_music_busy()
|
||||
* истинен вечно. Идемпотентно и уважает Ctrl+S (pop_snd_want). */
|
||||
(void)pop_sfx_start();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Шаг подкачки трека между кусками загрузки: заставочная тема играет уже
|
||||
* с первой страницы (pop_music_stream), остальные обязаны приезжать по
|
||||
* ходу — иначе насос уйдёт в ещё не прочитанную страницу. Запас после
|
||||
* шага — 0,75 с звучания на каждые 8 КБ, куски загрузки короче. */
|
||||
static void boot_music_step(void)
|
||||
{
|
||||
if (pop_music_loading()) (void)pop_music_load_step();
|
||||
}
|
||||
|
||||
int pop_boot_rest(void) __banked
|
||||
{
|
||||
if (boot_rest_done) return boot_rest_fail ? -1 : 0;
|
||||
boot_rest_done = 1;
|
||||
/* Заставку могли пропустить в первую же секунду — тогда звук ещё не
|
||||
* поднимался. Идемпотентно. */
|
||||
(void)pop_boot_sound();
|
||||
|
||||
(void)pop_sfx_init_finish(); /* дочитать эффекты (музыка уже играет) */
|
||||
boot_music_step();
|
||||
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */
|
||||
puts("kid.ani load failed\n");
|
||||
boot_rest_fail = 1;
|
||||
return -1;
|
||||
}
|
||||
boot_music_step();
|
||||
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
|
||||
boot_music_step();
|
||||
if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
|
||||
puts("kid atlas load failed");
|
||||
boot_rest_fail = 1;
|
||||
return -1;
|
||||
}
|
||||
boot_music_step();
|
||||
|
||||
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно выполняет
|
||||
* pop_level_switch. Единственное исключение — явно включённый
|
||||
* build-info: его фон по контракту является игровой сценой, поэтому
|
||||
* только здесь читаются фон с уровнем и применяется игровая палитра. */
|
||||
if (pop_settings.show_build_info) {
|
||||
if (pop_bg_load(pop_level_type()) != 0 ||
|
||||
pop_level_load_num(FIRST_LEVEL) != 0) {
|
||||
puts("build-info scene load failed");
|
||||
boot_rest_fail = 1;
|
||||
return -1;
|
||||
}
|
||||
pop_guard_load();
|
||||
pop_pal_game_load();
|
||||
pop_start_level();
|
||||
if (pop_current_level == POP_UPSIDE_LEVEL) pop_vflip_load_all();
|
||||
gfx_set_draw_page(0);
|
||||
pop_char_draw(POP_CH_KID);
|
||||
pop_char_fore(POP_CH_KID);
|
||||
}
|
||||
/* Звук включаем ПОСЛЕДНИМ действием инициализации — когда все атласы,
|
||||
* уровень и палитры уже прочитаны. Раньше нельзя: ESTEX уходит в диск
|
||||
* надолго, насос не успевает долить блок, и на слух это скрежет
|
||||
* (pop_sfx.h). Парная к pop_sfx_pause в pop_level_switch. */
|
||||
pop_sfx_start();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1219,6 +1292,11 @@ int pop_level_switch(void) __banked
|
||||
uint8_t old_page;
|
||||
gfx_rect_t full;
|
||||
|
||||
/* РЕСУРСЫ МОГУТ БЫТЬ ЕЩЁ НЕ ПРОЧИТАНЫ. Кид, Тень и шрифт грузятся под
|
||||
* заставкой (см. шапку pop_boot), а её можно пропустить клавишей в
|
||||
* первую же секунду. Идемпотентно: со второго раза это проверка байта. */
|
||||
if (pop_boot_rest() != 0) return -1;
|
||||
|
||||
/* Граница FG0: повреждённый snapshot или старый внешний вызов не должен
|
||||
* даже пытаться открыть res2015.bin. Финальный маршрут 14 -> ENDING
|
||||
* добавит автомат приложения (FG11); пока просто оставляем уровень. */
|
||||
@@ -1363,6 +1441,10 @@ int pop_new_game_load(void) __banked
|
||||
gfx_wait_vsync();
|
||||
}
|
||||
|
||||
/* ДО pop_pal_game_load: она доплачивает слоты палитры по загруженным
|
||||
* наборам (Тень, страж, тайлсет), и звать её раньше их чтения нельзя. */
|
||||
if (pop_boot_rest() != 0) return -1;
|
||||
|
||||
pop_kid_hp_newgame();
|
||||
hitp_max = POP_START_HITP;
|
||||
pop_timer_new_game();
|
||||
@@ -1409,4 +1491,14 @@ void pop_shutdown(void) __banked
|
||||
pop_guard_free();
|
||||
pop_kid_data_free();
|
||||
pop_level_free();
|
||||
/* ЖЕЛЕЗО ЗВУКА ГАСИМ ПОСЛЕДНИМ ДЕЙСТВИЕМ, и pop_sfx_close выше для этого
|
||||
* НЕ ДОСТАТОЧНО: он закрывает сессию, а bit7 порта CBL остаётся включён,
|
||||
* пока его держит gfx_wait_vsync ради бита луча. Дальше main просто
|
||||
* возвращается, а crt0 при возврате уходит прямо в ESTEX EXIT, минуя
|
||||
* цепочку atexit (runtime/crt0_banked.s) — то есть vsync-ссылку снять
|
||||
* уже некому. В результате мы уходили в шелл с включённым CBL, и он
|
||||
* крутил свои 256 слотов: тихо до первой чужой записи в порт данных, а
|
||||
* дальше она зацикливалась (поймано пользователем на железе 2026-09-02;
|
||||
* в MAME не воспроизводится). */
|
||||
cbl_shutdown();
|
||||
}
|
||||
|
||||
@@ -83,9 +83,21 @@ void pop_flip_screen(void) __banked;
|
||||
* Три куска главного цикла с частотой «раз за игру» или «раз на уровень».
|
||||
* В резиденте от них остались только вызовы. */
|
||||
|
||||
/* Загрузить ресурсы, поднять графику и палитру, поставить Кида на старт и
|
||||
* нарисовать первый экран. 0 — OK, -1 — фатально (сообщение уже выведено). */
|
||||
/* СТАРТ В ТРИ ФАЗЫ — ради быстрого появления заставки; подробности и
|
||||
* причины (в том числе почему часть загрузок убрана совсем) — в шапке
|
||||
* pop_boot в sprpop_cold.c. 0 — OK, -1 — фатально (сообщение выведено).
|
||||
*
|
||||
* pop_boot() настройки + графика, чёрный экран;
|
||||
* pop_boot_sound() индекс+первая страница звука, индекс музыки, CBL —
|
||||
* звать, когда титул уже на экране: после него можно
|
||||
* пускать тему заставки;
|
||||
* pop_boot_rest() всё тяжёлое (эффекты, Кид, Тень, шрифт) под стоящим
|
||||
* титулом; идемпотентно, страхуется вызовом из
|
||||
* pop_new_game_load/pop_level_switch на случай, если
|
||||
* заставку пропустили сразу. */
|
||||
int pop_boot(void) __banked;
|
||||
int pop_boot_sound(void) __banked;
|
||||
int pop_boot_rest(void) __banked;
|
||||
|
||||
/* Перейти на pop_next_level: файл уровня, спрайты соперника, тайлсет,
|
||||
* старт. На время I/O закрывает CBL и оставляет его закрытым: caller обязан
|
||||
|
||||
@@ -65,6 +65,9 @@ OBJS_mouse := $(OBJS_phys) build/eng_guards.rel
|
||||
OBJS_shadow := $(OBJS_phys) build/eng_guards.rel
|
||||
# t_jaffar — спецсобытия уровня 13 (встреча, победа, выход, падающая гряда).
|
||||
OBJS_jaffar := $(OBJS_phys) build/eng_guards.rel
|
||||
# t_death — смерть от меча: HP, признак смерти и, главное, координата с
|
||||
# остаточной скоростью падения (находки 12/13 в docs/sdlpop_audit.md).
|
||||
OBJS_death := $(OBJS_phys) build/eng_guards.rel
|
||||
# t_cfg — весь codec POP.CFG из единственного прикладного модуля.
|
||||
OBJS_cfg := build/eng_pop_config.rel build/bank_stub.rel
|
||||
# pop_app — чистая таблица переходов, без DSS/графики.
|
||||
|
||||
@@ -411,3 +411,13 @@ void pop_vflip_reset(void) { }
|
||||
int pop_sword_take(const atlas_t *a) __banked { (void)a; return 0; }
|
||||
int pop_sword_vflip_take(const atlas_t *m) __banked { (void)m; return 0; }
|
||||
int pop_guard_vflip_load(void) __banked { return 0; }
|
||||
|
||||
/* --- заглушки, без которых не линковались t_char/t_gate и прочие ------ *
|
||||
*
|
||||
* pop_chdir_home живёт в pop_path_bank.c (банк 10), а его тянет pop_kboot;
|
||||
* kbd_raw_keypad_as_ext — из libc, её тянет pop_ctrl. Ни файловой
|
||||
* системы, ни клавиатуры в хостовых тестах нет, поэтому обе — пустышки.
|
||||
* До этого сборка tests/host падала на неразрешённых символах, и логику
|
||||
* движка нельзя было проверить без эмулятора. */
|
||||
int8_t pop_chdir_home(void) __banked { return 0; }
|
||||
void kbd_raw_keypad_as_ext(uint8_t on) { (void)on; }
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* t_death — смерть от меча: что происходит с координатой и состоянием.
|
||||
*
|
||||
* Набор заведён ПЕРЕД правкой находок 12/13 (docs/sdlpop_audit.md), чтобы
|
||||
* зафиксировать нынешнее поведение и поймать деградацию. История вопроса:
|
||||
* правка 13 в изоляции уже ломала смерть — мёртвый Кид оставался с
|
||||
* ненулевой скоростью падения, проваливался за нижнюю границу и игра
|
||||
* уходила на рестарт, не показав тела.
|
||||
*
|
||||
* Проверяем ровно то, на что эти правки влияют:
|
||||
* - HP и признак смерти;
|
||||
* - КООРДИНАТУ по Y и остаточную скорость падения (в этом вся суть);
|
||||
* - что смерть наступает при ударе не в боевой стойке (порт оригинала:
|
||||
* «ранение вне боевой стойки означает смерть»).
|
||||
*/
|
||||
#include "tcheck.h"
|
||||
#include "scene.h"
|
||||
#include "stubs.h"
|
||||
#include "pop_kid.h"
|
||||
#include "pop_map.h"
|
||||
#include "pop_guard.h"
|
||||
|
||||
#define E 0
|
||||
#define F 1
|
||||
#define W 20
|
||||
|
||||
/* Ровный пол во всю ширину: ряд 2 — пол, выше пусто. Комната без краёв,
|
||||
* чтобы смерть не смешивалась с падением. */
|
||||
static const uint8_t room_flat[30] = {
|
||||
E, E, E, E, E, E, E, E, E, E,
|
||||
E, E, E, E, E, E, E, E, E, E,
|
||||
F, F, F, F, F, F, F, F, F, F,
|
||||
};
|
||||
|
||||
/* Площадка, обрывающаяся справа: колонки 0-4 — пол, 5-9 пусто. Кид у
|
||||
* самого края спиной к обрыву — целевая сцена находки 12 (в оригинале
|
||||
* такой удар отправляет в отдельную последовательность падения замертво). */
|
||||
static const uint8_t room_ledge[30] = {
|
||||
E, E, E, E, E, E, E, E, E, E,
|
||||
E, E, E, E, E, E, E, E, E, E,
|
||||
F, F, F, F, F, E, E, E, E, E,
|
||||
};
|
||||
|
||||
/* Поставить сцену «страж бьёт Кида» и нанести удар. */
|
||||
static void strike_kid(const uint8_t *room, uint8_t col, uint8_t hp, uint8_t sword)
|
||||
{
|
||||
sc_room(room, 1);
|
||||
sc_kid_at(col, 2, 1 /* лицом вправо */);
|
||||
hitp_curr = hp;
|
||||
Kid.sword = sword;
|
||||
pop_kid_dead = 0;
|
||||
/* Признак «жив» обвязка ставит только стражу, поэтому Киду задаём его
|
||||
* сами: обработчик удара первым делом отсекает уже мёртвого. */
|
||||
Kid.alive = -1;
|
||||
/* Удар оформляется действием 99 «ранен» — так его помечает боевая
|
||||
* проверка оригинала, а разбирает pop_check_sword_hurt. */
|
||||
Kid.action = 99;
|
||||
pop_check_sword_hurt();
|
||||
/* Удар выставляет ДЕЛЬТУ, а HP меняет отдельный шаг кадра — как в
|
||||
* оригинале. Без него hitp_curr остался бы прежним, и тест мерил бы
|
||||
* не то. */
|
||||
pop_do_delta_hp();
|
||||
}
|
||||
|
||||
TC_TEST(death_unarmed_hit_is_lethal)
|
||||
{
|
||||
strike_kid(room_flat, 4, 3 /* HP */, 0 /* без меча */);
|
||||
/* Удар не в боевой стойке смертелен независимо от запаса HP. */
|
||||
TC_EQ(hitp_curr, 0);
|
||||
}
|
||||
|
||||
TC_TEST(death_armed_hit_costs_one_hp)
|
||||
{
|
||||
strike_kid(room_flat, 4, 3, 2 /* меч вынут */);
|
||||
TC_EQ(hitp_curr, 2);
|
||||
}
|
||||
|
||||
TC_TEST(death_armed_last_hp_kills)
|
||||
{
|
||||
strike_kid(room_flat, 4, 1, 2);
|
||||
TC_EQ(hitp_curr, 0);
|
||||
}
|
||||
|
||||
/* --- КООРДИНАТА ПОСЛЕ УДАРА: суть находок 12 и 13 -------------------- */
|
||||
|
||||
TC_TEST(survivor_is_placed_on_floor)
|
||||
{
|
||||
/* Пережил удар — оригинал ставит его на пол своего ряда и гасит
|
||||
* скорость падения. Это единственная ветка, где он так делает. */
|
||||
strike_kid(room_flat, 4, 3, 2);
|
||||
TC_EQ(Kid.fall_y, 0);
|
||||
TC_EQ(Kid.y, pop_y_land[Kid.curr_row + 1]);
|
||||
}
|
||||
|
||||
TC_TEST(death_on_flat_floor_keeps_body_in_place)
|
||||
{
|
||||
/* Смерть на ровном полу: тело обязано остаться в своём ряду. Если
|
||||
* координата или скорость падения уедут, физика утащит труп вниз —
|
||||
* ровно так ломалась игра при правке 13 в одиночку. */
|
||||
strike_kid(room_flat, 4, 1, 2);
|
||||
TC_EQ(Kid.curr_row, 2);
|
||||
TC_EQ(Kid.fall_y, 0);
|
||||
}
|
||||
|
||||
TC_TEST(death_at_ledge_keeps_body_in_place)
|
||||
{
|
||||
/* Смерть у самого обрыва — целевая сцена находки 12. Сейчас мы
|
||||
* ставим «заколот на месте» и удерживаем тело; в оригинале здесь
|
||||
* своя последовательность падения замертво. Тест фиксирует НЫНЕШНЕЕ
|
||||
* поведение: после правки 12+13 ожидание изменится осознанно. */
|
||||
strike_kid(room_ledge, 4, 1, 2);
|
||||
TC_EQ(Kid.curr_row, 2);
|
||||
TC_EQ(Kid.fall_y, 0);
|
||||
}
|
||||
|
||||
/* --- ДВЕ СМЕРТИ БЕЗОРУЖНОГО: на месте и сброшенным с уступа ---------- *
|
||||
*
|
||||
* Оригинал выбирает между ними по обстановке ПОЗАДИ: есть опора или до
|
||||
* кромки меньше четырёх — «заколот на месте»; сзади обрыв — отдельная
|
||||
* последовательность падения замертво (docs/sdlpop_audit.md, находка 12).
|
||||
*
|
||||
* Проверяем сам ВЫБОР: последовательности должны различаться. Сравнение
|
||||
* с конкретным номером было бы хрупким — смещения приходят из таблицы,
|
||||
* извлечённой из данных оригинала. */
|
||||
|
||||
static uint16_t seq_after_unarmed_death(const uint8_t *room, uint8_t col, int8_t dir)
|
||||
{
|
||||
sc_room(room, 1);
|
||||
sc_kid_at(col, 2, dir);
|
||||
hitp_curr = 3;
|
||||
Kid.sword = 0; /* безоружен: падение уже спрятало меч */
|
||||
Kid.alive = -1;
|
||||
pop_kid_dead = 0;
|
||||
Kid.action = 99;
|
||||
pop_check_sword_hurt();
|
||||
pop_do_delta_hp();
|
||||
return Kid.curr_seq;
|
||||
}
|
||||
|
||||
TC_TEST(unarmed_death_with_floor_behind_differs_from_ledge)
|
||||
{
|
||||
uint16_t on_floor, at_ledge;
|
||||
/* Лицом ВПРАВО в середине сплошного пола: сзади (слева) опора. */
|
||||
on_floor = seq_after_unarmed_death(room_flat, 4, 1);
|
||||
/* Лицом ВЛЕВО у кромки: сзади (справа) обрыв — колонки 5..9 пусты. */
|
||||
at_ledge = seq_after_unarmed_death(room_ledge, 4, -1);
|
||||
TC_TRUE(on_floor != at_ledge);
|
||||
}
|
||||
|
||||
TC_TEST(unarmed_death_at_ledge_is_lethal_too)
|
||||
{
|
||||
(void)seq_after_unarmed_death(room_ledge, 4, -1);
|
||||
TC_EQ(hitp_curr, 0);
|
||||
}
|
||||
|
||||
/* --- ПРИЗЕМЛЕНИЕ МЁРТВОГО ТЕЛА ---------------------------------------
|
||||
*
|
||||
* Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
|
||||
* развязка «мягко / средне / разбиться» отведена ЖИВОМУ, а телу — своя
|
||||
* ветка (добить HP, звук падения насмерть, seq_22).
|
||||
*
|
||||
* У нас развилки не было, и это не бросалось в глаза только из-за высоты:
|
||||
* тело, сброшенное ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет,
|
||||
* «последнее HP» не тратится, ветка «разбился» не выбирается никогда, и
|
||||
* труп приземлялся в ПРИСЕД (кадр 109). Отсюда симптом «убитого Кида
|
||||
* уронили, а он сел этажом ниже».
|
||||
*
|
||||
* Пара тестов держит обе стороны развилки: мёртвый обязан разбиться,
|
||||
* живой на той же высоте — сесть, как и раньше. */
|
||||
|
||||
#define FRAME_109_CROUCH 109
|
||||
|
||||
/* Уронить персонажа с ряда 1 на пол ряда 2 и дать физике доиграть. */
|
||||
static void drop_from_row1(const uint8_t *room, uint8_t col, uint8_t alive, uint8_t hp)
|
||||
{
|
||||
uint8_t i;
|
||||
sc_room(room, 1);
|
||||
sc_kid_at(col, 1, 1 /* лицом вправо */);
|
||||
hitp_curr = hp;
|
||||
Kid.alive = (int8_t)alive; /* -1 жив, 0 мёртв (соглашение оригинала) */
|
||||
Kid.sword = 0;
|
||||
pop_kid_dead = 0;
|
||||
Kid.action = 4; /* свободное падение */
|
||||
for (i = 0; i < 40; i++) pop_phys_tick();
|
||||
}
|
||||
|
||||
TC_TEST(dead_body_falls_crushed_not_crouched)
|
||||
{
|
||||
/* Мёртвое тело падает на ОДИН ряд: высоты не хватает ни на урон, ни на
|
||||
* «последнее HP», поэтому до фикса оно уходило в мягкое приземление. */
|
||||
drop_from_row1(room_flat, 4, 0 /* мёртв */, 0);
|
||||
TC_EQ(Kid.curr_row, 2); /* долетело до пола */
|
||||
TC_EQ(Kid.fall_y, 0); /* скорость погашена */
|
||||
TC_FALSE(Kid.frame == FRAME_109_CROUCH); /* НЕ присед */
|
||||
TC_EQ(pop_kid_dead, 1); /* пошли дорогой «разбился» */
|
||||
}
|
||||
|
||||
TC_TEST(living_soft_land_from_row1_still_crouches)
|
||||
{
|
||||
/* Обратная сторона развилки: живой с той же высоты обязан сесть, как и
|
||||
* до фикса. Если этот тест покраснеет — развилка съела живую ветку. */
|
||||
drop_from_row1(room_flat, 4, (uint8_t)-1 /* жив */, 3);
|
||||
TC_EQ(Kid.curr_row, 2);
|
||||
/* Кадр не проверяем: присед — это ПОСЛЕДОВАТЕЛЬНОСТЬ, и к сороковому
|
||||
* тику она уже ушла с начального 109 (первый прогон теста поймал это
|
||||
* на кадре 107). Держим то, что действительно различает ветки. */
|
||||
TC_EQ(pop_kid_dead, 0); /* живого не разбило */
|
||||
TC_EQ(hitp_curr, 3); /* один ряд — без урона */
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
sc_init();
|
||||
TC_RUN(death_unarmed_hit_is_lethal);
|
||||
TC_RUN(death_armed_hit_costs_one_hp);
|
||||
TC_RUN(death_armed_last_hp_kills);
|
||||
TC_RUN(survivor_is_placed_on_floor);
|
||||
TC_RUN(death_on_flat_floor_keeps_body_in_place);
|
||||
TC_RUN(death_at_ledge_keeps_body_in_place);
|
||||
TC_RUN(unarmed_death_with_floor_behind_differs_from_ledge);
|
||||
TC_RUN(unarmed_death_at_ledge_is_lethal_too);
|
||||
TC_RUN(dead_body_falls_crushed_not_crouched);
|
||||
TC_RUN(living_soft_land_from_row1_still_crouches);
|
||||
return 0;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -87,9 +87,120 @@ TC_TEST(wall_stops_undershot_jump)
|
||||
TC_EQ(bad, 0);
|
||||
}
|
||||
|
||||
/* --- ГДЕ КОНЧАЕТСЯ СТЕНА ПО X ---------------------------------------- *
|
||||
*
|
||||
* Колонка 5 — кладка (ряды 1-2). Тайл шириной 14, колонка c занимает
|
||||
* [58 + 14*c, 58 + 14*(c+1)). Для колонки 5 это [128, 142). Персонаж
|
||||
* НИКОГДА не должен оказаться внутри этого отрезка ниже верхнего ряда:
|
||||
* там сплошная кладка, и «падение частично в стене» — как раз оно.
|
||||
*
|
||||
* Проверка отдельная от той, что выше: та смотрит КОЛОНКУ в конце сцены,
|
||||
* а эта — X на КАЖДОМ кадре падения. Персонаж может кончить падение в
|
||||
* законной колонке, успев по дороге пройти сквозь кладку. */
|
||||
#define WALL_X_LO 128
|
||||
#define WALL_X_HI 142
|
||||
|
||||
/* Сколько кадров трассы имеют X внутри кладки, будучи ниже верхнего ряда. */
|
||||
static uint8_t frames_inside_wall(uint8_t x)
|
||||
{
|
||||
uint8_t i, n = 0;
|
||||
sc_room(room14, 14);
|
||||
sc_kid_at_x(8, 0, x, -1);
|
||||
sc_trace_clear();
|
||||
sc_run(SC_L | SC_U, 6);
|
||||
sc_run(0, 34);
|
||||
for (i = 0; i < sc_len; i++) {
|
||||
if (sc_trace[i].row == 0) continue; /* верхний ряд — не кладка */
|
||||
if (sc_trace[i].x >= WALL_X_LO && sc_trace[i].x < WALL_X_HI) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
TC_TEST(wall_x_never_inside_masonry)
|
||||
{
|
||||
uint8_t x, bad = 0;
|
||||
puts_("inside x=177..196: ");
|
||||
for (x = X_LO; x <= X_HI; x++) {
|
||||
uint8_t n = frames_inside_wall(x);
|
||||
put((char)(n ? ('0' + (n > 9 ? 9 : n)) : '.'));
|
||||
if (n) bad++;
|
||||
}
|
||||
put('\n');
|
||||
TC_EQ(bad, 0);
|
||||
}
|
||||
|
||||
/* Симметричный случай: прыжок ВПРАВО через провал колонок 3-4 к площадке
|
||||
* (0,5). Стартовая площадка — колонки 0-2 верхнего ряда; под провалом
|
||||
* дно (ряд 2), по бокам кладка. Недолёт обязан кончиться на дне, а не
|
||||
* внутри кладки: выталкивание из стены работает в обе стороны. */
|
||||
static char jump_right_from(uint8_t x)
|
||||
{
|
||||
sc_room(room14, 14);
|
||||
sc_kid_at_x(2, 0, x, 1 /* лицом вправо */);
|
||||
sc_trace_clear();
|
||||
sc_run(SC_R | SC_U, 6);
|
||||
sc_run(0, 34);
|
||||
if (!sc_len) return '?';
|
||||
{
|
||||
int8_t col = sc_trace[sc_len - 1].col;
|
||||
int8_t row = sc_trace[sc_len - 1].row;
|
||||
if (row == 0) return 'R'; /* остался на верхнем ряду */
|
||||
/* Ниже верхнего ряда законны ОБА провала — слева от кладки
|
||||
* (колонки 3-4, недолёт) и справа (6-7, перелёт через площадку).
|
||||
* Незаконна только сама кладка колонки 5. */
|
||||
if (col != 5) return '.';
|
||||
/* Вис на уступе площадки (0,5) — законное состояние, и колонка
|
||||
* при нём как раз 5. Отличаем по действию персонажа. */
|
||||
{
|
||||
uint8_t act = sc_trace[sc_len - 1].action;
|
||||
if (act == 2 || act == 6) return 'h'; /* hang_climb / hang_straight */
|
||||
}
|
||||
/* Осталось одно: персонаж НИЖЕ верхнего ряда, в колонке кладки и
|
||||
* не висит — то есть находится ВНУТРИ стены. Именно так выглядит
|
||||
* «падение частично в стене» (docs/sdlpop_audit.md, находка 24). */
|
||||
return '5';
|
||||
}
|
||||
}
|
||||
|
||||
/* ФИКС ПАДЕНИЯ СКВОЗЬ СТЕНУ ВЗЯТ — ЖДЁМ НОЛЬ.
|
||||
*
|
||||
* В ванили прыжок вправо через провал давал ДВА случая из четырнадцати,
|
||||
* где Кид оказывается в колонке кладки, будучи в воздухе, — «падение
|
||||
* частично в стене». Это баг оригинального PoP, для которого SDLPoP
|
||||
* держит опциональное исправление (fix_glide_through_wall, seg005 в
|
||||
* do_fall).
|
||||
*
|
||||
* 2026-08-31 исправление ПОРТИРОВАНО в текущий билд
|
||||
* (glide_through_wall_guard в pop_map.c), и оба случая исчезли. Поэтому
|
||||
* ожидание теперь НОЛЬ.
|
||||
*
|
||||
* Если когда-нибудь появится режим VANILLA без этого фикса, ожидание
|
||||
* станет зависеть от режима: 0 в ENHANCED и 2 в ванильном
|
||||
* (docs/vanilla_vs_bugfixed.md). Число 2 сохранено в имени константы
|
||||
* именно поэтому — оно измерено, а не выдумано. */
|
||||
#define WALL_VANILLA_GLIDE_CASES 2 /* сколько их было ДО фикса */
|
||||
#define WALL_EXPECTED_GLIDE_CASES 0 /* сколько ожидаем СЕЙЧАС */
|
||||
|
||||
TC_TEST(wall_stops_jump_from_left_side)
|
||||
{
|
||||
uint8_t x, bad = 0;
|
||||
/* Плита колонок 3-4: [100, 128). */
|
||||
/* Плита колонок 0-2: колонка 2 — это [86, 100). */
|
||||
puts_("right x=86..99: ");
|
||||
for (x = 86; x <= 99; x++) {
|
||||
char r = jump_right_from(x);
|
||||
put(r);
|
||||
if (r != 'R' && r != '.' && r != 'h') bad++;
|
||||
}
|
||||
put('\n');
|
||||
TC_EQ(bad, WALL_EXPECTED_GLIDE_CASES);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
sc_init();
|
||||
TC_RUN(wall_stops_undershot_jump);
|
||||
TC_RUN(wall_x_never_inside_masonry);
|
||||
TC_RUN(wall_stops_jump_from_left_side);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -96,6 +125,8 @@ def find_ci(directory, name):
|
||||
ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
|
||||
PACKED = APP / "assets" / "packed" # раскладка диска игры
|
||||
GEN = APP / "gen" # генерируемые заголовки
|
||||
DIST = APP / "dist" # ручное содержимое дистрибутива (README и
|
||||
# производные наборы, напр. тихий звук)
|
||||
|
||||
|
||||
def atl(name):
|
||||
@@ -110,3 +141,10 @@ def packed(name):
|
||||
p = PACKED / name
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def dist(name):
|
||||
"""Каталог внутри dist/ в раскладке диска; создаётся при обращении."""
|
||||
p = DIST / name
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
@@ -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("<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():
|
||||
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
|
||||
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост
|
||||
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pop_quiet.py — тише в N раз: пережать ГОТОВЫЕ наборы звука и музыки.
|
||||
|
||||
Берёт то, что уже лежит в assets/packed (SND/snd.arc + MUS/m*.bin), делает
|
||||
тише и кладёт в dist/ в той же раскладке диска, ПЕРЕЗАПИСЫВАЯ то, что там
|
||||
было. Оригиналы в assets/packed не трогаются никогда — значит скрипт
|
||||
идемпотентен: сколько раз ни запусти, громкость упадёт ровно один раз.
|
||||
|
||||
ФОРМУЛА. PCM у нас 8 бит БЕЗ ЗНАКА, тишина = 0x80 (см. cbl.h и
|
||||
docs/sound_plan.md), то есть байт — это знаковое отклонение от 0x80.
|
||||
Тише вдвое:
|
||||
|
||||
out = 0x80 + (in - 0x80) / 2 = in / 2 + 0x40
|
||||
|
||||
Правая форма — та самая, что делается на Z80 двумя командами
|
||||
(`srl a` / `add a,#0x40`), поэтому таблицу строим floor-делением: тогда
|
||||
результат совпадает с ней БАЙТ В БАЙТ и его можно сверять с железной
|
||||
реализацией. Тишина остаётся тишиной (0x80 -> 0x80) при любом gain, а
|
||||
клиппинга не бывает по построению: |отклонение| только уменьшается.
|
||||
|
||||
ЧТО ИМЕННО ПЕРЕСЧИТЫВАЕТСЯ.
|
||||
|
||||
MUS/m*.bin — файл целиком: это чистый PCM без заголовка, загрузчик
|
||||
читает его страницами с нулевого смещения (pop_music.c).
|
||||
SND/snd.arc — ТОЛЬКО тела записей архива PBA1. Заголовок (512 Б) и
|
||||
выравнивающий хвост между записями остаются как есть:
|
||||
там нули упаковщика, а не звук, и пересчёт превратил бы
|
||||
их в 0x40 — мусор на месте служебных байт.
|
||||
*.idx — копируются без изменений: длины и смещения не поменялись,
|
||||
меняются только сами отсчёты. Магию всё же проверяем,
|
||||
чтобы молча не скопировать файл чужого формата.
|
||||
|
||||
Запуск:
|
||||
|
||||
python3 tools/pop_quiet.py # вдвое тише, packed -> dist
|
||||
python3 tools/pop_quiet.py --gain 0.25 # вчетверо тише
|
||||
python3 tools/pop_quiet.py --src DIR --out DIR
|
||||
"""
|
||||
import argparse
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import paths
|
||||
|
||||
SILENCE = 0x80 # центр 8-битного беззнакового PCM
|
||||
ARC_MAGIC = b"PBA1" # архив ресурсов, см. src/pop_arc.h
|
||||
ARC_HEADER = 512
|
||||
ARC_SECTOR = 512
|
||||
SND_IDX_MAGIC = b"PSI1"
|
||||
MUS_IDX_MAGIC = b"PMI1"
|
||||
|
||||
|
||||
def gain_table(gain: float) -> bytes:
|
||||
"""Таблица перекодировки байта: 256 значений, готовые для bytes.translate.
|
||||
|
||||
floor, а не округление к ближайшему: при gain = 1/2 это ровно
|
||||
`in >> 1 + 0x40`, то есть побайтово то же, что сделал бы Z80.
|
||||
"""
|
||||
if not 0.0 <= gain <= 1.0:
|
||||
raise SystemExit("gain обязан лежать в [0, 1]; громче делать нечем — "
|
||||
"запас по амплитуде в исходнике уже выбран")
|
||||
out = bytearray(256)
|
||||
for v in range(256):
|
||||
dev = v - SILENCE # знаковое отклонение
|
||||
out[v] = max(0, min(255, SILENCE + _floor_div(dev, gain)))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _floor_div(dev: int, gain: float) -> int:
|
||||
"""dev * gain, округлённое ВНИЗ (к минус бесконечности)."""
|
||||
scaled = dev * gain
|
||||
i = int(scaled)
|
||||
return i - 1 if scaled < i else i # int() режет к нулю
|
||||
|
||||
|
||||
def quiet_raw(src: Path, dst: Path, table: bytes) -> int:
|
||||
"""Файл целиком — PCM (музыкальный трек). Возвращает размер."""
|
||||
data = src.read_bytes()
|
||||
dst.write_bytes(data.translate(table))
|
||||
return len(data)
|
||||
|
||||
|
||||
def quiet_arc(src: Path, dst: Path, table: bytes) -> int:
|
||||
"""Архив PBA1: пересчитываем только тела записей."""
|
||||
raw = bytearray(src.read_bytes())
|
||||
if raw[0:4] != ARC_MAGIC:
|
||||
raise SystemExit(f"{src}: не {ARC_MAGIC.decode()} — это не наш архив")
|
||||
count = raw[4]
|
||||
if not 1 <= count <= (ARC_HEADER - 8) // 4:
|
||||
raise SystemExit(f"{src}: count = {count}, вне 1..126")
|
||||
for i in range(count):
|
||||
off_sec, size = struct.unpack_from("<HH", raw, 8 + i * 4)
|
||||
off = off_sec * ARC_SECTOR
|
||||
if off + size > len(raw):
|
||||
raise SystemExit(f"{src}: запись {i} выходит за файл "
|
||||
f"({off} + {size} > {len(raw)})")
|
||||
raw[off:off + size] = bytes(raw[off:off + size]).translate(table)
|
||||
dst.write_bytes(bytes(raw))
|
||||
return count
|
||||
|
||||
|
||||
def copy_idx(src: Path, dst: Path, magic: bytes) -> None:
|
||||
"""Индекс переносим как есть — но убеждаемся, что он наш."""
|
||||
head = src.read_bytes()[:4]
|
||||
if head != magic:
|
||||
raise SystemExit(f"{src}: магия {head!r}, ожидалась {magic!r}")
|
||||
shutil.copyfile(src, dst)
|
||||
|
||||
|
||||
def peak(data: bytes) -> int:
|
||||
"""Максимальное отклонение от тишины — для отчёта «стало тише»."""
|
||||
return max(abs(b - SILENCE) for b in data) if data else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--gain", type=float, default=0.5,
|
||||
help="множитель амплитуды (по умолчанию 0.5 — вдвое тише)")
|
||||
ap.add_argument("--src", type=Path, default=paths.PACKED,
|
||||
help="откуда брать готовые наборы (умолчание assets/packed)")
|
||||
ap.add_argument("--out", type=Path, default=paths.DIST,
|
||||
help="куда класть (умолчание dist/)")
|
||||
args = ap.parse_args()
|
||||
|
||||
table = gain_table(args.gain)
|
||||
src_snd, src_mus = args.src / "SND", args.src / "MUS"
|
||||
out_snd, out_mus = args.out / "SND", args.out / "MUS"
|
||||
if not src_snd.is_dir() or not src_mus.is_dir():
|
||||
raise SystemExit(f"{args.src}: нет SND/ или MUS/ — сначала "
|
||||
"`make resources`")
|
||||
out_snd.mkdir(parents=True, exist_ok=True)
|
||||
out_mus.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"тише в {1 / args.gain:g} раз(а): {args.src} -> {args.out}")
|
||||
|
||||
arc = src_snd / "snd.arc"
|
||||
before = peak(arc.read_bytes()[ARC_HEADER:])
|
||||
n = quiet_arc(arc, out_snd / "snd.arc", table)
|
||||
after = peak((out_snd / "snd.arc").read_bytes()[ARC_HEADER:])
|
||||
copy_idx(src_snd / "snd.idx", out_snd / "snd.idx", SND_IDX_MAGIC)
|
||||
print(f" SND/snd.arc {n} записей, пик {before} -> {after}")
|
||||
|
||||
tracks = sorted(src_mus.glob("m*.bin"))
|
||||
if not tracks:
|
||||
raise SystemExit(f"{src_mus}: нет ни одного m*.bin")
|
||||
total = 0
|
||||
worst_before = worst_after = 0
|
||||
for t in tracks:
|
||||
total += quiet_raw(t, out_mus / t.name, table)
|
||||
worst_before = max(worst_before, peak(t.read_bytes()))
|
||||
worst_after = max(worst_after, peak((out_mus / t.name).read_bytes()))
|
||||
copy_idx(src_mus / "mus.idx", out_mus / "mus.idx", MUS_IDX_MAGIC)
|
||||
print(f" MUS/ {len(tracks)} треков, {total // 1024} КБ, "
|
||||
f"пик {worst_before} -> {worst_after}")
|
||||
print("индексы (snd.idx, mus.idx) скопированы без изменений — длины те же")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
"""test_pop_quiet — правило громкости и неприкосновенность служебных байт.
|
||||
|
||||
Скрипт трогает данные, которые потом играет насос на Z80, поэтому тест
|
||||
закрепляет ровно две вещи: формулу (она обязана совпадать с беззнаковым
|
||||
`A/2 + 0x40`, чтобы результат можно было сверять с железной реализацией) и
|
||||
то, что заголовок архива с выравнивающим хвостом остаются как были.
|
||||
|
||||
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import pop_quiet as X
|
||||
|
||||
|
||||
class Formula(unittest.TestCase):
|
||||
"""Таблица громкости: то же, что сделал бы Z80."""
|
||||
|
||||
def test_half_is_shift_plus_40(self):
|
||||
t = X.gain_table(0.5)
|
||||
for v in range(256):
|
||||
self.assertEqual(t[v], (v >> 1) + 0x40, f"байт {v:#04x}")
|
||||
|
||||
def test_silence_stays_silence(self):
|
||||
for gain in (0.5, 0.25, 0.1, 1.0):
|
||||
self.assertEqual(X.gain_table(gain)[X.SILENCE], X.SILENCE)
|
||||
|
||||
def test_no_clipping_and_range_shrinks(self):
|
||||
t = X.gain_table(0.5)
|
||||
self.assertEqual(t[0x00], 0x40)
|
||||
self.assertEqual(t[0xFF], 0xBF)
|
||||
for v in range(256):
|
||||
self.assertLessEqual(abs(t[v] - X.SILENCE), abs(v - X.SILENCE))
|
||||
|
||||
def test_gain_one_is_identity(self):
|
||||
self.assertEqual(X.gain_table(1.0), bytes(range(256)))
|
||||
|
||||
def test_gain_out_of_range_rejected(self):
|
||||
for bad in (-0.1, 1.5):
|
||||
with self.assertRaises(SystemExit):
|
||||
X.gain_table(bad)
|
||||
|
||||
|
||||
def make_arc(bodies):
|
||||
"""Собрать PBA1 ровно так, как это делает tools/pop_pack_arc.py."""
|
||||
raw = bytearray(X.ARC_HEADER)
|
||||
raw[0:4] = X.ARC_MAGIC
|
||||
raw[4] = len(bodies)
|
||||
blob = bytearray()
|
||||
for i, body in enumerate(bodies):
|
||||
off = X.ARC_HEADER + len(blob)
|
||||
struct.pack_into("<HH", raw, 8 + i * 4, off // X.ARC_SECTOR, len(body))
|
||||
blob += body
|
||||
blob += bytes(-len(blob) % X.ARC_SECTOR) # хвост выравнивания — нули
|
||||
return bytes(raw + blob)
|
||||
|
||||
|
||||
class Archive(unittest.TestCase):
|
||||
"""PBA1: тела пересчитаны, служебные байты — нет."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.bodies = [bytes([0x80, 0xFF, 0x00, 0x81, 0x7F]), bytes([0xC0] * 3)]
|
||||
self.src = self.dir / "in.arc"
|
||||
self.dst = self.dir / "out.arc"
|
||||
self.src.write_bytes(make_arc(self.bodies))
|
||||
|
||||
def test_bodies_scaled(self):
|
||||
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
||||
raw = self.dst.read_bytes()
|
||||
off = X.ARC_HEADER
|
||||
got = raw[off:off + len(self.bodies[0])]
|
||||
self.assertEqual(got, bytes([0x80, 0xBF, 0x40, 0x80, 0x7F]))
|
||||
|
||||
def test_header_and_padding_untouched(self):
|
||||
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
||||
src, dst = self.src.read_bytes(), self.dst.read_bytes()
|
||||
self.assertEqual(len(src), len(dst))
|
||||
self.assertEqual(src[:X.ARC_HEADER], dst[:X.ARC_HEADER])
|
||||
pad_at = X.ARC_HEADER + len(self.bodies[0])
|
||||
pad_len = -len(self.bodies[0]) % X.ARC_SECTOR
|
||||
self.assertEqual(dst[pad_at:pad_at + pad_len], bytes(pad_len))
|
||||
|
||||
def test_foreign_magic_rejected(self):
|
||||
self.src.write_bytes(b"NOPE" + bytes(X.ARC_HEADER))
|
||||
with self.assertRaises(SystemExit):
|
||||
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
|
||||
|
||||
|
||||
class Track(unittest.TestCase):
|
||||
"""Музыкальный трек — чистый PCM, пересчитывается целиком."""
|
||||
|
||||
def test_whole_file(self):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
src, dst = d / "m54.bin", d / "out.bin"
|
||||
src.write_bytes(bytes([0x00, 0x80, 0xFF] * 4))
|
||||
n = X.quiet_raw(src, dst, X.gain_table(0.5))
|
||||
self.assertEqual(n, 12)
|
||||
self.assertEqual(dst.read_bytes(), bytes([0x40, 0x80, 0xBF] * 4))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+45
-45
@@ -1,67 +1,67 @@
|
||||
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
|
||||
accfill 3783
|
||||
accfill 3797
|
||||
accop 7067
|
||||
argv 3431
|
||||
assrtest 3847
|
||||
argv 3445
|
||||
assrtest 3861
|
||||
atlas 9193
|
||||
attrprob 4087
|
||||
banked 1056
|
||||
bankedbg 1067
|
||||
banklocl 4683
|
||||
banktest 3767
|
||||
bgi_img 8677
|
||||
bgitest 3748
|
||||
bios_text 4461
|
||||
attrprob 4101
|
||||
banked 1070
|
||||
bankedbg 1081
|
||||
banklocl 4697
|
||||
banktest 3781
|
||||
bgi_img 8691
|
||||
bgitest 3762
|
||||
bios_text 4475
|
||||
blitperf 5860
|
||||
blitw 4939
|
||||
cat 913
|
||||
cblstream 5860
|
||||
cbltest 6006
|
||||
cblwav 6099
|
||||
conio 4605
|
||||
conio2 3929
|
||||
cat 927
|
||||
cblstream 5944
|
||||
cbltest 6090
|
||||
cblwav 6183
|
||||
conio 4619
|
||||
conio2 3943
|
||||
convbench 3508
|
||||
dec_test 860
|
||||
errno 5966
|
||||
fbench 8085
|
||||
fdmax 6023
|
||||
filetest 10547
|
||||
dec_test 874
|
||||
errno 5980
|
||||
fbench 8149
|
||||
fdmax 6087
|
||||
filetest 10611
|
||||
fpsdiv 4870
|
||||
gets 509
|
||||
gfx_dbuf 5131
|
||||
gfx_demo 4114
|
||||
gets 523
|
||||
gfx_dbuf 5207
|
||||
gfx_demo 4128
|
||||
gfxbanks 6044
|
||||
hello 4167
|
||||
hello2 4244
|
||||
irqtest 6690
|
||||
hello 4181
|
||||
hello2 4258
|
||||
irqtest 6754
|
||||
kbdpoll 1282
|
||||
kbdraw 4911
|
||||
ls 4835
|
||||
malloc 4461
|
||||
mem_test 4555
|
||||
mouse 4382
|
||||
openenv 6124
|
||||
ls 4849
|
||||
malloc 4475
|
||||
mem_test 4569
|
||||
mouse 4396
|
||||
openenv 6138
|
||||
pageflip 6334
|
||||
palfile 5406
|
||||
ptime 5744
|
||||
rt_test 5197
|
||||
ptime 5758
|
||||
rt_test 5216
|
||||
scroll 6744
|
||||
seek 4188
|
||||
simple 955
|
||||
solidt 11531
|
||||
seek 4202
|
||||
simple 969
|
||||
solidt 11595
|
||||
spranim 9431
|
||||
spriteclip 4119
|
||||
sprites 6791
|
||||
stattest 7549
|
||||
stdlib 6643
|
||||
stest2 3644
|
||||
strtest 1340
|
||||
text_palette 5024
|
||||
timedir 5454
|
||||
stattest 7563
|
||||
stdlib 6657
|
||||
stest2 3658
|
||||
strtest 1354
|
||||
text_palette 5038
|
||||
timedir 5468
|
||||
w0page 8831
|
||||
w3bgfx 5243
|
||||
w3big 3673
|
||||
w3huge 3700
|
||||
w3probe 3556
|
||||
w3tiny 3471
|
||||
winrest 4459
|
||||
winrest 4473
|
||||
|
||||
|
+31
-9
@@ -26,12 +26,29 @@
|
||||
*
|
||||
* _cbl_port_ref/_cbl_port_unref (_cbl_port.c) — общий счётчик желающих
|
||||
* держать bit7 порта 0x004E включённым НЕЗАВИСИМО от полноценной CBL-
|
||||
* сессии. Нужно gfx_wait_vsync() (см. libc/gfx/gfx_wait_vsync.c): бит
|
||||
* 5 порта 0xFE отражает позицию луча (см. MAME sprinter.cpp kbd_fe_r),
|
||||
* но доступен ТОЛЬКО пока включён cbl_mode(). Если реальный CBL уже
|
||||
* открыт — портом владеет он, ref/unref его не трогают; иначе включают
|
||||
* bit7 "немым" кодом частоты _CBL_VSYNC_FREQ (таймер CBL не заводится
|
||||
* — divs[2]=0 в MAME — ни звука, ни прерываний, только синхро-бит).
|
||||
* сессии. Нужно gfx_wait_vsync() (см. libbgi/common/gfx_wait_vsync.c):
|
||||
* бит 5 порта 0xFE отражает позицию луча (см. MAME sprinter.cpp
|
||||
* kbd_fe_r), но доступен ТОЛЬКО пока включён cbl_mode(). Если реальный
|
||||
* CBL уже открыт — портом владеет он, ref/unref его не трогают; иначе
|
||||
* включают bit7 "немым" кодом частоты _CBL_VSYNC_FREQ и заливают
|
||||
* аппаратный буфер тишиной — БЕЗ ЗАЛИВКИ ЖЕЛЕЗО ПОЁТ (см. ниже).
|
||||
*
|
||||
* ЖЕЛЕЗО ПОЁТ ТО, ЧТО ЛЕЖИТ В БУФЕРЕ. Буфер CBL — 256 слотов, которые
|
||||
* при включённом bit7 крутятся ПО КРУГУ и которые никто не чистит;
|
||||
* фирменный пример Sprinter Team (docs/converted/Forum.txt, "Самый
|
||||
* простой пример") на этом и построен: залил 256 отсчётов — "должен
|
||||
* пищать одну ноту". Значит любое состояние "bit7 включён, а насос не
|
||||
* работает" — это бесконечная нота из хвоста последнего сэмпла.
|
||||
*
|
||||
* В MAME этого не видно: там код частоты 2 не заводил таймер вовсе
|
||||
* (divs[2]=0, sprinter.cpp:822), поэтому "немой" режим был тих даром.
|
||||
* На железе код 2 — reserved (Forum.txt:704), поведение не определено, и
|
||||
* ЦАП тактируется: пользователь поймал бесконечную ноту после Ctrl+S и
|
||||
* после пропуска заставки (2026-09-02). Поэтому теперь: (1) частота —
|
||||
* документированная, самая медленная; (2) буфер ВСЕГДА оставляем залитым
|
||||
* тишиной — и на входе в "немой" режим, и при полном выключении (там те
|
||||
* же записи центруют ЦАП обычного COVOX, bit7=0 → порт данных идёт прямо
|
||||
* в ЦАП). Цена — 256 OUT'ов (~0,3 мс) на редкое событие.
|
||||
*/
|
||||
#ifndef _CBL_INTERNAL_H
|
||||
#define _CBL_INTERNAL_H
|
||||
@@ -42,7 +59,10 @@
|
||||
#define _CBL_CTRL_PORT 0x004E
|
||||
#define _CBL_DATA_PORT 0x4F
|
||||
#define _CBL_ACCEL_PAGE 0xFD /* спец-страница EMM: запись на W3@0xC000 идёт в CBL */
|
||||
#define _CBL_VSYNC_FREQ 0x02 /* "немой" код частоты — divs[2]=0 в MAME, таймер не идёт */
|
||||
#define _CBL_VSYNC_FREQ 0x08 /* "немой" режим: самый медленный ДОКУМЕНТИРОВАННЫЙ
|
||||
* код (7,8125 кГц). Тишина обеспечена не
|
||||
* остановкой таймера (это свойство MAME, не
|
||||
* железа), а залитым тишиной буфером. */
|
||||
|
||||
extern uint16_t _cbl_block; /* размер блока: 128 или 256 (по формату) */
|
||||
extern uint16_t _cbl_reqs; /* блоков-запросов обслужено */
|
||||
@@ -62,8 +82,10 @@ int _cbl_open_raw(uint8_t freq_code, uint8_t fmt, uint8_t pump_mode,
|
||||
uint8_t underrun_mode, cbl_fill_fn fill);
|
||||
|
||||
/* Залить весь аппаратный буфер (256 слотов) байтом тишины сразу после
|
||||
* включения — иначе первые 23 мс играет то, что лежало в буфере раньше.
|
||||
* Подробный разбор — в шапке _cbl_prime.c. */
|
||||
* ЛЮБОЙ записи в порт управления: при включении — иначе первые 23 мс
|
||||
* играет то, что лежало в буфере раньше; при выключении — иначе железо
|
||||
* крутит хвост последнего сэмпла бесконечной нотой. Подробный разбор —
|
||||
* в шапке _cbl_prime.c. */
|
||||
void _cbl_prime(uint8_t silence);
|
||||
|
||||
/* ISR-насосы: вызвать fill(), при недоливе — тишина по режиму (один
|
||||
|
||||
@@ -61,7 +61,12 @@ int _cbl_open_raw(uint8_t freq_code, uint8_t fmt, uint8_t pump_mode,
|
||||
static uint8_t atexit_armed;
|
||||
if (!atexit_armed) {
|
||||
atexit_armed = 1;
|
||||
atexit(cbl_close); /* CBL обязан умолкнуть до шелла */
|
||||
/* ИМЕННО shutdown, а не close: close оставляет bit7 включённым,
|
||||
* пока его держит gfx_wait_vsync, и железо крутит буфер уже под
|
||||
* шеллом. Подстраховка неполная — цепочку atexit разматывает
|
||||
* только exit(), а crt0 при возврате из main уходит прямо в ESTEX
|
||||
* EXIT, — поэтому программа обязана звать cbl_shutdown() сама. */
|
||||
atexit(cbl_shutdown); /* CBL обязан умолкнуть до шелла */
|
||||
}
|
||||
|
||||
IRQ_DISABLE();
|
||||
|
||||
+20
-3
@@ -7,9 +7,23 @@
|
||||
*
|
||||
* Если реальный CBL уже открыт (_irq_cbl_hook != 0) — портом владеет
|
||||
* он, ref/unref его не трогают вообще (это уже нужное состояние).
|
||||
* Иначе включаем/выключаем bit7 "немым" кодом частоты _CBL_VSYNC_FREQ
|
||||
* — таймер CBL при этом коде не заводится (divs[2]=0 в MAME), значит
|
||||
* ни звука, ни прерываний, только доступ к синхро-биту.
|
||||
* Иначе включаем/выключаем bit7 "немым" кодом частоты _CBL_VSYNC_FREQ.
|
||||
*
|
||||
* ТИШИНУ ДАЁТ ЗАЛИТЫЙ БУФЕР, А НЕ ОСТАНОВЛЕННЫЙ ТАЙМЕР. Раньше здесь
|
||||
* стоял reserved-код частоты 2 — ровно потому, что в MAME при нём не
|
||||
* заводится таймер (divs[2]=0). На железе поведение reserved-кодов не
|
||||
* определено, ЦАП тактируется, и включённый bit7 поверх старого буфера
|
||||
* даёт бесконечную ноту из хвоста последнего сэмпла (пойман Ctrl+S во
|
||||
* время музыки, 2026-09-02; разбор — в шапке _cbl.h). Поэтому после
|
||||
* КАЖДОЙ записи в порт управления заливаем все 256 слотов тишиной:
|
||||
* - bit7 включён — крутится тишина (беззвучно на любом железе);
|
||||
* - bit7 выключен — те же записи уходят в ЦАП обычного COVOX и
|
||||
* центруют его на 0x80, снимая щелчок/постоянное смещение.
|
||||
* Оба состояния редкие (старт пейсинга, пауза звука, выход), 256 OUT'ов
|
||||
* — около 0,3 мс.
|
||||
*
|
||||
* Цена для ГРАФИЧЕСКОЙ программы без звука: _cbl_prime (~10 Б кода)
|
||||
* приезжает следом за gfx_wait_vsync. Дешевле, чем гудящее железо.
|
||||
*/
|
||||
|
||||
#include "_cbl.h"
|
||||
@@ -19,6 +33,9 @@ void _cbl_port_sync(void)
|
||||
{
|
||||
if (_irq_cbl_hook) return; /* реальный CBL сам владеет портом */
|
||||
_cbl_ctrl(_cbl_port_refs ? (uint8_t)(0x80 | _CBL_VSYNC_FREQ) : 0);
|
||||
/* Формат "немого" режима всегда 8-битный (bit5 не выставлен), поэтому
|
||||
* тишина здесь — 0x80 независимо от того, каким был закрытый сеанс. */
|
||||
_cbl_prime(0x80);
|
||||
}
|
||||
|
||||
void _cbl_port_ref(void)
|
||||
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
* _cbl_prime — залить аппаратный буфер CBL тишиной СРАЗУ после включения.
|
||||
* _cbl_prime — залить аппаратный буфер CBL тишиной СРАЗУ после записи в
|
||||
* порт управления: и при включении, и при выключении.
|
||||
*
|
||||
* ЗАЧЕМ. Буфер CBL (256 слотов, две половины по 128) железо НЕ чистит ни
|
||||
* при сбросе, ни при записи в порт управления — там остаётся то, что лежало
|
||||
@@ -31,6 +32,19 @@
|
||||
* Слотов ровно 256 НЕЗАВИСИМО от формата (в 16-бит слот держит целый
|
||||
* сэмпл), поэтому и записей всегда 256 — байт тишины разный: 0x80 для
|
||||
* 8-бит беззнакового, 0x00 для 16-бит.
|
||||
*
|
||||
* ВТОРАЯ РОЛЬ — ГАШЕНИЕ (добавлена 2026-09-02, _cbl_port_sync). Запись
|
||||
* в порт управления НЕ чистит буфер и в обратную сторону: пока bit7
|
||||
* включён (а его держит gfx_wait_vsync ради бита луча), железо крутит
|
||||
* оставшиеся 256 байт по кругу — это ровно "одна нота" из фирменного
|
||||
* примера Forum.txt, только незваная. Поэтому после каждой записи в
|
||||
* порт управления зовём эту же функцию:
|
||||
* - bit7 остался включён — по кругу крутится тишина;
|
||||
* - bit7 снят — те же 256 записей идут прямо в ЦАП обычного COVOX
|
||||
* (MAME sprinter.cpp, `case 0x88` без cbl_mode()) и оставляют его в
|
||||
* нуле 0x80, а не на последнем сэмпле.
|
||||
* Гонки с насосом нет и здесь: при гашении хук уже снят, при включении
|
||||
* первое прерывание будет только на 128-м слоте.
|
||||
*/
|
||||
|
||||
#include "_cbl.h"
|
||||
|
||||
+11
-2
@@ -10,7 +10,16 @@
|
||||
* Порт 0x004E не гасится напрямую — вместо этого _cbl_port_sync()
|
||||
* пересчитывает нужное значение: если gfx_wait_vsync() ещё держит
|
||||
* ссылку (_cbl_port_refs > 0), останется "немой" режим ради бита
|
||||
* синхронизации; иначе порт гасится полностью.
|
||||
* синхронизации; иначе порт гасится полностью. Он же заливает
|
||||
* аппаратный буфер тишиной — БЕЗ ЭТОГО ЗАКРЫТИЕ CBL НЕ ЗАКРЫВАЕТ ЗВУК:
|
||||
* пока bit7 держит vsync-ссылка, железо крутит по кругу оставшиеся в
|
||||
* буфере 256 байт последнего сэмпла и поёт ноту до следующего
|
||||
* cbl_open() (пойман Ctrl+S во время музыки, 2026-09-02; полный разбор
|
||||
* — в шапке _cbl.h). В MAME это не воспроизводится.
|
||||
*
|
||||
* Порядок обязателен: сначала снять хук (иначе насос долил бы буфер уже
|
||||
* после заливки), и только потом _cbl_port_sync с заливкой — вся
|
||||
* скобка под DI, чтобы между снятием хука и тишиной ничего не влезло.
|
||||
*/
|
||||
|
||||
#include "_cbl.h"
|
||||
@@ -21,7 +30,7 @@ void cbl_close(void)
|
||||
if (!_irq_cbl_hook) return;
|
||||
IRQ_DISABLE();
|
||||
_irq_cbl_hook = 0;
|
||||
_cbl_port_sync(); /* режим порта + заливка буфера тишиной */
|
||||
IRQ_ENABLE();
|
||||
_cbl_port_sync();
|
||||
_irq_table_unref();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* cbl_shutdown — ЖЁСТКО погасить звук перед возвратом в шелл.
|
||||
*
|
||||
* ЧЕМ ОТЛИЧАЕТСЯ ОТ cbl_close(). Тот закрывает СЕССИЮ: снимает насос и
|
||||
* пересчитывает порт по текущим держателям. А держатель обычно есть —
|
||||
* gfx_wait_vsync() занимает bit7 навсегда ради бита луча (см. _cbl_port.c),
|
||||
* — и порт остаётся включённым в "немом" режиме. Для работающей программы
|
||||
* это правильно, но при выходе означает, что железо продолжает крутить свои
|
||||
* 256 слотов уже под шеллом: тихо ровно до первой чужой записи в порт
|
||||
* данных, а дальше эта запись зацикливается навсегда.
|
||||
*
|
||||
* ПОЧЕМУ НЕЛЬЗЯ ПОЛОЖИТЬСЯ НА atexit. Цепочку atexit разматывает только
|
||||
* exit() (libc/sys/exit.c), а crt0 при ВОЗВРАТЕ ИЗ main уходит прямо в
|
||||
* ESTEX EXIT (runtime/crt0_banked.s) — ни один хендлер не выполняется.
|
||||
* Значит и снятие vsync-ссылки, и cbl_close из atexit при обычном
|
||||
* `return 0;` из main не происходят. Поэтому выключение должно быть
|
||||
* ЯВНЫМ действием программы, а не следствием чужой уборки.
|
||||
*
|
||||
* Что делает: закрывает сессию (если была), обнуляет счётчик держателей —
|
||||
* программа уходит, держать порт больше некому, — гасит bit7 и центрует
|
||||
* ЦАП обычного COVOX (при выключенном CBL записи в порт данных идут прямо
|
||||
* в него, см. _cbl_prime.c), чтобы вместо щелчка осталась ровная тишина.
|
||||
*
|
||||
* Идемпотентно; звать последним действием завершения.
|
||||
*/
|
||||
|
||||
#include "_cbl.h"
|
||||
#include "../irq/_irq.h" /* IRQ_DISABLE/IRQ_ENABLE */
|
||||
|
||||
void cbl_shutdown(void)
|
||||
{
|
||||
cbl_close(); /* снять насос и отпустить IM2-таблицу */
|
||||
IRQ_DISABLE();
|
||||
_cbl_port_refs = 0; /* держателей больше нет: программа уходит */
|
||||
_cbl_ctrl(0); /* bit7 = 0 — CBL выключен целиком */
|
||||
_cbl_prime(0x80); /* ...и ЦАП COVOX в центре, без щелчка */
|
||||
IRQ_ENABLE();
|
||||
}
|
||||
+13
-3
@@ -55,9 +55,14 @@
|
||||
* cbl_underruns() считает недоливы в ОБОИХ режимах — это диагностика,
|
||||
* поведение не меняет.
|
||||
*
|
||||
* cbl_close() обязателен (atexit подстрахует) — иначе CBL продолжит
|
||||
* прерывать шелл после выхода; заодно освобождает буфер тишины, если
|
||||
* он был аллоцирован.
|
||||
* cbl_close() обязателен — иначе CBL продолжит прерывать шелл после
|
||||
* выхода. НО ОН НЕ ГАСИТ ЖЕЛЕЗО ПОЛНОСТЬЮ: пока bit7 держит
|
||||
* gfx_wait_vsync() ради бита луча, порт остаётся включённым в "немом"
|
||||
* режиме, и буфер крутится под шеллом. Перед возвратом из main звать
|
||||
* cbl_shutdown() — он выключает всё и центрует ЦАП.
|
||||
*
|
||||
* На atexit тут полагаться НЕЛЬЗЯ: цепочку разматывает только exit(), а
|
||||
* crt0 при возврате из main уходит прямо в ESTEX EXIT.
|
||||
*
|
||||
* Требования как у <irq.h>: код/данные в W2 (tiny/big).
|
||||
*/
|
||||
@@ -67,6 +72,11 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Выключить звук ПОЛНОСТЬЮ перед завершением программы: закрыть сессию,
|
||||
* снять bit7 независимо от держателей и оставить ЦАП в центре. Звать
|
||||
* последним действием завершения; идемпотентно. */
|
||||
void cbl_shutdown(void);
|
||||
|
||||
/* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума
|
||||
* Sprinter Team; коды 0/1 — legacy, не использовать). */
|
||||
#define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */
|
||||
|
||||
@@ -10,4 +10,11 @@
|
||||
extern void (*_atexit_stack[ATEXIT_MAX])(void);
|
||||
extern int _atexit_top;
|
||||
|
||||
/* Разматыватель цепочки (LIFO) — общее тело exit() и возврата из main. */
|
||||
void _atexit_run(void);
|
||||
|
||||
/* Указатель на него для crt0; ставит atexit() при первой регистрации.
|
||||
* Подробности и мотивация косвенности — в шапке _atexit_hook.c. */
|
||||
extern void (*_atexit_hook)(void);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* _atexit_hook — указатель на разматыватель цепочки atexit; его дёргает
|
||||
* crt0 при ВОЗВРАТЕ ИЗ main.
|
||||
*
|
||||
* ЗАЧЕМ КОСВЕННОСТЬ. По стандарту C возврат из main эквивалентен
|
||||
* exit(status), то есть цепочка atexit обязана размотаться. Но прямая
|
||||
* ссылка crt0 на разматыватель притащила бы в КАЖДУЮ программу и его, и
|
||||
* весь стек хендлеров — включая те, что atexit() не зовут вовсе. Поэтому
|
||||
* crt0 ссылается на этот модуль (два байта _DATA, ни байта кода), а
|
||||
* заполняет указатель сам atexit() при первой регистрации: нет
|
||||
* регистраций — указатель нулевой, разматыватель в программу не приезжает,
|
||||
* crt0 просто уходит в ESTEX EXIT как раньше. Тот же приём, что у
|
||||
* _irq_cbl_hook в libc/irq.
|
||||
*/
|
||||
|
||||
#include "_atexit.h"
|
||||
|
||||
void (*_atexit_hook)(void);
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* _atexit_run — размотать цепочку atexit-хендлеров (LIFO).
|
||||
*
|
||||
* Общее тело для двух входов: явного exit() и возврата из main (там его
|
||||
* зовёт crt0 через _atexit_hook). Отдельным модулем — чтобы программа,
|
||||
* не зарегистрировавшая ни одного хендлера, не получала этот код вовсе.
|
||||
*
|
||||
* Вершина сбрасывается ПЕРЕД вызовом каждого хендлера: если хендлер сам
|
||||
* позовёт exit(), цепочка не пойдёт по второму кругу.
|
||||
*/
|
||||
|
||||
#include "_atexit.h"
|
||||
|
||||
void _atexit_run(void)
|
||||
{
|
||||
while (_atexit_top > 0) {
|
||||
void (*fn)(void) = _atexit_stack[--_atexit_top];
|
||||
if (fn) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -3,9 +3,11 @@
|
||||
* завершении (exit()), максимум 8, порядок LIFO. Возвращает 0 или -1
|
||||
* при переполнении стека хендлеров.
|
||||
*
|
||||
* Внимание: возврат из main() без явного exit() уходит в inline RST
|
||||
* 10h #41 в crt0.s и цепочку atexit НЕ выполняет — программам с
|
||||
* хендлерами нужно завершаться через exit().
|
||||
* ВОЗВРАТ ИЗ main ТОЖЕ РАЗМАТЫВАЕТ ЦЕПОЧКУ (с 2026-09-02), как и требует
|
||||
* стандарт C. Раньше crt0 уходил из main прямо в ESTEX EXIT, и хендлеры
|
||||
* не выполнялись: программа теряла и сброс файловых буферов (_fclosall), и
|
||||
* гашение звука, если не звала exit() явно. Теперь первая регистрация
|
||||
* ставит _atexit_hook, а crt0 его дёргает — см. _atexit_hook.c.
|
||||
*/
|
||||
|
||||
#include <stdlib.h>
|
||||
@@ -17,5 +19,8 @@ int atexit(void (*fn)(void))
|
||||
return -1;
|
||||
}
|
||||
_atexit_stack[_atexit_top++] = fn;
|
||||
/* Хендлеры появились — значит crt0 обязан размотать цепочку и при
|
||||
* простом `return` из main. Ставим каждый раз: проверка дороже. */
|
||||
_atexit_hook = _atexit_run;
|
||||
return 0;
|
||||
}
|
||||
|
||||
+1
-6
@@ -9,11 +9,6 @@
|
||||
|
||||
void exit(int code)
|
||||
{
|
||||
while (_atexit_top > 0) {
|
||||
void (*fn)(void) = _atexit_stack[--_atexit_top];
|
||||
if (fn) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
_atexit_run();
|
||||
_exit(code);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
.module crt0
|
||||
.globl _main
|
||||
.globl __atexit_hook
|
||||
|
||||
;; Linker-emitted symbols (resolved at link time).
|
||||
.globl s__INITIALIZER
|
||||
@@ -88,6 +89,22 @@ _start::
|
||||
ld de, (_argv)
|
||||
call _main
|
||||
|
||||
;; Returning from main is exit(status) per the C standard, so the
|
||||
;; atexit chain must run here too. The call is INDIRECT on purpose:
|
||||
;; atexit() fills __atexit_hook on its first registration, so programs
|
||||
;; that never register anything keep it NULL and link neither the
|
||||
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
|
||||
push de ; DE = int return of main
|
||||
ld hl, (__atexit_hook)
|
||||
ld a, h
|
||||
or a, l
|
||||
jr Z, atexit_done
|
||||
ld de, #atexit_done
|
||||
push de ; return address: Z80 has no `call (hl)`
|
||||
jp (hl)
|
||||
atexit_done:
|
||||
pop de
|
||||
|
||||
;; SDCC's int return → DE. Low byte is the exit code.
|
||||
ld a, e
|
||||
ld b, a
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
.module crt0_banked
|
||||
.globl _main
|
||||
.globl __atexit_hook
|
||||
.globl _n_banks
|
||||
.globl _bank_pages
|
||||
|
||||
@@ -218,6 +219,22 @@ skip_bank_load:
|
||||
ld de, (_argv)
|
||||
call _main
|
||||
|
||||
;; Returning from main is exit(status) per the C standard, so the
|
||||
;; atexit chain must run here too. The call is INDIRECT on purpose:
|
||||
;; atexit() fills __atexit_hook on its first registration, so programs
|
||||
;; that never register anything keep it NULL and link neither the
|
||||
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
|
||||
push de ; DE = int return of main
|
||||
ld hl, (__atexit_hook)
|
||||
ld a, h
|
||||
or a, l
|
||||
jr Z, atexit_done
|
||||
ld de, #atexit_done
|
||||
push de ; return address: Z80 has no `call (hl)`
|
||||
jp (hl)
|
||||
atexit_done:
|
||||
pop de
|
||||
|
||||
;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1).
|
||||
ld a, e
|
||||
jr exit_with_a
|
||||
|
||||
Reference in New Issue
Block a user