Реализовать двухпанельный Commander от платформенного PoC до этапов P6-P20: EMM-каталог, сортировку и выбор, операции с файлами и деревьями, транзакционное копирование, метаданные, политику конфликтов и предварительную проверку свободного места.
Добавить проектную документацию, HDD/MAME-сценарии и проверенные артефакты. Расширить libc операцией bank_write_page, исправлением режима O_RDONLY и связанными регрессионными проверками.
Проверка 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
Заголовок записи стал `<имя тега> — <дата> — <коммит>`.
Зачем коммит, если есть имя тега: тег можно передвинуть или
переименовать, а ревизия, на которой релиз собран, должна оставаться в
записи однозначно — по ней билд воспроизводится точно. Заодно это тот же
хеш, что зашит в 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
До сих пор «что изменилось» восстанавливалось только из 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
С обобщения 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, уровень 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
В записи стояло, будто оригинал играет звук 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): на подъёме Кида шёл нескончаемый
свист клинка, хотя меча у него нет.
ОПОЗНАНИЕ. Звук снят с живой машины, а не угадан: 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
Регресс от 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
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
Выхлоп 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
Правки пользователя, разобранные по диффу и закоммиченные как есть.
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
Правка пользователя, разобранная по диффу и закоммиченная как есть.
Было «всё или ничего»: --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
Правка пользователя, разобранная по диффу и закоммиченная как есть.
Программа может временно маппить свою 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
Берёт то, что уже лежит в 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
Два бага одного пути завершения, оба видны только на железе.
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
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
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
От запуска до проявления титула проходило ~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
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-08-31. Две ПРОВЕРЕНЫ пользователем в MAME
(звук мигания, порядок «мелодия -> надпись»), две ждут проверки — образ
собран.
ЗВУК МИГАНИЯ (проверено). Упаковщик научился синтезировать ноты PC-
спикера в обычный сэмпл: заголовок с темпом, тройки «частота + длина»,
меандр на нашей частоте вывода. Берём из нот ТОЛЬКО номера, которых нет
ни в оцифровке, ни среди мелодий, — иначе синтез перекрыл бы музыку,
которую мы играем из MUS/. На поставке SDLPoP это ровно один номер: 38,
сигнал под мигание «Press Button»; 31, 34 и 42 там пустые заглушки.
Громкость по слуховой проверке снижена вдвое (44 -> 22): на полном
размахе сигнал перекрикивал игру. EXE не меняется — раскладка читается с
диска, набор занял те же 9 страниц.
НАДПИСЬ ЖДЁТ МЕЛОДИЮ (проверено). Порядок оригинала: ветка мёртвого
(seg006:1351) на седьмом шаге выходит, пока звук играет, и «Press Button»
появляется только после музыки смерти. Чтобы ожидание не было
принудительным, три быстрых пути (Ctrl+A, обе быстрые загрузки, пункты
меню) музыку глушат — оригинал при Ctrl+A делает то же (seg000:0617).
Обычная кнопка во время мелодии не действует: она ответ НА надпись.
ЧАСЫ (ждёт проверки). Тик стоит столько кадров ЛУЧА, сколько их в кадре
режима NORMAL, поэтому FAST/FASTEST больше не ускоряют время. Считаем
ФАКТИЧЕСКИ прошедшие кадры луча, а не ожидаемый делитель: логический кадр
не всегда укладывается в бюджет, и часы «по делителю» шли рывками (первый
прогон это показал — «несколько секунд быстро, потом притормаживание»).
Вклад одного вызова ограничен, иначе пауза и меню прыгнули бы вперёд.
Четыре новых теста: NORMAL не сдвинулся ни на тик (и вне боя, и в бою),
FAST и FASTEST держат реальное время.
ПОЛОСА HP ПОСЛЕ Ctrl+A (ждёт проверки). Корень: счётчик считает
СТРАНИЦЫ, а тратился по КАДРАМ — между двумя вызовами переворота может не
быть, и оба прохода уходили в одну страницу, вторая оставалась с
делениями прошлого боя. Теперь проход тратится только при смене
gfx_get_draw_page(). Плюс полная чистка всей ширины при инвалидации:
старая полоса могла заходить под статус-текст, где щадящая чистка её не
трогала; текст сразу перезапрашивается.
Все 16 наборов host-тестов зелёные, check_bank_calls чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Разбор без правок кода; все три отмечены как задачи по решению пользователя.
SND-SPEAKER-38 (TASKS_OPEN, P1). Надпись «Press Button» мигает молча.
Наш код не виноват: pop_dead_prompt зовёт звук 38 на каждом появлении,
как оригинал, но слот 38 в наборе ПУСТ. Причина — три параллельных
набора звука у оригинала: оцифровка (0-23, 44-49, 51), мелодии (24-43
частично, 50, 52-56) и ноты PC-спикера (весь диапазон). Звук 38 есть
ТОЛЬКО среди нот спикера, поэтому провалился между нашими конвейерами.
Полная ревизия: не оцифровка и не мелодия — номера 31, 34, 38, 42, из них
31/34/42 пустые заглушки, реально звучит ровно один — 38. Решение
выбрано пользователем: синтезировать ноты в PCM и класть в наш атлас;
формат разобран по спецификации Princed и записан в задачу.
TIME-SPEED (TASKS_OPEN, P1). Часы уменьшаются на каждом логическом кадре
(как оригинал), но длину кадра у нас меняет режим скорости — в FAST
минута проходит на треть быстрее. Разная длина кадра в игре и в бою есть
и в оригинале (поправка пользователя), поэтому замедление часов в бою не
трогаем; вопрос только в наших добавочных режимах. Два варианта с
рекомендацией оставить как есть.
HP-BAR-RESTART (BUGS_OPEN). После гибели и Ctrl+A (у нас это рестарт
уровня) на одной из страниц остаётся полоса по результатам боя. Механизм
перерисовки на месте — счётчик страниц, все холодные пути его взводят.
Подозреваемый: пока висит статус-текст, стирание чистит только края и не
трогает середину, а полоса стража при большом запасе HP заходит именно
туда.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Два независимых фикса, оба проверены в MAME.
1. ПРИЗЕМЛЕНИЕ МЁРТВОГО (симптом «убили Кида, а он сел этажом ниже»).
Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
развязка «мягко / средне / разбиться» отведена ЖИВОМУ, телу — своя
ветка (добить HP, звук падения насмерть, seq_22). У нас развилки не
было, и в глаза это не бросалось только из-за высоты: тело, сброшенное
ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет, «последнее HP»
не тратится, ветка «разбился» не выбирается никогда. Труп уходил в
мягкое приземление и садился (кадр 109).
Цена — один тест байта на вызов land(), то есть на событие касания
земли, а не на кадр. Живой путь не изменился ни на операцию.
2. ЗАЛИПАВШАЯ НАДПИСЬ «Press Button to Continue». Счётчик кадров смерти
живёт снаружи главного витка и потому переживает возврат на заставку.
Ответ игрока кнопкой его обнулял, а выход по таймауту (24 с молчания
-> title) уходил мимо сброса. Дальше счётчик оставался израсходованным
на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила в
title, не показав надписи; лечилось только перезапуском программы.
Сброс поставлен на входе в игровой маршрут — закрывает и остальные
боковые дороги (выпадение за нижнюю границу, смена уровня).
Здесь же довезена связка находок 12/13 аудита: смерть безоружного у
обрыва уходит в свою последовательность (seq_81), а прижатие к полу
осталось страховкой для прочих веток — снять его целиком не вышло дважды,
подробности в комментарии guards.c.
Тесты: t_death дополнен обеими сторонами развилки (мёртвый обязан
разбиться, живой с той же высоты — сесть без урона), 18 проверок; все 16
наборов host-тестов зелёные.
В доску записан CLIMB-VS-GUARD: Кид подтягивается к стражу этажом выше —
у нас удар порой смертелен, в оригинале Кид срывается без урона. Цепочка
засчитывания удара сверена с оригиналом и совпадает дословно, расходятся
входные данные. Лучшая зацепка — ЗВУК: оригинал играет взмах клинка (11)
при любом уколе, до всякой проверки попадания, а у нас слышен упор в
стену (8) — значит страж не атакует, а сталкивается. Набор звуков
проверен и не виноват. Отложено по решению пользователя.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Портировано опциональное исправление SDLPoP (fix_glide_through_wall,
seg005 в do_fall). В ванили персонаж, падающий после разворота в беге,
может оказаться внутри кладки и лететь «в стене» — баг оригинала,
воспроизведённый пользователем в игре и затем на host-тесте.
Решением 2026-08-31 фикс взят в ТЕКУЩИЙ билд: играбельность важнее
буквальности. Реализация вынесена отдельной функцией
glide_through_wall_guard() в pop_map.c намеренно — при разделении
VANILLA/ENHANCED это готовая точка отвязки, достаточно не звать её в
ванильном режиме.
ПРОВЕРКА. Набор t_wall был заранее написан так, чтобы сторожить ЧИСЛО
заходов в кладку: до фикса их было ровно два из четырнадцати стартовых
позиций, после — ноль. Остальные 15 наборов (в том числе phys с 1733
проверками и grab) остались зелёными. Живая проверка в MAME
пользователем: корректно.
Ожидание в тесте обновлено ОСОЗНАННО, прежнее число сохранено рядом
отдельной константой с пометкой «сколько было до фикса»: оно измерено, и
понадобится, когда появится режим VANILLA — там ожидание станет зависеть
от режима.
ЦЕНА: +57 байт в банке 3 (свободно 3043), резидент и куча не изменились.
По скорости попадание только на кадры падения: пересчёт колонки — одно
деление, дистанция до кромки считается лишь если персонаж действительно
внутри кладки.
Документы: в аудите находка 21 переведена в «портировано» с сохранением
исходного разбора; в vanilla_vs_bugfixed статус фикса стал ВЗЯТ, сводка
пересчитана (5 взято, 32 кандидата в ENHANCED).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Документ vanilla_vs_bugfixed.md дополнен полным перечнем опциональных
исправлений оригинала, какие есть в SDLPoP, с их статусом у нас и
местом в коде, где решение принято.
Расклад: 4 взяты (дверь выхода, звуки ворот, перо только для Кида,
приоритеты звуков), 4 сознательно оставлены ванильными (падение на
стража, прыжок через стража, трюк 35, кровь скелета), 1 в работе
(падение сквозь стену), 33 не реализованы — кандидаты в ENHANCED.
ГЛАВНОЕ СЛЕДСТВИЕ: наш билд — это не VANILLA, а «ваниль плюс четыре
исправления». При разделении режимов придётся пройтись по уже сделанным
отступлениям и распределить их, иначе текущее поведение нельзя считать
эталоном ни для одного режима.
ИСПРАВЛЕНА МОЯ НЕВЕРНАЯ ОЦЕНКА. Ранее было записано, что фиксы,
требующие правки байткода seqtbl, у нас недоступны без переделки
конвейера данных. Это неверно: байткод можно менять и у нас. Лучший
способ — держать ОБЕ версии в одной странице EMM: kid.ani занимает около
4 КБ при странице в 16 КБ, так что обе помещаются рядом, а переключение
режима сводится к смене базового смещения — без патчей и с мгновенным
откатом. У SDLPoP, к слову, рабочая таблица и неизменная копия
оригинала тоже существуют раздельно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
КОД ИГРЫ НЕ МЕНЯЛСЯ. Новый набор t_death — 9 проверок, всего наборов 16.
Заведён ПЕРЕД правкой находок 12/13, чтобы поймать деградацию: правка 13 в
изоляции уже ломала смерть (мёртвый оставался с ненулевой скоростью
падения, проваливался за нижнюю границу, игра уходила на рестарт, не
показав тела). Поэтому проверяется ровно то, на что эти правки влияют:
* удар не в боевой стойке смертелен независимо от запаса HP;
* удар с мечом снимает одно HP, на последнем — убивает;
* ПЕРЕЖИВШИЙ удар ставится на пол своего ряда с нулевой скоростью падения
(в оригинале это единственная ветка, где координата трогается);
* тело после смерти остаётся в своём ряду — и на ровном полу, и у самого
обрыва (целевая сцена находки 12; после правки ожидание изменится
осознанно).
По дороге тест дважды показал не баг движка, а мои ошибки в самой сцене:
обвязка выставляет признак «жив» только стражу, а урон применяется не
сразу — удар выставляет дельту, и HP меняет отдельный шаг кадра, как в
оригинале. Оба раза чинился тест.
VANILLA/BUGFIXED: записано, что переключатель уже существует в настройках
и зафиксирован в положении VANILLA, второй заводить не нужно. Отмечено
главное следствие — наш «ванильный» билд УЖЕ не чистая ваниль (часть
ванильных багов пофикшена), поэтому при разделении режимов придётся
пройтись по сделанным отступлениям и распределить их; отдельные фиксы
(падение сквозь стену) могут быть сделаны и в нынешнем билде.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
КОД ИГРЫ НЕ МЕНЯЛСЯ — правка находки 24 сделана и откачена.
ТЕСТЫ. Набор t_wall вырос с одной проверки до трёх: добавлены проверка
X на каждом кадре падения (персонаж не должен оказываться внутри кладки)
и симметричный прыжок через провал с другой стороны.
Ключевое в них — форма ожидания. Тест НЕ требует нуля заходов в кладку,
а сторожит их ЧИСЛО: сейчас ровно два случая из четырнадцати стартовых
позиций. Это ванильное поведение оригинала, для которого SDLPoP держит
отдельное опциональное исправление; больше двух — значит правка сделала
нас хуже ванили, меньше — значит фикс кем-то портирован. То есть тест
сразу готов обслуживать оба будущих режима.
По дороге тест дважды ловил не баг движка, а мою ошибку в самой сцене
(старт в пустой клетке; перелёт через площадку считался нарушением).
Оба раза чинился тест, а не движок.
НАХОДКА 24 ОТЛОЖЕНА. Перезагрузка кадра в in_wall верна по букве
оригинала, но эффекта показать не удалось: все 15 наборов host-тестов
дают одинаковый результат до и после. При этом правка не бесплатна —
маппинг окна и перезагрузка кадра на каждое выталкивание. Платить за
недоказанное не стали.
НОВАЯ ЗАДАЧА: docs/vanilla_vs_bugfixed.md — поддержка двух поведений,
ванильного и с багфиксами. Туда переехали находка 24, три опциональных
фикса SDLPoP (скольжение сквозь стену, прыжок над воротами, гобелен) и
готовый детектор из t_wall. Открытые вопросы записаны: чем переключать
(возможно, объединить с уже существующим VANILLA/ENHANCED), цена
рантайм-проверки в горячем пути, что считать умолчанием, как гонять
тесты в двух режимах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Правка 13 (перенос прижатия к полу в ветки пережитого удара, как в
оригинале) была сделана в изоляции и СЛОМАЛА смерть: страж убивает Кида,
а вместо тела и паузы идут вспышка, стопкадр и мгновенный выход в
заставку.
Причина: у нас прижатие к полу работало КОМПЕНСАЦИЕЙ отсутствующей ветки
«убит и сброшен с уступа» (находка 12). Без неё мёртвый остаётся с
ненулевой fall_y, физика ведёт его вниз, он пересекает нижнюю границу,
взводится pop_fell_out — и приложение уходит на рестарт РАНЬШЕ отрисовки,
поэтому тела не видно вовсе.
Оценка «чистое перемещение двух строк, риск низкий» была неверной. В
документе исправлено: риск ВЫСОКИЙ, пока ветка 12 отсутствует; обе
находки — одна правка, и порядок внутри неё обратный: сперва добавить
seq_81 с экспортом тайловых запросов, убедиться, что смерть на краю
отыгрывается ею, и только потом снимать страховку.
Правка откачена, дерево вернулось к проверенному состоянию.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
tests/host не линковались: не разрешались pop_chdir_home (его тянет
pop_kboot из банка 10) и kbd_raw_keypad_as_ext (тянет pop_ctrl из libc).
Поломка предсуществующая — воспроизводится и на коммите до всех правок
этой сессии. Ни файловой системы, ни клавиатуры в хостовых тестах нет,
поэтому обе заглушены пустышками в общей обвязке.
Теперь все пятнадцать наборов проходят: geom 3144 проверки, phys 1733,
char 72, grab 55, jaffar 44, shadow 45, app 58, cfg 51, demo 30, flow 27,
timer 28, cutscene 13, gate 10, mouse 17, wall 1.
Это условие для дальнейшей работы: логику движка снова можно проверять за
секунды, без сборки образа и ручного прохождения в MAME.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
КОД НЕ МЕНЯЛСЯ. Разбор одиннадцати находок рангов А и Б: что менять, во
что это обойдётся по скорости и памяти, каков риск.
СНЯТО ГЛАВНОЕ ПРЕПЯТСТВИЕ. Обоснование двух упрощений (guards.c:1011 —
«нужны тайловые запросы от Char, а pop_map умеет только от Kid»)
УСТАРЕЛО: get_tile_at_char, get_tile_infrontof_char, get_tile_behind_char
и distance_to_edge_weight в pop_map.c уже работают от Char, они лишь не
выведены в заголовок. Данные тоже на месте — pop_char_set_seq ставит
любую из 115 последовательностей, то есть seq_81 и seq_64 доступны без
единого нового байта. Три находки упираются не в архитектуру, а в четыре
строки объявлений.
СКОРОСТЬ. Места классифицированы по частоте вызова: play_seq и ИИ стража
— горячие, land/in_wall/bumped/hurt_by_sword — событийные. Из
одиннадцати правок две УСКОРЯЮТ код (уходит условие из горячего цикла;
звук перестаёт играть в двух случаях из трёх), большинство бесплатны
(перестановка строк), и ни одна не требует переделки архитектуры.
Единственный конфликт со скоростью — отложенная побудка чомперов:
play_seq маппит страницу байткода в W0 один раз перед циклом, и звать
start_chompers внутри цикла значило бы снимать и возвращать окно на
каждый переход ряда. Дешёвая замена: копить не один флаг, а битовую
маску рядов и разбудить их после цикла — теряться ряды перестанут, цена
в цикле нулевая. Для стражей аналогично: не межбанковый вызов wall_type,
а копия таблицы в 32 байта в своём банке.
Порядок работ — от «одна-две строки, низкий риск» (13, 24) к тем, где
правка может компенсировать наши отличия в другом месте (1, 7).
Политика: для критичных фиксов скорость не вето — такие выносятся в
отдельный разбор с поиском дешёвого способа.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
КОД НЕ МЕНЯЛСЯ. Построчный разбор наших реализаций против оригинала:
26 позиций за пять проходов, каждая с рангом вероятности (А..Д), с
описанием «чем грозит» и сценарием проверки.
Расхождения группируются в три узла, и это главный вывод аудита:
1. СМЕРТЬ ПРИ АКТИВНОЙ ФИЗИКЕ — здесь все находки ранга А. У нас смерть
это флаг, а физика продолжает вести персонажа как живого: нет ветки
«убит и сброшен с уступа» (оригинал выбирает её по тайлу позади),
прижатие к полу в hurt_by_sword стало безусловным (в оригинале только
для выжившего удара), в land лишний пересчёт колонки. Этим
объясняется наблюдение пользователя: заколотый на краю Кид доезжает
этажом ниже и садится в присед.
2. ГРАНИЦЫ МОДУЛЕЙ — pop_map не отдаёт наружу тайловые запросы от
произвольного Char, wall_type и загрузку кадра. Три ветки упрощены НЕ
по логике, а по доступности функций: отсутствующая ветка уступа,
«стена впереди» сужена у стражей до одного тайла (оригинал считает
преградой ещё ворота, верх двери, зеркало и чомпер), in_wall не
перезагружает кадр. Чинить это заплатками неправильно — сначала
расширять интерфейс pop_map.
3. МОМЕНТ ПОБОЧНЫХ ДЕЙСТВИЙ — делаем то же самое, но раньше или позже:
сброс fall_x, побудка чомперов (у нас отложена до конца play_seq),
звук удара, перезагрузка кадра. По отдельности мелочь, вместе — сдвиг
состояния на кадр.
Восемь позиций СВЕРЕНЫ И СОВПАДАЮТ (диспетчер control, все 15 опкодов
seqtbl, control_with_sword, parry, swordfight, sword_strike,
check_sword_hurt, check_hurting, bumped_fall, таблицы кадров) — их не
нужно перепроверять. Дважды по ходу работы едва не записана ложная
находка из-за чтения отфильтрованного вывода; отсюда правило: фиксировать
расхождение только после чтения обеих реализаций целиком.
Отдельно: второе наблюдение пользователя (падение частично в стене) —
у SDLPoP есть ТРИ опциональных фикса ровно про это, то есть в ванили баг
присутствует, и мы его намеренно повторяем. Но найдены два места, где мы
можем быть хуже ванили (гард curr_row<=2 в do_fall и in_wall выше).
Незакрытое перечислено в файле: тела autocontrol_*, check_grab,
check_bumped_look_left, старшие биты байта клинка. Также отмечено, что
ни одно найденное осознанное отличие не занесено в docs/impl_diff.md,
хотя правило проекта этого требует.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Часть I плана music_runtime_index_plan.md (MI0..MI5). gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools). Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.
ГДЕ ЖИВЁТ ИНДЕКС. 228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта. Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21. Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c). Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.
ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ. pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит». Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9). Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4). Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.
PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились. План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.
ПАМЯТЬ. За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б. Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.
ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.
НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре. Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Часть II плана music_runtime_index_plan.md (SI0..SI4). gen/pop_sound_tbl.h
БОЛЬШЕ НЕ ГЕНЕРИРУЕТСЯ: раскладка набора читается из SND/snd.idx (формат
PSI1, писатель и разборщик — tools/pop_idx.py, 22 теста в make test-tools).
Один и тот же sprpop.exe работает с набором SDLPoP (9 страниц) и MSDOS
(10) — sha256 бинарника при смене набора не меняется.
Заодно умолчание источника эффектов переведено на SDLPoP (SND_SRC=sdlpop):
сборка обязана работать без оригинального дистрибутива DOS. У кого он
есть, включает лучший набор явно — make SND_SRC=msdos (там полнее
оцифровка: в SDLPoP звук 48 spiked пустой).
Устройство: pop_snd_tbl/pop_snd_page/pop_snd_pages — резидентные данные
(pop_snd_data.c), тип и инварианты — рукописный pop_snd_tbl.h. Записи
читаются ОДНИМ read прямо в таблицу, поэтому sizeof(pop_snd_ent_t) == 5
стало частью дискового контракта: проверяется статически и полем размера
записи в заголовке. POP_SND_PAGES как compile-time размер набора исчез —
вместо него POP_SND_MAX_PAGES (вместимость, 16) и runtime pop_snd_pages.
Цена: таблица переехала из _CODE в _DATA, суммарный резидент почти не
изменился (куча 239 -> 229 Б); банк 8 +601 Б на чтение и валидацию.
Валидация не доверяет файлу: заголовок целиком плюс каждая запись
(страница, смещение, кратность блоку, непересечение с блоком тишины,
выход за последнюю страницу). Последнее считается В БЛОКАХ — байтовый
адрес конца не влезает в uint16, а 32-битная арифметика на Z80 дорога.
НЕТ ИНДЕКСА — ЭФФЕКТОВ НЕТ, НО МУЗЫКА ИГРАЕТ. Первая версия просто
возвращала ошибку, и игра становилась непроходимой: тишину льёт первый
блок набора, без набора CBL не открывался, а с ним вставала музыка (её
блоки считает тот же насос) — заставка ждала конца трека вечно. Теперь
поднимается пустой набор с блоком тишины. Заливается ровно 128 байт и
под DI: gfx_w0_page_prepare ставит в страницу IRQ-стабы, и заливка всей
страницы затирала их — первое же прерывание давало чёрный экран.
Грабли сборки: смена SND_SRC тихо давала неверный результат
(sdlpop -> msdos -> sdlpop оставлял чужой набор в assets/packed). Причина
не в логике, а в секундной гранулярности mtime. Лечение убирает время из
решения: смена варианта сносит stamp'ы своего семейства, а упаковка,
сборка архива и копия индекса делаются одним рецептом. То же получила и
музыка (MUSIC_FMT).
Проверено в MAME: таблица в памяти совпадает с файлом из образа побайтово;
один EXE поднимает оба набора; отладочный --order reverse (30 из 31
записей отличаются от штатных) звучит правильно; битый индекс выключает
эффекты, не роняя игру; без индекса PV-сцена проходит с музыкой; Ctrl+S
работает в обоих режимах. Разбор — docs/sound_plan.md §10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой. Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.
Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева. По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.
MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.
У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/). Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались. Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.
assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
Добавить общий каталог назначения для HDD и удалить локальную копию упаковщика.\n\nУбрать устаревшие generated-имена ресурсов, выводить число страниц Kid из kid.arc и ограничить звуковую таблицу горячим модулем.\n\nЗафиксировать планы runtime-индексов музыки и PCM-эффектов.
Замер тем же способом (брейк на pop_sfx_fill + печать totalcycles, три окна
по 800 вызовов = 9 секунд каждое, с уже снятыми глушениями):
период насоса 245 760 тактов (медиана во всех окнах)
максимум 245 832 / 270 096 / 311 346 (1,00 / 1,10 / 1,27 периода)
пропущено порций 0
Порция считается пропущенной, когда зазор доходит до ДВУХ периодов: сама
порция отдаётся железу за период до того, как она понадобится, поэтому
опоздание обработчика на 1,1 мс — джиттер, а не потеря. Запас
десятикратный.
Поэтому сняты и оставшиеся места:
* палитра через BIOS на переходе БЕЗ катсцены (sprpop_cold.c) — то самое,
где ловили скрежет 2026-08-25; в комментарии помечено, что при возврате
скрежета возвращать надо именно сюда;
* массовые чтения треков и ресурсов под чёрным экраном (pop_intro.c):
первая реплика PV, трек заставки между уровнями, «время вышло», ресурсы
финала.
Осталось только то, что глушит звук ПО СМЫСЛУ, а не ради защиты: выходы из
сцен (pop_music_free + pause), уход в титры и в игру, выключение по Ctrl+S.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Записана с оговорками, найденными при сегодняшнем разборе: загрузку придётся
разрезать на дисковую и палитро-экранную половины, шаг подкачки держать
полустраничным, проверить EMM-бюджет на два уровня разом. Половина идеи уже
работает — трек заставки играет поверх загрузки (порядок оригинала, замер
насоса приложен в записи).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ЗАМЕР (MAME, 2026-08-28). Брейк на pop_sfx_fill с печатью totalcycles, 500
подряд вызовов насоса через всю загрузку уровня 1->2 под звучащий трек 27:
медиана интервала 245 760 тактов, максимум 245 832 при дедлайне 251 000
(11,7 мс) — НИ ОДНОЙ пропущенной порции. Загрузка уровня насос не морит.
Поэтому снята двойная заплатка:
* pop_level_switch больше не глушит насос перед pop_level_load_num;
* pre_cut_finish больше не досиживает трек на чёрном экране (это делалось
только чтобы глушение не обрубило его на полуслове; ценой были ~8 секунд
пустого экрана — трек 27 длиннее сцены: 10,7 с против 2,6 с).
Взамен восстановлен порядок оригинала (seg003:68-108, play_level):
катсцена возвращает управление сразу -> уровень грузится ПОД музыку ->
ожидание конца трека на чёрном экране (порт `while (check_sound_playing())`
+ stop_sounds) -> показ уровня. Общая чернота теперь max(трек, загрузка), а
не их сумма, и трек не обрывается. Ожидание со страховкой на ~20 с, чтобы
потоковый трек не подвесил переход.
Пропуск сцены по-прежнему обрывает музыку — как и было.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обе записи закрыты сегодняшними правками: вторая шкала по насосу удалена
вместе с гонкой, которая её выбирала, а «сцена дороже бюджета» оказалась не
ценой кадра, а местом отсчёта интервала. Исходные разборы оставлены под
заголовками — они объясняют, как искали.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Продолжение предыдущего коммита: заплатка стояла не в одном месте.
* открытие меню (snapshot палитры + затемнение фона + первая полная
перерисовка) и закрытие (menu_erase — те же две полностраничные копии) —
DI у копира бандами по 16 строк (~1,6 мс против дедлайна 11,7 мс), а
палитровое затемнение идёт и в титрах с катсценами, где музыку не рвёт;
* сохранение POP.CFG и проба quickload — это десятки байт и open/close;
прежняя осторожность «ESTEX уходит в диск надолго» относилась к загрузке
НАБОРА страниц. Подтверждение с поля: HOF пишется под звучащий «won»;
* quicksave/quickload (pop_qsave.c) — единственное снятое место, где по
диску реально едут 16 КБ (~три периода насоса). Помечено в комментарии:
если на F6/F9 появится скрежет, вернуть pop_sfx_pause/start точечно сюда.
Дисковых глушений в живом звуке больше не осталось; те, что стоят на
загрузке уровня и наборов страниц, не трогали — там они по делу.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ui_begin() звал pop_sfx_pause() перед полностраничной accel-копией, и это
слышно: pause закрывает CBL целиком (cbl_close), курсор трека стоит, и
короткая мелодия начала уровня замирала ровно на время перерисовки меню —
а на перемещении по пунктам это повторялось на каждом кадре меню.
Обоснование заплатки устарело. Лист, который зовёт gfx_copy_page
(_bgi_scroll_rows_raw), режет DI бандами по 16 строк — ~1,6 мс против
дедлайна насоса 11,7 мс (85,4 порции в секунду), между бандами есть окно
прерываний. Тот же полностраничный копир каждый кадр делают игровой цикл
и катсцены, и звук там не рвётся. Скрежет, под который заплатка ставилась,
шёл от чтения насосом мусора и вылечен отдельно.
Парный pop_sfx_start() в ui_end оставлен: он идемпотентен и чинит вход в
меню при закрытом выводе (например сразу после загрузки уровня).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Насос CBL идёт из прерывания (и его запрос защёлкивается до подтверждения —
проверено по MAME irqack_cb), поэтому сам звук главному циклу не нужен. А
вот ПОТОКОВЫЙ трек — титульная тема играется кольцом — дочитывается с диска
только в pop_music_service: страница ложится в слот, который насос уже
прошёл. Этот вызов был лишь в игровом цикле, HOF и сценах, но не в меню и
не в фейдах, а там главный цикл стоит секундами — кольцо опустошалось, и
музыка замолкала до закрытия меню (жалоба пользователя).
Добавлено: ui_wait_frame() в pop_menu.c (обслужить музыку + ждать фронт,
заменил все ожидания кадра в меню) и pop_music_service() в fade_run и
transition_ltr (pop_ui.c) — темп тот же, 50 раз в секунду, как в игровом
цикле.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PV_MAGIC_LEAD двигал жест заклинания (замах, шаг назад, вспышка) на 100
тиков (1,67 с) раньше сценария: сцена была render-bound, шла ~49 тиков/с
вместо 60, а реплика играла по реальному времени — кода приходила раньше
молнии. После перевода сцены на единые часы и блочную отрисовку подгонка
стала вредной: молния била больше чем на секунду РАНЬШЕ коды (проверка
пользователем). Ставим 0; константу оставляем на месте — если запись
другого набора (mt32/ogg) разъедется, крутить надо её.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. СЦЕНА С ДЖАФАРОМ — ОДНИ ЧАСЫ, КАДРЫ ЛУЧА. Было две шкалы, и выбор между
ними делался гонкой на старте сцены: одна выборка pop_snd_tick через кадр,
«успел ли диск раскрутить звук». От прогона к прогону сцена шла то по
тикам насоса CBL, то по кадрам луча, и кода реплики приходилась каждый раз
на другое место картинки (наблюдение пользователя). Насос был нужен
потому, что кадр рисовался дольше своего интервала; теперь отрисовка
разложена по интервалам (pv_restore_bg), и счёт кадров честен — ветка
насоса убрана целиком.
2. ПОДКАЧКА ТРЕКА — ПОЛСТРАНИЦЫ ЗА ШАГ (pop_music_load_step). 8 КБ ≈ 16 мс
влезают в кадровый интервал, целая страница (33 мс) не влезала и
растягивала кадр сцены. В сцене шаг остаётся безусловным (иначе реплики
не успевали грузиться, memory pv_music_stall_regression) и оплачивается
ровно одним интервалом.
3. КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ. Оригинал (seg003:387, play_level_2) не
сменяет уровень, пока `check_sound_playing()`: экран пройденного уровня
живёт с анимацией факелов, пока звучит трек. Мы уходили на смену сразу и
обрывали мелодию на первых нотах. Теперь ждём большего из двух:
pop_music_busy() и счётчика pop_endmus_left по длине записи
(gen/pop_music_ticks.h) — второе нужно потому, что при ВЫКЛЮЧЕННОЙ музыке
busy ложен, а оригинал выдерживает паузу и молча.
Стартовый уровень возвращён на 1 (отладочный LEVEL=14 был только для замера).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Катсцены шли ~8,2 fps вместо десяти. Делитель тут ни при чём: у оригинала
cutscene_frame_time = 6 тиков по 1/60 с (reset_cutscene, seg001:527; его
зовёт load_intro прямо перед сценой) = 100 мс, у нас 5 кадров луча по 50 Гц
= те же 100 мс. Причина в том, ГДЕ отсчитывался интервал: сначала рисовали
кадр целиком, и только потом ждали vsync и ещё четыре — то есть отрисовка
ПРИБАВЛЯЛАСЬ к делителю. Полноэкранная gfx_copy_page стоит ~547 000 тактов
= 1,27 кадра, отсюда 6+ кадров вместо 5 (замер PV-RENDER-BOUND: 49 тиков/с
вместо 60).
Теперь отрисовка разложена на блоки, каждый из которых заведомо влезает в
кадровый интервал, и после каждого честно ждём vsync:
фон верхняя половина -> vsync | фон нижняя половина -> vsync |
актёры и декорации -> vsync (+ флип) | служебный блок (звук, подкачка
трека) и добор до CUT_FRAME_VSYNC.
Фон восстанавливаем только по картинке (200 строк с POP_YOFF), а не по всем
256: сверху и снизу чёрная рамка. Общий хелпер pv_restore_bg на все три
цикла — cut_run (сцены 8/9/12 и финал), pre_room_animated (2_6/4/12 и
time_expired) и intro_pv_draw_frame (сцена с Джафаром); последний теперь
возвращает 3 кадра вместо 1 (13 вместо 11 со вспышкой), вызывающий их и так
учитывал.
Сверка делителей с SDLPoP: у всех сцен 6 тиков = 5 наших кадров; плавает
только pv_scene (6 -> 8 -> 7, seg001:434/455) — это уже сделано кумулятивно
через pv_seq_period + POP_T60, и минимальный бюджет (5 кадров) больше трёх
съедаемых блоками.
Замер в MAME пока НЕ сделан: до финальной сцены на отладочном старте
LEVEL=14 не добраться (решётка перед комнатой 5 закрывается по таймеру, а
Ctrl-комбинации через MCP-мост до игры не доходят).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Корень двух багов SprPoP (KBD-STUCK-WAIT, частично GRAB-KBD-TIMING) нашёлся
в исходниках DSS (docs/sources/Estex-DSS): обработчик прерывания DSS живёт в
IM1 по 0x0038 и ПЕРВЫМ ДЕЛОМ делает `CALL KEYSCAN`, а тот вычерпывает FIFO
SIO досуха. Наш трамплин проверял «есть ли клавиатурный байт» один раз, на
входе в прерывание, а хвост кадрового пути уходил в DSS — значит скан-код,
прилетевший позже, доставался DSS и уезжал в его буфер. Rx-overrun при этом
НЕ взводится (байт не потерян железом, а прочитан не тем владельцем) — отсюда
и загадка исходного диагноза: бит залип при `_kbdraw_overrun == 0`.
Измерено в MAME (брейки + totalcycles): окно 738 тактов (~34 мкс) на каждом
кадровом прерывании, из них 481 такт — пролог самого DSS. Поэтому проверка
FIFO перед chain'ом снимает лишь треть и не годится (пробовали, кражи
продолжались); кадровый путь при открытом raw теперь заканчивается приватным
RETI, окно = 0. Цена: на это время у DSS замирает опрос мыши и мигание
текстового курсора — зафиксировано в <kbd_raw.h>.
Пойманный случай (старая сборка): DSS прочитал 0x74 (make стрелки «вправо»)
при _kbdraw_pending = EXT, то есть посылку E0 74 разорвало пополам между
двумя владельцами канала.
Проверка: брейк на входе KEYSCAN с условием «страница точно DSS + raw открыт»
до правки срабатывал мгновенно (50/с), после — молчит; положительный контроль
на нашем RETI срабатывает сразу.
Трамплин 300 -> 310 Б (буфер W2-копии поднят 336 -> 384, запас 74 Б);
размерный эталон обновлён: +10 Б у программ, линкующих IRQ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dist/SprPoP/** — снимок готового к раздаче каталога (exe + упакованные
ассеты) от старой раскладки. Ничто его больше не собирает: `make`
кладёт результат в build/, образ — в build/hdd/. В dist/ остаются
только ИСХОДНИКИ README (README.txt / README.ru.txt), из которых
Makefile печёт README на образе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замороженная roomtest: убраны рабочие заметки прошлых сессий
(NEXT_SESSION.md, «new bugs») и скриншоты уже закрытых багов;
в Makefile — PROF=0 по умолчанию и путь к HDD-образу.
applications/PoP/docs/keys.txt — раскладка управления оригинального
PoP (справочник для порта).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пройденная игра доходила до таблицы рекордов и умирала: программа
исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила
в reset. Одинаково из Flex Navigator и из голого DSS.
КОРЕНЬ. pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked. Пока
pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после
переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в
пустой хвост чужого банка. Процессор полз по 0xFF до 0x0000, где ловушка
DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3,
которую было видно на трупе, оказалась уборкой, а не причиной.
Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка
MAME на узком участке: trace включалась брейкпоинтом на входе в
pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56).
ЧТО СДЕЛАНО
* pop_ui.h/.c — группа text_*_mapped помечена __banked.
* toolchain/check_bank_calls.py — две проверки банкового кода:
1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено
намеренной поломкой);
2) указатель на данные своего банка, отданный в чужой (эвристика по
форме кода, только предупреждает).
Встроена в app.mk, запускается сразу после линковки.
* pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10
и после пометки __banked уезжал из-под ног чужому банку, заливая экран
знаками вопроса.
* libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды
нумпада в EXT-половину карты. Лечит залипание стрелок (потерянный
префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем),
заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз.
* pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и
gfx_wait_vsync: без этого F10 в геймплее не работал вовсе.
* pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с
оригиналом записано в docs/impl_diff.md).
* Экран версии показывается только через Menu/Settings/About: стартовый
показ и Ctrl+V убраны, мёртвый код снят.
* sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с
выросшим за уровень максимумом полоса HP моргала между страницами.
Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md
(FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY),
правило про банки — в applications/SprPoP/CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
На готовом HDD-образе вместо двух файлов оказывались два КАТАЛОГА с их
именами, и README лежал внутри каждого. Виноват генератор аргументов
упаковщика:
$(foreach f,$(DISK),$(word 1,$(subst /, ,$(f))):$(BUILD_DIR)/$(f))
Он безусловно брал первое слово до слэша как имя каталога. Для BG/bg.arc
это верно, но у записи БЕЗ слэша первое слово — всё имя, и README.TXT
превращался в README.TXT:build/README.TXT, то есть «каталог README.TXT,
файл внутри». Теперь префикс подставляется только при наличии слэша;
причина записана в комментарий, чтобы следующий файл в корне не наступил
на то же самое.
Заодно имена: README_E.TXT и README_R.TXT. Язык суффиксом, а не
расширением — прежний README.RUS в 8.3 укладывался, но терял .TXT, и
просмотрщик не открыл бы его как текст.
Проверено чтением готового .chd через chdman + mtools, а не по логу
сборки: баг был именно в том, что попадает на диск.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слева оставалось 6 свободных точек, справа 36 — блок выглядел прижатым к
краю. Содержимое шириной 278 точек, сдвиг на +15 делает поля 21 и ~20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README, который едет НА ДИСК рядом с программой, в двух версиях: README.TXT
(английский) и README.RUS (русский). Краткое описание, запуск, полная
раскладка управления, читы, файлы игры, чего не хватает против оригинала,
благодарности.
Русский обязан быть в CP866 — DSS и местные просмотрщики читают именно её,
UTF-8 показался бы кракозябрами. Исходники лежат в dist/ как UTF-8 (чтобы
читались в репозитории), перекодировка и CRLF делаются правилом Makefile.
ПОРЯДОК В КОНВЕЙЕРЕ ВАЖЕН: sed ставит CRLF ДО iconv — BSD sed в UTF-8
локали отказывается работать с байтами CP866 («RE error: illegal byte
sequence»), а с валидным UTF-8 работает. iconv БЕЗ -c намеренно: потеря
символа должна ломать сборку, а не молча портить текст.
ЭКРАН CONTROLS переписан в две колонки. В одну раскладка больше не
помещалась: после фаз A-C клавиш стало вдвое больше, а по высоте есть
только 200 точек вместе с заголовком. Слева игра (бег, лазание, бой),
справа служебное и читы; высота блока считается по ДЛИННОЙ колонке.
Строка «IN A FIGHT» — заголовок, а не клавиша: ниже неё те же стрелки и
Shift означают другое, и без разделителя список читался бы противоречиво.
Заодно экран перестал врать: там до сих пор висели P, I, U и F7/F8 —
клавиши, переназначенные ещё в фазе A.
Проверено в MAME: Backspace открывает меню, Settings -> Show key bindings
показывает обе колонки целиком.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три остатка плана keys_plan.md записаны в TASKS_OPEN.md с полным разбором,
чтобы не выводить его заново.
KEYS-D (осмотр соседних комнат) — главное, что стоит помнить: у SDLPoP это
три строки, потому что там drawn_room влияет ТОЛЬКО на отрисовку, а физика
ходит через get_tile(room, col, row) с явной комнатой. У нас наоборот —
карта коллизий грузится для ОТРИСОВАННОЙ комнаты: room_fg, lcol_fg,
rcol_fg, above_fg, below_fg это ОДИН комплект на программу. Уведи cur_room
к соседу, не трогая kid_room, и Кид считает столкновения по чужим тайлам.
Задел под расхождение уже стоит (kid_room отдельной переменной,
update_kid_render_dx со сдвигом ±140), но enter_room_side пишет обе разом —
это незакрытая часть S3 straddle.
Записаны оба пути с ценой: честный (правка ядра, дни) и смотровой режим с
остановкой игры (150-250 байт, часы) — плюс что главный риск не в
отрисовке, а в возврате.
KEYS-R (воскрешение) — четыре места, которые обязаны знать про окно
неуязвимости, со ссылками на seg-код.
KEYS-F12 (скриншот) — «возможно, когда-то», по пометке пользователя.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shift+S — одна единица HP (малое красное зелье), Shift+T — поднять потолок
HP (большая склянка), Shift+W — медленное падение (зелье пера). Механика
у всех трёх в движке уже была: читы просто ставят те же переменные, что и
подобранное зелье.
СВЕРКА С SDLPoP дала деталь, которую легко проглядеть: ветка ЧИТА и ветка
ЗЕЛЬЯ различаются. Зелье в seg006:1871 зовёт stop_sounds, а чит в
seg000:838 — нет. Повторяем чит, а не зелье. Исключение — перо: там
stop_sounds сидит внутри самой feather_fall(), поэтому остаётся.
Вторая деталь: Shift+T НЕ проверяет, полное ли HP, — потолок растёт всегда
(упираясь в POP_MAX_HITP = 10, как max_hitp_allowed оригинала). У Shift+S
условие hitp_curr != hitp_max есть и сохранено.
Модификаторы разведены: Shift+S делит скан-код с Ctrl+S (звук), поэтому
требует отпущенного Ctrl; Shift+T делит с голым T (таймер), но тот сам
требует отпущенного Shift.
Проверено в MAME по памяти, а не на глаз: Shift+T 3/3 -> 4/4, Shift+W
взводит pop_feather в 148, Shift+S на подпорченном отладчиком HP 2/4 -> 3/4.
R (воскрешение) НЕ входит: это не ещё один чит, а правка модели смерти —
окно неуязвимости, размазанное по кадровой цепочке (счётчик в seg003:512,
пропуск пик и челюстей в seg000:1245, пропуск урона мечом в seg000:876,
восстановление позы в seg006:1352). Задевает гейт «мёртв», про который
memory pop_level_restart_scope прямо предупреждает. Отложено по решению
пользователя.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Приводим клавиши к keys.txt (раскладка SDLPoP). Работа делится надвое, и
здесь только то, что не трогает игровую логику: переназначения (A) и три
мелкие функции поверх готовых механизмов (B). Читы, требующие правки
логики, и осмотр соседних комнат — фазы C и D, отложены.
ПЕРЕНАЗНАЧЕНИЯ (A)
U -> Shift+I переворот экрана; U отдан «комнате сверху»
Esc -> Backspace меню; Esc остаётся дублёром, как у SDLPoP
F7 / F8 -> - / + ±минута, основной ряд и цифровой блок
- / + -> Ctrl+- / Ctrl++ обход комнат (наш отладочный телепорт)
I -> Ctrl+I бессмертие
P -> Ctrl+P режим скорости
1 и 2 -> Ctrl+F стоп-кадр, теперь одной клавишей
F10 в игре -> Ctrl+Q вторая клавиша выхода; F10 ловится глобально
Плюс новое на готовых путях: Ctrl+A — рестарт уровня, Ctrl+V — версия
сборки (функция была, её показывал только старт), Ctrl+D — отладочная
строка (тот же тумблер, что в Settings; POP.CFG не пишем), Home / Page Up —
дублёры диагональных прыжков (у SDLPoP это не отдельное действие, а те же
Up+Left / Up+Right, поэтому просто добавляются к стрелкам).
Все наши сверхштатные клавиши ушли под Ctrl, чтобы не занимать голые буквы
из раскладки, и вписаны в keys.txt отдельным разделом. Ctrl+B намеренно
не занята: keys.txt держит её под «вернуться в комнату Кида» (фаза D).
ФУНКЦИИ (B)
Space «сколько осталось» (seg000:612). Не печатает сама: поднимает тот
же pop_show_time, которым пользуется автоматическое объявление
минут, и строку собирает time_msg() — «59 MINUTES LEFT» и «11
SECONDS LEFT» остаются в одном месте.
T постоянный показ таймера. Переиспользует поле DBG_F_TIME
отладочной строки, своего рендера нет. У верхней полосы теперь
три состояния, и отслеживается РЕЖИМ (0 нет / 1 таймер / 2 всё),
а не флаг: переход «таймер -> полоса» тоже перерисовывает всё.
Ctrl+R возврат в заставку — тот же переход, что «Restart Game» в меню.
ДВЕ ЛОВУШКИ, найденные по дороге
Модификатор обязан входить в САМО значение, а не в условие блока: с
`if (ctrl) { nav = ...; nav_prev = nav; }` при отпускании Ctrl кромка
застревала ненулевой и следующее нажатие глохло.
Один скан-код на два чита: Shift+I и Ctrl+I — это 0x43 в обоих случаях.
По той же причине ±минута требует ОТПУЩЕННОГО Ctrl (иначе сработает и
время, и обход комнат), а T — отпущенных Shift и Ctrl (Shift+T отдан
«добавить HP» в фазе C).
Обработчики положены в pop_frame_ui (банк 8), а не в резидент: там куча
всего 308 байт. Проверено в MAME: Ctrl+D поднимает строку
«Level 1, Room 1, Speed: NORMAL...», T — один таймер 59:30 без подписей,
Space — watchpoint на pop_show_time ловит запись значения 2 (именно
обработчик клавиши, автообъявление пишет 1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Банк 9 дошёл до 98,5% (247 свободных байт), и следующая заметная правка
в menu/status/ui упёрлась бы в потолок. Критерий переселения тот же, что
у остальной раскладки, — ЧАСТОТА ВЫЗОВА, а не размер: POP.CFG и POP.HOF
работают раз за партию и упираются в диск, где один `open` стоит 51 мс,
так что трамплин банк→банк на их фоне не существует.
Отдельный банк заводить не пришлось: в десятом лежал один pop_pal.c на
485 байт, то есть 3% страницы. Новой страницы в образе не появилось.
банк 9: 16137 (98,5%) -> 11948 (72,9%), свободно 247 -> 4436
банк 10: 485 ( 3,0%) -> 4674 (28,5%), свободно 11710
Переезд безопасен, потому что все публичные функции обоих модулей
помечены __banked: трамплин выбирается по пометке в объявлении, а не по
банку, и непомеченная функция пережила бы переезд только внутри своего
банка. pop_settings — обычный глобал в W2, виден отовсюду.
Следующий рычаг, если понадобится: pop_menu (4745 байт, 29% банка 9,
работает только при открытом меню). Трогать нельзя pop_ui, pop_status,
pop_music и pop_timer — они в кадровом пути.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три места, где нажатие раньше не работало или работало наполовину.
FADE. fade_run() был замкнутым циклом на 2,13 с без опроса клавиш, а на
стыке экранов их два: пропуск работал внутри сценария, но не между его
шагами, и заставка ощущалась невыключаемой. Добавлены прерываемые
варианты (pop_ui_fade_*_skip, обёртки в pop_pal) — отдельными функциями,
а не флагом в прежних: в меню паузы и на переходе уровня прерывать
нечего, и менять там поведение молча не следует. Прерванный fade всё
равно доводит палитру до конца, экран не остаётся на промежуточной
ступени. Подключено в интерпретаторе сценария, сцене с принцессой,
четырёх катсценах cut_*, титрах и таблице рекордов.
ПРОЯВЛЕНИЕ ПОЛОСАМИ. pop_screen_present_ltr() была void и нажатие
ГЛОТАЛА: полосы схлопывались, картинка появлялась целиком — и всё,
вызывающий о нажатии не узнавал. На заставке это выглядело как
«клавиша срабатывает наполовину». Теперь возвращает признак прерывания,
и он проброшен по маршруту: первый экран истории, титры финала, логотип
между «свадьбой» и титрами.
Везде считается КРОМКА нажатия от входа, как в сценах: клавиша, которой
закончили предыдущий экран, ещё зажата, и принимать её за новое нажатие
нельзя — иначе весь маршрут заставки схлопывался бы сам собой.
F10 — НЕМЕДЛЕННЫЙ ВЫХОД, откуда угодно. Проверка стоит в kbd_idle(), а
этот хук висит на gfx_wait_vsync, то есть вызывается везде, где программа
ждёт кадр: заставка, титры, fade, проявление полосами, меню, игра. Одна
точка вместо десятка по циклам ожидания. Флаг pop_quit_req резидентный —
взводится и читается без трамплина из любого банка. Проверка в НАЧАЛЕ
витка автомата обязательна: заставку прерывает любая клавиша, и без неё
F10 успевал уронить программу в загрузку уровня перед закрытием.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.
Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).
Раскладка:
src/ рукописный C (roomtest.c -> sprpop.c)
gen/ генерируемые заголовки, в репозитории
assets/orig/ оригинальные данные игры, вне репозитория (копирайт)
assets/packed/ то, что ложится на диск, в раскладке диска
tools/ конверторы; все пути — в одном tools/paths.py
build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/
Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею. Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.
Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.
Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой
make host-tests переключён на SprPoP.
Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии. Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Строки дорисовывались уже после полосового перехода: он копирует страницу
акселератором, а тот читает ОЗУ-копию, куда GFX_BANK_SPRITE (NOSHADOW +
TRANSPARENT) не пишет. Банк GFX_BANK_TRANSPARENT даёт ту же прозрачность
0xFF, но обновляет и копию, поэтому страницу можно собрать целиком до
показа — как offscreen оригинала (draw_full_image + show_hof, и только
потом transition_ltr).
Проверено в MAME: на промежуточных кадрах перехода строка рекорда видна в
уже проявившейся части экрана вместе с логотипом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регрессия 4086dde: тот коммит перенёс шаг постраничной подкачки «в паузу
кадра» — внутрь цикла ожидания тика насоса — и одновременно ввёл защиту от
промотки (snd_want подтягивается к snd_done). Вместе это убило подкачку:
паузы в этой сцене почти нет, цикл ожидания выходит сразу, шаг не
вызывается. Реплики Джафара не успевали загрузиться к своему тику,
pop_music_play() молча ничего не делал, и после первой реплики сцена шла
в тишине.
Замер в MAME (адреса статиков pop_music.c — от public _pop_mus_page по
смещениям из .sym): до фикса ld_next стоял на нуле 20 секунд при активной
загрузке; после — 11 страниц из 11, mus_ready=1, и звучат все три реплики
m50 → m53 → m52.
Фикс: один безусловный load_step в кадре сцены; шаг в паузе оставлен, он
бесплатный, когда время есть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pop_hof.c был написан, но никуда не вызывался: после финала автомат уходил
мимо него в title, а на титрах экран получался чёрным. Теперь это хвост
end_sequence и шаг show_title между credits и attract-demo, как в оригинале.
Два корня пустого экрана:
- story.pal — 256 записей, и запись 0x3F (цвет глифов шрифта) в ней чёрная:
текст рисовался, но был не виден. Генератор кладёт туда золотой 0xB7;
- полосовой переход копирует страницу акселератором, а тот читает ОЗУ-копию,
куда прозрачный блит текста не пишет. Строки рисуются после перехода,
прямо в видимую страницу.
Попутно:
- s5 в архиве PV — фон таблицы (рамка story + логотип на y=24, HOF_POP);
- отдельный индекс палитры под фон текстовой рамки: финал красит его в
#800000, титры оставляют #100060 (load_title_images(bgcolor)). Прежний
ремап в индекс 9 подменить было нельзя — им нарисована сама титульная
картинка;
- второй набор глифов в font.atl цветом 0x3E: у SDLPoP шрифт маска и
show_hof_text рисует текст дважды разным цветом, у нас цвет запечён в
пиксели. Каталог SPA1 держит счётчик в байте, поэтому набор обрезан по '_';
- общий pop_screen_present_ltr(): им теперь пользуются story-переход интро,
титры, таблица и титульная картинка финала. Заодно вернулся пропущенный
шаг show_title — экран с логотипом и Jordan Mechner между «свадьбой» и
титрами (BUGS_CLOSED#cutscene-ltr).
Формат POP.HOF — 6 записей, имя до 15 символов, версия 2. Проверено в MAME
на сборке LEVEL=14: HAIL → титульная картинка → таблица с полосой ввода и
временем справа → ввод имени → титульная картинка; на следующем запуске
таблица показывается между credits и demo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Последний неозвученный кусок игры. 115 секунд и 1,2 МБ в память не влезают
никак, поэтому тема играется кольцом из шести страниц (96 КБ = 9 с): насос
идёт по кругу, а pop_music_service дочитывает файл в те страницы, которые
насос уже прошёл. Страница звучит 1,5 с и читается 33 мс — запас
сорокакратный; дистанция считается без отдельных счётчиков, потому что
страница это ровно 128 блоков насоса.
Три вещи, всплывшие при живой проверке:
- кольцо обязано сниматься при любом обычном запуске трека, иначе
следующая тема играет по кругу первых шести страниц (поймано на титрах
сразу после победы);
- живые сцены комнаты принцессы теперь открывают CBL сами (cut_begin):
pop_ending_show глушит звук первым действием, и «arrived to princess»
не звучал вовсе;
- тема дослушивается до конца с возможностью прервать клавишей — как
seg001:637. У оригинала там стоит ввод имени в таблицу рекордов; когда
он появится, ожидание переедет за него (задача HOF-ENTRY).
Проверено в MAME на сборке LEVEL=14: после встречи с принцессой звучит
тема победы (id 56, кольцо 6), курсор уходит далеко за размер кольца —
подкачка успевает. Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обе задачи выполнены, но SND — не так, как планировалась: музыка пошла не
на AY, а тем же PCM через CBL. Исходные постановки свёрнуты в details,
сверху — что фактически сделано и какие грабли попались.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ПРОМОТКА. Подкачка следующей реплики (страница — 33 мс) и любой тяжёлый
кадр оставляли часы насоса впереди расписания, и цикл гнал кадры без
единого ожидания, пока не наверстает: пламя факелов мелькало, а события
соседних тиков слипались в один кадр — заявка на звук перезаписывала
заявку, и створка ворот или дверь покоев пропадала. Отсюда же ощущение,
что музыка до появления Джафара тянется дольше нужного.
Долг больше не наверстываем — тот же принцип, что у pop_pace_end: якорь
ставится по факту. Плюс сама подкачка перенесена ВНУТРЬ паузы кадра: 33 мс
диска укладываются в ожидание (кадр сцены — 133 мс) и к длительности кадра
не добавляются.
GAME PAUSED писалось поверх полосы, не очищая её: если в строке HP висело
сообщение игры («60 MINUTES LEFT», «QUICKSAVE»), две надписи ложились друг
на друга. Полосу теперь чистит pop_status_wipe.
Host-тесты: 15 наборов. Проверено в MAME: сцена с принцессой идёт ровно,
принцесса проявляется вместе с комнатой, GAME PAUSED чистое.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_main занимал 3658 байт резидента, и добрая половина этого — события «раз
в партию». Вынесены целиком:
- pop_level_change — смена уровня (катсцена, гашение, загрузка, палитра,
звук): 61 строка, наружу осталось одно условие и вызов;
- pop_frame_ui — меню, F10 и QuickSave/Load: 58 строк, плюс туда же уехали
edge-состояния клавиш и сами коды клавиш (снаружи они больше не нужны).
_CODE 24102 -> 23626, запас W2 вырос с 86 до 559 байт.
Заодно три правки по замечаниям:
- затемнение фона под меню считалось числом «2», а шкала ступеней выросла
с 4 до 32 — фон почти не гас. Теперь доля от шкалы (POP_PAL_DIM_BLACK/2);
тем же прошлись по остаткам pop_pal_apply(4);
- в катсценах персонажи появлялись только после fade in: первый кадр
рисуется ПОД чёрной палитрой, и картинка проявляется целиком, вместе с
актёрами (cut_scene_8/9/12_short/ending, pre_room_animated,
intro_pv_animated);
- отладочная полоса по умолчанию выключена.
Host-тесты: 15 наборов. Проверено в MAME: меню открывается и закрывается,
фон под ним затемнён, полосы нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Watchpoint на pop_mus_req поймал виновника: заявку ставил pop_sfx_play с
id 27, а звал его loose_shake — звук дрожащей плиты.
Классический sdcc_z80_cmp_store_a_bug. Было:
do { id = prandom(...) + 20; } while (id == last_loose_snd);
last_loose_snd = id; pop_sfx_play(id);
собиралось как
ld a, e / add a, #0x14 ; A = 20..22 — номер сэмпла
sub a, (hl) ; A = РАЗНОСТЬ, номер потерян
ld (_last_loose_snd), a ; сохраняем разность
jp _pop_sfx_play ; играем разность
То есть в звук уходил не сэмпл тряски, а id минус предыдущий id. Пока
такие «номера» попадали в пустые слоты набора, это молчало; с приходом
музыки мусор вида 22-251 = 27 стал запускать ТРЕК — отсюда музыка на
прыжке с уступа и на падающей плите.
Обход тот же, что в pop_status и pop_cdraw: записать ДО сравнения и играть
перечитанное из памяти. Проверено по сгенерированному asm.
Вторая линия обороны: pop_sfx_play принимает музыкальную заявку только в
диапазоне оригинала (24..43). Случайный мусорный id теперь молчит, а не
играет минуту музыки.
Проверено в MAME: тем же бегом, что раньше ловил заявку 27, watchpoint
больше не срабатывает. Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три места, все — наши заявки на треки, поставленные без условий оригинала.
Вступление первого уровня (25) оказалось не «звуком при приседе», а
автоматом (seg005:02EB): пока need_level1_music не ноль, control_crouched
НЕ читает управление — Кид сидит, играет тема, и только когда она смолкла,
он встаёт. Мы играли трек при любом первом приседе, и тема догоняла игрока
посреди уровня: пробежал, спрыгнул, присел — заиграла. Теперь автомат
портирован целиком, а «ещё звучит» спрашивается у курсора насоса
pop_mus_left (если музыка выключена, курсор нулевой и Кид просто встаёт).
Демо-уровень: оригинал молчит музыкой и там, где играет её в игре. Смерть
Кида — прямое условие `current_level != 0 && != 15` (seg006:1366), убитый
страж — отдельная ветка «беги из комнаты» без звука (seg006:1929). Плюс
общий гейт в pop_music_service: заставочная демка озвучена одними
эффектами, и любой новый музыкальный повод (меч, зелье) звучал бы там, где
оригинал их не играет.
Гейт живёт в банке, а не в главном цикле: в резиденте W1 оставалось
27 байт.
Host-тесты: 15 наборов. Проверено в MAME: на старте первого уровня тема 25
отыгрывает целиком, курсор доходит до нуля.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Шестнадцать ступеней на 2,13 с давали различимую лесенку (0,13 с на шаг).
Тридцать две меняются каждые 66 мс — это уже слитное затухание. Запас на
них есть: ступень стоит 487 тысяч тактов, тридцать три съедают около сорока
кадров из ста шести, остальное цикл ждёт луча.
Длительность каждого fade остаётся оригинальной (128 тиков), поэтому сумма
«fade in + сцена + fade out» совпадает с SDLPoP без правки самих сцен.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Затемнение длилось около восьми секунд вместо заказанных двух. Причин
две, и обе измерены в MAME.
Первая: пересчёт палитры звался на КАЖДОМ кадре — сто с лишним раз за
fade, хотя ступеней всего восемь. Теперь пересчёт идёт только при смене
ступени, остальное время цикл просто ждёт луча.
Вторая: сам пересчёт стоил 2,44 млн тактов — почти шесть кадров.
Разложение показало, что заливка палитры через BIOS тут ни при чём
(136 тысяч на восемь вызовов). Съедали два цикла на C: 768 умножений
uint16 на канал и побайтовое копирование снимка из страницы шрифта.
Умножения заменены таблицей яркости на стеке (256 сложений, без единого
умножения), копирование — memcpy, то есть LDIR. Итог: 487 тысяч тактов,
в пять раз меньше.
Освободившийся запас потрачен на плавность: ступеней теперь шестнадцать
вместо восьми. Длительность fade считает POP_FADE_FRAMES — из 106 кадров
(2,13 с оригинала) вычитается то, что съедает сам пересчёт.
Host-тесты: 15 наборов. Проверено в MAME: заставка проходит цепочку
кадров штатно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ДЛИТЕЛЬНОСТЬ. fade_in_1/fade_out_1 у оригинала — 64 шага палитры по два
тика, то есть 128 тиков = 2,13 с каждый; сцена перед уровнем 2 идёт с ними
около семи секунд. Наши четыре кадра укладывались в восемь сотых секунды,
и сцена выходила втрое короче. INTRO_FADE и TITLE_FADE теперь POP_T60(128).
Ступеней яркости стало восемь вместо четырёх (7/8, 3/4, 5/8, 1/2, 3/8,
1/4, 1/8, 0 — каждая парой сдвигов, умножения на Z80 не нужно): растянуть
четыре ступени на две секунды значило бы получить четыре скачка яркости.
МУЗЫКА. Прошлый коммит отдавал трек доигрывать уже в игре — так делает
оригинал. На слух вышло хуже: музыка спотыкается, потому что загрузка
уровня не даёт насосу долить блок вовремя (у DOS-версии такой проблемы
нет). Дослушиваем под чёрным экраном, до отрисовки уровня — тем более что
следующий load_intro у оригинала всё равно начинается с ожидания тишины
(seg001:681). Пропуск сцены обрывает и музыку.
Заодно: WAIT перед PV пересчитан (584 вместо 707 — наш fade больше не
короче), а тема титров пускается ПОСЛЕ fade_in, как в show_title.
Host-тесты: 15 наборов. Проверено в MAME: плавное появление титров.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ТАЙМИНГИ. Треки заставок между уровнями длиннее самих сцен (27 — 10,7 с
против 2,6 с картинки), и оригинал их не обрывает: load_intro гасит экран
и возвращает управление, а звук продолжает играть поверх загрузки уровня
и первых секунд игры (seg001:690). Мы глушили CBL на выходе — музыка
обрывалась на полуслове. Больше не глушим.
«ВРЕМЯ ВЫШЛО». Оригинал (seg001:04D3) не зовёт ни одного init_*: на
экране пустая комната и ОСТАНОВИВШИЕСЯ часы (state 7, струйки песка
нет). Мы показывали готовую композицию с принцессой и полными часами —
противоположное по смыслу. Теперь это живая сцена без персонажей,
102 кадра, и трек 36 дослушивается под чёрным экраном, как в оригинале.
ПЕСОЧНЫЕ ЧАСЫ. Сцена «времени мало» (cutscene_12) шла статической
композицией, куда упаковщик запёк res953 — ПОЛНЫЕ часы. Отсюда и
наблюдение «чем меньше времени, тем полнее часы»: картинка была одна и та
же независимо от таймера. Сделана живой (cut_scene_12_short: принцесса
стоит правее, через два кадра оборачивается), часы берутся от
pre_hourglass_state. Заодно: при state 7 струйка песка не рисуется.
МУЗЫКА — СВОЙ ТУМБЛЕР. Settings -> MUSIC, Ctrl+M и статус в debug bar.
Флаг pop_mus_want отдельный от звукового: музыка у нас поток с диска, и
выключают её по другим причинам, чем эффекты; выключение обрывает текущий
трек, эффекты продолжают звучать. Строка-заглушка GAMEPLAY PROFILE
(«VANILLA ONLY») уступила место MUSIC — на десятую строку экрана не
хватает. Подписи тумблеров в debug bar сокращены до S:/M:/I:.
БАГИ. После меню персонаж оставался невидимым до первого движения:
снимок слота продолжал утверждать, что кадр на странице уже нарисован.
Общая pop_cd_forget() теперь зовётся после меню, QuickLoad и переворота
экрана. «QUICKLOAD» мигал через кадр — pop_status_show_now снимал бит
видимой страницы, а её успевало переписать восстановление комнаты.
Host-тесты: 15 наборов. Проверено в MAME: debug bar, выход из меню.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Все ресурсы Кида теперь зовутся kid.*: kid.arc (атласы), kid.pal
(палитра), kid.ani (кадры + seqtbl). Расширение .ani говорит о
содержимом: это не «какие-то данные», а таблица кадров и байткод
последовательностей движения.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Звуки 24..43 в наборе оцифровки пустые — в оригинале это Adlib-музыка, и
в digisnd её нет. На этом и построено подключение: pop_sfx_play для звука
с нулевой длиной кладёт номер в pop_mus_req, а разбирает заявку
pop_music_service() — один вызов на кадр из любого цикла (игра, интро,
катсцена). Всё чтение с диска живёт там.
Ждать полной загрузки джингла нельзя — это фриз посреди игры. Поэтому
pop_music_stream читает первую страницу (33 мс) и сразу пускает трек: она
звучит 1,5 с, а следующая читается те же 33 мс. Остальные доливаются по
одной за кадр.
Расставлено по местам оригинала: смерть (24/28/32), вступление первого
уровня и тень шестого (25), сцены перед уровнями (27/35/40), встреча с
Джафаром (29), зелья (30/33/39), время вышло (36), меч и смерть стража
(37), смерть Джафара (43), конец уровня (41/32), встреча с принцессой
(26). Темы «раз за уровень» сбрасывает pop_music_level_start.
Упакован 21 трек (2,4 МБ). Не озвучен только финал: won — 115 с и 78
страниц EMM, ему нужен кольцевой стриминг (задача MUS-WON на доске).
Проверено в MAME: вступительная тема первого уровня отыгрывается на
старте (курсор дошёл до конца, id 25). Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ТАЙМИНГИ. Пользователь заметил, что заклинание Джафара не совпадает с
музыкой. Замер в MAME (breakpoint на вспышке + курсор трека): реплика m53
успевала ДОИГРАТЬ до молнии, хотя по шкале оригинала между входом Джафара
и заклинанием ровно 822 тика = 13,7 с.
Причина: пейсинг считал НАШИ ожидания vsync, а не прошедшее время.
Полноэкранная копия страницы стоит больше кадра луча, и разница копилась.
Кадровые прерывания для счёта не годятся (теряются в di-окнах
акселератора), поэтому часами стал насос CBL: он идёт от расхода буфера
железом, 85,4 Гц, и ему безразлично, чем занят главный цикл. Один тик
оригинала = 57/40 тика насоса (0,07 % ошибки, без деления в кадре).
Когда звук выключен, работает прежний путь по кадрам луча.
После фикса замер даёт 229 блоков остатка m53 против расчётных 233 —
расхождение 47 мс.
АРХИВЫ. Группы ресурсов сложены в PBA1: kid (58 атласов), pv (78),
shadow (32), bg (25), title (20), guard (10), vizier (5), skel (4). Было
232 файла на образе, стало 8 архивов плюс шесть палитр и таблицы уровней.
При цене `open` 51,4 мс против 32,6 мс за чтение 16 КБ это возвращает
секунды на каждой загрузке.
- pop_pack_arc.py печатает индексы элементов константами (ARC_<GRP>_<FILE>),
порядок задаёт Makefile, а серии код проверяет статически;
- atlas_load разделён: atlas_attach принимает уже прочитанную страницу,
поэтому чтение из архива не дублирует проверку магии и подготовку W0;
- имена архивов живут в pop_arc.c и адресуются номером. Строковый литерал
лежит в rodata своего банка, и указатель на него из другого банка после
переключения W3 показывает на чужие данные — ровно так падала загрузка
фона (поймано брейкпоинтом на puts).
Проверено в MAME: заставка, интро и уровень 1 собираются из архивов,
Тень грузит все 32 страницы. Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Добавлены треки 54 (тема титров), 50/53/52 (три реплики сцены с
принцессой и Джафаром) — заставка звучит целиком, как в оригинале.
pop_music: два слота EMM. Реплики идут встык, паузы под загрузку нет,
поэтому следующая читается в НЕ играющий слот, а play подменяет
резидентную таблицу страниц. Своей копии таблицы слот не держит — её
хранит блок EMM. Плюс постраничная загрузка (load_begin/load_step):
страница стоит 33 мс, четверть логического кадра сцены, и подкачка
посреди анимации не видна.
Тайминги: все длительности сцен — в тиках оригинала (60 Гц), перевод в
кадры луча делает POP_T60. Пока сцены были немыми, разбег в 20 % был
незаметен; с музыкой реплика кончалась раньше картинки. Переведены
титры, история, хвост после PV и пейсинг самой PV-сцены.
Паузы, которые оригинал отмеряет концом сэмпла, теперь равны реальной
длине наших записей: конец m50 на тике 846, вход Джафара 1046, уход
2073, конец сцены 2500 (было 1959). Fade перед PV сдвинут так, чтобы
полная темнота наступала в тот же момент трека, что у SDLPoP: их fade
длиннее нашего на 123 тика, наш полосовой переход длиннее на 16.
Финальная сцена: убраны песочные часы — end_sequence_anim не трогает
hourglass_state, а reset_cutscene его обнулил. Уровень пройден, время
больше не идёт.
Проверено в MAME: цепочка 54 -> 55 -> 50 -> 53 -> 52 отыгрывается
целиком, интро доходит до демо-режима. Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
СИМПТОМ: затемнение (fade) хрипело — одинаково с играющей музыкой и в
тишине. Ключевое наблюдение пользователя: повтор тишины обязан звучать
тишиной, значит дело не в недоливе буфера.
ПОИСК: отладочные клавиши, каждая делала ровно один кусок fade. Ожидание
кадров — чисто; чтение палитры, запись палитры и 512 вызовов
bios_get_place() (видео вообще не трогает) — скрежет во всех трёх.
Последнее и решило: виновата не палитра, а ЛЮБОЙ вызов BIOS.
ПРИЧИНА: `rst 8` раскрывается в `out ($7C),a`, который включает системное
ПЗУ и перестраивает окно 0 (MAME sprinter.cpp, update_memory: m_pages[0] +
m_bank_view0.select). ПЗУ ложится ПОВЕРХ страничного регистра, поэтому
запись в порт 0x82 из прерывания бесполезна — OTIR вычитывает ПЗУ и
отдаёт его в звук. Отсюда же старое правило «глушить CBL на время
загрузки файлов»: причина была не в том, что ESTEX долго занимает CPU.
РЕШЕНИЕ (идея пользователя): качать через W3. Он управляется только
портом 0xE2, подмену из прерывания никто не перекрывает, а BIOS во время
нашего ISR не исполняется — окно возвращается до выхода, и для него
подмена невидима. После этого BIOS безопасен везде.
* pop_sfx.c — насос берёт взаймы W3 вместо W0, чтение по 0xC000 + смещение.
* pop_ui.c — буфер палитры по фиксированному 0x4000 (эти 256 байт DSS
занимает только при загрузке программы): 256 байт со стека долой.
* libbgi/common/gfx_pal_write.c — запись палитры прямо в видеопамять,
минуя BIOS. Писалась как обход скрежета, после переноса насоса не
нужна; оставлена как более быстрый примитив (2,5 тыс. тактов на 64
цвета против 10,8 тыс. у BIOS) с честной шапкой. Адресация разобрана
по исходникам BIOS (FUNC_SCREEN.ASM): Port_Y = индекс цвета, адрес
0xC3E0 + pal*4, порядок R/G/B/Y.
* libc/video/pal_get.c, pal_load.c — в шапках зафиксировано, что BIOS
выбирает окно ПО АДРЕСУ БУФЕРА (`BIT 7,H`).
* pop_intro.c — при пропуске интро клавишей не глушился CBL, и следующая
загрузка уровня шла с открытым буфером; добавлен pop_sfx_pause.
* pop_ctrl.c — убраны отладочные «осторожные шаги» на J/L (эмуляция
Shift+стрелка для MAME), у них и стоял TODO.
Разбор целиком — docs/sound_plan.md §5. Бюджет: _CODE 23966, куча 245 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ЗАМЕР, из которого всё выросло (MAME, 2026-08-25): `open` стоит 51,4 мс,
а чтение 16 КБ из уже открытого файла — 32,6 мс. То есть 61 % времени
загрузки уходит на ОТКРЫТИЕ, а не на данные, и чистая скорость чтения —
491 КБ/с, а не 260. На диске игры 343 файла; одна только стартовая пачка
(kid+shadow+bg+sound+guard, ~142 файла) — это 7,3 секунды чистого open.
* toolchain/pop_pack_arc.py + pop_arc.c/h (банк 8) — формат PBA1:
заголовок 512 Б, магия, count, записи по 4 байта (offset в СЕКТОРАХ —
архив бывает больше 64 КБ; size в байтах — ресурс всегда <= 16 КБ,
он обязан лезть в EMM-страницу). Таблицу держит вызывающий на своём
стеке: в W2 её класть нельзя, а стека в точке загрузки израсходовано
75 байт из 1279 (замер там же).
* libc: bank_read_page — чтение из УЖЕ ОТКРЫТОГО файла в EMM-страницу.
bank_load_file переписана через неё, поэтому дублирования работы с W3
не осталось и резидент почти не вырос.
* Набор эффектов: 10 файлов -> SND\snd.arc, один open вместо десяти.
* Музыка: трек лежит ОДНИМ файлом и читается порциями по странице —
нарезка на куски больше не нужна. Этот же путь понадобится финалу
`won` (1,2 МБ), который в память целиком не влезает.
* Менеджер звука приведён к оригиналу: играющая музыка участвует в
таблицах приоритета наравне с эффектами (seg000:1672 + data.h:433).
Почти вся музыка НЕперебиваема, поэтому поверх неё эффект не стартует —
ровно как в SDLPoP; раньше эффект её перебивал.
* ALLOCS 3000 -> 6000 (решение пользователя): сборка +20 с, резидент
-161 байт. Куча 87 -> 248 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Путь C из sound_plan.md вместо ранее выбранного A (ноты спикера на AY).
Причина смены: появились готовые записи DOS-версии, и строка «нужен
синтезатор на хосте», из-за которой C считался дорогим, отпала —
осталось `ffmpeg -ac 1 -ar 10937 -f u8`. Оцифровки музыки в самой игре
нет вовсе: digisnd*.dat содержит только эффекты, музыка лежит MIDI под
Adlib, так что конвертировать ресурс игры всё равно было не из чего.
* toolchain/pop_pack_music.py — запись flac -> сырой u8 на 10 937,5 Гц
(частота CBL, чтобы её не переключать никогда), нарезка на куски
MUS\m<id>_<nn>.bin по 16 КБ: bank_load_file читает файл только целиком
и только в одну страницу. Каталог -> pop_music_tbl.h; длина трека
хранится ПОРЦИЯМИ по 128 байт — 169 КБ в uint16 не влезает, 1350
блоков влезает (32-бит арифметики на Z80 избегаем).
* pop_music.c (банк 9) — загрузка трека в EMM и курсор; pop_sfx_fill
(резидент) получил третий источник: эффект важнее музыки, музыка
важнее тишины. Эффект музыку не сбрасывает — её курсор стоит, пока
тот доигрывает, и она продолжается с места.
* story_1_absence звучит на первом экране истории, где была тишина.
Грузим ПОСЛЕ сборки картинки: чтение идёт через ESTEX, и при открытом
CBL насос не успел бы долить блок.
Проверено записью звука из MAME: корреляция огибающих с эталоном 0,836
при сдвиге 1,8 с, RMS 22,6 против 18,3 (разница — 8-битное квантование).
Эффекты двери в PV-сцене после трека звучат по-прежнему.
Бюджет: резидент +83 Б кода и +25 Б данных, запас W2 151 Б (кучи в
приложении нет, malloc не слинкован); банк 9 занят на 88 %.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Порт show_time() целиком: «N MINUTES LEFT», «N SECONDS LEFT» (у
единственной секунды свой total=12, как у оригинала), «TIME HAS
EXPIRED!». Флаг pop_show_time — это is_show_time: взводит и само ядро
таймера (круглые пятёрки, каждая секунда последней минуты), и старт
уровня, и читы времени. Значение 2 = «перебить текущую строку», как
оригинал делает в последнюю минуту и после читов.
* Отладочная полоса показывает оставшееся время ##:## у правого края.
На табло уходит minutes-1: rem_min у оригинала — НОМЕР идущей минуты, а
не остаток целых (старт 60 при rem_tick 719 = «почти 60:00»). Секунды
считаются делением раз в 12 кадров, а не каждый кадр — это единственное
деление на кадровом пути.
* Смерть Кида по образцу оригинала: строка «Press Button to Continue» с
7-го кадра (Kid.alive > 6), 288 тиков = 24 секунды, последние 72 тика
мигание с периодом 12 и звуком 38 на появлении. Промолчал — start_game
(title), нажал Enter/Shift — рестарт уровня. Авто-респавна по таймеру
больше нет. Отпускания клавиш оригинал не ждёт, а мы ждём: Shift у нас
клавиша действия — см. impl_diff.md.
* POP_APP_PLAYING принимает EV_RESTART_INTRO: start_game зовётся прямо из
геймплея, раньше это событие принимал только pause menu.
* expired() загейчен уровнем 13 (seg000:910). Время на уровне Джафара
идёт до открытия двери, но КОНЧИТЬСЯ игра там уже не может — у нас
этого гейта не было, и партия завершалась.
Бюджет: _CODE 23979, куча 263 Б. Host-тесты: 15 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* pop_status_level() — порт show_level (seg008:25A8): «LEVEL N» на старте
уровня. Демо-уровень 0 и номера от 14 молчат, тринадцатый показывается
двенадцатым, бесшовный переход 12->13 пропускается и гасит флаг за собой.
Первое сообщение с ПАРАМЕТРОМ: строка собирается вручную (dec2, без
printf и без деления), номер хранится снимком st_arg — иначе вторая
страница дабл-буфера нарисовала бы другое число.
* pop_status_show_now() — печать немедленно, в ВИДИМУЮ страницу.
QuickSave/QuickLoad заявляют лейбл ПЕРВЫМ действием, до mem_alloc/ESTEX:
запись снимка занимает доли секунды, и раньше игрок видел сначала
необъяснённый фриз, а надпись — уже после него. Отказ переписывает
строку на NO QUICKSAVE/NO QUICKLOAD обычной заявкой.
Расхождение с оригиналом (он печатает по результату) — impl_diff.md.
Проверено в MAME: watchpoint ловит заявку внутри pop_status_show_now,
step_out возвращает в pop_qsave_process — и QUICKSAVE уже на экране, диск
ещё не тронут. Бюджет прежний: _CODE 23981, куча 266 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Эта сессия (меню + текст в служебных полосах):
* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
(UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
ABOUT. About показывает тот же текст, что стартовый Sprinter screen,
минус строка про клавиши — общий about_text(), чтобы экраны не
разъехались. CONTROLS собирается таблицей и центрируется по
фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
(SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
а не пункт меню. POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах. Нижняя:
порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
24 тика). Верхняя отладочная переведена с палочек на текст
«Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
своим форматированием чисел (без printf и без деления).
Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
не растёт, весь рендер в банке. Бюджет после правок не изменился
(_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
не блитятся вовсе, а вход в комнату заливает только игровое поле
(pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
статус-строке: геометрия, семантика text_time_total как идентификатора
сообщения, мигание, рестарт по истечении 36/288.
Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
menu_settings_plan.md:
- §10 переписан: выбран Вариант A — текстовые строки + собственный
растровый рендерер в новом банке; референс SDLPoP (hc_small_font /
hc_font — один рендерер, два шрифта); шрифт как ассет из паковщика,
прототип MS4 — системный CP866 ZG; двуязычность eng/rus через пару
(таблица строк CP866, файл шрифта)
- Restart Level / Restart Game выполняются сразу, без подтверждения
(§3, §8, из §11 убраны диалоги RESTART *?)
- §13 MS4: текстовый рендерер + два шрифта; §14: host-тест рендерера
quicksave_plan.md: статус «РЕАЛИЗОВАНО и проверено в MAME»
(v0.6-pop-quicksave), документ оставлен справочником по формату 'POPQ'
TASKS_OPEN/TASKS_CLOSED: запись QSAVE переехала в закрытые с полным
протоколом; docs/README.md аннотации обновлены
roomtest:
- QuickSave/QuickLoad (F6/F9): снапшот 'POPQ' v3 в POP.SAV/POP.BAK на HDD,
транзакционная запись (POP.NEW -> rename, откат при ошибке), XOR-контрольная
сумма payload'а; сериализация всех игровых переменных через W0-примитивы
pop_qs_*; pop_qsave_process() на границе кадра вне Char-окон
- pop_qsave_restore_room(): полная перезагрузка комнаты после загрузки
(карта/края/швы, сброс bake-кэша, перерисовка обеих страниц, инвалидация
кэшей спрайтов и HP)
- сериализаторы в pop_map/pop_loose_mob/pop_trob/pop_guard_ai
(+ восстановление инвариантов: mobs_live, trob_drawn, redraw)
- immortal-чит 2 уровня: уровень 2 поглощает только малый урон Kid'а
libc/libbgi:
- bank_load_file()/bank_save_file() — резидентное файловое I/O в банк,
без правила W3 (путь читается до переключения страницы)
- gfx_w0_page_prepare(page) — подготовка W0-окна (IRQ/NMI-стабы) одной
функцией; atlas_load.c и roomtest переведены на новые примитивы;
ручные ISR-стабы удалены
sprinter-cc / сборка:
- --bank N=FILE.c: автогенерация n_banks (_n_banks_auto.c), ручные
const n_banks удалены из тестов
- roomtest/app.mk: ресурсы через stamp-файлы (.resource-stamps/) — один
запуск упаковщика на группу вместо N под -B; HDD_PACK_ARGS
pop_tile.c был крупнейшим жильцом резидента (5972 Б кода). Целиком он не
уедет — его const-таблицы читают банки 2, 3, 7 и 8, а таблица в чужом банке
не видна. Поэтому разрез, а не перенос.
Отбор ЗАМЕРОМ, а не по смыслу: каждая функция посчитана брейкпоинтом-
счётчиком в MAME — сколько вызовов в кадре покоя и сколько в кадре полной
перерисовки комнаты (форсируется читом +/-). Порог — пик не больше 3.
Проверено на ДВУХ тайлсетах, подземелье и дворец: pop_mem_b рисует
композитный кусок и мог оказаться дворцовым, но и там 0 вызовов.
Уехало: pop_mem_b, pop_cd_hit (+hit_rect), pop_cd_hit_slot, pop_cd_init,
pop_cd_clear, pop_t_win_set/clear, pop_bar_black, pop_heal_off,
pop_potion_flask, pop_room_set_above/below.
Осталось: pop_blit_b со статиками (184 вызова на редрав), pop_cd_touch
(198), pop_tile_code (296), pop_wall_modifier (101), pop_env_b (73),
pop_tile_mod (70), все таблицы. pop_fore_set_clip оставлен намеренно —
88 Б не стоят отказа от прямого вызова из банка 4.
Цена трамплина замерена: 252 такта пролог + 84 эпилог + ~50 у вызывающего
= ~410. Это ~1000 тактов на кадр покоя (0,2 % работы) и ~3700 на редрав.
blit_b_clip перестал быть static и объявлен в _pop_tile.h: вызов
банк -> резидент прямой, трамплин не нужен, поэтому статик горячей половины
переносить следом не пришлось.
Итог: _CODE 23716 -> 21928, свободно 747 -> 2535 Б (с 129 Б до всех работ).
Проверено в MAME на уровнях 1 и 4, с переходами комнат.
Метод замера, таблица частот и ловушка с данными банка — docs/resident_budget.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вертикальный скролл перестал ходить через строку-буфер на стеке: колонку
читаем с Port_Y=ys, пишем с Port_Y=yd, а STOP между read- и write-триггером
делает промежуточный OUT Port_Y безопасным. Один проход вместо
grab→blit-через-буфер.
Справочник libc приведён в соответствие (там же строка про новую точку
входа cbl_open_silence).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/cbltest не попал в правку API (мой греп обрезался на артефактах
сборки, полный make его и поймал).
Эталон размеров: cblstream −614, cbltest −617, cblwav −592 — это ушедший
malloc. bgi_img +229 к моей правке отношения не имеет (CBL он не линкует
вовсе): рост пришёл с b3e754a, реверта «каталог атласа читается из W0».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Линкер тянет .rel целиком, поэтому malloc/free, стоявшие в мёртвой ветке
CBL_UNDERRUN_SILENCE внутри cbl_open, приезжали в резидент КАЖДОМУ
приложению — включая те, что льют тишину сами и кучей не пользуются.
Разведено:
cbl_open(freq, fmt, pump, fill) — ничего не аллоцирует;
cbl_open_silence(freq, fmt, pump, fill) — аллоцирует буфер тишины;
_cbl_open_raw(...) — общее тело.
Параметр underrun_mode из публичного API убран: режим задаёт выбор функции.
cbl_close больше не зовёт free — иначе malloc возвращался бы тем же путём.
Буфер тишины живёт до выхода из программы и переиспользуется; его размер
запоминается, иначе открытие 16-бит после 8-бит писало бы memset'ом мимо
выделенного куска. _cbl_open_raw указатель на буфер не трогает вовсе —
иначе cbl_open после cbl_open_silence терял бы уже выделенную память.
В дереве режим SILENCE не использовал никто: все три вызова (cblwav,
cblstream, PoP) передавали CBL_UNDERRUN_APP.
Итог для PoP: _CODE 24329 -> 23716 (−613 Б), свободно в резиденте 129 ->
747 Б. make size-check чистый (67 программ), звук в MAME проверен —
насос отработал 696 запросов, pop_snd_ok/want = 1/1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Возврат к состоянию после звуковых правок (c6828c0). Отменяются 3545826 и
9025573 целиком: --bank-data=SRC в sprinter-cc, его включение в PoP и
docs/resident_budget.md.
Что выяснено и почему откат, а не доводка. Перенос писучих данных
банкового модуля в его 16-КБ страницу даёт цветной мусор блоками и уводит
DSS. У pop_trob причина найдена: pop_trob_modif() возвращает указатель на
room_modif[24][30], и его разыменовывают банки 2/3/7 и резидент — то есть
пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода. Def/Ref-анализ такое
не ловит: снаружи ссылки на символ нет, есть ссылка на функцию, отдающую
его адрес.
Но и один pop_room, у которого явной утечки указателя найти не удалось,
ломается так же — значит механизм понят не до конца. Пока не понят,
включать нельзя. Нулевая инициализация при этом ни при чём: mkexe -p 0
проверен по образу (прогон нулей 14304 Б, самый длинный прогон 0xFF — 14).
Место в резиденте искать другими путями: malloc (287 Б, требует раздельных
cbl_open для APP и SILENCE) и код pop_tile (5972 Б).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом: через несколько комнат живого прохода перезагружался DSS.
pop_trob_modif() возвращает указатель на room_modif[24][30], а зовут её из
банков 2, 3, 7 и резидента. После переноса массив лежит по 0xC000+ в
странице банка 6, но разыменовывает указатель ЧУЖОЙ код — когда замаплена
его собственная страница. Значит чтение и запись идут поверх кода соседнего
банка.
Анализ Def/Ref такое не ловит: снаружи нет ссылки на символ, есть ссылка на
функцию, которая отдаёт его адрес. Условий для кандидата два, и второе
проверяется только чтением кода — ни один указатель на статику не должен
уходить наружу.
pop_room оба условия проходит (_mobs не читает никто; bake_copy статическая;
atlas_load(&pop_env[i]) берёт адрес глобала из резидентного pop_tile.c).
Остаётся −1620 Б: данные в W2 6774 -> 5157, свободно 129 -> 1746 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sprinter-cc: новый повторяемый --bank-data=SRC — писучие данные ОДНОГО
банкового модуля в его же страницу. Прежний --bank-data был всё-или-ничего
и потому неприменим: у большинства банковых модулей часть глобалов читают
соседние банки и резидент (hitp_*, pop_upside, pop_loose_modif, pop_cd), и
такие данные обязаны остаться замапленными всегда.
Кандидаты отобраны по объектным файлам, а не на глаз: символ должен быть
Def только в своём .rel и нигде не Ref. Прошли ровно двое — pop_room
(_mobs не читает никто) и pop_trob (экспортируемых данных нет вовсе).
При любом --bank-data sprinter-cc сам добавляет mkexe -p 0: crt0 зануляет
только резидентный _DATA, а mkexe по умолчанию бьёт пустоты 0xFF — иначе
вся банковая статика поднялась бы мусором.
Итог: данные в W2 6774 -> 4228, свободно до стека 129 -> 2675 Б.
BANK7 87 %, BANK6 31 %. Проверено в MAME: уровень 1, комнаты 1-2, фон,
факелы, решётки, проваливающиеся полы, переход между комнатами.
Метод замера и оставшиеся кандидаты — docs/resident_budget.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверка на сцене 9/9 (кнопка 1,8; челюсти 1,2; ворота — в комнате 4,
тайл 1,9) вскрыла три расхождения.
1. Гейт слышимости стоял как «ворота в текущей комнате», а у оригинала
(play_door_sound_if_visible, seg007:1239) слышны ещё и ворота в комнате
СЛЕВА, если они в колонке 9; и НЕ слышны в колонке 9 своей комнаты; плюс
особый случай «уровень 3, комната 2». Сцена 9/9 — ровно первый пункт,
поэтому спуск решётки молчал. Подъём совпадал, потому что звук открытия
идёт без гейта — эта асимметрия и была подсказкой.
2. Потерян звук 7 «решётка встала»: gate_stop (seg007:05E3) зовётся из трёх
мест animate_door и каждый раз играет его через гейт. У нас во всех трёх
стояло только type = -1.
3. У trigger_button оригинала есть параметр playsound, нулевой в трёх
местах (вход на уровень, выход Джаффара, зелье «открыть»). Добавлен.
Щелчок кнопки слышен через раз — это НЕ баг: prio 0x66 против 0x10 у
челюстей, а укус занимает 465 мс из цикла 1229 мс (замерено). Разбор с
цифрами — sound_plan.md §12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь услышал расхождение с SDLPoP: у нас решётка обрывалась
приземлением Кида, в оригинале доигрывает до конца, а приземления не
слышно. Оказалось, упущен целый механизм.
play_sound (seg000:12C5) НЕ играет, а только номинирует кандидата на
кадр — из нескольких выживает важнейший (меньше prio = важнее, при
равенстве последний). play_next_sound (seg000:1304) раз в кадр решает,
запускать ли: можно, только если ничего не играет ЛИБО текущий помечен
перебиваемым и новый не менее важен. Иначе номинант выбрасывается —
очереди в оригинале нет.
Отсюда всё, что слышно: gate_closing_fast неперебиваем и доигрывает
целиком; челюсти (prio 0x10) всегда важнее решётки (0x32), поэтому
решётка звучит только в паузах между укусами.
Таблицы из SDLPoP с учётом fix_sound_priorities (в его config.h он
определён безусловно). Створка двери уровня — единственная запись,
правимая на ходу, вынесена в отдельный байт. Добавлен пропущенный
stop_sounds на завершении открытия двери (seg007:455).
Проверено записью MAME: старт уровня 1 был 135+210 мс (решётка, обрезанная
на 80 мс), стал один всплеск 455 мс с корреляцией огибающей +0,889 со
звуком 6.
Заведён BUG-SND-FIRSTRUN: искажение первого эффекта при первом запуске
после загрузки системы — вероятно, лечится _cbl_prime, но проверить можно
только на железе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Две разные болячки, обе разобраны записью звука MAME в WAV.
libc: буфер CBL железо не чистит, а запись в порт управления сразу пускает
воспроизведение с нулевого слота — первые 256 сэмплов (23,4 мс) уходит то,
что лежало раньше. Своими данными звук идёт лишь с третьей половины:
прерывание приходит на 128-м слоте и ставит указатель на противоположную
половину. _cbl_prime заливает буфер тишиной сразу после включения (раньше
нельзя — запись проходит только при поднятом bit7). В MAME это немо
(эмулируемый буфер стартует нулями при двухдополнительном ЦАП), на железе
это ровно тот мусор, что ловился на тестовых примерах CBL.
PoP: на выходе по ESC звучало ровно 11 мс шума на полной громкости — один
пропущенный блок (128 сэмплов). Причина: pop_shutdown освобождал атласы и
графику через ESTEX при открытом звуке, насос не успевал долить. Звук
гасим первым действием. Проверено записью — всплеска больше нет.
Заодно записан разбор стартового всплеска (sound_plan.md §10): это не
мусор, а gate_closing_fast из левой комнаты, обрываемый soft_land. Обрыв
одноголосьем — поведение оригинала (play_digi_sound начинается с
stop_digi, seg009.c:2402).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт Ctrl+S из SDLPoP (seg000:657). Выключение реально закрывает CBL, а
не глушит сэмпл: иначе насос продолжал бы отдавать блоки тишины и платить
те же 3,26 % процессорного времени.
Флаг намерения pop_snd_want отдельно от pop_snd_ok: последний гасит
служебная пауза на время загрузки уровня, и правь Ctrl+S только его —
первая же смена уровня вернула бы выключенный звук.
Индикация: пурпурная палочка в борте, когда звука НЕТ (включённый слышно
и так, а молчание неотличимо от «нечему звучать»).
Чит S «выдать меч» был отладочным и больше не нужен — снят, клавиша ушла
под звук.
Замер цены звука — sound_plan.md §9: 8 001 такт на прерывание при периоде
245 759 (3,26 % времени), +2,2 % к работе кадра в сцене 11/15, период
кадра не сдвинулся ни разу.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
При разделении загрузки набора (pop_sfx_init) и открытия CBL
(pop_sfx_start) парный вызов start попал только в pop_level_switch.
На стартовом пути его не было — игра шла молча до первого перехода.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Набор digisnd1..3.dat приведён упаковщиком к 10 937,5 Гц (частота
железа), склеен в 8 EMM-страниц с выравниванием каждого звука на 128 —
размер блока запроса CBL, поэтому ни один блок не пересекает границу
страницы и проигрыватель не знает слова «стык».
Насос (pop_sfx.c) резидентный: его зовут из прерывания CBL, из горячих
мест физики и из play_seq. Тишину льём свою (первый блок набора), а не
через CBL_UNDERRUN_SILENCE с его malloc — куча в резиденте W2 тесная.
Открытие CBL разведено с загрузкой (pop_sfx_start отдельно от
pop_sfx_init): пока ESTEX читает файлы, насос не успевает долить блок и
железо крутит хвост буфера — на слух скрежет.
Разведены все места play_sound() SDLPoP, у которых есть оцифровка
(id 0..23, 44..49): посадки, падение, удары о стену, зацеп, тряска и
провал плит, ворота, дверь уровня, пики, чомпер, кнопки, боёвка, меч,
зеркало, скелет, зелье. Таблица соответствий — docs/sound_plan.md §8.
Музыкальные id остаются с нулевой длиной до фазы музыки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Эффекты берём из MSDOS/digisnd*.dat: 28 из 31 звука совпадают побайтно с
набором SDLPoP, но три различаются не в пользу последнего — у него
sword_vs_sword короче, sword_moving другой, а spiked вообще пустой
(7 сэмплов против 5 069).
MIDI наоборот: у MSDOS формат 0 (всё слито в одну дорожку), у SDLPoP
формат 1 с 8-9 дорожками — готовое разделение голосов, если дойдём до
пути B (MIDI -> три канала AY).
Упаковщик: 31 эффект -> 10 937,5 Гц, 131 072 Б = 8 EMM-страниц. Начало
каждого звука выровнено на 128 (блок запроса CBL), а страница кратна 128,
поэтому ни один блок не пересекает границу страницы — проигрывателю не
нужна логика стыка.
Формат оригинала проверен по convert_digi_sound: один байт на кадр (моно),
байт беззнаковый с центром 0x80 — ровно формат нашего CBL. Стерео в
данных нет, каналы размножаются на выходе.
Живой замер памяти из работающей программы: занято 124 страницы из 256
(система с exe 43, наши ассеты 81), свободно 132 = 2,06 МБ. Самый
крупный ассет теперь набор Тени — 32 страницы. Эффекты WAV займут 8.
Эффекты — WAV через CBL; музыка первым заходом путь A (ноты на AY);
заставки потом WAV; музыка по ходу игры — открыто (WAV с гашением
эффектов либо путь B).
Единую частоту берём не 11 000, а ровно частоту CBL 10 937,5: тогда тон
точен (иначе −9,9 цента), а пересчитывать три файла всё равно надо.
112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц; рост целиком от
leveldoor_sliding (источник 2 750 Гц). Взамен CBL открывается один раз и
частота не меняется никогда.
Эффекты все 31 есть в WAV (digisnd, 8 бит PCM) — просьба «не спикер, а
wav» для них уже выполнена исходным планом. mt32snd оказался НЕ музыкой,
а теми же эффектами в MIDI для Roland MT-32.
У музыки WAV нет ни в одном наборе: только ноты спикера (7 КБ) или MIDI
(27 КБ). Посчитал третий путь — рендер MIDI в WAV на хосте: игровые
треки 74,7 с = 803 КБ = 50 EMM-страниц (влезает), заставки 248 с = 167
страниц (только стрим с диска).
Только спикером во всей игре остаётся один звук — blink (4 ноты).
Замеры по ассетам: 31 эффект уже 8-битным PCM на 11 000 Гц (у CBL есть
10 937,5 — расхождение 0,6 %, формат сэмпла совпадает байт в байт, то
есть конверсии нет вовсе), 103 941 Б = 6,3 EMM-страницы.
Вся музыка есть нотами PC-спикера — 7 КБ на 57 звуков, и нота там задана
в ГЕРЦАХ напрямую (проверено по speaker_callback), а не делителем PIT,
как кажется по числам. MIDI разбирать не нужно.
Отдельный таймер не нужен: секвенсор музыки двигает CBL-callback раз в
11,7 мс, а короче 12 мс во всей музыке 2 ноты из 1469.
DBG_START_ROOM/POS подменялись безусловно, а pop_start_level зовётся и на
границе уровня. Из-за этого на 7-м стартовой становилась отладочная
комната вместо комнаты 17 из данных, и спецсобытие «вход падением»
(set_start_pos, seg003:0196) не срабатывало — переход 6->7 выглядел
сломанным.
Подмена теперь действует только на своём уровне (FIRST_LEVEL); рестарт
того же уровня отладочную позицию сохраняет, как и задумано.
Оригинал кладёт спрайт дважды — прозрачным блитом в x и XOR-блиттером в
x+1; пакетный блит так не умеет, поэтому результат запечён упаковщиком.
Две половины, как и у оригинала: sk* — кадры вне боя (спрайты Кида),
sf* — кадры 150..189 (SHADOW.DAT, тоже графика Кида). 251 спрайт,
32 EMM-страницы, палитра 16 цветов в 0xA0..0xAF.
Закрывает BUG-SHADOW-SET: раньше тип 4 уходил в guard_names, и Тень в
бою дралась серым стражем.
Грабля: kid.pal заливает все 256 записей и затирает слоты Тени —
палитра вынесена в pop_shadow_pal_apply рядом с pop_bg_pal_apply.
Проверено в MAME на 6-м уровне: силуэт с контуром, как в оригинале.
Поправка к вчерашнему выводу «в бою Тень рисуется спрайтами стража».
Набор берётся из cur_frame.sword>>6 (seg008.c:1752), chtab_base жёстко
равен Киду. Тень идёт через chtab_5, но chtab_5 — это «соперник уровня»,
и на 12-м это SHADOW.DAT: графика КИДА в боевых позах, палитра побайтно
равна палитре Кида. Пользователь прав — Тень всегда выглядит Кидом.
Наш pop_guard_load уводит тип 4 в guard_names, SHADOW.DAT в ассетах нет
вообще — заведён BUG-SHADOW-SET.
Пересчитал палитру на правильных наборах: 251 кадр, 45 цветов; 16 цветов
гибридом дают 76 грубых промахов на все кадры (было 129 на ошибочном
наборе).
XOR у оригинала идёт по 24-битному RGB, а blitters_2_or — обычный блит с
colour key 0. От фона зависит только кайма в один пиксель по левым
кромкам силуэта; на чёрном фоне запечка точна.
Замеры: 253 кадра (Кид 219 + страж 34, Тень в боевых кадрах рисуется
спрайтами СТРАЖА), 59 разных цветов. 32 цвета оставляют перцептивно
значимыми 232 пикселя из 96 746. Палитра: занято 112 слотов, свободно
144; берём 0xA0..0xBF.
1 — только бой: удары мечом не отнимают HP (ветка в hurt_by_sword).
2 — плюс мелкий урон: не проходят «минус деление» от падения с двух
этажей и от падающей плиты (pop_take_hp гасит count < 100).
Мгновенная смерть остаётся на обоих: пики, чомпер, падение с трёх этажей
и удар вне боевой стойки приходят с count = 100. В коде ровно два
значения урона, 1 и 100, поэтому граница точная, а не эвристическая.
Заодно ушёл костыль «снять бессмертие на время вызова take_hp(100)» в
hurt_by_sword — он был нужен только потому, что прежний чит глушил и
смертельный урон.
Клавиша I идёт по кругу 0 -> 1 -> 2 -> 0; в отладочной метке число
красных палочек = уровень.
Четыре блока палочками в верхнем борте, каждый своим цветом. Цвета взяты
из 0x3A..0x3F — единственного диапазона, который не перезаписывают ни
зелья (0x40), ни env/wall тайлсета (0x50/0x60), ни страж (0x90). Прежняя
метка комнаты рисовалась цветом 0x57, то есть из env-диапазона: белой она
была только в подземелье, во дворце брала цвет тайлсета.
Записи палитры — BGR (BIOS $A4), не RGB; читать kid.pal «как привычно»
нельзя, цвета выйдут переставленными.
Режимы перенумерованы: NORMAL=0, FAST=1, FASTEST=2. Тогда дефолт (crt0
зануляет _DATA) — NORMAL, обход инкрементом даёт NORMAL->FAST->FASTEST, а
номер режима + 1 = число палочек.
Период стал max(n, ceil(W)) вместо ceil(W)+2: три gfx_wait_vsync после
работы отсчитывались от её КОНЦА, поэтому бюджет кадра был один растр.
Теперь ждём от якоря начала кадра, и при n=3 бюджет 1 290 240 тактов.
Счёт кадров — программный, по биту 5 порта 0xFE (положение луча), а не по
кадровым прерываниям: те теряются в DI-окнах акселератора фазозависимо
(замер: 0..2,8 %, на полной перерисовке три подряд). Условие точности
одно — зазор между выборками меньше 64 512 тактов; точки выборки
расставлены по замеру, а не на глаз.
Режимы (pop_pace.h), клавиша P по кругу, дефолт FASTEST. Условие боя
взято у оригинала буквально (SDLPoP seg003.c:363): Kid.sword ==
SWORD_2_DRAWN, а не «идёт бой».
Проверено в MAME на 11/15: счётчик без недосчёта на 270 кадров, период
ровно 3 растра на 302 логических кадрах (ни длиннее 3,1, ни короче 2,9),
NORMAL даёт ровно 4, с вынутым мечом — ровно 5.
Замеры в MAME: кадровые прерывания теряются фазозависимо (0..3%), на
полной перерисовке комнаты — три подряд. Причина: импульс запроса 32
такта (9,14 мкс) против DI-окон блита ~0,29 мс. Счёт попаданий
брейкпоинтом на этом драйвере недостоверен (WAIT-линия), достоверен
только детектор разрыва.
Блокер включения gfx_set_fps_div как есть: счётчиковый путь ждёт через
halt и не зовёт idle-хук, то есть возвращает KBD-1.
Максимумы по секциям против эталона mob-order-B-done: работа 911 862
(-1 986), синяя 149 106 (-10 704), зелёная 436 494 (-3 924), циан 382 770
(-10 230). Период 3 растра в 2341 кадре, 4 в 23, 5 в 2 — как в эталоне.
Записано, почему сумма минусов по фазам не равна минусу по работе:
максимумы разных фаз достигаются в разных кадрах, а «работа» — максимум
суммы, а не сумма максимумов (вопрос пользователя).
Отмечено, что зелёная по-прежнему выше растрового кадра и главный
оставшийся кандидат для этой сцены — P9 (G8): сосед падающей плиты
перезапекается целиком и повторно, при шести плитах это умножается.
И записан урок процесса: прогон 13/23 обязателен после каждой правки
loose-механики — именно он вскрыл пропущенный взвод гейта в check_fall_flo,
которого не поймали ни хост-тесты, ни сцена 11/15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Нашёл пользователь на прогоне 13/23: плиты потолка трясутся, но не
падают.
Причина — моя ошибка в P5. Гейт loose_any снимается циклом по факту
прохода без живых фаз, а взводиться обязан у КАЖДОЙ записи фазы. Я
пометил пять мест и пропустил шестое: check_fall_flo, который на уровне 13
раздаёт плитам-потолкам отложенный старт (0xF0..0xFF). В результате фаза
записывалась, а цикл её не досчитывал — ровно тот отказ, который я сам
описал в комментарии к loose_any: «ложный ноль стоит застывшей навсегда
плиты».
Исправлено, и в шапку loose_any добавлено предупреждение с этим случаем:
добавляя новое место записи фазы, добавляй и взвод.
Замер 13/23 после исправления (максимумы по секциям, 2367 кадров):
эталон сейчас
работа 913 848 911 862
синяя 159 810 149 106
зелёная 440 418 436 494
циан 393 000 382 770
Период: 3 растра в 2341 кадре, 4 в 23, 5 в 2 — как в эталоне. Зелёная
по-прежнему выше растрового кадра (436 494 против 430 000).
Урок для процесса: сцену 13/23 надо прогонять после КАЖДОЙ правки
loose-механики, а не только когда меняешь её сознательно. Хост-тесты
этот отказ не поймали: phys_loose_gate_survives_room_change проверяет
возврат в комнату, а не отложенный старт уровня 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замечание пользователя: весь чомпер помещается в свой тайл, значит его
heal максимум 32x60. Проверено по каталогу атласа и подтвердилось:
нижняя челюсть 101/102 = 32x60 низом на dmy = 63*row+62, занимает
63*row+3 .. +62;
верхние челюсти дают ТОТ ЖЕ верх — подъём 0x25 при высоте 23, 0x2F при
13 и 0x32 при 10 все три упираются в 63*row+3;
кровь 114..118 шириной 6 рисуется на x+8, то есть внутри 32.
Было 64 «на всю высоту тайла» (плюс лишняя строка запаса от прошлой
правки) — стало ровно 60 от +3.
Заодно зафиксирован разбор структуры перерисовки чомпера: ОДИН heal на
тайл и ДВА блита (низ и верх). Объединить блиты нельзя — при раскрытых
позах нижняя часть маленькая (32x30, 32x21, 32x17) и между ней и верхней
челюстью разрыв: например, при позе 2 низ занимает +33..+62, верх
+3..+25, а строки +26..+32 пустые.
Проверено в MAME: чомпер рисуется чисто, хвостов от прежнего кадра нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HEAL-WIDTH: плита 64->58, чомпер 64->61, габариты из каталогов атласов.
На 11/15 медиана не сдвинулась (плит нет), максимум -960. Основной
эффект ждёт прогона 13/23, где плит шесть одновременно.
P10 разобран: «просто передать готовое из физики» не выйдет, величины
РАЗНЫЕ. char_footprint берёт габарит кадра и расширяет диапазон на
колонку под меч; set_char_collision тот же fpw корректирует на FRAME_THIN
и меч не учитывает, а ряды у него — опорный curr_row, а не верх/низ
спрайта. У оригинала обе задачи пользуются одними величинами, потому что
он считает их один раз; у нас они исторически разошлись.
Значит P10 — это сведение двух геометрий к одной, с риском для физики,
которая сейчас работает правильно. Приоритет понижен до низкого, и
записано, чего не хватает: отдельного замера самого char_footprint
(сейчас известно лишь «вход + set_clip + footprint = 10 872»).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача была помечена обязательной. Габариты сняты из каталогов .atl, а
не «по клеткам на глаз»:
плита 41/69/70 = 32x13-14 43/73/74 = 32x3 42/71/72 = 26x15-16
чомпер 101/102 = 32x60 111 = 27x23 113 = 23x10
Отсюда два сужения:
pop_loose_shake_draw ширина 64 -> 58 (свой тайл 32 + правая грань 26,
во дворце 25)
pop_chomp_redraw высота 64 -> 61 (след 63*row+3..62: верх самого
высокого bot-кадра и низ на dmy;
верхняя челюсть при подъёме 0x32
и высоте 10 даёт ровно +3)
Замер 11/15: медиана не сдвинулась (437 484 — плит в комнате нет),
максимум 550 776 -> 549 816, то есть эффект только в кадрах перерисовки
чомпера и он мал, как и предсказал пользователь. Основной выигрыш от
сужения плиты (9,4 % площади) ждёт сцены 13/23 и требует отдельного
прогона на сборке LEVEL=13.
Пики не трогал: их таблицы кадров (POP_SPIKES_FRAM_LEFT/RIGHT) я по
атласу не разбирал, а сужать heal по догадке — прямой путь к
недочищенному хвосту.
Проверено в MAME: чомпер и факелы рисуются чисто, хвостов нет; хост-тесты
зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Оценка пары P6a/P6b была -20 000, факт по P6a — -840. Записана причина:
оценку я перенёс по аналогии с лучом видимости, где трамплин звался девять
раз за кадр, а тут trob'ов в комнате всего несколько. Урок в реестре:
«тот же паттерн» не означает «тот же порядок величины».
P6b (кэш префетча, 11 058) не делался: инвалидацию пришлось бы ловить из
трёх источников (pop_level_set_tile, вход в комнату, добавление trob), а
пропуск любого даёт застывшую анимацию.
Бюджет лёгкой позиции: 437 484, до цели 7 484.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pop_trob_modif объявлен __banked, а звался он на КАЖДЫЙ trob внутри
цикла pop_process_trobs — при том что комната у них в подавляющем
большинстве кадров одна (чужие появляются только у брошенных плит
соседней комнаты). Тот же паттерн «трамплин в цикле», что дал -23 784 на
луче видимости (P2b) и -14 118 на guard_over_kid (P16).
Замер 11/15: цикл trobs 78 726 -> 74 964, работа кадра 438 324 -> 437 484.
ОЖИДАНИЕ НЕ ОПРАВДАЛОСЬ: в реестре стояло -20 000 на пару P6a/P6b, а
вышло -840. Причина простая — trob'ов в комнате всего несколько, и кэш
экономит два-три вызова, а не двадцать. Оценка была построена на
аналогии с лучом видимости, где вызовов было девять на КАЖДЫЙ кадр.
Проверено в MAME на чистом запуске: факелы, чомпер и страж рисуются
правильно, хост-тесты зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь заметил: Кид перерисовывается, хотя с пламенем не
пересекается; на пиксель левее — перестаёт.
Разбор: спрайт Кида занимает x 213..224, колонка считается как x >> 5,
и 224 — ровно первый пиксель колонки 7, где лежит метка от пламени
(y 33..50). По вертикали пересечение настоящее, по горизонтали его нет:
пламя в той же колонке занимает x 232..247, зазор восемь пикселей.
То есть P15 исправил огрубление по Y и оставил его по X.
Отложено по решению пользователя с его же аргументами: x не влезает в
байт (0..319), значит нужны 16-битные сравнения в горячем пути, а они у
SDCC z80 дороги настолько, что могут съесть выигрыш; огрубление вдвое —
лишний сдвиг при записи и проверке плюс потеря точности.
Записана непроверенная идея: хранить границы как смещение ВНУТРИ колонки
(0..31, пять бит) — байта хватит и сравнение 8-битное, но запись
усложняется для прямоугольников через несколько колонок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P4 (каталог из W0) отменён по критерию пользователя: 408 тактов не стоят
второй публичной функции в libbgi с неявным контрактом «страница уже
подключена». Знание сохранено: gfx_w0_map стоит 324, поэтому потолок
непробованной части P4 — около 2 600, а не 10 000.
P17 — по замечанию пользователя про 16 бит там, где хватает 8: границы
экрана беззнаковыми сравнениями (-378) и габариты спрайтов в uint8_t
(-276 в статике, -1 134 в циане динамики, плюс 24 байта _DATA).
Бюджет: лёгкая 438 324, тяжёлая 603 684.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замечание пользователя: спрайты наших атласов не крупнее 64x64, а w/h
почти везде были uint16_t. Это уже записано в памяти проекта
(pop_sprite_size_limits: весь игровой кадр PoP <= 56x63; больше 255 только
восемь полноэкранных подложек титров, а они через слот персонажа не
проходят).
Переведены в uint8_t: w/h, ow/oh, fpw/fph, cw/ch в pop_cdraw_t, параметры
cd_overlay_add и cd_clip_add, локали w/h/vis_w в pop_char_draw и
cd_splash, и чтение габарита из шапки ленты (было двухбайтным сложением
со сдвигом).
Эффект: лёгкая позиция 438 600 -> 438 324 (там персонажи не рисуются,
поэтому почти ничего), тяжёлая — циан 181 404 -> 180 270. Плюс 24 байта
_DATA на двух слотах.
Скромно, но код от этого не запутаннее, а честнее: тип теперь отражает
реальный диапазон. Проверено в MAME — бой идёт, хвостов и обрезков нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверка «спрайт целиком на экране» стояла как
pb_x >= 0 && pb_top >= 0 && pb_x + pb_w <= 320 && pb_top + pb_h <= 256
— четыре знаковых 16-битных сравнения, а знаковое у SDCC z80 разворачивается
в пару sbc плюс jp PO / xor 0x80 / jp P (видно в листинге).
Беззнаковая форма делает то же двумя: отрицательная координата в
беззнаковом виде становится очень большой и проваливает условие так же,
как проверка >= 0, а верхняя граница переносится в правую часть вместе со
сложением. Границы неотрицательны по построению: pb_w и pb_h не больше
255, значит 320-pb_w >= 65 и 256-pb_h >= 1.
Работа кадра 438 978 -> 438 600. Немного, но идиома стандартная и код
не усложняется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Каталог атласа теперь читается из W0 вместо переключения W3 — минус 408
на кадре при ожидании минус 5 400. Цена блита 16 107 -> 16 005.
Причина записана: 672 такта atlas_image — это почти целиком вызов
функции и арифметика idx*8, а не переключение окна; после правки работа
переехала в статью «каталог + шапка + клип» (2 694), а сам gfx_w0_map
стоит всего 324.
Отсюда понижена оценка непробованной части P4 (один map на группу
блитов): потолок ~2 600 за кадр, а не 10 000.
Бюджет: лёгкая 438 570, тяжёлая 602 574. До цели 8 570 и 172 574.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
atlas_image ради двух байт записи каталога переключает W3 туда и обратно,
хотя вызывающий сразу после этого мапит ту же страницу в W0 — и каталог
там доступен по тому же смещению. Новый atlas_image_w0 (libbgi) читает
его из W0; в pop_blit_b порядок стал «сначала gfx_w0_map, потом каталог».
ОЖИДАНИЕ НЕ ОПРАВДАЛОСЬ. По раскладке блита atlas_image стоил 672 такта,
и я рассчитывал снять их целиком: 8 блитов зелёной фазы это 5 400 за кадр.
Фактически цена блита 16 107 -> 16 005 (-102), на кадре -408.
Причина: 672 — это почти целиком вызов функции и арифметика idx*8, а не
переключение окна. Замер после правки: gfx_w0_map 324, «каталог + шапка +
клип» 2 694 — работа просто переехала из одной статьи в другую.
Правку оставляю: она не вредит, убирает лишнее переключение W3 и делает
контракт честнее (страница мапится один раз). Но как способ снять
накладные блита она не работает — фиксированная часть 6 765 -> 6 663.
Замеры: лёгкая позиция 438 978 -> 438 570; тяжёлая 602 574 (прошлый замер
617 487 снят до P16, поэтому напрямую не сравним).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fore-проход Кида в тяжёлой позиции (89 268) разложен зондами:
вход + set_clip + char_footprint 10 872
арифметика границ окна 3 786
шов ворот + overlay-цикл 3 294
ЦИКЛ fore_tile ПО ТАЙЛАМ 67 854 76 %
gate_over_char + хвост 3 462
Счётчик показал, что цикл обходит ВСЕГО 4 тайла, и 3 из них реально
рисуют. То есть 67 854 — не перебор лишних тайлов и не проверки, а цена
самих блитов переднего слоя: около четырёх блитов по ~16 000, где 6 765
на каждом — фиксированная накладная.
Значит отдельной оптимизации fore-прохода почти нет: срезать можно цену
блита (P4, ~27 000 из 67 854), char_footprint из физики (P10) и слияние
двух трамплинов в банк 2 (~4 000).
P4 поднят в очереди: он бьёт и по fore-проходу (4 блита), и по зелёной
фазе (8 блитов) — то есть работает и в динамике, и в статике.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Записаны, чтобы не повторять, и с разбором причины.
1. cd_sig_same блоком (сравнение 10 байт циклом вместо 13 сравнений
полей): по листингу короче (1939 -> 1290), на машине хуже
438 978 -> 450 426. Сумма тактов по листингу считает инструкцию один
раз, а тело цикла исполняется десять раз.
2. cd_touch_pb (пометка «для блита» из file-scope вместо четырёх
аргументов): 438 978 -> 442 242. В зелёной фазе блиты идут пакетным
путём, где нужны все четыре значения, а в регистрах они дешевле, чем
чтение из статиков.
3. Обёртка pop_cd_hit_slot поверх pop_cd_hit — 1799 против 1318 тактов;
помогло только когда сравнение переехало внутрь.
Общий урок записан там же: короткий листинг не равно быстрый код, и
снятие аргументов со стека помогает не всегда.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P16 (цианные проверки) — минус 25 818 тремя правками: снимок без
построения структуры, guard_over_kid только когда кого-то рисуем,
проверка слота без пяти аргументов. Записан и отрицательный результат
внутри третьей: обёртка, которая внутри всё равно звала pop_cd_hit с
пятью аргументами, сделала хуже.
P14 уточнён: fore-проход не «62 778…89 000», а от 4 122 (персонаж
пропущен) до 117 570 (труп Кида в челюстях — широкий кадр в тайле с
передним слоем). Значит в бою он будет ближе к сотне тысяч.
Бюджет лёгкой позиции: 801 768 -> 438 978. До цели 430 000 осталось
8 978 — одна правка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pop_cd_hit принимает (p, x0, y0, x1, y1) — три последних идут стеком, и
функция целиком уезжает в IX-фрейм: 45 % её тактов на `-n(ix)` (asm).
А зовут её из cd_quiet до восьми раз за кадр.
Новый pop_cd_hit_slot(who, p) берёт координаты прямо из pop_cd, а само
сравнение вынесено в hit_rect с file-scope аргументами. Первая попытка —
обёртка, которая внутри всё равно звала pop_cd_hit — не дала ничего
(1799 Z80 вместо 1318, то есть стало хуже), и это записано здесь, чтобы
не повторять: снимать аргументы со стека нужно у ТОГО, кто их читает.
asm на путь «спрайт + накладной»: было 1799 + 2x1318 = 4435 тактов Z80,
стало 1221 + 2x1009 = 3239 (-27 %).
Замер 11/15, лёгкая позиция: синяя 219 894 -> 218 052, циан 41 280 ->
39 438, работа кадра 442 662 -> 438 978.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разложил остаток цианной фазы (44 007 на трёх вызовах):
pop_loose_mob_draw 978 гейт mobs_live работает
guard_over_kid 14 424 трамплин в банк 8 + два objtile_at_char
pop_char_skip_mask 28 605 трамплин в банк 4 + два cd_quiet
guard_over_kid отвечает на вопрос «кто рисуется поверх кого», а он не имеет
смысла, когда не рисуется никто. Перенёс вызов ПОСЛЕ pop_char_skip_mask и
сделал условным: при skip == 3 оба слота тихие, и порядок не нужен.
Перестановка безопасна: обе функции только читают, и читают разное —
skip_mask снимок cd_sig, guard_over_kid габариты pop_cd прошлого кадра.
Замер 11/15, лёгкая позиция: циан 55 257 -> 41 280, работа кадра
456 780 -> 442 662.
Проверено в MAME: статика чистая, в бою (Кид сближается и бьёт стража)
персонажи перекрываются правильно, порядок не сломался.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cd_sig_make СТРОИТ структуру из тринадцати полей в стековом кадре (то есть
через -n(ix)), и только потом шёл побайтовый цикл сравнения. А зовётся
проверка четыре раза за кадр: pop_char_skip_mask дважды, и в ней по два
слота.
Новый cd_sig_same сравнивает поля прямо с источником, с ранним выходом на
первом расхождении — у двигающегося персонажа это обычно первое же поле.
cd_sig_make остался: он нужен pop_char_draw, чтобы снимок записать.
Замер 11/15, лёгкая позиция: участок «mob_draw + guard_over_kid +
skip_mask» 47 883 -> 43 875, циан 59 265 -> 55 257, синяя 223 902 ->
219 753, работа кадра 464 796 -> 456 780.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вопрос пользователя после боя в комнате 15: Кид вытеснил стража вправо
(из-за края торчал только меч), убил — труп не появился ни в 15, ни в
соседней справа.
Это не наш баг, а сложение трёх механизмов оригинала: физика стража
работает только в полосе x 44..211, поэтому комнату он не менял;
мёртвый за Кидом не идёт (follow_guard требует alive < 0), и leave_guard
сохраняет его в прежнюю комнату; а из чужой комнаты страж не рисуется
вовсе — при Guard.room != drawn_room оригинал гасит слот (seg000:422).
Труп остаётся приписан комнате 15 с guards_x за правым краем: при
возврате восстанавливается там же, то есть вне видимого поля.
Живьём в SDLPoP сценарий не воспроизводился — вывод из чтения кода, о чём
в записи сказано прямо.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Лёгкая: 628 542 -> 464 796 (-163 746), персонажи не рисуются вовсе.
Тяжёлая: 758 358 -> 617 487 (-140 871), рисуется только Кид — он
действительно стоит под пламенем, а страж нет. Пятирастровые кадры в
тяжёлой позиции исчезли (было 27 %).
Итог восьми позиций: 801 768 -> 464 796 в лёгкой (-42 %). До цели
430 000 осталось 35 000 в лёгкой и 187 000 в тяжёлой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Две правки, обе про ложные срабатывания пропуска отрисовки персонажа.
1. МЕТКА: вместо «маска колонок по 32 px на ТРИ ряда по 63 px» теперь на
каждую колонку хранится диапазон затронутых y (ymin/ymax, 40 байт на обе
страницы). Прежняя гранулярность склеивала касания внутри ряда: пламя
факела занимает y 33..50, клинок стоящего стража — y 59..65, между ними
девять пикселей зазора, а метка считала слот задетым.
2. ПРОВЕРКА: cd_quiet сверяет с меткой спрайт и накладной (клинок, брызги)
ДВУМЯ ОТДЕЛЬНЫМИ прямоугольниками, а не объединённым bbox. Объединение
включает пустой угол между ними, и он ловил касания, которых нет: спрайт
стража лежит в колонке 8, клинок уходит в колонку 7 на y 59..65, пламя
метит колонку 7 на y 33..50 — прямоугольник «спрайт + клинок»
(x 241..284, y 46..84) цеплял метку углом.
Без второй правки первая почти ничего не дала (632 676 против 628 542 до
неё): объединённый bbox продолжал ловить ложное пересечение.
Замер 11/15:
фаза до P15 после
синяя 259 050 223 902 (heal тоже перестал платить)
зелёная 181 494 181 494
циан 194 262 59 406
работа 632 676 464 796
Проверено в MAME: в статике картинка чистая, в динамике (пробежка, бой,
переход в соседнюю комнату) хвостов и просвечивания нет. Хост-тесты
зелёные.
Заодно найден и исправлен собственный баг первой редакции: обе страницы
обновлялись по условию, проверяющему только страницу 0, и после
pop_cd_clear(0) метка страницы 1 переставала расти. Плюс pop_cd_init:
пустая колонка обозначается ymin = 255, а нули от crt0 читались бы как
«затронута строка 0».
У ОРИГИНАЛА такой метки нет вовсе: и Apple II (FRAMEADV.S RedBlockFast,
шесть буферов по блокам), и SDLPoP (set_redraw_fore) метят целыми тайлами,
но им это не мешает — персонаж у них рисуется каждый кадр безусловно.
Пропуск неизменившегося персонажа — наша добавка, поэтому и точность метки
нужна выше оригинальной.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь поймал ошибку в моём расчёте, глядя на экран: страж целиком
правее пламени, пересекаться может только меч.
Проверка по памяти машины подтвердила и уточнила:
страж, спрайт x 257..284 y 18..56
страж, клинок x 241..261 y 31..37
пламя факела x 232..247 y 5..22
Ошибок было две. Первая: координаты пламени я взял по предположению
«факел в колонке 7», а он в колонке 6 (пламя рисуется в ячейке правого
соседа). Вторая, содержательная: ФИЗИЧЕСКОГО ПЕРЕКРЫТИЯ НЕТ ВООБЩЕ — по x
клинок и пламя пересекаются, но по y между ними девять пикселей зазора.
Настоящая причина: pop_cd_touch хранит метку как маску КОЛОНОК по 32 px на
ТРИ ряда по 63 px (cd_row_of). Пламя (y 5..22) и клинок (y 31..37)
попадают в один ряд 0 и одну колонку 7 — cd_quiet считает слот задетым.
148 302 такта, 23 % кадра, за ложную тревогу.
Решение стало проще и точнее: хранить на колонку диапазон y вместо номера
ряда (10 x 2 байта x 2 страницы = 40 байт). Расчётом проверено, что это
спасает стража и НЕ спасает Кида в тяжёлой позиции — там перекрытие
настоящее, и он честно перерисовывается. Вариант с 8-пиксельными полосами
тоже работает, 16-пиксельные уже нет.
Прежние предложения (частичная перерисовка по пересечению, обрезка фона под
персонажем) записаны как НЕ НУЖНЫЕ: они решали задачу, которой нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Постановка пользователя: проверять, нужна ли отрисовка стража, когда он
не двигается. Если движется — лишние ~150 000 тактов приемлемы: в
оригинале во время боя число физических кадров на логический тоже растёт
на единицу.
Замер чтением pop_cd из памяти машины показал, насколько цена
несоразмерна поводу:
страж x 257..284, y 18..56 28 x 39
пламя (0,7) x 264..279, y 5..22 16 x 18
пересечение x 264..279, y 18..22 16 x 5
То есть пламя задевает страже только макушку — 80 пикселей, — а
перерисовывается он целиком за 148 302 такта (85 524 спрайт с клинком и
снимком + 62 778 fore-проход), это 23 % работы кадра. Пересечение при
этом настоящее: дело не в грубости маски меток, проверено числами.
В задаче записаны два варианта: A — частичная перерисовка только
пересечения (безопаснее, укладывается в контракт pop_cd), B — не рисовать
фон там, где он всё равно перекрыт неподвижным персонажем (дешевле, но
обрезанное пламя попадёт в ОЗУ-копию и heal вернёт дыру, когда персонаж
сдвинется).
Заодно уточнено, чем НЕ является P13 (вопрос пользователя): это не
перерисовка комнаты заново каждый кадр — такой вариант стоил бы порядка
3 000 000 тактов, семь растровых кадров, и оригинал так тоже не делает.
Разница в цене ПОСЕЩЕНИЯ тайла: у нас fore_tile сразу блитит, у оригинала
add_*table только кладёт запись, а рисует один draw_table в конце.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь передвинул Кида на один осторожный шаг вправо (x = 106
вместо 99, колонка та же) — его спрайт начал пересекаться с тайлом (0,3),
где одновременно чомпер и пламя факела.
фаза лёгкая тяжёлая
синяя 259 500 257 520
зелёная 181 068 180 870
циан 187 761 319 842 (+132 081)
работа 628 542 758 358
Период кадра: 4 растра в 335 кадрах, 5 растров в 123 (27 %). Это уже не
«стабильно медленно», а рывки.
Куда ушли 132 тысячи: pop_char_draw(KID) 204 -> 54 738 и fore-проход
Кида 4 356 -> 93 486. То есть Кид из «пропущен» превращается в
полноценного персонажа за ~144 000 — столько же, сколько страж.
Отсюда новая позиция P14: fore-проход персонажа, 62 778 у стража и
~89 000 у Кида, вместе около 152 000 = 20 % работы кадра. Это самая
дорогая единичная статья. У Кида он дороже потому, что в его футпринте
лежит чомпер со своим передним слоем.
P3 переведён в «частично сбылось»: выигрыш держится только пока персонаж
не подошёл к анимированному тайлу, а в игре он подходит постоянно.
Итог семи закрытых позиций: 801 768 -> 628 542, то есть -22 %. До цели
430 000 остаётся снять 199 000 в лёгкой позиции и 328 000 в тяжёлой, а
всё оставшееся в реестре даёт порядка 100 000. Арифметика не сходится —
в реестр записаны три возможных решения (P13, осознанное расхождение с
оригиналом, принять 4 растра), выбор за пользователем.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Циан 188 004 не двигался ни от P1, ни от P5, ни от P2b — разложил его
зондами.
Хорошая новость: Кид УЖЕ пропускается (204 такта на pop_char_draw), то
есть надежда P3 сбылась после P1 — метка от чомпера до него больше не
дотягивается. Страж же перерисовывается каждый кадр честно: пламя
правого факела (0,7) рисуется в ячейке (0,8), где он стоит, и реально
накрывает ему голову (пламя занимает y 5..22, страж 12..62).
Отрисовка стража — 148 302:
pop_char_fore (2 трамплина в банк 2 + обход тайлов) 62 778 42 %
клинок (sword_draw + overlay_add + clip_add) 27 522 19 %
блит спрайта + clip_char_right 20 982 14 %
загрузка кадра и геометрия 12 696 9 %
pop_clip_char_top (трамплин банк 4 -> банк 3) 8 658 6 %
снимок прямоугольника + cd_clip_add 7 890 5 %
gfx_w0_unmap + cd_sig_make 4 968 3 %
вход + cd_heal 2 946 2 %
Единственная явно лишняя статья — трамплин clip_char_top, и снять его
непросто: функции нужны get_tile и таблицы деления из банка 3, перенос в
резидент вернёт тот же трамплин внутрь. Остальное — работа, которую
персонаж действительно делает.
Зонды переставлены с уже закрытых замеров (loose_tick, физика) внутрь
pop_cdraw; оснастка снимается позицией P12, когда оптимизация закончится.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер отделил луч от pop_frame_timers: таймеры со всеми тремя
спецсобытиями уровней стоят 1 962, луч — 36 786, то есть 5,6 % работы
кадра на девять чтений байта.
Причина оказалась НЕ в алгоритме. Сверка трёх референсов:
SDLPoP (seg003:688) — идёт по x с шагом 14 и на каждом шаге переводит x
в колонку делением. Причём сам SDLPoP признаёт в комментарии, что
«DOS PoP does this: tile_div_tbl[xpos]» — то есть оригинал брал
таблицу, а порт заменил её на / и %, потому что на 32 битах так проще.
Apple II (MISC.S CHECKALERT) — тот же алгоритм байт в байт, но перевод
x -> блок через таблицу BlockTable[x]. Ровно то, что у нас уже было
сделано (POP_TILE_DIV, 2026-08-10).
mininim — другая архитектура (тайловые позиции, своя механика), для
сравнения реализации не годится.
То есть алгоритмически мы уже были на уровне Apple II, а платили за
другое: pop_tile_at объявлен __banked, луч живёт в guards.c (банк 1), и
на КАЖДУЮ колонку шёл трамплин банк 1 -> банк 3. На сцене 11/15 (Кид в
колонке 2, страж в 8) это девять трамплинов за кадр.
Сделано:
1. луч переведён на КОЛОНКИ вместо x-координат. Это эквивалентно:
начальные x — ровно центры тайлов персонажей, а обратный перевод даёт
ту же колонку (floor((58 + col*14 - 58)/14) == col). Ушли 16-битный
шаг, 16-битное сравнение и индексация таблицы на каждой итерации;
2. тайлы отрезка забираются ОДНИМ банковым вызовом (pop_row_tiles)
вместо девяти;
3. внутри pop_row_tiles — быстрый путь для отрезка целиком внутри
комнаты: get_tile при ряде 0..2 и колонке 0..9 сводится ровно к
g_fg[row*10+col] & 0x1F, идём указателем;
4. буфер тайлов — file-scope, а не локальный массив (иначе каждое
чтение это -n(ix)).
Замер по шагам: 36 786 -> 24 048 (колонки + один вызов) -> 13 002
(быстрый путь + буфер). Синяя фаза 283 215 -> 259 500, работа кадра
654 990 -> 628 542, то есть -26 448 при ожидании -30 000.
Кэш-гейт «пересчитывать только при смене позиции» НЕ понадобился:
расхождения с оригиналом нет, луч считается каждый кадр, как и должен.
Поведение проверено в MAME: страж в боевой стойке, но не идёт — между ним
и Кидом чомпер, то есть can_guard_see_kid = 1 («видит, но не пойдёт»).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разбор pop_phys_tick (61 266 тактов на НЕПОДВИЖНОМ Киде) зондами по
звеньям kid_phys:
check_collisions 33 846 55 %
хвост (spike/spiked/chomped/knock/leave/save) 16 188 26 %
check_press 4 140
check_action 2 622
loadkid_and_opp 2 148
determine_col 1 182
fall_accel+fall_speed 582
bump_into_opponent 198
Внутри check_collisions: три coll_row (сканирование рядов) — 23 256,
подготовка окна 3 240, set_char_collision 1 788, обход пересечения 5 562.
Сгенерированный asm coll_scan показал 322 такта Z80 на ПУСТУЮ колонку
(с wait-state'ами 773 — ровно замеренные 750), из них 137 (43 %) —
обращения через IX-фрейм, и четыре 16-битные операции на колонку там,
где от колонки зависит один операнд.
Сделано:
1. вся арифметика цикла в 8 битах. scan_left = x_bump[col+5] + TILE_MIDX
при колонках окна -2..11 лежит в [37, 233], wall_dl в [-1, 10],
wall_dr в [0, 13] — суммы в [36, 246], переполниться не могут.
Границы персонажа приводятся к 8 битам с клипом, и клип точен: порог
ниже 37 означает «условие не выполнится никогда», выше 233 — «всегда».
2. dst снят с IX-фрейма в file-scope (scan_dst).
ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ, не повторять: предпосчёт таблиц порогов по типу
стены (thr_l[6]/thr_r[6] на кадр) сделал ХУЖЕ — check_collisions
33 846 -> 36 570, синяя фаза +10 269. Колонок в окне четыре-пять, а типов
стен пять: кэша получилось больше, чем потребления.
Проверено на кодогенерации: register на параметре-указателе SDCC 4.5 z80
проигнорировал (asm байт в байт), а file-scope дал 607 -> 454 такта.
Итог: check_collisions 33 846 -> 30 360 (-10 %), работа кадра
657 882 -> 654 990. Крупной статьи в физике нет: остаток размазан по
десятку честных проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Позиция заводилась с НЕПОЛНЫМ диагнозом. Я приписал 190 260 тактов
пометке от факела (пламя лежит в ячейке правого соседа, то есть поверх
чомпера, и запекается каждый кадр). Правка по этому диагнозу не дала
ничего: 769 002 против 768 684.
Зонд pop_dbg_kind показал факт: все 312 перерисовок прогона — вид
POP_RD_CHOMP, полная, и ни одной от факела. Собственная пометка чомпера
просто перебивала пометку соседа.
Настоящая причина нашлась сверкой с animate_chomper (seg007:0448).
Оригинал заканчивает её так:
if ((curr_modifier & 0x7F) < 6) redraw_at_trob();
то есть перерисовывает чомпер только пока фаза меньше 6 — пять кадров из
пятнадцати. Это не оптимизация оригинала, а следствие таблицы поз:
chomper_fram1 = {3,2,0,1,4,3,3}, и с фазы 5 до конца круга поза одна и та
же. Мы метили тайл каждый кадр, пока trob жив, а живёт он всё время, пока
Кид в том же ряду — то есть платили полный draw_tile плюс heal 32x64 за
неизменную картинку в двух третях кадров.
Сделано:
1. пометка только при фазе < 6; на фазе 5 — обе страницы дабл-буфера
(она последняя рисуемая, её поза обязана лечь на обе; вторую страницу
пометка догоняет в кадре фазы 6, где поза та же — CHOMP_FRAM1[6] == 3);
2. новый вид POP_RD_CHOMP_ANIM -> pop_chomp_anim_draw: три блита графики
чомпера поверх свежего пламени, без heal и без остальных слоёв — порт
ветки redraw_frames_anim (seg008:0211), где оригинал делает ровно
draw_tile_anim_topright / draw_tile_anim_right / draw_tile_anim и
никакого wipe;
3. приоритет полной перерисовки над anim в pop_set_redraw: у оригинала
это два независимых счётчика и full побеждает, а у нас вид один на
тайл, и без проверки исход решал бы порядок trob'ов в списке.
Обе половины работают — замер даёт 40 % полных перерисовок и 60 % лёгких.
Работа 768 684 -> 657 882 (медиана), зелёная 294 510 -> 183 420. В 40 %
кадров цена прежняя: там поза реально меняется, это честная работа.
Циан не сдвинулся ни на такт, то есть надежда P3 (Кид перестанет будиться
каждый кадр) пока не оправдалась — метки продолжают его будить.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Синяя фаза (286 518) разложена зондами на 12 участков, process_trobs
(89 784) — на три.
Гипотеза, с которой я входил в замер, ОТВЕРГНУТА. Я ждал, что дорого
обходятся банковые трамплины на спецсобытиях уровней — по аналогии с
pop_clip_char_top, где трамплин ради одной проверки стоит 8 892. На деле
три спецсобытия (skel, mouse, killed_shadow) вместе стоят 1 650: они
гейтятся внутри и на уровне 11 выходят сразу.
Настоящие статьи синей:
физика двух Char 105 246 (61 266 Кид + 43 980 страж)
heal двух Char 67 734
луч видимости стража до 37 032 (вместе с frame_timers)
pop_ctrl_tick 18 648
логика стража 19 932
Физика съедает 13,7 % работы кадра при том, что ОБА персонажа стоят и кадр
позы не меняется. Цена измерена, причина нет — это отдельная позиция P2a.
Луч видимости считается каждый кадр, хотя никто не двигался: гейт по смене
позиции/комнаты — позиция P2b, ждём −30 000. heal отдельной правки не
требует, он уйдёт вместе с P1/P3.
process_trobs: префетч кодов тайлов с маппингом окна 0 — 11 058, обход
самих trob'ов ~43 000 (pop_trob_modif зовётся банковым вызовом на КАЖДЫЙ
trob, хотя комната одна), два факела ~36 000. Цена одного pop_pot_b
измерена отдельно: 17 346, и это единственная группа в распределении —
значит в кадре его зовут только факелы. Пиксели пламени 16x18 — 1 716,
то есть 10 % цены.
Заодно посчитано, достижим ли период 3 растра: снять надо 338 000, а сумма
ВСЕХ известных позиций даёт 357 000, из которых 160 000 держатся на одной
(P1). Цель достижима, но без запаса.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер 11/15 показал, что loose-механика берёт 28 872 такта в комнате, где
не анимируется ни одна плита и не летит ни один кусок. Раскладка зондами
m9..m12: два цикла по тайлам 9 852, обход 14 слотов mob 12 090, поиск
куска над головой Кида 5 868 (там ещё и банковый трамплин).
Два гейта:
loose_any (статик pop_map.c) — «идёт ли анимация плит». Ставят пять мест
записи ненулевой фазы: make_loose_fall, ветка потолка в check_press,
do_knock для обоих рядов и восстановление фазы из room_modif при входе в
комнату. Снимает его сам цикл, по факту прохода, в котором не осталось
ни одной живой фазы.
pop_mob_busy (резидент pop_state.c) — «занят ли слот падающего куска»
(active или дочистка clean). Ставит mob_alloc, снимает обход по факту
пустой таблицы. В резиденте, а не в pop_room.c, потому что читает его
pop_map из банка 3, а писучие статики банкового модуля наружу не видны.
Гейт отвечает не на «есть ли в комнате плиты», а на «идёт ли анимация»: у
лежащей плиты-потолка фаза нулевая, и крутить нечего (вопрос пользователя).
Асимметрия намеренная — ложная единица стоит одного холостого прохода,
ложный ноль стоит застывшей навсегда плиты, поэтому взвод стоит рядом с
КАЖДОЙ записью, а снятие только по факту пустого прохода.
Стало: 132 / 996 / 546, вся функция 28 872 -> 2 760. На кадре работа
801 768 -> 767 928. Ожидание по реестру было -28 000.
Покрытие: новый phys_loose_gate_survives_room_change на пятое место взвода
(фаза восстановлена входом в комнату) — единственное, которое не прогонял
ни один тест, и дающее самый тихий отказ. Мутационная проверка: со снятым
взводом тест падает (фаза 3 вместо 4).
Заодно отладочный старт сразу в целевую комнату: make ROOM=15 POS=2
(дефолт), roomtest стартует в 11/15 с Кидом в (0,2). kid_init ставит
x = x_bump[col] + TILE_SIZEX, а это левая граница СЛЕДУЮЩЕЙ колонки — с неё
физика относила Кида в тайл чомпера, и он погибал на старте (найдено
пользователем). Сдвиг внутрь на 2: колонку определяет весовая точка кадра,
поэтому число снято замером, а не выведено геометрией.
План работ между сессиями — docs/perf_registry.md §4: очередь позиций со
статусами, текущий бюджет сцены, рецепт её воспроизведения и метод замера.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Новая целевая сцена: уровень 11 комната 15 — два факела, чомпер, страж.
В отличие от 13/23 (разовый пик на каскаде плит) здесь дорога САМА
статика: Кид и страж стоят, а кадр стоит 801 768 тактов = 1,86
растрового кадра, период 4 растра во всех 866 интервалах прогона.
Фазы: синяя 293 238 (heal 141 048 + логика 152 190), зелёная 320 916
(loose_tick 28 872 + process_trobs 92 448 + redraw_needed 190 260),
циан 187 758. Впечатление «циан ~150 % кадра» не подтвердилось: за 867
кадров разброс циана 174 такта, это 0,44 растра.
Главная находка — 190 260 тактов на ОДИН тайл (pop_dbg_rdmax_tot = 1).
Пламя факела запекается в ячейке правого соседа, то есть поверх чомпера,
и process_trobs метит соседа (порт set_redraw_anim_right). Оригинал на
такую пометку рисует ТОЛЬКО слой anim, мы же отвечаем heal 32x64 плюс
полный draw_tile — со всеми слоями, которых пламя не касалось.
Заодно разложена цена одного блита фона (брейкпоинты на резидентных
адресах внутри pop_blit_b, temp0 на входе, 1603 блита): фиксированная
накладная 6 126 тактов на ЛЮБОЙ блит — пролог с IX-фреймом 810,
atlas_image 672, w0_map с чтением шапки 2 400, cd_touch 2 069, unmap 175.
У самого дешёвого блита это 59 % цены, у пламени 16x18 пиксели тянут
лишь 12 %. Причины ровно те, на которые указал пользователь:
16-битные аргументы там, где хватает 8 бит, и адресация через IX.
perf_registry.md сводит в один отсортированный список всё отложенное из
perf_green_phase (G1-G9), perf_cyan_phase (C1-C7), perf_backlog (1-7),
HEAL-WIDTH и сегодняшние находки — с пометкой замер/модель/гипотеза.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 11 комната 14 проверена пользователем визуально после варианта B —
порядок падающего куска, соседней плиты и Кида корректен. На 10 и 12
багов не найдено.
HEAL-WIDTH и G8 сведены как две половины одной темы: G8 про ширину ЗАПЕЧКИ
соседнего тайла (60 вместо 28 нужных, плюс draw_tile соседа дважды на
пометку), HEAL-WIDTH про ширину HEAL'ов (64 вместо фактических 58/57).
Оговорка из G8 перенесена: 60 = 32 свой тайл + 28 собственный свес, для
запечки самого тайла это минимум, сужать можно только пометку СОСЕДА.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт правила оригинала, разобранного в 272cf8f. y_to_row_mod4 даёт −1 и
для куска выше потолка, и для ушедшего ниже комнаты; get_tilepos_nominus
сводит оба в тайл 30, а объекты тайла 30 рисуются в redraw_needed_tiles
ПЕРВЫМИ, до всего обхода тайлов.
Что сделано:
- defer = 0 для таких кусков: они под всем, включая Кида. Раньше
сравнение рядов читало −1 как «обходится последним» = «поверх всего»;
- оверлею отдаётся ориентир 3 («раньше любого ряда 2,1,0») вместо сырого
−1 — гейт other_overlay_tile перестал отбрасывать возврат соседа, из-за
чего тело плиты не возвращалось и оставался только её торец из
переднего слоя;
- в набор перекрываемых тайлов добавлена СВОЯ клетка (только для этого
случая: у куска в обычном ряду объект вливается в midtable после частей
своего тайла, и перерисовывать её нельзя).
Отладочная обвязка разбора (журнал решений оверлея, маска перекрывающих
тайлов) снята; счётчик перерисовок за кадр в pop_redraw_needed оставлен —
он дешёвый и пригодится для HEAL-WIDTH.
ЗАМЕР 13/23, 3032 кадра, против тега mob-order-B-start:
работа 888 984 -> 913 848 (+24 864)
синяя 159 804 -> 159 810
зелёная 427 242 -> 440 418 (+13 176)
циан 378 864 -> 393 000 (+14 136)
Период кадра не изменился: 4 растра в 23 кадрах, 5 в двух.
Зелёная вышла за растровый кадр (440 418 против 430 000). Детализация:
pop_loose_tick 185 826, из них pop_loose_mob_tick 168 180; тробы +
redraw_needed 337 800. Разбор и план возврата тактов — HEAL-WIDTH.
Визуальная проверка комнаты 14 за пользователем: поймать кадр с куском
снимками мне не удалось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
СИМПТОМ (пользователь, ур.11 к.14). Кусок, отвалившийся от плиты нижнего
ряда, рисуется ПОВЕРХ соседней трясущейся плиты; при этом передний торец
соседа лежит поверх куска — порядок противоречив в разных частях
перекрытия.
КОРЕНЬ. draw_mob (seg007:13E5) считает ряд объекта как
y_to_row_mod4(y) = (y+60)/63 % 4 - 1. Из-за % 4 ряд 3 (кусок ушёл ниже
комнаты) и ряд −1 (кусок у потолка) дают ОДНО значение −1. Оригинал
прогоняет его через get_tilepos -> get_tilepos_nominus и получает тайл 30,
а объекты с тайлом 30 рисуются в redraw_needed_tiles ПЕРВЫМИ, до всего
обхода тайлов. Мы же передаём сырой −1 в мид-оверлей как «тайл объекта», и
гейт `row > pop_bg_obj_row` читает его как «объект в последнем ряду обхода»,
то есть «объект поверх всего», и отбрасывает возврат соседа. Один и тот же
−1 у нас значит «сверху», у оригинала — «снизу».
Торец при этом виден потому, что приходит из ДРУГОГО слоя: draw_loose кладёт
loose_fram_bottom в backtable и foretable, минуя ptr_add_table, — тело плиты
обязан вернуть мид-оверлей, а его и выключает гейт.
ЧТО ПРОВЕРЕНО ЗАМЕРОМ (журнал решений в pop_dbg_ovl/pop_dbg_pass, зонды
ВРЕМЕННЫЕ и будут сняты):
- на застывшем кадре: слот draw_y=194, r=−1, rt=2, оверлей позван, внутри
отбрасывается гейтом;
- пропуск оверлея на последнем кадре полёта ЗАКОНЕН: габарит куска уже
ниже габарита тайла, перекрывать нечего;
- в 13/23 кусок перекрывают 2-4 тайла (накопленно за полёт), включая СВОЮ
клетку, — то есть «сосед справа» покрытие не исчерпывает;
- перерисовок тайлов за кадр в 13/23: максимум 6, в покое 0.
ТУПИКИ, чтобы не ходить второй раз. Клип объекта тут ни при чём:
add_mob_to_objtable ставит clip.right = 40, но клип применяется только при
chtab_flip_clip[chtab_id], а для chtab_6_environment там 0 — поле
игнорируется и в оригинале. Пункт MOB-CLIP-RIGHT закрывается как
несуществующий. Добавлять торец плиты в мид-оверлей тоже не надо: в
foretable он уже кладётся из fore_tile.
ВЫБРАН ВАРИАНТ B: классифицировать ряд объекта (вне 0..2 = корзина 30),
отдавать оверлею ориентир «раньше любого тайла» и расширить набор
перекрываемых тайлов на СВОЮ клетку. Переносить проход отрисовки не нужно —
heal при этом не участвует, работа та же (оверлей = два блита в окне клипа),
разница с узким вариантом A всего один-два оверлея на кусок.
Заодно записана ОБЯЗАТЕЛЬНАЯ задача HEAL-WIDTH: ширины точечных heal'ов
взяты по клеткам (64), а фактический след плиты — 58/57 (замерено по
атласам: верх 32, правая грань 26 в подземелье и 25 во дворце).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Правый конец падающего куска лежал поверх соседней плиты. У оригинала
мид-оверлей — draw_tile2(), и его последний вызов draw_loose(0) рисует
передний торец плиты; у loose bottom_id = 0, поэтому больше его не рисует
никто, и в overlay_mid_tile торца не было вовсе.
Заодно снят вопрос про клип объекта: add_mob_to_objtable ставит куску
clip.right = 40, но клип применяется только при chtab_flip_clip[chtab_id],
а для chtab_6_environment там 0 — поле игнорируется и в оригинале. Значит
«клипа нет» у нас верно, MOB-CLIP-RIGHT закрывается как несуществующий.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 11 комната 14: плита ряда 2 уходит в комнату снизу на первом же
move_loose (спавн y=191 при границе ряда 188), а у нас кусок в чужой комнате
не рисовался вовсе — плита исчезала мгновенно. Оригинал (seg007:13E5)
рисует его ещё три кадра, выглядывающим из нижней кромки (+192), и
симметрично из комнаты сверху (−189).
У куска появилась экранная координата draw_y; по ней идут отрисовка, heal,
порядок относительно Кида, пометки соседа и оверлей, отбор в проходе — по
ней же, а не по комнате. Ссылки вверх/вниз кэшируются.
Цена (13/23): зелёная 419 562 -> 427 242, работа 880 272 -> 888 984.
Первый вариант стоил втрое дороже (445 248) из-за безусловной пометки по
прошлой нарисованной позиции — у куска из соседней комнаты она в 192
пикселях, и объединение растягивалось на весь экран; гейт вернул 18 000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 9 (зелье инверсии) — багов не найдено.
Регресс 13/23, 2701 кадр: работа 880 272, синяя 159 822, зелёная 419 562,
циан 380 202 — против 880 170 / 159 774 / 419 520 / 380 244 у 3bcaf51.
Разброс ±100 тактов на 880 000 (0,01 %), циан даже в минус. Период совпал
кадр в кадр: 4 растра в 24 кадрах, 5 в одном. Host-тесты 5106 проверок
без расхождений.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено и закрыто по дороге: GUARD-RESPAWN-COL0 (c40ae3f) — страж при
возврате в комнату телепортировался в колонку 0 и падал насмерть;
SEAM-FIGHT-FLICKER (a498255) — бой у шва перерисовывал комнату
туда-обратно, поведение сверено с SDLPoP покадрово.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Инструмент: pop_dbg_kidobj печатает то же, что вывод, добавленный
пользователем в SDLPoP add_kid_to_objtable — tilepos/frame/act/col/cols/rows
(+ наша комната). Считает по set_char_collision и set_objtile_at_char.
Выключен по умолчанию (DBG_KIDOBJ 0), включается одним define; вывод
забирает брейкпоинт MAME на резидентном pop_dbg_trap.
Сверка подтвердила фикс a498255: окно перехода совпало кадр в кадр, за бой
на уровне 1 комната сменилась один раз, на уровне 8 у шва 24/18 — четыре
раза на 387 кадров боя, и все четыре на РАЗРЕШЁННЫХ кадрах (170/164/170/165).
Смен на запрещённых кадрах во всей трассе нет.
Разбор целиком — BUGS_CLOSED.md#seam-fight-flicker, включая невыясненное
расхождение поля cols.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь: при бое на шве (Кид в 18, страж в 24) комната перерисовывается
то одна, то другая — драться невозможно.
leave_room запрещает уход не только на развороте, подъёме-с-зацепа и
вставании из приседа, но и на всей боевой анимации: кадры 150..162 и
166..168. У нас были только первые три условия, поэтому отступающий и
наступающий Кид пересекал границу почти каждый кадр.
Оригинал пускает смену комнаты только на «легальном» кадре вроде 170.
Известный побочный эффект — Trick 35 «retreat without leaving the room»;
в SDLPoP его чинит FIX_RETREAT_WITHOUT_LEAVING_ROOM, по умолчанию
выключенный, так что портируем оригинал.
Сторона стража проверена отдельно и расхождений не дала: play_guard_frame
(seg000:0F48) ухода из комнаты не содержит вовсе, страж меняет комнату
только через follow_guard (есть, pop_guard_follow) и check_guard_fallout
(есть, pop_guard_fallout).
Банк 3 +10 Б, резидент без изменений. Host-тесты 5106 проверок чисто.
Сам бой у шва не воспроизводился — проверка за пользователем.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 8 комната 24: после первого же выхода Кида страж телепортировался
в колонку 0, а ряд 0 там пустой в колонках 1..3 — страж падал с ряда 0 на
ряд 2 и разбивался.
leave_guard (seg002:02F5) кладёт в guards_tile get_tilepos(0, row), то есть
обнуляет колонку НАМЕРЕННО: позицию по горизонтали несёт guards_x, туда же
она и пишется. enter_guard берёт x оттуда всегда. Мы же считали x из
tile % 10 (то есть из нуля), а запомненную брали только у трупа.
Заодно портирован pos_guards (seg003:0913): при загрузке уровня guards_x
пересчитывается из колонки тайла, а файловое значение выбрасывается — в
комнате 24 уровня 8 там 255.
Проверено в MAME: три входа подряд дают одну и ту же позицию (x 156, ряд 0),
страж стоит на полу. Host-тесты 5106 проверок без расхождений.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Постановка пользователя: ручной проход уровня с логом фаз + комната/тайл,
дальше оптимизация конкретной комнаты. Записано вместе с блокером —
брейкпоинтами это делать нельзя (эмуляция падает в проценты от реального
времени, играть невозможно); варианты: тап на порт бордюра в Lua-мосте
(не привязан к адресам кода) или самозамер программой в растрах (работает
и на железе).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2435 кадров сцены каскада. Максимумы по секциям: работа 880 170,
синяя 159 774, зелёная 419 520, циан 380 244 — против 878 550 / 158 880 /
419 526 / 379 488 у ec1f384. Все четыре в пределах шума прогона, зелёная
совпала до 6 тактов. Распределение периода совпало кадр в кадр: 4 растра
в 24 кадрах, 5 в одном, остальные 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено и закрыто по дороге: SPIKE-BAKED (980d48c), DIED-ON-BUTTON
(6feab5d). Регресс после них: сцена 13/23 (каскад плит) отрабатывает
штатно, host-тесты 5106 проверок без расхождений — включая 1730 трасс
физики, которых касалось снятие раннего выхода для трупа.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 7 начинается падением; разбившись на кнопке открытия решётки
(комната 3, тайл 2,1), Кид в оригинале открывает решётку НАСОВСЕМ, а у нас
она закрывалась обратно. Чинить пришлось три места.
1. pop_phys_tick выходил по pop_kid_dead, то есть check_press до трупа не
доходил вовсе. У оригинала play_kid_frame гейтится только Char.room
!= 0. Кнопка получала одно нажатие — в кадре смерти, потому что флаг
ставит land() уже внутри цепочки. «Труп не шевелится» держит внутренний
выход в kid_phys, он остался.
2. Портирован died_on_button: открывалка → пол + связь дёргается типом
«щебень» (открыть насовсем), прочие кнопки → TILE_STUCK. Ветки по
Char.alive в check_press не было вовсе.
3. Рестарт уровня восстанавливал только foretable, а died_on_button
оставляет таймер связи нажатым (trob кнопки умирает сразу — тайл уже
пол). Остаток переживал респавн, и кнопка рисовалась нажатой с первого
кадра. pop_level_reset_tiles теперь возвращает и LINKMAP.
Проверено в MAME по всему циклу: смерть → FF (открыта навсегда), респавн →
кнопка цела и не нажата, второе падение → снова ломается.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кид гибнет на кнопке открытия решётки (уровень 7, комната 3, тайл 2,1) —
в оригинале решётка открыта насовсем, у нас отжимается. Мало того, что
died_on_button (seg007:776) не портирован: pop_phys_tick целиком выходит
по pop_kid_dead, так что check_press до трупа не доходит вовсе. У
оригинала play_kid_frame гейтится только Char.room != 0.
Фикса пока нет — запись в BUGS_OPEN.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 7, комната 19. Кнопка в (0,5) при нажатии зовёт pop_floor_bake,
а тот перерисовывает и правого соседа — пики (0,6) — в банке, который
пишет в ОЗУ-копию. Кадр брался живой, и выдвинутая пика оставалась в
фоне: дальше heal возвращал её каждый кадр. В (0,7) чисто, потому что
окно клипа запечки кончается на x=219, а колонка 7 начинается с 224.
Флаг pop_t_bake_rest («в фон кладём покой») для этого и был, но читал его
только pop_loose_frame. Кадр пик считался по месту в ЧЕТЫРЁХ слоях.
Добавлен pop_spike_frame — порт get_spike_frame (SDLPoP seg008:08A0),
которого у нас не было, — и все четыре слоя переведены на него; заодно
позу покоя в запечке стал отдавать pop_chomp_pose.
Одна точка лечит все пять путей запечки, включая вход в комнату.
Резидент +28 Б, скорость не затронута.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XOR-приём оригинала требует чтения видео-ОЗУ (нельзя) и несовместим с
0xFF-прозрачностью; план — отдельный атлас тени.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Флаг «no blue» лежит в нулевом бите модификатора стены В ФАЙЛЕ уровня, а
отрисовка (как и оригинал) ищет его в СТАРШЕМ: load_alter_mod переводит
их сдвигом на 7. Ветку стен мы пропускали намеренно — связи кладки
считаются по соседям, — но noblue из соседей не выводится.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тень на уровне 6 стояла на двух страницах дабл-буфера в разных позах.
Отрисовка брала image из кэша kid_frame/pop_gframe, который наполняет тик,
а снимок пропуска кадра писала по Char.frame — расхождение застревало
навсегда, потому что снимок совпадал и страница больше не перерисовывалась.
Кадр теперь грузит сама отрисовка, как в оригинале (add_*_to_objtable →
load_fram_det_col, seg008:22F0/2324). Разбор — BUGS_CLOSED.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 4: в анимации ухода на следующий уровень контур Кида обрезался не
правой гранью портала, а раньше — брался клип подземного проёма, а
дворцовый шире.
draw_leveldoor считал кромку как xh*8 + 48, без дворцовой поправки. В
оригинале строкой ниже стоит (seg008:1429):
if (custom->tbl_level_type[current_level]) leveldoor_right += 8;
Значение читает clip_char как правую границу клипа персонажа — отсюда
ранняя обрезка. Расхождение было осознанным и отложенным: в коде стоял
комментарий «+8 у palace-уровней — на уровне 1 не применяется», дворцовых
уровней тогда в порту не было. tbl_level_type[4] = 1, там и проявилось.
pop_palace выставляет pop_bg_load из того же tbl_level_type, что читает
оригинал, так что эквивалент дословный.
Попутно найдено и НЕ починено (заведено отдельным багом
LEVELDOOR-STARTROOM-WIPE): в той же функции оригинал в СТАРТОВОЙ комнате
кладёт затирающий прямоугольник вместо лестницы, со своей дворцовой/
подземной разницей 48/39 и сдвигом 2 px, а мы рисуем марш 144 безусловно.
Видно только при приподнятой створке входной двери, поэтому на обходах
уровней 1-4 не попалось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обход после оптимизации фаз: третий уровень прошёл без правок — в
отличие от первого и второго, чинить ничего не пришлось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пересмотр по вопросу пользователя. Первая редакция плана рекомендовала
EMM-страницу — ошибка: взвешивала скорость и недооценивала главный
сценарий.
EMM-страница не переживает рестарт программы, а именно рестарт — тот
случай, ради которого QuickSave и нужен: сцену каскада плит на 13/23
воспроизводит ТОЛЬКО ESC → запуск заново (perf_l13_room23.md §1). Снимок
в ОЗУ там не помогает вовсе.
Доводы за EMM при перепроверке оказались слабыми: лимит манипуляторов DSS
ни при чём (один файл, гард _fd_guard и так стоит), а экономия на пути к
файлу — одна строка. Разница в скорости некритична: 1,9 КБ на HDD не
заметны на фоне полной перерисовки комнаты при загрузке.
Добавлен шаг QS0 — проверить, что D: вообще пишется из-под MAME: если
образ только на чтение, это меняет весь план, поэтому идёт первым.
Критерий приёмки задачи: сохранить, выйти, запустить заново, загрузить —
и оказаться там же.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Только изучение и план, кода нет.
Первое, что выяснилось: в оригинале 1989 года быстрого сохранения НЕТ
вовсе — это enhancement SDLPoP (seg000.c, USE_QUICKSAVE, F6/F9). Значит
искать в Apple II / MSDOS нечего, и повторяем мы не букву, а устройство.
Что берём у SDLPoP: плоский снимок с ОДНИМ обходом на запись и на чтение
(#define process(x)); совместимость держится строкой версии и ничем больше;
клавиша только взводит флаг, работа идёт между кадрами; состояние отрисовки
не сохраняется вовсе — комната перерисовывается с нуля.
Чем наш случай тяжелее: уровень в EMM-странице, room_modif/trobs — static в
банковом pop_trob.c, ГСЧ у нас ТРИ (pop_t_seed, trob_seed, pop_fight_seed),
и дабл-буфер требует перерисовать после загрузки ОБЕ страницы.
Снимок ≈1,9 КБ, поэтому основной носитель — EMM-страница (мгновенно, мимо
DSS и его лимита манипуляторов), файл вынесен в необязательный шаг QS6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер 418 кадров, зонды A/C/D/E (база модуля roomtest 0x42AD — совпала с
прошлой сборкой, фикс ушёл в банк).
работа синяя зелёная циан
af189a1 916 458 142 830 546 900 270 510
40f0d46 873 930 158 874 417 630 379 482
ec1f384 878 550 158 880 419 526 379 488
Фиксы второго уровня на бюджет не повлияли: +4 620 работы и +1 896 зелёной
— шум прогона. Период: 3 растра в 392 кадрах, 4 в 24, 5 в одном, то есть за
бюджет вылезает только сам каскад.
Синяя и циан в бюджете 400 000; зелёная 419 526 — 1,05x цели и ниже
растрового кадра 430 000. Остаток на потом: раскол draw_tile на узкие части
и идея G8 (инвалидация соседнего тайла полосой 28 px вместо целых 60).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обход уровней после оптимизации фаз: уровни 1 и 2 пройдены, критичных
багов не осталось. Найденное по дороге закрыто и перечислено в шапке
TASKS_OPEN.md, чтобы регресс-база была видна одним взглядом.
README раньше читы не перечислял вовсе. Теперь у бессмертия явно записано
главное ограничение — работает ТОЛЬКО с мечом в руке — и почему оно не
косметическое: кадры seq_74 все с мечом, и подмена смерти на эту анимацию
оставляла Кида в стойке при sword == 0, где у control() нет ни одной ветки.
На физику (падения/пики/чомперы) бессмертие действует всегда.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кида сталкивают с ряда 1 на ряд 2, страж падает за спину — Кид встаёт в
боевую стойку без меча и перестаёт реагировать на клавиши.
Оба симптома — один отказ: control() уходит в control_with_sword только при
sword == 2, а для кадров стойки (158/170/171) среди обычных веток нет ни
одной. Разворот к сопернику за спиной (SEQ_60 при char_opp_dist() < -4)
живёт как раз внутри control_with_sword.
Корень — наш чит, а не механика. hurt_by_sword перехватывался бессмертием
в самом начале и безусловно ставил SEQ_74_HIT_BY_SWORD, а это анимация
«получил удар В БОЕВОЙ СТОЙКЕ», её кадры 150..179 все с мечом. В оригинале
(seg002) туда попадают только из ветки sword == 2; безоружного там убивают:
«Being hurt when not in fighting pose means death», take_hp(100).
Спецветка чита удалена целиком — она избыточна: под бессмертием
pop_take_hp(1) и так возвращает 0, и обычный путь сам даёт seq_74 с
сохранённым мечом. Осталось запретить читу действовать в безоружной ветке.
Глушится вызов, а не pop_take_hp: тот общий с физикой (падения/пики/
чомперы), там бессмертие обязано работать при любом положении меча.
Проверено в MAME: в бою удары урона не приносят, без меча — смерть с одного
удара. Разбор и грабли метода — BUGS_CLOSED.md#bug-cheat-imm-1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Идея пользователя 2026-08-17. При падении плиты помечаются ДВА тайла, и
сосед перезапекается ЦЕЛИКОМ и ПОВТОРНО (draw_tile для него зовётся дважды),
хотя потревожили у него только левые 28 px — там, куда свисает правая грань
упавшего тайла.
В записи разведено, что 60 = 32 свой тайл + 28 СОБСТВЕННЫЙ свес (сужать
нельзя), и сузить можно только пометку «изменился мой ЛЕВЫЙ сосед». Плюс три
условия: pop_floor_bake общая (кнопка/зеркало/предмет/щебень) и нужен
отдельный вход; вертикальные диапазоны двух запечек НЕ совпадают (20 строк
против 39), поэтому просто снять вторую пометку нельзя; ширину полосы брать
по максимальному свесу.
Брать ПОСЛЕ обхода всех уровней — решение пользователя.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем (2026-08-17, уровень 2 комната 4): после падения плит
(1,7) и (1,8) поверх стража в (1,0) появляются два чёрных бара, большой и
маленький, и МЕРЦАЮТ — то есть живут только на одной странице дабл-буфера.
Пока плиты не упали, баров нет.
Диагноз пользователя оказался верным дважды — и в причине, и в механизме.
ПРИЧИНА. Падение плиты (1,8) помечает соседа (1,9), и pop_floor_bake заливает
там прямоугольник ШИРИНОЙ 60 от x = 288 — то есть 348, на 28 пикселей за
экран. Ширина «свой тайл + свес соседа» (40/60/64 при шаге тайла 32) верна
для любой колонки, кроме последней.
А заливка по контракту НЕ КЛИПУЕТ, и это правильно: проверка координат стоила
бы дороже самой заливки (шапка _gfx_recthfill256: «Клиппинга НЕТ, координаты
обязаны быть валидны»). Значит обрезать обязан ВЫЗЫВАЮЩИЙ — bar не трогаем.
МЕХАНИЗМ МЕРЦАНИЯ (объяснение пользователя). Страница 1 начинается на 320
байт дальше страницы 0, поэтому запись в страницу 0 с x > 320 попадает в
страницу 1 по x−320. Отсюда и «бар только на одной странице». Хуже того,
такая же запись НА странице 1 уходит уже за пределы обеих страниц — в то, что
лежит дальше (палитры и прочее), так что баг не только косметический.
ПОДТВЕРЖДЕНИЕ АРТЕФАКТОМ. Трасса всех вызовов pop_bar_black на прогоне:
BAR x=288 y=117 w=60 h=39 ret=D754 <- pop_floor_bake, 288+60 = 348
и независимо снятое расхождение страниц: РОВНО x 0..27 (348−320 = 28 px) при
y 117..155 — то есть в точности y этого бара (yb+26 = 117, высота 39).
ФИКС. Хелпер bake_w(x, w) обрезает ширину по правому краю (и отдаёт 0, если
прямоугольник целиком за экраном); применён во всех точечных запечках —
pop_floor_bake, pop_ceil_bake_empty, pop_loose_bake_empty. Обрезка ничего не
теряет: правее 320 восстанавливать нечего. Копия второй страницы (bake_copy)
берёт ту же обрезанную ширину, иначе запечка и копия разъехались бы.
Заодно, того же рода:
- pop_torch_wipe у факела в колонке 9 заливал x = 328, то есть ЦЕЛИКОМ за
экраном — теперь пропускается;
- pop_loose_bake_empty читал POP_COL_XH[col + 1] БЕЗУСЛОВНО, то есть у
последней колонки за концом массива (10 элементов); чтение убрано под
проверку.
Отсечено по дороге (проверками, не рассуждением): копия запечки не виновата
(сборка с выключенной копией — бары остались, проверил пользователь).
8 наборов tests-host зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер на сцене каскада (ур.13 к.23) ПОСЛЕ багфиксов показал регресс, причём
в том числе в СИНЕЙ фазе, которую фиксы не трогали:
до фиксов после фиксов после этой правки
работа 783 798 1 038 528 873 930
синяя 142 830 275 538 142 830
зелёная 414 456 415 284 417 630
циан 269 538 479 628 379 482
период (растр.) 4 5 4 (один кадр 5)
Разбор синей зондом L (граница «ввод+heal» / «логика») сразу указал место:
логика 88 020 не изменилась, а «ввод+heal» вырос 55 000 -> 187 500. Это
pop_fore_heal чистит ОБЪЕДИНЁННЫЙ ov_mark: кромка потолка (полоса y 0..8) и
оверлеи соседей (64x64 на тайл) от шести кусков сливались в прямоугольник на
полкомнаты.
Правки, каждая с замером:
1. Оверлей соседа рисуется в ОКНЕ = прямоугольник самого куска. Смысл
оверлея — перекрыть кусок, а что лежит вне него, и так нарисовано
правильным фоном. Даёт три вещи разом: пикселей в разы меньше; куски
тайла, не задевающие кусок, отсеиваются предфильтром pop_blit_b даром (это
и есть вертикальный гейт, отдельного не надо); ov_mark становится НЕ НУЖЕН
— всё нарисованное лежит внутри heal-коридора куска, а он чистится каждый
кадр. Для этого заведён ov_suppress.
2. Кромка потолка — тоже без ov_mark: её куски рисуются от dby = 2, то есть
занимают строки 0..2, а помечает её только кусок и только пока достаёт
(mob_y <= 18) — значит она внутри его же коридора.
3. Предусловия перенесены в РЕЗИДЕНТ, до вызова в банк 2: pop_mob_overlay_tile
и разбор пометок живут в других банках, и каждый вызов платит межбанковый
трамплин (~8 900), а условий всего два дешёвых чтения тайла
(pop_tile_code — резидент, ~1 100). Гейты: тайл соседа не пуст и его
габарит пересекается с габаритом куска; для полосы потолка — тайл полосы
не пуст (а он там чаще всего пуст: кусок существует ровно потому, что
плита из своей клетки выпала, и пустой тайл в переднем слое не рисует
ничего).
ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ, откачен: сдавать пометки ряда −1 одной МАСКОЙ
вместо до двенадцати отдельных вызовов (один трамплин вместо двенадцати) —
873 930 -> 879 276, то есть хуже. Пометки ставятся редко, а цена копилки и
разбора маски платится всегда.
Итог: все три секции в бюджете 400 000 (синяя 158 880, циан 379 482), зелёная
417 630 — она была такой и ДО фиксов, это не регресс. Период вернулся к 4
растровым кадрам; ровно один логический кадр из 54 идёт за 5 (работа 873 930
при пороге 860 000).
Цена четырёх багфиксов по итогу: +90 000 работы за кадр (+11 %), из них весь
рост в циане.
Тестовый образ теперь всегда собирается LEVEL=1 (решение пользователя:
переход на нужный уровень он делает сам).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем (2026-08-17, уровень 1 комната 15): при входе в комнату
блеск меча иногда горит несколько секунд вместо одиночной вспышки. Повторить
не удавалось.
Порядок в enter_room:
1) pop_trob_room_changed() обнуляет trob_drawn[] для ВСЕХ тайлов
= «нарисовано без блеска»;
2) pop_trob_anim_room() задаёт мечу СЛУЧАЙНУЮ фазу счётчика 0..31
(start_anim_sword, seg007:087C);
3) pop_room_draw() рисует комнату по ЖИВОМУ modif.
Если случайная фаза попала ровно на 1, комната рисуется С БЛЕСКОМ (draw_tile
берёт кадр (modif==1)+10), а trob_drawn говорит «блеска нет». На следующем
кадре animate_sword уводит счётчик с 1, признак снова 0 — и СОВПАДАЕТ со
стоячим trob_drawn, поэтому перерисовка не помечается. Блеск остаётся
запечённым в фон.
Оба наблюдённых числа сходятся:
вероятность ровно 1/32 на вход в комнату — отсюда «редко»;
длительность animate_sword считает ВНИЗ и на нуле берёт новый период
0x28..0x67, то есть до 103 логических кадров ~ 6 секунд.
Фикс: trob_drawn для меча инициализируется тем, что реально нарисует комната
(mod == 1), а не нулём. Одно присваивание на вход в комнату — в кадре ноль.
ПОДТВЕРЖДЕНО АРТЕФАКТОМ, а не рассуждением: временная сборка с подменой фазы
на mod[i] = 1 давала баг на КАЖДОМ входе в комнату 15 (пользователь
подтвердил), с фиксом — ни на одном. Временный форсаж снят.
У КНОПКИ той же дыры нет, хотя механизм общий (оба используют trob_drawn):
её признак — ДЛИТЕЛЬНОЕ состояние (нажата/нет), рассогласование само
исправляется на первой же смене. У блеска признак — одиночная вспышка в
ОДНОМ кадре из 40..103, и пропущенная смена стоит целого периода. Записано в
комментарии, чтобы их не «унифицировали» обратно.
8 наборов tests-host зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем (2026-08-17, уровень 1 комната 12, стоп-кадр):
плита (0,2) отвалилась и накрыла собой переднюю кромку пола (0,3).
Разбор по стоп-кадру: mobs[0] = {x 64, y 68, row 1, col 2, defer 1}, то есть
кусок в тайле (1,2), а страдает (0,3). Тайл (0,3) в комнате 12 — КНОПКА
(0x0F), и у неё fore_id = 0. Проверено по tile_table оригинала
(seg008.c:43) — там ровно то же, наша таблица совпадает байт в байт. То есть
через ПЕРЕДНИЙ слой эту кромку вернуть нельзя, и вопрос «может, у кнопки в
оригинале есть fore?» закрыт: нет.
Механизм в оригинале другой — draw_mob (seg007:1149) ставит соседу ДВЕ
пометки:
set_redraw2(tilepos, 1); // draw_other_overlay -> MIDtable
set_redraw_fore(tilepos, 1); // draw_tile_fore -> FOREtable
Мы портировали только вторую. А перекрывает кусок именно ПЕРВАЯ:
draw_other_overlay кладёт части соседа в midtable (seg008:1507/1512), тогда
как кусок сидит в objtable, и тот вливается в midtable при обходе СВОЕГО
тайла. Обход идёт ряды 2,1,0, колонки 0..9 — тайл (0,3) идёт ПОЗЖЕ тайла
(1,2), значит его оверлей ложится поверх куска.
Условие тоже сходится: draw_other_overlay рисует, когда тайл СЛЕВА пуст, —
а (0,2) стал пустым ровно потому, что плита оттуда и упала.
Порт: сам draw_other_overlay у нас уже есть (other_overlay_tile, применялся к
персонажу через redraw_at_char2), ему не хватало только ориентира «тайл
объекта». Новый вход pop_mob_overlay_tile подставляет тайл куска в
pop_bg_obj_* (с сохранением и возвратом — их читает ещё и mob_tick_one) и
зовёт other_overlay_tile; вся проверка порядка обхода остаётся внутри него.
Зовётся сразу после mob_render: списков у нас нет, порядок эмулируется
последовательностью вызовов.
По бюджету работа появляется только когда сосед слева пуст, то есть в кадрах
сразу после падения плиты, и попадает в циановую фазу (там запас).
8 наборов tests-host зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем (2026-08-17, уровень 1 комната 6): падающая плита
(-1,5) перекрывает собой кромку потолка (-1,6), чего физически быть не может.
Дыра архитектурная: pop_fore_needed обходил только ряды 0..2, ряда -1 в
переднем слое не было вовсе.
Сверено с оригиналом. redraw_needed_tiles (seg008:1B06) обходит ряды 2,1,0, а
ПОТОМ отдельным проходом ряд 2 комнаты сверху (redraw_needed_above), и его
draw_tile_fore кладёт куски в FOREtable. Падающая плита идёт в MIDtable
(draw_mobs). draw_tables рисует back -> mid -> fore (seg008:1373), поэтому у
оригинала кромка потолка оказывается поверх плиты сама собой.
Порт:
- pop_fore_needed: проход по ряду -1 добавлен и идёт ПОСЛЕДНИМ, как в
оригинале. Свой набор пометок (rdfa/rdfa_pending) — как и у самих
перерисовок ряда -1 (rda_*), это отдельный проход, а не 11-я колонка;
- новый лист pop_ceil_fore_tile_b (pop_bg.c) — тот же redraw_needed_above,
что уже рисовался над персонажем (ceil_over_kid_tile), плюс окно клипа
ровно на полосу столбца и ov_mark (полоса идёт банком без тени, на второй
странице её восстановит pop_fore_heal);
- mob_mark_neighbour помечает ряд -1, пока кусок достаёт до кромки. Кромка
живёт в трёх верхних строках поля (dby = 2 при клипе по POP_YOFF), спрайт
куска занимает mob_y-16 .. mob_y, отсюда условие mob_y <= 18 — три кадра
после отрыва (y = 2, 5, 11 при ускорении 3). Помечаются ОБА столбца,
которые кусок накрывает по x (mob_x .. mob_x+62 = col и col+1); именно
поэтому страдал сосед.
По бюджету работа появляется только в эти три кадра на кусок и попадает в
циановую фазу, где сейчас запас (270 тыс. из 400 тыс.). Замер на ур.13 —
следующим шагом.
8 наборов tests-host зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Новый раздел §4: копия годится только если (1) запечка ограничена
копируемым прямоугольником — иначе то, что легло вне него, на второй
странице остаётся прежним и страницы расходятся (мерцание торцов, ур.1
к.6, кнопка (0,2)); и (2) содержимое тайла между двумя кадрами не
менялось — у кнопки с идущим таймером пометка обновляется каждый кадр.
Перечёркнут прежний вывод «окно клипа в pop_floor_bake — только вред»:
по скорости да, но оно ОБЯЗАТЕЛЬНО как условие (1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регресс от копии второй страницы (5ef721e), найден пользователем: уровень 1,
комната 6, Кид на кнопке (0,2) — дрожит нижняя грань переднего торца кнопки.
На уровнях 1-3 то же по торцам плит, полов и кнопок.
Причина системная. Полная запечка зовёт draw_tile, а тот рисует тайлы
ЦЕЛИКОМ, то есть пишет ШИРЕ прямоугольника бара. bake_copy переносит на
вторую страницу ровно бар — и всё, что легло вне него, на второй странице
остаётся прежним. Страницы расходятся, это и есть мерцание через кадр.
У полосы потолка (pop_ceil_bake_empty) проблемы не было: там окно клипа
поставлено ещё в G1, и запечка ограничена ровно копируемым прямоугольником.
А у pop_floor_bake окна не было — до этого оно трижды отвергалось как
невыгодное по скорости.
Фикс: окно клипа в pop_floor_bake возвращено, но теперь это условие
КОРРЕКТНОСТИ, а не оптимизация — записано в коде, чтобы его не сняли снова
«как убыточное». Само окно стоит ~8 500 такта (клипованный путь дороже
быстрого на ~1 500 на каждом из 7,6 блитов), но открывает копию второй
страницы, экономящую ~145 000: пара «запечка + копия» вдвое дешевле двух
запечек.
Второй гард того же захода: pop_set_redraw / pop_set_redraw_above гасят слот
копии при ПЕРЕпометке тайла — у кнопки с идущим таймером связи пометка
обновляется каждый кадр, и картинка каждый раз другая, копия старой не
годится. Объявления pop_bake_slot_reset* без __banked: pop_redraw.c и
pop_room.c в одном банке, трамплин не нужен.
Заодно починен скрипт рестарта MAME: он оставлял недоеденный запрос `exit` в
очереди IPC моста, и НОВЫЙ экземпляр его подхватывал и сразу выходил.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
зелёная пик 423 558 -> 414 456
работа пик 793 266 -> 783 798
ПЕРИОД логического кадра в каскаде: было 5-6 растровых, стало РОВНО 4
mob_tick_one: рабочие переменные в file-scope (было 16 байт кадра и 99
обращений `-N(ix)`, стало 17) — то же лечение, что у draw_tile и blit_b_clip.
Снята временная оснастка из ГОРЯЧИХ путей: шесть вызовов pop_dbg_b1..b6 в
pop_blit_b (по ~65 такта каждый на КАЖДЫЙ блит), pop_dbg_kind/m16 и
подсчёт состава кадра в pop_redraw_needed, pop_dbg_m13..m15 в
pop_ceil_shake_draw. Сами пустышки в pop_state.c оставлены — вставить их
обратно на один замер дешевле, чем заводить заново; как это делается,
записано в docs/perf_l13_room23.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Идея пользователя: то, что пишется в ДВЕ видеостраницы, на вторую можно
копировать, а не считать заново — тем же приёмом, что при перевороте экрана
(зелёное зелье), только без зеркала.
Точечные запечки ставятся с pages = 2, срабатывают два кадра подряд (по разу
на страницу дабл-буфера) и оба раза считают одно и то же. А после первого
раза нужный прямоугольник уже лежит в ОЗУ-копии первой страницы:
gfx_copy_page берёт источником ОЗУ-копию НЕактивной страницы (то есть ЧИСТЫЙ
фон — спрайты рисуются банком без тени и в копию не попадают), а приёмник
обновляет и в видео-ОЗУ, и в ОЗУ-копии.
запечь щебень (RD_FLOOR) 179 914 -> 107 844 в среднем (копия ~35 000)
запечь колодец (RDA_CEIL_GONE) 138 318 -> 80 810 в среднем (копия ~17 500)
зелёная пик 546 900 -> 423 558
работа пик 916 458 -> 793 266
Слот на тайл (bake_pg / bake_pg_above) помнит, НА КАКОЙ странице сделана
первая запечка. Копируем только если первая была на ДРУГОЙ странице и
дабл-буфер включён; иначе честно пересчитываем. Такая проверка выдерживает и
переплетение двух запечек в одном кадре, и однобуфер (чит SPACE), и смену
комнаты (pop_bake_forget в enter_room — номера тайлов повторяются).
Проверено: 8 наборов tests-host зелёные. Мерцания через кадр нет — шесть
снимков подряд после каскада попиксельно совпадают в поле, различаются ТОЛЬКО
полосы профилировочного бордюра (снимок ловит разную строку растра);
пользователь подтвердил визуально.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зелёная и циан — раскладка ПОСЛЕ правок, журнал правок с коммитами,
отрицательные результаты с объяснением причины (окно клипа в
pop_floor_bake — три попытки), и что осталось: раскол draw_tile на девять
узких частей по образцу seg008:01C7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ПОПРАВКА К b2da0b8. Там я снял размеры частей падающего куска из каталогов
атласов с НЕВЕРНЫМ сдвигом раскладки (page = id>>5 вместо id>>4 —
POP_ENV_SHIFT равен 4). Настоящие размеры:
оба тайлсета 70 = 32x13 74 = 32x3
72 = 26x16 в подземелье, 25x16 во дворце
То есть прежний комментарий в mob_render (32x13 / 32x3 / 26x16) был ВЕРЕН, а
«исправление» — нет. Следствия:
- НИКАКОГО БАГА КОРИДОРА НЕ БЫЛО: след куска по x — mob_x .. mob_x+57, и
старый коридор mob_x-4 .. mob_x+59 его покрывал. Заявление про «три
пикселя, не стиравшиеся во дворце» неверно, снимаю.
- Сам коридор оставляю как стало (mob_x .. mob_x+63): площадь та же, но
четыре пикселя запаса переехали слева, где кусок не рисует ничего, вправо,
где их было всего два. Это не исправление бага, а перекладка запаса.
- КОД во всех случаях работал правильно: он читает раскладку через
POP_ENV_SHIFT/POP_ENV_MASK, ошибка была только в моём анализе.
Само дело: композит собирается с ТОЧНЫМ габаритом вместо буфера-максимума.
Габариты частей читаются первым проходом (у тайлсетов правая часть разная),
из них считаются ширина и высота блоба, и страйд равен ширине. Раньше блоб
объявлялся 63 px шириной при фактических 58 — пять прозрачных колонок
переносились на каждом кадре каждого куска.
циан пик 273 108 -> 270 510
работа пик 920 862 -> 916 458
Проверено: 8 наборов tests-host зелёные; кадр с четырьмя плитами в воздухе
совпадает пиксельно с прежним.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Держали константные 24 строки «на всякий случай», хотя собранный композит
знает свою высоту: у дворца 16 строк, у подземелья 19. Теперь коридор =
высота композита + две строки сверху и одна снизу (mob_heal_up/mob_heal_h
ставит mob_spr_build). На самой дорогой операции кадра, помноженной на шесть
падающих плит:
pop_loose_mob_tick 168 600 -> 156 762
зелёная пик 557 706 -> 546 948
работа пик 931 782 -> 920 862
Плюс ТРЕТЬЯ проверка окна клипа в pop_floor_bake — снова хуже (179 914 ->
188 417), и теперь ясно ПОЧЕМУ: детальный зонд по каждому блиту показал, что
клипованный путь стоит +1 500 такта на КАЖДОМ из 7,6 блитов (+11 400), а
режет он только редкие высокие куски (в трассе нашлись два: 34 878 -> 25 872
и 28 818 -> 24 090, всего -13 700), которых в среднем по 12 перерисовкам нет.
Запись в коде: больше не пробовать.
Заодно снят детальный профиль pop_floor_bake (одна перерисовка, 179 914):
вход 2 382
pop_bar_black 60x39 (вкл. pop_cd_touch) ~15 500
контекст draw_tile #1 13 584
4 блита тайла #1 50 718
диспетчер между блитами #1 11 388
контекст draw_tile #2 13 584
4 блита тайла #2 ~54 000
диспетчер между блитами #2 11 388
хвост 6 474
Итог по сцене от базового замера:
работа 1 437 150 -> 920 862 (-36%)
зелёная 805 000 -> 546 948 (-32%)
циан 631 800 -> 273 108 (-57%, в бюджете)
Проверено: 8 наборов tests-host зелёные; в MAME кадр с четырьмя плитами в
воздухе чистый (коридор сузился — следов нет), итоговая картинка прежняя.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три части падающей плиты (env 70/74/72) складываются в ОДИН getimage-блоб
при загрузке тайлсета, и кусок рисуется одним блитом вместо трёх. Блоб
лежит в обычной памяти (W2), поэтому вызов идёт без atlas_image и без
gfx_w0_map/unmap — ещё ~1 350 такта. Прозрачность соблюдена: части
ПЕРЕКРЫВАЮТСЯ (74 и 70 обе от mob_x), поэтому композит собирается
попиксельно с пропуском 0xFF, то есть точно как три прозрачных блита.
Мотив: у блита ~8 800 такта постоянных накладных против ~5 000 на пиксели.
Шесть кусков в воздухе = 18 вызовов = 258 708 такта, больше половины
цианового блока. Резервный путь на три блита оставлен (mob_spr_ok).
циан пик 435 180 -> 273 078 (цель 400 000 — ВЫПОЛНЕНА)
работа 1 037 250 -> 931 782
зелёная 558 498 -> 557 706 (не затронута, ею занимаемся дальше)
Заодно НАЙДЕН И ПОФИКШЕН БАГ КОРИДОРА heal. Снятые из каталогов реальные
габариты частей оказались другими, чем в комментарии, И РАЗНЫМИ у тайлсетов:
подземелье 70 = 32x16 74 = 26x15 72 = 26x16
дворец 70 = 32x13 74 = 25x15 72 = 31x13
То есть во ДВОРЦЕ (уровни 4-6, 10, 11, 13, 14) кусок достаёт до mob_x+62, а
коридор heal был mob_x-4 .. mob_x+59 — правые три пикселя не стирались
никогда. Четыре пикселя слева при этом чистились впустую: левее mob_x
кусок не рисует ничего. Коридор стал mob_x .. mob_x+63, площадь та же.
Высоту (24 строки при следе 19) НЕ сужаем: 24 — это след подземелья плюс
две строки поля с каждой стороны, «сужение по палаццовому следу» сломало бы
подземелье.
Ещё три правки того же захода:
- pop_blit_b: аргументы в file-scope. Третий и дальше SDCC передаёт стеком,
и каждое чтение шло через `-N(ix)` — 76 обращений. Стало 11.
- pop_loose_mob_tick: пометки всех кусков одним пакетом (было по 4 502 такта
на кусок). mobTk 176 772 -> 168 600.
- pop_floor_bake: окно клипа проверено ВТОРОЙ раз (после того как
blit_b_clip подешевел) и снова хуже — 182 124 -> 188 460. Причина
записана в коде: у этого тайла клипа нет, блиты идут быстрым путём, а окно
их уводит в клипованный и при этом не отсеивает ни одного куска и не
режет ни одной строки. Не пробовать в третий раз.
Новый лист pop_mem_b (резидент): блит блоба из обычной памяти с теми же
клипом полосы у потолка и окном перерисовки, что у pop_blit_b.
Проверено: 8 наборов tests-host зелёные; в MAME кадр с летящими плитами и
итоговая картинка совпадают с прежними.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сцена — ур.13 комната 23, шесть плит с потолка (docs/perf_l13_room23.md).
Все замеры сняты одним и тем же прогоном «ESC -> зонды -> roomtest».
было стало
работа за кадр 1 437 150 1 051 332 -27%
зелёная (фон) 805 000 575 730 -28%
циан (спрайты) 631 800 438 546 -31%
синяя 142 830 142 830 в бюджете
Цена одной перерисовки:
RDA_CEIL_GONE (запечь колодец) 251 335 -> 137 975 -45%
RD_FLOOR (щебень на посадке) 198 805 -> 182 124 -8%
RDA_CEIL (дрожащая плита) 48 785 -> 43 543 -11%
C1. Пометки «фон трогали» в mob_render ПОДАВЛЕНЫ (pop_cd_mute): коридор
куска уже помечен одним вызовом в pop_loose_mob_tick, а три блита метили
подмножества того же прямоугольника по 4 502 такта. Плюс пометка в
mob_spawn_copy — кусок, рождённый внутри тика, свою мог не получить
(слот выдаётся с начала таблицы, то есть уже пройденный). -55 тыс.
G2. Контекст тайла — file-scope, а не локали draw_tile (порт
load_curr_and_left_tile, seg008:0339). В функции 57 вызовов, и каждое
живое через вызов значение спиливалось: 26 байт кадра и 513 обращений
`-N(ix)`. Стало 33 обращения, кадра нет, банк 7 -703 Б. -55 тыс.
G1. Окно клипа для точечной перерисовки (pop_t_win_set/clear). В
pop_ceil_bake_empty куски ряда 0 высотой 63 px рисовались целиком, хотя
восстановить надо девять строк: блит стоил 21 447, стал 7 619. -90 тыс.
В pop_floor_bake окно попробовано и ОТКАЧЕНО (там нет клипа, блиты шли
быстрым путём, а окно уводило их в blit_b_clip и не отсеивало ничего:
187 266 -> 198 279) — вернуться после G3, запись в коде.
G3. blit_b_clip: байтовый габарит + file-scope вместо локалей. Одного
байтового габарита НЕ ХВАТИЛО (кадр остался, 211 -> 173 обращений) —
значений, живых через шесть вызовов ядер libbgi, больше, чем регистров.
С file-scope: 51 обращение, кадр 22 -> 12 Б. Клипованный блит подешевел.
C4. Падающий кусок КЛИПУЕТСЯ сам, вместо чистки бортов после. Раньше он
рисовался в борт целиком и взводил border_dirty, а pop_room_clip_borders
стирал две полосы 320x28 — 150 978 тактов в каждом кадре, пока хоть один
кусок торчит выше поля (гряда 13-го рождается ровно у потолка, y=2), то
есть почти весь каскад. Стало 1 722. -138 тыс.
Заодно blit_b_oversize больше не ходит через blit_b_clip (у того габарит
теперь байтовый) — рисует напрямую gfx_blit_part. Это путь под
полноэкранные подложки интро/финала (320x200, docs/perf_l13_room23.md §5).
Проверено: 8 наборов tests-host зелёные; в MAME каскад рисуется корректно
(кадр с летящими плитами, чистые борта, итоговая картинка как до правок).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Контрольный замер перед оптимизацией (задача OPT-BLIT требовала начать с
него, а не с правок). Сцена: старт уровня 13, комната 23, шесть плит
сыплются с потолка вразнобой.
Пик логического кадра — 1 437 150 тактов при растровом кадре 430 000, то
есть 5-6 растровых кадров вместо трёх. По секциям (бюджет 400 000):
синяя 142 830 (в норме), зелёная до 805 000, циан до 632 000.
Что нашлось (всё подтверждено зондами, не гипотезы):
RDA_CEIL дрожащая плита-потолок 48 658 x до 6 = 292 000
RDA_CEIL_GONE запечь колодец 251 023 x до 2 = 619 000
RD_FLOOR щебень на месте посадки 198 259 x до 2 = 397 000
Внутри RDA_CEIL: heal 8 491 + draw_tile 39 029, а блитов в этом draw_tile
всего 1,17 на тайл (15-16 тыс.) — то есть ~23 700 тактов чистых накладных.
Подсчёт asm сходится до процентов: у draw_tile 26-байтовый стековый кадр и
513 обращений (ix), при ~46 замеренных тактах на обращение это те же
~23 600. У blit_b_clip — 22 Б кадра и 211 (ix).
Разбор одного pop_blit_b (157 замеров быстрого пути): atlas_image+w0_map
1 086, ядро libbgi 10 422, pop_cd_touch 4 502 (28%!), unmap+возврат 264.
В mob_render эти три pop_cd_touch к тому же ИЗБЫТОЧНЫ — коридор куска уже
помечен одним вызовом в pop_loose_mob_tick: 81 000 тактов в кадре впустую.
Итог по пиковому кадру: блиты 488 100 (34%), из них «железный» минимум
пикселей ~150 000; остальные ~1 150 000 (80% кадра) — наши накладные.
Узкое место не передача пикселей, а 16-битная арифметика в IX-кадрах.
Ответ на вопрос про uint8_t: просканированы все 109 .atl и наборы
TITLE/PV — максимум по игровому кадру 56x63 (kid3), фон 48x63, спрайты
комнаты принцессы 49x60. Шире 255 только восемь полноэкранных подложек
титров/сюжета (три 320x200 и пять 256..272), и все рисуются раз на экран.
Значит горячий путь переводится на байтовые габариты целиком, а подложкам —
отдельный банк / прямой libbgi (решение пользователя).
Документы (решение пользователя — вести работу по фазам, чтобы результаты
не теряись между сессиями):
docs/perf_l13_room23.md сцена, рецепт воспроизведения, зонды, канал clog,
сводка по кадрам, габариты спрайтов
docs/perf_green_phase.md зелёная фаза: раскладка, позиции G1..G6, журнал
docs/perf_cyan_phase.md циан: раскладка, позиции C1..C7, журнал
Отдельно записан рецепт воспроизведения: сцена повторяется ТОЛЬКО
перезапуском программы (ESC → зонды → roomtest). Возврат в комнату не
годится — упавшая плита уходит в страницу уровня насовсем; рестарт уровня
не годится — гряда доваливается заочно через trob комнаты 17, пока телепорт
уносит Кида; запись отладчиком на бегущей машине затирается тем же trob.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Точки на цепочку вызовов (libbgi живёт в резиденте W1, адреса однозначны —
пересборка не нужна) разложили постоянные накладные блита:
pop_blit_b ДО вызова ядра ....... 6 683 <- НАШ код, 77% накладных
gfx_blit_noclip + _bgi_begin
+ _gfx_blit_sprite_noclip ..... 1 938
пролог _bgi_blit_rows_raw ....... 457
строчный цикл ................... 389/строку (= 198 + 5,96*32, сходится
с регрессией)
эпилог + _bgi_end + возврат ..... 1 355
«вне цикла» ..................... 8 725 при ЛЮБОЙ высоте (h=9..60)
Причина в pop_blit_b, подтверждена чтением .asm: `w`/`h` объявлены
uint16_t, 16-битные значения не влезли в регистры, и SDCC увёл функцию в
14-байтовый стековый кадр (`ld iy,#-14 / add iy,sp / ld sp,iy`), после чего
`w = img[0] | (img[1] << 8)` развернулось в ДВА ДЕСЯТКА IX-относительных
пересылок между ячейками -7..-13 кадра.
Правка: габарит читается БАЙТАМИ. Корректно по построению — обе ветки и
так требовали w<256 && h<256 (эти проверки теперь убраны как тождественные),
а кадры атласов не крупнее 32x63; формат .atl допускает больше, такой кадр
уходит на общий путь (blit_b_oversize).
Замер A/B на той же детерминированной сцене, те же выборки:
gfx_blit_noclip 13 679 -> 11 530 (-16%)
blit_b_clip 18 853 -> 15 340 (-19%)
Стековый кадр 14 -> 6 байт, _CODE -42 Б. Тайл ~107 000 -> ~95 000.
Все 8 наборов tests-host проходят, комната 23 в MAME рисуется корректно.
Плюс TASKS_OPEN.md: OPT-BLIT — руководство на следующую сессию (где ещё
uint16->uint8, как искать IX-спиллы по asm, что НЕ делать, и грабли с
несколькими экземплярами MAME на один error.log).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер (MAME, комната 23 ур.13) разложил цену одного gfx_blit_noclip
регрессией по 339 блитам, высоты 3..63:
такты = 8791 + 198.2 * h + 5.96 * (w * h)
Модель ложится на весь диапазон (32x3 -> 9 891, 32x13 -> 13 887,
32x60 -> 32 073), разброс внутри размерной группы — ТРИ такта.
Выводы:
- 5.96 на байт — предел железа: байт идёт через акселератор дважды
(burst src->память акселератора, burst ->экран), по 3 такта на проход
при системном клоке 21 МГц. Ускорять передачу нечем.
- 198 на строку — цикл _bgi_blit_rows_raw; при w=32 это половина
построчной цены.
- 8791 на ВЫЗОВ — крупнейшая статья, НЕ объяснена. Проверено, что это не
W3-скобка (в обеих половинах по 5 инструкций) и не прерывания (разброс
3 такта). Один тайл = 5-6 спрайтов ~ 107 000 тактов, из них ~50 000 —
постоянные накладные вызовов. Это и есть главный резерв.
Заодно: pop_cd_touch собирается в ПАКЕТ на тайл (pop_cd_batch_begin/end,
скобка в draw_tile) вместо вызова на каждый кусок — раньше 2 866 тактов
на спрайт, 15% цены блита. ВНИМАНИЕ: выигрыш замером НЕ подтверждён —
в захваченных кадрах скобка не сработала (блиты шли из холодной отрисовки
комнаты, мимо draw_tile). Правка безопасна по построению: объединение
прямоугольников может пометку только расширить, не сузить.
Снятые сегодня неверные утверждения (чтобы не всплыли):
- «клипованный блит дороже полного» — артефакт сравнения разных выборок
спрайтов; 32x60 с клипом до полосы стоит 7 236, то есть клип работает;
- «блиты идут программным циклом со скоростью ldir» — нет, ядро на
акселераторе, см. модель выше;
- «отложить запекание на кадр» — НЕЛЬЗЯ: запекание пишет ОЗУ-копию фона,
из которой heal восстанавливает; отложенное даёт призрак плиты.
Все 8 наборов tests-host проходят. Оснастка замера временная.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер зелёного блока (комната 23 уровня 13, MAME, такты эмулятора) показал,
что 86% его стоимости в ПОКОЕ — это pop_loose_tick, который не делает ничего.
Причина — кодоген SDCC, подтверждена чтением .asm. С `int pos` и записью
pop_loose_modif[pos] компилятор держал счётчик в IX-фрейме, каждую итерацию
заново складывал 16-битный адрес элемента, клал его в локал и тут же
вычитывал обратно парами `pop bc / pop hl / push hl / push bc`. 40 холостых
итераций (30 тайлов + 10 потолков) стоили 27 936 тактов. То же в
pop_loose_mob_tick: запись mobs[i] заставляла умножать i на sizeof(mob_t)=15
заново под КАЖДОЕ поле (.active/.clean/.x/.y), 14 пустых слотов — 35 790.
Правка — обход указателем, счётчик uint8_t, пустые слоты отсеиваются в
вызывающем цикле (а не гардом внутри mob_tick_one, до которого надо ещё
дойти). Холостая итерация стала `ld a,(de) / or a,a / jp Z` — три
инструкции вместо дюжины с обращениями к памяти.
Результат (такты MAME, холостой кадр):
циклы по тайлам 27 936 -> 9 852 (2,8x)
pop_loose_mob_tick 35 790 -> 7 416 (4,8x)
pop_loose_tick 70 866 -> 24 384 (2,9x)
ЗЕЛЁНЫЙ БЛОК 82 242 -> 35 760 (2,3x)
Банки ужались: BANK3 -90 Б, BANK7 -74 Б.
Все 8 наборов tests-host проходят; комната 23 в MAME рисуется корректно.
Плюс ВРЕМЕННАЯ оснастка замера (маркеры m9..m16, pop_dbg_kind) — она же
показала, что пик зелёного блока сидит НЕ в тряске плит, а в запекании
тайлов; разбор продолжается, оснастку снять перед закрытием темы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слой fore переведён на пометки ОБЪЕКТОВ вместо окна вокруг персонажа —
порт set_redraw_fore/redraw_frames_fore (seg007:0550, seg008:214):
pop_set_redraw_fore + pop_fore_needed + pop_fore_tile_b, пометки ставит
падающий кусок вокруг себя (draw_mob, seg007:1147).
Ключевая правка: пометки соседа считаются в ПРОХОДЕ ОТРИСОВКИ, а не в
тике. Стояли в цикле тика — то есть по позиции ДО mob_tick_one, а кусок
рисовался уже по новой; пока он летит внутри ряда, тайлы совпадают, но в
кадр пересечения границы пометка указывала на прежний тайл и колонна кусок
не перекрывала. Ровно один кадр, как и наблюдалось.
Спрайты падающего куска: obj_id = 10 (add_mob_to_objtable, seg007:1170),
части берутся по этому индексу — 70/74/72, а не 41/43/42 (плита В ПОКОЕ).
Отсюда была «цельная ровная плита» вместо двух половин со сдвигом.
Убрана подпорка «перерисовать соседний тайл поверх куска»: тащила на плиту
чужой узор и окно и стоила по полной отрисовке тайла на кусок за кадр.
Коридор heal сужен по высоте 32 -> 24 (реальный след спрайта 16 px).
gfx_set_bank вынесен из потайлового цикла в обрамление прохода.
Выяснено и зафиксировано: clip.right = 40 в add_mob_to_objtable — МЁРТВЫЕ
данные. set_clip_rect вызывается под chtab_flip_clip[chtab_id], а для
окружения флаг равен нулю — оригинал падающие куски не клипует вовсе.
Портировать нечего; две попытки это сделать были ошибочны.
Замер кадра (маркеры m1..m4, оставлены временно для проверки правок):
покой 196 614 тактов (15,2 % кадра при периоде 1 290 000), во время
падения гряды среднее 222 667, пик 1 428 990 — то есть 111 % кадра,
шесть кадров подряд за пределом. Логика неизменна (88 020), растут фон
(до 887 376) и спрайты (до 660 792).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
set_redraw_fore (seg007:0550) зовут ровно из трёх мест, и нигде нет
пометки «всё»: персонаж помечает прямоугольник своего футпринта
(redraw_at_char, seg003:0576; для Кида — объединение с прошлым кадром),
падающий кусок — соседа справа и второй тайл на границе рядов (draw_mob,
seg007:1147), анимация тайла — один тайл.
Значит пометка переднего слоя НЕ ШИРЕ нашего окна вокруг персонажа, и
полный порт objtable её не отнимает, а формализует. Главный риск снят.
Побочно найден точный рецепт для MOB-CLIP-RIGHT: оригинал не рисует соседа
поверх куска и не полагается на clip.right — он помечает соседний тайл
парой set_redraw2 + set_redraw_fore, и порядок делает всё сам. Это же
закрывает «плиту перед колонной».
Рекомендация по итогам: вариант A (полный порт).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 1 показал ошибку критерия: комнаты 13 и 18 ссылаются только друг на
друга (13.down = 18, 18.up = 13), то есть входящая связь у каждой есть, но
из остального уровня в них не попасть. Счётчик входящих их пропускал.
Заменено на BFS от стартовой комнаты по связям. Уровень 1 теперь даёт
ровно {13, 18, 24}, как и должно быть. Заодно всплыло, насколько мало
комнат реально проходимо на поздних уровнях: 13-й — 11 из 24, 14-й — 6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Посчитано скриптом по res20NN.bin для всех 15 уровней. Три критерия:
нет входов (ни одна комната не ссылается связями), нет пола (стоять не на
чем) и пол только опасный (обычного пола код 1 нет — пики/расшатанные).
Плюс спецкомнаты, которые пропускать независимо от критериев: seamless
exit 12/23 (меняет уровень), falling exit 6/1, falling entry 7/17,
спуск-через-зацеп 7/14, тень с зельем 5/24 и 13/23+13/16, где вход роняет
гряду плит. Уровень 15 (защита от копирования) — целиком.
Нужна для полного обхода комнат с первого уровня после порта слоёв.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Спрайты: у падающего куска obj_id = 10 (add_mob_to_objtable, seg007:1170),
и три части берутся из таблиц по этому индексу — 70/74/72, а не 41/43/42.
Мы рисовали плиту В ПОКОЕ, отсюда «цельная ровная» вместо двух половин со
сдвигом правой на пиксель.
Подпорка «перерисовать соседний тайл поверх куска» убрана: она тащила на
плиту чужой узор и окно, да ещё стоила по одной полной отрисовке тайла на
кусок за кадр. Клип объекта (clip.right = 40) НЕ портирован — единицы поля
не выяснены, буквальные 40 пикселей срезают задний угол плиты. Цена отказа
— правая грань куска и передние части чужих тайлов его не перекрывают
(MOB-CLIP-RIGHT, BUGS_OPEN.md).
Коридор heal сужен по высоте 32 -> 24: реальный след спрайта 16 px.
Ширина оставлена 64 — из неё используются 62, экономить нечего; заодно в
комментарии зафиксировано, что «выравнивание блока акселератора» ничем не
подтверждается и требует проверки артефактом.
docs/midtable_analysis.md — разбор слоёв оригинала и замеры:
- логический кадр 1 289 526 тактов;
- pop_redraw_needed 978 тактов (0,08 %);
- fore-проход 128 778 тактов (10 %) на ОДНОГО персонажа, но выполняется
лишь на 4 % кадров — гасит пропуск неизменившегося персонажа;
- максимум объектов на одном тайле 2 (пара из loose_fall), значит
сортировка внутри тайла бесплатна.
Вывод: единственный риск порта objtable — сохранится ли окно клипа
fore-прохода; до разбора redraw_frames_fore выбирать вариант рано.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 12 (тень) — порт seg002/seg006:
- check_shadow: подъём тени в комнате 15 по условию «меч подобран»,
init_shad_12, вход падением (seq 7);
- autocontrol_shadow_level12: ждать Кида, бой, сближение, СЛИЯНИЕ;
- общий урон (ранил тень — ранил себя) и check_killed_shadow
(убил тень — убил себя);
- таймер вспышки слияния 42 -> -1, sword_disappears при уходе из
комнаты 18, появление плит в комнатах 2/13 после слияния.
Переход 12 -> 13 бесшовный: двери у уровня нет, он кончается фактом
попадания в комнату 23 (tbl_seamless_exit); флаг pop_seamless не даёт
сбросить HP. Чит навигации по комнатам этот триггер придерживает —
иначе комнату 23 двенадцатого уровня не посмотреть в принципе.
Уровень 13 (Джафар): guard_notice_timer (фора после встречи),
on_guard_killed + Jaffar_exit, check_fall_flo (гряда плит сверху) и три
исключения loose-механики. Плюс спрайты визиря VIZIER.DAT — без них он
рисовался обычным стражем; заодно цвет из данных комнаты применяется
только к обычному стражу, как в оригинале.
Падающие плиты — семь дефектов, найденных прогонами:
- MOB_MAX 4 -> 14: check_fall_flo роняет шесть плит разом, лишние
терялись без щебня;
- одиночные сигналы посадки/провала больше не затираются в кадре;
- честный loose_fall: сбитая плита рождается в том же кадре, от места
удара, с половинной скоростью;
- при снятии плиты метится и сосед справа (висел передний торец);
- heal и отрисовка кусков разнесены на два прохода;
- куски рисуются ПОСЛЕ фона, порядок между собой — по убыванию y
(compare_curr_objs для пары 0x80 сортирует наоборот);
- wall_pattern убран из ceil_over_kid_tile: у оригинала узор из
draw_tile_bottom идёт в фон, а не в передний слой.
Хост-тесты: два новых набора, t_shadow (45 проверок) и t_jaffar (44).
TK_ROOMS 8 -> 24 — спецсобытия адресуют комнаты по реальным номерам.
Осознанные расхождения (docs/impl_diff.md): нет мигания Кида спрайтами
тени при слиянии, чит навигации не запускает бесшовный переход, чит K
убивает через штатный путь смерти.
Открытым остаётся разбор слоёв: MOB-CLIP-RIGHT, MID-OVERLAY-LAYER,
GUARD-FALLOUT-VICTORY, BG-ONCE (BUGS_OPEN.md / TASKS_OPEN.md).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача на завтра: smoke-прогон уровня 9 + регресс уровней 1-8 (слой фона
теперь весь спрашивает pop_upside). Записаны раскладка кода по банкам после
разгрузки (куча 902 -> 5795 Б), устройство переворота с замерами, два
правила, которые линкер не проверяет (банк не маппит W3; прямые вызовы
только внутри одного банка), новые тайминги моста и грабли дня — включая
баг SDCC с потерянным `return 1` и разницу логических/экранных координат
при перевороте.
Открытый риск на проверку: имена kid10_v.atl…kid27_v.atl — 9 символов до
точки при DSS 8.3.
На скриншоте пользователя после U остался чёрный квадрат в стороне от
факела, а у двух факелов пропало пламя. Причина — система координат:
pop_upside переключается в главном цикле ДО pop_flip_screen, а картинка на
экране к этому моменту нарисована ещё в прежней. pop_torch_wipe считала
координаты уже зеркально, поэтому чёрный бар ложился в отражённое место
(там и остался квадрат), а само пламя не стиралось.
Чистка теперь идёт при временно возвращённом старом значении pop_upside —
то есть ровно по той картинке, что на экране.
Полная перерисовка (прошлый коммит) чинила лишние языки огня, но стоила
6 245 477 тактов = 14.5 кадра (замер MAME) против 2.1 кадра у копии
акселератором — пользователь увидел сильное подтормаживание на U, причём в
обе стороны.
Оказалось, чинить надо ровно одну вещь: пламя факела запекается в ОЗУ-копию
(pop_torch_draw — heal'а нет, следующий кадр непрозрачно накрывает
предыдущий), и отражение утаскивало его в зеркальную позицию, где накрывать
уже нечем. Новая pop_torch_wipe(row,col) стирает запечённый кадр пламени и
возвращает фон покоя (bar + draw_tile своей ячейки и правого соседа —
канвас 16x18 в ячейке правого соседа, как в seg008:560).
pop_flip_screen снова отражает копией, но ПЕРЕД этим проходит 30 тайлов
комнаты и чистит факелы (коды 19 и 30) на обеих страницах.
Замер после правки: 904 247 тактов = 2.1 кадра — то есть чистка стоит ~7 000
тактов, а переворот вернулся к скорости копии (ускорение 6.9x против
перерисовки). tests-host 6/6.
Три правки по следам прогона пользователя на зелье инверсии.
1. ЛИШНИЕ ЯЗЫКИ ПЛАМЕНИ. pop_flip_screen отражал уже нарисованное копией
акселератора, а пламя факела ЗАПЕКАЕТСЯ в ОЗУ-копию (pop_torch_draw: heal'а
у него нет, следующий кадр непрозрачно накрывает предыдущий). Отражённое
вместе с фоном, оно оказывалось в зеркальной позиции, где накрывать его
некому — и оставалось навсегда (уходило только после входа в комнату,
который строит фон с нуля). Теперь переворот ПЕРЕРИСОВЫВАЕТ комнату тем же
приёмом, что вход (ENTER-ROOM-FAST). Это совпадает с оригиналом: SDLPoP на
need_redraw_because_flipped зовёт redraw_screen(0), а не отражает картинку
(seg000.c:928).
2. МОМЕНТ ПЕРЕКЛЮЧЕНИЯ. Зелье выпивается из play_seq, в середине кадра, и
pop_upside переключался прямо там — остаток кадра рисовался зеркально
поверх ещё неперевёрнутого фона. Разделены pop_upside_want (пишут зелье,
смерть, чит U) и pop_upside (читают слои); переключение — одно место, начало
кадра, вместе с перерисовкой. Оригиналу этого не нужно: он всегда рисует в
offscreen неперевёрнутым и зеркалит только на выводе — расхождение записано
в docs/impl_diff.md.
3. CLIP_CHAR В ПЕРЕВОРОТЕ. Граница clip_char приходит в ЛОГИЧЕСКИХ
координатах (y_clip), сравнивать её с уже отражённым top нельзя: то, что
логически выше линии, на экране ниже неё. Теперь тот же срез применяется
с другого конца — укорачивает кадр снизу (bcut), верх остаётся на месте;
строки ленты не сдвигаются, потому что зеркальная лента отдаёт их в
обратном порядке. Раньше в перевороте клип был отключён совсем, и висящий
Кид рисовался поверх плиты.
Проверено в MAME: переворот чистый, фон без остатков. tests-host 6/6.
Замеры в MAME (такты Z80, кадр = 430 000) решили вопрос однозначно:
загрузка 28 атласов Кида с HDD — 27 160 092 такта (1.26 с), 970 003 на страницу;
разворот ОДНОЙ страницы на месте — 12 645 582 такта (0.59 с), т.е. 771 такт/байт.
Даже идеальный вариант (копия accel'ом ~0.25 кадра + asm-реверс ~2.2 кадра)
дал бы ~1.05 млн на страницу — то есть в лучшем случае сравнялся бы с чтением
готового файла. На 34 страницы: 1.5 с загрузки против 20 с разворота.
* Упаковщики пишут второй набор: vflip_cols/vflip_page в pop_pack_kid.py ->
kid0_v.atl…kid27_v.atl, sword_v.atl; pop_pack_guard.py -> g0_v.atl…g4_v.atl
ТОЛЬКО для обычного стража (на уровне 9 tbl_guard_type = 0; тень рисуется
атласами Кида, скелет/визирь с переворотом не встречаются).
* pop_vflip.c схлопнулся до таблицы «страница -> зеркальная пара»; весь
побайтовый разворот и аллокация EMM удалены.
* pop_vflip_load_all (банк 8) грузит набор ОДНИМ вызовом и откуда угодно —
задел под загрузку ресурсов во время интро. Зовётся при старте и при
смене уровня, если это POP_UPSIDE_LEVEL; чит U на других уровнях поднимает
набор лениво (страховка в главном цикле).
* Стартовый уровень стал параметром сборки: make LEVEL=N (-DFIRST_LEVEL).
Проверено в MAME на уровне 9: переворот мгновенный, pop_flip_screen = 897 263
такта (2.1 кадра) — это сама копия экрана, загрузки в кадре больше нет.
tests-host 6/6.
Симптом (нашёл пользователь, ур. 9): в перевёрнутом виде Кид рисуется ПЕРЕД
передней колонной — передние грани его больше не перекрывают.
Причина: окно fore-клипа задаётся прямоугольником уже перевёрнутого спрайта,
то есть в ЭКРАННЫХ координатах, а весь отсев «задевает ли ТАЙЛ окно» считает
позицию тайла из его РЯДА (63*row + …) — величину логическую, от переворота
не зависящую. Сравнивались разные системы координат, и выбрасывались ровно
те тайлы, что должны лечь поверх персонажа.
Заведена вторая пара границ того же окна — pop_t_fclip_ly0/ly1 (логические,
ставятся в pop_fore_set_clip). По ней теперь идут ВСЕ отсевы по тайлам:
tile_in_fclip, грубый отсев в pop_blit_b, wp_vis, wpp_fill и отсев кладки в
pop_wall_b, а также выбор диапазона рядов в pop_fore_over_char. Экранная
пара осталась там, где режется сам блит (он работает с уже перевёрнутым
прямоугольником).
Проверено в MAME: колонна снова перекрывает перевёрнутого Кида.
tests-host 6/6.
Спрайты персонажей хранятся column-major (ради бесплатного H-флипа), а
вертикальное зеркало у такой раскладки — разворот байтов ВНУТРИ колонки,
чего accel не умеет. Поэтому pop_vflip.c готовит зеркальные КОПИИ страниц
атласов: копия побайтово повторяет раскладку .atl (заголовок, каталог,
смещения лент), развёрнуты только пиксели — значит вызывающему достаточно
подменить номер страницы, которую он мапит в W0.
* Кэш ленивый (страница зеркалится при первом обращении в перевёрнутом
виде) и живёт до смены уровня — зелье переключает состояние туда-обратно,
перезеркаливать по 16 КБ на каждый переворот незачем. EMM хватает: 34
страницы из ~215 свободных.
* pop_cdraw/pop_kdraw: страница + позиция (top = 191 - obj_y для персонажа,
192 - top - h для клинка). «Пропустить skip строк сверху» у зеркальной
ленты = «срезать снизу» у оригинала, поэтому клипы считаются как обычно.
* Модуль резидентный: он маппит W3 (приёмник копии) — банку так нельзя.
* pop_vflip_reset на смене уровня: копии сделаны из старых страниц.
Проверено в MAME: чит U переворачивает всё разом, Кид и факелы вверх ногами,
полоса HP на месте, бег без следов на фоне (heal бьёт в те же координаты).
ОСТАЁТСЯ (L9-CLIPCHAR): clip_char в перевороте должен резать снизу, а не
сверху — пока при pop_upside не режем вовсе. Тени/брызг это не касается.
Первый запуск после разгрузки встал в halt по мусору в 0xC40E. Причина:
pop_level_read_file и pop_kid_data_load маппят страницу данных в W3 на время
read() (файл читается по 0xC000), а модули банка исполняются ИЗ ЭТОГО ЖЕ
окна — после sprinter_page_w3() следующая инструкция приходит уже из чужой
страницы. Обе функции вернулись в резидент; в банке остался код, который
ходит через W0 (gfx_w0_map) или окон не трогает вовсе.
Правило записано в _pop_level.h и _pop_kid.h: банковый модуль НЕ маппит W3
(через W0 — можно). Поэтому же в резиденте живёт весь libbgi.
Куча 6095 Б (было 902 до разгрузки). Проверено в MAME: игра стартует,
комната рисуется, Кид управляем. tests-host 6/6.
MEM-COLD3, шаги 3-5. Кандидаты выбирались не по размеру, а по тому, КТО
зовёт: если единственный вызывающий уже в банке, код едет к нему и трамплин
не появляется вовсе.
* pop_kdraw.c -> БАНК 4 (к pop_cdraw.c): клинок в руке (pop_sword_draw) и
блит спрайта chtab_2 по id (pop_kid_img_blit). Оба звал ровно pop_cdraw,
теперь вызовы прямые — контракт и условие корректности в _pop_kdraw.h.
* pop_kboot.c -> БАНК 8: загрузка атласов Кида и страницы kid_data.bin
(раз за игру). Дескриптор страницы — _pop_kid.h.
* pop_guard_cold.c -> БАНК 8: страж идёт за Кидом + вход стража в комнату.
Оба зовёт roomtest_cold.c из того же банка — снова без трамплина.
* Из main в банк 8 уехали pop_boot (загрузка ресурсов, палитра, первый
экран), pop_level_switch (смена уровня) и pop_shutdown. FIRST_LEVEL
переехал в pop_tune.h — константа нужна обеим половинам цикла.
_CODE 23005 -> 19861, куча 3525 -> 6669 Б. Банк 8 — 6.5 из 16 КБ.
tests-host 6/6 (в наборы добавлены eng_pop_kboot и eng_pop_guard_cold).
MEM-COLD3, шаг 1-2 из плана разгрузки резидента:
* pop_redraw.c целиком -> банк 7 (к pop_room.c: он и есть единственный
потребитель разбора пометок). Два модуля в одном банке линкуются в один
сегмент — проверено на .map (BANK7 7928 -> 8556).
* pop_level.c расколот по частоте вызова: горячая половина (чтение тайлов,
связи, дверные таблицы, живые стражи — зовут все банки, местами на каждый
тайл) осталась в резиденте, холодная (чтение файла уровня, разбор комнаты,
рестарт, потабличные различия) уехала в pop_level_cold.c -> банк 8.
Общее состояние объявлено в _pop_level.h: данные банковых модулей всё
равно линкуются в общий _DATA, в банк уехал только КОД.
Побочно найден баг кодогена SDCC: `return 1;` из ветки не кладёт 1 в A —
до __banked он маскировался случайным ненулевым остатком в A. Репро и
разбор — docs/bugs/sdcc-z80-ret-const-lost/, стаб t_char починен одним
выходом через переменную. Аудит всех .asm roomtest: других мест нет.
_CODE 25628 -> 23005, куча 902 -> 3525 Б. tests-host 6/6.
Найдено пользователем: после телепорта в перевёрнутом виде ниже поля мусор.
Спрайты, торчащие в обычном виде ВЫШЕ поля (полоса кладки у потолка), после
отражения торчат НИЖЕ и лезут в борт с полосой HP.
blit_b_clip при перевороте режет по обеим границам поля всегда, а не по
pop_t_clip_top; быстрый путь pop_blit_b отдаёт клипующему всё, что выходит
за поле.
Проверено в MAME: телепорт по комнатам в перевёрнутом виде — борта чистые,
комнаты (факелы, чомперы, пол) зеркальны. Переходы между комнатами в
перевёрнутом виде работают.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Продолжение фикса решётки: первый грep пропустил заливки, где setfillstyle и
bar стоят не соседними строками. Переведены на pop_bar_black шов (col0,
бары ворот соседа) и сосед в loose_bake_empty; в pop_bg.c универсальная
заливка слоя фона (оверлеи кромки/полосы) зеркалится той же POP_FLIP_TOP.
Голых bar в слое фона больше не осталось (проверено грепом).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем: поднимающаяся решётка анимировалась неправильно, а в
углу (0,0) оставался чёрный прямоугольник — заливки шли голым bar по
НЕПЕРЕВЁРНУТЫМ экранным координатам.
Формула переворота вынесена в pop_bg.h (POP_FLIP_TOP) — одна на весь порт,
и добавлен pop_bar_black: чёрная плита банком 0x50 с учётом переворота и с
пометкой области (bar идёт мимо pop_blit_b). На неё переведены все четыре
стирания слоя фона: решётка, полоса потолка, floor_bake, loose_bake_empty.
Проверено в MAME: перевёрнутая комната чистая, артефакта в углу нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено пользователем на первом прогоне зеркального фона: чомперы
анимировались неправильно. Причина — pop_heal_off: точечные перерисовки
(чомпер, пики, loose, ворота, полоса потолка) чистят фон по КОМНАТНОЙ
координате, и при перевороте heal стирал прямоугольник не там, где тайл
рисуется. Фикс в единой точке — та же формула FLIP_TOP, что у блита.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
II.3: pop_upside проведён в единственную точку тайлового слоя (pop_blit_b /
blit_b_clip): позиция через FLIP_TOP, блит — vflip-двойник. Этим зеркалятся
полная отрисовка комнаты, точечные редрои, trob-анимации, fore-слой и кладка.
В клипованном пути обрезка сверху экрана съедает нижние строки источника,
поэтому кусок пересчитывается как sy' = h - sy - dh.
MEM-COLD2 п.1: enter_room_side/enter_room — в банк 8 (куча 450 -> 1231 Б).
Рабочие массивы комнаты остались в _DATA, в банк уехал только код.
Проверено в MAME (уровень 9, чит U): комната перевёрнута целиком, пол
вверху, дверь и кладка вверх ногами. tests-host 6/6, size-check OK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gfx_blit_noclip_vflip / gfx_blit_part_noclip_vflip / gfx_blit_part_vflip.
Ассемблера не потребовалось: у row-major вертикальное зеркало — это порядок
строк, а он задаётся ЗНАКОМ src-страйда, который _bgi_blit_rows_raw и так
принимает знаковым и патчит SMC. Даём адрес последней строки и -stride.
Клип у part_vflip свой: обрезка сверху экрана съедает НИЖНИЕ строки
источника, и первая строка обязана считаться от высоты ДО клипа (поймано
тестом — сначала формула брала уже укороченную).
tests/pageflip расширен до 8 проверок, все PASS в MAME.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вход в комнату: одна отрисовка в скрытую страницу, показ флипом, вторая
страница — gfx_copy_page(DIRECT) вместо повторной отрисовки. Процесс
рисования тайлов больше не виден, обе страницы синхронны (два снимка подряд
идентичны). Видимую страницу главный цикл теперь перечитывает у железа в
начале кадра: её меняют и вход в комнату, и переворот — оба из банка.
MEM-COLD2 п.2: guard_over_kid с хелперами (objtile_at_char/char_x_left_of/
tile_div_mod) — в банк 8. Куча 1005 -> 1574 Б, цена — один трамплин на кадр.
tests-host 6/6, size-check OK, в MAME проверены старт уровня 9, переходы
между комнатами и отрисовка стража.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Шаг II.1 плана. pop_upside/pop_upside_dirty (порт upside_down и
need_redraw_because_flipped), ветка case 4 в proc_get_object (только тоггл —
ни вспышки, ни урона, как в оригинале), сброс стартом уровня и смертью Кида.
pop_flip_screen (банк 8) переворачивает уже нарисованное двумя копиями
акселератора: VFLIP в скрытую страницу, показ, DIRECT во вторую; затем
инвалидация слотов персонажей, метки фона и полосы HP.
Чит-клавиша U — порт seg000:0793 (в оригинале переворот тоже на чите): без
неё эффект не проверить, чит-телепорт до зелья в комнате 7 не дотягивается.
Проверено в MAME на уровне 9: комната переворачивается целиком за кадр,
повторное нажатие возвращает. Персонажи и факелы пока не зеркалятся —
это шаги II.3/II.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первый шаг L9-INVERT. Разведка железа закрыта экспериментом: Port_Y можно
менять между read- и write-триггером, если перед вторым OUT стоит STOP —
приём уже был в проде в _bgi_scroll_cols_raw (указал пользователь), так что
обходной путь через ОЗУ-буфер не понадобился.
- _bgi_flip_rows_raw: построчная копия video->video с реверсом Port_Y
приёмника (DI/EI бандами по 16 строк, размер блока армируется в каждой
скобке, убывающий y1 держится SMC-ячейкой);
- gfx_copy_page(area, GFX_COPY_DIRECT|GFX_COPY_VFLIP): копия области из
неактивной страницы в активную; банк 0x50, то есть приёмник обновляется и
в VRAM, и в ОЗУ-копии — иначе heal восстанавливал бы старый фон;
- tests/pageflip: 5/5 PASS в MAME (прямая, vflip, рамка, широкая 320 двумя
проходами, источник цел).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кэш — ленивый постраничный, живёт до конца уровня; ENTER-ROOM-FAST делается
шагом 4 на готовом gfx_copy_rect. Открытых вопросов в плане не осталось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разведка железа первым шагом (смена Port_Y между burst-триггерами и
адресация двух страниц в W3), ядро копии с реверсом Y, vflip-блиты для
row-major, gfx_copy_rect экран-экран; в приложении — состояние, единая точка
пересчёта Y, фон через pop_tile.c, зеркальные кадры персонажей, клип.
Побочной задачей ENTER-ROOM-FAST на том же кирпиче.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Момент переключения зелья переворота делаем не перерисовкой комнаты, а двумя
построчными проходами акселератора (A->B с реверсом Y, B->A без): accel
читает ОЗУ-копию, поэтому копируется чистый фон и ложится и в видео, и в
копию приёмника — heal остаётся без изменений. Бюджет 768 burst-строк
(0.2-0.3 логического кадра) против почти миллиона тактов у перерисовки.
Отдельным пунктом: деления HP переезжают из column-major атласов в
const-массивы резидента, что убирает pop_kid_img_blit (334 Б) и делает
страницы kid27/g0 однородными для кэша перевёрнутых кадров.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровни 10 и 11 не приносят ни новых тайлов, ни спецсобытий (зелья только
heal / +max HP). Уровень 9 — зелья типа 4 (переворот) в комнатах 7 и 10.
Записан разбор механики оригинала и разбор цены переворота для наших двух
раскладок спрайтов (row-major бесплатно, column-major требует копий).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Спецсобытие уровня 8 (SDLPoP seg003:0545/0ABA, seg002:07EB): когда дверь
уровня открыта, а Кид остаётся в комнате 16, через 150 кадров приходит мышь,
пробегает справа налево по верхнему ряду, наступает на кнопку (0,7) —
решётка (0,3) открывается — и убегает.
Ассетов не потребовалось: кадры 186..188 идут по таблице Кида, их спрайты
(images 130..132) уже лежат в kid16.atl, отрисовка подхватывает мышь сама.
- guards.c: pop_check_mouse, autocontrol_mouse, ветки мыши в
autocontrol_opponent / play_guard / check_can_guard_see_kid;
- pop_leveldoor_open стал word, как в оригинале (в него же считается
задержка — байт переполнился бы через 255 кадров);
- leave_guard больше не записывает тень и мышь в данные уровня (seg002:02F5);
- полосы HP у мыши нет (seg000:1159);
- tests-host/t_mouse: 17 проверок, единственный набор с guards.c.
Разгрузка резидента (куча в huge упала до 879 Б): pop_start_level,
find_start_level_door, чит-навигация по комнатам и лейбл номера уехали в
roomtest_cold.c (--bank 8, n_banks = 8). _CODE 25653 -> 24825, куча -> 1707 Б;
кадровый путь не тронут.
Проверено: tests-host 6/6, make size-check OK, живьём в MAME (мышь появилась,
нажала кнопку, решётка поехала вверх, мышь ушла).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L7-FEATHER: тип 3 (уровень 7, комната 1) больше не пустой TODO.
- pop_map.c: pop_feather — счётчик кадров эффекта (POP_FEATHER_FRAMES = 225,
ванильный порог do_timers seg003:0517; привязку к звуку взять неоткуда).
fall_accel — ускорение 1 / потолок 4 (seg006:057C) вместо 3 / 33.
proc_get_object case 3: взвести эффект + зелёная вспышка на 3 кадра.
- pop_kid.c: опкод JMP_IF_FEATHER (0xF7) больше не пропускает адрес
безусловно — под пером прыгает по нему, то есть seqtbl уводит падение и
удар в ветки stepfloat/bumpfloat (плавные кадры, без урона).
- roomtest.c: pop_flash_red -> pop_flash_color (жёлтый/красный/ЗЕЛЁНЫЙ);
сброс pop_feather в pop_start_level (seg003:189).
- Цвет пузырька по ТИПУ зелья (seg008:652), чего у нас не было вовсе:
3/4 зелёный, 5/6 СИНИЙ, остальные красный. Mono-блиттера с параметром
цвета в libbgi нет, поэтому цвет запекается при упаковке: pop_pack_bg.py
кладёт те же 7 кадров ещё дважды (id 30..36 зелёные, 40..46 синие),
pop_potion_draw выбирает набор. Атлас 23 -> 37 спрайтов (+423 Б).
- Синее зелье «−HP» приведено к оригиналу (seg006:1892): своей вспышки не
ставит (красный кадр даёт общий flash_if_hurt — иначе экран красился
дважды), а на уровне зелий забирает ПОЛОВИНУ запаса HP.
Такое зелье стоит уже на пройденном уровне 2 (комната 13, тайл (1,3)),
а также ур.8 комн.2 и весь ур.15 — до сих пор оно было красным.
Тесты: phys_feather_fall_is_slow_and_harmless (обычное падение с двух рядов
разгоняется и стоит HP, под пером скорость <= 4 и HP целое); tests-host
5/5 (1727 в [phys]), size-check OK.
Расхождения с ванилью — в docs/impl_diff.md (перо ловит только Кида;
синее зелье без своей вспышки).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 7, комната 14: спуск с ряда 0 на ряд 2 через зацеп (повис на кромке
кнопки (0,2), отпустил, взялся за (2,2)) не работал ни при какой фазе и
задержке — Кид пролетал мимо и разгонялся до fall_y = 33, после чего окно
зацепа закрыто уже по скорости.
Корень: do_fall не давал curr_row выйти за 2, а check_grab целится в тайл
ряда curr_row-1 — то есть ряд 2 своей комнаты становится целью только при
curr_row == 3. В оригинале (seg005:0030) inc_curr_row безусловный, а
get_tile для ряда 3 уходит по links.down (find_room_of_tile, seg006:005D).
- pop_map.c do_fall: inc_curr_row теперь 2 -> 3; ветка «достиг y_land»
гейтится по curr_row <= 2 (при ряде 3 ни in_wall, ни land звать нельзя —
тайлов своей комнаты там нет, а до y_land[4] дело не доходит: комнату
меняет check_leave_below на y >= 211).
- pop_map.c check_action: восстановлена ветка ACT_MIDAIR (кадры 102..105,
seg006:0619) — первые четыре кадра падения, где fall_y ещё не разогнан,
зацеп не работал вовсе. Отсюда же «иногда цепляется, иногда нет».
- tests-host/t_grab.c: регресс grab_below_room_edge_window_exists — сцена
комнаты 14 + переход в 15 по pop_fell_out. До фикса ни одного зацепа,
после — окно из 8 фаз X при задержках 0..5 кадров; проверяется и
играбельность (зацеп при «отпустил и сразу зажал Shift»).
- roomtest.c + pop_guard.h: чит-навигация ставит Кида на верхний ряд в
комнате 14 уровня 7 (GRAB_DOWN_LEVEL/ROOM) — снизу этот спуск не проверить.
Общее правило (снизу вверх) не тронуто: в верхнем ряду пола чаще нет и Кид
проваливается сразу после телепорта.
Проверено: tests-host 5/5 (55 в [grab]), size-check OK, живьём в MAME —
Кид повис на кромке (2,2): frame=91 y=55 row=0 room=15.
Доски: GRAB-BELOW-ROOM в BUGS_CLOSED, GRAB-KBD-TIMING в BUGS_OPEN (отложен
пользователем до готовности всех уровней), план L7-FEATHER в TASKS_OPEN.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Всё проверено пользователем в MAME.
1. Переход 6 -> 7 падением. leave_room (seg002:0504) для «вниз» на уровне 6
из комнаты 1 даёт особый результат −2, главный цикл (seg000:0893) читает
его как ++next_level; вход на 7-м (set_start_pos, seg003:0196) ставит Кида
в комнату 17 и сразу переводит экран на комнату под ней. Проверка ОБЯЗАНА
идти раньше связи вниз: у комнаты 1 сосед снизу есть (шахта, комната 3).
2. BUG-BALCONY-RIGHT: правая половина портала не рисовалась. id 12 стоял в
FORE_ENV_IDS (в tile_table он fore_id ЗЕЛЬЯ), но зелье рисуется из
chtab_1, а в chtab_6 под этим номером — правая часть арки балкона 32x62,
идущая в backtable. Спрайт уезжал в fore-атлас, env-страница получала
дырку (idx 12: w=0 h=0). Найдено трассой POP_TRACE_BT + чтением каталога
.atl; дифф комнаты с оригиналом 1151 -> 375.
3. BUG-BELOWROW-WALL: жёлтые треугольники в шахте падения. load_rowbelow
(seg008:368) при отсутствующей комнате снизу подставляет tiles_0_empty
колонкам 1..9 и tiles_20_wall только левому краю; у нас стеной забивались
все 11 позиций.
4. BUG-MOB-MULTIROOM: честный mob_down_a_row (seg007:1387) — кусок переходит
в комнату снизу и летит дальше. Плита (1,7) комнаты 6 пролетает шахту
комнаты 7 и жмёт кнопку (2,7) комнаты 11, открывая ворота комнаты 18.
5. BUG-MOB-STALE-PAGE: чистка следа куска стояла под гейтом «в отрисованной
комнате» — улетевший вниз оставлял себя на одной из страниц навсегда.
6. BUG-CHAR-STALE-PAGE: тень застывала на двух страницах в РАЗНЫХ позах.
Прямоугольник и valid пишутся внутри `if (w && h)`, а снимок состояния
брался безусловно — при нулевом спрайте слот залипал «тихим». Остаток
(почему спрайт нулевой) заведён как SPRITE-ZERO-W0 в BUGS_OPEN.
NEXT_SESSION переписан: следующая задача — зацеп за край пола в свободном
падении (вход на уровень 7). tests-host 5/5, size-check чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. L6-SHADOW (seg002:0090 + seg002:1064). Тень встаёт при каждом входе в
комнату 1 уровня 6 и делает ОДИН осторожный шаг (Shift+вперёд) в тот
кадр, когда Кид прыгает к решётке (Kid.frame == 43, Kid.x < 128): встаёт
на closer (1,1) и роняет решётку (1,2), за порожек которой Кид цепляется.
do_init_shad переписан под таблицы init_shad_5/6 — первые 7 полей Char,
как memcpy(&Char, source, 7) у оригинала.
Проверено в MAME: «Кид прыгнул, зацепился, Тень сделал шаг, решётка
упала». Тем самым закрыт остаток GUARD-PHYS — нажатие плиты НЕ-Кидом
работает.
2. BUG-BELOWROW-WALL. При отсутствующей комнате снизу load_rowbelow
(seg008:368) подставляет tiles_0_empty колонкам 1..9 и tiles_20_wall
только левому краю; у нас стеной забивались все 11 позиций, и её
topright лез жёлтыми треугольниками под пустые тайлы нижнего ряда
(видно в шахте падения, комната 3).
Комнаты 1 и 3 уровня 6 сверены с картой SDLPoP: расхождения только силуэты
персонажей. tests-host 5/5, size-check чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Уровень 5, комната 12, тайл (0,9): чёрная дыра вместо арки. У doortop нет
своей базы (base_id 0), и рядом с lattice_down оригинал рисует пару одним
спрайтом 6, опущенным на 3 px. Ветки у нас не было.
Правка нужна В ДВУХ местах: pop_room.c (сама отрисовка) и
toolchain/render_room.py — pop_pack_bg.py собирает атлас по обходу
render_room, без этого спрайт 6 не попал бы в pop_env*.atl.
Найдено сверкой с оригиналом: prince megahit 5 --screenshot-level даёт карту
уровня, снимок MAME совмещается с клеткой комнаты, разница по яркости
показала расхождение ровно в (0,9) — 1892 пикселя до фикса, 995 после
(остаток = Кид, факел, статус-полоса). Что рисует оригинал в тайле,
показала трасса POP_TRACE_BT в add_backtable: id=6 32x63 рядом с id=85 32x4.
Методика записана в BUGS_CLOSED.md и memory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три бага одной отладочной сессии, все проверены в MAME пользователем.
1. SHADOW-FIGHT-L5. Кид доставал меч, а тень вставала в боевую стойку и
рисовалась спрайтами стража. В pop_check_can_guard_see_kid первым
множителем стояло `Guard.charid != 0`, тогда как оригинал (seg003:702)
пишет `Guard.charid != charid_1_shadow || current_level == 12`: тень
боевая только на 12-м уровне. Оба симптома шли из одной ветки
control_standing (seg005:352). Возвращён и пропущенный множитель
`Guard.direction != dir_56_none`.
2. SEAM-BUTTON-STALE. Кнопка в шве не меняла вид ни при нажатии, ни при
отжатии. Сигнатура редроя шва сравнивала room_modif, а у кнопки modif —
это индекс LINKLOC, константа уровня: нажатие живёт в doorlinks2, и в
сигнатуру не приходило никогда. seam_row_sig подмешивает бит нажатости.
Оригинал шов в этом случае не перерисовывает вовсе — расхождение
осознанное, записано как D-2 в docs/impl_diff.md.
3. BUG-POTION-STRIPE. Синяя лента паласа пропадала на месте выпитого зелья
и не возвращалась даже после перезахода в комнату. У оригинала
curr_room_tiles — сама таблица уровня, у нас fg живёт в двух местах, и
страницу правил главный цикл уже после pop_process_trobs. В этот зазор
trob зелья крутил фазу пузырька поверх обнулённого модификатора:
bubble_next_frame(0) = 1, а для пола в паласе modif 1 означает «узор не
рисовать» (seg008:499). do_pickup теперь правит страницу уровня сразу.
Тем же лечится меч: animate_sword делал `--mod[tp]` из нуля.
tests-host 5/5 (добавлена заглушка pop_level_set_tile), size-check чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом: Кид встаёт на плиту, которая физически в СОСЕДНЕЙ комнате (стоя в
шве, curr_col = −1), и вместо одних ворот открываются двое — на уровне 5
плита комнаты 11 открывала и нижние ворота комнаты 24, и верхние, хотя
связана только с нижними.
Причина. check_press читал тайл через get_tile (тот резолвит шов в
соседнюю комнату), а комнату и tilepos для trigger_button брал из
координат персонажа: (своя комната, row*10 + curr_col). При curr_col = −1
это tilepos 9 СВОЕЙ комнаты — там стена с bg = 0, то есть индекс цепочки
LINKLOC 0. Цепочка от нуля в данных уровня 5 ведёт на ДВА тайла: нижние
ворота (link[0], next=1) и верхние (link[1]) — ровно то, что наблюдалось.
Настоящая кнопка имеет индекс 9 и одну цель.
В оригинале этого нет по построению: get_tile зовёт find_room_of_tile
(seg006:005D) и ПЕРЕСТАВЛЯЕТ curr_room/curr_tilepos, а trigger_button
работает уже с ними. У нас резолв комнаты жил только внутри get_tile, а
наружу не отдавался.
Фикс: tile_room_of(col,row) — комната и tilepos клетки с учётом швов (по
образцу gate_modif, который так делал давно), check_press зовёт
trigger_button с резолвнутыми room/tilepos. Ветка loose там же оставлена
на координатах персонажа: make_loose_fall работает с g_fg своей комнаты.
Два других вызова trigger_button (зацеп за кромку, севшая на кнопку плита)
правки не требуют — оба ограничены колонками 0..9 своей комнаты.
Заведён GATE-FORE-KID (BUGS_OPEN.md): Кид, стоящий В ПРОЁМЕ ворот, виден
поверх решётки — у нас портирован только шовный случай окклюзии, а
draw_tile_fore (seg008:0D15) рисует решётку поверх персонажа и внутри
комнаты. Чинить в fore-слое отдельно, он горячий.
tests-host 5/5, make size-check OK. Банк 3: 10648 -> 10828 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт seg002: check_shadow (0064), do_init_shad (0000), do_auto_moves (1089),
autocontrol_shadow_level5 (1157) + таблица shad_drink_move (data.h:866).
Механика. При входе в комнату 24 уровня 5, если зелье (кол 3, ряд 0) ещё
на месте, слот соперника занимает ТЕНЬ — и занимает его ВМЕСТО стража:
enter_guard в этой комнате оригинал не зовёт НИКОГДА (в данных страж есть,
guards_tile[23] = 8, но check_shadow уходит в return до него — в том числе
когда зелье уже выпито). Поэтому pop_check_shadow вернула 1 = «событие
обработано, стража не поднимать», и вызов стоит ПЕРЕД pop_guard_enter.
Тень стоит, пока не откроются ворота комнаты (openness >= 80), затем
проигрывает ЗАПИСЬ ходов: подойти, взять, выпить, развернуться, уйти за
левый край. do_auto_moves — тот же движок, что у демо-режима заставки;
тонкость выбора записи (при достигнутом времени индекс сдвигается, а ход
берётся из ещё не сдвинутой записи) портирована дословно.
Три вещи, найденные по дороге:
1. ГЕЙТ ВЫКЛЮЧЕННОГО ПЕРСОНАЖА. play_guard_frame (seg000:1248) начинается
с `if (Guard.direction != dir_56_none)`, и clear_char выключает
персонажа именно так — он не трогает charid. У нас гейт был только по
charid, поэтому ушедшая за край тень продолжала тикать: do_auto_moves за
концом таблицы отдаёт последнюю запись {0x31,1} = «вперёд», тень
разворачивалась, вбегала обратно, пробегала верхний ряд и падала в проём
(наблюдение пользователя в MAME). Гейт добавлен и в pop_guard_tick, и в
pop_guard_phys_tick.
2. ЭФФЕКТ ЗЕЛЬЯ — ТОЛЬКО КИДУ. proc_get_object (seg006:16CB) начинается с
`if (Char.charid != charid_0_kid || ...) return`: предмет забирает любой
персонаж, а эффект достаётся только Киду. На этом стоит всё спецсобытие
— тень пьёт, зелье исчезает и не достаётся никому. Без проверки
hitp_delta уходил бы Киду, то есть тень его ЛЕЧИЛА бы.
3. ВЕТКА ТЕНИ в check_guard_fallout (seg002:0241): исчезает, только если
реально падает (action == 4). Была помечена в коде как «появится с
L3-SKEL» — на самом деле она про тень.
Чит ROOMNAV: телепорт ставил Кида в первый попавшийся пол, то есть почти
всегда (0,0). Теперь ищет пол С МАКСИМАЛЬНЫМ Y (ряд 2 -> 1 -> 0, по
просьбе пользователя): в комнате 24 старая посадка была вплотную к тени, и
вместо сцены кражи начиналась схватка, которой там быть не должно.
Отладочный тумблер POP_DBG_SHADOW_NOWAIT (pop_tune.h, по умолчанию 0) —
посмотреть сцену, не проходя уровень до кнопки.
Проверено в MAME (уровень 5, комната 24): при закрытых воротах тень стоит
за левым краем и не видна; с тумблером — подошла, выпила (зелье исчезло),
развернулась, ушла за край, выключилась и НЕ вернулась. tests-host 5/5,
make size-check OK. Цена: резидент +102 Б (24637 -> 24739), банк 1
+368 Б, банк 3 +11 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прогон читом ROOMNAV: телепорт в комнату 4 сразу после нажатия кнопки —
зеркала нет вовсе (тайл ставится в момент, когда дверь ДОРИСОВАЛА открытие,
43 тика анимации, телепорт успевает раньше); обычный вход в комнату —
зеркало на месте и непроходимо, кроме правильного прыжка, то есть коллизия
его видит. Сценария «постановка при игроке в комнате» в реальном
прохождении нет: дверь выхода стоит не в комнате зеркала, а при входе
комната и снимок room_fg берутся из данных уровня разом.
Решение пользователя: телепорт по комнатам — отладочный режим, чинить нечего.
Запись целиком уехала в BUGS_CLOSED.md вместе с протоколом и с указанием, чем
лечить, если симптом всё же всплывёт на моде/уровне, где кнопка и зеркало в
одной комнате.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Доска отставала от кода на три задачи — планировать по ней было нельзя.
Сверка проведена грепом по исходникам, а не по записям:
- L4-MIRROR ЗАКРЫТА: шаги 1-5 сделаны и проверены пользователем в MAME
(зеркало в атласе, постановка тайла, отражение, прыжок сквозь зеркало с
рождением тени, левый клип тени). Протокол с разбором решений — в архиве;
- L3-CHOMP и L3-SKEL закрыты ещё 2026-08-08/07 (коммиты dc0bd47, 4d4323f,
db4106a, 1461ed5), на доске значились как предстоящие;
- тайлсет palace (шаг 2 levels_plan) в коде есть целиком — pop_bg_load(type),
pal_*.atl, дворцовый wall_pattern, решётки 25-29 и в tile_table, и в
коллизии (tile_is_floor совпадает с seg006:0628);
- в GUARD-PHYS остаток пересобран по факту: check_chomped_guard сделан,
скелет в check_guard_fallout сделан, ветки ТЕНИ нет — она уехала в L5-SHADOW.
Приёмки: по решению пользователя уровни 1-4 приняты SMOKE-тестами, полные
обходы всех комнат делаются по готовности ВСЕХ уровней — L3-PASS/L4-PASS как
отдельные задачи отменены, вместо них политика приёмок в архиве.
Новая цель — уровень 5. Инвентарь res2005.bin: НИ ОДНОГО нового тайла, всё
портировано на уровнях 1-4. Единственная новая механика — спецсобытие «тень
крадёт зелье» (комната 24): заведена задача L5-SHADOW с портом по SDLPoP
(check_shadow / do_init_shad / do_auto_moves + shad_drink_move /
autocontrol_shadow_level5 + ветка тени в check_guard_fallout), включая
готовые константы и то, что у нас уже есть под это.
Заведён MIRROR-FG-STALE (низкий): place_mirror пишет тайл в данные уровня, но
не в снимок room_fg, по которому работает коллизия — если зеркало поставлено,
пока игрок В комнате 4, оно невидимо для коллизии (тень не родится). В
обычном прохождении недостижимо: дверь выхода в другой комнате. Записан
точный сценарий воспроизведения читом ROOMNAV и фикс на несколько строк.
Правило «в _OPEN только незакрытое» теперь выполняется буквально:
- bug_list.md → BUGS_OPEN.md, bug_closed.md → BUGS_CLOSED.md (ссылки
обновлены во всех документах и в комментарии pop_trob.c);
- из TASKS_OPEN убраны блоки закрытых задач (L3-CHOMP, L3-SKEL, L3-PASS,
L4-MIRROR, DRAW-CHAR), справка по связности комнат уехала в архив;
- из BUGS_OPEN убраны 8 строк таблицы закрытых багов, закрытый T-2 (уехал в
BUGS_CLOSED) и раздел «уровень 3 — неначатые задачи» (обе записи закрыты);
сводная таблица пересобрана по реально открытым записям.
Все внутренние ссылки проверены скриптом: битых якорей 0. make size-check
OK (65 программ), tests-host 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Чтобы следующая сессия не начала задачу заново: библиотечная часть закрыта
(полный набор блочных AND/OR/XOR/NOT в libbgi, tests/accop 10/10), а вид
тени отложен по решению пользователя — со ссылкой на docs/shadow_render.md.
Исходная постановка оставлена ниже как справка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NOT. У акселератора нет режима «инвертировать буфер»: буфер меняется
только на ЧТЕНИИ и только опкодами AND/OR/XOR (HL) (драйвер MAME,
update_accel_buffer), а `CPL` автомат вообще не распознаёт — инвертируется
регистр CPU, не буфер. Зато ~src = src XOR #FF, поэтому NOT собирается из
уже имеющегося: буфер := src, XOR-burst по константному блоку единиц
(common/_bgi_ones256.c), запись. Ядра _bgi_blit_{rows,cols}_not_raw.c;
между burst'ами меняется HL, поэтому каждая смена — под СТОПом (иначе fetch
операнда перезапустит burst). В колоночном варианте второй OUT Port_Y не
нужен: op-burst идёт по блоку единиц горизонтально, а Port_Y шагает только
вертикальный.
Наружу — тем же op-параметром (GFX_OP_NOT), диспетчер один раз на вызов, в
цикл по полосам/колонкам не заходит. putimage лишился попиксельного пути
ЦЕЛИКОМ: все пять операций BGI идут через акселератор и клиппируются
одинаково. tests/accop дополнен T9/T10 (NOT строками и колонками) —
10/10 PASS в MAME; tests/bgi_img P1/P2 по-прежнему PASS.
tests/convbench (новый) — замер побайтной конвертации атласа
«прозрачный #FF -> 0x00» (источник для XOR-блита): 145.3 такта/байт, то
есть 2.5× от 59 номинальных T-states цикла. 0.11 с на страницу 16 КБ,
3.2 с на все 28 страниц, 1.3 с по фактическому объёму данных (186 КБ).
applications/PoP/docs/shadow_render.md — вид Тени (два блиттера OR+XOR)
ОТЛОЖЕН по решению пользователя: пока рисуем обычной копией атласами Кида.
В документе собрано, почему в лоб не выходит (XOR несовместим с
прозрачностью #FF; операция читает ОЗУ-копию, поэтому два прохода
оригинала вырождаются в один XOR — нужен однопроходный композит
s | bg ^ s(сдвиг)), замер стоимости источника и четыре варианта. Ключ к
выбору — список кадров, которыми тень реально пользуется; снимать по факту,
когда пойдут уровни 5/6/12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Акселератор умеет не только копировать блок, но и совмещать его с
приёмником: опкод `and/or/xor (hl)` между триггерами чтения и записи
переводит внутренний автомат из «буфер := память» в «буфер <op>= память».
Операция ортогональна направлению (гориз. LD L,L / верт. LD A,A), значит
одинаково работает и для row-major картинок, и для column-major спрайтов
персонажей с бесплатным флипом.
Полный набор, по образцу существующего копирующего:
- ядра bgi256/_bgi_blit_rows_op_raw.c и _bgi_blit_cols_op_raw.c;
- строками: gfx_blit_op / gfx_blit_part_op (через _gfx_blit_full_op);
- колонками: gfx_blit_cols_op / gfx_blit_cols_part_wx_op (skip/rows/
skipw/maxw и flip — как у копирующего близнеца);
- putimage(XOR/OR/AND_PUT) переведён на accel, попиксельным остался
только NOT_PUT — закрыт пункт 2d-1 docs/TODO.md.
Одна функция на три операции: опкод патчится SMC, как размер блока и
страйд, — ветвления в цикле нет. Колоночный путь дороже строкового на
один OUT Port_Y: вертикальный op-burst шагает Port_Y, и перед записью
его надо вернуть на верх колонки (STOP обязателен — иначе fetch операнда
OUT перезапустит burst, memory/accel_operand_fetch_retrigger).
Две оговорки (в шапках модулей):
- операция ЧИТАЕТ ОЗУ-копию экрана, а не видео-ОЗУ, поэтому при банках
0x54/0x5C совмещается с чистым фоном, а не с нарисованным поверх;
- аппаратная прозрачность #FF совместима с AND (нейтраль) и OR (#FF|bg =
#FF, запись подавляется), но НЕ с XOR: там источник обязан хранить
прозрачный пиксель как 0x00.
Проверка: tests/accop (новый) — 8/8 PASS в MAME, побайтно, источник с
разными значениями по строкам И колонкам + рамка вокруг; tests/bgi_img
получил две байтовые самопроверки (COPY==источник, XOR дважды == фон),
обе PASS. make size-check: роста нет (эталон принят заново — kbdpoll
+63 Б приехал из прошлой сессии, воспроизводится и без этих правок).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Одним файлом: состояние репозитория (11 коммитов дня с хешами), состояние
окружения (образ MAME на уровне 4; как пересобрать SDLPoP с -g без
pkg-config — нужен -std=gnu99, иначе прячется strncasecmp; неприбранный
диагностический fprintf DBGMIRROR), задача на завтра и открытые пункты.
Задача на завтра — XOR/OR-блит через акселератор. Записано всё, что
установлено замером: тень уровня 4 рисуется ДВУМЯ блитами одного спрайта
Кида (seg008:1602, blitters_2_or + blitters_3_xor со сдвигом на пиксель),
обе сущности идут из chtab=2 — различие только в блиттере. Механизм на
Sprinter из accelerator_doc.txt: операцию задаёт опкод CPU между
триггерами, цена «байт / 7 МГц», операция ортогональна направлению.
Открытый вопрос честно помечен: акселератор XOR-ит ИНДЕКСЫ, а SDLPoP — RGB;
как это ляжет на нашу перепакованную палитру, надо увидеть.
Отдельным разделом — грабли дня, чтобы не повторять: не оценивать железо по
своей же memory-заметке (ошибся с accel, поправил пользователь); lldb через
FIFO ненадёжен, вместо него fprintf прямо в SDLPoP; make без hdd не
обновляет образ MAME.
Ссылка на файл добавлена в roomtest/CLAUDE.md, который грузится сам.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Найдено вопросом пользователя. У нас fore-проход зовёт каждый рисующий по
своему прямоугольнику, поэтому тайл, накрытый двумя объектами, рисуется
два раза (Кид+отражение — всегда, Кид+соперник — весь ближний бой).
В оригинале дубль невозможен по построению: redraw_at_char (seg003:0427)
только ПОМЕЧАЕТ тайлы, а set_redraw_fore (seg007:0550) —
redraw_frames_fore[tilepos] = frames, присваивание. Единственный обход
тайлов рисует передний кусок один раз.
Картинку не портит (прозрачный блит идемпотентен), тратит такты в самом
горячем месте. Запись требует СНАЧАЛА замера: окно клипа вместо перебора
тайлов в своё время дало 3.2x, и переход на пометки может часть вернуть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом (скриншоты пользователя, ур. 4): у отражения видны ноги ниже
нижней кромки зеркала, а на прыжке — голова и руки выше верхней. В
оригинале и то и другое скрыто.
Причина: над отражением не шёл fore-проход. В SDLPoP отражение уходит в
objtable (add_objtable(4), seg003:0798), и передний слой тайлов накрывает
его на общих основаниях — у зеркала это fore_id 77, ПЕРЕДНЯЯ ЧАСТЬ АРКИ.
Именно она прячет всё, что вылезло из проёма. Клип obj_clip_top этого не
делает и делать не может: при curr_row = 0 он равен y_clip[1] = 3, то есть
режет только по верху поля.
У нас отражение рисуется мимо конвейера персонажей (сокращённый путь, как
и в оригинале), поэтому проход надо звать руками — ровно как это делает
pop_char_fore для слотов: pop_fore_set_clip по нарисованному
прямоугольнику + pop_fore_over_char(&Char, ...) с ЛОГИЧЕСКОЙ x кадра.
Порядок сходится сам: pop_check_mirror стоит до отрисовки персонажей, так
что получается отражение -> его fore -> Кид -> его fore, и каждый ставит
своё окно клипа.
Банк 4: 9590 -> 9770. tests-host: все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
libbgi: новая gfx_blit_cols_part_wx — обрезка СЛЕВА (skipw) в дополнение к
верхней (skip/rows) и правой (maxw). По подсказке пользователя сделано
примитивом, а не обходным путём «нарисовать и вернуть фон поверх лишнего»:
для column-major левая обрезка стоит ровно столько же, сколько правая —
колонка это непрерывный кусок ОЗУ, меняется стартовая колонка источника и
экранная X. Внутри это уже было (так клипается левый край экрана), наружу
не было выведено. Полное тело блита колонками переехало туда,
gfx_blit_cols_part_w стала обёрткой (skipw=0). size-check: OK, роста нет
(62 программы, -22..-33 Б на пользователей блита).
Шаг 5 — клип ТЕНИ (seg008:1699): на уровне зеркала она показывается только
СПРАВА от него, obj_clip_left = 137 + (mirror_column-4)*32.
Шаг 3 — ОТРАЖЕНИЕ (check_mirror, seg003:0798): пока Кид стоит на тайле
зеркала, каждый кадр рисуется его зеркальная копия с клипом
left = (curr_col<<5)+9 и top = y_clip[curr_row+1]. Отдельной функцией
pop_mirror_draw, а НЕ третьим слотом Char — это структура самого оригинала:
отражение идёт сокращённым путём load_frame_to_obj + add_objtable(4), без
клинка, брызг, fore-прохода и пропуска кадра; гейтить всё это в общем теле
значило бы добавить ветки в самый горячий путь. Свой heal (pop_mirror_heal)
рядом с pop_char_heal, в skip-маске не участвует.
tests-host: заглушки pop_mirror_draw/pop_mirror_heal (pop_map теперь на них
ссылается), все 5 наборов прошли.
Цена: _CODE 24501 -> 24629, банк 3 10551, банк 4 8267 -> 9590.
НЕ ПРОВЕРЕНО В MAME.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт seg003:0798..08A9 + seg004:0239 + seg002:081D/1131 + seg006:1945.
- is_obstacle (pop_map.c): ветка зеркала — Кид, кадры бегового прыжка
39..43, направление ВЛЕВО -> modif = 0x56, pop_jumped_mirror = -1,
препятствия нет (пролетает насквозь).
- mirror_image / jump_through_mirror / pop_check_mirror (pop_map.c):
отражённый Char уходит в слот Guard как CHARID_1_SHADOW, guardhp =
hitp_max, у Кида hitp_curr = 1. savekid НЕ делается — как в оригинале,
отражается только копия. Полосы HP перерисует pop_hp_draw сам.
- pop_check_mirror() зовётся из главного цикла ПЕРЕД отрисовкой персонажей
(в оригинале — первая строка draw_people, seg008:228A).
- autocontrol_shadow + autocontrol_shadow_level4 + clear_char (guards.c):
тень идёт СВОЕЙ веткой целиком, к стражьему ИИ не сводится — на уровне 4
она не дерётся, а бежит влево и при x < 80 исчезает.
АТЛАС ТЕНИ — вскрылось при чтении seg006:0532. Тень вне боевых кадров
150..189 ходит по таблице КИДА, и image оттуда индексирует спрайты Кида,
а не стража. Выбор атласа в pop_cdraw шёл по СЛОТУ, то есть тень
рисовалась бы спрайтами стража. Условие вынесено в
pop_frame_tbl_is_guard() (pop_kid.c) — его теперь читают и load_frame, и
отрисовка, разъехаться не могут. Заодно из pop_load_fram_det_col выделен
pop_load_frame() без determine_col: jump_through_mirror берёт ось
отражения из curr_col, и пересчёт колонки по x там был бы вреден.
Константы MIRROR_* и DIR_56_NONE переехали в pop_guard.h — нужны и
постановке тайла (банк 6), и ИИ тени (банк 1).
Звука sound_45_jump_through_mirror в порте нет, пропущен.
Шаг 5 (клип тени слева от зеркала) НЕ сделан и оказался не однострочником:
в pop_cdraw есть клип сверху/снизу/справа, левого нет вовсе — нужен новый
примитив либо срез исходных колонок. Расписано в TASKS_OPEN.
Цена: _CODE +18 Б, банк 1 2311 -> 2367, банк 3 10328 -> 10551,
банк 4 8243 -> 8267. tests-host: все 5 наборов прошли.
НЕ ПРОВЕРЕНО В MAME — сценарий проверки записан в TASKS_OPEN.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача заведена активной на доске по решению пользователя (palace был
отложен 2026-08-04, но уровень 4 уже гоняется в MAME и зеркало —
единственное, что мешает пройти его сюжетно). Механика целиком сверена по
SDLPoP, таблица соответствий в TASKS_OPEN.
Шаг 1 — АТЛАС. tile_table[0x0D] = база 75, фронт 77 (наша таблица
совпадает с SDLPoP байт в байт). Тайла 13 НЕТ НИ В ОДНОМ уровне
статически: перебор всех 15 res200N.bin даёт ноль попаданий (санити
разбора: ур.1 без чомпера, ур.3 с 18, ур.4 с паласными 25..29). Значит
render_room эти id не увидит и на месте зеркала был бы чёрный провал —
грабли memory pop_atlas_dynamic_ids. Добавлены MIRROR_ENV_IDS = {75,77} в
pop_pack_bg.py, 77 ещё и в FORE_ENV_IDS. Оба набора переупакованы:
fore 17 -> 18 спрайтов, все страницы EMM в пределах 16 КБ.
Шаг 2 — ПОСТАНОВКА. place_mirror() в pop_trob.c по переходу
pop_leveldoor_open 0/2 -> 1 (условие оригинала, seg007:0457 — иначе тайл
ставился бы заново каждый кадр открытой двери). Пишет тайл 13 в комнату 4,
колонку 4, ряд 0; если комната уже на экране — POP_RD_FLOOR на обе
страницы. Банк 6 3450 -> 3518.
НЕ ПРОВЕРЕНО В MAME: нужно нажать плиту выхода на уровне 4 и дойти до
комнаты 4. Отдельный вопрос к проверке — не устареет ли g_fg коллизии,
если игрок окажется в комнате 4 в момент постановки.
Дальше по плану: 4 (прыжок сквозь + рождение тени), 5 (клип тени),
3 (отражение — косметика, самое дорогое).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регрессия от BUG-TORCH-CHOMP-1 (пламя перевели на запекание в фон).
Аргумент «запекать безопасно, кадры пламени самонакрываются» верен для
пикселей самого факела, но не для чужой графики в той же ячейке: пламя
рисуется в клетке ПРАВОГО СОСЕДА (seg008:560), и челюсти чомпера возвращал
поверх огня только его собственный trob — пока анимация жива.
SDLPoP так не делает: animate_torch (seg007:0241) заканчивается вызовом
set_redraw_anim_right(), который метит правого соседа, а redraw_needed
(seg008:0178) рисует его слой строго в порядке draw_tile_anim_topright ->
draw_tile_anim_right (пламя) -> draw_tile_anim (СВОЯ графика тайла). То
есть челюсти возвращаются поверх огня КАЖДЫЙ кадр факела, независимо от
собственной анимации чомпера. У нас пламя рисуется напрямую, минуя
механизм пометок, — этой второй половины не было.
Фикс: после pop_torch_draw метим правого соседа POP_RD_CHOMP на ОДНУ
страницу (факел анимируется каждый кадр -> обе страницы получат свою
перерисовку по очереди). Порядок сходится сам: блок факелов идёт до
pop_redraw_needed. Код соседа читается в том же префетче кодов тайлов
(trob_rcode[]), чтобы не свапать W0 второй раз за кадр. Банк 6 +104 Б.
Осознанное расхождение (оригинал метит соседа безусловно, мы — только под
чомпера) заведено открытым: TORCH-ANIM-RIGHT в bug_list.md. Слой
draw_tile_anim рисует ещё пики/зелье/меч, но такого соседства на уровнях
1-4 не встретилось, а безусловная пометка стоит перерисовки тайла каждый
кадр на каждый факел.
tests-host: все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обе сборки прогнаны полным циклом (make hdd -> рестарт MAME -> уровень 1),
сцена «комната 1, Кид стоит, соперника нет», скриншоты идентичны. Фазы
сняты брейкпоинтами на out (_io_border), a, медиана по 60 кадрам:
спрайты 129 568 -> 127 510 (-2 058)
работа/кадр 391 258 -> 389 221 (-2 037)
остальные фазы совпали такт в такт
Счётчик делений (bp на __divsint/__modsint/__divuchar/__moduchar с
печатью адреса возврата):
комната 1, только Кид : 1,00 __divsint/кадр (возврат 0xD3AA = pop_cdraw) -> 0
комната 3, бой стража : 2,01 __divsint/кадр -> 0
Экономия ровно в фазе спрайтов и ровно на стоимость одного вызова
(2 058 тактов против документированной оценки ~2 400).
Честные оговорки записаны в TASKS_OPEN: период цикла как был 3 растровых
кадра, так и остался (выигрыш ушёл в запас, 40 800 вместо 38 700);
остальные правки в этой сцене не срабатывают; тайминги комнаты 3
несравнимы между прогонами (живой страж + pop_char_skip_mask), оттуда взят
только счётчик делений.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Гипотеза «двухбайтная индексация съест выигрыш от сложения» не
подтвердилась. Собраны ОБА варианта, такты посчитаны по сгенерированному
asm (хвост после проверки границ):
int16_t готовое: add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)
= 132 такта, 2 304 байта
int8_t x/7: add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a /
ld h,a / add hl,de = 131 такт, 1 152 байта
Расширение знака плюс 16-битное сложение стоят ровно столько же, сколько
лишний add hl,hl при двухбайтном индексе, а обращений к памяти на одно
меньше — под wait-state'ами Sprinter (2,4x номинала именно на обращениях
к ОЗУ) байтовый вариант ещё чуть выгоднее номинала.
Банк 4: 9 394 -> 8 243 из 16 384 (свободно 8 141 вместо 6 990) — запас под
рост pop_cdraw, о котором и был вопрос.
Тест переименован в geom_mul8div7_table_rules и проверяет ОБА правила
генерации таблицы: тождество 8x/7 == x + x/7 и усечение к нулю.
tests-host: [geom] 1992 -> 3144, все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первая версия крыла только 0..255 байтовой таблицей x/7 по тождеству
8x/7 == x + x/7. Это было мимо: obj_x = 2*fwd - 116 уходит в минус, как
только fwd < 58 (левее x_bump[5]) — то есть у ЛЕВОЙ КРОМКИ комнаты, и там
мы продолжали звать __divsint.
Границы взяты из данных, а не на глаз: kid_data.bin даёт dx кадров Кида
-5..+10, стража -2..+10; при Char.x типа uint8_t и render_dx из
{-140,0,+140} полный диапазон obj_x = -416..695. SCRX[1152] кроет
-448..703 — деление стало недостижимым, оставлено страховкой.
Хранится ГОТОВОЕ значение (int16_t), а не x/7: байтовая таблица вдвое
меньше, но со знаковыми значениями требует расширения знака плюс
16-битного сложения — те же такты, что лишний add hl,hl при 2-байтном
индексе. Кодоген проверен: индекс полный 16-битный (грабли
sdcc_z80_const_ptr_index_bug обойдены отдельной uint16_t-переменной),
~130 тактов номинала против ~1 000 у __divsint.
Банк 4: 7 336 -> 9 394 из 16 384 (свободно 6 990). Таблица сверена
питоном обратно из .c (1 152 записи), правило генерации «усечение к нулю»
закреплено тестом geom_mul8div7_trunc_to_zero на целевом компиляторе.
tests-host: [geom] 1961 -> 1992, все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обход всех сгенерированных .asm (awk по `call __div/__mod/__mul` с
привязкой к строке исходника) нашёл 31 вызов в 9 модулях. Три из них
были в горячем пути:
- pop_cdraw.c calc_screen_x_coord: `x * 8 / 7` -> __divsint, 2 400 тактов
на ПЕРСОНАЖА КАЖДЫЙ КАДР (два вызова при живом сопернике). Заменено
тождеством 8x/7 == x + x/7 плюс таблица DIV7[256] в банке 4 — резидент
не тронут, обычный диапазон (obj_x 0..252 при x_bump 58..184) покрыт
целиком, деление осталось только хвостом для шва (render_dx = ∓140).
- pop_guard.c guard_col_from_x: /14 и %14 звались БЕЗУСЛОВНО, мимо
POP_TILE_DIV — единственное 16-битное деление без подключённой таблицы.
- pop_trob.c animate_chomper: `tp / 10` на чомпера каждый кадр, при том
что TP_ROW/TP_COL лежали в этом же файле, но ниже по тексту. Таблицы
подняты выше чомперов.
Остальные 25 оставлены осознанно и расписаны в TASKS_OPEN.md: хвосты за
таблицей (x вне 0..255 = персонаж в соседней комнате), намеренный
медленный хвост pop_y_to_row, недостижимая ветка pop_rnd_fit и холодные
места (вход стража, старт уровня, читы, имя файла, отладочный HUD).
В банках 4, 6, 7 теперь ноль __div*. Тождество 8x/7 закреплено тестом
geom_mul8div7_identity (перебор −420..700), таблица DIV7 сверена с x//7.
tests-host: [geom] 840 -> 1961, все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mob_tick_one (927) и mob_render (976/977) считали `(y+60)/63 % 4 - 1`
вручную, хотя pop_y_to_row — точный эквивалент этой формулы на всём
int16_t (включая усечение деления к нулю для отрицательных). В asm это
были три пары __divsint+__modsint, ~16 200 тактов (3,8 % кадра) — только
пока кусок плиты в полёте, то есть в самом тяжёлом кадре.
В банке 7 теперь ноль __divsint. Эквивалентность закреплена тестом
geom_y_to_row_matches_formula: перебор −400..400 против исходной формулы
(вызовы разбросаны по трём банкам, соблазн написать деление «по месту»
возвращается). tests-host: [geom] 39 -> 840, все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tile_at_kid (guards.c) считала колонку честным / и %, хотя резидентная
POP_TILE_DIV — это и есть tile_div_tbl оригинала, и остальной порт давно на
неё переведён. У SDCC z80 пара / и % над int это __divsint плюс __modsint,
который внутри снова зовёт __divsint — ~5 400 тактов на вызов.
Зовут её в ЦИКЛЕ по колонкам между стражем и Кидом
(check_can_guard_see_kid, seg003:761). Когда Кид у шва, его curr_col = −1,
луч тянется через всю комнату: замер дал ВОСЕМЬ пар делений за кадр,
около 43 000 тактов = 10 % растрового кадра, в фазе логики. После фикса
таких вызовов не остаётся.
Как ловилось: брейкпоинт на __divsint с печатью адреса возврата дал
ret=C033 восемь раз за кадр; остановка на нём с dasm при замапленном банке
показала HL−65 / ld de,#14 / call __divsint по смещению 0x24 банка 1.
Снята и ложная тревога из прошлого коммита: пролог pop_char_fore на шве НЕ
разбухает до 134 730 — это была ошибка зонда (адрес fore_tile в банке
совпадает с кодом других банков, в интервал попадали чужие срабатывания).
Чистый замер: пролог 16 950, как и в середине комнаты. Урок записан в
«Как мерить» в docs/perf_backlog.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три шага пункта 0 из docs/perf_backlog.md.
1. Ранний выход из wall_pattern_palace по окну fore-клипа: узор целиком
лежит в x [xh*8, xh*8+32), y [dmy-59, dby], и если окно его не задевает
— возврат до первой заливки. Замер: от входа в узор до конца всего
прохода 6 055 тактов вместо ~60 000 на тайл.
2. Отсев КАЖДОГО декаля (wp_blit) по реальному габариту. pop_blit_b тоже
отсеивает до маппинга страницы, но по заведомо большему 64x64 — куски
кладки высотой 3..12 px он пропускал и платил полный
atlas_image + gfx_w0_map (~9 760 тактов на блит), чтобы там обнаружить,
что рисовать нечего. Размеры сняты из каталогов атласов и заданы верхней
границей по группе.
3. То же для ПОДЗЕМЕЛЬЯ: ранний выход wall_pattern (габарит выше — левая
марка уходит на dby+POP_YOFF-67) плюс wp_blit на RNDBLOCK, обоих
разделителях и обеих марках.
Замер (уровень 4, комната 18, Кид сдвигается читом ] по пикселю, skip
выключен, шесть тайлов в fore-окне, три из них — дворцовая стена):
тайл стены ~12 700 вместо 60 000-74 000, fore-проход целиком 70 681 вместо
171 693, работа за кадр 419 839, период 3 растровых кадра вместо 4.
Уровень 1 (подземелье) проверен снимком — кладка, марки, разделители на
месте.
Остаток в проходе — пролог pop_char_fore 17 208 тактов (пункт 1 backlog'а).
Отдельно записано: на ШВЕ пролог разбухает до 134 730, причина не разобрана.
BUG-CHEAT-FIGHT-1 (заведён 2026-08-07) закрыт по своему же плану: ветка
ROOMNAV после kid_init/pop_kid_hp_reset гасит состояние схватки
(Kid.sword = SWORD_0_SHEATHED, holding_sword, offguard, guard_refrac).
Меч в инвентаре не теряется. Это болезнь чита: в оригинале телепорта между
комнатами нет и в режим боя без соперника попасть нечем.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ОПТИМИЗАЦИЯ. Метка «фон трогали» была одним union-прямоугольником на
страницу, и три факела комнаты склеивались в полосу x 40..280 на всю
комнату — Кид, стоящий между крайними факелами, терял пропуск перерисовки
и каждый кадр платил полным fore-проходом. Теперь это маска тайлов
(uint16_t pop_cd_dmask[2][3]: бит = колонка, слово = ряд, набор = страница),
проверка — три AND через резидентный pop_cd_hit. Гранулярность тайла — это
гранулярность оригинала (redraw_frames_anim[tilepos], set_wipe;
подтайловое уточнение там только по высоте, wipe_heights). Замер на (1,7):
циан 233 515 -> 24 781, работа за кадр 517 609 -> 306 553, период 4 -> 3
растровых кадра.
BUG-LOOSE-BUTTON-1. Упавшая плита не нажимала кнопку. Три слоя: порт
loose_land не звал trigger_button вовсе; pop_room_col_landing считала
площадкой только чистый пол, а у оригинала их семь (пол, пика, обе кнопки,
зелье, оба факела); сигнал приходил в момент ОТРЫВА плиты, из-за чего
ворота начинали открываться, пока она ещё в воздухе. Нажатие в оригинале
ОДНО, но с button_type = tiles_14_debris — это «открыть НАСОВСЕМ»
(modifier 0xFF), и кнопка съедается. Посадка в комнате снизу переехала на
новый сигнал pop_loose_exit (взводит mob_tick_one, когда кусок ушёл ниже
поля).
BUG-GATE-FF-1. 0xFF был перегружен: сторожевое «тайла нет» в gate_modif и
живое «открыто навсегда» из trigger_gate. gate_passable заворачивал Кида в
воротах, нарисованных открытыми. Мёртвая ветка убрана.
BUG-TORCH-CHOMP-1. Чомпер (0,7) комнаты 23 healит x 224..255 / y 30..93 и
стирал пламя факела (0,6), которое рисуется в ячейке правого соседа. Фикс —
запекать пламя (GFX_BANK_NORMAL): у факела все девять кадров на общем
канвасе 16x18 и непрозрачны, протухнуть в ОЗУ-копии нечему, а heal чомпера
сам возвращает огонь и кладёт челюсти поверх — z-порядок как в оригинале.
Пузырёк зелья так нельзя (ползёт вверх, нужен heal) — остаётся в SPRITE.
Заведено открытым: died_on_button (seg007:776) не портирован — нужен тайл
tiles_5_stuck в атласе.
Проверено пользователем в MAME (уровень 4: плита 16(1,1) -> кнопка 17(0,1)
-> ворота 23(0,9); комната 23 с чомпером и факелом); make -C tests-host —
все 5 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUG-GUARD-IX-1 — «зависание» в бою со стражем. Не зависание: главный цикл
крутился, а отладочная локаль frozen сама становилась ненулевой. Корень —
у main затирался IX (0xBFFA -> 0xBF00), и все его локали адресовали живой
стековый мусор. Затирал check_chomped_guard: coll_row() пишет по
flags + scan_off, длину берёт из win_lo/win_hi, а scan_off выставляла
только coll_scan_prepare() из пути Кида. Ряд стража писался по смещению
Кида длиной стража и при Киде у правого края комнаты вылезал за flags[13]
— прямо в сохранённый IX. Фикс: coll_scan_prepare() в начале
get_row_collision_data(); заодно чинится расчёт (scan_left0 задаёт x
колонок, чомпер-коллизия стража считалась по координатам Кида).
На уровне 1 не проявлялось: бой идёт левее середины, запись оставалась
внутри массива — данные были неверны молча.
BUG-GATE-SEAM-ROW1 — решётка в шве не анимировалась. Плита комнаты 1
открывает ворота комнаты 8 в (1,9), видимые через левый шов, а
change-driven редрой смотрел только m[9] (ряд 0) и перерисовывал жёстко
draw_tile(0,0). На уровне 1 та же связка работала лишь потому, что
решётка соседа стояла в (0,9). Фикс: сигнатура по всем трём рядам,
маска изменившихся рядов в seam_rows (переживает оба кадра дабл-буфера),
pop_room_redraw_seam_left(rows) перерисовывает только помеченные. Плюс
ряд выше (changed | changed>>1): верх решётки (draw_tile_anim_topright,
seg008:0568) рисует тайл над-справа от ворот, без этого чёрный
треугольник над ними оставался статичным.
Проверено пользователем в MAME (бой в комнате 18; анимация решётки в
стартовой комнате), детекторы IX висели без починки и не сработали;
make -C tests-host — все 5 наборов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разбивка логического кадра брейкпоинтами (уровень 4, Кид неподвижно на
(1,7)): работа 517 609 тактов = 120% растрового кадра, период 4 кадра.
Циан (PROF(6)) — 233 515 = 54% растрового кадра, из них
pop_char_fore(KID) = 171 693.
Внутри fore-прохода шесть тайлов, и два из них — СТЕНА ряда 2 под ногами
Кида — стоят 74 310 и 65 553. Дворцовая кладка на тайл: 6 wpp_fill
(~3 100) + 5 pop_wall_b (~9 760) ≈ 60 000. Окно клипа в этот момент
x 229..241, y 106..147 (прочитано из pop_t_fclip_*), верхний кусок узора
стоит на y=157 — не пересекается вовсе, тайл (2,6) промахивается и по x.
То есть 139 863 такта за кадр рисуют ноль пикселей; снятие уводит период
с 4 растровых кадров на 3.
Записано пунктом 0 в docs/perf_backlog.md с тремя вариантами лечения.
Состав узора сверен с SDLPoP (seg008.c:1943) — порт дословный.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом (наблюдение пользователя): кирпичи дворцовой кладки правильного
цвета, а швы между ними — нет.
Корень шире, чем швы. init_game_main (seg000:164) подменяет ДВЕ записи
16-цветной палитры сразу после загрузки:
// (blood, hurt flash) #E00030 = red
set_pal(12, 0x38, 0x00, 0x0C);
// (palace wall pattern) #C09850 = light brown
set_pal( 6, 0x30, 0x26, 0x14);
Подтверждено дампом палитры живого SDLPoP: PAL[6] = 48,38,20,
PAL[12] = 56,0,12. У нас в таблице VGA16 стояли стандартные VGA-цвета
(42,21,0) и (63,21,21). Цветом 6 рисуются швы дворцовой кладки
(blitters_46h_mono_6), цветом 12 — кровь чомпера и вспышка урона, так что
промах был не только в стенах.
Заодно исправлена вспышка урона в roomtest.c: было flash_bg(255,85,85)
(стандартный brightred), стало (224,0,48).
Проверка: ряд стен уровня 4 теперь совпадает с эталоном SDLPoP по всем
восьми цветам и их количествам один в один (5333/4013/3110/2065/1785/1372/
1075/447). Остаточное различие значений — только наше масштабирование
6->8 бит: (v*255)/63 против v*4 у SDLPoP, то есть (194,153,80) против
(192,152,80).
ПОПРАВКА к f107711: там записано, будто рисунок кладки не совпадает с
оригиналом из-за замены PRNG. Это неверно — POP_PRANDOM_EXACT по умолчанию
1, работает ассемблерный LCG оригинала, и совпадение счётчиков цветов это
подтверждает. Расхождения по PRNG нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Во дворце тело стены — не кирпичи-спрайты, а шесть СПЛОШНЫХ ЗАЛИВОК плюс
пять моно-разделителей поверх (seg008:1946). Порт целиком:
- gen_palace_wall_colors (seg000:1942): 3 ряда × 4 подряда × 11 колонок = 132
цвета, сид = номер комнаты, подряды 1/3 из 0x61..0x64, подряды 0/2 из
0x66..0x69, соседние по горизонтали не повторяются. Одиннадцать колонок,
а не десять: заливки 3 и 5 берут цвет СЛЕДУЮЩЕЙ колонки. Пересчёт на
смене комнаты — там же, где сбрасывается кэш кладки (wall_pattern_reset).
Таблица не static: writable-данные банка живут в _DATA/W2.
- Геометрия заливок дословно из add_wipetable(layer, left, bottom, height,
width): прямоугольник = x..x+width-1, (bottom-height+1)..bottom.
- Пять prandom(2) на тайл кэшируются так же, как подземельные решения
(wp_a/wp_b переиспользуются — наборы в одной комнате не сосуществуют).
Сохранён квирк порядка: при which_part == 0 разыгрывается ОДНО значение, и
нижний разделитель берёт ПЕРВОЕ из серии, а не пятое.
- Заливки режутся по окну fore-клипа: иначе легли бы поверх областей, которые
в этом кадре никто не восстанавливает. Вне fore-прохода — pop_cd_touch,
потому что bar идёт мимо pop_blit_b.
- wall_fram_bottom / wall_fram_main во дворце НЕ рисуются (seg008:576, 711) —
и в горячей половине слоя (pop_bg.c), и в холодной (pop_room.c).
- Упаковщик: дворцовые wall-id 3..17 пакуются силуэтом в цвете 6 общей
16-цветной палитры (blitters_46h_mono_6). В подземелье те же id —
обычные кирпичи, поэтому mono только у паласного набора.
ИЗВЕСТНОЕ РАСХОЖДЕНИЕ: рисунок цветов не совпадает с SDLPoP попиксельно,
потому что наш prandom — 16-битный xorshift, а не LCG оригинала (замена
сделана раньше по бюджету кадра, prng_alternatives.md). Совпадают
геометрия, диапазоны цветов и правило «соседние не повторяются».
Проверено в MAME: уровень 4 — песочный мрамор с разделителями, структурно
как эталон SDLPoP; уровень 1 не изменился. tests-host 5/5.
Остаётся расхождение по двери уровня (мы заполняем проём плетёнкой целиком,
оригинал рисует несколько кусков лестницы) — отдельным шагом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт семи мест seg008, расходящихся по tbl_level_type, плюс общая дыра
порта, которая на дворце стала видна.
Паласные ветки (все — «в подземелье этого нет»):
- doortop_fram_top / doortop_fram_bot (seg008:413, 506): декоративная панель
над воротами. У шва она и есть тот «ковёр», которого не хватало.
- stripe_id соседа слева (seg008:486): орнаментная лента под окнами. Она
непрерывная, потому что stripe_id = 145 у пола, кнопок, зелья, loose,
чомпера и меча; без неё лента шла кусками (только blueline).
- полоска на стене id 84 (seg008:510), при (modifier & 0x80) == 0.
- blueline_fram3: условие `num == !!level_type` — в подземелье пропускается
num==0, в паласе num==1 (seg008:501).
- левая половина кнопки-opener без пола (id 148) — только подземелье
(seg008:628).
- склянка зелья: id += 2 во дворце (seg008:747).
- remove_loose возвращает ТИП УРОВНЯ, и он ложится модификатором пустой
клетки от упавшей плиты (seg007:846/1083).
Потерянный вывод (НЕ паласное расхождение, просто заметили здесь):
draw_tile_anim_topright (seg008:0568) не был портирован вовсе — верх ворот,
который рисует тайл НАД ними: маска 68 (mono, чёрным) + door_fram_top
[(modifier>>2) % 8] = 60..67. Ids 60..68 в атлас не паковались. Симптом —
чёрный клин над воротами; нашёлся сравнением с эталоном SDLPoP
(--screenshot) и трассой add_backtable.
Флаг тайлсета pop_palace вынесен в резидент (pop_tile.c): по нему расходятся
ветки в банке 7 (полная отрисовка), банке 2 (fore-проход) и банке 3
(модификатор пустой клетки) — читается напрямую, без трамплина.
Известное расхождение: модификатора ряда СНИЗУ у нас нет (pop_t_below —
только fg), поэтому паласная панель над воротами в комнате снизу не
рисуется. Помечено в коде.
tests-host: 5/5 (добавлен include-путь до pop_bg_atlas.h и стаб pop_palace).
Проверено в MAME: уровень 4 совпадает с эталоном SDLPoP в рядах 0-1
попиксельно (кроме фазы пламени); уровень 1 не изменился.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом (эталон SDLPoP против нашего кадра, уровень 4): дворцовая геометрия
рисовалась подземельными красками — сине-серые арки вместо песочных,
бирюзовая дверь уровня вместо кремовой, сланцевый пол вместо
коричнево-розового. Бирюза и зелень — это dungeon-слоты 0x5E (0,117,76) и
0x5F (0,165,157).
Причина в порядке старта: атласы (и вместе с ними палитра тайлсета) грузятся
ДО initgraph, потому что тот снимает DSS-страницу W0. А kid.pal — ЕДИНАЯ
игровая палитра, собранная из VDUNGEON (pop_pack_kid.py build_palette), —
читается ПОСЛЕ initgraph и затирает слоты 0x50..0x6F.
pop_bg_pal_apply() возвращает 32 записи текущего набора; зовётся сразу за
gfx_pal_sync(). На смене уровня палитра по-прежнему едет внутри
pop_bg_load — там initgraph давно позади.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт tbl_envir_ki[tbl_level_type[level]] (seg000:1108): оригинал под один и
тот же набор id грузит РАЗНЫЙ .DAT — VDUNGEON или VPALACE.
Упаковщик (toolchain/pop_pack_bg.py):
- аргумент набора: `pop_pack_bg.py dungeon|palace`. Каскад каталогов —
сначала свой набор, потом чужой фолбэком (в распакованном data/ res230/
231/348 есть только в VDUNGEON, два десятка — только в VPALACE).
- ОБА набора пакуются по одному объединению id, поэтому раскладка
id -> (страница, idx) общая и заголовок один: коду достаточно подменить
имена файлов.
- PALACE_ENV_IDS: 78/80/82 (doortop_fram_bot), 81/83 (doortop_fram_top),
84 (полоска стены), 145 (stripe_id) — их рисует только палас, render_room
про них не знает.
- ENV_SHIFT 5 -> 4: с паласными кусками страница 2 переваливала за 16 КБ
(16 996). Цена — 10 страниц EMM на набор вместо 5, при 215 свободных.
- *tile.pal: 32 записи (env 0x50..0x5F + wall 0x60..0x6F) на набор. Полная
kid.pal не трогается — Кид, страж, меч и зелья в других слотах.
Движок:
- pop_level_type() (tbl_level_type, SDLPoP data.h:840): дворцовые уровни
4, 5, 6, 10, 11, 14. Живёт в pop_level.c, потому что по типу расходятся
не только атласы, но и ветки отрисовки seg008, кладка стены и модификатор
пустой клетки от упавшей плиты (remove_loose, seg007:0EB8).
- pop_bg_load(set): no-op при том же наборе, при смене выгружает старый
(иначе текут 12 EMM-страниц) и правит 32 записи палитры в ОБЕ страницы
дабл-буфера. Зовётся на старте и на границе уровня, не в кадре.
- Путь к атласу склеивается на месте (bg_path): двадцать строк-имён в банке
— лишние полкилобайта.
Проверено в MAME: уровень 1 (подземелье) рисуется как прежде; уровень 4
(-DFIRST_LEVEL=4) — дворцовые арки, окна, пол, решётчатая дверь уровня,
Кид не перекрашен. Стены пока чёрные: паласный wall_pattern — шаг C.
tests-host: 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Клипованный путь вынесен в отдельную функцию blit_b_clip: под его девять
16-битных локалей SDCC заводит кадр IX, и за этот кадр платили ВСЕ блиты
фона, включая те, где клипа нет вовсе (весь фон вне fore-прохода — факелы,
зелья, перерисовка тайлов, у них pop_t_fclip_on == 0). Быстрый путь идёт
сразу в gfx_blit_noclip.
Замер: pop_torch_draw 41 778 -> 31 218 тактов на факел (часть разницы —
прошлая правка pop_cd_touch; чистый вклад этой ~6 300 на блит). Поведение
не изменилось: клипованная ветка перенесена дословно.
docs/perf_backlog.md — отложенные идеи с измеренной ценой (футпринт из
физики 11 574, размеры ленты из каталога атласа, один map/unmap на группу
блитов, единый проход по тайлам как redraw_needed_tiles, objtable,
отложенные таблицы back/mid/fore) плюс раздел «как мерить»: wait-state'ы
дают 2,4x к справочным тактам, кадр 430 000, адреса символов меняются
после каждой пересборки, сцена между сессиями не воспроизводится.
Там же — что уже проверено и НЕ сработало.
tests-host: 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сближение с SDLPoP, замером НЕ подтвердилось — фиксирую как есть.
- FORE_ANY[32]: есть ли у кода тайла хоть что-то в переднем слое. Порт
ранней проверки оригинала `if (tile_table[curr_tile].fore_id == 0) return;`
из начала ветки default в draw_tile_fore (seg008:0D15), расширенной нашими
спецслучаями (стена 20, пики 2, чомпер 0x12, нижняя грань loose 11 — они
рисуются мимо fore_id). Была в самом конце цепочки сравнений.
- ft_code/ft_x/ft_dmy: контекст тайла считается один раз на тайл, как глобалы
curr_tile/draw_xh/draw_main_y у load_curr_and_left_tile. Код тайла читался
дважды (fore_tile и снова fore_only_tile), координаты — в каждом листе.
Замер (Кид на 2,4 в щебне, MAME): pop_fore_over_char 58 764 -> 58 866, то
есть в пределах шума. Ранний выход не срабатывает — в футпринте Кида тайлы
почти всегда С передним слоем; снятое второе чтение кода съедено проверкой
FORE_ANY и записью контекста. Отрисовка не изменилась: щебёнка блитится теми
же параметрами (x=150 y=208 sx=22 w=10 h=2).
Где время на самом деле: ~16 000 тактов фиксированных накладных на КАЖДЫЙ
блит фона независимо от размера (atlas_image + gfx_w0_map + чтение w/h через
окно 0 + арифметика клипа + unmap). Блит щебёнки 10×2 обходится в 21 168,
факел 16×22 — в 31 000 при самом gfx_blit_noclip 8 706.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Главный цикл спейсится тремя gfx_wait_vsync, поэтому работа сверх 430 000
тактов стоит сразу целый лишний растровый кадр. Было 470 964, стало
~425 600 — игра быстрее на треть (16,7 логических кадров/с против 12,5).
- pop_y_to_row: цепочка сравнений вместо (y+60)/63%4-1. ВАЖНО: медленный
хвост вынесен в ОТДЕЛЬНУЮ функцию — SDCC видит одинаковое выражение в двух
ветках и поднимает деление в вершину, быстрые возвраты не спасают.
- col_from_x (pop_bg) и get_tile_div_mod (pop_map) — общие резидентные
таблицы POP_TILE_DIV/POP_TILE_MOD в pop_tile.c (const банка из чужого
банка не читается).
- pop_fore_over_char: расширение окна считается арифметикой, а не перебором
10 колонок и 3 рядов (условие монотонно -> границы). Формулы сверены с
прежним перебором перебором значений, расхождений нет.
- pop_cd_touch: цикл по страницам развёрнут, x+w/y+h считаются один раз.
Зовётся с каждого блита фона, стоил 6 846 тактов.
- process_trobs: tp/10 и tp%10 у факелов — таблицей.
- Пустой слот соперника (стража на сцене нет, на странице ничего не
нарисовано) считается «тихим»: ни heal, ни вход в pop_char_draw, ни
fore-проход.
Приём для поиска делений: брейкпоинт на __divsint/__divuint/__divuchar с
печатью адреса возврата (printf "%04X", w@(sp)).
Профиль остатка — TASKS_OPEN.md#draw-cost. tests-host: 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- move_coll_to_prev -> memcpy (LDIR): цикл на C пересчитывал адрес
назначения через слот кадра IX и обходился в 5 514 тактов на 14 байт.
- Окно перебора режется на НЕПРЕРЫВНЫЕ пробеги (комната слева / своя /
справа), по каждому идёт coll_scan с шагающим указателем. Прежний
«быстрый путь для окна внутри комнаты» не срабатывал почти никогда: Кид
в колонке 0 даёт окно с −1, и всегда шёл медленный сбор во временный
буфер с тернарником на колонку (1 340 тактов на колонку).
- Пролог ряда (координата грани, длина окна, смещение слота) вынесен из
тела ряда на кадр; базы соседних рядов — те же ±10 без пересчёта.
check_collisions 44 022 -> 38 334, физика Кида 67 518 -> 63 102, работа за
логический кадр 470 964 -> 466 560 (бюджет растрового кадра 430 000).
Замер итерации: пустая колонка 750 тактов, колонка-стена ~1 700.
tests-host: все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замерено брейкпоинтами в MAME (уровень 1 комната 1, Кид стоит у факела).
Калибровка, без которой цифры не сходятся: такт totalcycles != номинальный
T-такт Z80, wait-state'ы ОЗУ Sprinter дают ~2,4x (get_tile 574 против 1422).
1. Окно перебора коллизии — как у оригинала (left_checked_col..right_checked_col,
seg004:0047), было: все 14 колонок каждый кадр. Признак годности слота у
нас дешевле оригинального: не массив номеров комнат с очисткой, а границы
окна, которые move_coll_to_prev переносит в prev вместе с флагами; бамп
считается по пересечению двух окон. check_chomped_flags тоже ограничен
окном, иначе протухшие слоты дают фантомный перемол.
2. get_tile_div_mod — таблицами tile_div_tbl/tile_mod_tbl (seg006:702), было
/14 и %14. SDCC разворачивал это в __divsint + __modsint, а __modsint
внутри зовёт __divsint ещё раз: 5 400 тактов на вызов, 13 вызовов за
кадр = 16 % кадрового периода на «в какой колонке точка».
3. get_row_collision_data: ряд разрешается один раз на весь перебор (было —
get_tile на каждую колонку, 1 422 такта), грань идёт шагом TILE_SIZEX как
в оригинале, wall_type таблицей вместо switch.
Итог: check_collisions 60 888 -> 42 750, физика Кида 100 578 -> 67 518,
синяя полоса ~60 % -> ~30 % кадрового периода. Профиль остатка и следующие
цели (отрисовка Кида 47 %, process_trobs 21 %) — в TASKS_OPEN.md#draw-cost.
tests-host: все 5 наборов прошли.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Оригинал (seg004:0004) индексирует флаги перекрытия колонкой ВНУТРИ
разрешённой комнаты и хранит рядом её номер, поэтому решётка комнаты 8
остаётся в своём слоте и после перехода 8->6: переход флага 0->1 виден,
bumped() срабатывает. У нас индекс — колонка отрисованной комнаты, тот же
тайл менял слот, и enter_room вынужден был выбрасывать историю целиком —
на кадре входа бампа не было, и Кид с разбега уходил сквозь закрытые ворота.
Вариант B (сдвиг вместо тега комнаты): при БОКОВОМ переходе история не
выбрасывается, а перенумеровывается на 10 слотов. check_leave двигает x
ровно на ∓140 = 10 тайлов, координата грани едет на те же 140 вместе с
габаритом Кида — сами флаги инвариантны, меняется только номер слота.
Сдвигаются curr/above/below (prev на следующем кадре всё равно перезапишет
move_coll_to_prev), освободившиеся слоты = 3 «уже перекрывал».
Вверх/вниз и прочие входы в комнату — по-прежнему полная инвалидация.
Дословный вариант A (10 слотов + массив номеров комнат) не взят: он тянет
за собой сужение окна перебора колонок, то есть отказ от FIX_COLL_FLAGS.
Заведён docs/impl_diff.md — список осознанных расхождений с SDLPoP; правило
«фиксировать расхождение» в обоих CLAUDE.md теперь указывает туда.
tests-host: все 5 наборов прошли. Приёмка в MAME впереди.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сценарий: ур.1 комн.8, ряд 0, x=170, лицом вправо, решётка шва закрыта —
разбежаться вправо и не отпускать. Кид проходит сквозь решётку.
Корень (две трассы, наша и оригинала, стартовая позиция совпала до пикселя):
смена комнаты в этой точке ШТАТНАЯ и у нас, и в оригинале — leave_room вправо
блокируют только doortop. Оригинал держит Кида бампом СРАЗУ ПОСЛЕ перехода,
потому что check_collisions хранит флаги по паре (колонка в СВОЕЙ комнате,
номер комнаты) и сравнивает prev/curr только при совпадении номеров: решётка
комнаты 8 и до, и после перехода лежит в слоте 9 с room=8, история не рвётся.
У нас индекс — колонка относительно отрисованной комнаты, при переходе все
индексы уезжают на 10, поэтому enter_room зовёт pop_coll_invalidate, тот
подавляет бамп на кадре входа, а дальше перехода флага 0->1 уже не будет.
Что делать — порт индексации оригинала (10 слотов по колонке своей комнаты +
массив её номера); тогда pop_coll_invalidate не нужен вовсе. Осторожно: это
сердце коллизии из BUG-SEAM-PINGPONG.
SDLPoP инструментирован для сверки: POP_TRACE=1 включает покадровую печать
Char + обе пары краёв, плюс маркеры BUMPED и LEAVE. В bug_list записана и
команда сборки на macOS (штатный Makefile требует pkg-config, которого нет).
073a6e0 вернул в safe_step(distance == 0) оригинальный seq_39 — а это шаг на
ОДИННАДЦАТЬ пикселей, который останавливает только бамп. У ворот шва
(ур. 1, комн. 6) наш бамп срабатывает не на том же кадре, что у оригинала
(своя история — BUG-SEAM-PINGPONG), и Кид с разбега ПРОБЕГАЛ сквозь
закрытую решётку между комнатами 8 и 6. Мелким шагом упирался нормально —
то есть промах именно в длине шага, а не в самом бампе.
Теперь ветка разделена по препятствию:
ЧОМПЕР — оригинальный seq_39: бампа там нет по определению (is_obstacle
требует modif == 2), и шаг обязан пройти целиком, иначе Кид топчется на
1 px и не успевает между челюстями;
СТЕНА И ВОРОТА — прежний «шаг-1»: осознанное приближение, проверенное
трассой живого SDLPoP в BUG-SEAM-PINGPONG. Останется таким, пока наш
бамп не сойдётся с оригиналом покадрово.
Симптом (уровень 3, комната 22): после спуска с уступа Кид проваливался
СКВОЗЬ пол; при подъёме проскакивало лишнее движение вперёд с прыжком вверх.
Корень. play_seq держит W0 замапленным на страницу данных Кида весь цикл и
читает seqtbl прямо через окно (макрос SEQ). Вызов pop_start_chompers,
поставленный в dc0bd47 внутрь обработки SEQ_UP/SEQ_DOWN, лезет за тайлами
уровня: pop_level_access_begin/end — это ровно gfx_w0_map/unmap. После
возврата цикл продолжал читать байткод из закрытого окна, то есть исполнял
мусор.
Фикс: SEQ_UP/SEQ_DOWN только взводят флаг, а pop_start_chompers зовётся
после выхода из цикла, когда W0 уже размаплен. Эффект тот же — трасса
кадра не меняется, чомперы заводятся в том же кадре.
Заодно убрана вложенность того же рода внутри самого pop_start_chompers:
start_anim_chomper получает mod параметром, а не зовёт pop_trob_modif —
тот при первом обращении к комнате сам перемапливает W0 под её bg.
Остальные точки вызова (start_fall, land, enter_room) проверены: там окно
W0 не открыто.
safe_step при distance == 0: возвращена ветка оригинала (seg005:0604) —
seq_39 (шаг 11) вместо нашего «шага-1». Для СТЕНЫ результат прежний: первый
же dx(1) даёт бамп, ровно как в трассе живого SDLPoP из BUG-SEAM-PINGPONG.
Для ЧОМПЕРА бампа нет (в разомкнутой фазе он не препятствие), и оригинал
уносит Кида на все 11 px — а мы шагали на один. Это и был «микрошаг вместо
нормального короткого шага» перед челюстями.
ВНИМАНИЕ на приёмке ур.1: это тот самый safe_step из BUG-SEAM-PINGPONG —
проверить комнату 6, осторожный шаг вплотную к воротам шва.
BUG-CHOMP-JUMP-1 (низкий, маловоспроизводим, на пререлиз): прыжок с места
вплотную к чомперу иногда даёт кадр с отступом назад. В запись сложено всё,
что выяснено: seq_3_standing_jump состоит ТОЛЬКО из положительных dx (значит
отступ даёт bumped, а не анимация); is_obstacle для чомпера у нас совпадает
с оригиналом; прямая трасса (UP+RIGHT одновременно) отката не показала —
главная гипотеза в порядке нажатий (↑ раньше → уводит в up_pressed с
выравниванием x). Там же метод ловли.
T-2 (idle-skip) закрыт — сделан шире, чем формулировался, как DRAW-COST
шаг 1 (a25ce58).
forward_pressed (seg005:0577) исключает чомпер из правила «у стены шагаем,
а не бежим»: `edge_type == EDGE_TYPE_WALL && curr_tile2 != tiles_18_chomper
&& distance < 8`. У нас исключения не было, а wall_type(18) = 3 — чомпер
считается стеной, — поэтому из позиции вплотную к челюстям Кид сначала
делал safe_step на 1-2 px и только потом бежал. Лишние кадры шага не дают
проскочить между челюстями (поймано на приёмке, комната 3.22).
Добавлен pop_edge_tile() — порт curr_tile2 после get_edge_distance.
control_turning не трогаем: у нас он ванильный, а второе такое исключение
в SDLPoP сидит под фиксом fix_turn_running_near_wall.
Две ошибки в одном месте. 1) Кадры 106..110 и передняя кровь 119..123
упакованы в pop_fore.atl (FORE_ENV_IDS в pop_pack_bg.py), а блитились через
pop_env_b — id уходил в env-страницу 3 по индексу 10, где записи нет, и
передний слой не рисовался вовсе: Кид, стоящий ЗА челюстями, был виден
целиком. 2) В draw_tile была только backtable-часть; у оригинала фронт
чомпера рисует отдельная ветка draw_tile_fore (через таблицу он не идёт —
у записи 0x12 fore_id нулевой), поэтому передние зубья появлялись лишь там,
где по тайлу прошёлся fore-проход персонажа.
Порт SDLPoP:
animate_chomper / start_chompers / start_anim_chomper /
next_chomper_timing (seg007) -> pop_trob.c;
draw_tile_anim + draw_tile_fore, ветка tiles_18_chomper (seg008) ->
pop_room.c (низ/кровь/верх, backtable) и pop_bg.c (передний слой);
check_chomped_kid / check_chomped_guard / chomped (seg004) -> pop_map.c.
Состояние — в room_modif, как у пик и ворот: младшие 7 бит фаза 1..N,
старший бит «перемололо кого-то» (кровь остаётся на тайле навсегда).
Номер позы chomper_fram1 и передние куски лежат в РЕЗИДЕНТЕ (pop_tile.c):
их читают обе половины слоя фона, а const-таблица банка из чужого банка
не видна.
start_chompers зовётся там же, где в оригинале: SEQ_UP/SEQ_DOWN в play_seq
(pop_kid.c), start_fall и land (pop_map.c), вход в комнату (roomtest.c).
Поэтому чомперы щёлкают только пока персонаж в ИХ ряду — так в оригинале.
check_chomped_guard у оригинала отдельное тело (страж не проходит через
check_collisions). У нас та же формула уже есть в get_row_collision_data,
поэтому флаги ряда считаются во ВРЕМЕННЫЙ массив: coll_curr/above/below —
это кадр Кида, из них move_coll_to_prev берёт прошлые флаги, затирание
сломало бы ему бамп.
Период смыкания — POP_CHOMPER_SPEED в pop_tune.h (15, как в оригинале).
Заодно: устаревшая заглушка pop_fore_set_clip в tests-host была __banked,
хотя функция давно переехала в резидент — всплыло при пересборке.
Ассеты уже были упакованы (pop_pack_bg.py, 2026-08-07). Банк 6: 20.0 %,
банк 7: 39.7 %, банк 3: 53.8 %. tests-host зелёные (5 наборов).
Зубья в MAME рисуются; анимация и смерть — на ручной приёмке.
Персонаж, у которого с прошлой отрисовки ЭТОЙ страницы дабл-буфера не
изменился ни один вход отрисовки, а фон в его прямоугольнике не трогали,
уже нарисован правильно: heal, блит и fore-проход пропускаются целиком.
Не спецкейс «мёртвый страж», а общее правило — покрывает и труп, и
стоящего Кида, и ждущего стража.
Механизм: снимок входов по страницам (pop_cdraw.c, cd_sig/cd_quiet) +
позиционная метка «фон трогали вот здесь» (pop_cd_touch в резидентном
pop_tile.c, зовёт сам pop_blit_b). Решение перепроверяется перед
отрисовкой, а pop_char_draw страхуется собственным heal — если тик всё-таки
сдвинул персонажа, прошлый кадр стирается там. Слоты рядом (32 px) —
перерисовываем оба, иначе heal соседа выест кусок из «тихого».
Метка обязана быть ПОЗИЦИОННОЙ: с флагом «фон трогали хоть где-то» выигрыш
был ровно нулевым — факелы анимируются каждый кадр и гасили пропуск для
всех сразу (597 684 такта, как без оптимизации).
Замеры (MAME, брейкпоинты по totalcycles, бюджет кадра 430 000):
комн. 1.3, труп стража, Кид стоит: 210 % -> 116 % (500 772 такта),
ноль вызовов pop_heal_fast за кадр, весь фон — 2 блита (44 136);
комн. 1.1, Кид стоит вдали от факелов: 404 112 (94 %), цикл 4 -> 3 кадра.
Узкое место сместилось на ЛОГИКУ: 60 % кадра уходит на тик персонажей,
которые СТОЯТ, ещё 28 % — на loose_tick + process_trobs в комнате без
единой ловушки. Разбивка и план — TASKS_OPEN.md#draw-cost.
Комнаты 2 и 3 отличаются ровно телом убитого стража, и оно даёт +20% синей
и +50% циана, то есть ~70% кадрового периода. Причина видна в коде: ни
roomtest.c, ни pop_cdraw.c не смотрят на Char.alive — мёртвый страж каждый
кадр проходит весь путь живого (heal + блит + clip_char + брызги + клинок +
перебор тайлов fore), хотя его кадр постоянен до выхода из комнаты.
Кандидат на фикс — запечь труп в фон, как loose-плиты.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pop_bg.c разрезан по ЧАСТОТЕ вызова, а не по размеру:
pop_bg.c (банк 2) горячее fore-проход, оверлеи, кладка, клип
pop_room.c (банк 7) холодное draw_tile, точечные перерисовки, mob,
загрузка атласов
Стык — три тонкие __banked-обёртки (wall_pattern, wall_pattern_reset,
draw_gate_back): тела остаются непомеченными, поэтому горячий fore-проход,
зовущий wall_pattern до девяти раз за кадр, платит ноль, а трамплин
достаётся только холодному пути — ~40 вызовов на вход в комнату, 26 000
тактов = 0.06 кадра РАЗОВО. Общее состояние (pop_loose_modif, pop_ceil_modif,
obj_row/obj_col) писучее, лежит в _DATA/W2 и видно обеим половинам.
Заодно удалена мёртвая potion_bubble (169 Б).
Грабля: n_banks объявляет само приложение (roomtest.c), а не sprinter-cc.
Восьмой банк без правки константы линкуется молча, _bank_pages[7] остаётся
0xFF, и программа встаёт намертво до первого кадра. Диагноз снят дампом
_bank_pages из MAME.
Замер (ALLOCS=3000): BANK2 11 942 -> 5 872, BANK7 6 035; _CODE и куча не
тронуты (22 556 / 4 301).
Проверено: tests-host зелёные; построчная сверка pop_bg.c+pop_room.c против
дорефакторного pop_bg.c — ни одной строки логики не пропало; 8 комнат в MAME
до/после совпали попиксельно по активному экрану (различия только в фазе
анимации факелов и кадре Кида); живой прогон с переходами комнат, боем и
воротами сверен контрольным запуском HEAD-бинаря.
DRAW-COST поднят в приоритете: замер пользователя (ур.1 комната 3, страж
убит, Кид стоит) — синяя 80%, зелёная 20%, циан 110%, итого ~210 %
кадрового периода В ПОКОЕ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DRAW-CHAR. Отрисовка персонажа сведена к одному набору функций над Char —
как физика после GUARD-PHYS. В оригинале add_kid_to_objtable (seg008:22F0) и
add_guard_to_objtable (seg008:2324) имеют идентичное тело и различаются
окном (loadkid/loadshad), набором спрайтов и типом объекта, а
redraw_at_char/redraw_at_char2 гейтов по charid не имеют вовсе.
pop_gdraw.c -> pop_cdraw.c: pop_char_draw/heal/fore(who), слот
POP_CH_KID / POP_CH_OPP; состояние слотов pop_cd[] в _DATA — читается из
любого банка без трамплина. Проход окклюзии тоже один
(pop_fore_over_char), pop_fore_over_kid больше нет.
Починилось само (расхождения, которые и были ценой дублирования): у
соперника не было clip_char; у Кида не было клипа полем 192 и ветки брызг
«мёртв/падение»; char_width_half СТРАЖА считался по спрайту КИДА.
Замер: _CODE 24 881 -> 20 524 (куча 2023 -> 6333), BANK2 -265, итого -3.2 КБ.
Проверено пользователем в MAME; циан-полоса профиля подросла — оптимизация
заведена отдельной задачей DRAW-COST.
MEM-BANK2, шаг 1: общие «листья» слоя фона в РЕЗИДЕНТ (pop_tile.c/.h).
Ограничение платформы: писучие данные банка лежат в _DATA и видны всем, а
const-таблицы — в странице банка, из другого банка их не прочитать; трамплин
же выбирается объявлением, то есть __banked на листе бьёт и по горячим
вызывающим (654 такта). W1 замаплено всегда — оттуда обе половины зовут
листья прямым call и читают таблицы напрямую.
MEM-BANK2, шаг 2: дедуп внутри банка. wall_pattern 808 -> 394 и wall_rnd
786 -> 654: четыре ветки по виду стены отличались только набором кусков и
числами в одной серии prandom — сведены к таблицам WP_PARTS и WR_RULE,
порядок вызовов prandom сохранён дословно.
Заодно: kid_seq_off больше не static const в kid_data.h (230 Б мёртвой копии
в каждом из 9 модулей) — генератор pop_extract_kid_data.py отдаёт
макро-инициализатор, массив определяет один pop_kid.c.
Итог: BANK2 14 815 -> 11 942 (72.9 %, свободно 4442 Б), _CODE 22 556,
куча 4301 Б. tests-host зелёные (65/39/53/1723/1); в MAME комната 1
совпала с дорефакторным снимком попиксельно (0 из 227 520), комната 3 —
та же раскладка кладки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Скелет (L3-SKEL, ассеты + механика):
- pop_pack_guard.py получил параметр набора (GUARD/SKEL): атлас скелета
poc/res/skel/g0..g3.atl (28 кадров), палитра — из его res750.pal (на ур. 3
curr_guard_color = 0, оригинал палитру не подменяет);
- pop_guard_load выбирает набор по tbl_guard_type и перезагружается ПРИ СМЕНЕ
УРОВНЯ (load_lev_spr, seg000:1092) — без этого скелет рисовался атласом
стража и был невидим;
- load_frame: charid_4_skeleton идёт по таблице стража (seg006:529), тень —
только в кадрах 150..189. Пока ветка была одна (charid_2_guard), скелет
получал image из таблицы Кида (180 при 28 спрайтах) и не рисовался;
- check_skel (seg002:1042), ветка charid_4 в enter_guard, возрождение в
комнате 3 при падении (seg002:252), autocontrol_skeleton;
- leveldoor_open (seg007:456) — новый флаг, сбрасывается стартом уровня.
Цвета стражей (BUG-GUARD-COLOR-1, закрыт):
- все 7 палитр res10.bin -> pop_guard_pal.h, заливка 16 слотов по
guards_color комнаты перед отрисовкой (set_chtab_palette, seg003:257).
Проверено в MAME: ур. 2 комн. 11 = цвет 1, комн. 7 = цвет 3, полоса HP
меняется вместе со стражем. Грабля: gfx_pal_load отдаёт указатель в BIOS,
а тот читает только #4000-#BFFF — таблицу из банка копируем в стек.
Кэш соседних комнат (BUG-SWORD-GHOST-1, закрыт):
- pop_map кэширует fg соседей слева/справа ЦЕЛИКОМ и резолвит col -10..19.
Было -2..11, дальше мнимая стена: луч видимости упирался в неё (страж
после follow_guard в col 12), Кид прятал меч посреди боя и не мог достать
обратно. +48 байт W2.
Окклюзия соперника:
- pop_fore_over_char получил проход other_overlay_tile (порядок midtable,
seg008:1B06) и расширение перебора объединённым прямоугольником
«персонаж + клинок + брызги» — падающий скелет больше не рисуется поверх
кладки и верхней грани пола;
- клип полем 192 строк (reset_obj_clip, seg006:0507) для спрайта, клинка
(общий pop_sword_draw) и брызг — спрайт не залезает на полосу HP;
- ROOMNAV после смерти Кида делает честный pop_start_level: телепорт
«оживлял» мёртвого мимо старта уровня, оставляя живого скелета рядом с
вернувшейся кучей костей.
Ассеты чомпера (под L3-CHOMP): весь набор кадров в атласе явным списком
(101-105 низ, 111-113 верх, 106-110 фронт, 114-123 кровь mono-силуэтом) —
render_room анимированные тайлы пропускает, и в атласе не было ни одного.
Число EMM-страниц не изменилось.
Тесты: tests-host все 5 наборов зелёные, t_char вырос до 65 проверок
(резолв колонок за краем комнаты, возрождение скелета); в testkit добавлен
гард «код наехал на данные» (DATA_LOC).
Доски: TASKS.md разнесён на TASKS_OPEN/TASKS_CLOSED, закрытые баги с
разбором корней — в bug_closed.md; заведены DRAW-CHAR (отрисовка одна на всех
Char, как физика после GUARD-PHYS) и L3-COLOR (зелёная кладка уровня 3:
level_var_palettes = ресурс 20, есть в MSDOS/PRINCE.DAT).
В roomtest.c временно оставлен автостоп на падении соперника (отладка
падений скелета) — помечен ВРЕМЕННО.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Точки вызова стоят и в обеих ветках check_bumped, поэтому регрессия
проявится не в зацепе, а в обычном ударе о стену с зажатым Shift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L3-CHKP (чекпойнт уровня 3). Флаг взводится, когда Кид уходит ВЛЕВО ИЗ
комнаты 7, а do_startpos по нему подменяет старт на комнату 2, тайл (0,6),
лицом влево и снимает loose-плиту (7,0,4). Тонкость, на которой я сначала
ошибся: level3_set_chkp (seg002:0665) вызван из leave_room ДО
goto_other_room, поэтому `Char.room == 7` — это комната, ИЗ которой уходят,
а не в которую входят. Поймал пользователь прогоном в SDLPoP: смерть В
комнате 7 вернула его в стартовую 9, а плита осталась цела. Проверено в
MAME: вход в 7 флаг не ставит, уход влево — ставит; респавн в комнате 2;
после обычной смерти (без чекпойнта) плиты восстанавливаются как раньше.
Зацеп ПРЯМО В ПРЫЖКЕ (check_grab_run_jump, seg006:1228) — портирован за
выключателем POP_ENABLE_JUMP_GRAB. Это НЕ ваниль: в оригинале зацепиться
можно только в начале падения (кадры 102..105), то есть Shift приходится
жать уже в полёте; у SDLPoP это enable_jump_grab, и работает он лишь при
включённых fixes-and-enhancements. Три точки вызова как у оригинала:
check_action и обе ветки check_bumped (зацеп за верх стены вместо удара).
pop_tune.h — настраиваемые константы в одном месте (аналог
custom_options_type SDLPoP): чекпойнт, выключатель зацепа и отладочная
крутилка POP_DBG_GATE_HOLD (сколько кадров решётка держится поднятой;
оригинал 5, потолок 30 — таймер связи пятибитный, 31 = «заклинено»).
Задача TUNE-1 в TASKS.md: читать это из ini рядом с exe.
СБОРКА. --max-allocs-per-node снижен со 100000 (дефолт sprinter-cc) до
3000 (дефолт SDCC) через `make ALLOCS=...`, а pop_trob.c уехал в БАНК 6 —
на 3000 резидент иначе не влезает (замер: конец _HOME 0xBC69 при стеке с
0xBB00). Итог: сборка с нуля 1:48 вместо >10 минут, куча 2751 Б вместо
2298. Релизная сборка — make ALLOCS=100000; сравнивать занятость банков
можно только при одинаковом ALLOCS.
Отдельно (вне git, SDLPoP в .gitignore): из референса вычищена вся наша
отладка DBG-GRAB — трасса JMP, GRAB try/probe/fail/OK/skip, автоскриншоты
seg003, печати DBG mob/mid/overlay/kidobj и счётчик dbg_shots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Оба фикса подтверждены игрой, записи с разбором корней переехали в
bug_closed.md. BUG-GATE-PASS-1 остался ждать сценария, но его оговорка
про BUG-GATEMOD-1 обновлена (тот закрыт). BUG-SPIKE-1 понижен до низкого
приоритета: маловоспроизводим, смертельность пик подтверждена замером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверено в игре: комната 11 уровня 2, страж выталкивает Кида в 22, Кид
возвращается бегом — страж оборачивается и достаёт меч. Разбор корня
(is_guard_notice не взводился ни в одном из пяти мест оригинала) переехал
в bug_closed.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Запись переведена в «ждёт сценария»: чистый пробег по убранным пикам
убивает (трасса по кадрам в записи), а наблюдавшееся «нет урона» — это
уже выдвинутые пики, безвредные для бегущего и в оригинале. Открытым
остаётся только визуальное расхождение со скриншотом SDLPoP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Watchpoint на модификатор пики (ур.2 к.6, тайл 13) на чистом пробеге:
modif=1 — кадр 11 (беговой), x=112, col=2
modif=2 — кадр 12 (беговой), x=117, col=3 (Кид на тайле, h=2)
modif=3 — кадр 177 (frame_177_spiked) (напоролся)
То есть check_spike_below/check_spiked/is_spike_harmful и тайминг
выдвижения верны, «раннего» триггера нет. Настоящий корень: пики
ЗАЛИПАЮТ выдвинутыми — пока габарит Кида накрывает колонку, каждый кадр
start_anim_spike переставляет отрицательный модификатор обратно в 0x8F.
Выдвинутые пики (h=1) для бегущего безвредны по правилам оригинала, отсюда
обе жалобы: пробег не убивает и острия остаются на экране.
Открытый вопрос сузился до 2–3 пикселей: код start_anim_spike совпадает с
оригиналом дословно, значит на скриншоте SDLPoP Кид стоит чуть левее и
колонку пики не задевает. План закрытия — в записи.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Четыре разбора с прогонов пользователя; три бага закрыты, четвёртый
(BUG-SPIKE-1, пики) заведён с замером и гипотезой.
BUG-JUMPWALL-1 (Critical) — недолетевший прыжок проходил СКВОЗЬ стену.
check_collisions держал флаги перекрытия ОДНОГО ряда, а оригинал
(seg004:0004) — трёх, и move_coll_to_prev (seg004:00DF) берёт «прошлые»
флаги из нужного. Наш prev=3 («уже перекрывал») подавлял бамп ровно на
кадре смены ряда, а в падении ряд меняется почти каждый кадр — переход 0→1
на стене приходился как раз на него. Порт трёх рядов дословно.
Воспроизведено и закрыто на харнессе (новый набор t_wall: свип по 20
стартовым X, 4 давали проход сквозь кладку); 1723 трассы t_phys НЕ
изменились — правка поведение-сохраняющая. Живьём подтвердил пользователь.
BUG-GUARD-DEAF-1 (Major) — is_guard_notice не взводился НИГДЕ, поэтому
неактивный страж не оборачивался на Кида за спиной никогда. Портированы
все пять мест оригинала: опкод SOUND в play_seq (звуки 0..2), bumped_sound,
мягкое/среднее приземление, обрушенная плита, щелчок кнопки. Ждёт
игровой проверки боем в комнате 11 уровня 2.
BUG-SEAM-WEDGE-1 — клин кладки в пустом (2,0). Сосед угла снизу-слева
лежит в комнате по диагонали (room_BL); мы безусловно считали его стеной,
оригинал (load_rowbelow, seg008:368) — только когда такой комнаты нет.
Ряд «снизу» стал 11-байтным: [10] = тайл (0,9) диагональной комнаты.
BUG-LOOSE-3 — чёрный бар под упавшей плитой-потолком. pop_ceil_bake_empty
стирал полосу и восстанавливал только два тайла ряда −1, а в полосу лезет
графика соседа слева и верхушки ряда 0. Теперь перерисовываются ряды −1 и
0, колонки col−1..col+1. Проверено попиксельной сверкой с эталонной
перерисовкой: 0 различий.
Плюс карта связности комнат уровней 1–3 (TASKS.md): на ур. 2 недостижимых
нет, на ур. 3 это 23 и 24 — те же односторонние ссылки, что дали 13/18/24
на уровне 1, только комнаты полностью пустые.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три правки едут вместе намеренно: n_banks и --bank обязаны меняться
атомарно, иначе промежуточный коммит — зависание (см. ниже).
MEM-BANK5. CODE и DATA делят одно 32-КБ пространство W1+W2, поэтому
килобайт кода, уехавший в банк, — это килобайт, доступный данным.
Кандидат выбран не по размеру, а по частоте вызова: диспетчер управления
дёргается раз в кадр на персонажа и горячих банк→банк переходов не
создаёт (в отличие от pop_level, чей pop_level_tile зовётся из банка 2 на
КАЖДЫЙ тайл).
_CODE 26 780 -> 24 662 Б (−2 118)
куча 180 -> 2 298 Б
банк 5 2 211 / 16 384 (13.5 %)
Шина control_* (8 глобалов) переехала в pop_state.c. Сегодня она уцелела
бы и в pop_ctrl.c — банки собираются без --bank-data, их писучие данные
остаются в общем _DATA, — но это флаг сборки, а не свойство кода, а шину
трогают уже три банка: 5 пишет с клавиатуры, 1 подаёт синтетический ввод
ИИ (autocontrol_*, seg002), 3 читает через pop_ctrl_shift_held.
Заодно pop_ctrl.c наконец включает собственный заголовок — раньше
объявления жили прямо в нём.
ГРАБЛИ, на которые наступили (стоили дольше самой задачи): n_banks в
roomtest.c захардкожен, и его надо править вместе с числом --bank. С
n_banks=4 и пятым банком crt0 выделил четыре страницы, _bank_pages[5]
остался нулём, и первый же вызов pop_ctrl_init() через трамплин
отобразил в W3 страницу 0 и прыгнул на 0xC874 в мусор — исполнение
забрело в дисковый код DSS и осталось крутить чтение секторов. Симптом:
загрузка ресурсов проходит целиком (open=45 — все атласы), комната и Кид
успевают нарисоваться из enter_room, а HP и номер комнаты уже нет, и
kid_tick не вызывается ни разу. Ровно предупреждение из шапки
runtime/bank.s. Сверку n_banks с реальным максимальным индексом банка
записал в docs/TODO.md (Auto-banking) — это должно быть ошибкой сборки.
DBG-CHEATS: [ (0x54) и ] (0x5B) двигают Кида на пиксель (seg000:1828),
по фронту нажатия, под pop_cheats. Нужны потому, что мост MAME теряет
нажатия при быстрой отправке и подогнать Кида в позу скриптом нельзя —
на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.
ROOMNAV больше не зовёт pop_trob_reset: reset обнуляет room_seen, то есть
чит ОТМАТЫВАЛ МИР (открытые/закрытые ворота, нажатые кнопки). Навигация
обязана только телепортировать.
Проверено в MAME: старт уровня 1 рисуется полностью (комната, Кид, HP,
номер), бег вправо и падение на второй ряд отрабатывают, ] даёт x+1 и
[ даёт x−1 по одному нажатию, Shift+→ — осторожный шаг (x 131 -> 142,
колонка 4 -> 5) и при удержании 90 кадров не срывается в бег (x 142 ->
151), то есть pop_ctrl_shift_held работает через границу банк 3 -> банк 5.
Наборы под ucsim: geom 39, grab 53, phys 1723 — все зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUG-FALL-SWORD-1 — start_fall (seg006:1044) был портирован не целиком:
не хватало трёх веток выбора последовательности и уборки меча в ножны.
Из-за seq_7 с set_fall(1,15) (дрейф 1 px/кадр) Кид с мечом уезжал примерно
на тайл вбок; в оригинале это seq_81_fightfall — падение строго вниз.
Сверено по логу SDLPoP: кадры 102..105 дают x = 155,157,159,160.
BUG-RJUMP-1 — run_jump (seg005:0AA8) не выравнивал Кида по кромке пола
перед толчком, а был заглушкой «полировка K3». Суммарный dx seq_4 —
62 px при тайле 14, то есть провал ровно в три тайла берётся ТОЛЬКО с
кромки: без выравнивания Кид не перепрыгивал его никогда. Порт —
pop_run_jump_align() в pop_map (беззнаковое сравнение оригинала = «сдвиг
не попал в [-8,-1]»). На харнессе: было — толчок с x=165, кадр 44 в
колонке 3 (провал); стало — 5 кадров добега, толчок с x=149, кадр 44 даёт
x=87 col=1 row=1. Остальные 8 сценариев не изменились.
BUG-GATEMOD-1 — load_alter_mod (seg008:198E) был портирован только для
зелий, поэтому ворота с bg=1 («Open» по спецификации DAT, табл. 8)
стартовали закрытыми. Добавлены ветки gate (1 -> 188) и loose. Ветка
wall намеренно НЕ портируется: связи стен наш pop_bg считает по типам
соседей в момент отрисовки. На уровнях 1-3 таких ворот всего двое
(ур. 1 комн. 5 (0,9); ур. 2 комн. 13 (1,5)) — у остальных bg=2, а 2 и 0
ведут себя одинаково.
Убран временный трассировщик pop_dbg_trace/pop_dbg_draw; pop_dbg_trap()
оставлен как многоразовый инструмент.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make all` собирал и examples/, из-за чего цикл «правка libc -> проверка»
упирался в mdview (компилируется минутами) и ничего нового про libc не
показывал. Регресс ловит разжирение библиотеки, а для этого хватает
мелких tests/ — каждый тянет свой кусок libc и пересобирается за секунды.
- `make all` = tools lib tests; examples собираются явно (`make examples`,
и как зависимость `make floppy`);
- size_check.py смотрит только tests/*.
Эталон принят заново. Разбор расхождения, чтобы оно не выглядело
необъяснённым: эталон стоял с 30 июля (0280b05), а libc менялась 1 и 3
августа (b56f2b4 kbd_raw_poll, 1f16e8f fake shift) — _irq_tramp вырос
267 -> 336 Б и не был перебазирован, отсюда +33 Б у всех, кто линкует
трамплин (cbl*, irqtest, rt_test), и +22 у gfx_dbuf (gfx_set_idle_hook в
libbgi из того же KBD-1). Текущий фикс BUG-KBD-5 вернул 36 Б из этих 69.
Заодно выкинуты мёртвые строки эталона (rpgprof/rpgwalk/scroll/space —
их давно нет в APPS, mdview/mdview2 — теперь вне регресса).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом: Shift работал в одиночку и НЕ работал вместе со стрелками —
прыжок с зацепом не выходил (BUG-GRAB-1), а осторожный шаг срывался в бег.
Декодеры делали из «fake shift» два вывода, и второй был неверен:
обёртка E0 F0 12 / E0 12 есть -> Shift зажат -> взвести бит — верно;
расширенный make БЕЗ обёртки -> Shift отпущен -> снять бит — НЕТ.
Замер потока байт (MAME, breakpoint на выходе из in a,($18)): клавиатура
pc_kbd ms_naturl обёртку не шлёт вовсе — при зажатом Shift поток на ↑ ровно
`E0 75 E0 75 …`, ни одного F0/12. А typematic-повторы идут непрерывно,
пока стрелка зажата, значит каждый повтор снимал реально зажатый Shift.
Короткий тап это маскировал: после отпускания стрелки Shift снова
становился последней клавишей, и его собственный автоповтор `12` взводил
бит обратно за ~30 мс.
Фикс: обратный вывод убран, расширенная клавиша о Shift не судит.
Состояние Shift ведут его собственные make/break 12 / F0 12 — они приходят
всегда. Прямой вывод оставлен (дёшев и верен там, где обёртка есть).
Ушла ставшая ненужной _kbdraw_fakesh; трамплин короче на 36 Б (0x150→0x12C),
что важно — его клавиатурный блок упирается в диапазон jr.
Плата: потерянный при overrun break Shift снять нечем, модификатор может
залипнуть до перенажатия (BUG-KBD-3). Размен решён как и раньше в
kbd_raw_sync: лучше залипание, чем отвал — сорванный посреди игры Shift
в PoP стоит жизни.
Проверено в MAME чтением _kbdraw_down: Shift+↑+→ зажаты 5 с (автоповтор
идёт) -> LSh остаётся 04; отпускание Shift -> 00. Зацеп в игре
подтверждён пользователем.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверять физику Кида глазами в MAME дорого и ненадёжно: ошибка почти
всегда не в одной функции, а в РАСХОЖДЕНИИ ТРАЕКТОРИИ через несколько
кадров. Харнесс гоняет тот же кадр, что и главный цикл
(pop_ctrl_tick -> kid_tick -> pop_phys_tick -> pop_loose_tick), и
сравнивает трассу состояния с эталоном.
- scene.c/.h — раннер: комната + стартовая поза + скрипт ввода -> трасса;
sc_kid_at_x задаёт точный X (исход часто зависит от фазы внутри тайла).
- stubs.c/.h — libc/libbgi/соседние модули; read() реально отдаёт
kid_data.bin (иначе kdat_ok=0 и play_seq молчит — трасса замирает).
- t_phys.c — 9 характеризующих сценариев, 1723 сверки (golden/).
- t_grab.c — окно зацепа: существует, достижимо коротким шагом, не
зависит от рисунка нажатий.
- record_golden.py — снятие эталона по одному сценарию за прогон.
- testkit/host-tests.mk — CODE_LOC настраиваемый, EXTRA_INC/EXTRA_CFLAGS.
Именно харнесс дал доказательство, что физика зацепа у нас верна, и тем
самым перевёл поиск BUG-GRAB-1 на клавиатуру.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Отличие от остальных записей раздела «НЕ БАГИ»: там картинка кривая и
совпадает с оригиналом лишь потому, что оригинал сам так рисует (порядок
midtable). Здесь же спрайт перекрывает кромку ровно настолько, насколько
персонаж за неё зашёл — геометрия и рендер согласованы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пользователь проверил в SDLPoP v1.24: оригинал падает с той же позиции,
по той же траектории и с тем же видом кадра падения (голова/руки поверх
кромки пола). Кадры совпадают один в один.
Механика записана с числами: у стоек с мечом weight_x = 13-14 против 3 у
обычной стойки, поэтому при взгляде влево точка веса уезжает на 13 px
вправо от Char.x, и кромку персонаж переступает раньше, чем выглядит.
Замер: x=151 -> dx_weight=164 -> колонка 7 (дыра) вместо 6 (пол).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт seg003:0430 «If char is holding sword, it makes redraw-area bigger»:
при Char.sword >= sword_2_drawn футпринт расширяется на одну колонку в
сторону взгляда (вправо при dir>=0, влево иначе). У нас char_footprint
этого не делал, и клинок торчал на колонку дальше области, где
перерисовываются передние грани: меч оставался поверх столба, а его след
— на фоне.
char_footprint получил параметр sword; pop_fore_over_char — тоже (страж
машет мечом ровно так же). Ветка Кида берёт Kid.sword, ветка стража —
Guard.sword.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cur_frame — один глобал на всех персонажей (как в оригинале), владелец —
тот, кто последним прошёл load_frame. Последним в кадре тикает страж,
поэтому к моменту control() Кида там лежал кадр СТРАЖА, а через
kid_cur_dx/dy/flags по нему считается вся геометрия управления:
dx_weight -> determine_col -> distance_to_edge_weight -> get_edge_distance
-> выбор ветки в check_jump_up.
Оригинал зовёт load_fram_det_col() (seg006:0144) сразу после
loadkid/loadshad и ДО control() — play_kid_frame (seg000:1211) и
play_guard_frame (seg000:1248). У нас этого не было.
Замерено брейкпоинтом на pop_jump_up_seq: Kid x=156 col=6 кадр 15
(dx=0 weight_x=3) при кадре стража image17 (dx=-1 weight_x=8) дал
curr_col=7 и distance=2 вместо 6 и 10 — то есть jump_up_plain (вернулось
A=28, пустой прыжок) вместо «шаг назад на x=160 + зацеп». Предсказание
по кадру стража совпало с намеренным до единицы.
Отсюда же плавающее поведение: кадр стража меняется каждый тик, distance
Кида скакал через порог 6 — то прыжок, то попытка зацепа с неверной X.
И «голова Кида поверх плиты (1,6)» — не баг отрисовки, а следствие позы,
которой в оригинале в этом месте не бывает.
Фикс: pop_load_fram_det_col() (pop_kid.c) + вызовы в pop_ctrl_tick и
pop_guard_tick. determine_col — только на ветке Кида: у нас он
существует лишь для него (pop_map работает с Kid, а не с Char).
Разбор с числами — bug_closed.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порядок работ по решению пользователя: L2-PASS -> L3-CHOMP/SKEL/CHKP.
Причина техническая — чомперы лягут в банк 2, где живёт отрисовка, и
чинить баги фона поверх свежей механики дороже.
TASKS: запись L2-PASS с картой уровня 2, снятой с res2002.bin —
стражи (5, комнаты 4/7/11/15/24), ловушки и зелья по комнатам, и
декодированные из LINKLOC/LINKMAP цепочки «кнопка -> что открывает»
(в т.ч. кнопка к.9 @1,1, открывающая дверь выхода в к.23). Плюс
отдельный список того, что сделано именно в L2 и на уровне 1 не
проверялось: большая склянка, меч с начала уровня, выход через дверь,
респавн на своём уровне.
bug_list: заведён раздел «Уровень 2» под список багов отрисовки,
который пользователь подаст отдельно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Palace (уровни 4+) отложен решением 2026-08-04. На доску вынесены три
задачи уровня 3, снятые с данных и SDLPoP, а не с общих соображений:
- L3-CHOMP: 5 чомперов (комнаты 5, 16×3, 22); шаблон как у пик/ворот,
риск — банк 2 занят на 86.5%.
- L3-SKEL: в данных уровня 3 стражей НЕТ ВООБЩЕ; единственный враг —
скелет, и он спецсобытие check_skel (seg002:1044), а не страж из
данных. Нужен новый атлас (data/SKEL, 29 файлов) — pop_pack_guard.py
прибит к GUARD/.
- L3-CHKP: чекпойнт (seg002:519 + seg003:141); hitp_beg_lev уже есть.
Плюс инвентарь тайлов по уровням: ур. 3 вводит только chomper(18),
palace-набор (lattice*) начинается с уровня 4 — отсюда и граница скоупа.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт levels_plan.md §2 (шаг 1) — машинерия смены уровня целиком.
- Номер уровня стал состоянием: pop_current_level (порт current_level),
pop_next_level больше не флаг, а НОМЕР; главный цикл срабатывает по
расхождению next != current (порт play_level_2, seg003:0386).
- pop_level_load_num(n): LEVELS\res20NN.bin (fallback a:\), старая
EMM-страница отпускается только после успешной загрузки новой.
На диск кладутся все 15 уровней (34 КБ).
- Потабличные различия (data.h:840..848) — таблицы по 16 в pop_level.c:
tbl_entry_pose, tbl_guard_hp (HP стража ушло из хардкода 3),
tbl_guard_type (−1 = стражей на уровне нет), tbl_level_type.
- find_start_level_door (seg003:02E6): на уровне 2 стартовый тайл —
правая половина двери уровня, без modif=43 + add_trob(...,3) Кид
материализуется внутри глухой створки. Тип 3 = дверь захлопывается
за спиной, как в оригинале.
- HP через уровень: hitp_beg_lev (seg003) — рестарт уровня откатывает
HP к нему, пройденный уровень подтягивает его к hitp_max. Заодно
большая склянка add_life (тип зелья 2: +1 к потолку HP до 10) —
на уровне 2 она есть, комната 20.
- have_sword = level >= 2 (play_level, seg003:106).
- Чит Shift+L — следующий уровень (seg000:698).
Проверено в MAME: уровень 1 → Shift+L → уровень 2 (комната 5, новые для
нас тайлы 8/9 «большая колонна» на месте, дверь захлопнута, HP 3) →
влево в комнату 4 со стражем → Shift+L → уровень 3. Вход в стартовую
дверь уровня 2 по Up не срабатывает — как и должно (start_room-гард).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUG-SEAM-DRAW-1: Кид упирается в решётку и встаёт на x=61, curr_col=-1,
room=1 — и в комнате 1 РИСУЕТСЯ, за решёткой. При x=61 он уже внутри
системы координат комнаты 1 (obj_x = 6), straddle-смещение не требуется.
Остаток теоретический: при x <= 60 спрайт ушёл бы за левую кромку; полное
лекарство — довести S3, но поводов нет, заводить обратно только по живому
наблюдению.
BUG-GATE-PASS-1: однократное наблюдение — Кид стоял НА тайле решётки (0,9)
комнаты 5, дождался закрытия, пошёл вправо и прошёл в комнату 1. Повторить
не удалось: в том же месте при x=196/col=9 решётка держит штатно.
Плоскость блокировки для колонки 9 — x=205, а «колонка 9» по m7 это
x ∈ [191,205), то есть при curr_col==9 проход невозможен по построению;
значит наблюдался x >= 205, и поведение может оказаться штатным (решётка
закрылась за спиной). Гипотеза НЕ подтверждена, поэтому баг оставлен
открытым со списком того, что снять в следующий раз, и с двумя запасными
кандидатами на корень.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Падающий кусок теперь привязан к своей комнате и долетает после ухода Кида;
подтверждено живым прогоном. Автоматикой гонка не воспроизводилась — мост
MAME шлёт нажатия рывками, и «уйти раньше, чем долетит плита» через него не
набиралось; это отмечено в записи вместе с указанием, что кейс стоит первым
в плане host-тестов.
Вторая волна прогона уровня 1 закрыта целиком: BUG-KBD-4, BUG-RESPAWN-2,
BUG-DRAWORDER-1, BUG-LOOSE-2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сверено в SDLPoP: Кид, стоящий колонкой правее лежащего трупа, и там
рисуется поверх его головы. Значит порт верен, а артефакт врождённый:
set_objtile_at_char приписывает персонажа РОВНО ОДНОМУ тайлу, ширина
спрайта на выбор тайла не влияет, и всё, что стоит правее, перекрывает
выступающую часть тела. Механизма «широкий объект в нескольких тайлах» в
оригинале нет — сверено с draw_objtable_items_at_tile, sort_curr_objs и
веткой tile_object_redraw == 0xFF (та про оверлеи пола).
Закрытая запись перечисляет все четыре корня, которые пришлось снять по
очереди: порядок по роли вместо тайла; своя регрессия с общим окном
fore-клипа; колонка трупа из тайла вместо X; непортированная ветка
actions_1_run_jump. Записано и то, как отличать остаток от этих багов,
и цена «починки» остатка (тайл по центру габарита = отход от эталона).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обвязка для быстрых тестов plain-C логики: секунды вместо прогона в MAME,
без образа диска. ucsim_z80 идёт в комплекте нашего SDCC — новых
зависимостей нет.
ПОЧЕМУ ПОД Z80, А НЕ ХОСТОВЫМ GCC. У SDCC z80 int 16 бит, у хоста 32, и
расходится это НЕ в объявлениях, а в выражениях: integer promotion
повышает операнды до int независимо от того, объявлены они как uint8_t
или uint16_t. Перевод кода на фиксированные типы разницу не убирает —
убирает только исполнение с z80-семантикой. Побочно проверяется
кодогенерация SDCC и модули с inline-asm, которых хостовая сборка не
видит в принципе.
Устройство: crt0_ucsim.s (SP, зануление, main, halt), tcheck.* (итог в
структуру в ОЗУ), run_ucsim.py (гоняет ucsim, дампит tc_result, печатает
отчёт), host-tests.mk (общие правила). Вывода через printf нет: тестовый
бинарь линкуется без Sprinter-libc. Через ucsim-simif не идём — номера
его команд плавают между версиями, halt + dump работают везде.
Наборы лежат РЯДОМ с проверяемым кодом, обвязка общая:
testkit/t_selftest.c — самопроверка (sizeof(int)==2)
applications/PoP/roomtest/tests-host/ — движок PoP
Первый содержательный набор — t_geom: сверяет рукописный asm-LCG из
pop_geom.c с наивной 32-битной формулой на 128 шагах. Заявка «бит-в-бит
как в SDLPoP» до сих пор держалась на комментарии. Тест проверен
мутацией: порча эталонной константы даёт красный.
Планы дальнейшего покрытия:
docs/host-tests-plan.md — libc и libbgi (не начато)
applications/PoP/docs/host_tests_plan.md — движок PoP
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Одиннадцать наблюдений первого прогона свелись к шести корням, четыре
наблюдения второго — ещё к четырём. Разбор каждого — bug_closed.md.
Первая волна:
- BUG-LVLSTATE-1: уровень стал мутабельным (эталонная копия foretable для
рестарта, pop_level_set_tile вместо таблицы оверрайдов);
- BUG-RESPAWN-1: рестарт = load_level, тайлы возвращаются из эталона;
- BUG-DEATH-1: смерть от меча доигрывается (порт control_kid, seg006:0CD1);
- BUG-GATE-ANIM-1: ворота в отрисованной комнате перерисовываются
(POP_RD_GATE, порт draw_trob seg007:01E6);
- BUG-COLL-1: полный порт check_collisions/bumped (seg004) вместо поиска
стены только в колонке переднего края;
- BUG-STANDUP-1: убран лишний guard в bumped_floor — вставание у стены
роняло Кида сквозь пол.
Вторая волна:
- BUG-RESPAWN-2: рестарт возвращает и СТРАЖЕЙ (в оригинале play_level на
каждой итерации делает load_level + pos_guards);
- BUG-LOOSE-2: падающий кусок привязан к своей комнате и долетает после
ухода Кида (do_mobs крутит mobs[] независимо от drawn_room);
- BUG-DRAWORDER-1: порядок «Кид / страж» задаётся обходом тайлов
(redraw_needed_tiles: ряды 2,1,0, колонки 0..9), а не ролью персонажа.
По BUG-DRAWORDER-1 понадобилось три захода, и два первых были неполны:
1) сам порядок — но общее окно fore-клипа осталось стражьим, и Кид
нарисовался поверх передних столбов (kid_fore_clip_restore);
2) enter_guard брал curr_col из тайла, а leave_guard пишет туда
get_tilepos(0,row) — у запомненного ТРУПА колонка была 0 при
настоящей X. Теперь колонка выводится из X, как в оригинале;
3) ветка actions_1_run_jump в set_objtile_at_char оказалась не
«упрощаемой»: в беге тайл берётся из нижнего ряда и ЛЕВОЙ колонки
габарита, поэтому бегущий Кид уходит за объекты справа. Считается
для обоих персонажей — enter_guard ставит action=1 и стражу.
Проверено в MAME: зелья/меч/плиты переживают выход из комнаты и
восстанавливаются после смерти; кнопка room5 поднимает решётку; падение с
кнопки больше не роняет в комнату 6; убитый страж жив после respawn;
Кид проходит за телом стража. make size-check — роста нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Клавиатура PS/2 обёртывает КАЖДЫЙ расширенный код парой E0 F0 12 / E0 12,
пока реально зажат Shift (замер на железе/MAME: правый шифт обёртывается
своим кодом 0x59). Это прямое и непрерывное свидетельство состояния
Shift — единственное доступное, потому что опросить PS/2 нельзя, а
typematic повторяет последнюю нажатую клавишу, то есть стрелку.
Оба декодера (_irq_tramp.c, kbd_raw_poll.c) читают обёртку в обе стороны:
обёртка есть -> Shift взвести; расширенный make без обёртки -> Shift
снять. Бит взводит общий писатель — достаточно обнулить префиксы, и код
уходит в plain-половину карты как make.
Это снимает размен, между крайностями которого мы метались:
- исключать модификаторы из сброса по overrun -> Shift залипал навсегда
(BUG-KBD-3);
- сбрасывать всю карту, как DSS -> Shift сносился каждым overrun'ом, а
при зажатом Shift тап стрелки это 10 байт в 3-байтовый FIFO, то есть
overrun почти гарантирован (BUG-KBD-4).
Теперь kbd_raw_sync снова не трогает модификаторы, и это безопасно:
залипание снимается первым же нажатием стрелки.
Раскладка трамплина: клавиатурный блок перевалил за 127 байт, а jp внутри
запрещён (копия в W2). Префиксные обработчики переехали вплотную к своим
cp, посередине тела стоят ретрансляторы tr_kbd_hub/tr_hub_notkbd/
tr_hub_dss. В kbd_raw_poll такого ограничения нет — там три jp.
Проверено в MAME: Shift переживает пять тапов подряд; штатное отпускание
снимает; искусственно залипший бит снимается первым тапом.
make size-check — роста нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом (нашёл пользователь сразу после L1-EXIT): при подъёме по лестнице за
дверью уровня силуэт Кида вылезал ПРАВЕЕ правого косяка проёма; по высоте
обрезка была корректна.
Причина — недопортированная половина clip_char (seg006:1231). Для кадров
двери оригинал ставит ДВА клипа, у нас был только первый:
obj_clip_top = leveldoor_ybottom + 1; // было
obj_clip_right = leveldoor_right; // не было
Отдельная ловушка: комментарий в SDLPoP говорит «frames 217..228», а КОД
проверяет >= frame_224_exit_stairs_8, то есть 224..228 — портировано по коду.
Fore-слоем это не лечится: створка и косяк уходят в оригинале целиком в
backtable (draw_leveldoor, все add_backtable), рисуются ПОД персонажем и
перекрыть его не могут. Единственный способ — срезать сам спрайт.
libbgi: gfx_blit_cols_part_w(..., uint8_t maxw) — обрезка СПРАВА у
колоночного блита. Для column-major это ровно уменьшение числа колонок, то
есть внутри ядра механизм уже был (так же клипается край экрана,
w = _bgi_maxx + 1 - x), наружу не выводился. Тело блита переехало туда,
gfx_blit_cols_part стал тонкой обёрткой (maxw=0) — тем же приёмом, каким
gfx_blit_cols уже обёрнут вокруг gfx_blit_cols_part. Работает и при flip:
первые maxw нарисованных колонок всегда ложатся в левую часть футпринта.
make size-check: роста нет.
PoP: pop_leveldoor_right / pop_leveldoor_ybottom (порт одноимённых глобалов)
пишет draw_leveldoor в pop_state — их читает clip_char из другого банка;
pop_clip_char_right() отдаёт границу, kid_draw превращает её в maxw и уводит
эти кадры с noclip-пути на общий. Прямоугольник heal (kid_lw) сужается тоже
— стираем ровно нарисованное.
Проверено в MAME: pop_leveldoor_right = 176, что есть ровно (draw_xh<<3)+48
для двери комнаты 9; pop_leveldoor_ybottom = 112 у закрытой створки и 69 у
поднятой — сходится с формулой оригинала. Отрисовку подтвердил пользователь
на живом подъёме.
Заодно: ROOMNAV остаётся включённым осознанно — это наш чит, которого в
оригинале не было, как и S/K/I; позже сведём в общий блок читов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L1-START. Старт и оба рестарта (смерть, выпадение из уровня) сведены в
pop_start_level() — порт start_level + do_startpos + set_start_pos (seg003).
Комната/тайл/направление берутся из pop_level_start_*, направление
инвертируется (~start_dir), поза входа — из tbl_entry_pose: у уровня 1 это
падение внутрь (seq_7_fall) плюс нажатие кнопки room5(0,2), то самое, что
захлопывает решётку за спиной. Жёсткие START_ROOM/COL/ROW убраны.
Проверено в MAME: старт даёт room 1, col 0, падение на row 1 — как по данным.
L1-EXIT. Ветка двери уровня из up_pressed + go_up_leveldoor (seg005:0482/
0574): тайлы и геометрия — pop_leveldoor_enter() в pop_map, последовательность
seq_70 — в pop_ctrl. Опкод 0xF1 END_LEVEL в play_seq инкрементит
pop_next_level (порт next_level), главный цикл по нему перезапускает уровень
— ровно та точка, куда levels_plan §2.2 подключит загрузку уровня 2.
Открытость двери проверяется по modifier >= 42 (ветка fix_exit_door), а не по
ванильному leveldoor_open: иначе можно войти в ещё ползущую створку.
Отдельно стоило разбора: go_up_leveldoor сначала писал Char.x/Char.direction,
и оба присваивания молча терялись — окно Char вокруг диспетчера возвращает
назад только curr_seq и sword (pop_savekid_state). Направление оставалось
«вправо», а все DX в seq_70 отрицательные, поэтому Кид уходил ИЗ проёма
влево (поймано стоп-кадром). Геометрию персонажа в этом порте меняет
pop_map, пишет в Kid — как pop_down_action и pop_jump_up_seq.
Известный остаток — BUG-DOOR-CLIP в bug_list.md: нет обрезки силуэта правым
косяком проёма (недопортирован obj_clip_right в clip_char); нужен вариант
колоночного блита с ограничением ширины. ROOMNAV пока оставлен включённым —
он нужен, чтобы попадать в комнату 9 для этой работы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kid_heal/pop_guard_heal звали gfx_heal всегда, хотя рисуют ровно тот
прямоугольник, который блит в большинстве кадров кладёт noclip-ядром.
Все три места heal (+ heal_off фона) сведены к общему pop_heal_fast.
Замер в MAME, счётчики totalcycles на входах kid_heal и kid_tick (вся
группа heal за кадр), комната 1, Кид стоит:
клипающее ядро 26 200 тактов/кадр (149 кадров)
noclip 15 848 тактов/кадр (239 кадров)
−10 352 такта, −39.5 %. A/B в одном прогоне: вторая половина снята с
пропатченным в памяти условием (jr nz → jr), то есть на той же геометрии.
Размер СУММАРНО −362 Б: _CODE +17, BANK2 −116 (свободно 2708 — это тесный
банк из рисков levels_plan §5), BANK3 −263, BANK4 без изменений.
Грабли по дороге: первым заходом хелпер был static inline в pop_bg.h —
SDCC 4.5 И встраивает тело (181 Б) в каждый вызов, И оставляет копию в
каждом TU, который видит заголовок. pop_guard_heal раздулся с ~60 до
663 Б, итого +1091 Б в _CODE и +636 Б в банке стража. Отсюда pop_draw.c:
обычная функция в резиденте W1, из банков это прямой call без трамплина.
pop_room_clip_borders оставлен клипающим осознанно (320 не лезет в 8 бит,
гейт border_dirty редкий) — причина записана в коде.
Проверено визуально: ходьба, прыжок, спуск, позиция за решёткой шва
(straddle — там работает клипающий фолбэк) — артефактов нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три Critical'а (BUG-1 провал на row 1 при боковом переходе, BUG-2 ping-pong
при возврате, BUG-3 окклюзия climb-up на кнопке) висели непроверенными с
2026-07-21. Прогнал в MAME:
- BUG-1 не воспроизводится: room6 → кнопка (0,2) → открытая решётка →
переход влево даёт room8, y=55, curr_row=0. Заодно снят и сам диагноз
записи — репроекция Y при БОКОВОМ переходе не нужна: goto_other_room
(seg002.c:390) меняет только x, наш check_leave делает то же.
- BUG-2 не воспроизводится: шов room2↔room3, четыре пересечения с
разворотом сразу после входа — комната меняется ровно раз на пересечение.
- BUG-3 закрыт фиксом tile_code_drawn от 2026-07-28 (это дубль уже
записанного «спуск с кнопки»); оговорка про непереснятый подъём — в
bug_closed.md.
bug_list.md теперь только открытое (BUG-CEIL-1/2/3, BUG-OCCL-1, T-1, T-2,
таблица обхода 24 комнат) + индекс с якорями. bug_closed.md — закрытое
вместе с разбором корней (odd-pixel char_x, подстановка тайла кнопки, баг
кодогенератора SDCC), он и есть главная ценность архива.
TASKS.md: кросслинки на открытые баги в шапке, в L1-TRIAGE, L1-PASS и
«Отложено». Указатели в CLAUDE.md/README/room_model_plan/layout_plan_v2
переведены на нужный из двух файлов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ручная проверка пользователем: стало значительно лучше, но редкие пропуски
стрелок при зажатом Shift всё же ощущаются. Счётчики на 35 нажатиях подряд
потерь не показали, то есть остаточная частота заметно ниже прежних ~15 %.
Задача отложена до финальной полировки программы (решение пользователя) —
для работы клавиатура пригодна.
Записано, где именно осталась дыра, чтобы не начинать с нуля: idle-хук
покрывает простой (~2/3 кадра), а в занятой трети DI-окно одного
accel-прохода доходит до ~650 мкс при допуске FIFO ~300 мкс — пачка байт,
целиком попавшая в такое окно, ещё может потерять байт. Порядок действий
на возврат: вызовы между блитами занятой фазы, замер тем же счётным методом
от 50 нажатий, и только потом рычаги вне нашего кода (Scan Code Set 3 через
BIOS $EA — в MAME непроверяемо; общий m_irq_off_timer в драйвере).
Заодно сняты оговорки «плотный опрос ещё не подтверждён замером» в
kbd_raw.h и libc-reference.md — теперь там штатный рецепт через
gfx_set_idle_hook.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Причина потерь (замеры — applications/PoP/roomtest/TASKS.md, KBD-1): при
зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», нажатие
стрелки становится 5 байтами вместо 2, а импульс запроса прерывания здесь
теряется примерно в 44 % случаев — трёхбайтовый FIFO SIO переполняется, и
байт пропадает ДО чтения порта. Лечится только плотным вычерпыванием: раз
в ~0.5 мс. Столько времени есть даром — при пейсинге «3 растровых кадра на
логический тик» процессор проводит ~42 мс из 60 в gfx_wait_vsync, крутя
опрос луча и больше ничего не делая.
- gfx_set_idle_hook(fn) — что вызывать, пока gfx_wait_vsync ждёт луч.
Состояние в отдельном data-модуле (_gfx_idle_state.c), чтобы не тянуть
сеттер в программы, которые хук не ставят.
- Лучевой цикл зовёт хук в обеих фазах. BC (счётчик таймаута)
сохраняется, косвенный вызов — push адреса возврата + jp (hl), так как
`call (hl)` в Z80 нет; без хука это ret по нулевому указателю, порядка
двух десятков тактов в цикле, который и так сжигает время.
- Путь FPS-делителя не затронут: там ожидание через HALT.
- roomtest вешает на хук kbd_raw_poll.
Проверка в MAME счётчиками (брейкпоинты с { b@ADDR = b@ADDR+1 ; g } на
чтении порта 0x18 и на установке make-бита): 35 нажатий Shift+Home → 35
make, ноль потерь; до фикса было 9 из 10. Боевой сценарий: четыре Shift+→
подряд дали четыре осторожных шага (Kid.x 114 -> 147). _CODE +170 Б,
кадровый бюджет не затронут.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом: при удерживаемом Shift часть нажатий стрелок не отрабатывает
(~15 % по наблюдению пользователя), без Shift потерь нет.
Переведено в числа: нажимается Home — тоже расширенная клавиша (тот же
E0-префикс и тот же «fake shift»), но игрой игнорируется, поэтому рельеф
комнаты на результат не влияет. Счётчики — брейкпоинты MAME с действием
{ b@ADDR = b@ADDR+1 ; g } на входе клавиатурной ветки трамплина, на чтении
порта 0x18 и на установке make-бита.
Что измерено (10 нажатий Shift+Home, дошло make):
игра идёт, опрос ВКЛ 9/10 игра идёт, опрос ВЫКЛ 9/10
игра ЗАМОРОЖЕНА (блитов нет вообще, длинных DI нет) 8/10
Обе исходные гипотезы отпали:
- длина наших DI-окон ни при чём (в замороженном кадре потерь больше);
- снятие di в accel-ядрах libbgi УРОНИЛО машину — режим «акселератор при
EI» из docs/new/06-accel.md §6.6 в этой прошивке недоступен.
Байт теряется НИЖЕ нашего кода: на 49 прочитанных байт пришлось только 28
входов в клавиатурную ветку, то есть ~44 % импульсов запроса прерывания не
обслуживается и трёхбайтовый FIFO SIO переполняется.
Потолок приёма измерен отдельной программой tests/kbdpoll (ничего, кроме
kbd_raw_poll в цикле): 25 нажатий -> 25 make, 250 байт из 250, ноль потерь.
Значит опрос лечит полностью, вопрос только в плотности: нужно раз в
~0.5 мс, а шесть вызовов за 60-мс кадр давали раз в 10 мс.
Поэтому вызовы из roomtest.c УБРАНЫ (они стояли там, где прерывания и так
разрешены, и дублировали трамплин — 9/10 с ними и без). Сама функция
kbd_raw_poll оставлена в libc: она корректна и нужна как основа плотного
опроса. В заголовке и в libc-reference — честная оговорка, чтобы её не
ставили в игровой цикл «на всякий случай» без замера.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Документы отстали от кода: PORT_PLAN писал «PoC не начат», хотя играется
весь уровень 1, а четыре плана были исполнены целиком.
- PORT_PLAN: таблица статусов по разделам; фазы 0-3 сделаны, 4-6 нет;
риски §8 п.1/п.3 закрыты, п.2 переформулирован под реальный движок
(спрайтовый движок для персонажей не используется, лимит «21 спрайт»
неприменим), п.4 — найдено расхождение таймингов: оригинал считает
логический кадр за 5 тиков при BASE_FPS=60 (83.3 мс, в бою 100 мс), а мы
ждём три vsync (60 мс) — игра идёт примерно на 39 % быстрее эталона.
- levels_plan.md — новый: машинерия перехода между уровнями, второй
тайлсет (palace), потабличные различия и читы SDLPoP, которые окупаются
сразу. Инвентарь тайлов снят прямо с res200N.bin: уровень 2 не требует
ни одного нового ассета и ни одной новой механики.
- roomtest/TASKS.md — новый: доска текущих задач с критериями готовности.
- Удалены как исполненные и перекрытые кодом: clip_char_plan,
double_buffer_plan, loose_floors_plan, size_optimization_plan. Его §8
(замеры скорости отрисовки) не был перекрыт — перенесён в
layout_plan_v2 §9, чтобы не потерять цифры.
- KID_PLAN / gates_spikes_plan — шапки «реализовано, оставлено
справочником»; room_model_plan — «S1 сделан, остальное не срочно».
- docs/README.md стал индексом с отметками актуальности.
- ideas_backlog: зелье переворота экрана — оригинал переворачивает готовый
буфер построчно, спрайты не трогает; по данным уровней тип 4 встречается
только на уровне 9, до него механика не нужна.
- examples/scroll: ссылка на удалённый план вела к неверному факту
«теневая копия одна — общая»; заменено на подтверждённое «у каждой
страницы своя».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Банк 2 (pop_bg + pop_gdraw) подошёл к границе страницы вплотную: 16 021 из
16 384, свободно 363 байта. А расти ему ещё есть куда — тайлы поздних
уровней, чомперы, зеркало, анимации смерти стража.
pop_gdraw.c уехал в банк 4 (n_banks 3 -> 4):
банк 2 16 021 -> 13 792 (84.2 %, свободно 2 592)
банк 4 2 236 (13.6 %, свободно 14 148)
Цена: pop_fore_over_char стал кроссбанковым, поэтому помечен __banked —
один трамплин (~654 такта) за кадр, других вызывающих у него нет.
pop_fore_set_clip уже был __banked, так что там ничего не изменилось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Каркас предметов уже был (check_get_item/do_pickup/proc_get_object), пустой
оставалась только ветка зелий. Порт seg005 get_item + seg006
proc_get_object:
- pop_get_item_action теперь отдаёт 3 = «пить» и делает do_pickup с ТИПОМ
зелья, который лежит в старших битах модификатора тайла (modif >> 3);
- pop_ctrl на код 3 запускает seq_78_drink;
- эффекты: тип 1 (здоровье) — +1 HP через hitp_delta и красная вспышка,
причём как в оригинале только если HP не полные; тип 5 («злое») — −1 HP.
Типы 2/3/4/6 (жизнь, перо, переворот, открыть ворота) — свойства поздних
уровней, портируем вместе с ними.
Вспышка фона получила цвет: меч даёт ярко-жёлтую (было), зелье — красную
(flash_color оригинала; двух значений достаточно, других в игре нет).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прошлая правка убрала только управляющий терминал (os.setsid), и mmd
переключился на stdin: lsof показал fd 0 = /dev/ttys002, процесс снова спал,
теперь уже после отметки «копирование файлов».
Каналов, откуда mtools может ждать ответ, два — /dev/tty и stdin — и
закрывать надо оба. Обёртка mt() теперь и создаёт новую сессию, и подаёт
stdin из /dev/null.
Проверено: с stdin=/dev/null mmd на свежем образе отрабатывает с кодом 0, а
на уже существующем каталоге честно возвращает 1 и ничего не спрашивает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Симптом: сборка образа молча вставала навсегда сразу после эхо-строки
команды. Появилось не «само» — ровно тогда, когда образ разложили по
подкаталогам (BG/KID/GUARD/LEVELS) и в скрипте появился mmd.
Диагноз по артефакту, а не по догадке: зависший процесс — `mmd z:/BG`,
и lsof показал fd 0 = /dev/null, fd 4 = /dev/tty. То есть mtools (собран
с enable-raw-term) для интерактивного вопроса открывает УПРАВЛЯЮЩИЙ
ТЕРМИНАЛ напрямую, в обход stdin — поэтому ни `< /dev/null`, ни
перенаправления stdio не помогают. А `2>/dev/null` на mmd прятал сам
вопрос, из-за чего это выглядело как зависание на пустом месте.
Из НЕинтерактивного запуска (CI, фоновая задача) терминала нет, вопрос не
задаётся, и баг не воспроизводится — потому и жил незамеченным.
Лечение: все вызовы mtools идут через обёртку mt(), которая запускает их в
НОВОЙ СЕССИИ (os.setsid + exec питоном; setsid(1) в macOS нет). Без
управляющего терминала открывать /dev/tty нечего, и mtools выбирает
неинтерактивный путь.
Заодно добавлены отметки этапов («разметка и формат», «копирование
файлов», «конвертация RAW -> CHD»): если что-то встанет снова, будет сразу
видно где, а не после последнего аргумента команды.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Смерть стража теперь персистентна между входами в комнату — по механизму
оригинала, а не отдельной таблицей «убит/не убит». В SDLPoP массивы
level.guards_* лежат в ОЗУ и движок их ПЕРЕПИСЫВАЕТ: leave_guard (seg002:02F5)
кладёт туда позицию/направление/мастерство, а у МЁРТВОГО ещё и curr_seq;
enter_guard, увидев непустой seq_hi, поднимает стража прямо в этой
последовательности и по кадру смерти (185/177/178) ставит alive = 1.
У нас уровень лежит в EMM-странице только на чтение, поэтому в W2 добавлена
живая копия — 6 байт на комнату (tile/dir/x/skill/seq_lo/seq_hi):
- pop_guard_leave() в начале enter_room запоминает уходящего стража;
- pop_guard_enter поднимает труп сохранённой последовательностью И
сохранённой X (pos_guards пересчитывает её из колонки только при загрузке
уровня, дальше ею владеет leave_guard — иначе тело прыгает в центр тайла).
ГРАБЛИ: guards_seq_lo/hi в ФАЙЛЕ уровня не используются, там 0xFF во всех
комнатах (оригинал чистит их в reset_level_unused_fields). Прочитав их как
есть, я скормил интерпретатору curr_seq = 0xFFFF, и приложение зависало —
бордюр оставался синим, цикл не доходил до vsync. Живая копия стартует
нулями: 0 = «поднимать стандартной стойкой».
Меч Киду больше не выдаётся автоматически: DEBUG_SWORD_ROOM убран, вместо
него чит S (выдать меч). Штатный путь — подобрать с пола.
Проверено в MAME: чит K убивает стража, уход из комнаты 3 и возврат —
тело на месте, страж не воскресает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регрессия от окна Char вокруг control() (cf06896): диспетчер работает с
копией Char, а часть его действий у нас исполняет pop_map (pop_down_action,
pop_jump_up_seq, safe_step, зацеп) — и пишет ПРЯМО в Kid, потому что на Char
он ещё не переведён. Завершающее `Kid = Char` затирало эти правки:
выравнивание x и ряд терялись, и спуск с уступа через вис не срабатывал —
Кид просто приседал.
pop_savekid_state теперь копирует только то, что диспетчер реально меняет
у персонажа: curr_seq и sword. Когда pop_map переведём на Char, вернётся
полное копирование — в комментарии это зафиксировано.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Боёвка (порт seg002/seg006):
- check_sword_hurting / check_hurting / check_sword_hurt / hurt_by_sword /
take_hp через дельты; do_delta_hp сводит их раз в кадр;
- парирование (justblocked), refractimer после ранения стража;
- смерть по seq_71_dying — через неё же теперь работает чит K: страж
действительно погибает, а не замирает на месте;
- парные окна Char/Opp: loadkid_and_opp / savekid_and_opp /
saveshad_and_opp.
Индикаторы HP (порт draw_kid_hp / draw_guard_hp): Кид слева, страж справа.
Перерисовка ТОЛЬКО при изменении числа и тогда на ОБЕИХ страницах
дабл-буфера (счётчик hp_todo, иначе на второй странице осталось бы старое
значение и мерцало через кадр); pop_hp_invalidate при входе в комнату, где
фон перерисован целиком.
Чит I — бессмертие Кида (нашего изобретения, в оригинале его нет).
Перекрывает и путь «безоружного закалывают насмерть»: тот идёт мимо HP, и
без этого чит бесполезен ровно там, где нужен.
ДВА НАЙДЕННЫХ БАГА:
1. Полосу HP блитил row-major примитивом, а атласы Кида и стража хранятся
COLUMN-major (ради бесплатного флипа) — марки выходили транспонированными.
Теперь колоночный блит, стрелки как в оригинале.
2. Кэш кадра для ОТРИСОВКИ заполняли pop_savekid/pop_saveshad. Любое окно
Char БЕЗ play_seq — а это оба окна боёвки — записывало Киду кадр, который
принадлежал СТРАЖУ, и kid_draw искал этот image в атласе Кида, рисуя
произвольную позу. Владельцем кэша стал load_frame: он один знает, чей
кадр загружен (по Char.charid).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пункт 2 плана закрыт: страж не только замечает Кида, но и идёт к нему и
дерётся. Порт по SDLPoP, диспетчер общий — ИИ выставляет те же control_*,
что и клавиатура игрока.
guards.c (банк 1), порт seg002:
- autocontrol_guard_active (737) + kid_in_sight (0A93) + kid_armed (0AC1)
+ kid_far (09CB);
- guard_advance / guard_block / guard_strike с таблицами вероятностей по
12 градациям мастерства (seg002:26..38), бросок prob > prandom(255);
- move_2_backward / move_3_up / move_6_shift / move_down_back;
- таймеры justblocked / kid_sword_strike / guard_refrac убывают раз в кадр
в autocontrol_opponent, как в оригинале.
pop_ctrl.c, порт seg005: control_with_sword (964), swordfight (0CDB),
sword_strike, parry, forward_with_sword, back_with_sword. Ветвление у
Кида и у соперника разное — соперник блокирует только на кадре 152, Кид
ещё и по 153 (и тогда последовательность прокручивается сразу).
pop_guard.c: guard_skill из данных уровня (12 градаций, вне диапазона -> 3),
HP по get_guard_hp (extrastrength[skill] + tbl_guard_hp[уровень]),
собственный сид бросков pop_fight_seed — иначе перерисовка стены сбивала бы
решения стража.
char_opp_dist переехал из банка в pop_kid.c: он нужен по ОБЕ стороны
банковой границы — и ИИ, и диспетчеру боёвки.
Проверено в MAME (комната 3): страж проходит комнату, встаёт в дистанцию и
машет мечом, позы меняются. Урона пока нет — HP-учёт и check_hurt
следующим шагом, без них бой не заканчивается.
Бюджет В БОЮ (175 кадров): 412 224 – 421 068 = 0.958–0.979 кадра.
В покое было 400 800. Запас в худшем кадре ~9 000 — тесно, но в один
кадр укладываемся; оптимизация отложена сознательно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тексты обеих Z80-процедур, разбор их устройства и качества, почему НЕ берём
8-битный RND Apple II (вырожденные младшие биты — раскладка кладки читает
prandom(1), вышла бы шахматка), и главное — сколько это реально даст.
Потолок выигрыша 2 814 тактов за кадр (0.65 %): тело генератора уже не
основной расход, остаются обёртка pop_prandom, pop_rnd_fit и ABI вызова.
Поэтому первый шаг, если упрёмся, — слить приведение к диапазону в ту же
asm-процедуру (один call вместо трёх), и только потом менять генератор.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Возврат к БИТ-В-БИТ генератору оригинала по умолчанию (POP_PRANDOM_EXACT=1):
по нему проще отлаживать и сверять картинку с эталоном. Чтобы это не
стоило процента бюджета, сам шаг LCG переписан на Z80-ассемблере —
единственное место в порте, где это сделано, с явного разрешения.
Приём: 214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3 — схема Горнера по
РАЗРЕЖЕННОЙ записи константы. Вместо 12 сложений (по числу единиц в
0x343FD) — 17 удвоений, три сложения и одно вычитание; величина 3*s,
нужная в конце, попадается по дороге на втором шаге.
Проверка в ДВА этапа:
- схема на хосте: horner(s) == s*214013+2531011 на 3 000 000 сидов;
- сама asm-транскрипция на живой машине: breakpoint на pop_prandom,
11 последовательных состояний сида из MAME — каждый переход совпал с
s*214013+2531011 бит-в-бит.
Замер, комната 3, 175 кадров (медиана кадра / prandom->torch_draw):
C, бит-в-бит (16-бит половины) 403 632 / 10 933
C, xorshift16 + шаг Вейля 397 986 / 7 927
asm, бит-в-бит 400 800 / 9 331
То есть asm вернул половину разрыва (2 832 такта за кадр), сохранив
совместимость с эталоном. Ветка xorshift оставлена под
-DPOP_PRANDOM_EXACT=0 как запасной ход — брать её имеет смысл, только
если не хватит последних 2 800 тактов.
Итог оптимизационного круга: 416 154 -> 400 800 (0.968 -> 0.932 кадра).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью на 32-бит сделан ПО ASM, а не по коду (искали и безымянные
временные): во всём приложении был ровно ОДИН 32-битный вызов —
__mullong в pop_prandom. Замер в MAME: 8 430 тактов на вызов, два
вызова за кадр. Прочие библиотечные вызовы 16-битные (__divsint 16,
__modsint 12, __moduchar 8, __divuchar 5).
1. pop_prandom. Состояние 32-бит -> две 16-битные половины. Два
генератора, выбор через POP_PRANDOM_EXACT:
- 0 (по умолчанию) — xorshift16 + шаг Вейля, без единого умножения;
- 1 — LCG оригинала бит-в-бит, посчитанный половинами (для сверки
картинки с эталоном).
8-битный RND Apple II (5*x+23 mod 256) НЕ взят: у LCG по модулю 256
вырождены младшие биты (бит 0 просто чередуется), а раскладка кладки
берёт как раз prandom(1) и prandom(4) — вместо шума вышла бы
правильная шахматка. Шаг Вейля ещё и убирает ноль как неподвижную
точку xorshift (сид кладки вполне может быть нулём).
Бит-в-бит эквивалентность half-word версии проверена на хосте:
70 000 сидов x 8 шагов + 7 крайних сидов x 2000 шагов.
Остаток 0..maxv: делитель степень двойки — маска вместо __moduint.
2. libbgi: gfx_blit_cols_part_noclip — column-major блит без клипа
(пара к gfx_blit_cols_part, как gfx_blit_part_noclip к
gfx_blit_part). Клипающий вариант платит ~5 622 такта подготовки на
КАЖДЫЙ вызов независимо от того, вылезает край (замер: подготовка
5 622 против 13 596 на сам accel-проход). Kid, страж и клинок
выбирают путь по pop_onscreen_cols. size-check: роста нет.
Бюджет (175 кадров, комната 3, медиана):
было (после клинка) 416 154 0.968 кадра
стало 397 986 0.926 кадра
Разница между генераторами, замер на одинаковой сборке:
xorshift16 + Вейль 397 986 prandom->torch_draw 7 927
бит-в-бит LCG 403 632 prandom->torch_draw 10 933
то есть точность обходится в 5 646 тактов за кадр (1.3 %).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Меч в оригинале ОДИН на всех: и Кид, и страж рисуют клинок из chtab_0
(add_sword_to_objtable, seg006:1798). Раньше sword.atl содержал только
кадры подъёма/ножен (sword_tbl 35..42), поэтому у стража меча не было
видно вовсе.
- pop_pack_kid.py пакует chtab_0 целиком (id 0..33, 5168 Б), индекс в
атласе = id;
- pop_extract_kid_data.py вытаскивает sword_tbl (53 строки) в kid_data.h
макро-инициализаторами — таблица ложится в один TU, а не в каждый;
- pop_sword_draw (pop_kid.c) — общая точка отрисовки клинка с полным
условием оригинала (кадры 229..237 ИЛИ меч обнажён ИЛИ живой страж);
зовут и kid_draw, и pop_guard_draw.
Раздельный heal накладных спрайтов. Клинок и брызги урона раньше
объединялись в один прямоугольник с персонажем, а объединение почти вдвое
больше суммы двух (клинок уходит вперёд-вверх) — heal же стоит ровно по
площади. Теперь у накладных свой прямоугольник и свой gfx_heal, а
объединение осталось ТОЛЬКО для окна fore-клипа: там это 4 сравнения без
рисования, но покрыть клинок обязано, иначе он полезет поверх столба.
Отладка: DEBUG_SWORD_ROOM — Киду выдаётся меч при входе в комнату 3
(там страж), чтобы не бегать за ним в комнату 15.
Бюджет (225 кадров, комната 3): 415 284 – 416 370 = 0.966–0.968 кадра.
Против 384 168 – 384 636 до этого шага, то есть +31 700. Разложение по
фазам (медианы): process_trobs 83 190, pop_guard_draw 92 483 (спрайт
28 763 + клинок 23 820 + fore_over_char 39 906), kid_draw 59 089, fore
поверх Кида + борта 47 483, heal 40 068, логика 76 338, ввод 13 050.
Видно, что клинок 21x8 стоит почти как спрайт стража — это фиксированные
накладные расходы клипающего блита, а не пиксели; лечится noclip-путём
для column-major (см. gfx_blit_noclip_fast). Отдельным шагом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Первый самостоятельный кусок ИИ стража (план, пункт 2). В оригинале у
соперника нет своего диспетчера: autocontrol_* (seg002) выставляет те же
глобалы control_*, что и ввод игрока, а дальше исполняется общий control().
Инфраструктура под это встала прошлым коммитом, здесь — сама логика.
Порт:
- check_can_guard_see_kid (seg003:688) — луч видимости по ряду: стены и
верхи дверей рвут его совсем, loose/чомпер/дыра/неподнятые ворота дают
«вижу, но не пойду». В guards.c (банк 1);
- Opp + loadshad_and_opp (seg006:841) и char_opp_dist (seg006:2135);
- autocontrol_guard_inactive (seg002:710) + move_* (seg002:0706..);
- ветки control(): control_guard_inactive (seg006:2123) и draw_sword
(seg005:945) — соперник уходит сразу в seq_90 en garde;
- pop_guard_tick перестроен по play_guard_frame (seg000:1246): окно
Char/Opp вокруг ИИ, диспетчера и play_seq.
По дороге:
- Kid.alive не выставлялся (= 0 = «мёртв» в семантике оригинала), из-за
чего луч видимости не мог сработать в принципе — ставим -1 в kid_init;
- Kid.room не выставлялась вовсе; условие Kid.room == Guard.room всегда
было ложным. Ставим в enter_room (полная модель Kid.room != drawn_room
у шва по-прежнему впереди);
- pop_tile_at — тайл текущей комнаты наружу из pop_map (луч видимости);
- control_x/y/shift открыты в шину: ИИ заполняет оси как есть, без
flip_control_x (его «вперёд» уже в системе персонажа).
Проверено в MAME (комната 3, страж на tile 17): страж переходит из
стойки 166 в 171 stand_with_sword, sword=2, can_guard_see_kid=2; ввод
игрока не пострадал. Клинок отдельным спрайтом пока не рисуется —
sword.atl содержит только кадры 229..237 (подъём меча Кидом), остальные
строки sword_tbl приедут с боёвкой.
Бюджет (225 кадров, комната 3): 384 168 – 384 636 тактов, 0.893–0.895
кадра, запас 45 364. Прошлый замер 382 584 – 383 064 — шаг стоил ~1 570.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Инфраструктура пункта 2. В оригинале у соперника НЕТ своего диспетчера:
autocontrol_* (seg002) выставляет те же глобалы control_*, что и ввод
игрока, а дальше исполняется тот же control() (seg005:252). Значит перед
портом ИИ надо было привести к этому обе половины:
- control_forward/backward/up/down/shift2 перестали быть static в
pop_ctrl.c — это общая шина синтетического ввода, объявлена в pop_ctrl.h
вместе с POP_CONTROL_*;
- control() переименован в pop_control() и работает с Char, а не с Kid;
- ввод игрока обёрнут в окно Char (loadkid/user_control/savekid), как в
play_frame оригинала.
Ловушка по дороге (ввод отвалился целиком, Kid не двигался): макрос
seqtbl_offset_char вёл на kid_set_seq, который пишет прямо в Kid, а
следом savekid затирал Kid копией Char со старой curr_seq. В оригинале
seqtbl_offset_char работает именно с Char — макрос переведён на
pop_char_set_seq. kid_set_seq остался для вызовов ВНЕ окна (pop_map).
Добавлен pop_savekid_state (Kid = Char без кадра): control() кадр не
трогает, а cur_frame в этот момент принадлежит тому, кто последним крутил
play_seq.
Проверено в MAME: бег и упор в стену работают как прежде.
Бюджет (комната 3, 125 кадров): 382 584..383 064 против 380 292..381 282,
то есть +2 300 тактов на копии окна. 0.891 кадра, запас 46 936.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пункт 1 плана стражей. play_seq был прибит к Киду, поэтому страж стоял на
захардкоженном кадре 166. Теперь как в оригинале: интерпретатор работает
с АКТИВНЫМ персонажем Char, а вокруг стоят loadkid/savekid и
loadshad/saveshad (порт seg006:809..825).
Почему копия, а не указатель: так в оригинале, и на Z80 это быстрее —
горячий цикл обращается к глобалу абсолютной адресацией, а 16-байтовое
копирование платится один раз на переключение персонажа, тогда как
указатель дал бы индексную адресацию в каждом обращении.
Сопутствующее:
- kid_t и pop_char_t слиты в один pop_char_t (pop_char.h): в оригинале
char_type один на всех, и без этого общий интерпретатор невозможен.
Kid получил поля room/charid/sword/alive — они и так нужны боёвке;
- load_frame выбирает таблицу кадров по Char.charid (у стража своя,
frame_tbl_guard с индексом frame + add_frame − 149, seg006:0293);
- cur_frame разведён на два кэша: страж тикает ПОСЛЕ Кида, и без этого
kid_draw брал бы кадр стража. savekid/saveshad раскладывают кадр по
своему персонажу;
- kid_set_seq пишет ИМЕННО Kid (его зовут pop_ctrl/pop_map вне окна Char,
иначе loadkid затёр бы), для окна Char добавлен pop_char_set_seq —
порт seqtbl_offset_char;
- в pop_map 9 голых play_seq() заменены на pop_kid_play() (load+play+save);
- страж входит в комнату через seq_77_guard_stand_inactive (seg002:0208),
а не через прибитый кадр.
Проверено в MAME: Kid бегает и упирается в стену как прежде, в комнате 12
плиты проваливаются со щебнем (правка задела 9 вызовов play_seq в
физике), страж в комнате 3 рисуется в той же позе, но теперь
curr_seq=0x19A9 и charid=2 — кадр получен прокруткой последовательности,
а не константой.
Бюджет (комната 3, 150 кадров): 380 292..381 282 тактов против
370 140..371 160 до правки, то есть +10 150 (+2.7 %). Основное — не
копии Char, а то, что страж теперь реально крутит интерпретатор каждый
кадр, а раньше стоял замороженным. 0.887 кадра, запас 48 718.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Мышь игре не нужна (управление — raw-клавиатура, которую мы и так
забираем у DSS), а её прерывания воруют такты из бюджета, занятого на
86 %. Эффект измерен побочно: при движении мыши на хосте кадры выбивались
до 1.5 кадрового периода, при неподвижной — 225 кадров без превышений.
Записано с тем, что проверить (есть ли в RST 30h выключение, сколько
стоит одно прерывание, восстановление состояния на выходе) и почему не
сейчас: выигрыш только когда игрок двигает мышью, риск оставить систему
без мыши после выхода — заметный.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Делитель (torch_tick & 1) занижал скорость пламени вдвое: один логический
кадр = 3 vsync и соответствует игровому тику оригинала, а animate_torch
(seg007:03C1) меняет кадр КАЖДЫЙ тик. Теперь темп задаётся явной
константой TORCH_ANIM_DIV (1 = как в оригинале, 2 = прежнее поведение),
счётчик компилируется только когда он реально нужен.
Побочный эффект важнее визуального: раньше половина кадров делала работу
факелов, половина нет, и бюджет кадра «прыгал». Замер по 100 кадрам
до правки: 349 008..371 262, разброс 22 254 такта (6.2 %). После: по
225 кадрам 370 140..371 160, разброс 1020 тактов (0.27 %) — каждый кадр
стал худшим случаем, и цифре можно верить.
Бюджет сейчас (комната 3, Kid + страж, статика): 0.861..0.863 кадра,
запас 58 840 тактов до 430 000. Кроссбанковых вызовов 19 за кадр
(~654 такта каждый = 12 400, 3.3 % кадра) — столько максимум вернёт
батчинг; профиль вызовов снят breakpoint'ом на ___sdcc_bcall_ehl с
печатью HL/E и раскладкой адресов по .map.
Замечание по методике: мерить надо ПО МНОЖЕСТВУ кадров. Единичные
всплески до 1.5 кадра, которые я сперва принял за проблему движка,
оказались наводкой от прерываний мыши на хосте — при неподвижной мыши
225 кадров подряд без единого превышения.
Проверено, что --w3 (он остался в sprinter-cc) кладёт в W3 только код и
rodata: --dataseg ему не передаётся, глобал --w3 модуля лёг в общий
_DATA — сюрпризов при возврате к резиденту не будет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Резидента --w3 больше нет: отрисовка (pop_bg + pop_gdraw) уехала в БАНК 2,
физика/коллизия (pop_map) — в БАНК 3. Куча W1/W2 1294 -> 6750 Б.
Что это разблокировало. Резидент был тупиком: из банка он недостижим ни
прямо, ни транзитивно, поэтому pop_map (самый крупный модуль, 5.8 КБ) в
банк было не увести — он зовёт mob-отрисовку. Проверено, что банк->банк
РАБОТАЕТ: ___sdcc_bcall_ehl читает страницу окна портом 0xE2 и кладёт её
на СТЕК своего кадра (runtime/bank.s), поэтому вложенность корректна по
построению. Подтверждено в MAME цепочкой W1 -> банк1 -> банк2 -> банк1:
nested=124 after=8, ровно ожидаемое. Значит развязка mob'а (самое
рисковое место, loose-полы) НЕ понадобилась — pop_map зовёт pop_bg
трамплином.
Правила вызовов проверены на сгенерированном asm и записаны в memory
sdcc_banked_call_rules: трамплин выбирает ОБЪЯВЛЕНИЕ (__banked), а не
раскладка — даже внутри одного .c между __banked функциями он есть.
Внутрибанковые функции оставлены непомеченными и зовутся напрямую, в т.ч.
через границу файла (pop_gdraw -> pop_fore_over_char).
sprinter-cc: --dataseg BANKn БОЛЬШЕ НЕ ставится по умолчанию. Раньше вся
писучая память банкового модуля уезжала в страницу банка и снаружи не
читалась (проверено на .map: глобал лёг по 0x0001C000) — грабли на
каждом переносе. Теперь данные банков по умолчанию в общем _DATA (W1/W2,
замаплен всегда), а прежнее поведение — по явному --bank-data.
Замеры (комната 3, Kid + страж; кадр Sprinter в турбо = 430 000 тактов):
до переноса 338 508 (0.79 кадра)
+ pop_bg в банк 2 347 100 (+2.5 %)
+ pop_map в банк 3 371 100 (+9.6 % к исходному, 0.86 кадра)
Плата — трамплины (~654 такта на вызов, ~30 вызовов за кадр). При
пейсинге в 3 кадра это 29 % логического кадра, но запас до ОДНОГО кадра
всего ~59 000 тактов — под звук его надо возвращать (следующий шаг:
батчить кроссбанковые вызовы, начиная с pop_redraw_needed).
Профилирование бордюром включено по умолчанию (make PROF=0 выключает) и
переведено на реально работающие биты: бит 0 (красный) у бордюра Sprinter
ИГНОРИРУЕТСЯ, поэтому различимых состояний четыре и значения обязаны быть
чётными — 0 чёрный (ждём vsync), 2 синий (логика), 4 зелёный (фон),
6 циан (спрайты). Раньше нечётные номера сливались и полосы не читались.
Проверено в MAME: комнаты 1/3 рисуются как прежде, бег и коллизия
работают, в комнате 12 плиты проваливаются со щебнем — то есть цепочка
loose банк3 -> банк2 живая. Полосы бордюра в комнате 3: логика 73
строки, фон 64, спрайты 128, свободно 23.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разгрузка W1/W2 под будущий ИИ стражей: _kbdraw_down был БАЙТОМ на
скан-код (512 Б в _DATA при 32-килобайтной раскладке). Теперь бит на
код: код>>3 = байт, код&7 = бит, расширенные (префикс 0xE0) — смещение
+32 байта вместо +256.
Трамплин прерывания строит маску СДВИГОМ, а не таблицей: таблица
потребовала бы `ld hl,#метка` внутри трамплина, а он копируется в W2
побайтно и обязан быть без абсолютных само-ссылок (см. его шапку).
Маска строится в BC, поэтому в клавиатурной ветке добавлен push/pop bc.
Трамплин вырос 244 -> 267 Б, буфер копии поднят 320 -> 336 (запас 69 Б).
Проверено в MAME на roomtest, все три класса клавиш:
- обычные: '=' (обход комнат) и 'K' (чит-убийство стража — читал
guardhp_curr/delta: 3/0 -> 0/-3);
- расширенные (E0): стрелка вправо — Kid добежал до края комнаты;
- модификаторы: удержание Shift ставит бит 2 байта 2 карты
(скан-код 0x12), отпускание снимает.
Скорость: кадр 334 716 -> 338 508 тактов (+1.1 %) на битовой арифметике
в kbd_raw_down (~15 вызовов за кадр); при бюджете 430 000 это 0.79
периода вместо 0.78 — регрессии нет.
Итог по roomtest: данные 4422 -> 4022 Б, куча W2 996 -> 1294 Б.
Эталон размеров принят заново (make size-baseline): _CODE десяти
программ вырос на 14-23 Б — это код битовой арифметики в трамплине и
kbd_raw_down, обмен на -448 Б данных, которые size_check не считает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Жалоба: стойка неподвижных Кида и стража занимала больше полутора
кадровых периодов. Гипотеза «виноват __banked» ЗАМЕРОМ НЕ
ПОДТВЕРДИЛАСЬ: весь банковый вызов (трамплин + смена страницы + тело
pop_guard_tick + возврат) стоит 654 такта при бюджете кадра 430 000.
Как мерил (выборка PC бесполезна — мост MAME отвечает из фреймового
колбэка, все сэмплы падают в обработчик прерывания): breakpoint'ы MAME с
действием {printf totalcycles; g} на входах фаз главного цикла, разности
соседних меток = стоимость фазы. Плюс профилирование полосами бордюра
(make PROF=1, макрос PROF() в roomtest.c) для быстрого взгляда.
Замер комнаты 3 (Kid + страж), такты, кадр = 430 000:
fore поверх стража 431 964
fore поверх Kid 402 816 -> 78 % всей работы кадра
остальное 241 956
ИТОГО 1 076 736 = 2.5 кадра
Две причины, обе устранены:
1. Fore-слой рисовал ЦЕЛЫЕ тайлы, хотя существует ровно для того, чтобы
вернуть куски поверх спрайта — за его прямоугольником в видеопамяти и
так правильный фон. Введено ОКНО клипа (pop_fore_set_clip): спрайт
сообщает свой итоговый габарит (у Kid — с клинком, брызгами и
обрезкой clip_char), fore-проход режет по нему. Отсев трёхступенчатый:
тайл целиком (tile_in_fclip, до обращения к атласу), кусок по грубому
габариту (до gfx_w0_map — w/h лежат в EMM-странице), и точный клип в
blit_b. Для последнего добавлен libbgi-примитив
gfx_blit_part_noclip — пара к gfx_blit_noclip, но под-прямоугольник.
2. Оставшиеся 341 К после клипа оказались НЕ пикселями: 18 вызовов
pop_prandom за проход, ~10 700 тактов каждый (32-битный LCG:
__mullong ~8 000 + __moduint). Раскладка кладки тайла — чистая
функция (комната, ряд, колонка), то есть константа комнаты, а
wall_pattern пересчитывал её каждый кадр. Теперь кэшируются готовые
РЕШЕНИЯ (что рисовать и с каким смещением), 3 байта на тайл, сброс в
pop_room_draw. Порядок вызовов prandom воспроизведён один в один,
включая то, что значение метки берётся только при сработавшем условии.
Итог того же замера: fore поверх стража 37 464, поверх Kid 46 464,
кадр целиком 334 716 = 0.78 периода (было 2.5). Ускорение 3.2x, сами
fore-проходы — 10x.
Проверка отсутствия регрессии: попиксельная разность скриншотов комнат
1/2/3 до и после — отличаются ТОЛЬКО языки пламени факелов (анимация),
кладка и метки совпадают байт в байт.
Побочно: sprinter-cc научился пробрасывать -DNAME в sdcc.
Память: куча W2 1245 -> 996 Б (кэш кладки 120 Б), резидент W3 12 819 ->
14 512 (свободно 1872 Б — становится тесно), банк 1 236/16384.
ВНИМАНИЕ: make size-check показывает рост 7 программ, но эталон
docs/size_baseline.tsv отстал (последний раз принят в 484b18d, libbgi
менялась в 95c22be/c127a4b/64ce633) — к этой правке рост отношения не
имеет: новый модуль библиотеки в чужие программы не линкуется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разгрузка W1/W2 перед ИИ стражей (вариант 2 из двух обсуждённых).
1. pop_guard.c разделён по окнам: состояние/логика (Guard, HP, enter,
kill, load_frame) остаются в W1/W2 — их обязан видеть банк guards.c;
ОТРИСОВКА уехала в новый pop_gdraw.c, собираемый как --w3 (резидент).
Правило границы: резидент = только то, что рисует и зовётся
исключительно из главного цикла. Кадр стража стал глобальным
(pop_gframe): заполняет логика, читает резидент.
2. Страж не окклюдировался передними гранями тайлов — рисовался поверх
столба. В оригинале любой Char это запись midtable, а foretable
рисуется после всех midtable (draw_tile_fore, seg008:690), т.е. столб
перекрывает всех одинаково. Футпринт персонажа выделен из
pop_fore_over_kid в char_footprint(), поверх него добавлен
pop_fore_over_char() — слой fore + полоса потолка, без оверлеев поз
виса/полёта/подъёма (у стража их нет; появятся — портируем
redraw_at_char2 общим кодом, а не догадками).
3. Упаковщик стража: тот же off-by-one, что уже ловили у Kid.
load_chtab_from_file(id_chtab_5_guard, 750) даёт images[0] = res751,
а рисование индексирует images[frame.image] — значит image=N это
res(751+N), а не res(750+N). Из-за сдвига frame_166_stand_inactive
рисовался как res767 (выпад) вместо res768 (стойка).
Проверено в MAME: страж в комнатах 3 и 21 стоит в правильной позе;
окклюзия подтверждена патчем Guard.x в живой сессии — при заходе за
столб спрайт корректно срезается его передней гранью.
Память: _CODE 26 149 -> 25 703, куча W2 805 -> 1245 Б, резидент W3
11 656 -> 12 819 (свободно 3565 Б), банк 1 236/16384 Б.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Шаги 1-2 из плана стражей:
1. Спрайты (chtab_5_guard, база 750). Новый упаковщик pop_pack_guard.py:
data/GUARD/res751..784 -> GUARD\g0..g4.atl (5 EMM-страниц, адресация
id>>3 / id&7, как у Kid). Палитра берётся НЕ из PNG, а из res10.bin
(guard_palettes: 7 палитр по 16 цветов, 6-бит) по level.guards_color —
на уровне 1 у обоих стражей color = 2; группа слотов 0x90..0x9F
добавлена в общий kid.pal.
2. Таблица кадров стража у оригинала СВОЯ (frame_tbl_guard, seg006:372,
41 запись) и индексируется как frame + add_frame - 149, где add_frame
= 70 для кадров 102..106. Она дописана в kid_data.bin (3515 -> 3720 Б,
смещение в KID_BIN_GFRAMES_OFF); pop_kid получил pop_kid_data_frame()
— чтение кадра из ЛЮБОЙ таблицы страницы данных.
3. Появление: pop_level_guard() читает guards_tile/dir/color/skill из
уровня, pop_guard_enter() ставит стража по enter_guard (seg002:0112) +
pos_guards (seg003): row из тайла, y = y_land[row+1], x из колонки,
charid = guard, sword сложен, alive = -1, HP = 3. Отрисовка
pop_guard_draw() — та же математика, что kid_draw (load_frame_to_obj +
calc_screen_x_coord), но атлас стража и своя таблица кадров; heal по
странице дабл-буфера, как у Kid.
Интерпретатора последовательностей у стража ПОКА НЕТ: кадр ставится
напрямую (166 = frame_166_stand_inactive, что и даёт seq_77 при входе в
комнату). play_seq для произвольного персонажа + ИИ — следующая фаза.
Проверено в MAME: в комнате 3 страж появляется на своём месте (ряд 1,
кол 7) и рисуется; цвета совпадают с эталонным рендером спрайта в
палитре color=2.
ВНИМАНИЕ по памяти: куча W2 просела до 805 Б (было 2349). Перед ИИ
стражей нужен шаг 5 плана (данные: room_modif 720 Б, dl1/dl2 512 Б,
_kbdraw_down 512 Б) либо вынос кода отрисовки стража в резидент W3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Все .atl лежали в корне диска рядом с exe — с атласами стражей корень
зарос бы окончательно. Теперь ресурсы разложены по каталогам (8.3, как
принято в DSS):
BG\ фон (env0..4, wall, fore, pot)
KID\ персонаж (kid0..27, kid.pal, sword, kid_data.bin)
GUARD\ стражи (появятся здесь)
LEVELS\ уровни (res2001.bin)
make_hdd.sh принимает аргумент вида КАТАЛОГ:файл — создаёт каталог на
образе и кладёт файл туда; без префикса файл идёт в корень. В Makefile
roomtest появилась цель `make hdd`, которая собирает образ с этой
раскладкой (раньше команда набиралась руками на 15 строк).
Проверено в MAME: DSS открывает пути вида KID\kid0.atl — комната
рисуется, Kid и факелы на месте, то есть все атласы, палитра, таблицы
анимации и уровень грузятся из подкаталогов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раскладка (по подтверждённой пробником модели, tests/w3bankgfx):
- roomtest переведён на MEMORY=huge: та же small-раскладка резидента
(CODE в W1, DATA за ним) плюс банки кода в W3;
- guards.c собирается как --bank 1=guards.c — там будет ИИ и боёвка;
- СОСТОЯНИЕ стража живёт в W1/W2 (pop_guard.c): писучие статики
__banked-модуля линкуются в страницу банка и снаружи не читаются, так
что банк — только код;
- поля pop_char_t повторяют char_type оригинала (types.h:302), чтобы порт
seg005/seg006 ложился один в один.
Режим читов (порт cheats_enabled, seg000:111): глобальный флаг pop_cheats,
на время разработки включается в main. Реализован один чит — K (kill
guard, seg000:786): скелета не берёт, живому стражу ставит
guardhp_delta = -guardhp_curr и alive = 0. Обработка по фронту нажатия.
Остальные читы оригинала не портированы.
Проверено в MAME: приложение в huge-раскладке стартует, комната рисуется,
Kid бегает — то есть банкованный pop_guard_tick() зовётся каждый кадр
через трамплин и корректно возвращается; K не роняет приложение (стража
в комнате пока нет). Банк занят на 6 Б из 16384.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Проверяет то, на чём стоит план раскладки PoP (layout_plan_v2.md §2):
R3 резидент/HOME -> __banked через трамплин работает;
R4 примитивы libbgi можно звать ИЗ БАНКА: _bgi_begin запоминает текущую
страницу W3 (порт 0xE2), _bgi_end её возвращает — банк переживает
рисование и продолжает исполняться;
то же верно для функции W1/W2, вызванной из банка: она рисует, а в W3
остаётся страница БАНКА, не резидента.
Результат в MAME: все пять полос на месте, вердикт ЗЕЛЁНЫЙ. Замеры:
страница банка 0xF0 до рисования, после прямого блита и после возврата из
W1/W2-функции — та же 0xF0; резидент 0xF3; банк дожил до конца и вернул
корректное значение.
Два побочных вывода, важных для стражей:
1. Писучие статики __banked-модуля линкуются В СТРАНИЦУ БАНКА (адрес
0x1C000+), снаружи их не прочитать — состояние банка держать в W1/W2.
2. Инлайновый `in a,(0xE2)` посреди тела функции затирает A, куда SDCC уже
положил параметр (в первой версии пробника цвет заливки становился
номером страницы, и «резидент не рисовал»). Читать порт отдельной
__naked-функцией.
Имя exe — 8.3 (w3bgfx.exe): DSS длинных имён не понимает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сведены дубли, разъехавшиеся по модулям:
- x_bump[20] был в pop_kid (uint8_t!) и в pop_map (int16_t) — теперь одна
таблица int16_t;
- y_land[5] — две копии;
- y_to_row_mod4 — в pop_bg и pop_map;
- 32-битный LCG оригинала (prandom) — в pop_bg и pop_trob; функция теперь
одна, а СИДЫ остались раздельными (у раскладки кладки и у фаз факелов
свои последовательности, смешивать нельзя — иначе поедет рисунок стен).
Экономия по коду скромная (_CODE 24462 -> 24421, W3 11643 -> 11632: часть
выигрыша съели межмодульные вызовы). Главное здесь другое: pop_geom лежит
в W1/W2 и не трогает графику, то есть это тот самый «чистый» слой, который
сможет звать __banked-код стражей (docs/layout_plan_v2.md §4, §5.2).
Проверено в MAME: комната 1 после пересборки отрисована ПОБАЙТОВО так же,
как до правки (0 различающихся пикселей в области комнаты) — значит
последовательности PRNG и геометрия не поехали; Kid бегает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Loose-полы, плита-потолок и щебень от приземления больше не зовут pop_bg —
ставят пометки (pop_set_redraw / pop_set_redraw_above), которые разбирает
pop_redraw_needed из главного цикла. Удалены самодельные счётчики
loose_bake/loose_rest/ceil_rest/ceil_bake/land_bake: их роль (вторая
страница дабл-буфера) теперь у счётчика страниц в пометке.
Осталось ОДНО исключение: падающий кусок (mob) — spawn/tick/pos. Это
движущийся ОБЪЕКТ, а не перерисовка тайла, и в оригинале он живёт отдельно
(mobs + draw_moving), поэтому разделение его на логику и отрисовку —
следующая фаза. Из-за него pop_loose_tick остаётся единственной функцией
pop_map, которую нельзя звать из __banked-кода; вся коллизия, физика,
кромки и предметы — то, что понадобится стражам — чисты от графики.
Замер: _CODE 24718 -> 24462, _DATA 4301 -> 4219 (ушли rest-массивы).
Проверено в MAME: комната 12, осторожный шаг на плиту — тряска, падение
плиты, Kid проваливается на ряд 1, дыра и щебень отрисованы; на
замороженном кадре обе страницы дабл-буфера побайтово совпадают в области
изменений (7 строк).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Порт архитектуры оригинала: логика анимации тайлов НИЧЕГО не рисует, она
ставит флаг (set_redraw_full/set_wipe/redraw_20h/redraw_21h, seg007), а
отрисовка идёт отдельным проходом redraw_needed (seg008:0178). У нас
появился pop_redraw.c/.h: pop_set_redraw(tilepos, вид, страницы) +
pop_set_redraw_above(col, ...) + pop_redraw_needed(), который зовёт
главный цикл в слое фона (до kid_draw).
Отличие от оригинала (наша платформа): счётчик пометки — это ЧИСЛО СТРАНИЦ
дабл-буфера (обычно 2), а вид перерисовки хранится явно (heal+поверх или
запечь фон), потому что у нас у каждой страницы своя ОЗУ-копия фона. В
оригинале вид кодируется тем, в какой из таблиц redraw_frames_* стоит флаг.
pop_trob переведён на пометки: пики, кнопки, дверь уровня. Его самодельные
массивы spike_rest/button_rest/ldoor_rest/rest_pending удалены — их роль
теперь у счётчика страниц в pop_redraw. Прямыми вызовами pop_bg осталось
только пламя факела и пузырёк зелья: это не тайловая перерисовка, а
покадровый оверлей; из-за них pop_process_trobs остаётся единственной
функцией модуля, которую нельзя звать из банка.
Зачем: из __banked-кода резидентная страница W3 недостижима транзитивно
(docs/layout_plan_v2.md §2 R2), поэтому логика, которую будут звать стражи,
не должна вызывать pop_bg.
Проверено в MAME: комната 6 — Kid на кнопке-открывалке, решётка в шве
поднимается; сошёл с кнопки — закрывается; упал в шахту на пики — пики
выдвинулись и отрисованы (кадр 177).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кадры Kid читались из EMM-страницы по НЕВЕРНОМУ адресу для индексов >= 52.
Запись
(const uint8_t *)(0x100) + (uint16_t)i * 5u
SDCC 4.5 собрал так: умножение честно в 16 битах (add hl,hl / add hl,bc),
а затем `ld c,l` + `inc b` — то есть взял только МЛАДШИЙ байт результата и
подставил старший байт константы. При i*5 >= 256 адрес уезжал на -256*k,
и cur_frame наполнялся чужой строкой таблицы: у кадров бега/шага/подъёма
пропадал бит FRAME_NEEDS_FLOOR — Kid «вкручивался» в пол и проваливался
вниз, последовательности кадров не соответствовали seqtbl.
Фикс: адрес считается в uint16_t (i*5 = i + i<<2, без умножения) и
кастуется один раз — сгенерированный код теперь сохраняет старший байт
(ex de,hl / inc d).
Коварство бага: тот же паттерн в pop_level.c (room_fg_ptr/room_bg_ptr,
links) компилируется ПРАВИЛЬНО — проверил все три места по .asm. Записано
в memory sdcc_z80_const_ptr_index_bug.
Проверено в MAME на замороженных кадрах: 10 выборок (кадры 4,10,15,49,50,
54,123) — cur_frame совпадает с таблицей во всех, включая те, что раньше
были испорчены.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--w3 берёт ОДИН файл на флаг, поэтому запись "--w3 pop_trob.c pop_map.c"
означала "W3 = pop_trob", а pop_map всё это время ехал в W1/W2 (в
build-каталоге лежал осиротевший w3_pop_map.rel). Теперь список явный.
pop_trob переведён в W1/W2: в W3 должно оставаться только то, что банк
никогда не позовёт (из __banked резидентная страница W3 не видна ни
напрямую, ни транзитивно — docs/layout_plan_v2.md §2 R2). pop_trob же
стражам понадобится: в оригинале они тоже давят кнопки.
Замер: W3 14376 -> 11643 (свободно 2008 -> 4741 Б), W1/W2 _CODE
21634 -> 24367 (куча 5315 -> 2582 Б). Освободившееся место в W3 —
задел под шаг 3 (loose/потолок из pop_map, чтобы pop_map стал
bank-safe).
Проверено в MAME скриптом: комната рисуется, Kid бежит и тормозит
(кадры 15 -> 10 -> 15), факелы анимируются (152 различающихся пикселя
между соседними кадрами) — то есть pop_trob работает из нового окна.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kid_frames (241*5) и kid_seqtbl (2310) занимали 3.5 КБ в _CODE окна W1/W2
— самого дефицитного ресурса. Теперь они лежат в kid_data.bin (отдельная
EMM-страница), которая маппится в W0 ровно на время play_seq — один
map/unmap за тик, в фазе тика, без конфликта с атласом в W0.
Ключ к переносу — порт load_frame/cur_frame (seg006): оригинал раз за тик
копирует кадр в структуру, и вся коллизия/отрисовка читает ЕЁ, а не
таблицу. У нас так же: kid_cur_dx/kid_cur_flags (их дёргают несколько раз
за кадр из pop_map) и kid_draw читают cur_frame — 5 байт в _DATA.
kid_seq_off (230 Б) оставлен резидентным: его читает kid_set_seq из
pop_ctrl/pop_map, вне страницы.
pop_extract_kid_data.py теперь пишет и kid_data.bin, и урезанный
kid_data.h (тип kframe, размеры, смещения в бинаре, kid_seq_off).
Замер: _CODE 24881 -> 21634 (-3247 Б), куча W2 2076 -> 5315 Б, W3 без
изменений. Проверено в MAME скриптом: стойка -> бег (кадр 8) -> стоп,
перемещение и коллизия у кромки работают, спрайт рисуется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Новый документ applications/PoP/docs/layout_plan_v2.md по свежему замеру
(коммит 1214785): точные размеры окон/модулей/функций/данных, уточнённая
модель банкинга и пошаговый план.
Главное уточнение против v1: из __banked-кода резидент W3 недостижим — и
транзитивно тоже (bank -> pop_map -> pop_bg сломается). Отсюда целевая
раскладка: W3-резидент = графика, которую зовёт только главный цикл;
W1/W2 = ядро, достижимое отовсюду (включая банки); банки = новая холодная
логика (стражи/боёвка). Проверено по libbgi: скобка _bgi_begin/_bgi_end
сохраняет и возвращает ТЕКУЩУЮ страницу W3, поэтому примитивы libbgi
можно звать и из банка; нельзя лишь открывать скобку из кода, лежащего
в W3.
Крупнейшие цели: kid_data.h (3745 Б таблиц в _CODE) -> EMM-страница с
портом load_frame/cur_frame; вынос loose/потолка из pop_map в W3 (делает
pop_map bank-safe); дедуп геометрии в pop_geom.c; разгрузка _DATA.
Попутная находка: --w3 принимает ОДИН файл на флаг, поэтому в Makefile
"--w3 pop_trob.c pop_map.c" кладёт в W3 только pop_trob, а pop_map едет
в W1/W2 (в build-каталоге остался устаревший w3_pop_map.rel).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/extra — ~570 МБ архивов чужих исходников (525 МБ из них — четыре
почти одинаковых zip'а bad_apple); docs/sources — клоны чужих
репозиториев со своими .git внутри, которые при обычном add стали бы
битыми gitlink-ссылками (без .gitmodules клон их не подтянет).
Материалы остаются на диске, но в историю не попадают: раздувание репо
необратимо без перезаписи истории.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- docs/new/ — сводные справочники (архитектура, BIOS, DSS, память,
графика, акселератор, IRQ, порты, ввод, звук, известные баги);
- docs/Original/ — первоисточники, из которых они собраны (BIOS, Estex
DSS, мануалы, описание акселератора), + Форум.doc/.docx в reference;
- libbgi/common/gfx_scroll_h.c — обход бага скролла при ширине >256
(правка автора: шаг банды 255 и продвижение указателей на cw; старый
вариант с 256 оставлен закомментированным с TODO);
- удалён applications/PoP/roomtest/hang_variants.png — рабочая раскладка
из разбора позы виса, в репозитории ей не место.
Большие архивы (docs/extra ~568 МБ, docs/sources с вложенными git-репо
~68 МБ) в коммит НЕ включены — см. обсуждение.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:26:31 +03:00
556 changed files with 37223 additions and 71619 deletions
хранят по контейнерному формату (§1) множество мелких чанков (десятки—сотни
байт каждый).
Что подтверждено:
- Наборы `shadow.dat`/`kid.dat` и `fat.dat`/`vizier.dat` содержат **побайтово
идентичные фрагменты** данных в начале файла — это ожидаемо: "Тень" (Shadow)
визуально копирует анимацию Кида, а "Толстый страж" (Fat guard, пасхалка)
переиспользует модель Визиря. Подтверждает, что персонажи одного "типа
тела" используют общий набор геометрии/анимации.
- Отдельные чанки *не* имеют очевидного унифицированного заголовка
(высота/ширина/палитра) фиксированного размера — попытка интерпретировать
первые байты чанка как `{height:u16, width:u16, flags:u16}` не подтвердилась
на реальных данных (получаются нереалистичные размеры для маленьких чанков).
Вероятно, как и в Apple II версии (см. `FRAMEDEF.S`/`SEQTABLE.S`), геометрия
кадра (ширина, высота, точка привязки) хранится **отдельно от самих
пиксельных данных** — в таблицах внутри `PRINCE.EXE`, а не в `.DAT`-чанке.
Сам чанк, вероятно, содержит только упакованные пиксельные данные
(RLE/дельта-упаковка, по аналогии с `UNPACK.S` в Apple II исходниках).
- Точный алгоритм упаковки пикселей **не восстановлен** в рамках этого
анализа по сырым байтам — байт-в-байт разбор распаковщика без
дизассемблирования `PRINCE.EXE` надёжно не сделать. **Но для практических
целей это не требуется**: см. §7 — в SDLPoP уже есть тот же самый набор
изображений в готовом, распакованном виде (PNG), которым можно пользоваться
напрямую как источником ассетов, не реализуя свой декодер `.DAT`-пикселей.
Писать собственный декодер имеет смысл только если понадобится читать
оригинальные `.DAT` "на лету" (например, для точной сверки контента именно
нашей копии игры) — тогда ориентир — исходник SDLPoP (`src/seg009.c`).
---
## 4. Звук — уверенность: высокая (по структуре), низкая (по деталям кодека)
Обнаружено 4 параллельных набора звуковых ресурсов под разные звуковые
устройства DOS-эпохи — типично для игр начала 1990-х с "звуковым меню":
| Файл | Устройство | Формат чанка |
|---|---|---|
| `midisnd1.dat`, `midisnd2.dat` | General MIDI / MPU-401 | каждый чанк = 2-байтовый LE-префикс длины + встроенный Standard MIDI File (`MThd`...`MTrk`...) |
| `mt32snd1.dat`, `mt32snd2.dat` | Roland MT-32/CM-32L | тот же формат: префикс длины + `MThd`/`MTrk`, с MT-32-специфичными SysEx (видны строки `MT-32.mff`, текстовые мета-события вроде `"The Princess awaits"`) |
| `prince.dat` | (аналогично MIDI) | отдельный крупный музыкальный ресурс, тот же MIDI-контейнер — вероятно, финальная тема |
| `digisnd1/2/3.dat` | Covox / Disney Sound Source / Sound Blaster (оцифрованный звук) | чанк начинается с нескольких служебных байт, среди которых слово `0x2AF8` = 11000 — похоже на частоту дискретизации 11 кГц; далее — сырые 8-битные PCM-сэмплы (значения кластеризуются вокруг ~0x7A–0x90, типично для беззнакового 8-бит аудио, смещённого к середине шкалы) |
| `ibm_snd1.dat`, `ibm_snd2.dat` | PC Speaker | чанк — последовательность троек байт похожих на (длительность, делитель_частоты) — простой формат "бипера", отличный от MIDI |
Подтверждено разбором первых чанков в каждом файле (см. байтовые дампы,
проверялись скриптом). Точная семантика полей внутри `digisnd`/`ibm_snd`
(разрядность, порядок байт служебного заголовка) не выведена до конца — при
реализации порта достаточно распознавания по типу файла и (для MIDI-семейства)
можно напрямую воспроизводить встроенный Standard MIDI File, пропустив
2-байтовый префикс длины.
---
## 5. Готовые распакованные ассеты в SDLPoP (`data/`) — практический источник для порта
Репозиторий github.com/NagyD/SDLPoP содержит папку `data/`, где, помимо
самих `.DAT`-контейнеров, каждый ресурс **продублирован в виде отдельно
распакованного файла**, названного по его `id` из таблицы оглавления (§1.2).
Проверено через GitHub API (`api.github.com/repos/NagyD/SDLPoP/contents/...`):
| `DIGISND1.DAT`, `MIDISND2.DAT` и др. | сырые `.DAT` | размер **не совпадает** с нашими локальными файлами (48545 vs 50101, 18408 vs 18958) — другой релиз/сборка игры |
| `GUARD/res751.png` … `res784.png` | готовые PNG, по одному на кадр анимации, имя = `res<id>.png` | id-диапазон (751-784) точно совпадает с нашим разбором `guard.dat` |
| `LEVELS/res2000.bin` … `res2015.bin` | сырые дампы уровней по 2304-2305 байт | id совпадает с `levels.dat`; содержимое `res2001.bin`**сверено побайтово** с нашим id=2001 — тайловые данные совпадают |
| `KID/`, `PRINCE/`, `SHADOW/`, `SKEL/`, `VIZIER/`, `FAT/`, `TITLE/`, `VDUNGEON/`, `PV/`, `IBM_SND1/`, `IBM_SND2/`, `font/`, `music/` | аналогичные наборы для остальных ресурсов | не проверялись по отдельности, но структура (папка на каждый `.dat`, файлы `res<id>.ext`) наблюдается одинаково |
**Вывод:** это данные из немного **другого релиза DOS-версии**, чем те, что
лежат у нас в `MSDOS/` (см. расхождение в размере `digisnd`/`midisnd`), но
формат контейнера и нумерация `id` — те же самые. Практически это значит:
1. Для получения играбельных PNG-спрайтов и VGA-фонов **не нужно
реализовывать декодер сжатия пикселей** — можно взять готовые файлы
`data/<ИМЯ>/res<id>.png` напрямую как исходный материал для конвертации
под видеорежим ZX Sprinter (в т.ч. `VPALACE`/`VDUNGEON` — уже
256-цветный VGA-арт, что прямо отвечает на вопрос про полноцветность).
2. Если в проекте важно использовать именно ту версию контента, что в наших
`MSDOS/*.dat` (а не версию из SDLPoP) — распаковку своих файлов всё же
придётся делать (кодек пикселей по-прежнему не восстановлен для сырых
`.DAT`, см. §3), либо принять решение работать с версией SDLPoP как
мастер-источником ассетов вместо своей.
---
## 6. Служебные не-ресурсные файлы
-`config.dat`, `setup.dat` — 28 байт, не являются ресурсными контейнерами
(не проходят проверку §1.1 — "размер" получается больше самого файла).
Скорее всего простые бинарные структуры настроек (звук/видеорежим,
выбранный на этапе `SETUP.EXE`/`INSTALL.EXE`), не связаны с игровым
-`PRINCE.EXE` / `PRINCE.REM` — почти идентичны (отличие в единичных байтах
в районе смещения ~0x4ED0), похоже на кряк/патч одного байта проверки —
не относится к формату ресурсов.
-`old-games.nfo` — ASCII-арт NFO релиз-группы (old-games.ru), не игровые
данные.
---
## 7. Итоговая таблица уверенности
| Раздел | Уверенность | Как подтверждено |
|---|---|---|
| Контейнер `.DAT` (заголовок + таблица) | Высокая | Проверено скриптом на всех 28 файлах, инвариант offset+size выполняется без исключений; независимо подтверждено именованием `res<id>.*` в SDLPoP `data/` |
| ID-пространства ресурсов | Средняя-высокая | Наблюдение по диапазонам + сверка с `res<id>` именами файлов SDLPoP и побайтовым содержимым `res2001.bin` |
| Формат уровня = формату Apple II | Высокая (по размеру и содержимому), служебный блок id=2000 — открытый вопрос | Совпадение размера (2304 vs 2305), тайловые байты сходятся с `res2001.bin` из SDLPoP |
| Формат изображений/спрайтов (сырой `.DAT`) | Низкая-средняя | Контейнер подтверждён, кодек пикселей — нет; но практически закрыто наличием готовых PNG в SDLPoP `data/` (§5) |
| Формат звука (тип контейнера) | Высокая для MIDI-семейств, средняя для digisnd/ibm_snd | Явные MIDI-сигнатуры `MThd`/`MTrk` видны в байтах |
**Рекомендация для дальнейшей работы:** для получения арт-ассетов (спрайты,
фоны, палитры) — использовать готовые распакованные файлы из
`github.com/NagyD/SDLPoP/tree/master/data` (§5), это быстрее и надёжнее
самостоятельной реализации декодера. Декодер сырого `.DAT`-формата
изображений и точную семантику служебных полей `digisnd`/`ibm_snd`
(§3, §4) стоит восстанавливать только если понадобится читать именно нашу
локальную копию `MSDOS/*.dat` "как есть" — тогда ориентир прежний: исходник
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.