Compare commits

...

20 Commits

Author SHA1 Message Date
snark13 8e389c03f8 Volkov: добавить Sprinter Commander
Реализовать двухпанельный Commander от платформенного PoC до этапов P6-P20: EMM-каталог, сортировку и выбор, операции с файлами и деревьями, транзакционное копирование, метаданные, политику конфликтов и предварительную проверку свободного места.

Добавить проектную документацию, HDD/MAME-сценарии и проверенные артефакты. Расширить libc операцией bank_write_page, исправлением режима O_RDONLY и связанными регрессионными проверками.
2026-09-10 10:45:30 +03:00
snark13 05bcd8197e check_bank_calls: ловить адрес ПОЛЯ структуры в аргументах банкового вызова
Проверка 2 искала форму «адрес метки своего банка положен в аргументы вызова в
чужой банк», но регулярка принимала только `ld hl,#_метка` и `#_метка+6`. Адрес
поля структуры SDCC пишет в скобках:

	ld	bc, #(_mstream + 6)
	push	bc
	...
	call	___sdcc_bcall_ehl

Из-за этого целый класс ошибок собирался «чисто». Поймано вживую 2026-09-03 в
порту Loom: mask.c (банк 5, --bank-data 5) отдавал res_copy (банк 11) адрес поля
своей структуры — поток маски читался в чужую страницу, маска выходила «всё
заполнено», тайлов в комнате стало 30 вместо 17. Фон при этом оставался
побайтово верным, потому что его путь идёт из резидентного кода, так что
попиксельная сверка экрана молчала.

Класс проверки не меняется: адрес метки ДАННЫХ своего банка, положенный в стек
как аргумент вызова в ЧУЖОЙ банк, неверен всегда — это по-прежнему
доказательный случай, а не эвристика.

Проверено в трёх режимах: с ошибкой срабатывает, на исправленном коде молчит, и
на всех банковых сборках самого тулчейна (banktest, w3probe, banked, bankedbg,
banklocl, w3bankgfx, roomtest, сборка SprPoP) ложных срабатываний нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qxu9rwsmyDyYPxG198nzSR
2026-09-03 11:04:27 +03:00
snark13 25abf8ea36 SprPoP: в CHANGELOG к версии и дате добавлен коммит тега
Заголовок записи стал `<имя тега> — <дата> — <коммит>`.

Зачем коммит, если есть имя тега: тег можно передвинуть или
переименовать, а ревизия, на которой релиз собран, должна оставаться в
записи однозначно — по ней билд воспроизводится точно.  Заодно это тот же
хеш, что зашит в EXE как BUILD_ID и виден на экране About, так что
принесённый пользователем скриншот сразу сопоставляется с записью
changelog.

Берётся именно `*objectname` (дереференс аннотированного тега), а не
`objectname`: последний вернул бы хеш самого объекта тега, а не коммита.
Команда целиком выписана и в шапке CHANGELOG, и в правиле в CLAUDE.md,
чтобы не восстанавливать её каждый раз.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 18:33:05 +03:00
snark13 f206a4cca6 SprPoP: завести CHANGELOG, версия и дата — из git-тега
До сих пор «что изменилось» восстанавливалось только из git log, а он
устроен по ПРИЧИНАМ правок, а не по тому, что заметит игрок.  Заводим
отдельный файл — как это уже сделано у mdview2 (changelog в репозитории
ведётся на приложение, а корневой RELEASE_NOTES.md описывает тулчейн и
живёт своей жизнью).

Начат с v0.9.6: зацеп, зацикленный взмах клинка, кнопка в паласе.  Более
ранняя история остаётся в git log и досках — переписывать её задним
числом смысла нет.

ВЕРСИЯ И ДАТА БЕРУТСЯ ИЗ GIT, а не проставляются руками: заголовок записи
= имя аннотированного тега, дата = дата его создания
(`git for-each-ref refs/tags/<тег>`).  Так запись не может разойтись с
историей.  Правило записано в CLAUDE.md рядом с дисциплиной досок, иначе
файл заведётся один раз и умрёт.

В запись идёт только то, что видно ИГРОКУ или меняет сборку/запуск, по
схеме «симптом -> причина одной фразой -> хеш»; полный разбор остаётся в
сообщении коммита и не дублируется.

Файл в UTF-8, как остальные доки SprPoP.  CP866 у mdview2 — вынужденная
мера (его changelog читает сам MDView), здесь такого требования нет.

Ссылка добавлена во входные точки docs/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 18:32:11 +03:00
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
snark13 7377bf6a2c SprPoP: заставка и музыка сразу после запуска, а не через несколько секунд
От запуска до проявления титула проходило ~3 с чёрного экрана в тишине:
pop_boot читал ВСЕ ресурсы игры до первого пикселя, а pop_title_show перед
fade_in вычитывал целиком трек заставки (248 КБ).

Старт разбит на три фазы (sprpop_cold.c):
- pop_boot() — настройки, POP.CFG, графика, чёрный экран, FONT.ATL;
- pop_boot_sound() — snd.idx + ОДНА страница snd.arc, индекс музыки, CBL;
- pop_boot_rest() — остальные страницы звука, kid.ani, Тень, атласы Кида;
  идемпотентно, между кусками доливает трек.

pop_title_show теперь: fade_in -> pop_boot_sound -> pop_music_stream(54) ->
pop_boot_rest под стоящим титулом.  Порядок «сначала fade_in, потом тема»
сохранён как у show_title (seg000.c:1981).  Первый такт заставки укорочен
на фактически потраченное время (часы насоса 85,4 Гц -> кадры луча
сдвигами), иначе сцена уехала бы относительно музыки.  title_wait доливает
трек по полстраницы за кадр.

Музыка играет с ПЕРВОЙ страницы: pop_music_stream уже был (игровые
джинглы), кольцо won не понадобилось — m54 это 16 страниц из 20 доступных.

Звук поднимается в два приёма (pop_sfx_init_begin/finish): насосу для
тишины нужен ровно один блок, и упаковщик обязан класть его первым блоком
страницы 0 (tools/pop_idx.py) — значит для открытия CBL хватает ОДНОЙ
страницы (33 мс вместо 440).  Пока набор неполон, pop_sfx_play пропускает
эффекты; заявки на музыку проходят.

Четыре загрузки из boot УБРАНЫ, а не отложены: атлас фона, страж, уровень 1
и pop_pal_game_load — их и так делает pop_level_switch на входе в любой
уровень (а pop_new_game_load ещё и палитру перед ним), и оба маршрута в
игру, LEVEL_LOAD и DEMO, упираются в него.  Это ~0,77 с на ровном месте.
Так же устроен оригинал: init_game_main до заставки читает только меч,
пламя, звуки и палитры, а chtab_5/6/7 грузит load_lev_spr на старте уровня.
Страховка на случай пропуска заставки в первую секунду — идемпотентный
pop_boot_rest() в начале pop_new_game_load и pop_level_switch.

FONT.ATL остаётся в минимальной фазе, и это не про шрифт: снимок палитры
физически лежит в хвосте его страницы (pop_ui.c), а вся машинерия яркости
начинается с `if (!font_ready) return`.  С отложенным шрифтом заставка
возникала разом на полной яркости — поймано пользователем.

