Compare commits

...

14 Commits

Author SHA1 Message Date
snark13 aaa480d0f2 SprPoP: в CLAUDE.md — игра на образе лежит в D:\GAMES\SPRPOP
С обобщения HDD-сборки (ea8efdb) один образ рассчитан на несколько
приложений, и EXE переехал в подкаталог.  В инструкции по запуску это не
отразили, поэтому старая последовательность (`d:` + `sprpop`) отвечает
`Bad command or file name`, а `dir D:\` показывает только каталог GAMES —
на это можно потратить время впустую.

Записаны и сам путь, и исправленная цепочка клавиш для моста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:40 +03:00
snark13 8548aa9132 SprPoP: кнопка под Кидом больше не заливается кладкой (палас)
Симптом (пользователь, 2026-09-02, уровень 4 комната 18): Кид
подтягивается на открывающую кнопку (0,4), и кромка, которая его
прячет, нарисована кирпичом вместо кнопки.

РАЗБОР ПО ЖИВОМУ КАДРУ (заморозка): Kid frame 139, x 123, col 4, row 1,
room 18 — то есть персонаж действительно под кромкой кнопки (0,4).
Отсюда путь: кадр подъёма -> climb_overlay_tile; тайл не floor-подобный
(кнопка) -> other_overlay_tile; сосед слева (0,3) пуст -> overlay_mid_tile,
полная перерисовка тайла ПОВЕРХ персонажа.

ПРИЧИНА.  draw_tile_base оригинала подменяет базу кнопки на «левую
половину без пола слева» (id 148) по ТРЁМ условиям (seg008:628): тайл —
opener, слева пусто И tbl_level_type[current_level] == 0, то есть только
в ПОДЗЕМЕЛЬЕ.  Третье условие у нас было потеряно ИМЕННО В ОВЕРЛЕЕ:
статическая отрисовка (pop_room.c, draw_tile_base) его имеет и даже
ссылается на ту же строку оригинала, а pop_bg.c — нет.  Уровень 4
дворцовый, поэтому оверлей брал спрайт 148, а в дворцовом наборе это
другая картинка.

Этим же объясняется, почему баг не виден «просто так»: пока тайл рисует
статика, кнопка правильная — кирпич появляется ровно в тот момент, когда
персонаж встаёт под кромку и включается оверлей.

Цена: +6 байт в банке 2 (8976 -> 8982, свободно 7402).

Проверять стоит на любом ДВОРЦОВОМ уровне, где у открывающей кнопки
слева пусто и персонаж лезет на неё снизу.  Живая проверка в MAME
пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:29 +03:00
snark13 400f5cba63 SprPoP: поправка в CLIMB-VS-GUARD — звук 11 значит ПРОМАХ
В записи стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет.  Это
было описание НАШЕГО кода, а не SDLPoP: ровно так звук у нас и стоял —
внутри ветки «не парировано» и до присвоения Opp.action = 99_hurt (снято
предыдущим коммитом).  Классическое нарушение правила «источник истины —
SDLPoP»: за оригинал приняли собственную реализацию, и вывод из неё уехал
в доску как факт.

На деле звук 11 в оригинале защищён условием Opp.action != 99_hurt, то
есть звучит ТОЛЬКО на промахе: на попадании играет боль, на парировании
Opp.frame уже 161.

Что это меняет для самого бага:

- УЦЕЛЕЛО наблюдение «в оригинале 11, у нас 8» — это данные.  Арифметика
  приоритетов их подкрепляет: snd_prio[11] = 0x12 против snd_prio[8] =
  0x4B (меньше значит важнее), взмах не мог быть заглушён упором в стену.
- ОТПАЛ вывод «оригинал ведёт стража атакой с промахом, а мы —
  столкновением»: он опирался на неверную посылку.
- УСИЛИЛАСЬ версия про ПОРОГ ДИСТАНЦИИ: раз в оригинале слышен именно 11,
  укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ.  Прежняя «версия про столкновение»
  вытеснила дистанцию зря — возвращаем её в главные подозреваемые.

Отдельно предупреждение тому, кто вернётся к багу: диагностика по звуку
теперь значит другое, старые заметки прогона будут вводить в заблуждение,
сцену надо переснимать.  Положена таблица соответствий (11 = промах, 13 =
попадание, 8 = столкновение, тишина = ранний выход из check_hurting).

На механику урона правки не влияют — расхождение «удар убивает» остаётся
открытым как было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:07 +03:00
snark13 cb995bf0fc SprPoP: непрерывный взмах клинка у безоружного Кида
Симптом (пользователь, 2026-09-02): на подъёме Кида шёл нескончаемый
свист клинка, хотя меча у него нет.

ОПОЗНАНИЕ.  Звук снят с живой машины, а не угадан: snd_curr == 11
(sound_11_sword_moving).  Заодно проверено, что тракт исправен —
указатели насоса (pg 2, ptr 0x2B80, left 512) сошлись с записью 11 в
SND/snd.idx (страница 2, off 0x2880, длина 1280, конец 0x2D80) байт в
байт.  То есть играл честно заявленный эффект из своих данных, и виновата
была ЗАЯВКА, а не звук.

ПРИЧИНА.  Хвост check_hurting был портирован не до конца.  В оригинале
(seg002:1039..1044) звук 11 стоит В КОНЦЕ функции, после ОБЕИХ веток, и
защищён тремя вещами: ранним выходом по dir_56_none, кадром 154 и
условием Opp.action != actions_99_hurt.  У нас он стоял ВНУТРИ ветки «не
парировано», до присвоения Opp.action = 99_hurt, без проверки на
попадание и — главное — без гарда по dir_56_none, который автор SDLPoP
подписал прямым текстом: «Fix looping sword moving sound».  Направление
dir_56_none означает «персонаж выключен» (clear_char), махать ему нечем.

Наш движок эту константу знает и применяет в pop_guard_tick — в
check_hurting она просто не доехала.

ТОНКОСТЬ ПОРТА: хвост оригинала ПЕРЕЧИТЫВАЕТ Char.frame и Opp.frame у
персонажей, а не берёт снимки начала функции.  Ветка парирования только
что записала Opp.frame, а play_seq в ней — Char.frame; на кэшированных
cf/of условие дало бы неверный ответ.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:45 +03:00
snark13 38fb3c03bb SprPoP: зацеп больше не срывается на кадре после захвата
Регресс от 4e12aa5: glide_through_wall_guard() звался в do_fall сразу
после check_grab и отменял ТОЛЬКО ЧТО СОСТОЯВШИЙСЯ зацеп.

Причина в имени последовательности: seq_15 — это grab_ledge_MIDAIR, и
после удачного захвата Char.action == 3, то есть персонаж формально всё
ещё «в воздухе», уже подтянутый вплотную к кромке.  А под кромкой в
комнатах оригинала стоит кладка.  Guard видел ровно её (замерено на живой
сцене: t == TILE_WALL, d == 10, col 0, row 1), считал это пролётом сквозь
стену, отбрасывал персонажа на 5 пикселей назад и гасил fall_x.  Зацеп
РИСОВАЛСЯ и тут же срывался — ловилось на длинном прыжке уровня 3
(комната 7) и в attract-демо (комната 2, прыжок с места на кромку 0,2,
после срыва Кид падал на пики).

ФИКС.  check_grab() возвращает признак «зацепился», и при нём guard не
зовётся.  Цена — один тест байта на кадр падения; фикс «падение сквозь
стену» цел, t_wall зелёный.

ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ — и главный урок.  t_grab, t_phys
(1733 проверки) и t_wall судят по «действие стало вис», а вис-то
наступал, он просто не жил.  Первый A/B guard'а по этой же метрике дал
ЛОЖНО-ОТРИЦАТЕЛЬНЫЙ ответ, и подозрение с него было снято зря.  Разница
между «зацепился» и «зацепился и держится» — это и есть разница между
багом и нормой.

Отсюда новый набор t_hang: критерий — вис ДЕРЖИТСЯ три кадра подряд, и в
отчёте различаются «не наступило» и «наступило и сорвалось».  Окно — 34
фазы разбега из 41; при намеренно возвращённой поломке 0 из 41, тест
краснеет (проверено).  Сцена — геометрия уровня 3 комнаты 7, приведённая
внутрь одной комнаты, чтобы шов не примешивался; кромка-пол и
кромка-решётка проверяются отдельно.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:23 +03:00
snark13 879f2bae31 size_baseline: принять atlas — эталон был записан со старым .map
atlas значился 9193 Б против 9627 фактических, и расхождение держалось
через все правки этой сессии: замерял со СПРЯТАННЫМИ правками — те же
9627, то есть к ним оно отношения не имеет.

Похоже на механику самого инструмента: size_check.py читает .map С ДИСКА,
а не пересобирает.  Программа, которую в тот раз не пересобрали, попадает
в эталон со СТАРЫМ числом — и дальше висит расхождением, пока её однажды
не соберут заново.  Сегодня после make clean впервые за долгое время
пересобралось всё, и накопленный рост libc/libbgi стал видимым разом.

Записываю честное число.  Полезный вывод на будущее: `make size-baseline`
имеет смысл только после полной пересборки тестов, иначе он фиксирует
смесь свежих и устаревших размеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:42 +03:00
snark13 b3f9a7430c SprPoP: тихие наборы звука и музыки в dist/
Выхлоп tools/pop_quiet.py на текущих наборах: те же snd.arc и m*.bin, но
вдвое тише (out = in/2 + 0x40, тишина 0x80 остаётся на месте), плюс
неизменённые индексы.  Раскладка совпадает с диском игры.

Это ПРОИЗВОДНЫЙ артефакт: восстанавливается из assets/packed одной
командой `python3 tools/pop_quiet.py` за секунды.  Лежит в репозитории по
той же логике, что и assets/packed — чтобы вариант был под рукой без
пересборки; если решим, что 3,6 МБ того не стоят, снимается одним
git rm --cached плюс строка в .gitignore.

На образ игры dist/ автоматически не попадает: make hdd берёт assets/packed
через build/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:12 +03:00
snark13 fee3bdb354 SprPoP: ALLOCS по умолчанию 10000; обёртка dist_to_edge — за своим хелпером
Правки пользователя, разобранные по диффу и закоммиченные как есть.

Makefile: умолчание --max-allocs поднято с 6000 до 10000 — плотнее
упаковка регистров.  Напоминание из шапки остаётся в силе: занятость
банков сравнима только при ОДНОМ значении ALLOCS, иначе сравниваются не
правки, а уровни оптимизации.

pop_map.c: банковая обёртка pop_dist_to_edge_weight() переехала НИЖЕ
статического distance_to_edge_weight(), который она зовёт, — раньше стояла
до его определения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:56 +03:00
snark13 6a97124e0d sprinter-cc: --bank-data принимает номер банка
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Было «всё или ничего»: --bank-data уводил писучие данные В СТРАНИЦУ для
ВСЕХ банков сразу.  Теперь флаг принимает необязательный номер и
повторяется: --bank-data 5 --bank-data 6.  Без аргумента поведение прежнее
(все банки), поэтому существующие сборки не меняются.

Зачем поштучно: данные в странице банка НЕ ВИДНЫ снаружи, поэтому модуль с
экспортируемым глобалом обязан остаться на общем _DATA в W1/W2 — а его
сосед в это же время может держать большой приватный буфер вне резидентного
бюджета.  Одним флагом на всю программу это не выражается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:43 +03:00
snark13 8610a8c178 toolchain: предупреждать, когда ISR-стаб W0-страниц остался в W1
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Программа может временно маппить свою EMM-страницу в окно 0 (атласы
спрайтов, gfx_w0_map).  Пока страница там, любое прерывание уходит на
#0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare прошивает туда переход на
_gfx_w0_isr.  Сам стаб живёт в _CODE, а в режимах small и huge _CODE
начинается с 0x4100 — то есть попадает в W1, окно, которое DSS перемаплет
на время СВОИХ вызовов.  Прерывание в этот момент уходит по адресу,
которого сейчас нет: W1 читается как #FF.

Ловится тяжело: собирается молча, проявляется недетерминированно —
зависанием примерно на каждом третьем холодном старте.  Поймано вживую
2026-09-01.

check_w0_isr.py берёт адрес __gfx_w0_isr из .map: символа нет — W0-путь не
слинкован, молчим; адрес >= 0x8000 — он в W2, всё хорошо; иначе WARNING.
Сборку не валит намеренно: свои страницы в W0 кладут не все.  Вызов
добавлен в app.mk рядом с check_bank_calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:26 +03:00
snark13 81e8ed4676 SprPoP: pop_quiet.py — тихая копия готовых наборов звука и музыки
Берёт то, что уже лежит в assets/packed, делает тише и кладёт в dist/ в
раскладке диска, перезаписывая прежнее.  Оригиналы не трогаются, поэтому
скрипт идемпотентен: сколько раз ни запусти, громкость упадёт один раз.

Формула — та, что предложил пользователь: PCM у нас 8 бит без знака с
тишиной 0x80, значит вдвое тише это out = in/2 + 0x40 (беззнаковый сдвиг).
Таблица на 256 значений строится floor-делением отклонения от центра, что
при gain = 1/2 совпадает с этой формулой байт в байт — то есть результат
можно сверять с реализацией на Z80 (srl a / add a,#0x40).  Тишина остаётся
тишиной при любом gain, клиппинга нет по построению.

Пересчитываются: MUS/m*.bin целиком (чистый PCM) и ТОЛЬКО тела записей
PBA1 в SND/snd.arc — заголовок и выравнивающие хвосты остаются как есть,
там нули упаковщика, и пересчёт превратил бы их в 0x40.  Индексы
копируются без изменений (длины те же), но с проверкой магии.

Прогон на текущих наборах: 10 записей архива и 22 трека, пик 128 -> 64,
размеры байт в байт прежние, заголовок PBA1 не тронут.

Тест закрепляет формулу и неприкосновенность служебных байт; make
test-tools — 45 тестов.  Раскладка dist/ добавлена в tools/paths.py, по
правилу «пути знает один файл».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:16:07 +03:00
snark13 8490288d79 libc/crt0: возврат из main завершает программу по-настоящему
Два бага одного пути завершения, оба видны только на железе.

1. ЗВУК ПРОДОЛЖАЛСЯ ПОСЛЕ ВЫХОДА.  cbl_close() закрывает СЕССИЮ, но не
гасит железо: bit7 порта 0x004E держит gfx_wait_vsync ради бита луча,
поэтому порт оставался включённым ("немой" режим), и CBL крутил свои 256
слотов уже под шеллом — тихо ровно до первой чужой записи в порт данных,
а дальше она зацикливалась.  Новый cbl_shutdown() гасит bit7 независимо
от держателей и центрует ЦАП обычного COVOX; pop_shutdown зовёт его
последним действием, а _cbl_open_raw регистрирует в atexit его, а не
cbl_close.

2. ЦЕПОЧКА atexit НЕ ВЫПОЛНЯЛАСЬ ПРИ ВОЗВРАТЕ ИЗ main.  crt0 уходил прямо
в ESTEX EXIT, то есть нарушал контракт C (возврат из main = exit(status)).
Молча терялись не только гашение звука и снятие vsync-ссылки, но и
_fclosall: буферизованная запись в файлы пропадала, если программа не
звала exit() явно.  Теперь crt0 после main дёргает _atexit_hook.

Косвенность обязательна: прямая ссылка crt0 на разматыватель притащила бы
его и стек хендлеров в КАЖДУЮ программу.  Указатель живёт в отдельном
data-модуле (два байта _DATA, ни байта кода), ставит его сам atexit() при
первой регистрации — нет регистраций, нет и кода.  Тот же приём, что у
_irq_cbl_hook.

Цена замерена: +14 Б всем программам (блок в crt0) и +64 Б тем
одиннадцати, что реально регистрируют хендлеры (CBL, файловые через
_fclosall, irqtest, gfx_dbuf, solidt) — у них раньше эти хендлеры были
мёртвым кодом.  Эталоны обновлены (кроме atlas: его +434 Б не отсюда,
замерен тот же и без этих правок).

Проверено в MAME: старт и звук как были, выход по F10 возвращает в шелл
чисто (текстовый режим восстановлен, зависания нет), повторный запуск
работает.  Пункт 1 проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:15:48 +03:00
snark13 31d0075090 SprPoP: набор звуковых эффектов пересобран из MSDOS-оцифровки
make SND_SRC=msdos MUSIC_FMT=flac resources-rebuild — полная перегенерация
всех ресурсов.  Изменился только SND/: у MSDOS-набора оцифровка полнее,
чем у SDLPoP (там пуст звук 48 spiked и короче 10 sword_vs_sword — разбор
в docs/sound_plan.md).

Музыка и остальные архивы после перегенерации совпали с закоммиченными
байт в байт — набор flac и есть тот, из которого они сделаны.

Умолчание сборки НЕ меняется: SND_SRC ?= sdlpop, чтобы клон без
оригинального дистрибутива DOS собирался целиком (make fetch).  Вернуть
прежний набор — make resources-sound после make clean stamps, или
make SND_SRC=sdlpop -B resources-sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:17 +03:00
snark13 6176f6da31 SprPoP: Restart Game возвращал заставку без музыки и вешал её на титуле
pop_boot_sound() был целиком под разовым флагом, а pop_sfx_start() стоял
внутри него.  До перестановки старта CBL открывал сам шаг 0 титров, и
делал это КАЖДЫЙ раз; теперь второй вызов уходил по раннему return, а
pop_title_show начинается с pop_sfx_pause() — значит после Restart Game
насос оставался выключенным и заставка шла молча.

Тем же корнем объясняется зависание: с закрытым CBL курсор трека не
двигается, pop_mus_left не убывает, и ожидания «дослушать тему» в титрах
и интро (while (pop_music_busy())) висели до нажатия клавиши — на экране
Prince of Persia.

- pop_boot_sound: под разовым флагом осталась только ЗАГРУЗКА (индекс
  звука, первая страница набора, индекс музыки, settings_apply);
  pop_sfx_start() зовётся всегда — он идемпотентен и уважает Ctrl+S;
- pop_music_busy(): признак теперь «звучит», а не «есть курсор» —
  спрашивает и про открытый вывод (pop_snd_ok).  Ждать неиграющую музыку
  нельзя в принципе, и эта строка закрывает весь класс подвисаний.

Проверено в MAME: уровень 1 -> ESC -> RESTART GAME -> титул с музыкой
(pop_snd_ok=1, курсор трека прошёл страницы 8..16, pop_mus_id=54), дальше
последовательность сама уходит в сцену с принцессой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:02 +03:00
54 changed files with 882 additions and 117 deletions
+9 -1
View File
@@ -92,11 +92,19 @@ RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
# ПРОВЕРКА ISR-СТАБА W0-СТРАНИЦ — там же, по свежей карте. Ловит стаб
# _gfx_w0_isr, оставшийся в W1: программа, кладущая свои страницы в W0
# (атласы спрайтов, gfx_w0_map), получает недетерминированные зависания,
# когда прерывание приходит во время вызова DSS и W1 перемаплен. Только
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
+7 -1
View File
@@ -133,9 +133,15 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
**Игра лежит на образе в `D:\GAMES\SPRPOP\`**, а НЕ в корне диска (так с
обобщения HDD-сборки, коммит ea8efdb — один образ рассчитан на несколько
приложений). `dir D:\` показывает только каталог `GAMES`; запуск из корня
отвечает `Bad command or file name`.
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
старт `run_bridge.sh` → 6 с`go` → 8 с`keyseq d:{ENTER}` +
`keyseq sprpop{ENTER}` → 5 с программа работает.
`keyseq cd games\sprpop{ENTER}` + `keyseq sprpop{ENTER}` → 5 с
программа работает.
Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
+1 -1
View File
@@ -47,7 +47,7 @@ MEMORY ?= huge
#
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000
ALLOCS ?= 10000
# Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell git log -1 --format=%cs 2>/dev/null || echo unknown)
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54 -14
View File
@@ -38,7 +38,7 @@
| [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) |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные; толкование звука ИСПРАВЛЕНО 2026-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
@@ -1374,23 +1374,63 @@ uint16_t pop_music_left(void) { uint16_t a, b;
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
А звучащий вместо него упор в стену говорит, что у нас сработало
СТОЛКНОВЕНИЕ, а не атака.
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
«атака с промахом», а мы — по ветке «столкновение с персонажем»
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
сквозь пол стража: столкновение двигает его координату, а не атака.
Проверять надо ветку выбора действия стража, а не только дистанцию.
**ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
Здесь стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет. Это
описание НАШЕГО кода, а не оригинала: ровно так звук стоял у нас, внутри
ветки «не парировано» и до присвоения `Opp.action = 99_hurt`. В SDLPoP
(seg002:1039..1044) он стоит в ХВОСТЕ `check_hurting` и защищён условием
`Opp.action != actions_99_hurt`:
if (Char.direction == dir_56_none) return; // Fix looping "sword moving" sound.
if (Char.frame == frame_154_poking && Opp.frame != frame_161_parry &&
Opp.action != actions_99_hurt)
play_sound(sound_11_sword_moving);
То есть звук 11 в оригинале — это индикатор **ПРОМАХА**, а не укола: на
попадании его нет (играет боль), на парировании нет (`Opp.frame` уже 161).
Расхождение найдено и исправлено 2026-09-02 по симптому «непрерывный взмах
клинка у безоружного Кида на подъёме» (звук опознан в живой сессии по
`snd_curr == 11`; указатели насоса при этом сошлись с `snd.idx` байт в
байт, то есть тракт звука был исправен). Заодно приехал и пропущенный
ранний выход по `dir_56_none` — тот самый, что автор SDLPoP подписал
«Fix looping sword moving sound».
**Что из прежних выводов уцелело, а что нет.**
* УЦЕЛЕЛО: наблюдение «в оригинале слышен 11, у нас 8» — это данные, и они
остаются. Арифметика приоритетов их подкрепляет: `snd_prio[11] = 0x12`
(18) против `snd_prio[8] = 0x4B` (75), меньше значит важнее, поэтому
взмах не мог быть заглушён упором в стену. При ТОГДАШНЕМ коде звук 11
звучал на любом уколе, значит его отсутствие действительно означало, что
до кадра укола дело не дошло.
* ОТПАЛО: вывод «оригинал ведёт стража атакой с промахом, а мы —
столкновением» опирался на неверную посылку и больше не следует из звука
сам по себе.
* УСИЛИЛОСЬ: то, что в оригинале в этой сцене слышен ИМЕННО 11, теперь
доказывает, что укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ. А это ровно версия
про ПОРОГ ДИСТАНЦИИ (см. выше про пару пикселей и нижний порог 8), а не
про потерянную ветку. Прежняя «версия про столкновение» её вытеснила
зря — возвращаем дистанцию в главные подозреваемые.
**ВНИМАНИЕ тому, кто вернётся к этому багу: диагностика по звуку с
2026-09-02 ЗНАЧИТ ДРУГОЕ.** Старые заметки прогона будут вводить в
заблуждение — переснимать сцену заново. Новая таблица:
| слышно | что это значит |
|---|---|
| 11 (взмах) | укол состоялся и ПРОМАЗАЛ — поведение оригинала в этой сцене |
| 13 (боль Кида) | укол ПОПАЛ, `Opp.action = 99_hurt` |
| 8 (упор в стену) | укола не было вовсе, сработало столкновение |
| тишина | `check_hurting` вышел раньше: меч не вынут / ряды не совпали / кадр не 153-154 |
На саму механику урона правка 2026-09-02 НЕ влияет (тронут только звук),
поэтому расхождение «удар убивает» остаётся открытым как было.
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному
+23 -2
View File
@@ -1102,8 +1102,6 @@ static void check_hurting(void)
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
/* Соперник НЕ парирует. */
if (cf == FRAME_154_POKING) {
/* seg002:0DAE — свист клинка мимо цели. */
pop_sfx_play(11);
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
distance = pop_char_opp_dist();
if (distance >= (int16_t)min_range && distance < 29)
@@ -1116,6 +1114,29 @@ static void check_hurting(void)
pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
play_seq();
}
/* СВИСТ КЛИНКА МИМО ЦЕЛИ (seg002:1039..1044) — в оригинале он стоит в
* ХВОСТЕ функции, после ОБЕИХ веток, а не внутри ветки «не парировано».
* Мы его туда и переносим; раньше он звучал раньше времени и без двух
* условий оригинала, отчего на подъёме Кида шёл непрерывный взмах
* клинка у безоружного (наблюдение пользователя 2026-09-02, звук
* опознан по snd_curr == 11 в живой сессии).
*
* Три отличия от прежнего кода, все из оригинала:
*
* 1. Ранний выход по dir_56_none — это ИМЕННО анти-зацикливание, автор
* SDLPoP так его и подписал («Fix looping sword moving sound»).
* Направление dir_56_none означает «персонаж выключен» (clear_char),
* и махать клинком ему нечем.
* 2. Условие `Opp.action != ACTION_99_HURT`: попали — играет звук боли,
* а не свист мимо. Поэтому звук обязан идти ПОСЛЕ присвоения выше.
* 3. Кадры перечитываются у персонажей, а не берутся из cf/of: ветка
* парирования только что записала Opp.frame, а play_seq в ней —
* Char.frame, и снимки выше уже устарели. */
if (Char.direction == DIR_56_NONE) return;
if (Char.frame == FRAME_154_POKING && Opp.frame != FRAME_161_PARRY &&
Opp.action != ACTION_99_HURT)
pop_sfx_play(11);
}
/* check_sword_hurting (seg002:0D1A): прогнать проверку с ОБЕИХ сторон. */
+14 -3
View File
@@ -771,15 +771,26 @@ static void overlay_mid_tile(int row, int col)
pop_env_b(42, x, pop_tile_table[1].right_y + dmy);
{ /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id:
* у loose верх плиты берётся из loose_fram_left (в pop_tile_table base_id=0),
* у opener'а без пола слева 148. Раньше здесь стоял голый base_id, и
* у opener'а без пола слева 148, НО только в ПОДЗЕМЕЛЬЕ. Условие
* `tbl_level_type[current_level] == 0` в оригинале стоит третьим
* (seg008:628) и у нас было потеряно ИМЕННО ЗДЕСЬ: статическая
* отрисовка (pop_room.c, draw_tile_base) его имеет, а оверлей нет.
* Отсюда симптом «кнопка, на которую лезет Кид, залита кирпичом»:
* пока тайл рисует статика, кнопка правильная, но как только Kid
* встаёт под кромку, оверлей перерисовывает её поверх него уже
* спрайтом 148, а во дворце это другая картинка (кладку палас
* вообще рисует заливками, а не спрайтами). Уровень 4, комната 18,
* кнопка (0,4) найдено пользователем 2026-09-02.
*
* Раньше здесь стоял голый base_id, и
* верх loose-плиты в оверлей не попадал: передняя грань ложилась поверх
* Kid (foretable), а сама плита нет, и подтягивающийся Kid рисовался
* поверх её верхней плоскости (комната 12). */
uint8_t base_id = t->base_id;
if (code == 11 && row >= 0)
base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])];
else if (code == 0x0F && lcode == 0)
base_id = 148;
else if (code == 0x0F && lcode == 0 && !pop_palace)
base_id = 148; /* ТОЛЬКО ПОДЗЕМЕЛЬЕ (seg008:628) */
if (base_id)
pop_env_b(base_id, x, t->base_y + dmy);
}
+28 -13
View File
@@ -555,11 +555,6 @@ void pop_determine_col(void) __banked { determine_col(); }
* Обе давно работают от 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)
@@ -570,8 +565,15 @@ static int distance_to_edge(int xpos)
if (Char.direction == 0) d = TILE_RIGHTX - d; /* dir_0_right */
return d;
}
static int distance_to_edge_weight(void) { return distance_to_edge(dx_weight()); }
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));
}
/* ---- ТОЧНАЯ коллизия со стеной (seg004) --------------------------- *
* Передний край персонажа = char_x_right (взгляд вправо) / char_x_left
* (влево) из set_char_collision (seg006:0723): obj_x/2 + 58, где obj_x =
@@ -837,20 +839,25 @@ static uint8_t can_grab_front_above(void)
/* check_grab (seg006:0A28): в падении при зажатом Shift — зацепиться за
* уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота
* подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит
* grab_timer (блок climb-up на несколько кадров). */
static void check_grab(void)
* grab_timer (блок climb-up на несколько кадров).
*
* ВОЗВРАТ: 1 зацеп СОСТОЯЛСЯ на этом кадре. Нужен вызывающему (do_fall):
* дальше по кадру идёт glide_through_wall_guard, и ему нельзя трогать
* персонажа, которого мы только что повесили на кромку (см. там же). */
static uint8_t check_grab(void)
{
uint8_t old_x;
if (!pop_ctrl_shift_held()) return; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return;
if (!pop_ctrl_shift_held()) return 0; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return 0; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return 0;
old_x = Char.x;
Char.x = (uint8_t)char_dx_forward(-8);
determine_col();
if (!can_grab_front_above()) {
Char.x = old_x; /* не за что — назад */
determine_col();
} else {
return 0;
}
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
@@ -860,7 +867,7 @@ static void check_grab(void)
grab_timer = 12;
pop_sfx_play(9); /* seg006 check_grab */
is_screaming = 0; /* seg006:1219 */
}
return 1;
}
static void make_loose_fall(int pos, uint8_t modifier);
@@ -970,7 +977,15 @@ 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(); /* ещё летит — попытка зацепа */
/* ЗАЦЕП ОТМЕНЯЕТ GUARD. seq_15 — «grab ledge MIDAIR», то есть после
* удачного зацепа Char.action == 3, персонаж уже подтянут вплотную к
* кромке, а под кромкой в оригинальных комнатах стоит КЛАДКА. Guard,
* позванный следом, видел ровно её (t == TILE_WALL, d == 10), считал
* это «пролётом сквозь стену», отбрасывал Кида на 5 пикселей назад и
* гасил fall_x зацеп рисовался и тут же срывался. Ловилось на
* длинном прыжке уровня 3 (комната 7 -> кромка комнаты 2) и в
* attract-демо; регресс-набор tests/host/t_glide.c. */
if (!check_grab()) /* ещё летит — попытка зацепа */
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
} else if (Char.curr_row <= 2) {
if (get_tile_at_char() == TILE_WALL)
+10 -1
View File
@@ -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);
}
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
+21 -1
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
@@ -1205,7 +1206,7 @@ int pop_boot(void) __banked
* полсекунды, и лучше, чтобы она прошла под картинкой. */
int pop_boot_sound(void) __banked
{
if (boot_sound_done) return 0;
if (!boot_sound_done) {
boot_sound_done = 1;
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
@@ -1213,6 +1214,15 @@ int pop_boot_sound(void) __banked
* это необязательный ресурс, как и сами треки. */
(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;
}
@@ -1481,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();
}
+2
View File
@@ -49,6 +49,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys)
# t_hang — зацеп обязан ДЕРЖАТЬСЯ (регресс glide_through_wall_guard).
OBJS_hang := $(OBJS_phys)
# t_gate — открытая решётка не должна считаться стеной у осторожного шага
# (нужны pop_map + физика, то есть тот же состав).
OBJS_gate := $(OBJS_phys)
+95
View File
@@ -0,0 +1,95 @@
/*
* t_hang.c ЗАЦЕП ОБЯЗАН ДЕРЖАТЬСЯ, а не срываться на следующем кадре.
*
* Регресс 2026-09-02. glide_through_wall_guard() (порт fix_glide_through_wall,
* взят в билд 2026-08-31) звался в do_fall СРАЗУ ПОСЛЕ check_grab. Беда в
* том, что seq_15 называется grab_ledge_MIDAIR: после удачного зацепа
* Char.action == 3 (в воздухе), персонаж уже подтянут вплотную к кромке а
* под кромкой в комнатах оригинала стоит КЛАДКА. Guard видел ровно её
* (t == TILE_WALL, d == 10), считал это «пролётом сквозь стену», отбрасывал
* персонажа на 5 пикселей назад и гасил fall_x. Зацеп РИСОВАЛСЯ и тут же
* срывался уровень 3 (комната 7, длинный прыжок через четыре пролёта) и
* attract-демо (комната 2, прыжок с места).
*
* ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ. t_grab судит по «действие стало
* вис» а вис-то наступал, он просто не жил. Отсюда мера здесь другая:
* вис обязан ДЕРЖАТЬСЯ подряд несколько кадров. Разница между «зацепился»
* и «зацепился и держится» это и есть разница между багом и нормой.
*
* СЦЕНА (геометрия уровня 3, комната 7, приведённая внутрь одной комнаты,
* чтобы шов не примешивался): кромка на (0,0), под ней кладка (1,0), четыре
* пролёта (0,1..4), толчок с плиты (0,5), разбег справа.
*/
#include <stdint.h>
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#define E 0x00
#define F 0x01
#define G 0x04
#define L 0x0B
#define T 0x13
#define W 0x14
static uint8_t room[30] = {
F, E, E, E, E, L, F, T, F, T,
W, E, E, E, E, W, W, W, W, W,
W, E, E, E, E, W, W, W, W, W,
};
#define ACT_HANG_CLIMB 2
#define ACT_HANG_STRAIGHT 6
#define HOLD_FRAMES 3
/* Вис ДЕРЖИТСЯ: действие «вис» стоит HOLD_FRAMES кадров подряд. */
static uint8_t hang_holds(uint8_t ledge, uint8_t nrun)
{
uint8_t i, run = 0;
room[0] = ledge;
sc_room(room, 7);
sc_kid_at(9, 0, -1); /* лицом влево, у правого края */
sc_run(SC_L, nrun); /* разбег */
sc_run(SC_L | SC_U, 6); /* толчок в прыжок */
sc_trace_clear();
sc_run(SC_SHIFT, 30); /* полёт, зацеп и жизнь после него */
for (i = 0; i < sc_len; i++) {
uint8_t a = sc_trace[i].action;
if (a == ACT_HANG_CLIMB || a == ACT_HANG_STRAIGHT) {
if (++run >= HOLD_FRAMES) return 1;
} else run = 0;
}
return 0;
}
/* Сколько фаз разбега из 41 дают ЖИВОЙ вис. До регресса — все, кроме
* первой (слишком близкий толчок), то есть 34; при сорванном зацепе ноль. */
static uint8_t window(uint8_t ledge)
{
uint8_t k, n = 0;
for (k = 6; k <= 46; k++) if (hang_holds(ledge, k)) n++;
return n;
}
TC_TEST(long_jump_hang_survives_wall_under_ledge)
{
/* Кромка-пол: под ней кладка — та самая, на которую срабатывал guard. */
TC_TRUE(window(F) >= 30);
}
TC_TEST(long_jump_hang_survives_on_gate)
{
/* Кромка-решётка: тайл другой, кладка под ним та же. Проверяется
* отдельно, потому что зацеп за решётку идёт своей веткой can_grab. */
TC_TRUE(window(G) >= 30);
}
int main(void)
{
sc_init();
TC_RUN(long_jump_hang_survives_wall_under_ledge);
TC_RUN(long_jump_hang_survives_on_gate);
return 0;
}
+9
View File
@@ -125,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):
@@ -139,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
+163
View File
@@ -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()
+24 -5
View File
@@ -33,9 +33,16 @@
# --data-loc 0xN override SDCC --data-loc (default: derived from --memory)
# -Wl FLAG extra linker flag (repeatable)
# --bank N=FILE.c compile FILE.c as bank N; repeatable; pulls crt0_banked
# --bank-data put bank modules' writable data INTO the bank page
# --bank-data [N] put bank modules' writable data INTO the bank page
# (default: it goes to the shared _DATA in W1/W2, which
# stays mapped and is visible from everywhere)
# stays mapped and is visible from everywhere).
# Without an argument — for ALL banks (old behaviour).
# With a bank number — only for that bank; repeatable:
# --bank-data 5 --bank-data 6
# Per-bank matters because bank-local data is NOT visible
# from outside the bank: a module with an exported global
# must stay on the shared _DATA while its neighbour keeps
# a big private buffer off the resident budget.
# automatically and adds -Wl-b_BANKN=0x{N}C000
# --w3 FILE.c place FILE.c resident in window 3 (0xC000), called
# DIRECTLY (no trampoline). Repeatable. Defaults to
@@ -111,7 +118,8 @@ MKEXE_EXTRA=()
BANK_SPECS=() # entries like "1=engine.c"
W3_SPECS=() # entries like "mod.c" — резидентные модули окна W3 (--w3)
USER_DEFS=() # -DFOO / -DFOO=bar — пробрасываются в sdcc как есть
BANK_LOCAL_DATA=0 # 1 (--bank-data): писучие данные банков — В СТРАНИЦУ банка
BANK_LOCAL_DATA=0 # 1 (--bank-data без аргумента): данные ВСЕХ банков — в страницу
BANK_LOCAL_LIST=() # номера банков из --bank-data N (по одному на банк)
W3_RELS=() # заполняется при компиляции W3-модулей
W3_LD_FLAGS=() # -Wl-b_W3CODE=0xC000, если есть --w3
USER_SET_MEMORY="" # непусто, если --memory задан явно (для --w3 авто-small)
@@ -147,7 +155,14 @@ while [[ $# -gt 0 ]]; do
-Wl) LD_EXTRA+=("$2"); shift 2;;
-D*) USER_DEFS+=("$1"); shift;;
--bank) BANK_SPECS+=("$2"); shift 2;;
--bank-data) BANK_LOCAL_DATA=1; shift;;
--bank-data)
# Аргумент необязателен: «--bank-data» = все банки (как было),
# «--bank-data N» = только банк N, флаг повторяемый.
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
BANK_LOCAL_LIST+=("$2"); shift 2
else
BANK_LOCAL_DATA=1; shift
fi;;
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
--gfx) GFX_MODE="$2"; shift 2;;
@@ -464,7 +479,11 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
# W1/W2, но требует ручного контроля видимости, поэтому
# включается явно через --bank-data.
bank_data_flags=()
[[ $BANK_LOCAL_DATA -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
bank_local=$BANK_LOCAL_DATA
for bl in "${BANK_LOCAL_LIST[@]}"; do
[[ "$bl" == "$bank_n" ]] && bank_local=1
done
[[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
run "$SDCC" "${CC_FLAGS[@]}" \
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \
"${bank_data_flags[@]}" \
+46 -46
View File
@@ -1,67 +1,67 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3783
accfill 3797
accop 7067
argv 3431
assrtest 3847
atlas 9193
attrprob 4087
banked 1056
bankedbg 1067
banklocl 4683
banktest 3767
bgi_img 8677
bgitest 3748
bios_text 4461
argv 3445
assrtest 3861
atlas 9627
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 5862
cbltest 6008
cblwav 6101
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 5142
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
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
2 accfill
3 accop
4 argv
5 assrtest
6 atlas
7 attrprob
8 banked
9 bankedbg
10 banklocl
11 banktest
12 bgi_img
13 bgitest
14 bios_text
15 blitperf
16 blitw
17 cat
18 cblstream
19 cbltest
20 cblwav
21 conio
22 conio2
23 convbench
24 dec_test
25 errno
26 fbench
27 fdmax
28 filetest
29 fpsdiv
30 gets
31 gfx_dbuf
32 gfx_demo
33 gfxbanks
34 hello
35 hello2
36 irqtest
37 kbdpoll
38 kbdraw
39 ls
40 malloc
41 mem_test
42 mouse
43 openenv
44 pageflip
45 palfile
46 ptime
47 rt_test
48 scroll
49 seek
50 simple
51 solidt
52 spranim
53 spriteclip
54 sprites
55 stattest
56 stdlib
57 stest2
58 strtest
59 text_palette
60 timedir
61 w0page
62 w3bgfx
63 w3big
64 w3huge
65 w3probe
66 w3tiny
67 winrest
+6 -1
View File
@@ -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();
+38
View File
@@ -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
View File
@@ -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 кГц */
+7
View File
@@ -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
+18
View File
@@ -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);
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+17
View File
@@ -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
+17
View File
@@ -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
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
check_w0_isr.py предупредить, что ISR-стаб W0-страниц оказался в W1.
Зачем. Программа может временно маппить свою EMM-страницу в W0 (атласы
спрайтов в sprite_update, ресурсы через gfx_w0_map). Пока страница там,
ЛЮБОЕ прерывание уходит на #0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare()
прошивает туда `JP _gfx_w0_isr`, а стаб возвращает страницу ядра DSS, зовёт
честный обработчик и восстанавливает страницу обратно.
Мина. Сам стаб живёт в _CODE. В режимах small и huge _CODE начинается с
0x4100, то есть попадает в W1 окно, которое DSS перемаппит на время СВОИХ
вызовов (docs/new/04-memory.md §4.2, §4.9). Если прерывание придёт, пока
наша страница в W0 И идёт вызов DSS, переход уйдёт по адресу, которого в этот
момент нет: W1 читается как #FF, процессор уползает в бесконечный пролог
системного прерывания. Само libbgi это документирует в common/_gfx_w0_isr.c
(«обязан быть в W2»).
Собирается это МОЛЧА и проявляется НЕДЕТЕРМИНИРОВАННО зависанием примерно
на каждом третьем холодном старте, что легко списать на случайность. Поймано
вживую 2026-09-01 в порте Loom (huge): стаб был на 0x6769.
Как проверяем. По .map берём адрес __gfx_w0_isr. Если символа нет W0-путь
в программу не слинкован, молча выходим. Если адрес >= 0x8000 он в W2, всё
хорошо. Иначе печатаем WARNING: сборку не валим, потому что программа могла
и не класть свои страницы в W0.
Лечение (со стороны приложения): вернуть модули из банков в резидент или
разгрузить _DATA так, чтобы _CODE перевалил за 0x8000 и хвост libbgi уехал в
W2. Порядок линковки сам по себе не помогает: пока весь _CODE влезает в W1
(ёмкость 0x8000-0x4100 = 16128 Б), расстановка модулей ничего не меняет.
"""
import re
import sys
from pathlib import Path
W2_BASE = 0x8000
SYM = '__gfx_w0_isr'
MAP_RE = re.compile(r'^\s+([0-9A-Fa-f]{4,8})\s+(\S+)\s')
def find_symbol(map_path, name):
for line in map_path.read_text(errors='replace').splitlines():
m = MAP_RE.match(line)
if m and m.group(2) == name:
return int(m.group(1), 16)
return None
def main(argv):
if len(argv) != 2:
print(__doc__.strip())
return 2
build = Path(argv[1])
maps = list(build.glob('*.map'))
if not maps:
print(f'check_w0_isr: нет .map в {build}', file=sys.stderr)
return 2
addr = find_symbol(maps[0], SYM)
if addr is None:
return 0 # W0-путь не слинкован — проверять нечего
if addr >= W2_BASE:
print(f'check_w0_isr: {SYM} @ 0x{addr:04X} — в W2, ок')
return 0
print(f'check_w0_isr: ВНИМАНИЕ — {SYM} @ 0x{addr:04X} лежит в W1.',
file=sys.stderr)
print(' Если программа маппит свои страницы в W0 (атласы спрайтов, '
'gfx_w0_map,', file=sys.stderr)
print(' atlas_attach), прерывание во время вызова DSS уйдёт в '
'перемапленное W1 —', file=sys.stderr)
print(' недетерминированные зависания на старте. Стаб обязан быть в W2 '
f'(>= 0x{W2_BASE:04X}).', file=sys.stderr)
print(' Лечение: увести _CODE за 0x8000 (модули из банков в резидент / '
'разгрузить', file=sys.stderr)
print(' _DATA). Подробности — в шапке этого скрипта и '
'docs/new/04-memory.md §4.2.', file=sys.stderr)
return 0 # предупреждение, сборку не валим
if __name__ == '__main__':
sys.exit(main(sys.argv))