Проверено в MAME: титул виден сразу и проявляется полосами, pop_mus_left
убывает (трек стримится), pop_snd_pages=10 (набор дочитался), цепочка
титул -> интро -> пропуск -> уровень 1 работает.  Банк 8: 95,5 % (732 Б).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:30:53 +03:00
snark13 b34997073e libc/cbl: выключение CBL больше не оставляет железо петь одну ноту
cbl_close() не пишет в порт управления ноль: пока gfx_wait_vsync держит
ссылку ради бита луча (в PoP её берёт pop_pace_arm на всю программу),
bit7 остаётся включённым.  А буфер CBL (256 слотов) при включённом bit7
крутится по кругу и не чистится ничем — железо бесконечно поёт хвост
последнего сэмпла, тоном и громкостью по последней мелодии.  Пойман
пользователем на железе: Ctrl+S во время музыки и пропуск заставки
давали ноту до следующего cbl_open.

В MAME не воспроизводится: "немым" кодом частоты был reserved-код 2, у
которого divs[2]==0 и таймер не заводится вовсе.  На железе reserved-коды
не определены, ЦАП тактируется — тишина держалась на свойстве эмулятора,
а не железа.

Теперь тишину даёт СОДЕРЖИМОЕ БУФЕРА:
- _cbl_port_sync() после каждой записи в порт управления зовёт
  _cbl_prime(0x80).  Это закрывает и паузу звука, и включение bit7 ради
  луча на холодном старте (в буфере лежал мусор от прошлой программы), и
  полное выключение — при bit7=0 те же 256 записей уходят прямо в ЦАП
  обычного COVOX и центруют его, снимая щелчок;
- _CBL_VSYNC_FREQ переведён с reserved-кода 2 на документированный 8
  (7,8125 кГц): поведение определено и на железе, и в MAME, прерывания
  по-прежнему выключены (bit4=0), а бит 7 порта 0xFE трамплин смотрит
  только при живом хуке насоса;
- cbl_close() зовёт sync внутри той же DI-скобки, где снимает хук, иначе
  насос долил бы буфер уже после заливки.

Цена: +2 Б программам со звуком, +11 Б графическим (тянется _cbl_prime
следом за gfx_wait_vsync), 256 OUT'ов (~0,3 мс) на редкое событие —
эталоны cblstream/cbltest/cblwav/gfx_dbuf обновлены.  Рост atlas в
size-check к этой правке отношения не имеет (замерен тот же и без неё).

Проверено: кодоген _cbl_port.asm; SprPoP пересобран и прогнан в MAME
(титры → пропуск заставки → уровень 1, пейсинг по лучу жив).  Сам баг
проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 10:55:15 +03:00
936 changed files with 22611 additions and 239 deletions
+9 -1
View File
@@ -92,11 +92,19 @@ RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# bankN_*.asm; обычным небанковым программам ничего не стоит. # bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py 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) $(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@)) $(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) $(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \ @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): $(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe $(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
+56
View File
@@ -0,0 +1,56 @@
# SprPoP — CHANGELOG
Заметные изменения по релизам. **Версия, дата и коммит каждой записи
берутся из git** — из аннотированного тега, а не проставляются руками,
поэтому расходиться с историей они не могут:
git for-each-ref \
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
refs/tags/<тег>
Заголовок записи = `<имя тега> — <дата тега> — <коммит тега>`. Коммит —
дереференс аннотированного тега (`*objectname`), то есть та ревизия, на
которой релиз собран: по ней воспроизводится ровно этот билд.
Здесь только то, что видно ИГРОКУ или меняет способ сборки/запуска.
Разбор причин живёт в сообщениях коммитов, открытые баги — в
`docs/BUGS_OPEN.md`, закрытые — в архиве roomtest.
Файл начат с релиза v0.9.6; более ранняя история — в `git log` и в
досках задач.
## v0.9.6-hang-sound-button — 2026-09-02 — `aaa480d`
Три бага, найденные в живой игре и проверенные в MAME после исправления.
- **Зацеп срывался на следующем кадре** (регресс, тянулся с 4e12aa5).
Кид цеплялся за кромку, поза зацепа рисовалась — и он тут же падал.
Ловилось на длинном прыжке уровня 3 (комната 7) и в attract-демо, где
Кид вместо подъёма на кромку (0,2) улетал на пики. Причина: страж
«падения сквозь стену» принимал кладку под кромкой за стену, в которую
персонаж влетел, и отменял только что состоявшийся захват. Теперь
удачный зацеп отменяет этого стража. `38fb3c0`
- **Непрерывный взмах клинка у безоружного Кида.** На подъёме шёл
нескончаемый свист меча, которого у Кида нет. Причина: в порт не
доехали два условия оригинала, включая гард, который автор SDLPoP
подписал прямым текстом как «фикс зацикленного звука взмаха». `cb995bf`
- **Кнопка под Кидом заливалась кладкой** на дворцовых уровнях. Кид
подтягивается на открывающую кнопку, и кромка, которая его прячет,
нарисована кирпичом (уровень 4, комната 18). Причина: подстановка
спрайта «кнопка без пола слева» действует только в подземелье, и это
условие было потеряно в перерисовке поверх персонажа — при том что в
статической отрисовке комнаты оно есть. `8548aa9`
Сопутствующее, для разработки:
- В доске исправлено толкование звука 11: это индикатор ПРОМАХА, а не
«любого укола». Прежняя запись описывала наш собственный код вместо
оригинала, и из-за неё в CLIMB-VS-GUARD зря отодвинули версию про порог
дистанции. `400f5cb`
- Новый набор host-тестов `t_hang`: мерит не «вис наступил», а «вис
ДЕРЖИТСЯ» — прежние наборы регресс зацепа не видели именно поэтому.
Входит в `38fb3c0`.
- В `CLAUDE.md` записан путь запуска на HDD-образе: игра лежит в
`D:\GAMES\SPRPOP`, а не в корне диска. `aaa480d`
+25 -1
View File
@@ -60,6 +60,24 @@ seqtbl, `seg007` mob/loose/падающие объекты, `seg008` отрис
нового бага грепни архив по симптому: корень мог уже разбираться. нового бага грепни архив по симптому: корень мог уже разбираться.
Открытые файлы обязаны читаться целиком за раз. Открытые файлы обязаны читаться целиком за раз.
**CHANGELOG. Ставишь релизный тег — добавь запись в `CHANGELOG.md`.**
Версия, дата И КОММИТ в заголовке берутся ИЗ GIT, руками не
проставляются:
git for-each-ref \
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
refs/tags/<тег>
Заголовок = `<имя тега> — <дата> — <коммит>`. Коммит обязателен: тег
можно передвинуть или переименовать, а ревизия, на которой релиз собран,
должна оставаться в записи однозначно. Берётся именно `*objectname`
(дереференс аннотированного тега), а не `objectname` — последний вернёт
хеш самого объекта тега, а не коммита.
В запись идёт только то, что видно ИГРОКУ или меняет сборку/запуск, — по
одному пункту «симптом -> причина одной фразой -> хеш коммита». Полный
разбор живёт в сообщении коммита, дублировать его сюда не надо.
Индекс документации с отметками актуальности — **`docs/README.md`**, Индекс документации с отметками актуальности — **`docs/README.md`**,
начинать чтение оттуда. начинать чтение оттуда.
@@ -133,9 +151,15 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`). (memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`). Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
**Игра лежит на образе в `D:\GAMES\SPRPOP\`**, а НЕ в корне диска (так с
обобщения HDD-сборки, коммит ea8efdb — один образ рассчитан на несколько
приложений). `dir D:\` показывает только каталог `GAMES`; запуск из корня
отвечает `Bad command or file name`.
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10): **Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
старт `run_bridge.sh` → 6 с`go` → 8 с`keyseq d:{ENTER}` + старт `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** — заморозить Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
+1 -1
View File
@@ -47,7 +47,7 @@ MEMORY ?= huge
# #
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе # ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации. # сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000 ALLOCS ?= 10000
# Build info не хранится в C: ID и дата извлекаются из текущего commit. # Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) 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) 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**: ветка удалена, сцена на единых часах по лучу | | [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось | | [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился | | [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) | | [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные; толкование звука ИСПРАВЛЕНО 2026-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) | | [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
--- ---
@@ -1374,23 +1374,63 @@ uint16_t pop_music_left(void) { uint16_t a, b;
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`). слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
А звучащий вместо него упор в стену говорит, что у нас сработало
СТОЛКНОВЕНИЕ, а не атака.
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11 Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута. (1664 Б). Раскладка не сдвинута.
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке **ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
«атака с промахом», а мы — по ветке «столкновение с персонажем»
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося Здесь стояло, будто оригинал играет звук 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) — поведение в **Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному этой связке расходится широко, чинить нужно целиком, а не по одному
+1
View File
@@ -10,6 +10,7 @@
|----------|-------| |----------|-------|
| [`TASKS_OPEN.md`](TASKS_OPEN.md) | **Что берётся в работу сейчас** — начинать отсюда | | [`TASKS_OPEN.md`](TASKS_OPEN.md) | **Что берётся в работу сейчас** — начинать отсюда |
| [`BUGS_OPEN.md`](BUGS_OPEN.md) | Открытые баги и незакрытые оптимизации | | [`BUGS_OPEN.md`](BUGS_OPEN.md) | Открытые баги и незакрытые оптимизации |
| [`../CHANGELOG.md`](../CHANGELOG.md) | **Что менялось по релизам** глазами игрока; версия и дата берутся из git-тега |
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде | | [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде |
| [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP | | [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP |
| [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе | | [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе |
+23 -2
View File
@@ -1102,8 +1102,6 @@ static void check_hurting(void)
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) { (of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
/* Соперник НЕ парирует. */ /* Соперник НЕ парирует. */
if (cf == FRAME_154_POKING) { if (cf == FRAME_154_POKING) {
/* seg002:0DAE — свист клинка мимо цели. */
pop_sfx_play(11);
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12); min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
distance = pop_char_opp_dist(); distance = pop_char_opp_dist();
if (distance >= (int16_t)min_range && distance < 29) 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); pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
play_seq(); 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): прогнать проверку с ОБЕИХ сторон. */ /* 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); pop_env_b(42, x, pop_tile_table[1].right_y + dmy);
{ /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id: { /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id:
* у loose верх плиты берётся из loose_fram_left (в pop_tile_table base_id=0), * у 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-плиты в оверлей не попадал: передняя грань ложилась поверх * верх loose-плиты в оверлей не попадал: передняя грань ложилась поверх
* Kid (foretable), а сама плита нет, и подтягивающийся Kid рисовался * Kid (foretable), а сама плита нет, и подтягивающийся Kid рисовался
* поверх её верхней плоскости (комната 12). */ * поверх её верхней плоскости (комната 12). */
uint8_t base_id = t->base_id; uint8_t base_id = t->base_id;
if (code == 11 && row >= 0) if (code == 11 && row >= 0)
base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])]; base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])];
else if (code == 0x0F && lcode == 0) else if (code == 0x0F && lcode == 0 && !pop_palace)
base_id = 148; base_id = 148; /* ТОЛЬКО ПОДЗЕМЕЛЬЕ (seg008:628) */
if (base_id) if (base_id)
pop_env_b(base_id, x, t->base_y + dmy); 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; не хватало только объявлений * Обе давно работают от Char, а не от Kid; не хватало только объявлений
* (docs/sdlpop_audit.md, находка 12). */ * (docs/sdlpop_audit.md, находка 12). */
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); } 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). */ /* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos) 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 */ if (Char.direction == 0) d = TILE_RIGHTX - d; /* dir_0_right */
return d; return d;
} }
static int distance_to_edge_weight(void) { return distance_to_edge(dx_weight()); } 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) --------------------------- * /* ---- ТОЧНАЯ коллизия со стеной (seg004) --------------------------- *
* Передний край персонажа = char_x_right (взгляд вправо) / char_x_left * Передний край персонажа = char_x_right (взгляд вправо) / char_x_left
* (влево) из set_char_collision (seg006:0723): obj_x/2 + 58, где obj_x = * (влево) из 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 — зацепиться за /* check_grab (seg006:0A28): в падении при зажатом Shift — зацепиться за
* уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота * уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота
* подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит * подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит
* grab_timer (блок climb-up на несколько кадров). */ * grab_timer (блок climb-up на несколько кадров).
static void check_grab(void) *
* ВОЗВРАТ: 1 зацеп СОСТОЯЛСЯ на этом кадре. Нужен вызывающему (do_fall):
* дальше по кадру идёт glide_through_wall_guard, и ему нельзя трогать
* персонажа, которого мы только что повесили на кромку (см. там же). */
static uint8_t check_grab(void)
{ {
uint8_t old_x; uint8_t old_x;
if (!pop_ctrl_shift_held()) return; /* Shift не зажат */ if (!pop_ctrl_shift_held()) return 0; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return; /* падает слишком быстро */ 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; if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return 0;
old_x = Char.x; old_x = Char.x;
Char.x = (uint8_t)char_dx_forward(-8); Char.x = (uint8_t)char_dx_forward(-8);
determine_col(); determine_col();
if (!can_grab_front_above()) { if (!can_grab_front_above()) {
Char.x = old_x; /* не за что — назад */ Char.x = old_x; /* не за что — назад */
determine_col(); determine_col();
} else { return 0;
}
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight()); 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.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0; Char.fall_y = 0;
@@ -860,7 +867,7 @@ static void check_grab(void)
grab_timer = 12; grab_timer = 12;
pop_sfx_play(9); /* seg006 check_grab */ pop_sfx_play(9); /* seg006 check_grab */
is_screaming = 0; /* seg006:1219 */ is_screaming = 0; /* seg006:1219 */
} return 1;
} }
static void make_loose_fall(int pos, uint8_t modifier); 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 (nrow > 4) nrow = 4; /* защита pop_y_land[] от выхода */
if ((uint16_t)pop_y_land[nrow] > (uint16_t)Char.y) { 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(); /* и не сквозь кладку (см. выше) */ glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
} else if (Char.curr_row <= 2) { } else if (Char.curr_row <= 2) {
if (get_tile_at_char() == TILE_WALL) if (get_tile_at_char() == TILE_WALL)
+10 -1
View File
@@ -17,6 +17,7 @@
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */ #include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h" #include "pop_music.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "_pop_sfx.h" /* pop_snd_ok — открыт ли вывод (см. pop_music_busy) */
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */ #include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h" #include "_pop_file.h"
@@ -454,7 +455,15 @@ void pop_music_service(void) __banked
uint8_t pop_music_busy(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);
} }
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит. /* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
+7
View File
@@ -263,6 +263,13 @@ void pop_sfx_play(uint8_t id)
if (pop_mus_want && id >= 24 && id <= 43) pop_mus_req = id; if (pop_mus_want && id >= 24 && id <= 43) pop_mus_req = id;
return; return;
} }
/* НАБОР ЕЩЁ ДОЧИТЫВАЕТСЯ. На старте программы звук открывается после
* ОДНОЙ страницы (в ней блок тишины), чтобы музыка заставки заиграла
* почти сразу; остальные приезжают позже (pop_sfx_init_begin/finish).
* Страницы под них уже выделены, но не прочитаны эффект из такой
* страницы был бы шумом из чужих данных. Заявку на МУЗЫКУ выше это
* не трогает: её данные лежат отдельно. */
if (!pop_snd_pages) return;
if (!snd_next1 || snd_prio[id] <= snd_prio[snd_next1 - 1]) if (!snd_next1 || snd_prio[id] <= snd_prio[snd_next1 - 1])
snd_next1 = (uint8_t)(id + 1); snd_next1 = (uint8_t)(id + 1);
} }
+22 -4
View File
@@ -21,10 +21,28 @@
#include <stdint.h> #include <stdint.h>
/* Поднять набор: занять 8 EMM-страниц и вычитать в них данные. ВЫВОД НЕ /* ПОДЪЁМ НАБОРА РАЗБИТ НАДВОЕ — ради быстрого старта программы.
* ВКЛЮЧАЕТ см. pop_sfx_start. 0 OK, -1 не получилось (тогда всё *
* дальнейшее молча ничего не делает, игра идёт без звука). */ * Насосу, чтобы открыть CBL и заиграть МУЗЫКУ, нужен из всего набора
int pop_sfx_init(void) __banked; * ровно один блок тишина, и упаковщик обязан класть её первым блоком
* СТРАНИЦЫ 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). Звать ПОСЛЕ всей загрузки: пока идёт /* Включить вывод (открыть CBL). Звать ПОСЛЕ всей загрузки: пока идёт
* чтение файлов, ESTEX уходит в диск надолго, насос не успевает долить * чтение файлов, ESTEX уходит в диск надолго, насос не успевает долить
+51 -10
View File
@@ -18,6 +18,11 @@
#include <sprite.h> /* gfx_w0_map — залить блок тишины */ #include <sprite.h> /* gfx_w0_map — залить блок тишины */
static uint8_t snd_blk; /* блок EMM под весь набор */ 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) ----------------------- * /* --- ИНДЕКС НАБОРА (SND/snd.idx, формат PSI1) ----------------------- *
* *
@@ -142,11 +147,13 @@ static int8_t snd_silence_only(void)
return 0; 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) != if (pop_arc_open_id(&arc, POP_ARC_SND, tbl, POP_SND_MAX_PAGES) !=
(int8_t)pages) (int8_t)pages)
return -1; return -1;
/* Блок выделяем СРАЗУ на весь набор: номера страниц отдаёт
* mem_get_page по индексу, и finish дочитает в те же самые. */
blk = mem_alloc_pages(pages); blk = mem_alloc_pages(pages);
if (!blk) { pop_arc_close(&arc); return -1; } if (!blk) { pop_arc_close(&arc); return -1; }
for (i = 0; i < pages; i++) { pop_snd_page[POP_SND_SILENCE_PAGE] = mem_get_page(blk, 0);
pop_snd_page[i] = mem_get_page(blk, i); if (!pop_snd_page[POP_SND_SILENCE_PAGE] ||
if (!pop_snd_page[i] || pop_arc_read(&arc, tbl, 0, 0,
pop_arc_read(&arc, tbl, i, 0, pop_snd_page[i], 0, 0) != 16384) { pop_snd_page[POP_SND_SILENCE_PAGE], 0, 0) != 16384) {
pop_arc_close(&arc); pop_arc_close(&arc);
mem_free_block(blk); mem_free_block(blk);
return -1; return -1;
} }
}
pop_arc_close(&arc); pop_arc_close(&arc);
} }
snd_blk = blk; 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 и не пытаться ходить по полузаполненной таблице. */ * видеть 0 и не пытаться ходить по полузаполненной таблице. */
pop_snd_pages = pages; pop_snd_pages = snd_want_pages;
pop_snd_want = 1; /* набор есть — звук по умолчанию включён */
return 0; return 0;
} }
+44 -10
View File
@@ -21,6 +21,7 @@
#include "pop_pal.h" #include "pop_pal.h"
#include "pop_title.h" #include "pop_title.h"
#include "pop_ui.h" #include "pop_ui.h"
#include "sprpop_cold.h" /* pop_boot_sound/pop_boot_rest — фазы старта */
#define TITLE_W 320 #define TITLE_W 320
#define TITLE_H 200 #define TITLE_H 200
@@ -110,10 +111,14 @@ int pop_title_logo_ltr(void) __banked
return pop_screen_present_ltr() ? 1 : 0; return pop_screen_present_ltr() ? 1 : 0;
} }
/* Ждать ticks кадров луча, доливая трек. Тема заставки играет с ПЕРВОЙ
* страницы (pop_music_stream), остальные обязаны приезжать по ходу шаг
* стоит 16 мс и укладывается в кадровый интервал. */
static uint8_t title_wait(uint16_t ticks) static uint8_t title_wait(uint16_t ticks)
{ {
while (ticks--) { while (ticks--) {
if (title_skip_pressed()) return 1; if (title_skip_pressed()) return 1;
if (pop_music_loading()) (void)pop_music_load_step();
gfx_wait_vsync(); gfx_wait_vsync();
} }
return 0; return 0;
@@ -130,7 +135,8 @@ static void title_load_palette_dimmed(void)
uint8_t pop_title_show(void) __banked uint8_t pop_title_show(void) __banked
{ {
uint8_t step; uint8_t step;
uint8_t skipped = 0, music_ok = 0; uint8_t skipped = 0;
uint16_t first_wait = 0; /* такт 0 за вычетом времени загрузки */
int prepared; int prepared;
uint8_t cold_start = (uint8_t)(!title_shown && uint8_t cold_start = (uint8_t)(!title_shown &&
!pop_settings.show_build_info); !pop_settings.show_build_info);
@@ -154,22 +160,50 @@ uint8_t pop_title_show(void) __banked
pop_screen_present((uint8_t)prepared); pop_screen_present((uint8_t)prepared);
title_shown = 1; title_shown = 1;
/* Тему читаем с диска ЗАРАНЕЕ (пока экран ещё тёмный), а пускаем ниже — /* ЗДЕСЬ ЖЕ ДОГРУЖАЕТСЯ ВСЯ ИГРА — и это не случайное место.
* после fade_in, ровно как show_title: fade_in_2, затем *
* play_sound(sound_54). */ * До 2026-09-02 pop_boot читал ВСЕ ресурсы перед первым пикселем: от
music_ok = (uint8_t)(pop_music_load(POP_MUS_INTRO) == 0); * запуска до проявления титула проходило несколько секунд чёрного
* экрана в тишине, потому что перед 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++) { for (step = 0; step < sizeof(title_scene); step++) {
if (step && title_draw(title_scene[step], 1) < 0) if (step && title_draw(title_scene[step], 1) < 0)
break; break;
if (step == 0) { if (step == 0) {
uint8_t t0;
if (pop_pal_fade_in_skip(TITLE_FADE)) { skipped = 1; break; } if (pop_pal_fade_in_skip(TITLE_FADE)) { skipped = 1; break; }
if (music_ok) { (void)pop_boot_sound();
(void)pop_sfx_start(); (void)pop_music_stream(POP_MUS_INTRO);
pop_music_play(); /* ЧЕМ ЗАПЛАЧЕН ПЕРВЫЙ ТАКТ. Загрузка занимает около секунды, и
* без поправки титул простоял бы на первом кадре дольше
* оригинала, а сцена уехала бы относительно темы. Считаем
* потраченное часами насоса (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; skipped = 1;
break; break;
} }
+137 -50
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */ #include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */ #include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */ #include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */ #include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */ #include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */ #include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
@@ -1114,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 int pop_boot(void) __banked
{ {
/* Настройки: дефолты, затем POP.CFG, если он есть и корректен. */ /* Настройки: дефолты, затем POP.CFG, если он есть и корректен. */
@@ -1141,77 +1172,114 @@ int pop_boot(void) __banked
gfx_set_visible_page(0); gfx_set_visible_page(0);
pop_pal_black(); pop_pal_black();
/* Набор фона — по типу СТАРТОВОГО уровня; atlas_load безопасен и после /* FONT.ATL — В МИНИМАЛЬНОЙ ФАЗЕ, И ОТКЛАДЫВАТЬ ЕГО НЕЛЬЗЯ.
* initgraph: при первом вызове он сохраняет страницу DSS W0. Уровень к *
* этому моменту ещё не прочитан, поэтому тип берём напрямую от номера. */ * Дело не в шрифте: СНИМОК ПАЛИТРЫ физически живёт в хвосте страницы
* шрифта (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;
}
pop_current_level = FIRST_LEVEL; 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_cd_init(); /* метка «фон трогали» — пустые диапазоны */
/* pop_cheats/speed применит pop_settings_apply после pop_sfx_init. */
pop_guard_reset(); pop_guard_reset();
if (pop_kid_data_load("KID\\kid.ani") != 0) { /* кадры+seqtbl в EMM-странице */ pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
puts("kid.ani load failed\n"); pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
return -1; pop_checkpoint = 0; /* init_game (seg003:37) */
if (!pop_settings.show_build_info) pop_pal_apply(POP_PAL_DIM_BLACK);
return 0;
} }
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */ /* ЗВУК ВПЕРЁД ОСТАЛЬНЫХ РЕСУРСОВ. Насосу для тишины (а значит и для того,
pop_sfx_init(); /* звук: 8 страниц оцифровки (вывод пока молчит) */ * чтобы 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. Без него музыки не /* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает: * будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */ * это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load(); (void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */ 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) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */ if (pop_kid_load(KID_PAGES) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed"); puts("kid atlas load failed");
boot_rest_fail = 1;
return -1; return -1;
} }
if (pop_level_load_num(FIRST_LEVEL) != 0) { /* сырой уровень в EMM-страницу */ boot_music_step();
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; загрузка идёт ещё на чёрной /* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно выполняет
* палитре и поэтому не открывает игроку старое содержимое VRAM. */ * pop_level_switch. Единственное исключение явно включённый
if (pop_menu_init() != 0) { * build-info: его фон по контракту является игровой сценой, поэтому
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: его фон по контракту является игровой сценой. */
if (pop_settings.show_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(); pop_start_level();
if (pop_current_level == POP_UPSIDE_LEVEL) pop_vflip_load_all(); if (pop_current_level == POP_UPSIDE_LEVEL) pop_vflip_load_all();
gfx_set_draw_page(0); gfx_set_draw_page(0);
pop_char_draw(POP_CH_KID); pop_char_draw(POP_CH_KID);
pop_char_fore(POP_CH_KID); pop_char_fore(POP_CH_KID);
} }
/* Звук включаем ПОСЛЕДНИМ действием инициализации — когда все атласы,
* уровень и палитры уже прочитаны. Раньше нельзя: ESTEX уходит в диск
* надолго, насос не успевает долить блок, и на слух это скрежет
* (pop_sfx.h). Парная к pop_sfx_pause в pop_level_switch. */
pop_sfx_start();
return 0; return 0;
} }
@@ -1224,6 +1292,11 @@ int pop_level_switch(void) __banked
uint8_t old_page; uint8_t old_page;
gfx_rect_t full; gfx_rect_t full;
/* РЕСУРСЫ МОГУТ БЫТЬ ЕЩЁ НЕ ПРОЧИТАНЫ. Кид, Тень и шрифт грузятся под
* заставкой (см. шапку pop_boot), а её можно пропустить клавишей в
* первую же секунду. Идемпотентно: со второго раза это проверка байта. */
if (pop_boot_rest() != 0) return -1;
/* Граница FG0: повреждённый snapshot или старый внешний вызов не должен /* Граница FG0: повреждённый snapshot или старый внешний вызов не должен
* даже пытаться открыть res2015.bin. Финальный маршрут 14 -> ENDING * даже пытаться открыть res2015.bin. Финальный маршрут 14 -> ENDING
* добавит автомат приложения (FG11); пока просто оставляем уровень. */ * добавит автомат приложения (FG11); пока просто оставляем уровень. */
@@ -1368,6 +1441,10 @@ int pop_new_game_load(void) __banked
gfx_wait_vsync(); gfx_wait_vsync();
} }
/* ДО pop_pal_game_load: она доплачивает слоты палитры по загруженным
* наборам (Тень, страж, тайлсет), и звать её раньше их чтения нельзя. */
if (pop_boot_rest() != 0) return -1;
pop_kid_hp_newgame(); pop_kid_hp_newgame();
hitp_max = POP_START_HITP; hitp_max = POP_START_HITP;
pop_timer_new_game(); pop_timer_new_game();
@@ -1414,4 +1491,14 @@ void pop_shutdown(void) __banked
pop_guard_free(); pop_guard_free();
pop_kid_data_free(); pop_kid_data_free();
pop_level_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();
} }
+14 -2
View File
@@ -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(void) __banked;
int pop_boot_sound(void) __banked;
int pop_boot_rest(void) __banked;
/* Перейти на pop_next_level: файл уровня, спрайты соперника, тайлсет, /* Перейти на pop_next_level: файл уровня, спрайты соперника, тайлсет,
* старт. На время I/O закрывает CBL и оставляет его закрытым: caller обязан * старт. На время I/O закрывает CBL и оставляет его закрытым: caller обязан
+2
View File
@@ -49,6 +49,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
OBJS_grab := $(OBJS_phys) OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же. # t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys) OBJS_wall := $(OBJS_phys)
# t_hang — зацеп обязан ДЕРЖАТЬСЯ (регресс glide_through_wall_guard).
OBJS_hang := $(OBJS_phys)
# t_gate — открытая решётка не должна считаться стеной у осторожного шага # t_gate — открытая решётка не должна считаться стеной у осторожного шага
# (нужны pop_map + физика, то есть тот же состав). # (нужны pop_map + физика, то есть тот же состав).
OBJS_gate := $(OBJS_phys) 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" # промежуточные атласы, по набору в папке ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
PACKED = APP / "assets" / "packed" # раскладка диска игры PACKED = APP / "assets" / "packed" # раскладка диска игры
GEN = APP / "gen" # генерируемые заголовки GEN = APP / "gen" # генерируемые заголовки
DIST = APP / "dist" # ручное содержимое дистрибутива (README и
# производные наборы, напр. тихий звук)
def atl(name): def atl(name):
@@ -139,3 +141,10 @@ def packed(name):
p = PACKED / name p = PACKED / name
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p 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()
+8
View File
@@ -0,0 +1,8 @@
# Локальные исполняемые файлы платформенных проб собираются рядом с
# исходниками. Общий шаблон репозитория не покрывает этот дополнительный
# уровень вложенности applications/Volkov/tests/<probe>/.
/tests/*/*.exe
# Клон исторических исходников VC служит локальным справочным материалом и
# сохраняет собственный .git; во внешний репозиторий его как gitlink не кладём.
/VolkovCommander/
+222
View File
@@ -0,0 +1,222 @@
# Sprinter Commander — двухпанельный файловый менеджер.
PROJ_ROOT := $(abspath $(CURDIR)/../..)
EXAMPLE := sprcmd
SRC_DIR := src
BUILD_DIR := build
MEMORY := big
ALLOCS ?= 3000
HDD_IMG := $(CURDIR)/build/hdd/sprcmd.chd
MDVIEW2_DIR := $(PROJ_ROOT)/examples/mdview2
MDVIEW2_SRCS := $(wildcard $(MDVIEW2_DIR)/*.c)
MDVIEW2_HEADERS := $(wildcard $(MDVIEW2_DIR)/*.h)
MDVIEW2_TEST_EXE := $(CURDIR)/build/external/mdview2.exe
P5_COPY_FAULT_EXE := $(CURDIR)/build/tests/cperr.exe
P6_SORT_TEST_EXE := $(CURDIR)/build/tests/sortrt.exe
P7_SELECT_TEST_EXE := $(CURDIR)/build/tests/selrt.exe
P9_MKDIR_TEST_EXE := $(CURDIR)/build/tests/mkrt.exe
P10_RENAME_TEST_EXE := $(CURDIR)/build/tests/renrt.exe
P11_DELETE_TEST_EXE := $(CURDIR)/build/tests/delrt.exe
P19_OVERWRITE_TEST_EXE := $(CURDIR)/build/tests/p19/delrt.exe
BANK1_SRCS := src/sc_source_fs.c src/sc_sort.c src/sc_select.c \
src/sc_copy_job.c src/sc_metadata.c src/sc_disk.c src/sc_tree.c src/sc_exec_ui.c
BANK2_SRCS := src/sc_draw.c src/sc_dialog.c src/sc_copy_ui.c \
src/sc_file_ui.c src/sc_name.c src/sc_rename.c
EXTRA_FLAGS := --safe --max-allocs $(ALLOCS) -I include \
$(foreach f,$(BANK1_SRCS),--bank 1=$(f)) \
$(foreach f,$(BANK2_SRCS),--bank 2=$(f))
EXTRA_SRCS := \
src/sc_app.c \
src/sc_command.c \
src/sc_input.c \
src/sc_path.c \
src/sc_panel.c \
src/sc_platform_screen.c \
src/sc_platform_exec.c \
src/sc_screen.c \
src/sc_store.c \
src/sc_theme.c \
src/sc_video_system.c
include $(PROJ_ROOT)/app.mk
HEADERS := $(wildcard include/*.h)
$(EXE): Makefile $(HEADERS) $(BANK1_SRCS) $(BANK2_SRCS)
hdd-stress: $(EXE)
sh tests/make_p3_stress_hdd.sh $(CURDIR)/build/hdd/p3_stress.chd $(EXE)
hdd-copy: $(EXE)
sh tests/make_p4_copy_hdd.sh $(CURDIR)/build/hdd/p4_copy.chd $(EXE)
tests/p4_exec_child/p4child.exe: tests/p4_exec_child/p4child.c \
tests/p4_exec_child/Makefile
$(MAKE) -C tests/p4_exec_child ALLOCS=$(ALLOCS)
hdd-exec: $(EXE) tests/p4_exec_child/p4child.exe
sh tests/make_p4_exec_hdd.sh $(CURDIR)/build/hdd/p4_exec.chd \
$(EXE) tests/p4_exec_child/p4child.exe
hdd-p5: $(EXE)
sh tests/make_p5_hdd.sh $(CURDIR)/build/hdd/p5_stability.chd $(EXE)
hdd-p5-enospc: $(EXE)
sh tests/make_p5_enospc_hdd.sh \
$(CURDIR)/build/hdd/p5_enospc.chd $(EXE)
hdd-p5-panels: $(EXE)
sh tests/make_p5_panel_edges_hdd.sh \
$(CURDIR)/build/hdd/p5_panel_edges.chd $(EXE)
$(MDVIEW2_TEST_EXE): $(MDVIEW2_SRCS) $(MDVIEW2_HEADERS) $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory small --max-allocs $(ALLOCS) \
-o $@ $(MDVIEW2_SRCS)
hdd-p5-viewer: $(EXE) $(MDVIEW2_TEST_EXE)
sh tests/make_p5_viewer_hdd.sh \
$(CURDIR)/build/hdd/p5_viewer.chd $(EXE) \
$(MDVIEW2_TEST_EXE) $(MDVIEW2_DIR)/README.md
$(P5_COPY_FAULT_EXE): tests/p5_copy_faults.c src/sc_copy_job.c \
src/sc_metadata.c src/sc_disk.c \
src/sc_path.c src/sc_platform_exec.c include/sc_copy.h \
include/sc_metadata.h include/sc_disk.h include/sc_path.h \
include/sc_exec.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_copy_job.c --bank 1=src/sc_metadata.c \
--bank 1=src/sc_disk.c -o $@ \
tests/p5_copy_faults.c src/sc_path.c src/sc_platform_exec.c
hdd-p5-copy-faults: $(P5_COPY_FAULT_EXE)
sh tests/make_p5_copy_faults_hdd.sh \
$(CURDIR)/build/hdd/p5_copy_faults.chd $(P5_COPY_FAULT_EXE)
hdd-p5-media: $(EXE)
sh tests/make_p5_media_hdd.sh \
$(CURDIR)/build/hdd/p5_media.chd $(EXE)
$(P6_SORT_TEST_EXE): tests/p6_sort_probe.c src/sc_sort.c src/sc_store.c \
include/sc_entry.h include/sc_panel.h include/sc_sort.h \
include/sc_store.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_sort.c -o $@ \
tests/p6_sort_probe.c src/sc_store.c
hdd-p6-sort: $(EXE) $(P6_SORT_TEST_EXE)
sh tests/make_p6_sort_hdd.sh $(CURDIR)/build/hdd/p6_sort.chd \
$(EXE) $(P6_SORT_TEST_EXE)
$(P7_SELECT_TEST_EXE): tests/p7_select_probe.c src/sc_select.c \
src/sc_store.c include/sc_entry.h include/sc_panel.h \
include/sc_select.h include/sc_store.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_select.c -o $@ \
tests/p7_select_probe.c src/sc_store.c
hdd-p7-select: $(EXE) $(P7_SELECT_TEST_EXE)
sh tests/make_p7_select_hdd.sh $(CURDIR)/build/hdd/p7_select.chd \
$(EXE) $(P7_SELECT_TEST_EXE)
hdd-p8-masks: hdd-p7-select
$(P9_MKDIR_TEST_EXE): tests/p9_mkdir_probe.c src/sc_name.c src/sc_path.c \
include/sc_path.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_name.c -o $@ \
tests/p9_mkdir_probe.c src/sc_path.c
hdd-p9-mkdir: $(EXE) $(P9_MKDIR_TEST_EXE)
sh tests/make_p9_mkdir_hdd.sh $(CURDIR)/build/hdd/p9_mkdir.chd \
$(EXE) $(P9_MKDIR_TEST_EXE)
$(P10_RENAME_TEST_EXE): tests/p10_rename_probe.c src/sc_rename.c \
include/sc_file_ops.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_rename.c -o $@ \
tests/p10_rename_probe.c
hdd-p10-rename: $(EXE) $(P10_RENAME_TEST_EXE)
sh tests/make_p10_rename_hdd.sh $(CURDIR)/build/hdd/p10_rename.chd \
$(EXE) $(P10_RENAME_TEST_EXE)
$(P11_DELETE_TEST_EXE): tests/p11_delete_probe.c $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-o $@ tests/p11_delete_probe.c
hdd-p11-delete: $(EXE) $(P11_DELETE_TEST_EXE)
sh tests/make_p11_delete_hdd.sh $(CURDIR)/build/hdd/p11_delete.chd \
$(EXE) $(P11_DELETE_TEST_EXE)
hdd-p12-group-copy: $(EXE)
sh tests/make_p12_group_copy_hdd.sh \
$(CURDIR)/build/hdd/p12_group_copy.chd $(EXE)
hdd-p13-group-delete: $(EXE)
sh tests/make_p13_group_delete_hdd.sh \
$(CURDIR)/build/hdd/p13_group_delete.chd $(EXE)
hdd-p14-default-colors: $(EXE)
sh tests/make_p14_default_colors_hdd.sh \
$(CURDIR)/build/hdd/p14_default_colors.chd $(EXE)
hdd-p15-tree-copy: $(EXE)
sh tests/make_p15_tree_copy_hdd.sh \
$(CURDIR)/build/hdd/p15_tree_copy.chd $(EXE)
hdd-p15-tree-cancel: $(EXE)
sh tests/make_p15_tree_cancel_hdd.sh \
$(CURDIR)/build/hdd/p15_tree_cancel.chd $(EXE)
hdd-p15-tree-self: $(EXE)
sh tests/make_p15_tree_self_hdd.sh \
$(CURDIR)/build/hdd/p15_tree_self.chd $(EXE)
hdd-p16-group-tree-copy: $(EXE)
sh tests/make_p16_group_tree_copy_hdd.sh \
$(CURDIR)/build/hdd/p16_group_tree_copy.chd $(EXE)
hdd-p17-tree-delete: $(EXE)
sh tests/make_p17_tree_delete_hdd.sh \
$(CURDIR)/build/hdd/p17_tree_delete.chd $(EXE)
hdd-p17-tree-delete-cancel: $(EXE)
sh tests/make_p17_tree_delete_cancel_hdd.sh \
$(CURDIR)/build/hdd/p17_tree_delete_cancel.chd $(EXE)
hdd-p18-metadata: $(EXE)
sh tests/make_p18_metadata_hdd.sh \
$(CURDIR)/build/hdd/p18_metadata.chd $(EXE)
$(P19_OVERWRITE_TEST_EXE): tests/p19_overwrite_probe.c \
src/sc_copy_job.c src/sc_metadata.c src/sc_disk.c src/sc_path.c \
include/sc_copy.h include/sc_metadata.h include/sc_disk.h \
include/sc_path.h $(LIB)
mkdir -p $(dir $@)
$(PROJ_ROOT)/bin/sprinter-cc --memory big --safe --max-allocs $(ALLOCS) \
-I include --bank 1=src/sc_copy_job.c --bank 1=src/sc_metadata.c \
--bank 1=src/sc_disk.c \
-o $@ tests/p19_overwrite_probe.c src/sc_path.c
hdd-p19-overwrite: $(P19_OVERWRITE_TEST_EXE)
sh tests/make_p19_overwrite_hdd.sh \
$(CURDIR)/build/hdd/p19_overwrite.chd $(P19_OVERWRITE_TEST_EXE)
hdd-p19-policy: $(EXE)
sh tests/make_p19_policy_hdd.sh \
$(CURDIR)/build/hdd/p19_policy.chd $(EXE)
.PHONY: hdd-stress hdd-copy hdd-exec hdd-p5 hdd-p5-enospc hdd-p5-panels \
hdd-p5-viewer hdd-p5-copy-faults hdd-p5-media hdd-p6-sort hdd-p7-select \
hdd-p8-masks hdd-p9-mkdir hdd-p10-rename hdd-p11-delete \
hdd-p12-group-copy hdd-p13-group-delete hdd-p14-default-colors \
hdd-p15-tree-copy hdd-p15-tree-cancel hdd-p15-tree-self \
hdd-p16-group-tree-copy hdd-p17-tree-delete hdd-p17-tree-delete-cancel \
hdd-p18-metadata hdd-p19-overwrite hdd-p19-policy
+112
View File
@@ -0,0 +1,112 @@
# Sprinter Commander
Функциональный аналог классического двухпанельного Commander для Sprinter
Sp2000. Volkov Commander 4.05/4.99 и Dos Navigator используются как
поведенческие и архитектурные референсы; проект не является переносом их
DOS-кода.
Текущее состояние: PoC 0.1 (этапы P1P5) пройден в MAME / BIOS 3.06 /
DSS 1.71.57; аппаратный smoke-тест остаётся внешним контрольным пунктом.
Начат этап 0.2: готовы независимые для каждой панели сортировки по имени,
расширению, размеру и дате/времени в обоих направлениях, выбор записей через
`Insert`, `*` и маски `+`/`-`, создание каталога по `F7` и локальное
переименование файла/каталога по `F6` без overwrite, однообъектный F8 и
групповой рекурсивный F8 для выбранных файлов и каталогов, а также рекурсивный
F5 для выбранных файлов и каталогов. F5 сохраняет FAT date/time и атрибуты
`R/H/S/A` обычных файлов и поддерживает conflict-policy
`overwrite/skip/rename`, включая `overwrite all`/`skip all`, отдельную
защиту readonly и backup/rollback. Обход использует динамическую EMM-очередь.
Встроенный профиль имён уже различает белые каталоги, жёлтые `.EXE` и
светло-серые остальные файлы; загрузка пользовательских цветовых правил
остаётся задачей 1.0.
Командер
сканирует, хранит и сортирует каталоги, показывает явную виртуальную запись
предела DSS 1.71, безопасно копирует один файл через выделенную EMM-страницу
W3 и запускает `.EXE` с восстановлением режима, палитры, CWD и обеих панелей.
## Сборка
Из этого каталога:
```sh
make
```
Результат: `build/sprcmd.exe`, конфигурация
`--memory big --safe --max-allocs 3000`. Значение `ALLOCS=3000` принято как
исходное для Commander и всех его проб; при диагностике его можно переопределить
явно, например `make ALLOCS=4000`.
Основные тесты используют быстрый HDD `D:`:
```sh
make hdd # только Commander
make hdd-stress # большой каталог P3
make hdd-copy # набор успех/overwrite/cancel/zero для P4.1
make hdd-exec # запуск child/restore/error для P4.2
make hdd-p5 # 20 copy, граничные размеры, 100 refresh, lifecycle
make hdd-p5-enospc # реальный disk-full и cleanup temp
make hdd-p5-panels # точные границы панели 27/28 записей
make hdd-p5-copy-faults # детерминированная матрица ошибок copy/EXEC
make hdd-p5-viewer # запуск реального mdview2 и восстановление Commander
make hdd-p5-media # снятие/возврат hard2 во время F5
make hdd-p6-sort # target-матрица и UI-сценарий сортировок 0.2
make hdd-p7-select # выбор Insert/*, статистика и EMM cleanup
make hdd-p8-masks # тот же расширенный образ: редактор и маски +/−
make hdd-p9-mkdir # validator DOS 8.3, mkdir/error/CWD и UI F7
make hdd-p10-rename # file/dir rename, no-overwrite, CWD и UI F6
make hdd-p11-delete # confirm/cancel, file/empty-dir, guards и UI F8
make hdd-p12-group-copy # legacy group fixture; current F5 also copies selected dirs
make hdd-p13-group-delete # legacy file-only group F8 regression
make hdd-p14-default-colors # built-in dir/EXE/file colours и restore палитры
make hdd-p15-tree-copy # recursive current directory, depth=16 и repeat guard
make hdd-p15-tree-cancel # Esc в 4-МБ файле и cleanup temp/traversal page
make hdd-p15-tree-self # target-inside-source guard до mutation
make hdd-p16-group-tree-copy # selected files + multiple directory roots
make hdd-p17-tree-delete # recursive group F8, depth=12, cancel confirmation
make hdd-p17-tree-delete-cancel # Esc после начала recursive group F8
make hdd-p18-metadata # exact FAT date/time + R/H/S/A после F5
make hdd-p19-overwrite # target backup/commit/rollback matrix
make hdd-p19-policy # UI overwrite/skip/rename/all/readonly/cancel
```
`make floppy` сохранён для smoke-проверки совместимости с FDD и не запускает
эмулятор. Подробнее: [политика тестовых носителей](docs/test-media.md).
## Документы
- [требования PoC](docs/commander-requirements.md);
- [архитектура](docs/commander-architecture.md);
- [roadmap](docs/commander-roadmap.md);
- [клавиши и команды](docs/commander-keymap.md);
- [результаты платформенных проб P1](docs/p1-platform-probes.md);
- [экранный backend и границы BIOS API](docs/p2-screen-backend.md);
- [результаты coordinate-screen probe P2.1](docs/p2-screen-probe-results.md);
- [результаты системной палитры P2.2](docs/p2-palette-results.md);
- [результаты двухпанельного skeleton P2](docs/p2-skeleton-results.md);
- [результаты файловых панелей P3](docs/p3-panel-results.md);
- [результаты копирования P4.1](docs/p4-copy-results.md);
- [результаты запуска EXE P4.2](docs/p4-exec-results.md);
- [результаты стабилизации P5](docs/p5-stability-results.md);
- [результаты сортировок 0.2/P6](docs/p6-sort-results.md);
- [результаты выбора записей 0.2/P7](docs/p7-selection-results.md);
- [результаты выбора по маске 0.2/P8](docs/p8-mask-results.md);
- [результаты создания каталога 0.2/P9](docs/p9-mkdir-results.md);
- [результаты локального rename 0.2/P10](docs/p10-rename-results.md);
- [результаты однообъектного delete 0.2/P11](docs/p11-delete-results.md);
- [результаты группового F5 0.2/P12](docs/p12-group-copy-results.md);
- [результаты группового F8 0.2/P13](docs/p13-group-delete-results.md);
- [результаты стандартных цветов P14](docs/p14-default-colors-results.md);
- [результаты рекурсивного F5/P15](docs/p15-tree-copy-results.md);
- [результаты рекурсивного group F5/P16](docs/p16-group-tree-copy-results.md);
- [результаты рекурсивного group F8/P17](docs/p17-tree-delete-results.md);
- [результаты сохранения метаданных F5/P18](docs/p18-metadata-results.md);
- [результаты conflict-policy F5/P19](docs/p19-copy-policy-results.md);
- [направление развития UI по образцу VC](docs/ui-reference.md);
- [инструкция и ограничения PoC 0.1](docs/poc-0.1-release.md);
- [носители интеграционных тестов](docs/test-media.md);
- [предел больших каталогов DSS и стратегия Commander](docs/dss-large-directories.md).
Экран выводится системными координатными функциями ESTEX/BIOS без оконных
дескрипторов и без прямой адресации VRAM. Системные вызовы палитры разрешены;
их save/set/readback/restore и многоцветная сцена подтверждены пробой P2.2.
@@ -0,0 +1,36 @@
# Артефакты MAME-прогона P1
Дата: 6 сентября 2026 года.
Окружение: MAME 0.287 (`b0c4527c`), машина `sprinter`, BIOS `v3.06`, текущие системные
образы проекта. `p1probe.exe` и `p1child.exe` запускались с дискеты A:.
Каталоги:
- `directory/` — CWD, `*.*`, `*`, завершение F_NEXT с `ENOENT=3`;
`strict-enoent.png` снят после удаления временного допуска `EISDIR`;
- `emm/` — три страницы, граничные patterns и возврат free pages;
- `dskinfo/` — геометрия и свободное место текущего диска;
- `winrest/` — полный экран 80x32 и частичный вывод из offset `0x2000`;
`partial-phase-hidden.png`/`partial-stable.png` показывают неудачные raw-
атрибуты, а `partial-flash-phase.png`/`partial-contrast-stable.png`
именованный цветной pattern. Повторная проверка декодированных RGB показала,
что два последних кадра пиксельно идентичны; P2.2 отдельно подтвердил
стабильность четырёх палитровых планов;
- `exec/` — оба вызова child и итоговые значения EXEC/WAIT;
- `exec-then-emm/` — повторный EMM-тест после двух EXEC в том же процессе;
- `keyboard-commander/` — навигация, F1F10, Ctrl+R, Ctrl+PgUp, Alt+F1;
- `keyboard/` — дополнительная проба Ctrl+A, Alt+X и Shift+Tab.
Нумерованные клавиатурные кадры соответствуют времени сценария:
- `0000.png` — первые навигационные события;
- `0001.png` — продолжение навигации и начало F-клавиш;
- `0002.png` — полная базовая таблица до сочетаний;
- `0003.png` — сочетания, Esc и общий `RESULT: PASS`;
- `0004.png` — возврат в главное меню.
Интерпретация результатов и ссылки на основные кадры находятся в
`docs/p1-platform-probes.md` и `docs/commander-keymap.md`.
Актуальная интерпретация палитры находится в
`docs/p2-palette-results.md`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Some files were not shown because too many files have changed in this diff Show More