Compare commits

...

295 Commits

Author SHA1 Message Date
snark13 f66fd0e1b6 Музыка на прыжке и на плите: SUB затирал A в звуке тряски
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>
2026-08-26 11:04:55 +03:00
snark13 e5179af9d8 Музыка звучала там, где оригинал играет эффекты
Три места, все — наши заявки на треки, поставленные без условий оригинала.

Вступление первого уровня (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>
2026-08-26 10:41:07 +03:00
snark13 06a1772011 Fade: 32 ступени яркости — на глаз неотличимо от оригинальных 64
Шестнадцать ступеней на 2,13 с давали различимую лесенку (0,13 с на шаг).
Тридцать две меняются каждые 66 мс — это уже слитное затухание. Запас на
них есть: ступень стоит 487 тысяч тактов, тридцать три съедают около сорока
кадров из ста шести, остальное цикл ждёт луча.

Длительность каждого fade остаётся оригинальной (128 тиков), поэтому сумма
«fade in + сцена + fade out» совпадает с SDLPoP без правки самих сцен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:21:55 +03:00
snark13 5ede8b9045 Fade: пересчёт палитры в пять раз дешевле, шестнадцать ступеней
Затемнение длилось около восьми секунд вместо заказанных двух. Причин
две, и обе измерены в 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>
2026-08-26 10:14:55 +03:00
snark13 0db4f94707 Катсцены: fade длиной как в оригинале, музыка дослушивается до уровня
ДЛИТЕЛЬНОСТЬ. 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>
2026-08-26 09:47:16 +03:00
snark13 4a939e42f8 Катсцены: тайминги, часы и «время вышло» — по оригиналу; тумблер музыки
ТАЙМИНГИ. Треки заставок между уровнями длиннее самих сцен (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>
2026-08-26 09:28:36 +03:00
snark13 0086ac80c2 Таблицы анимации Кида: kid_data.bin -> KID\kid.ani
Все ресурсы Кида теперь зовутся kid.*: kid.arc (атласы), kid.pal
(палитра), kid.ani (кадры + seqtbl). Расширение .ani говорит о
содержимом: это не «какие-то данные», а таблица кадров и байткод
последовательностей движения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:02:14 +03:00
snark13 7e6b38cca6 Музыка по ходу игры: смерть, зелья, меч, конец уровня, катсцены
Звуки 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>
2026-08-25 21:52:37 +03:00
snark13 65797af44c Ресурсы — в архивы PBA1; тайминги сцены PV — по часам насоса CBL
ТАЙМИНГИ.  Пользователь заметил, что заклинание Джафара не совпадает с
музыкой. Замер в 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>
2026-08-25 21:29:14 +03:00
snark13 d86adc54c4 Музыка: озвучена вся заставка, тайминги сведены со шкалой оригинала
Добавлены треки 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>
2026-08-25 20:17:38 +03:00
snark13 16d3262340 Звук: насос CBL качает через W3 — вход в BIOS ломал W0
СИМПТОМ: затемнение (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>
2026-08-25 19:36:47 +03:00
snark13 4327ac88b9 R1 (рабочая копия roomtest) — в .gitignore
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:13:35 +03:00
snark13 ba3aca07bf Архивы ресурсов PBA1: звук одним файлом, музыка потоком; ALLOCS 6000
ЗАМЕР, из которого всё выросло (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>
2026-08-25 17:13:25 +03:00
snark13 34ba5f71d5 Музыка: PCM-трек через CBL, первый экран истории озвучен
Путь 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>
2026-08-25 15:21:22 +03:00
snark13 ffb59b3470 Статус-строка: время, отсчёт ##:## в отладочной полосе, смерть как в оригинале
* Порт 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>
2026-08-25 15:06:17 +03:00
snark13 5756101d29 Статус-строка: LEVEL N и лейбл QS/QL до дисковой операции
* 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>
2026-08-25 14:13:48 +03:00
snark13 47a4b084c4 Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры
Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (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>
2026-08-25 13:58:36 +03:00
snark13 7fd7f28ffc Доки: план меню (рендер, restart без подтверждения), QSAVE закрыт
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 аннотации обновлены
2026-08-22 12:45:01 +03:00
snark13 f4b4852d51 QuickSave F6/F9 в roomtest; bank_load_file/bank_save_file/gfx_w0_page_prepare; sprinter-cc: авто n_banks
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
2026-08-22 11:53:10 +03:00
snark13 b6699b3aef Разрез pop_tile: холодная половина в банк 5 (−1788 Б резидента)
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>
2026-08-20 22:26:05 +03:00
snark13 536c60d14c AGENTS.md + .codex/config.toml — конфигурация агента
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:59:28 +03:00
snark13 e2730f78e0 gfx_scroll_v: колоночный accel-проход без буфера-посредника + тест scroll
Вертикальный скролл перестал ходить через строку-буфер на стеке: колонку
читаем с 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>
2026-08-20 21:59:28 +03:00
snark13 3ea4546656 cbltest: пропущенный вызывающий cbl_open + обновление размерного эталона
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>
2026-08-20 21:59:28 +03:00
snark13 8458ec65f4 libc/cbl: две точки входа вместо underrun_mode — malloc больше не в резиденте
Линкер тянет .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>
2026-08-20 21:53:08 +03:00
snark13 63e426cc0d Откат оптимизации резидента: перенос данных банка в его страницу ломает картинку
Возврат к состоянию после звуковых правок (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>
2026-08-20 21:26:23 +03:00
snark13 90255737c2 Откат bank-data для pop_trob: указатель на его статику уходил наружу
Симптом: через несколько комнат живого прохода перезагружался 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>
2026-08-20 21:21:44 +03:00
snark13 354582662c Резидент W1/W2: данные pop_room и pop_trob — в свои банки (−2546 Б)
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>
2026-08-20 21:14:02 +03:00
snark13 c6828c0ad1 Ворота PoP: правильный гейт слышимости, звук «решётка встала», playsound кнопки
Проверка на сцене 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>
2026-08-20 21:03:16 +03:00
snark13 a630568a8b Звук PoP: приоритеты и перебиваемость вместо «всегда перебивать»
Пользователь услышал расхождение с 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>
2026-08-20 16:46:04 +03:00
snark13 f159aa47e1 CBL: заливка буфера тишиной при открытии + щелчок на выходе из PoP
Две разные болячки, обе разобраны записью звука 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>
2026-08-20 16:23:06 +03:00
snark13 e0c86a96ef Ctrl+S — вкл/выкл звук; чит «выдать меч» убран
Порт 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>
2026-08-20 15:49:33 +03:00
snark13 7e2e7fbb37 Тишина на первом уровне: звук включался только после смены уровня
При разделении загрузки набора (pop_sfx_init) и открытия CBL
(pop_sfx_start) парный вызов start попал только в pop_level_switch.
На стартовом пути его не было — игра шла молча до первого перехода.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:25:09 +03:00
snark13 8ea4c32e51 Звуковые эффекты PoP: оцифровка оригинала через CBL
Набор 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>
2026-08-20 15:16:43 +03:00
snark13 24ced6058f Упаковщик звуковых эффектов + сверка наборов MSDOS и SDLPoP
Эффекты берём из 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,
поэтому ни один блок не пересекает границу страницы — проигрывателю не
нужна логика стыка.
2026-08-20 12:42:19 +03:00
snark13 a3d37bcbfe Тень закрыта: прогон на уровнях 4/5/6/12, расхождение по кайме в impl_diff 2026-08-20 12:33:46 +03:00
snark13 22cdc67c3a Звук: подтверждён формат (8 бит моно) и замерен бюджет EMM
Формат оригинала проверен по convert_digi_sound: один байт на кадр (моно),
байт беззнаковый с центром 0x80 — ровно формат нашего CBL.  Стерео в
данных нет, каналы размножаются на выходе.

Живой замер памяти из работающей программы: занято 124 страницы из 256
(система с exe 43, наши ассеты 81), свободно 132 = 2,06 МБ.  Самый
крупный ассет теперь набор Тени — 32 страницы.  Эффекты WAV займут 8.
2026-08-20 12:25:31 +03:00
snark13 4688364091 Звук: решения пользователя и единая частота 10 937,5 Гц
Эффекты — 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 открывается один раз и
частота не меняется никогда.
2026-08-20 12:10:04 +03:00
snark13 9f9a8f26ae Звук: разобраны все наборы MS-DOS версии, включая mt32snd
Эффекты все 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 ноты).
2026-08-20 11:57:19 +03:00
snark13 7fcd93a35b Разбор звука: эффекты через CBL как есть, музыка на AY из нот PC-спикера
Замеры по ассетам: 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.
2026-08-20 11:53:02 +03:00
snark13 d1183f7315 Отладочный старт перехватывал смену уровня
DBG_START_ROOM/POS подменялись безусловно, а pop_start_level зовётся и на
границе уровня.  Из-за этого на 7-м стартовой становилась отладочная
комната вместо комнаты 17 из данных, и спецсобытие «вход падением»
(set_start_pos, seg003:0196) не срабатывало — переход 6->7 выглядел
сломанным.

Подмена теперь действует только на своём уровне (FIRST_LEVEL); рестарт
того же уровня отладочную позицию сохраняет, как и задумано.
2026-08-20 11:35:09 +03:00
snark13 30bcc3459b Атлас Тени: запечённый набор вместо спрайтов стража
Оригинал кладёт спрайт дважды — прозрачным блитом в 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-м уровне: силуэт с контуром, как в оригинале.
2026-08-20 11:22:53 +03:00
snark13 e60a04e900 Тень: набор спрайтов выбирает поле кадра, а не charid; SHADOW.DAT у нас нет
Поправка к вчерашнему выводу «в бою Тень рисуется спрайтами стража».
Набор берётся из 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 на ошибочном
наборе).
2026-08-20 10:50:36 +03:00
snark13 b6660d7694 Атлас Тени: остановились на 16 цветах (гибридный подбор), блок 0xA0..0xAF 2026-08-20 10:39:40 +03:00
snark13 acb483897d Разбор атласа Тени: алгоритм оригинала, замеры палитры, план
XOR у оригинала идёт по 24-битному RGB, а blitters_2_or — обычный блит с
colour key 0.  От фона зависит только кайма в один пиксель по левым
кромкам силуэта; на чёрном фоне запечка точна.

Замеры: 253 кадра (Кид 219 + страж 34, Тень в боевых кадрах рисуется
спрайтами СТРАЖА), 59 разных цветов.  32 цвета оставляют перцептивно
значимыми 232 пикселя из 96 746.  Палитра: занято 112 слотов, свободно
144; берём 0xA0..0xBF.
2026-08-20 10:38:27 +03:00
snark13 47c26d1899 TUNE-2: параметры стражей в CFG-файл (формат секций как у SDLPoP) 2026-08-20 10:23:20 +03:00
snark13 0ef8c4b60e Бессмертие: два уровня вместо тумблера
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; в отладочной метке число
красных палочек = уровень.
2026-08-20 10:08:52 +03:00
snark13 10b920f156 impl_diff: ГСЧ разведён по доменам (у оригинала один сид) 2026-08-20 09:43:07 +03:00
snark13 6bdac70508 Отладочная метка: уровень, комната, режим скорости, бессмертие; дефолт NORMAL
Четыре блока палочками в верхнем борте, каждый своим цветом.  Цвета взяты
из 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 = число палочек.
2026-08-20 09:40:57 +03:00
snark13 5d61224229 Реестр оптимизации: бюджет кадра вырос втрое, срочность позиций падает 2026-08-19 23:23:22 +03:00
snark13 35d7bd38d4 L1-SPEED закрыта режимами скорости 2026-08-19 23:21:52 +03:00
snark13 5e9c6a2e9e Пейсинг: результаты замеров и грабли методики 2026-08-19 23:21:33 +03:00
snark13 a6e39070af Фиксированный логический кадр по лучу + режимы FASTEST/FAST/NORMAL
Период стал 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.
2026-08-19 23:06:27 +03:00
snark13 61b8d80275 Пейсинг: подробный разбор счётчика кадров по лучу (условие точности, точки выборки, приёмка) 2026-08-19 21:55:05 +03:00
snark13 7f778bba2f Разбор перехода на фиксированный логический кадр (кода не трогали)
Замеры в MAME: кадровые прерывания теряются фазозависимо (0..3%), на
полной перерисовке комнаты — три подряд.  Причина: импульс запроса 32
такта (9,14 мкс) против DI-окон блита ~0,29 мс.  Счёт попаданий
брейкпоинтом на этом драйвере недостоверен (WAIT-линия), достоверен
только детектор разрыва.

Блокер включения gfx_set_fps_div как есть: счётчиковый путь ждёт через
halt и не зовёт idle-хук, то есть возвращает KBD-1.
2026-08-19 21:41:13 +03:00
snark13 39c3247532 Док 13/23: регресс после дня оптимизации 11/15
Максимумы по секциям против эталона 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>
2026-08-19 18:56:07 +03:00
snark13 d0ac4b1c2a Каскад уровня 13 не падал: в гейте P5 пропущено шестое место взвода
Нашёл пользователь на прогоне 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>
2026-08-19 18:50:32 +03:00
snark13 d0de6dedf4 HEAL-WIDTH: heal чомпера ровно 32x60 — его точный след
Замечание пользователя: весь чомпер помещается в свой тайл, значит его
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>
2026-08-19 18:36:04 +03:00
snark13 b6b214e225 Реестр: HEAL-WIDTH закрыт, P10 разобран без реализации
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>
2026-08-19 18:27:18 +03:00
snark13 f62989e358 HEAL-WIDTH: ширина heal'ов по фактическому следу из атласа
Задача была помечена обязательной.  Габариты сняты из каталогов .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>
2026-08-19 18:25:35 +03:00
snark13 a29fb8da34 Реестр: P6a закрыт (-840), P6b оставлен неделанным
Оценка пары 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>
2026-08-19 18:18:13 +03:00
snark13 5a42b2d245 P6a: указатель модификаторов комнаты кэшируется между trob'ами
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>
2026-08-19 18:17:38 +03:00
snark13 52a36caa75 Реестр: P18 — метка огрублена по X (отложено)
Пользователь заметил: Кид перерисовывается, хотя с пламенем не
пересекается; на пиксель левее — перестаёт.

Разбор: спрайт Кида занимает 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>
2026-08-19 18:07:36 +03:00
snark13 9e03739bb0 Реестр: P4 откачен, добавлен P17 (разрядность)
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>
2026-08-19 18:02:09 +03:00
snark13 79bcabde94 Габариты спрайтов в байтах: uint16_t -> uint8_t в слоте отрисовки
Замечание пользователя: спрайты наших атласов не крупнее 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>
2026-08-19 18:00:19 +03:00
snark13 892f005ca5 pop_blit_b: границы экрана двумя беззнаковыми сравнениями вместо четырёх знаковых
Проверка «спрайт целиком на экране» стояла как
  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>
2026-08-19 17:52:43 +03:00
snark13 b3e754a66b Revert "P4: каталог атласа читается из W0, а не через мап W3 — минус 408"
This reverts commit 06fb4235f0.
2026-08-19 17:42:51 +03:00
snark13 a993cb3b62 Реестр: P4 частично, эффект много меньше ожидаемого
Каталог атласа теперь читается из 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>
2026-08-19 17:41:14 +03:00
snark13 06fb4235f0 P4: каталог атласа читается из W0, а не через мап W3 — минус 408
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>
2026-08-19 17:40:24 +03:00
snark13 6f077b0d6d Разбор P14: он сводится к P4 (цене блита)
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>
2026-08-19 17:29:19 +03:00
snark13 952879e7fa Реестр: три отрицательных результата по оптимизации проверок
Записаны, чтобы не повторять, и с разбором причины.

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>
2026-08-19 17:17:27 +03:00
snark13 68e7d17b14 Реестр: P16 закрыт, P14 уточнён замером трупа
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>
2026-08-19 16:57:47 +03:00
snark13 e501982457 Проверка «задет ли слот» без пяти аргументов: минус 3 684
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>
2026-08-19 16:56:50 +03:00
snark13 f47d79ded8 guard_over_kid — только когда кого-то рисуем: минус 14 118
Разложил остаток цианной фазы (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>
2026-08-19 16:42:54 +03:00
snark13 379c513087 cd_quiet: сравнение снимка без построения структуры — минус 8 016
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>
2026-08-19 16:33:31 +03:00
snark13 52bcc65a62 Особенность: убитый за правым краем страж не виден ни в одной комнате
Вопрос пользователя после боя в комнате 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>
2026-08-19 16:23:41 +03:00
snark13 41deb69013 Реестр: P15 закрыт замерами обеих позиций
Лёгкая: 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>
2026-08-19 16:09:47 +03:00
snark13 767d6f78a4 P15: метка «фон трогали» стала точной — минус 167 880 тактов на кадре
Две правки, обе про ложные срабатывания пропуска отрисовки персонажа.

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>
2026-08-19 16:04:46 +03:00
snark13 17c41b32de P15 переписан: перекрытия нет, виновата грубость метки
Пользователь поймал ошибку в моём расчёте, глядя на экран: страж целиком
правее пламени, пересекаться может только меч.

Проверка по памяти машины подтвердила и уточнила:

  страж, спрайт   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>
2026-08-19 15:40:30 +03:00
snark13 a65da96960 CHAR-PARTIAL-REDRAW: обязательная задача о неподвижном персонаже
Постановка пользователя: проверять, нужна ли отрисовка стража, когда он
не двигается.  Если движется — лишние ~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>
2026-08-19 15:35:54 +03:00
snark13 d3049693b8 Реестр: сводка и статусы обновлены; замер тяжёлой позиции
Пользователь передвинул Кида на один осторожный шаг вправо (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>
2026-08-19 15:30:04 +03:00
snark13 6faf81016a Замер цианной фазы: крупного лишнего в отрисовке персонажа нет
Циан 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>
2026-08-19 12:53:49 +03:00
snark13 068e21b56a Реестр: P2b закрыт; иерархия референсов и трамплины в цикле
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:37:34 +03:00
snark13 f8493a4c04 P2b: луч видимости стража 36 786 -> 13 002 (-65 %)
Замер отделил луч от 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>
2026-08-19 12:37:06 +03:00
snark13 da17a48576 Реестр: P2a закрыт, следующий P2b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:09:30 +03:00
snark13 2bdaf0f4cd P2a: coll_scan переведён на 8 бит и снят с IX — минус 3 486 в коллизиях
Разбор 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>
2026-08-19 12:06:43 +03:00
snark13 de68eb5cec P1: чомпер перерисовывался неизменной позой — минус 110 802 такта
Позиция заводилась с НЕПОЛНЫМ диагнозом.  Я приписал 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>
2026-08-19 11:30:01 +03:00
snark13 f81b30eb68 Замер P2 и P6: главные статьи — физика двух Char и луч видимости
Синяя фаза (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>
2026-08-19 11:05:58 +03:00
snark13 6d7c1c8b6b P5: гейты холостого хода в pop_loose_tick — минус 33 840 тактов на кадре
Замер 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>
2026-08-19 10:49:19 +03:00
snark13 fd570c7eb8 Замер сцены 11/15 и единый реестр оптимизаций
Новая целевая сцена: уровень 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>
2026-08-19 10:21:02 +03:00
snark13 09f32ce834 Уровни 10-12 прошли предварительный тест; HEAL-WIDTH связан с G8
Уровень 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>
2026-08-19 00:00:23 +03:00
snark13 4db60c750f Вариант B: кусок с завёрнутым рядом рисуется под всем (корзина 30)
Порт правила оригинала, разобранного в 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>
2026-08-18 23:10:41 +03:00
snark13 272cf8f195 Разбор порядка отрисовки падающей плиты: найден корень, выбран вариант B
СИМПТОМ (пользователь, ур.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>
2026-08-18 22:51:00 +03:00
snark13 cdac5746ac Мид-оверлей не рисовал передний торец плиты (draw_loose из draw_tile2)
Правый конец падающего куска лежал поверх соседней плиты.  У оригинала
мид-оверлей — 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>
2026-08-18 21:16:59 +03:00
snark13 ed5615a95a Плиты нижнего ряда пропадали без кадров падения: draw_mob рисует и соседей
Уровень 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>
2026-08-18 18:55:39 +03:00
snark13 0832445614 Уровень 9 прошёл предварительный тест; замер тактов 13/23 без регресса
Уровень 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>
2026-08-18 17:49:37 +03:00
snark13 a737412154 Уровень 8 прошёл предварительный тест
Найдено и закрыто по дороге: GUARD-RESPAWN-COL0 (c40ae3f) — страж при
возврате в комнату телепортировался в колонку 0 и падал насмерть;
SEAM-FIGHT-FLICKER (a498255) — бой у шва перерисовывал комнату
туда-обратно, поведение сверено с SDLPoP покадрово.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:25:08 +03:00
snark13 3f643532c9 Трасса kidobj для сверки с SDLPoP + разбор боя у шва
Инструмент: 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>
2026-08-18 17:07:01 +03:00
snark13 a498255471 Бой у шва: смену комнаты запрещают ещё и кадры меча (leave_room, seg002:0490)
Пользователь: при бое на шве (Кид в 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>
2026-08-18 16:35:12 +03:00
snark13 c40ae3f8f5 Страж возвращался в комнату не туда: колонку несёт guards_x, а не тайл
Уровень 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>
2026-08-18 16:20:40 +03:00
snark13 0077ef3850 Задача PERF-SWEEP: поиск узких мест по всей игре, а не в одной сцене
Постановка пользователя: ручной проход уровня с логом фаз + комната/тайл,
дальше оптимизация конкретной комнаты.  Записано вместе с блокером —
брейкпоинтами это делать нельзя (эмуляция падает в проценты от реального
времени, играть невозможно); варианты: тап на порт бордюра в Lua-мосте
(не привязан к адресам кода) или самозамер программой в растрах (работает
и на железе).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:59:11 +03:00
snark13 ed7e63a4fa Замер тактов 13/23 после фиксов уровней 3-7: регресса нет
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>
2026-08-18 15:50:26 +03:00
snark13 a6666489c0 Уровень 7 прошёл предварительный тест
Найдено и закрыто по дороге: SPIKE-BAKED (980d48c), DIED-ON-BUTTON
(6feab5d).  Регресс после них: сцена 13/23 (каскад плит) отрабатывает
штатно, host-тесты 5106 проверок без расхождений — включая 1730 трасс
физики, которых касалось снятие раннего выхода для трупа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:36:51 +03:00
snark13 3bcaf51221 NEXT_SESSION: DIED-ON-BUTTON закрыт
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:28:19 +03:00
snark13 6feab5d2f2 Смерть на кнопке ломает её насовсем (died_on_button, seg007:776)
Уровень 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>
2026-08-18 15:27:36 +03:00
snark13 b49ba15666 DIED-ON-BUTTON: найдена вторая половина — физика трупа выключена целиком
Кид гибнет на кнопке открытия решётки (уровень 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>
2026-08-18 15:11:33 +03:00
snark13 980d48c168 Пики оставались навсегда: запечка фона консервировала выдвинутый кадр
Уровень 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>
2026-08-18 14:53:44 +03:00
snark13 214d3a822a Уровни 4-6 прошли предварительный тест
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:09:03 +03:00
snark13 671c947ce9 impl_diff: тень спрайтами Кида — осознанное расхождение, не баг
XOR-приём оригинала требует чтения видео-ОЗУ (нельзя) и несовместим с
0xFF-прозрачностью; план — отдельный атлас тени.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:06:19 +03:00
snark13 af4e64455e Кусок узора на стене: модификатор стены не переводился при загрузке
Флаг «no blue» лежит в нулевом бите модификатора стены В ФАЙЛЕ уровня, а
отрисовка (как и оригинал) ищет его в СТАРШЕМ: load_alter_mod переводит
их сдвигом на 7.  Ветку стен мы пропускали намеренно — связи кладки
считаются по соседям, — но noblue из соседей не выводится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:01:07 +03:00
snark13 085a198c20 Мигание тени: кадр приходил из кэша, а снимок писался по Guard.frame
Тень на уровне 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>
2026-08-18 11:48:45 +03:00
snark13 89b603ae04 Дворцовый портал: Кид скрывался за кромкой раньше времени
Уровень 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>
2026-08-17 23:27:22 +03:00
snark13 188469d574 Уровень 3 принят: критичных багов нет
Обход после оптимизации фаз: третий уровень прошёл без правок — в
отличие от первого и второго, чинить ничего не пришлось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:04:40 +03:00
snark13 0cae2cb32a QuickSave: носитель — файл, а не EMM-страница
Пересмотр по вопросу пользователя.  Первая редакция плана рекомендовала
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>
2026-08-17 23:01:14 +03:00
snark13 78b93a6b5e План QuickSave/QuickLoad: разбор SDLPoP + инвентаризация нашего состояния
Только изучение и план, кода нет.

Первое, что выяснилось: в оригинале 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>
2026-08-17 22:56:27 +03:00
snark13 f44663040c Регресс тактов на 23/13 после обхода уровней 1-2: изменений нет
Замер 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>
2026-08-17 22:49:02 +03:00
snark13 ec1f384688 Уровень 2 принят; в README — оговорка, что иммортал только в бою
Обход уровней после оптимизации фаз: уровни 1 и 2 пройдены, критичных
багов не осталось.  Найденное по дороге закрыто и перечислено в шапке
TASKS_OPEN.md, чтобы регресс-база была видна одним взглядом.

README раньше читы не перечислял вовсе.  Теперь у бессмертия явно записано
главное ограничение — работает ТОЛЬКО с мечом в руке — и почему оно не
косметическое: кадры seq_74 все с мечом, и подмена смерти на эту анимацию
оставляла Кида в стойке при sword == 0, где у control() нет ни одной ветки.
На физику (падения/пики/чомперы) бессмертие действует всегда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:42:06 +03:00
snark13 18f58439a3 Боевая стойка без меча: чит бессмертия ставил seq_74 не глядя на меч
Кида сталкивают с ряда 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 22:34:46 +03:00
snark13 aa845c0362 Зелёная фаза: записана идея G8 — пометка соседа узкой полосой
Идея пользователя 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 18:50:56 +03:00
snark13 dd40c24e7a Чёрные бары поверх стража: заливка запечки уезжала за правый край экрана
Найдено пользователем (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>
2026-08-17 18:46:09 +03:00
snark13 40f0d46542 Регресс тактов после четырёх фиксов: убран +130 000 в heal и +130 000 в циане
Замер на сцене каскада (ур.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 18:14:35 +03:00
snark13 e4e9836f29 Залипший блеск меча: trob_drawn обнулялся, а комната рисовалась по живому modif
Найдено пользователем (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 17:47:26 +03:00
snark13 6a91b9c688 Сосед поверх падающей плиты: не хватало set_redraw2 из draw_mob
Найдено пользователем (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 17:31:56 +03:00
snark13 7ae691b070 Fore потолка поверх падающей плиты: в разборе пометок не было ряда -1
Найдено пользователем (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>
2026-08-17 17:17:04 +03:00
snark13 bb7cf30910 Зелёная фаза: записаны ДВА условия корректности копии второй страницы
Новый раздел §4: копия годится только если (1) запечка ограничена
копируемым прямоугольником — иначе то, что легло вне него, на второй
странице остаётся прежним и страницы расходятся (мерцание торцов, ур.1
к.6, кнопка (0,2)); и (2) содержимое тайла между двумя кадрами не
менялось — у кнопки с идущим таймером пометка обновляется каждый кадр.

Перечёркнут прежний вывод «окно клипа в pop_floor_bake — только вред»:
по скорости да, но оно ОБЯЗАТЕЛЬНО как условие (1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:05:29 +03:00
snark13 35b7cd5196 Фикс мерцания торцов: копия запечки требует ОКНА КЛИПА
Регресс от копии второй страницы (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>
2026-08-17 17:04:09 +03:00
snark13 18ee60eb69 mob_tick_one в file-scope + снятие временной оснастки замеров
зелёная пик  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>
2026-08-17 16:47:20 +03:00
snark13 5ef721e340 Вторая страница запечки — КОПИЕЙ с первой (зелёная 546 900 -> 423 558)
Идея пользователя: то, что пишется в ДВЕ видеостраницы, на вторую можно
копировать, а не считать заново — тем же приёмом, что при перевороте экрана
(зелёное зелье), только без зеркала.

Точечные запечки ставятся с 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>
2026-08-17 14:32:35 +03:00
snark13 14e183108d Документы по фазам: результаты дня и оставшийся план
Зелёная и циан — раскладка ПОСЛЕ правок, журнал правок с коммитами,
отрицательные результаты с объяснением причины (окно клипа в
pop_floor_bake — три попытки), и что осталось: раскол draw_tile на девять
узких частей по образцу seg008:01C7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:23:38 +03:00
snark13 af189a1d92 Композит куска: точный габарит + ИСПРАВЛЕНИЕ размеров в b2da0b8
ПОПРАВКА К 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>
2026-08-17 14:20:29 +03:00
snark13 9a50ab20d7 Коридор heal падающего куска — по высоте композита (-12 тыс. на кадр)
Держали константные 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>
2026-08-17 14:14:12 +03:00
snark13 b2da0b85b5 Композит падающего куска: циан в бюджете (632 000 -> 273 078)
Три части падающей плиты (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>
2026-08-17 14:01:48 +03:00
snark13 a3c473d910 Оптимизация каскада плит: -27% работы за кадр (пять правок с замерами)
Сцена — ур.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>
2026-08-17 13:39:57 +03:00
snark13 babd40bc84 Профиль каскада плит ур.13 к.23: три рабочих документа по фазам
Контрольный замер перед оптимизацией (задача 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>
2026-08-17 12:22:21 +03:00
snark13 c312e4a043 Найдены 8791 тактов на вызов блита: uint16_t габарит в pop_blit_b
Точки на цепочку вызовов (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>
2026-08-13 23:51:20 +03:00
snark13 b27b31318a Разбор draw_tile: модель цены блита + пакетная пометка pop_cd_touch
Замер (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>
2026-08-13 23:36:48 +03:00
snark13 f89b7dd0d2 Ускорение холостого хода: указательный обход вместо arr[i] в горячих циклах
Замер зелёного блока (комната 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>
2026-08-13 23:08:05 +03:00
snark13 50e4eda2ad Падающие плиты: передний слой по пометкам, честные спрайты, замер кадра
Слой 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>
2026-08-13 21:38:59 +03:00
snark13 c31930dcad midtable: разобрано, насколько узко оригинал помечает передний слой
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>
2026-08-13 20:10:55 +03:00
snark13 322d021411 roomnav_skip: достижимость считать обходом от старта, а не входящими связями
Уровень 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>
2026-08-13 20:08:47 +03:00
snark13 69081ae51e Таблица комнат для отладочного телепорта: что пропускать и почему
Посчитано скриптом по 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>
2026-08-13 20:04:44 +03:00
snark13 9e1a55bdc3 Падающие плиты: спрайты падающего набора, замеры слоёв, анализ midtable
Спрайты: у падающего куска 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>
2026-08-13 19:56:56 +03:00
snark13 67d62e3e8b Уровни 12 и 13: тень, Джафар, падающие плиты + разбор слоёв
Уровень 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>
2026-08-13 18:21:52 +03:00
snark13 716f6f9273 NEXT_SESSION: итоги дня — разгрузка резидента и переворот приняты
Задача на завтра: smoke-прогон уровня 9 + регресс уровней 1-8 (слой фона
теперь весь спрашивает pop_upside).  Записаны раскладка кода по банкам после
разгрузки (куча 902 -> 5795 Б), устройство переворота с замерами, два
правила, которые линкер не проверяет (банк не маппит W3; прямые вызовы
только внутри одного банка), новые тайминги моста и грабли дня — включая
баг SDCC с потерянным `return 1` и разницу логических/экранных координат
при перевороте.

Открытый риск на проверку: имена kid10_v.atl…kid27_v.atl — 9 символов до
точки при DSS 8.3.
2026-08-12 23:49:41 +03:00
snark13 7f5c99ba4f Фикс чистки факелов при перевороте: чистим в СТАРОЙ системе координат
На скриншоте пользователя после U остался чёрный квадрат в стороне от
факела, а у двух факелов пропало пламя.  Причина — система координат:
pop_upside переключается в главном цикле ДО pop_flip_screen, а картинка на
экране к этому моменту нарисована ещё в прежней.  pop_torch_wipe считала
координаты уже зеркально, поэтому чёрный бар ложился в отражённое место
(там и остался квадрат), а само пламя не стиралось.

Чистка теперь идёт при временно возвращённом старом значении pop_upside —
то есть ровно по той картинке, что на экране.
2026-08-12 23:44:22 +03:00
snark13 c9aa6d9c16 Переворот: убрать перерисовку комнаты — стирать только запечённое пламя
Полная перерисовка (прошлый коммит) чинила лишние языки огня, но стоила
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.
2026-08-12 23:41:19 +03:00
snark13 948d8f08f2 Переворот: перерисовка вместо отражения, переключение на границе кадра, clip_char
Три правки по следам прогона пользователя на зелье инверсии.

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.
2026-08-12 23:33:01 +03:00
snark13 5b6664ffce Зеркальные кадры персонажей — готовыми файлами, а не разворотом в рантайме
Замеры в 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.
2026-08-12 23:17:22 +03:00
snark13 c4dd2c7a87 Фикс переворота: отсев тайлов по fore-окну — в ЛОГИЧЕСКИХ координатах
Симптом (нашёл пользователь, ур. 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.
2026-08-12 22:39:55 +03:00
snark13 755190674c L9-INVERT II.4: персонажи рисуются перевёрнутыми (зеркальные страницы атласов)
Спрайты персонажей хранятся 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 не режем вовсе.  Тени/брызг это не касается.
2026-08-12 22:25:18 +03:00
snark13 055d6d7c89 Фикс: чтение файлов нельзя выносить в банк — банковый код сам живёт в W3
Первый запуск после разгрузки встал в 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.
2026-08-12 22:17:18 +03:00
snark13 147cb185b1 Резидент: pop_kid/pop_guard/main расколоты по частоте вызова (куча 6669 Б)
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).
2026-08-12 22:03:34 +03:00
snark13 5d31ed086f Резидент: pop_redraw + холодная половина pop_level в банки (902 -> 3525 Б кучи)
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.
2026-08-12 21:53:08 +03:00
snark13 f541c0aad9 L9-INVERT: клип поля при перевороте (мусор в бортах)
Найдено пользователем: после телепорта в перевёрнутом виде ниже поля мусор.
Спрайты, торчащие в обычном виде ВЫШЕ поля (полоса кладки у потолка), после
отражения торчат НИЖЕ и лезут в борт с полосой HP.

blit_b_clip при перевороте режет по обеим границам поля всегда, а не по
pop_t_clip_top; быстрый путь pop_blit_b отдаёт клипующему всё, что выходит
за поле.

Проверено в MAME: телепорт по комнатам в перевёрнутом виде — борта чистые,
комнаты (факелы, чомперы, пол) зеркальны.  Переходы между комнатами в
перевёрнутом виде работают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:16:27 +03:00
snark13 0be0502388 L9-INVERT: зеркалятся ВСЕ заливки слоя фона (шов, сосед loose, оверлеи)
Продолжение фикса решётки: первый гр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>
2026-08-12 18:07:51 +03:00
snark13 c60415a322 L9-INVERT: чёрные плиты слоя фона тоже зеркалятся (баг решётки)
Найдено пользователем: поднимающаяся решётка анимировалась неправильно, а в
углу (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>
2026-08-12 17:59:18 +03:00
snark13 abc7ff88ed L9-INVERT: heal точечных перерисовок тоже зеркалится (баг чомперов)
Найдено пользователем на первом прогоне зеркального фона: чомперы
анимировались неправильно.  Причина — pop_heal_off: точечные перерисовки
(чомпер, пики, loose, ворота, полоса потолка) чистят фон по КОМНАТНОЙ
координате, и при перевороте heal стирал прямоугольник не там, где тайл
рисуется.  Фикс в единой точке — та же формула FLIP_TOP, что у блита.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:53:00 +03:00
snark13 7c1ded340c L9-INVERT: зеркальный слой фона + вход в комнату в банк 8
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>
2026-08-12 17:43:22 +03:00
snark13 5c015739fa size-baseline: pageflip вырос от новых проверок теста (не регрессия libbgi) 2026-08-12 17:36:19 +03:00
snark13 3f45430454 libbgi: vflip-блиты для row-major картинок (шаг I.2 плана L9-INVERT)
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>
2026-08-12 17:35:50 +03:00
snark13 70c1668e9f ENTER-ROOM-FAST + вынос порядка отрисовки в банк 8
Вход в комнату: одна отрисовка в скрытую страницу, показ флипом, вторая
страница — 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>
2026-08-12 17:22:05 +03:00
snark13 b1ef48bd16 L9-INVERT: состояние переворота, зелье типа 4 и pop_flip_screen
Шаг 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>
2026-08-12 16:48:08 +03:00
snark13 7fe4c25ad1 libbgi: переворот экрана акселератором (gfx_copy_page + _bgi_flip_rows_raw)
Первый шаг 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>
2026-08-12 16:38:10 +03:00
snark13 52b43b3d81 L9-INVERT: зафиксированы решения по кэшу зеркальных кадров и порядку работ
Кэш — ленивый постраничный, живёт до конца уровня; ENTER-ROOM-FAST делается
шагом 4 на готовом gfx_copy_rect.  Открытых вопросов в плане не осталось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:14:41 +03:00
snark13 e11877f475 План реализации L9-INVERT (docs/l9_invert_plan.md)
Разведка железа первым шагом (смена 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>
2026-08-12 16:12:33 +03:00
snark13 c9a4efe119 L9-INVERT: переворот экрана accel-копией как основной вариант + UI-SPRITES
Момент переключения зелья переворота делаем не перерисовкой комнаты, а двумя
построчными проходами акселератора (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>
2026-08-12 15:58:16 +03:00
snark13 6a392f5403 Доска: анализ уровней 9-11, задача L9-INVERT (зелье переворота)
Уровни 10 и 11 не приносят ни новых тайлов, ни спецсобытий (зелья только
heal / +max HP).  Уровень 9 — зелья типа 4 (переворот) в комнатах 7 и 10.
Записан разбор механики оригинала и разбор цены переворота для наших двух
раскладок спрайтов (row-major бесплатно, column-major требует копий).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:35:29 +03:00
snark13 361c9d9f78 Мышь уровня 8 + холодная половина главного цикла в банк 8
Спецсобытие уровня 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>
2026-08-12 15:21:20 +03:00
snark13 af04a8141e NEXT_SESSION: актуализация окружения и граблей сессии
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:14:55 +03:00
snark13 ebbc712aab NEXT_SESSION: зелья написаны, ждут живой проверки в MAME
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:14:04 +03:00
snark13 d7d18aef77 Зелье медленного падения (перо) + цвет пузырьков по типу зелья
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>
2026-08-12 12:13:29 +03:00
snark13 b5951c9b5c Зацеп за кромку нижнего ряда: do_fall пускает ряд до 3 + check_grab в midair
Уровень 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>
2026-08-12 11:58:17 +03:00
snark13 4fc4283232 NEXT_SESSION: состояние на конец сессии уровня 6
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:03:10 +03:00
snark13 9ecea17ba6 Уровень 6: переход на 7-й, портал, многокомнатное падение плиты
Всё проверено пользователем в 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>
2026-08-11 23:01:58 +03:00
snark13 e4ef489872 L6-SHADOW: тень роняет решётку + кромка ряда ниже
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>
2026-08-11 22:20:12 +03:00
snark13 740cc6d652 Арка у ворот: спецслучай «Lattice + door A» (seg008:622)
Уровень 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>
2026-08-11 21:46:38 +03:00
snark13 abe4a83d38 Уровень 5: тень не дерётся, кнопка в шве, узор паласа после зелья
Три бага одной отладочной сессии, все проверены в 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>
2026-08-11 21:24:15 +03:00
snark13 36a60f54ba Кнопка в ШВЕ срабатывала как чужая связь (нашёл пользователь на ур. 5)
Симптом: Кид встаёт на плиту, которая физически в СОСЕДНЕЙ комнате (стоя в
шве, 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>
2026-08-11 17:32:11 +03:00
snark13 8dc53e6b57 L5-SHADOW: тень уровня 5 крадёт зелье (спецсобытие комнаты 24)
Порт 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>
2026-08-11 17:22:10 +03:00
snark13 096517d8aa MIRROR-FG-STALE закрыт как НЕ БАГ (проверено пользователем)
Прогон читом ROOMNAV: телепорт в комнату 4 сразу после нажатия кнопки —
зеркала нет вовсе (тайл ставится в момент, когда дверь ДОРИСОВАЛА открытие,
43 тика анимации, телепорт успевает раньше); обычный вход в комнату —
зеркало на месте и непроходимо, кроме правильного прыжка, то есть коллизия
его видит.  Сценария «постановка при игроке в комнате» в реальном
прохождении нет: дверь выхода стоит не в комнате зеркала, а при входе
комната и снимок room_fg берутся из данных уровня разом.

Решение пользователя: телепорт по комнатам — отладочный режим, чинить нечего.
Запись целиком уехала в BUGS_CLOSED.md вместе с протоколом и с указанием, чем
лечить, если симптом всё же всплывёт на моде/уровне, где кнопка и зеркало в
одной комнате.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:22:04 +03:00
snark13 5348feb5f4 Доски приведены в соответствие с кодом; bug_list/bug_closed → BUGS_OPEN/BUGS_CLOSED
Доска отставала от кода на три задачи — планировать по ней было нельзя.
Сверка проведена грепом по исходникам, а не по записям:

- 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>
2026-08-11 16:10:10 +03:00
snark13 c0075c7754 NEXT_SESSION.md: op-блиты сделаны, вид тени отложен
Чтобы следующая сессия не начала задачу заново: библиотечная часть закрыта
(полный набор блочных AND/OR/XOR/NOT в libbgi, tests/accop 10/10), а вид
тени отложен по решению пользователя — со ссылкой на docs/shadow_render.md.
Исходная постановка оставлена ниже как справка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:41:42 +03:00
snark13 860468f3c5 libbgi: NOT_PUT тоже через акселератор + изыскания по виду Тени
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>
2026-08-11 15:41:06 +03:00
snark13 a823e7ee9c libbgi: блочные AND/OR/XOR акселератора (блит строками и колонками)
Акселератор умеет не только копировать блок, но и совмещать его с
приёмником: опкод `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>
2026-08-11 15:03:47 +03:00
snark13 d154cb452c NEXT_SESSION.md: точка входа для старта с чистого контекста
Одним файлом: состояние репозитория (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>
2026-08-10 23:02:48 +03:00
snark13 8beb66a48d FORE-DUP: передний слой рисуется дважды при перекрытии — заведено с разбором
Найдено вопросом пользователя.  У нас 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>
2026-08-10 22:22:48 +03:00
snark13 3913f1eb7d Отражение в зеркале: fore-проход поверх него (ноги/голова вылезали из арки)
Симптом (скриншоты пользователя, ур. 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>
2026-08-10 22:19:14 +03:00
snark13 8f0362f3d4 L4-MIRROR шаги 3 и 5 + левый клип колонок в libbgi
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>
2026-08-10 22:09:45 +03:00
snark13 844fa6d767 L4-MIRROR шаг 4: прыжок сквозь зеркало и рождение тени
Порт 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>
2026-08-10 21:46:30 +03:00
snark13 1b2111f2a0 L4-MIRROR шаги 1-2: зеркало в атласе + постановка тайла по открытию двери
Задача заведена активной на доске по решению пользователя (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>
2026-08-10 21:26:58 +03:00
snark13 67a4c71138 BUG-TORCH-CHOMP-2: застывший чомпер накрывался пламенем факела
Регрессия от 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>
2026-08-10 21:16:58 +03:00
snark13 e261a35acb Замер в MAME: A/B со сборкой до правок, деления в горячем пути = 0
Обе сборки прогнаны полным циклом (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>
2026-08-10 21:02:35 +03:00
snark13 fc0ede91e1 scr_x: байтовая таблица x/7 вместо словарной — 1 такт быстрее, -1152 Б
Гипотеза «двухбайтная индексация съест выигрыш от сложения» не
подтвердилась.  Собраны ОБА варианта, такты посчитаны по сгенерированному
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>
2026-08-10 20:43:27 +03:00
snark13 8175121d25 scr_x: таблица готовых значений на весь диапазон, включая отрицательные
Первая версия крыла только 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>
2026-08-10 20:35:01 +03:00
snark13 72797e1be8 Инвентаризация делений по .asm: убраны три, остальные разобраны
Обход всех сгенерированных .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>
2026-08-10 20:23:43 +03:00
snark13 8cac51d592 Убраны три последних /63 %4 в pop_room.c — вызов pop_y_to_row
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>
2026-08-10 20:10:52 +03:00
snark13 ec3cca5e1e Луч видимости стража: колонка из таблицы вместо деления (Кид у шва)
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>
2026-08-10 18:41:53 +03:00
snark13 08ac38ce3d Пункт 0: отсев кладки по окну fore-клипа; закрыт BUG-CHEAT-FIGHT-1
Три шага пункта 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>
2026-08-10 18:24:27 +03:00
snark13 4718bff767 Уровень 4: skip по маске тайлов + порт loose_land, ворота 0xFF, пламя в фон
ОПТИМИЗАЦИЯ.  Метка «фон трогали» была одним 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>
2026-08-10 17:52:04 +03:00
snark13 6a824dd7ab Закрыты BUG-GUARD-IX-1 и BUG-GATE-SEAM-ROW1 (уровень 4)
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>
2026-08-10 16:32:14 +03:00
snark13 93aa51db45 Профиль дворца: циан = fore-проход, 27% кадра на ноль пикселей
Разбивка логического кадра брейкпоинтами (уровень 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>
2026-08-10 16:31:45 +03:00
snark13 dbf9166836 Цвета 6 и 12 базовой палитры: игра правит их на старте, мы брали VGA-значения
Симптом (наблюдение пользователя): кирпичи дворцовой кладки правильного
цвета, а швы между ними — нет.

Корень шире, чем швы.  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>
2026-08-10 12:32:19 +03:00
snark13 f10771194b Шаг C: дворцовая кладка стен (wall_pattern, паласная ветка)
Во дворце тело стены — не кирпичи-спрайты, а шесть СПЛОШНЫХ ЗАЛИВОК плюс
пять моно-разделителей поверх (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>
2026-08-10 12:19:42 +03:00
snark13 0941ef1d90 Шаг D: паласные ветки отрисовки + потерянный верх ворот
Порт семи мест 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>
2026-08-10 12:04:49 +03:00
snark13 89400ff1d4 Тайлсет дворца: палитра применялась до kid.pal и затиралась ею
Симптом (эталон 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>
2026-08-10 11:34:53 +03:00
snark13 97929b55d3 Уровень 4: второй тайлсет (дворец) — ассеты и переключение (шаги A и B)
Порт 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>
2026-08-10 11:28:45 +03:00
snark13 c8fe0bd37a pop_blit_b: быстрый путь без клипа + backlog отложенной оптимизации
Клипованный путь вынесен в отдельную функцию 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>
2026-08-10 11:05:42 +03:00
snark13 a9f4521ffd fore-проход: ранний выход по коду тайла + контекст тайла один раз (эффект нулевой)
Сближение с 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>
2026-08-10 10:55:01 +03:00
snark13 b0524b9ad0 Оптимизация: логический кадр уложился в бюджет, цикл 4 растровых кадра -> 3
Главный цикл спейсится тремя 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>
2026-08-09 15:49:46 +03:00
snark13 d0030922ff Оптимизация логики, шаг 2: пробеги вместо ветвления на колонку
- 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>
2026-08-09 15:09:51 +03:00
snark13 8c4bc4f621 Оптимизация логики: окно коллизии как в оригинале + деление таблицей
Замерено брейкпоинтами в 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>
2026-08-09 14:38:08 +03:00
snark13 6960e1cc76 BUG-GATE-PASS-1 закрыт: смоук уровня 1 пройден, запись переехала в bug_closed.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 12:41:51 +03:00
snark13 9234c03020 BUG-GATE-PASS-1: история флагов коллизии переживает боковой переход
Оригинал (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>
2026-08-09 12:25:38 +03:00
snark13 c93a348b4a BUG-GATE-PASS-1: воспроизведён и разобран; SDLPoP собран с трассой
Сценарий: ур.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, которого нет).
2026-08-08 20:35:41 +03:00
snark13 463f35d440 ФИКС РЕГРЕССИИ шва: полный seq_39 у стены пропускал Кида сквозь ворота
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.  Останется таким, пока наш
    бамп не сойдётся с оригиналом покадрово.
2026-08-08 20:09:58 +03:00
snark13 1461ed5633 ФИКС РЕГРЕССИИ: start_chompers ломал play_seq через окно W0
Симптом (уровень 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 не открыто.
2026-08-08 20:00:01 +03:00
snark13 073a6e0264 safe_step по оригиналу + BUG-CHOMP-JUMP-1 в низкоприоритетные, T-2 закрыт
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).
2026-08-08 19:46:34 +03:00
snark13 db4106a55e L3-CHOMP: перед чомпером Кид разбегается сразу, без осторожного шага
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.
2026-08-08 19:24:21 +03:00
snark13 4d4323fc54 L3-CHOMP: передние зубья чомпера — блит pop_fore_b и ветка в draw_tile
Две ошибки в одном месте.  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-проход персонажа.
2026-08-08 19:11:24 +03:00
snark13 dc0bd47368 L3-CHOMP: чомперы — анимация, отрисовка и смерть в челюстях
Порт 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 рисуются; анимация и смерть — на ручной приёмке.
2026-08-08 19:05:16 +03:00
snark13 4310f94795 DRAW-COST шаг 2 на доску: цена ОДНОЙ перерисовки персонажа (бегущий Кид — циан ~100%) 2026-08-08 18:43:13 +03:00
snark13 a25ce58869 DRAW-COST шаг 1: пропуск неизменившегося персонажа — 210% -> 116% кадра
Персонаж, у которого с прошлой отрисовки ЭТОЙ страницы дабл-буфера не
изменился ни один вход отрисовки, а фон в его прямоугольнике не трогали,
уже нарисован правильно: 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.
2026-08-08 18:39:00 +03:00
snark13 fd54bc78c0 DRAW-COST: контрольный замер комнаты 2 (140% против 210%) — след найден
Комнаты 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>
2026-08-08 16:30:48 +03:00
snark13 14190f0210 MEM-BANK2 шаг 3: холодная половина слоя фона в банк 7 (90.4% -> 35.8%)
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>
2026-08-08 16:28:02 +03:00
snark13 bbf91d10ee DRAW-CHAR: отрисовка одна на всех Char; разгрузка банка 2 (90.4% -> 72.9%)
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>
2026-08-08 13:25:53 +03:00
snark13 6673279cef PoP: скелет уровня 3, цвета стражей, окклюзия соперника; кэш соседних комнат
Скелет (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>
2026-08-07 22:11:03 +03:00
Александр Петров 18d177407b TASKS: зацеп в прыжке — в список на финальную приёмку уровней 1-3
Точки вызова стоят и в обеих ветках check_bumped, поэтому регрессия
проявится не в зацепе, а в обычном ударе о стену с зажатым Shift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:59:59 +03:00
Александр Петров 0cd6b2d737 L3-CHKP, зацеп в прыжке опцией, pop_tune.h; сборка 10 мин -> 1:48
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>
2026-08-06 00:57:51 +03:00
Александр Петров c6cadd0140 Закрыты BUG-GRAB-1 и BUG-GATEMOD-1; BUG-SPIKE-1 — в низкоприоритетные
Оба фикса подтверждены игрой, записи с разбором корней переехали в
bug_closed.md.  BUG-GATE-PASS-1 остался ждать сценария, но его оговорка
про BUG-GATEMOD-1 обновлена (тот закрыт).  BUG-SPIKE-1 понижен до низкого
приоритета: маловоспроизводим, смертельность пик подтверждена замером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:50:26 +03:00
Александр Петров 3078886306 BUG-GUARD-DEAF-1 закрыт: страж оборачивается на вернувшегося Кида
Проверено в игре: комната 11 уровня 2, страж выталкивает Кида в 22, Кид
возвращается бегом — страж оборачивается и достаёт меч.  Разбор корня
(is_guard_notice не взводился ни в одном из пяти мест оригинала) переехал
в bug_closed.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:41:20 +03:00
Александр Петров 4424ea20a8 BUG-SPIKE-1: попиксельная подгонка X тоже не воспроизводит — состояние не позиционное
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:55:38 +03:00
Александр Петров eecb00f911 BUG-SPIKE-1: пользователь не воспроизвёл; смертельность подтверждена замером
Запись переведена в «ждёт сценария»: чистый пробег по убранным пикам
убивает (трасса по кадрам в записи), а наблюдавшееся «нет урона» — это
уже выдвинутые пики, безвредные для бегущего и в оригинале.  Открытым
остаётся только визуальное расхождение со скриншотом SDLPoP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:51:15 +03:00
Александр Петров 2b17408609 BUG-SPIKE-1: замер опроверг гипотезу о раннем триггере
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>
2026-08-05 21:41:42 +03:00
Александр Петров dc8b2b7115 Приёмка ур. 2–3: сквозь стену, клин в шве, бар под плитой, глухой страж
Четыре разбора с прогонов пользователя; три бага закрыты, четвёртый
(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>
2026-08-05 21:29:37 +03:00
Александр Петров 4737ec323c MEM-BANK5: pop_ctrl.c → банк 5; чит [/] подгонки Кида по X
Три правки едут вместе намеренно: 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>
2026-08-05 13:45:15 +03:00
Александр Петров cfc3602375 L2: падение с мечом, разбег-прыжок через 3 тайла, стартовое состояние ворот
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>
2026-08-05 12:34:26 +03:00
Александр Петров 3d8b81c9f6 build: examples вне регрессной сборки; эталон размеров пересобран
`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>
2026-08-05 12:33:34 +03:00
Александр Петров 030af74631 BUG-KBD-5: зажатый Shift снимался автоповтором стрелки
Симптом: 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>
2026-08-05 12:33:15 +03:00
Александр Петров 3fe083331f tests-host: покадровый харнесс сценариев Кида (физика + зацеп)
Проверять физику Кида глазами в 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>
2026-08-05 12:32:55 +03:00
Александр Петров 29d60665e0 docs: уточнение — при падении с мечом рендер не «кривой», а корректный
Отличие от остальных записей раздела «НЕ БАГИ»: там картинка кривая и
совпадает с оригиналом лишь потому, что оригинал сам так рисует (порядок
midtable).  Здесь же спрайт перекрывает кромку ровно настолько, насколько
персонаж за неё зашёл — геометрия и рендер согласованы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:20:27 +03:00
Александр Петров 969f3f1b9a docs: падение при отходе с мечом — НЕ БАГ (сверено с SDLPoP)
Пользователь проверил в 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>
2026-08-04 13:19:24 +03:00
Александр Петров 3164065243 Клинок расширяет футпринт перерисовки на колонку (redraw_at_char)
Порт 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>
2026-08-04 13:15:34 +03:00
Александр Петров e67117219f BUG-CTRL-FRAME-1: геометрия Кида считалась по кадру стража
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>
2026-08-04 12:55:43 +03:00
Александр Петров 5353bdaaec docs: приёмка уровня 2 — первым приоритетом, карта содержимого уровня
Порядок работ по решению пользователя: 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>
2026-08-04 11:46:12 +03:00
Александр Петров 6aefee0cc7 docs: TASKS — цель «уровни 1-3 (подземелье)», разбор что нужно уровню 3
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>
2026-08-04 11:38:43 +03:00
Александр Петров e36828ae6e L2: переход между уровнями и игра уровня 2
Порт 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>
2026-08-04 11:35:56 +03:00
Александр Петров 7ef007757b docs: BUG-SEAM-DRAW-1 закрыт (не воспроизводится), заведён BUG-GATE-PASS-1
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>
2026-08-03 23:20:01 +03:00
Александр Петров f63751edce docs: BUG-LOOSE-2 закрыт — проверено вручную
Падающий кусок теперь привязан к своей комнате и долетает после ухода Кида;
подтверждено живым прогоном.  Автоматикой гонка не воспроизводилась — мост
MAME шлёт нажатия рывками, и «уйти раньше, чем долетит плита» через него не
набиралось; это отмечено в записи вместе с указанием, что кейс стоит первым
в плане host-тестов.

Вторая волна прогона уровня 1 закрыта целиком: BUG-KBD-4, BUG-RESPAWN-2,
BUG-DRAWORDER-1, BUG-LOOSE-2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:04:13 +03:00
Александр Петров a89b8b30c6 docs: BUG-DRAWORDER-1 закрыт; остаток «ноги поверх головы» — в НЕ БАГИ
Сверено в 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>
2026-08-03 23:02:02 +03:00
Александр Петров 61d4255091 testkit: модульные тесты под ucsim_z80 + планы покрытия
Обвязка для быстрых тестов 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>
2026-08-03 22:36:16 +03:00
Александр Петров b5d2a81ee3 L1: фиксы прогона уровня 1 — уровень мутабелен, стражи, loose-плиты, порядок
Одиннадцать наблюдений первого прогона свелись к шести корням, четыре
наблюдения второго — ещё к четырём.  Разбор каждого — 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>
2026-08-03 22:35:41 +03:00
Александр Петров 1f16e8fa70 KBD: состояние Shift по «fake shift» — ни залипания, ни отвала
Клавиатура 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>
2026-08-03 22:16:27 +03:00
Александр Петров ba37bd1133 BUG-DOOR-CLIP: обрезка силуэта правым косяком двери уровня
Симптом (нашёл пользователь сразу после 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>
2026-08-01 23:43:01 +03:00
Александр Петров e86f254b87 L1-START + L1-EXIT: старт по данным уровня и выход через дверь уровня
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>
2026-08-01 22:47:47 +03:00
Александр Петров 1146c57544 CLIP-1: heal Кида и стража — линейным ядром, когда клип не нужен
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>
2026-08-01 21:53:30 +03:00
Александр Петров d552cbaca9 docs: bug_list — только открытые баги; закрытые → bug_closed.md
Три 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>
2026-08-01 20:57:33 +03:00
Александр Петров 6b4a3b6b41 docs: итог KBD-1 — что лечит плотный опрос и что осталось
Ручная проверка пользователем: стало значительно лучше, но редкие пропуски
стрелок при зажатом 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>
2026-08-01 16:15:21 +03:00
Александр Петров 4b498d171b libbgi: idle-хук в ожидании кадра; им лечится потеря нажатий с Shift
Причина потерь (замеры — 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>
2026-08-01 16:05:24 +03:00
Александр Петров b56f2b4582 libc/kbd: kbd_raw_poll + замер потери нажатий при зажатом Shift (KBD-1)
Симптом: при удерживаемом 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>
2026-08-01 15:33:16 +03:00
Александр Петров 774b1cc7c4 docs(PoP): документация к актуальному статусу + план следующих уровней
Документы отстали от кода: 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>
2026-08-01 15:32:48 +03:00
snark13 2f3e854854 PoP roomtest: отрисовка стража — в собственный банк (банк 2 упёрся в потолок)
Банк 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>
2026-07-30 22:22:27 +03:00
snark13 75a51fb1db PoP roomtest: выпивание зелья (уровень 1 — склянка здоровья)
Каркас предметов уже был (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>
2026-07-30 22:09:36 +03:00
snark13 a32b66700f toolchain: make_hdd.sh — закрывать mtools ОБА канала запроса, не один
Прошлая правка убрала только управляющий терминал (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>
2026-07-30 22:01:39 +03:00
snark13 59e51f7e83 toolchain: make_hdd.sh не виснет при запуске из терминала
Симптом: сборка образа молча вставала навсегда сразу после эхо-строки
команды.  Появилось не «само» — ровно тогда, когда образ разложили по
подкаталогам (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>
2026-07-30 21:57:39 +03:00
snark13 b22cee3456 PoP roomtest: убитый страж остаётся мёртвым; меч только по подбору или читу S
Смерть стража теперь персистентна между входами в комнату — по механизму
оригинала, а не отдельной таблицей «убит/не убит».  В 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>
2026-07-30 21:33:21 +03:00
snark13 2e90eaf7d7 PoP roomtest: окно Char больше не затирает правки pop_map (спуск с уступа)
Регрессия от окна 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>
2026-07-30 18:51:49 +03:00
snark13 3983fa4513 PoP roomtest: HP-учёт, индикаторы HP, чит бессмертия; фикс кэша кадра
Боёвка (порт 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>
2026-07-30 18:43:39 +03:00
snark13 d014a3f577 PoP roomtest: ИИ стража — подход к Киду и боевые ветки диспетчера
Пункт 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>
2026-07-30 17:24:53 +03:00
snark13 4f7d9c0596 PoP docs: запасные PRNG (LFSR/LCG, xorshift(7,9,8)) + оценка потолка выигрыша
Тексты обеих 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>
2026-07-30 17:10:35 +03:00
snark13 37fc572cc3 PoP roomtest: LCG оригинала на ассемблере — точность без потери скорости
Возврат к БИТ-В-БИТ генератору оригинала по умолчанию (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>
2026-07-30 17:05:13 +03:00
snark13 99b430f2ed PoP/libbgi: убрана 32-бит арифметика, noclip для column-major, быстрый PRNG
Ревью на 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>
2026-07-30 16:48:37 +03:00
snark13 d438a1d3da PoP roomtest: клинок в атласе целиком + раздельный heal накладных спрайтов
Меч в оригинале ОДИН на всех: и Кид, и страж рисуют клинок из 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>
2026-07-30 11:40:08 +03:00
snark13 79ea473910 PoP roomtest: страж замечает Кида и достаёт меч (ИИ, шаг 1)
Первый самостоятельный кусок ИИ стража (план, пункт 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>
2026-07-30 11:09:12 +03:00
snark13 cf06896dbd PoP roomtest: окно Char вокруг control() + общая шина ввода — база под ИИ стража
Инфраструктура пункта 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>
2026-07-30 10:32:15 +03:00
snark13 8bbc6b4d07 PoP roomtest: интерпретатор последовательностей стал общим (Char) — стражи
Пункт 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>
2026-07-30 10:11:10 +03:00
snark13 565ba98852 PoP docs: бэклог идей — начат с «отключать мышь на время игры»
Мышь игре не нужна (управление — raw-клавиатура, которую мы и так
забираем у DSS), а её прерывания воруют такты из бюджета, занятого на
86 %.  Эффект измерен побочно: при движении мыши на хосте кадры выбивались
до 1.5 кадрового периода, при неподвижной — 225 кадров без превышений.

Записано с тем, что проверить (есть ли в RST 30h выключение, сколько
стоит одно прерывание, восстановление состояния на выходе) и почему не
сейчас: выигрыш только когда игрок двигает мышью, риск оставить систему
без мыши после выхода — заметный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:58:40 +03:00
snark13 ac9871c58c PoP roomtest: факел анимируется каждый логический кадр (TORCH_ANIM_DIV)
Делитель (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>
2026-07-30 09:57:26 +03:00
snark13 1e6c377edc PoP roomtest: pop_bg и pop_map в банки; --dataseg BANKn стал опцией
Резидента --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>
2026-07-30 09:31:58 +03:00
snark13 0280b05933 libc/kbd: held-карта клавиш в биты (512 -> 64 Б); эталон размеров принят
Разгрузка 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>
2026-07-30 00:50:37 +03:00
snark13 f2093e0d89 PoP roomtest: fore-слой — окно клипа, кэш кладки; кадр 2.5 -> 0.78 периода
Жалоба: стойка неподвижных Кида и стража занимала больше полутора
кадровых периодов.  Гипотеза «виноват __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>
2026-07-30 00:19:32 +03:00
snark13 af5f0a4638 PoP roomtest: отрисовка стража в резидент W3 + fore-окклюзия + off-by-one спрайта
Разгрузка 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>
2026-07-29 23:14:21 +03:00
snark13 3dbad6120c PoP roomtest: страж — спрайты, таблица кадров и появление в комнате
Шаги 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>
2026-07-29 21:57:38 +03:00
snark13 1dc89b0f26 PoP roomtest: ресурсы по каталогам образа (BG/KID/LEVELS), цель make hdd
Все .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>
2026-07-29 21:42:16 +03:00
snark13 e1ee447b7f PoP roomtest: каркас стражей в БАНКЕ + режим читов (K — убить стража)
Раскладка (по подтверждённой пробником модели, 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>
2026-07-29 21:31:11 +03:00
snark13 18f5115e0d docs(PoP): план v2 — результат пробника банка (модель подтверждена)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:19:00 +03:00
snark13 b3bf2cca3d tests/w3bankgfx: пробник модели «huge + резидент W3 + банк W3 + графика»
Проверяет то, на чём стоит план раскладки 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>
2026-07-29 21:18:27 +03:00
snark13 3b2dd8bfcc docs(PoP): план v2 — статус выполнения шагов 1..4 и найденная ловушка SDCC
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:08:47 +03:00
snark13 ecf5ecfc14 PoP roomtest (план v2, шаг 2): общая геометрия в pop_geom
Сведены дубли, разъехавшиеся по модулям:
- 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>
2026-07-29 21:05:24 +03:00
snark13 4cffbc9aa4 PoP roomtest (план v2, фаза 1b): pop_map переведён на пометки перерисовки
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>
2026-07-29 20:44:56 +03:00
snark13 24bb724c22 PoP roomtest (план v2, фаза 1a): пометки перерисовки вместо прямых блитов
Порт архитектуры оригинала: логика анимации тайлов НИЧЕГО не рисует, она
ставит флаг (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>
2026-07-29 20:37:03 +03:00
snark13 eef6eebd8c PoP roomtest: фикс чтения таблицы кадров — баг кодогенерации SDCC
Кадры 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>
2026-07-29 20:24:03 +03:00
snark13 3de8c7500c PoP roomtest (план v2, шаг 4): в W3-резиденте остаётся только pop_bg
--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>
2026-07-29 19:30:57 +03:00
snark13 8e33cd07bc PoP roomtest (план v2, шаг 1): таблицы анимации Kid -> EMM-страница
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>
2026-07-29 19:15:16 +03:00
snark13 a5773ab654 docs(PoP): план v2 — размер кода и раскладка по окнам/банкам/страницам
Новый документ 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>
2026-07-29 18:55:36 +03:00
snark13 68b8fe5851 .gitignore: не версионировать docs/extra и docs/sources
docs/extra — ~570 МБ архивов чужих исходников (525 МБ из них — четыре
почти одинаковых zip'а bad_apple); docs/sources — клоны чужих
репозиториев со своими .git внутри, которые при обычном add стали бы
битыми gitlink-ссылками (без .gitmodules клон их не подтянет).

Материалы остаются на диске, но в историю не попадают: раздувание репо
необратимо без перезаписи истории.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:28:19 +03:00
snark13 64ce6339eb docs: справочники по железу Sprinter + правка gfx_scroll_h
- 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
610 changed files with 76658 additions and 4746 deletions
+11
View File
@@ -0,0 +1,11 @@
[mcp_servers.mame-z80]
command = "/Users/alex/.local/bin/uv"
args = [
"run",
"--python",
"3.12",
"--no-project",
"--with",
"mcp<2",
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
]
+18
View File
@@ -8,6 +8,7 @@ build/
# sprinter-cc per-example intermediate directory
.sprinter-cc-*/
.resource-stamps/
# Per-program final/intermediate outputs landing alongside the source
# (real apps under examples/ and libc feature tests under tests/).
@@ -113,3 +114,20 @@ mame/
# Claude Code local settings (per-machine, not for the repo)
.claude/
# Тяжёлые справочные материалы, НЕ версионируются: docs/extra — архивы
# исходников (~570 МБ, четыре почти одинаковых bad_apple), docs/sources —
# клоны чужих репозиториев (Sprinter-BIOS, Estex-DSS, SaymanNsk) со своими
# .git внутри (в коммите стали бы битыми gitlink-ссылками).
docs/extra/
docs/sources/
# Записи музыки DOS-версии PoP (43 МБ в четырёх форматах) — ИСТОЧНИК для
# toolchain/pop_pack_music.py, а не ресурс сборки: на диск игры уходят уже
# упакованные poc/res/music/*.bin, и они в репозитории есть. Если понадобится
# перегенерировать музыку — положить сюда PoP1_DOS_music (flac).
applications/PoP/PoP1_DOS_music/
# R1 — рабочая копия roomtest для экспериментов пользователя, в репозиторий
# не идёт (сама roomtest и есть версируемая ветка разработки).
applications/PoP/R1/
+83
View File
@@ -0,0 +1,83 @@
# Sprinter C-Compiler — правила проекта
Target-слой SDCC 4.5 (z80) для компьютера Sprinter Sp2000: crt0,
линковка, libc, mkexe. Общение и комментарии — на русском.
## Сборка и проверка
```
make # tools + lib + libbgi + все тесты (45) + examples
make -C libc # только libc → lib/sprinter.lib (fast) + sprinter_safe.lib
make -C libbgi # только BGI → lib/bgi256.lib (fast) + bgi256_safe.lib
make floppy # упаковать все .exe в mame/v306/IMG/mc.img
make size-check # размерный регресс: _CODE vs docs/size_baseline.tsv
make size-baseline # принять текущие размеры эталоном
```
Обе библиотеки собираются в двух вариантах: fast (дефолт; `-D*_NOCHECK`
параметр-валидации вырезаны) и safe (линкуется по `sprinter-cc --safe`).
Критичные гарды (напр. _fd_guard — 9-й OPEN вешает DSS) — в ОБОИХ.
Графика (BGI) — отдельная библиотека libbgi/ (см. ниже). Программа,
использующая graphics.h, собирается с `--gfx 256` (или `--gfx 16` в
Фазе 2): sprinter-cc подлинкует lib/bgi256.lib и добавит -I libbgi/include.
Одиночный тест: `cd tests/<имя> && make run` (пакует exe + EXTRA_DATA на
дискету и запускает MAME автоматически через `toolchain/mame_interactive.py`,
снимает скриншоты, выводит пути). Для сложных сценариев (диалог, несколько
шагов ввода) — прямой вызов:
`python3 toolchain/mame_interactive.py tests/<имя>/<имя>.exe --snap T1,T2 --timeout T`.
Скриншоты лежат в `mame/v306/snap_auto/sprinter/` (читаются инструментом Read).
Подробности: `docs/mame-autotest.md`.
После правок libc/libbgi: пересборка от чистого листа (`make -C libc clean`
/ `make -C libbgi clean`) не обязательна — stale .rel чистятся
автоматически; `make size-check` обязателен (рост _CODE без причины —
регрессия).
## Правила libc
- **1 публичная функция = 1 .c-модуль** (линкер тянет .rel целиком —
гранулярность файлов = гранулярность DCE). Никакой группировки
«используются вместе». Internal-хелперы — тоже по одному на модуль
(`_`-префикс); общие статики — в отдельные data-модули
(`_xxx_state.c`); internal-заголовки (`_file.h`, `_gfx.h`, …) —
рядом с исходниками, НЕ в libc/include.
- Имя файла = имя функции. libc/Makefile собирает wildcard'ом —
ничего регистрировать не надо.
- Комментарии — на русском; шапка модуля объясняет что/зачем + ABI.
- File-scope переменные НЕ инициализировать `= 0` (crt0 зануляет
_DATA; см. memory/sdcc_static_storage_gotcha).
- asm-связки между модулями: `call/jp _global` — ок; `jr/djnz` через
границу и fall-through — НЕЛЬЗЯ (docs/libc-split-asm-cases.md).
- Заголовки: сначала пробовать include_next-паттерн; полная замена
SDCC-заголовка обязана дублировать его контракт
(docs/libc-headers.md).
- Справочник API — docs/libc-reference.md (обновлять при добавлении
функций).
## ABI и платформа (кратко; детали в memory/)
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
на стеке (callee-pops в __naked); **возврат int/ptr в DE**, uint8 в A.
IX callee-saved (в __naked с IX — push/pop обязательны).
- ESTEX (rst #0x10): CF=1 — ошибка, код в A → `call __errno_set`;
все регистры клобберятся (IX сохранять); стек обязан быть в W2.
- BIOS (rst #0x08): строки/буферы в #4000-#BFFF.
- Квирки: ESTEX WRITE возвращает DE=0 на успехе (судить по CF/A);
лимит 8 файловых манипуляторов, 9-й OPEN ВЕШАЕТ DSS (_fd_guard);
ENV $46: A=0 = NOT FOUND.
- Перед обвинением компилятора/железа — подтвердить артефактом
(сгенерированный .asm в libc/build/ или libbgi/build/, дамп, репро) — см.
memory/defer_unexplained_quirks.
## Структура
- `libc/<area>/*.c` — модули libc (ядро, БЕЗ графики); `libc/include/` — публичные заголовки libc
- `libbgi/` — графика BGI (отдельная библиотека): `common/` — mode-agnostic (один исходник, .rel в обеих driver-библиотеках), `bgi256/` + `bgi16/` — mode-specific leaf'ы (реальные реализации, без обёрток); `include/` — graphics.h + gfx.h; `_bgi.h` — внутренний заголовок. Собирает `lib/bgi256.lib``bgi16.lib` в Фазе 2). Выбор режима линковкой: `--gfx 256` / `--gfx 16`.
- `runtime/` — crt0-семейство, heap, bank (bank.s собирается per-build)
- `bin/sprinter-cc` — обёртка компилятора; `toolchain/mkexe` — упаковщик
- `tests/` — по одному API/фиче; `examples/` — реальные приложения
- `docs/` — дизайн-доки; `docs/TODO.md` — roadmap
- `third_party/solid-c/` — нативный Sprinter C (референс, CP866;
их ABI несовместим — только как образец)
+16 -4
View File
@@ -1,10 +1,14 @@
# Sprinter C Compiler — top-level Makefile
#
# make build host tools, libc archive, all tests, all apps
# make build host tools, libc archive, all tests
# make tools build only host tools (mkexe)
# make lib build lib/sprinter.lib (libc) + lib/bgi256.lib (libbgi)
# make tests build all libc feature tests under tests/
# make examples build all real applications under examples/
# make examples build all real applications under examples/ (НЕ входит
# в `make all`: это регрессная сборка, а examples/ —
# крупные приложения, которые её только замедляют
# (mdview компилируется минутами) и ничего нового про
# libc не показывают. Собирать явно перед `make floppy`.)
# make floppy package every .exe + test fixtures into mame/v306/IMG/mc.img
# make check run mkexe unit tests
# make clean remove all build artefacts
@@ -39,9 +43,9 @@ DATA_FILES := \
examples/mdview/SAMPLE.MD
.PHONY: all tools lib tests examples check clean sdcc floppy \
size-check size-baseline $(TESTS) $(APPS)
size-check size-baseline host-tests $(TESTS) $(APPS)
all: tools lib tests examples
all: tools lib tests
tools:
$(MAKE) -C toolchain/mkexe
@@ -73,6 +77,14 @@ floppy: tests examples tests/seek/big.txt
@echo "Floppy ready: $(FLOPPY_IMG)"
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
# ucsim идёт в комплекте нашего SDCC.
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
host-tests:
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
size-check:
python3 toolchain/size_check.py
+2 -1
View File
@@ -144,7 +144,8 @@ sprinter-cc -o foo.exe foo.c [more.c ...] [options]
--memory-manual SPEC explicit placement (CODE=W1|W2,DATA=W1|W2|SAME,BANKED=W1|W3)
--stack-size N bytes reserved for the stack (default ~1278)
--crt0=TYPE default | minimal | banked | small
--bank N=FILE.c compile FILE.c into bank N (repeatable, max 15)
--bank N=FILE.c compile FILE.c into bank N (repeatable, consecutive 1..15;
crt0 bank count is generated automatically)
--debug enable runtime diagnostics (defines DEBUG_RT)
-I PATH extra include path
-L 0xADDR / -E / -S override load / entry / stack addresses
+4 -1
View File
@@ -44,6 +44,9 @@ RUN_MAME := $(MAME_DIR)/run_mame.sh
# Optional knobs — see top of file.
MEMORY ?= tiny
SOURCES := $(EXAMPLE).c $(EXTRA_SRCS)
# Аргументы упаковщика HDD. Обычно это exe и EXTRA_DATA; приложение со
# своей раскладкой каталогов может переопределить переменную до include.
HDD_PACK_ARGS ?= $(EXAMPLE).exe $(EXTRA_DATA)
CC_FLAGS := --memory $(MEMORY)
ifneq ($(STACK_SIZE),)
@@ -92,7 +95,7 @@ run: floppy
# используется MCP-мостом к MAME (run_bridge.sh). После пересборки образа
# MAME ОБЯЗАН полный рестарт (chdman -f = новый inode; см. memory).
hdd: $(EXAMPLE).exe
$(MAKE_HDD) $(HDD_IMG) $(EXAMPLE).exe $(EXTRA_DATA)
$(MAKE_HDD) $(HDD_IMG) $(HDD_PACK_ARGS)
@echo
@echo "HDD (D:) ready: $(HDD_IMG) (with $(EXAMPLE).exe$(if $(EXTRA_DATA), + $(EXTRA_DATA)))"
@echo "ВНИМАНИЕ: перезапусти MAME (run_bridge.sh) — образ пересобран."
+13 -6
View File
@@ -23,7 +23,9 @@ sprinter-cc / libc / libbgi. Действуют правила корневог
исходнике, а не додумывать.
3. Расхождение нашей реализации с SDLPoP — по умолчанию **баг у нас**, пока
не доказано обратное (наша платформа/ABI требует отличия — тогда явно
зафиксировать почему в комментарии).
зафиксировать почему в комментарии **и записью в `docs/impl_diff.md`**:
что делает оригинал, что делаем мы, чем платим, что проверять при
регрессе).
Карта сегментов: `seg005` control-диспетчер, `seg006` play_kid/коллизия/
seqtbl, `seg007` mob/loose/падающие объекты, `seg008` отрисовка тайлов/
@@ -59,11 +61,16 @@ Princed для DAT v1.0 (контейнер/индекс/чек-сумма, ко
## Структура папки
- `docs/` — планы и форматы: `PORT_PLAN.md` (общий план фаз),
`KID_PLAN.md`, `double_buffer_plan.md`, `loose_floors_plan.md`, форматы
ресурсов. Начинать чтение отсюда.
- `roomtest/`**активная разработка**: комната 1 + Kid (анимация, ввод,
коллизия, падение, зацеп, fore-окклюзия, loose-полы). Свой `CLAUDE.md`.
- `docs/` — планы и форматы; **индекс с отметками актуальности —
`docs/README.md`**, начинать чтение оттуда. Ключевое:
`levels_plan.md` (следующий этап), `layout_plan_v2.md` (раскладка кода по
окнам/банкам + скорость отрисовки), `PORT_PLAN.md` (карта фаз со
статусами).
- `roomtest/`**активная разработка**: уровень 1 целиком (Kid, стражи,
ловушки, ворота, loose-полы). Свой `CLAUDE.md`; текущие задачи —
`roomtest/TASKS_OPEN.md` (закрытые с протоколами —
`roomtest/TASKS_CLOSED.md`), открытые баги — `roomtest/BUGS_OPEN.md`,
закрытые с разбором корней — `roomtest/BUGS_CLOSED.md`.
- `poc/` — ранний proof-of-concept (снег/атлас/kbd_raw); ассеты в `poc/res/`.
- `bgtest/`, `coltest/` — отдельные проверки фона/коллизии.
- `toolchain/` — python-упаковщики ассетов + эталонные PNG (`1.1-2.png`).
+11 -4
View File
@@ -4,15 +4,22 @@
target-слоя SDCC (sprinter-cc / libc / libbgi). Цель — 320×256×256 (режим
0x81), VGA-256 ассеты оригинала переносятся почти впрямую.
Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md).
Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md).
**Состояние (2026-08-01): играется весь уровень 1** — комнаты и переходы,
Kid со всем набором действий, ловушки, ворота, дверь уровня, меч и бой,
стражи с ИИ, HP и зелья. Нет: перехода на следующий уровень, звука,
таймера/HUD, сохранений.
- Что в работе прямо сейчас — [`roomtest/TASKS_OPEN.md`](roomtest/TASKS_OPEN.md).
- Следующий этап (уровни 2+) — [`docs/levels_plan.md`](docs/levels_plan.md).
- Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md).
- Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md).
## Что где
| Папка | Назначение |
|-------|-----------|
| `roomtest/` | **Активная разработка.** Комната 1 уровня 1 живой композицией тайлов + Kid: анимация (seqtbl), управление с клавиатуры, коллизия, падение, зацеп/подтягивание, fore-окклюзия, проваливающиеся полы. Свой README/CLAUDE. |
| `docs/` | Планы (`PORT_PLAN`, `KID_PLAN`, `double_buffer_plan`, `loose_floors_plan`) и разбор форматов ресурсов Apple II / DOS. |
| `roomtest/` | **Активная разработка.** Уровень 1 целиком: фон композицией тайлов, Kid (seqtbl-анимация, ввод, коллизия, падение, зацеп, окклюзия), ловушки, ворота, стражи, бой. Свой README/CLAUDE/TASKS. |
| `docs/` | Планы и разбор форматов ресурсов Apple II / DOS — см. индекс в [`docs/README.md`](docs/README.md). |
| `toolchain/` | Python-упаковщики ассетов под Sprinter (рендер комнат, атласы тайлов/Kid, извлечение данных анимации) + эталонные скриншоты. |
| `poc/` | Ранний proof-of-concept (снег, атлас, raw-клавиатура). Ассеты в `poc/res/`. |
| `bgtest/`, `coltest/` | Точечные проверки фона и коллизии. |
+16 -1
View File
@@ -1,6 +1,21 @@
# Prince of Persia — Kid (персонаж): анализ и план
Статус: план (2026-07-16). Опирается на разбор `SDLPoP/src/seg006.c`
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Kid играется целиком: интерпретатор
> `seqtbl` + `frame_table` (`roomtest/pop_kid.c`), диспетчер `control()`
> (`pop_ctrl.c`), коллизия/физика/зацеп (`pop_map.c`), бой и HP. Модель
> персонажа стала общей: `Char`-окно (`pop_state.c`) обслуживает и Кида, и
> стражей. Таблицы кадров и `seqtbl` уехали из `_CODE` в EMM-страницу
> (`kid_data.bin`, см. `layout_plan_v2.md` шаг 1).
>
> Отступление от §2.1 плана: выбран ПАДДИНГ кадров (общий канвас), а не
> per-frame offset — компромисс зафиксирован в `PORT_PLAN.md §6.1`.
>
> **Документ оставлен как СПРАВОЧНИК по модели персонажа** (`char_type`,
> категории `actions_*`, устройство `play_seq`, объём спрайтов) — он нужен
> при портировании остальных акторов (скелет, тень, визирь). Текущие
> задачи — `../roomtest/TASKS_OPEN.md`.
Составлен 2026-07-16. Опирается на разбор `SDLPoP/src/seg006.c`
(ядро физики/управления Kid), `seqtbl.c` (таблицы последовательностей),
`types.h` (char_type, seq_*, SEQ_*, actions_*), `SDLPoP/data/KID` (спрайты).
Фон уже готов и проверен на MAME (`applications/PoP/roomtest`, см.
+98 -52
View File
@@ -1,8 +1,29 @@
# Prince of Persia на ZX Sprinter — план порта
Статус: план (2026-07-15). §2 (A: kbd_mod_state / B: kbd_raw) —
РЕАЛИЗОВАНО и частично проверено в MAME (tests/kbdraw, 2026-07-15,
подробности в §2.2); PoC (§5) и остальные фазы — не начаты. Опирается на
## СТАТУС (обновлено 2026-08-01)
Документ составлен 2026-07-15 как план «с нуля» и с тех пор во многом
исполнен. Читать его надо так:
| Раздел | Что с ним сейчас |
|--------|------------------|
| §1 возможности библиотек | актуально как обзор, но **спрайтовый движок `sprite.h` для персонажей НЕ используется**: Kid/страж рисуются прямыми блитами атласов (`gfx_blit_cols_part*`) с ручным heal — так требует модель оригинала (§6) |
| §2 held-state клавиатуры | **сделано** (`kbd_mod_state`, `<kbd_raw.h>`). Открытая проблема — потеря байт при аккордах Shift+стрелка; диагноз и план в `../roomtest/TASKS_CLOSED.md` (KBD-1) |
| §3 форматы данных | актуально; уровень читается живьём (`roomtest/pop_level.c`) |
| §4 стратегия фона | **сделано** — тайловый рендерер в рантайме (`roomtest/pop_bg.c`) |
| §5 PoC | **закрыт и превзойдён.** `poc/` (плейсхолдер-персонаж) — история; активная разработка ушла в `roomtest/` с настоящей графикой |
| §6 модель движения | **сделано**: `play_seq` + `frame_table` оригинала, не физика с нуля |
| §7 фазы | см. отметки статуса прямо в разделе |
| §8 риски | п.1 закрыт, п.3 закрыт (28 страниц-атласов Кида), п.2/п.4 — см. отметки в разделе |
| §10 режим памяти | **сделано и переросло план**: `huge` + четыре банка кода; актуальная раскладка — `layout_plan_v2.md` |
**Где смотреть текущее состояние, а не план:** `../roomtest/README.md`
(что играется), `../roomtest/TASKS_OPEN.md` (что в работе), `levels_plan.md`
(следующие уровни), `layout_plan_v2.md` (раскладка кода по окнам и банкам).
---
Опирается на
`APPLEII_RESOURCE_FORMAT.md` / `MSDOS_RESOURCE_FORMAT.md` / `README.md` в
этой папке, на текущий sprinter-cc/libc/libbgi (см. §1) и на локальные копии
`applications/PoP/SDLPoP` (github.com/NagyD/SDLPoP, GPLv3) и
@@ -227,6 +248,13 @@ dirty-биты, heal против фона через ОЗУ-копию); `room_
## 5. Proof-of-Concept — цель: доказать, что порт вообще ощущается как PoP
> **Закрыт (исторический раздел).** PoC в `poc/` свою задачу выполнил и
> дальше не развивается: управление ощущается как PoP, held-state работает.
> Всё, что ниже про плейсхолдер-персонажа и приблизительную дугу прыжка,
> — уже неправда для активной ветки: в `roomtest/` стоит настоящая графика
> Кида и авторские таблицы кадров (§6). Раздел оставлен ради истории
> решений (в частности §5.1 — почему сначала был плейсхолдер).
**Объём**: одна комната (например Level 1, экран старта Кида), без
переходов между экранами, без стражников (стретч-цель, не обязательна).
@@ -364,45 +392,53 @@ memory/png_strip_padding_tradeoff.
## 7. Полноценное приложение — фазы (после PoC)
Порядок — по риску и зависимостям, не по геймплейной важности.
**Отметки статуса — на 2026-08-01.**
**Фаза 0 — инфраструктура порта** (расширяет PoC, не переписывает):
- Хелд-стейт клавиатуры — финальное решение и реализация по §2 (после
подтверждения пользователем).
- Полный конвертер уровней (все 15 файлов `levels.dat`/`LEVELn`) → бинарный
формат приложения (можно 1-в-1 raw dump, читать по офсетам в рантайме —
не обязательно разворачивать в C-struct с указателями).
- Полный конвертер фона (24 экрана × N уровней) в растры + конвертер
спрайт-лент Кид/стражник/скелет/тень/Джаффар в атласы `.atl` (расширение
`conv_sprites.py`/формата `.atl`, если частот кадров/атласов на актора не
хватит текущего лимита — см. риск в §8).
**Фаза 0 — инфраструктура порта** — **СДЕЛАНА**, но иначе, чем задумано:
- Хелд-стейт клавиатуры по §2 — сделан.
- Конвертер уровней не понадобился: `res200N.bin` из `SDLPoP/data/LEVELS`
кладётся на образ как есть и читается по офсетам в рантайме
(`roomtest/pop_level.c`), уровень живёт в EMM-странице.
- Конвертер фона в растры **отменён осознанно** (§4): фон собирается
тайлами в рантайме. Спрайты — `toolchain/pop_pack_bg.py` /
`pop_pack_kid.py` / `pop_pack_guard.py` → атласы `.atl` (Kid — 28
страниц, риск §8 п.3 закрыт).
**Фаза 1 — Кид, полный набор действий**: стоять/идти/бежать/тормозить/
разворот/прыжок (на месте, вперёд, «прыжок с разбега»)/повисание на
краю/подтягивание/спуск по свисанию/приседание/питьё зелья/смерть от
провала. Переходы между экранами (`MAP`-граф, `INFO.KidStartScrn`).
**Фаза 1 — Кид, полный набор действий** — **СДЕЛАНА**: стоять/идти/бежать/
тормозить/разворот/прыжки/повисание/подтягивание/спуск/приседание/
осторожный шаг/питьё зелья/смерть от провала и от пик; переходы между
комнатами во все четыре стороны. Осталось: **старт по данным уровня**
(`pop_level_start_*` реализованы, но не подключены) — задача L1-START в
`../roomtest/TASKS_OPEN.md`.
**Фаза 2 — мир и ловушки**: нажимные плиты/двери через граф
`LINKLOC`/`LINKMAP` (см. `APPLEII_RESOURCE_FORMAT.md` §1.2), шипы
(выдвижение/втягивание/заклинивание), шаткие плиты (loose, обрушение),
зелья (эффект по `BLUESPEC×32`), стартовые позиции по `INFO`.
**Фаза 2 — мир и ловушки** — **СДЕЛАНА**: кнопки/ворота через
`LINKLOC`/`LINKMAP`, шипы, loose-полы (тряска, обрушение, щебень, пробой
потолка), зелья, дверь уровня (открывается), факелы. Подробности и
справочник — `gates_spikes_plan.md`.
**Фаза 3 — бой**: подбор/выхватывание меча, состояние стойки, парирование/
удар, коллизия клинков — по логике `AUTO.S`/`seg003-006.c` (референс, не
копия). Стражник: базовое AI-поведение по `GdStartProg` (несколько
шаблонов программ), Y-сортировка слоями уже есть в движке для «кто
спереди/сзади».
**Фаза 3 — бой** — **СДЕЛАНА в объёме обычного стражника**: подбор и
выхватывание меча, стойка, удар/парирование, коллизия клинков, HP обеих
сторон, смерть; ИИ стража (замечает Кида, подходит, боевые ветки),
персистентность трупа между комнатами.
**Фаза 4 — разнообразие противников**: скелет, тень (копия анимации Кида —
подтверждено побайтовым совпадением данных, см. `MSDOS_RESOURCE_FORMAT.md`
§3), толстый стражник/визирь (общая база анимации с визирем).
**Фаза 4 — разнообразие противников** — **НЕ НАЧАТА**. Скелет нужен на
уровне 3, толстый — на 6, тень — на 12, визирь — на 13; привязка
«уровень → тип стража» (`tbl_guard_type`) описана в `levels_plan.md` §1.
**Фаза 5 — звук**: CBL-эффекты (шаги, удары, двери, падение) из
`digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как опциональный
дешёвый бипер без CBL, если формат подтвердится простым парсингом.
**Фаза 5 — звук** — **НЕ НАЧАТА**: CBL-эффекты (шаги, удары, двери,
падение) из `digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как
опциональный дешёвый бипер без CBL, если формат подтвердится простым
парсингом. Опкод `SOUND` в `play_seq` пока просто съедает свой аргумент —
точки вызова уже на месте.
**Фаза 6 — оболочка**: титры, меню/выбор уровня, HUD (таймер/жизни),
сохранение прогресса (FILE*), финальные катсцены — по минимуму,
геймплейно не критично.
**Фаза 6 — оболочка** — **НЕ НАЧАТА**: титры, меню/выбор уровня, HUD
(таймер/жизни), сохранение прогресса (FILE*), финальные катсцены — по
минимуму, геймплейно не критично. Полоса HP — единственное, что уже есть.
**Между Фазами 4 и 5 вклинивается то, чего в этом плане не было:
переход между УРОВНЯМИ** (загрузка следующего уровня, второй тайлсет
palace, потабличные различия уровней). Отдельный документ —
`levels_plan.md`.
**Фаза 7 — стабилизация**: полный прогон всех 14 уровней в MAME
(`mame_interactive.py`), затем на реальном железе; профилирование бюджета
@@ -418,23 +454,33 @@ tiny/small.
(по правилу `defer_unexplained_quirks` — не гадать, проверять)
1. **Held-state клавиатуры** (§2) — блокирует даже PoC, если решать
«правильно»; иначе PoC на компромиссном варианте 2 (таймаут-эвристика).
2. **Бюджет спрайтов на насыщенный экран** — сцена с 2+ стражниками +
несколько анимированных ловушек может приблизиться к лимиту
~21 спрайт/кадр (`sprite_engine_perf`) — нужна прикидка по реальным
уровням (сколько объектов одновременно активно в худшем экране).
3. **Ёмкость одного атласа/страницы EMM на актора** — у Кида ~220 кадров
(все действия) против 4×12 у `rpgwalk` — потребуется либо несколько
атласов на актора с переключением по фазе действия (стоять/идти отдельно
от боя), либо расширение формата `.atl`/загрузчика на мульти-страничные
атласы — оценить фактический байтовый вес конвертированных кадров Кида
прежде чем проектировать.
4. **Тайминг оригинала** — сверить логическую частоту кадров анимации
оригинала (Apple II ~60 Гц NTSC / DOS — фиксированный таймер) с 50 Гц
Sprinter; если оригинал считался на другой частоте — потребуется
коэффициент пересчёта смещений кадров (§6), иначе прыжки/бег будут
визуально быстрее/медленнее эталона.
1. ~~**Held-state клавиатуры** (§2)~~**закрыт** (`<kbd_raw.h>`). Открытый
остаток — не «есть ли held-state», а потеря байт при аккордах
Shift+стрелка: `../roomtest/TASKS_CLOSED.md`, KBD-1.
2. **Бюджет кадра** — риск подтвердился, но не в том виде, в каком ожидался:
спрайтовый движок для персонажей не используется, поэтому лимит
«~21 спрайт/кадр» неприменим. Реальный бюджет упирается в heal+блиты и
перерисовку тайлов; замер 2026-07-30 — типичный кадр ~371 К тактов
(~86 % периода). Инструмент замера уже в коде: полосы бордюра `PROF()`
в `roomtest.c`. План выжимания — `../roomtest/TASKS_CLOSED.md` (CLIP-1) и
`../roomtest/BUGS_OPEN.md` (T-1/T-2).
3. ~~**Ёмкость атласа на актора**~~ — **закрыт**: Kid разложен на 28
атласов-страниц по 8 спрайтов (`pop_pack_kid.py`), страж — на 5;
мульти-страничного формата `.atl` не потребовалось. Побочно
подтвердился компромисс паддинга (§6.1).
4. **Тайминг оригинала** — **ОТКРЫТ, и сверка 2026-08-01 показывает
расхождение.** Цифры оригинала (SDLPoP): базовый таймер `BASE_FPS = 60`
(`types.h:1373`), логический кадр игры — `base_speed = 5` тиков
(`data.h:869`), то есть **83.3 мс (12 лог. кадров/с)**; в бою
`fight_speed = 6` → **100 мс (10/с)**. У нас (`roomtest.c`) — три
ожидания `gfx_wait_vsync()` на итерацию, то есть **60 мс (16.7/с)** и
без отдельной скорости боя. Значит **игра идёт примерно на 39 %
быстрее эталона**. Точное соответствие даёт 4 ожидания vsync (80 мс
против 83.3) и 5 в бою (100 мс — совпадает точно).
Проверять не «на глаз», а секундомером по одинаковому отрезку
(SDLPoP рядом на том же экране), и только после того, как кадр
перестанет иногда вылезать за период (см. п.2) — иначе замедление
спрячет проблему бюджета вместо того, чтобы её показать.
---
+56 -2
View File
@@ -1,4 +1,54 @@
# Форматы ресурсов Prince of Persia — сводка
# `applications/PoP/docs` — индекс + сводка по форматам ресурсов
## Индекс документов (актуальность на 2026-08-22)
**Живые планы — читать перед работой:**
| Документ | О чём |
|----------|-------|
| [`../roomtest/TASKS_OPEN.md`](../roomtest/TASKS_OPEN.md) | **Что берётся в работу сейчас** (не в этой папке, но входная точка) |
| [`../roomtest/BUGS_OPEN.md`](../roomtest/BUGS_OPEN.md) | Открытые баги roomtest (закрытые — в `BUGS_CLOSED.md` рядом) |
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где мы сделали не дословно и почему |
| [`perf_l13_room23.md`](perf_l13_room23.md) | **Сцена и метод замера кадра** (каскад плит, ур.13 к.23): как воспроизвести, зонды, канал `clog`, сводка по кадрам, габариты спрайтов и ответ про `uint8_t`. 2026-08-17 |
| [`perf_green_phase.md`](perf_green_phase.md) | **ЗЕЛЁНАЯ фаза (слой фона)**: раскладка тактов, способы ускорения (G1..G6), журнал правок — рабочий документ между сессиями. 2026-08-17 |
| [`perf_cyan_phase.md`](perf_cyan_phase.md) | **ЦИАН фаза (персонажи + передний слой)**: раскладка тактов, способы ускорения (C1..C7), журнал правок — рабочий документ между сессиями. 2026-08-17 |
| [`perf_backlog.md`](perf_backlog.md) | Отложенная оптимизация отрисовки с замерами 2026-08-10 + **как мерить** (wait-state'ы, границы кадра). Позиции 1–7 переехали в фазовые документы выше |
| [`quicksave_plan.md`](quicksave_plan.md) | **QuickSave/QuickLoad** (✅ реализовано, F6/F9, POP.SAV+BAK): разбор (это enhancement SDLPoP, в оригинале 1989 его НЕТ), инвентаризация нашего состояния, формат снимка 'POPQ' v3, шаги QS1..QS6. Справочник. 2026-08-22 |
| [`full_game_plan.md`](full_game_plan.md) | **Полноценная игра**: app state machine, title/intro, demo level 0, таймер, cutscenes, уровни 1..14, ending и Hall of Fame. Уровень 15 исключён. 2026-08-21 |
| [`menu_settings_plan.md`](menu_settings_plan.md) | **Pause menu и Settings**: QuickSave/QuickLoad в основном menu, `POP.CFG`, один `POP.SAV` + `POP.BAK`, текущий VANILLA и задел под ENHANCED; §10 — выбор UI-рендера (текстовые строки + свой растровый рендерер, референс SDLPoP: два шрифта), restart без подтверждения. 2026-08-22 |
| [`palette_plan.md`](palette_plan.md) | **Палитры и fade**: карта всех 256 слотов (kid.pal/title/story, тайлсеты dungeon/palace, стражи), механика fade (4 ступени vs ~64 у SDLPoP), план модуля `pop_pal.c` (API load/apply/black) + переход уровня через fade. 2026-08-23 |
| [`status_line_text.md`](status_line_text.md) | **Строка HP как статус-строка**: полная инвентаризация ВСЕХ текстов SDLPoP в `rect_bottom_text` (геометрия, семантика `text_time_total`, мигание, рестарт по истечении) + что из этого уже есть у нас. 2026-08-25 |
| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP |
| [`levels_12_15_plan.md`](levels_12_15_plan.md) | **Уровни 12/13** (тень, Джафар, падающие плиты) + что такое 14/15 и 0. 2026-08-13 |
| [`midtable_analysis.md`](midtable_analysis.md) | **Слои отрисовки**: как устроены back/mid/fore и objtable в оригинале, чего стоит порт, развилки. 2026-08-13 |
| [`roomnav_skip.md`](roomnav_skip.md) | **Комнаты для отладочного телепорта**: какие пропускать и почему (посчитано по данным уровней). 2026-08-13 |
| [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки |
| [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди |
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии из `BUGS_CLOSED.md`, дифф против SDLPoP |
| [`shadow_render.md`](shadow_render.md) | **Вид Тени (OR+XOR)** — отложено: почему XOR несовместим с прозрачностью `#FF`, замер подготовки источника, четыре варианта |
| [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) |
| [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра |
**Исполненные планы, оставленные как справочники:**
| Документ | Чем ещё полезен |
|----------|-----------------|
| [`PORT_PLAN.md`](PORT_PLAN.md) | Общая карта фаз со статусами; §6 (модель движения), §10 (режим памяти) |
| [`KID_PLAN.md`](KID_PLAN.md) | Модель персонажа: `char_type`, `actions_*`, устройство `play_seq` — нужна для скелета/тени/визиря |
| [`gates_spikes_plan.md`](gates_spikes_plan.md) | Раскладка объектов уровня 1 по комнатам, декод `LINKLOC`/`LINKMAP`, точные ссылки на seg-код |
**Форматы ресурсов** (ниже по этому файлу): `POP-DAT-FormatSpecifications.pdf`
/ `.txt` (первоисточник), `APPLEII_RESOURCE_FORMAT.md`,
`MSDOS_RESOURCE_FORMAT.md`.
Удалены 2026-08-01 как полностью исполненные и перекрытые кодом:
`clip_char_plan.md`, `double_buffer_plan.md`, `loose_floors_plan.md`,
`size_optimization_plan.md` (его §8 про скорость отрисовки перенесён в
`layout_plan_v2.md` §9). Ищутся в истории git, если понадобятся.
---
## Форматы ресурсов — сводка
**Каноническая спецификация форматов**`POP-DAT-FormatSpecifications.pdf`
(+ текстовая конверсия `POP-DAT-FormatSpecifications.txt` для grep/цитирования):
@@ -70,7 +120,11 @@ DOS-упаковщика). Это значит: раскладку `BLUETYPE`/`B
дворца/подземелий) практичнее взять их оттуда напрямую, чем писать свой
декодер сжатия пикселей DOS `.DAT`.
## Что дальше (не сделано в этом заходе)
## Что дальше по форматам (не сделано и пока не нужно)
Порт читает уровень напрямую из `res200N.bin` (`roomtest/pop_level.c`), а
графику берёт из распакованных PNG `SDLPoP/data/` — поэтому ни один пункт
ниже сейчас не блокирует работу.
1. Точный кодек сжатия пикселей спрайтов в сыром DOS `.DAT` (нужен только
если понадобится читать именно нашу локальную копию `MSDOS/*.dat`
-112
View File
@@ -1,112 +0,0 @@
# План: порт `clip_char()` — обрезка спрайта персонажа
Статус: в работе с 2026-07-28. Контекст: баг «спуск Кида с кнопки в комнате 8»
(Kid просвечивает в щель между кнопкой и ближним столбом, мусор на кромке).
## Симптом
room 8, Kid спускается (climbdown) с тайла-кнопки у ближней колонны. Спрайт
Кида нарисован ЦЕЛИКОМ, включая часть, которая в оригинале обрезана по линии
пола: видно «просвет» Кида в щели между кнопкой и колонной и мусор на кромке.
## Корень
Не портирован `clip_char()` (SDLPoP `seg006.c:1749`, вызывается из
`add_kid_to_objtable`/`add_guard_to_objtable`, `seg008.c:1671/1690`, ПОСЛЕ
`set_char_collision`/`set_objtile_at_char`/`redraw_at_char*` и ПЕРЕД
`add_objtable`). Оригинал кладёт в objtable не только позицию спрайта, но и
прямоугольник клипа `obj_clip_{top,bottom,left,right}`; блиттер рисует только
внутри него. Мы рисуем без клипа вообще.
## Что делает оригинал (дословно)
`reset_obj_clip()``left=0, top=0, right=320, bottom=192`.
Дальше (упрощая C-трюк с глобалью `curr_tile2`, которую ставит каждый
`get_tile`: `X == wall || tile_is_floor(curr_tile2)` — это «тайл X = стена
ИЛИ пол»):
```
T_L = get_tile(room, char_col_left, char_top_row)
T_R = get_tile(room, char_col_right, char_top_row)
if (T_L — стена или пол) &&
( (action == stand && (frame == 79 || frame == 81)) /* прыжок вверх / зацеп */
|| (T_R — стена или пол) )
{
clip_row = Char.curr_row + 1;
clip_y = y_clip[clip_row]; /* y_clip[] = {-60, 3, 66, 129, 192} */
if (clip_row == 1 || (clip_y < obj_y && clip_y - 15 < char_top_y))
obj_clip_top = char_top_y = clip_y;
}
```
Смысл: `y_clip[row+1]` — верхняя граница СВОЕЙ полосы ряда. Если над головой
пол/стена — всё, что выше этой линии, не рисуется (персонаж «уходит под пол»).
Для ряда 0 (`clip_row == 1`) клип применяется БЕЗУСЛОВНО.
Метрики из `set_char_collision` (seg006:0723):
- `char_x_left = obj_x/2 + 58``-= char_width_half`, если смотрит вправо)
- `char_x_right = char_x_left + char_width_half`, `char_width_half = (w+1)/2`
- `char_top_y = obj_y - h + 1`; если `>= 192``0`
- `char_top_row = y_to_row_mod4(char_top_y)`
- `char_col_left = MAX(get_tile_div_mod(char_x_left), 0)`,
`char_col_right = MIN(get_tile_div_mod(char_x_right), 9)`
Второй блок `clip_char``obj_clip_right` (doortop / стена / зеркало для
виса-полёта-подъёма при взгляде влево, кадры 137..139) и ветка кадров 224..228
(выход в дверь уровня). **Фаза 2**, не в этом заходе.
## Наши точки касания
- `roomtest/pop_kid.c` `kid_draw()` — сам считает `obj_x/obj_y/w/h` и блитит
через `gfx_blit_cols(bx, top + POP_YOFF, img, flip)`; запоминает
прямоугольник в `kid_l{x,y,w,h}[page]` для `kid_heal()`.
- `roomtest/pop_map.c` — там `get_tile()`, `tile_is_floor()`, `y_to_row()`,
`get_tile_div_mod_m7()`, Kid-структура. Аналогичный расчёт габарита уже
есть в `check_spike_below()` (строка ~1198) — брать за образец.
- `libbgi/common/gfx_blit_cols.c` — клип по экрану есть (при `y < 0`
пропускает `sy` верхних строк колонки), но произвольной верхней границы нет.
## Шаги
1. **libbgi**: `gfx_blit_cols_part(int x, int y, const void *img, uint8_t flip,
int sy, int h)` — «пропустить `sy` верхних строк спрайта, нарисовать `h`».
Реализация = тело `gfx_blit_cols` с предустановленными `sy`/`h` (источник
`+= sy`, экранный `y += sy`). Прототип в `libbgi/include/gfx.h`.
Стоимость: 0 в общем пути (обычный `gfx_blit_cols` вызывает то же ядро).
2. **pop_map.c**: `int pop_clip_char_top(int obj_x, int obj_y, uint16_t w,
uint16_t h)` — порт первого блока `clip_char`; возвращает КОМНАТНЫЙ y
(0 = клипа нет). Экспорт в `pop_map.h`.
3. **pop_kid.c**: в `kid_draw()` после расчёта `top` —
`ct = pop_clip_char_top(...)`; если `ct > top` → `sy = ct - top`,
рисовать `gfx_blit_cols_part(bx, ct + POP_YOFF, img, flip, sy, h - sy)`;
в `kid_l*[page]` класть ОБРЕЗАННЫЙ прямоугольник (иначе heal чистит лишнее
и стирает кромку пола). То же для `kid_draw_splash` — там оригинал делает
`reset_obj_clip()`, т.е. splash НЕ клипится (ничего не менять).
4. **Сборка**: `make -C libbgi`, `make -C applications/PoP/roomtest`,
`make size-check`; вывести свободное место в W2/W3 (порог 512 Б).
5. **Проверка в MAME** (`mame_hdd_test_disk`, полный рестарт после
пересборки образа): комната 6 → переход в 8 → влезть на кнопку → спуск.
Сверять с эталоном: живой SDLPoP той же позой (см. `pop_check_sdlpop_first`).
## Риски / что проверить отдельно
- `char_top_row` считается `y_to_row_mod4` — у нас `y_to_row()` даёт 1 для
полосы у потолка; `get_tile(row 1)` уже умеет ряд 2 верхнего соседа.
- Kid ниже комнаты (`char_top_y >= 192` → `0`) — обязателен ресет, иначе клип
прыгнет.
- `clip_row == 1` (ряд 0) — клип БЕЗУСЛОВНЫЙ: проверить, что не режет Кида в
обычной стойке на ряду 0 (условие внешнего `if` про пол/стену над головой
должно отсекать).
- Клип меняет прямоугольник heal → возможен «хвост» на второй странице
дабл-буфера: проверять оба кадра (SPACE — выключить дабл-буфер).
## Дальше (Фаза 2, отдельно)
`obj_clip_right` (doortop/стена/зеркало) — нужен для виса и подъёма при
взгляде влево; и `obj_clip_left` для зеркала (уровень 4). Требует клипа по
колонкам в `gfx_blit_cols` (обрезка справа = уменьшить `w`) — дёшево, но
без тестовой сцены проверять нечем.
См. memory: `pop_clip_char_todo`, `pop_backtable_vs_midtable`,
`pop_check_sdlpop_first`, `gfx_blit_noclip_fast`.
@@ -1,69 +0,0 @@
# Double buffer (два экрана + флип) — план
Цель: убрать мерцание/тиринг при перерисовке слоёв (Kid ↔ fore/пол-оверлей)
и гарантировать, что на экране ВСЕГДА готовый кадр с правильным порядком
слоёв. Нужно для отладки fore-слоя (видно момент композиции, а не
промежуточные состояния heal-рендера). **Тумблер обязателен**
однобуферный режим удобнее для отладки багов рисования.
## Что уже готово (libbgi — трогать НЕ нужно)
- Две графических страницы 0/1: `gfx_set_draw_page(p)` (двигает
`_gfx_addr_base` 0xC000/0xC140 — рисуют все примитивы),
`gfx_set_visible_page(p)` (ESTEX $54 SELPAGE — display-учёт DSS).
- `gfx_wait_vsync()` — луч (bit5 порта 0xFE), момент без разрывов.
- Паттерн из gfx.h: `set_draw_page(hidden); draw(); wait_vsync();
set_visible_page(hidden);`
- Замечание gfx.h: у каждой страницы СВОЯ плоскость палитры
(page0→pal0, page1→pal1) — для seamless грузить одну палитру в ОБЕ.
## Текущая модель рендера roomtest (однобуфер)
`roomtest.c`: `draw_page=0`, `visible_page=0` фиксированы. Фон комнаты
рисуется ОДИН раз в видео-ОЗУ + теневую копию (GFX_BANK_TRANSPARENT).
Цикл: 3× `gfx_wait_vsync` (пейсинг) → `pop_ctrl_tick` → `kid_heal()`
(восстановить прямоугольник Кида из тени) → `kid_tick`/`pop_phys_tick`
→ `kid_draw` → `pop_fore_over_kid`. Мерцание = heal+draw+fore длиннее
бланка, луч ловит промежуток.
## Работа на стороне PoP
1. **Инициализация обеих страниц**: `pop_room_draw` в page 0 И page 1
(теневая копия одна — общая, из неё heal'ит любая страница).
2. **Палитра в обе плоскости**: сейчас `gfx_pal_fload(0,...)` + sync.
Продублировать в plane 1 (проверить сигнатуру gfx_pal_fload/sync —
plane-параметр).
3. **Per-page heal-история** (ядро): вынести `kid_lx/ly/lw/lh` в
массивы `[2]`, индекс = страница, в которую рисуем. `kid_heal(page)`
восстанавливает СВОЙ прошлый прямоугольник (кадр -2, т.к. рисуем
через страницу). То же для fore/пол-оверлея, если они рисуют вне
футпринта Кида.
4. **Ping-pong в цикле**:
```
uint8_t back = dbuf ? (front ^ 1) : 0;
gfx_set_draw_page(back);
kid_heal(back); kid_tick; phys; kid_draw; fore;
gfx_wait_vsync();
if (dbuf) { gfx_set_visible_page(back); front = back; }
```
5. **Тумблер** `dbuf`: off → draw==visible==0, без флипа, heal[0] —
бит-в-бит текущее поведение. Управление — клавишей (напр. F2) или
compile-флагом.
6. **Пейсинг**: сейчас 3× vsync/лог.кадр. При флипе — один vsync перед
свопом; недостающий пейсинг добрать `gfx_set_fps_div(3)` или ручным
счётом кадров, чтобы скорость игры не изменилась.
## Порядок
- D1: обе страницы + палитра в обе плоскости; ping-pong без per-page
heal (проверить, что флип работает, фон корректен на обеих).
- D2: per-page heal-история (kid_l*[2]) — убрать «хвост» Кида.
- D3: тумблер dbuf + сверка однобуферного пути с текущим (регресс-нет).
- D4: пейсинг (fps_div) — вернуть исходную скорость.
## Связанные
Рендер-модель — [[pop_fore_layer]], [[pop_background_strategy]];
heal — kid_heal/gfx_heal. Будущие динамические слои (loose-floors
[[двойной буфер требует их per-page перерисовки]]) должны рисоваться в
обе страницы по той же дисциплине.
+603
View File
@@ -0,0 +1,603 @@
# Фиксированный логический кадр — разбор перед реализацией
Дата разбора: 2026-08-19. Отправная точка — тег `0.0.1-prealpha`.
Статус: **АНАЛИЗ, кода не трогали.**
Задача пользователя: перевести логический кадр на фиксированный размер,
не зависящий от длительности синей/зелёной/циан фаз. Инструмент —
`gfx_set_fps_div`, режимы FASTEST / FAST / NORMAL.
## 1. Что на самом деле меняется
Сейчас главный цикл (`roomtest.c`) после отрисовки ждёт **три**
`gfx_wait_vsync()` подряд. Каждый ждёт ближайший фронт луча, поэтому
период логического кадра равен
период = ceil(W) + 2 растровых кадра, W = работа в растрах
Первое ожидание доедает хвост текущего растра, два следующих — целые
растры. Отсюда наблюдаемое: `W <= 1` → период 3; `W = 1.1` → период
уже 4. То есть **реальный бюджет логического кадра сегодня — один
растр (430 080 тактов)**, всё сверх него стоит целого лишнего растра.
Нужное поведение:
период = max(n, ceil(W))
При `n = 3` бюджет становится **1 290 240 тактов** — втрое больше.
Замеренный максимум работы на 13/23 (911 862) укладывается туда с
запасом, а 11/15 (437 484 лёгкая / ~603 000 тяжёлая позиция) — вдвойне.
Это главный выигрыш, и он не про скорость игры, а про **исчезновение
скачков**: сегодня превышение растра на один такт стоит +33 % к периоду.
## 2. Три режима и их привязка к оригиналу
Растровый кадр Sprinter: 320 строк × 896 пикселей при 14 МГц =
**20,48 мс** (48,83 Гц). В тактах CPU (21 МГц) — **430 080**;
замерено на холостом DSS: 430 131 на прерывание.
Оригинал (`SDLPoP/src/seg003.c:363`): `BASE_FPS = 60`,
`base_speed = 5` тика = **83,3 мс**, `fight_speed = 6` = **100 мс**.
| режим | обычно | в бою | мс обычно | мс в бою | к оригиналу |
|---|---:|---:|---:|---:|---|
| FASTEST | 3 | 3 | 61,4 | 61,4 | +36 % скорости |
| FAST | 3 | 4 | 61,4 | 81,9 | +36 % / точно |
| NORMAL | 4 | 5 | 81,9 | 102,4 | точно (83,3 / 100) |
NORMAL воспроизводит оригинал с точностью 1,7 % и 2,4 % — расхождение
только из-за 48,83 Гц против 60 Гц, целыми делителями точнее не выйдет.
**Условие «бой» берём у оригинала буквально** — оно проще, чем кажется:
```c
if (Kid.sword == sword_2_drawn) set_timer_length(timer_1, fight_speed);
else set_timer_length(timer_1, base_speed);
```
Это не «идёт бой» и не «есть страж рядом», а **только «у Кида вынут
меч»**, и проверяется в самом верху главного цикла, до `play_frame()`.
У нас поле есть (`Kid.sword`, `SWORD_2_DRAWN``guards.c`), так что
переключение — одна строка в том же месте цикла.
Это закрывает и давнюю задачу **L1-SPEED** (`TASKS_OPEN.md`): сейчас мы
идём на 60 мс вместо 83,3 — примерно на 39 % быстрее эталона.
## 3. Как устроен темп сейчас и что мешает
`gfx_wait_vsync()` имеет две ветки (`libbgi/common/gfx_wait_vsync.c`):
- **`_gfx_fps_div <= 1`** — лучевой поллинг бита 5 порта `0xFE` в тесном
цикле, и в этом цикле зовётся **idle-хук** (`gfx_set_idle_hook`).
roomtest вешает туда `kbd_raw_poll` — это единственное, что делает
клавиатуру работоспособной (задача KBD-1: приёмный FIFO SIO 3 байта,
импульс запроса прерывания живёт 32 такта и теряется в DI-окнах
акселератора; лечится только плотным опросом раз в ~0,5 мс).
- **`_gfx_fps_div >= 2`** — счётчиковый путь: ждёт, пока фоновый ISR
(`_gfx_frame_isr`, слот кадровой цепочки) насчитает `n` фронтов, а
ожидание реализовано через **`ei; halt`**.
**И вот здесь блокер.** На счётчиковом пути idle-хук не зовётся вообще.
Включив `gfx_set_fps_div(3)` как есть, мы немедленно возвращаем KBD-1:
теряются нажатия при зажатом Shift, залипают клавиши. Это не мелочь и
не «потом поправим» — это единственная причина, по которой клавиатура
сейчас вообще работает.
## 4. Что измерено (MAME, 2026-08-19)
### Метод и его границы
Прерывания считались брейкпоинтами на резидентных адресах трамплина
(`_irq_tramp` = 0x88B4, ветка кадрового пути `tr_frame` = 0x8995).
**Счёт попаданий брейкпоинтом на этом драйвере недостоверен**: sprinter
дёргает `Z80_INPUT_LINE_WAIT` (`do_mem_wait`), инструкция пересчитывается,
и один и тот же PC срабатывает по нескольку раз. Наблюдалось
«попаданий больше, чем растровых кадров» и «попаданий в `tr_frame`
больше, чем входов в трамплин» — логически невозможные результаты.
Достоверен только **детектор разрыва**: брейкпоинт на следующей
инструкции пишет `temp3 = totalcycles`, брейкпоинт с условием
`(totalcycles - temp3) > 0x9D800` (1,5 растра) останавливает машину.
Дубли попаданий его не портят. Все числа ниже — этим методом.
Отдельная грабля: **литералы в отладчике MAME шестнадцатеричные**.
Первый прогон с порогом «700000» на деле проверял 0x700000 = 17 растров
и не срабатывал никогда.
### Факты
1. **Холостой DSS** — 799 прерываний на 343 674 880 тактов = 430 131 на
прерывание. Подтверждает константу растра и что потерь нет, когда
нечего рисовать.
2. **11/15, покой (чомпер + два факела + страж)** — потери ЕСТЬ:
разрыв ровно 860 129 тактов = два растра = одно потерянное
прерывание. Частота в «плохой» фазе: 12 потерь на 427 растровых
кадров = **2,8 %**; повтор — 12 на 495 (2,4 %) при бегущем Киде.
3. **Та же сцена после сдвига Кида** (`]`/`[`, попиксельно) —
**0 потерь на 3919 кадров**, и после возврата обратно **0 на 4010**.
4. **Полная перерисовка комнаты** (переход/`+`) — разрыв 1 720 289 =
ровно четыре растра = **три потерянных прерывания подряд**.
5. **13/23** — ни в покое, ни при беге потерь не поймано; на смене
комнаты — поймано.
6. **Клавиатура ни при чём**: с удержанной клавишей 3,1 %, без неё
2,8 % — в пределах разброса.
### Как это читать
Пункты 2 и 3 вместе — самое важное. Одна и та же сцена даёт то 2,8 %,
то ноль. Значит потеря определяется **не нагрузкой, а фазой**: попадает
ли момент кадрового прерывания внутрь DI-окна блита.
Механизм подтверждён исходником MAME (`sprinter.cpp`):
- `irq_on` поднимает линию и заводит `irq_off_timer` на **32 такта
неразогнанного клока (3,5 МГц) = 9,14 мкс**; `irq_off` гасит. Ядро
z80 в MAME уровневое (`m_irq_state` без защёлки) — импульс, пришедший
под `di`, теряется НАСОВСЕМ. Это же поведение у настоящего Spectrum
(INT 32 такта), так что это не эмуляторный артефакт.
- DI-окно у нас — один вызов `_bgi_blit_rows_raw`, а он по контракту
режется вызывающим на **чанки ≤16 строк** (≈6 200 тактов ≈ 0,29 мс).
То есть DI-окна короткие и с промежутками, отсюда и «то теряем, то
нет»: всё решает, куда попал 9-микросекундный импульс.
- `irqack_cb` гасит **все три** входа мержера (экран/клавиатура/CBL)
одним подтверждением. Плюс трамплин обслуживает за вход ровно один
источник и делает приватный RETI. Значит кадровое прерывание может
быть съедено клавиатурной веткой или (в будущем) CBL-веткой.
**Вывод по фазе.** Наш период сейчас 3 растра, но иногда 4 — и каждый
такой случай сдвигает фазу рендера относительно луча. Отсюда «полосы»:
десятки секунд без потерь, потом полоса с потерями. При жёстком
пейсинге период станет РОВНО n, фаза перестанет плавать — и сцена может
**залипнуть в плохой фазе надолго**. Это хуже случайных 3 %: систематическая
потеря по прерыванию на кадр превратит логический кадр из 3 растров в 4,
то есть даст ровные −25 % скорости, которые никак не проявятся в
профиле тактов.
## 5. Проблемы по убыванию риска
| # | проблема | риск |
|---|---|---|
| P1 | счётчиковый путь ждёт через `halt` → idle-хук не зовётся → возврат KBD-1 (потеря нажатий, залипание клавиш) | блокер |
| P2 | счётчик кадров теряет тики (фазозависимо, 0…3 %), при жёстком пейсинге может залипнуть в плохой фазе → ровный минус скорости | высокий |
| P3 | полноэкранная перерисовка (вход в комнату, старт уровня) теряет 3+ тика подряд → счётчик недосчитает, ожидание растянется сильнее самой работы | средний |
| P4 | звук через CBL (`_irq_cbl_hook`, реальный ISR в трамплине): (а) ещё один источник, крадущий кадровые тики приватным RETI; (б) обратно — любое подтверждение гасит ожидающий запрос CBL → underrun (счётчик `cbl_underruns()` уже есть); (в) ISR длинный (полный сейв обоих наборов + вызов в приложение) | средний, растёт |
| P5 | второй call-site `gfx_wait_vsync()` — ветка `frozen` (`roomtest.c:320`) ждёт один фронт; под делителем её смысл меняется | низкий |
| P6 | `IRQ_CHAIN_MAX = 4`; делитель занимает слот, звук — свой; запас есть, но конечный | низкий |
| P7 | бит 5 порта `0xFE` доступен только при включённом `cbl_mode`; сейчас его лениво занимает сам `gfx_wait_vsync` через `_cbl_port_ref`, при открытии реального звука владение переходит к CBL — переход уже спроектирован, но его надо проверить в связке | низкий |
Отдельно, не проблема а рычаг: по сообщению разработчиков
(IvanMak.txt:846848) **новая прошивка позволяет акселератору работать с
EI** — по приходу прерывания он отключается, по `RETI` включается.
Если это подтвердится на железе и моделируется в MAME, P2 исчезает
полностью. Проверять отдельно; строить на этом нельзя (неизвестно, какая
прошивка у пользователя).
## 6. Варианты реализации
### A. Включить `gfx_set_fps_div(3)` как есть
Отвергается: P1 (убивает клавиатуру) — сразу, без вариантов.
### B. Свой `wait` в приложении: счётчик только на рендер, ожидание — лучом
Счётчик кадровых прерываний отвечает на один вопрос — «сколько фронтов
съел рендер», а само ожидание идёт **существующим лучевым поллингом с
idle-хуком**, то есть клавиатура работает ровно как сегодня.
```
k = tick - tick_at_frame_start; /* фронтов съел рендер */
if (k >= n) k = n - 1; /* опоздали — ждём хотя бы один */
повторить (n - k) раз: ждать фронт луча (поллинг + idle-хук)
tick_at_frame_start = tick; /* якорь на фактическом фронте */
```
Плюсы: минимальная правка, клавиатура нетронута, фаза переякоривается
каждый кадр (ошибка не копится). Минус: остаётся P2 — при залипании в
плохой фазе `k` систематически занижен на 1, и кадр ровно на растр
длиннее. Профилем это не видно.
### C. Программный счётчик кадров по лучу (без прерываний вообще)
Считать фронты **выборкой бита 5 в точках, которые мы и так проходим**.
Окно бланка (строки 272…319) длится **3,07 мс**, максимальное DI-окно —
0,29 мс, значит достаточно опрашивать чаще, чем раз в 3 мс.
Точки выборки: `pop_blit_b` (наша обёртка, зовётся на каждый блит —
в фазах рисования это плотнее 0,3 мс) плюс несколько точек в синей фазе
(она 149 106 тактов ≈ 6,9 мс без единого блита, нужно 3-4 точки).
Цена: `in a,(0xFE)` + проверка бита + дедуп фронта ≈ 40-50 тактов; при
~50 выборках это 2 500 тактов = 0,6 % растра.
Плюсы: **точно, и точность не зависит ни от DI, ни от звука, ни от
клавиатуры** — снимает P2, P3, P4(а) разом. Минус: заводит инвариант
«между выборками не больше 3 мс», который легко нарушить будущей правкой.
Инвариант проверяем в MAME тем же детектором разрыва.
### D. CTC как источник кадра
`irq_ctc_install` уже есть (каналы 2+3, вектор 0x06, отдельный от 0xFF).
Запрос CTC **защёлкивается** (daisy chain — в `_irq.h` прямо записано,
что без `RETI` следующего прерывания не будет), поэтому под `di` он не
теряется, а откладывается. Пресет 112 × 160 даёт ровно период кадра
без дрейфа (обе частоты — от одного X_SP).
Блокер: `irq_ctc_install` вектрится напрямую и требует кода в W2 →
сейчас только tiny/big, а roomtest — **huge**. Нужна W2-копия
CTC-трамплина (в дизайн-доке помечена как follow-up). Плюс защёлка
хранит только ОДИН отложенный запрос — на полноэкранной перерисовке
(P3) всё равно недосчитает.
### Рекомендация
**B как первый шаг, C — как способ закрыть P2**, и оба под одним
интерфейсом: приложение зовёт свой `pop_wait_logical_frame(n)`, а чем
внутри считаются кадры — деталь реализации. Тогда B→C не трогает ни
главный цикл, ни режимы.
D не нужен, пока C справляется, и требует работы в libc (W2-копия
CTC-трамплина) ради того же результата.
## 7. Порядок работ с критериями приёмки
**Ш0. Инструмент.** Скрипт замера потерь кадровых прерываний (детектор
разрыва) — зафиксировать как повторяемую процедуру, он понадобится на
каждом шаге. Критерий: воспроизводит числа §4 на 11/15 и на смене комнаты.
**Ш1. Свой `wait` (вариант B), делитель ещё не включён.** Вынести
хвост главного цикла в `pop_wait_logical_frame(n)`, поведение при n=3
должно быть **бит-в-бит прежним** (три фронта после работы). Критерий:
такты по фазам и распределение периода не изменились, клавиатура
работает.
**Ш2. Счётчик кадров.** Слот кадровой цепочки + `k = tick - anchor`.
Критерий: в 11/15 период стал ровно 3 растра ВСЕГДА (сейчас 3/4/5), а
на 13/23 — 3 вместо нынешних 3/4/5; клавиатура не деградировала
(проверка Shift+стрелки по методике KBD-1, не «на глаз»).
**Ш3. Режимы.** `POP_SPEED_FASTEST/FAST/NORMAL` + условие боя
`Kid.sword == SWORD_2_DRAWN` в верху цикла, как у оригинала. Критерий:
NORMAL секундомером совпадает с живым SDLPoP на одинаковом отрезке
(методика из L1-SPEED — секундомер, не глазомер).
**Ш4. Закрыть P2 (вариант C).** Выборка луча в `pop_blit_b` и в синей
фазе. Критерий: детектор разрыва не ловит ни одного расхождения между
программным счётчиком и лучом за 10 000 кадров, включая смену комнаты.
**Ш5. Звук.** Только после Ш4: открыть CBL и перемерить P4 —
`cbl_underruns()` и потери кадровых тиков.
## 8. Что проверить артефактом до начала
1. Сколько именно фронтов съедает вход в комнату — от этого зависит,
нужен ли отдельный «resync» на тяжёлых переходах или хватит того,
что якорь переставляется каждый кадр.
2. Ветка `frozen` (`roomtest.c:320`) — какой темп ей нужен под делителем.
3. Проверить, что при n=4/5 бит 5 всё ещё единственный источник фронта
(то есть `_cbl_port_ref` держится всё это время).
---
# 9. Предлагаемый вариант подробно: программный счётчик кадров по лучу
Дополнение от 2026-08-19 по запросу: как именно получается **точное**
число пройденных кадровых интервалов. Акселератор рассматриваем только
в нынешнем виде — с DI/EI (режим «акселератор с EI» из новой прошивки
из рассмотрения снят).
## 9.1. Сигнал и его геометрия
Единственный источник — **бит 5 порта `0xFE`**. Точная семантика по
исходнику MAME (`sprinter.cpp`, `kbd_fe_r`):
```c
data |= 0xe0;
data ^= 0x40;
if (cbl_mode()) {
data &= ~0xa0; /* гасит биты 5 и 7 */
data |= (vpos >= BORDER_TOP + SCREEN_YSIZE) << 5;
data |= ... & 0x80; /* бит 7 — CBL */
}
```
То есть **бит 5 = 1 ровно тогда, когда луч ниже картинки**
(`vpos >= 16 + 256 = 272`), и это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА, а не событие:
ни прерывания, ни защёлки, ни очереди — его невозможно «потерять»,
можно только не посмотреть.
Важное следствие из той же строки: вне `cbl_mode` бит читается как 1
всегда (его выставляет `data |= 0xe0` и уже ничто не гасит). Поэтому
счётчик обязан работать только при взведённом `_cbl_port_ref()` — том
самом, который сейчас лениво взводит `gfx_wait_vsync`.
Геометрия кадра (320 строк × 896 пикселей при 14 МГц):
| | строк | мс | тактов CPU (21 МГц) |
|---|---:|---:|---:|
| бит 5 = 1 (нижний бланк) | 48 | 3,07 | **64 512** |
| бит 5 = 0 (картинка + верхний бордер) | 272 | 17,41 | 365 568 |
| кадр целиком | 320 | 20,48 | 430 080 |
Границей кадра берём **фронт 1→0** — это `vpos = 0`, ровно то же
событие, которого ждёт сегодняшний `gfx_wait_vsync`. Значит момент
свопа страниц не меняется: до начала картинки остаётся верхний бордер,
16 строк ≈ 1 мс запаса, как и сейчас.
## 9.2. Счётчик
```c
static uint8_t beam_prev; /* бит 5 на прошлой выборке */
static uint8_t frame_tick; /* счётчик кадров, разностная арифметика */
/* ~20 T-состояний тела + вызов; в тактах MAME ≈ 120 на выборку */
void pop_beam_sample(void)
{
uint8_t b = in_fe() & 0x20;
if (beam_prev && !b) frame_tick++; /* фронт 1→0 = начало кадра */
beam_prev = b;
}
```
Вся арифметика ожидания — разностная по модулю 256, wrap безопасен
(тот же приём, что в существующем `_gfx_fps_state`).
## 9.3. Почему счёт ТОЧНЫЙ (условие и запас)
Утверждение: **если между соседними выборками проходит меньше 64 512
тактов, то каждый фронт 1→0 будет засчитан ровно один раз.**
Доказательство прямое. Пусть максимальный зазор между выборками
Δ < 64 512. Окно «бит 5 = 1» длится 64 512 тактов, то есть длиннее Δ,
значит в него попадает хотя бы одна выборка → `beam_prev` обязательно
станет 1 внутри каждого бланка. Окно «бит 5 = 0» длится 365 568 — тем
более содержит выборку → сразу после бланка `beam_prev` перейдёт в 0 и
даст ровно один инкремент. Двойной счёт невозможен: инкремент
происходит только на переходе 1→0, а `beam_prev` тут же обновляется.
Условие ОДНО и оно про зазор, а не про нагрузку, не про DI, не про
прерывания. Отсюда все свойства варианта.
**Какой запас по факту.** Самый длинный неделимый кусок кода без
возможности выборки — одно DI-окно акселератора, то есть один вызов
`_bgi_blit_rows_raw`. Он по контракту режется вызывающим на чанки
**≤16 строк**; при ширине 32 это ≈ 6 200 тактов, при полной высоте
спрайта 63 строки самый дорогой замеренный блит целиком — 32 073.
Даже если мерить самым грубым образом (одна выборка на целый блит,
а не на чанк), зазор вдвое меньше окна бланка.
## 9.4. Где ставить выборки
Правило простое: **выборка обязана стоять так, чтобы ни один путь
исполнения не давал зазора длиннее 64 512 тактов.** По фазам:
- **Зелёная и циан** (436 494 и 382 770 тактов на 13/23) состоят из
блитов и хилов. Достаточно одной выборки на вызов наших обёрток
`pop_blit_b`, `pop_heal_off`, `pop_heal_fast` — но НЕ только их:
прямые вызовы `gfx_blit_*` / `gfx_heal*` разбросаны по шести файлам
(`pop_cdraw.c`, `pop_draw.c`, `pop_kdraw.c`, `pop_room.c`,
`pop_state.c`, `pop_tile.c`). Точный набор точек определяем НЕ
рассуждением, а замером (см. 9.7): ставим в обёртки, меряем худший
зазор, добавляем точки только там, где замер их требует.
- **Синяя** (149 106 тактов) — блитов нет вообще, это 2,3 окна бланка.
Нужны явные точки: после ввода, после физики, после `pop_process_trobs`,
после mob-тика. Ставятся на границах, которые и так размечены
зондами `pop_dbg_m*`.
Чего заведомо НЕ хватит: выборок только в главном цикле. Один
`pop_floor_bake` — 179 914 тактов, почти три окна бланка.
## 9.5. Ожидание — тот же примитив
Ожидание фронта и есть плотная выборка, поэтому оно сливается со
счётчиком, а опрос клавиатуры остаётся ровно таким же плотным, как
сегодня:
```c
static void wait_edge(void)
{
uint8_t t = frame_tick;
do {
pop_beam_sample();
kbd_raw_poll(); /* то, что сейчас висит idle-хуком */
} while (frame_tick == t);
}
```
`gfx_set_idle_hook` при этом больше не нужен — опрос зовётся прямо.
Обязателен аварийный выход по счётчику попыток (как в нынешнем
`gfx_wait_vsync`): на железе, где бит ведёт себя иначе, цикл не должен
виснуть насмерть.
## 9.6. Пейсинг целиком
```c
uint8_t k = (uint8_t)(frame_tick - anchor); /* фронтов съел рендер */
if (k >= n) {
wait_edge(); /* опоздали — выравниваемся на ближайший */
} else {
do { wait_edge(); } while ((uint8_t)(frame_tick - anchor) < n);
}
anchor = frame_tick; /* якорь по ФАКТУ, фаза не копится */
flip_page();
```
`anchor = frame_tick`, а не `anchor += n` — сознательно: догонять
пропущенное время нельзя, иначе после тяжёлого кадра игра рванёт
вперёд. Это же правило заложено в исходном дизайне делителя
(«выравнивание на ближайший фронт, без накопления фазовой ошибки»).
Поведение по случаям:
| работа W (растров) | период | комментарий |
|---|---|---|
| W ≤ n | ровно n | цель задачи |
| n < W ≤ n+1 | ceil(W) | подтормаживает ровно настолько, насколько не успели |
| вход в комнату, W ≫ n | ceil(W) + 1 | ветка «опоздали»: один фронт, без растягивания |
| загрузка уровня, файловые операции | ceil(W) + 1…n | выборок нет вовсе → k занижен; худшее — n лишних растров ОДИН раз |
Последняя строка — единственный случай, где счёт неточен, и он
безобиден: во время `ESTEX`-вызова выбирать нечего, а ошибка живёт один
кадр, потому что якорь переставляется по факту.
## 9.7. Как это доказывается, а не декларируется
Инструмент уже построен и проверен на нынешнем коде (§4): брейкпоинт на
следующей инструкции пишет `temp3 = totalcycles`, второй с условием
`(totalcycles - temp3) > 0x9D800` останавливает машину.
Для приёмки он ставится **на инструкцию инкремента `frame_tick`**.
Если хоть один фронт пропущен, зазор между инкрементами станет два
растра и детектор остановит машину. Критерий: **10 000 кадров без
единого срабатывания**, включая смену комнаты и смерть Кида.
Дополнительно, в отладочной сборке — перекрёстная проверка со счётчиком
кадровых прерываний (слот цепочки, один INC): прерывания теряются, луч
не должен, значит `frame_tick` обязан идти НЕ МЕДЛЕННЕЕ `irq_tick`.
Расхождение в другую сторону = пропущенная выборка.
Напоминание о методике: **счёт попаданий брейкпоинтом на этом драйвере
недостоверен** (WAIT-линия, инструкция пересчитывается) — только
детектор разрыва. И литералы в отладчике MAME шестнадцатеричные.
## 9.8. Цена
| статья | тактов |
|---|---:|
| одна выборка (с вызовом) | ≈ 120 |
| ~60 выборок за логический кадр | ≈ 7 200 |
| доля от бюджета при n=3 (1 290 240) | **0,6 %** |
В горячих местах (`pop_blit_b`) выборку можно заинлайнить и снять цену
вызова.
## 9.9. Чем это лучше счётчика прерываний
| | счётчик кадровых IRQ | счётчик по лучу |
|---|---|---|
| теряет тик под `di` акселератора | да, фазозависимо 0…3 % | нет — читается положение луча |
| теряет тик, если IRQ съела клавиатурная/CBL-ветка трамплина | да (приватный RETI) | нет |
| ломается от добавления звука | да (ещё один источник, `irqack` гасит все входы мержера) | нет |
| поведение на полной перерисовке | 3 тика подряд мимо | считает все |
| условие корректности | никакого — не в нашей власти | зазор выборок < 64 512 тактов, проверяется артефактом |
| риск залипнуть в плохой фазе и ровно потерять 25 % скорости | есть | нет |
## 9.10. Что осталось проверить зондами до кодирования
1. **Худший зазор между выборками** при размещении «только в обёртках»
— сколько точек реально нужно добавить. Это же число решает, нужна
ли выборка в синей фазе в четырёх местах или в двух.
2. **Владение `cbl_mode`**: сейчас бит 5 доступен потому, что
`gfx_wait_vsync` взвёл `_cbl_port_ref()` при первом вызове. Свой
ожидатель обязан взвести его сам — значит примитив логичнее держать
в libbgi (там доступен `_cbl_port_ref`), а не в приложении.
3. **Ветка `frozen`** (`roomtest.c:320`) — какой темп ей нужен.
4. Совпадает ли момент возврата `wait_edge()` с нынешним возвратом
`gfx_wait_vsync()` с точностью до микросекунд (иначе поедет момент
свопа и появятся разрывы картинки).
---
# 10. РЕЗУЛЬТАТ (2026-08-19, реализовано и проверено в MAME)
Реализовано в приложении (`roomtest/pop_pace.c/.h`), в libbgi пока НИЧЕГО не
переносили — по решению пользователя: сначала обкатать у себя.
## 10.1. Что сделано
- `pop_beam_sample()` — выборка бита 5 порта `0xFE`, 10 инструкций,
быстрый путь 46 T + вызов. Модуль НЕ банковый, поэтому из банков
зовётся прямым `call` (проверено: банки так зовут `_pop_cd_hit_slot`).
- `pop_wait_edge()` — ожидание одного фронта; внутри тот же
`kbd_raw_poll()`, что раньше висел idle-хуком.
- `pop_pace_end(n)` — добрать до n фронтов от якоря; якорь ставится ПО
ФАКТУ. Главный цикл: `pop_wait_edge()` → строб вспышки →
`pop_pace_end(n)` → своп страниц.
- `pop_pace_arm()` — взводит `cbl_mode` через `gfx_wait_vsync()` и
ПРОВЕРЯЕТ, что фронты идут; если нет — `pace_ok = 0` и всё молча
откатывается на прежние `gfx_wait_vsync`.
- Режимы FASTEST/FAST/NORMAL, клавиша **P** по кругу, дефолт FASTEST.
Условие боя — `Kid.sword == SWORD_2_DRAWN`, буквально как у оригинала.
## 10.2. Где стоят выборки и как они выбраны
Точки ставились **не на глаз, а по замеру**: детектор зазора между
выборками (порог 64 512) останавливает машину, адрес возврата со стека
называет виновника. Пять итераций «замерил → закрыл дыру → перемерил»:
| итерация | найденная дыра | тактов |
|---|---|---:|
| 1 | `kid_tick` + `pop_phys_tick` без выборок | 68 340 |
| 1 | весь циан, когда оба персонажа «тихие» | 100 044 |
| 2 | отрисовка персонажей идёт мимо `pop_blit_b` | 82 242 |
| 2 | полоса HP: `pop_kid_img_blit` в цикле | 86 586 |
| 3 | между двумя `pop_blit_b` — работа `pop_bg` | 75 000 |
| 4 | сам блит и сам heal (выборка была только НА ВХОДЕ) | 71 900 |
| 5 | `pop_loose_tick` | 73 990 |
Итог: выборки в `pop_blit_b` (вход и перед каждым `gfx_w0_unmap`),
`pop_heal_fast` (вход и выход), после каждого `gfx_set_bank(SPRITE)` в
`pop_cdraw/pop_kdraw/pop_room`, в 16 потайловых функциях `pop_bg`, после
зондов `pop_dbg_p1..p8` в физике и в 21 точке главного цикла.
## 10.3. Замеры
Метод точности счётчика — **атомарный снимок одной командой отладчика**:
`printf "%d %d", totalcycles, b@<адрес pop_frame_tick>`. Раздельные
`lmem` и `print totalcycles` НЕ ГОДЯТСЯ: между двумя обращениями к мосту
проходят десятки кадров, и «недосчёт» получается на ровном месте (на этом
я сначала и обжёгся).
| проверка | результат |
|---|---|
| счётчик, 11/15 покой, 5 окон | недосчёт **0** (270 растровых кадров) |
| счётчик, 11/15 тяжёлая позиция Кида | недосчёт **0** |
| счётчик, 13/23 | недосчёт **0** |
| период кадра, 11/15 | **ровно 3 растра**: ни длиннее 3,1, ни короче 2,9 на 302 логических кадрах |
| период кадра, 13/23 | **ровно 3 растра** на 308 логических (эталон был 3/4/5) |
| режим NORMAL | ровно 4 растра |
| NORMAL + `Kid.sword = 2` | ровно 5 растров |
| клавиша P | 0 → 1 → 2 → 0 |
| клавиатура | Кид отвечает на удержание и отпускание |
**Цена выборок** — A/B прямо в памяти (заглушить `pop_beam_sample`
байтом `C9` и снять `pace_ok`, чтобы игра не зависла в ожидании фронта):
работа за кадр 543 860 с выборками против 539 832 без — **≈4 000 тактов,
0,9 %**. На фоне бюджета, который вырос втрое, это ничто.
## 10.4. Грабли, стоившие времени
1. **Литералы в отладчике MAME шестнадцатеричные.** Порог «700000» на
деле проверял 0x700000 = 17 растров и не срабатывал никогда.
2. **Счёт попаданий брейкпоинтом на этом драйвере недостоверен** (WAIT-
линия, инструкция пересчитывается): наблюдались «попаданий больше, чем
растровых кадров». Достоверны только сравнения ВРЕМЁН.
3. **Раздельные чтения через мост не атомарны** (см. 10.3).
4. **Мёртвый Кид перезапускает уровень** раз в `RESPAWN_DELAY` тиков, а
рестарт уровня — это 3-5 растров без единой выборки. Полдня я гонялся
за «дырой в статике», которой не было: Кид успел убежать в соседнюю
комнату и погибнуть, пока я мерил. **Проверяй, что на экране, прежде
чем объяснять числа.**
5. **`make LEVEL=13` без `make clean` не пересобирает** — флаги в
зависимостях не участвуют, на диск уезжает старый уровень.
## 10.5. Что осталось
- Перенос примитива в libbgi — по решению пользователя ПОСЛЕ обкатки.
Там же уместнее взводить `_cbl_port_ref` напрямую, без обходного
`gfx_wait_vsync()` в `pop_pace_arm`.
- Загрузка уровня/комнаты остаётся без выборок (ESTEX-вызовы) — счётчик
там недосчитывает. Это безобидно: якорь переставляется по факту, и
ошибка живёт один кадр. Отдельного «resync» не потребовалось.
- Проверить на реальном железе, что `pace_ok` взводится (в MAME — да).
+367
View File
@@ -0,0 +1,367 @@
# От roomtest к полноценной игре — сценарий и оболочка
Статус: **частично реализовано; аудит обновлён 2026-08-24**. Ранее пометка
«завершены FG0–FG12» была неверной: для многих этапов уже есть код и
host-тесты, но их критерии приёмки на Sprinter ещё не выполнены. Фактический
статус каждого FG приведён в [§14](#14-этапы-реализации).
Этот документ описывает превращение текущего игрового цикла
`roomtest` в законченную игру: заставка, интро, демонстрационный уровень,
сцены между уровнями, таймер, финал и Hall of Fame. План меню и постоянных
настроек вынесен в [`menu_settings_plan.md`](menu_settings_plan.md),
детальный план QuickSave — в [`quicksave_plan.md`](quicksave_plan.md).
## 1. Зафиксированный scope
- Целевая последовательность — оригинальная SDLPoP/DOS PoP с уровнями
**1..14**. Уровень 14 — скрытая финальная часть после Джаффара: его номер
игроку не показывается, победа наступает в комнате 5.
- Уровень **15 удаляется полностью**: не пакуется на HDD, не загружается,
отсутствует в переходах, читах и UI; специальная логика potions/copy
protection level удаляется.
- Уровень **0** остаётся только демонстрационным (attract mode), а не частью
новой игры.
- Title sequence повторяет SDLPoP. Перед ней допускается отдельный
пропускаемый экран с информацией о Sprinter-сборке.
- Первая версия использует текущий профиль поведения `VANILLA`. Сейчас это
означает **существующую реализацию roomtest**, включая уже встроенные
исправления. Аудит и разведение `VANILLA/ENHANCED` — будущая задача.
- Программа работает **только с HDD**. Варианты без сохранения для floppy не
проектируются.
- Моды и выбор levelset в этот план не входят.
## 2. Что делает SDLPoP
Источники истины в локальном SDLPoP:
- `src/seg000.c`: `start_game()`, `show_title()`, demo mode, общий кадр,
проверка финала;
- `src/seg003.c`: `init_game()`, `play_level()`, `play_level_2()`;
- `src/seg001.c`: cutscene engine, `pv_scene()`, сцены 2/4/6/8/9/12,
`time_expired()`, `end_sequence()` и Hall of Fame;
- `src/data.h`: `tbl_cutscenes`, параметры уровня 0, win level/room;
- `data/TITLE`, `data/PV`, `data/LEVELS/res2000.bin`: ресурсы оболочки.
Штатный маршрут:
```text
boot
-> title / story screens
-> Princess + Jaffar intro
-> credits / Hall of Fame
-> demo level 0
-> title или новая игра
-> levels 1..14
before 2 -> princess cutscene
before 4 -> princess cutscene
before 6 -> princess cutscene
before 8 -> princess + mouse
before 9 -> princess + mouse
before 12 -> scene selected by remaining time
-> level 14, room 5
-> embrace + mouse
-> ending text/music
-> Hall of Fame
-> title
```
Кроме уровней, здесь есть глобальный 60-минутный таймер, сцена истечения
времени, пропуск сцен клавишей, fade/flash, ожидание музыки и возврат в
attract loop после демо или финала.
## 3. Текущее состояние roomtest
Уже реализованы игровой кадр, комнаты, уровни, тайлсеты, Kid/Guard/Shadow,
Джаффар, специальные события, checkpoint, переходы уровней, бесшовный
выход 12-го уровня, перенос максимального HP и звуковые эффекты.
Поверх игрового цикла уже добавлены автомат оболочки, title/story, demo
уровень 0, global timer, сценарный интерпретатор, level-flow, ending и Hall
of Fame. Полный маршрут также собирается в HDD-образ.
Однако это **не означает готовность оболочки**. На момент аудита остаются
существенные незакрытые места:
- lifecycle палитр: gameplay-переходы используют чёрный барьер без fade;
cold start и полный набор dungeon/palace переходов ещё не прошли приёмку;
- PV intro Princess/Jaffar уже покадровый (актёры, факелы, звёзды, часы,
молния и foreground-колонна); сцены перед 2/4/6 и длинной веткой 12
анимируют факелы, звёзды и песок, а сцены 8/9 и короткая ветка 12 пока
используют статические позы с исходной длительностью;
- demo отображается с игровой палитрой, проходит второй разворот/зацеп и
доходит до боя; после смерти Кида корректно завершает цикл;
- time-expired, ending и Hall of Fame имеют маршрут и реализацию UI, но не
прошли сквозную MAME-проверку вместе с ресурсами и возвратом к title;
- нет полного регресса EMM/FD для каждого перехода состояния.
## 4. Архитектура: автомат состояний приложения
Нельзя наращивать все режимы условиями внутри кадрового цикла. Текущий
цикл должен стать реализацией одного состояния `PLAYING`:
```text
BOOT -> BUILD_INFO -> TITLE -> INTRO -> DEMO
| |
+---- NEW_GAME <-+
NEW_GAME -> LEVEL_LOAD -> PLAYING <-> PAUSE_MENU
|
+-> CUTSCENE -> LEVEL_LOAD
+-> TIME_EXPIRED -> TITLE
+-> ENDING -> HALL_OF_FAME -> TITLE
```
Минимальный контекст оболочки:
```c
typedef enum {
POP_APP_BOOT,
POP_APP_BUILD_INFO,
POP_APP_TITLE,
POP_APP_INTRO,
POP_APP_DEMO,
POP_APP_LEVEL_LOAD,
POP_APP_PLAYING,
POP_APP_PAUSE_MENU,
POP_APP_CUTSCENE,
POP_APP_TIME_EXPIRED,
POP_APP_ENDING,
POP_APP_HALL_OF_FAME,
POP_APP_QUIT
} pop_app_state_t;
```
Переходы задаются результатом состояния, а не прямыми рекурсивными
вызовами наподобие SDLPoP `start_game()`/`longjmp()`. На Z80 это проще для
стека и позволяет освобождать ресурсы каждого режима в одном месте.
## 5. Ресурсная модель
Title и cutscene-ресурсы нельзя постоянно держать рядом с игровыми
атласами. Для каждого состояния нужен явный lifecycle:
```text
enter: pause sound -> unload incompatible set -> load set -> apply palette
run: process input/timer/animation
leave: stop sound -> release EMM pages -> clear transient state
```
Новые группы HDD:
```text
TITLE\ title/story images, palette, optional build-screen assets
PV\ princess room, Princess/Jaffar/mouse frames, palettes
MUSIC\ intro, cutscene and ending tracks/samples
LEVELS\ res2000..res2014.bin
```
Конкретный формат атласов выбирает упаковщик. Runtime не должен разбирать
PNG/DAT: как и игровые спрайты, он получает подготовленные `.atl`/`.bin`.
## 6. Экран Sprinter build
Отдельное состояние перед оригинальной заставкой:
```text
PRINCE OF PERSIA
SPRINTER SP2000 BUILD
version / date / build id
```
Требования:
- пропускается любой клавишей;
- выключается в Settings;
- не запускает музыку оригинального title и не меняет её тайминги;
- данные версии генерируются сборкой, а не правятся вручную в C;
- отсутствие экрана приводит прямо к `TITLE`.
## 7. Title и текстовая подсистема
Порядок переносится из `show_title()`:
1. основной титульный экран;
2. Presents;
3. название игры и Jordan Mechner;
4. story frame / “In the absence…”;
5. intro Princess + Jaffar;
6. story “Marry Jaffar…”;
7. credits;
8. Hall of Fame, если таблица непуста;
9. demo level 0.
Нужны общие примитивы: загрузить full-screen image, вывести строку,
показать экран заданное время, transition left-to-right, fade in/out,
прервать ожидание клавишей. Текст и меню должны использовать один renderer.
Критерий: последовательность и музыкальные точки совпадают с SDLPoP;
Sprinter build screen не сдвигает оригинальный soundtrack.
## 8. Demo level 0
- Добавить на HDD `res2000.bin`.
- Загружать уровень обычным loader, но выставлять demo HP и demo mode.
- Воспроизводить `demo_moves` как синтетический источник `control_*`.
- Пользовательский ввод прерывает демо и начинает новую игру.
- Достижение demo end room (у SDLPoP — 24), смерть или конец скрипта
возвращают в `TITLE`.
- Pause menu, QuickSave и cheats в demo недоступны.
- RNG демо и начальное состояние должны быть детерминированы.
Критерий: без ввода attract loop не требует перезапуска процесса;
title -> demo -> title повторяется неограниченно.
## 9. Глобальный таймер
Состояние: минуты, тики и флаг показа. Таймер создаётся при New Game,
переносится между уровнями и входит в QuickSave.
Правила `VANILLA`:
- на pause menu, загрузке HDD, QuickSave/QuickLoad время не идёт;
- игровые тики следуют темпу логического кадра, а не частоте render loop;
- поведение во время level-end sound и cutscenes сверяется буквально с
SDLPoP;
- после Джаффара/на финальном уровне время не должно вызвать поражение;
- ноль времени переводит приложение в `TIME_EXPIRED`.
Критерий: одинаковый игровой отрезок в NORMAL даёт то же уменьшение времени,
что SDLPoP; сохранение/загрузка не добавляет и не отнимает тики.
Реализация FG4 живёт одним модулем `roomtest/pop_timer.c` в bank 9:
`60:719`, 720 тиков на минуту, счёт только в живом игровом кадре. Settings
хранит `TIME LIMIT: 60 MIN / UNLIMITED` в `POP.CFG`; старый семибайтный v1
payload по-прежнему читается как `60 MIN`. Читы таймера повторяют SDLPoP,
но из-за занятого `+/-` используют F7 (−1 минута, не ниже одной) и F8
(+1 минута). Состояние входит в QuickSave v4.
## 10. Cutscene engine
Сцены SDLPoP состоят из небольшого набора повторяемых команд. Вместо набора
крупных C-функций нужен компактный интерпретатор:
```text
SET_ACTOR actor
SET_POS x,y,dir
START_SEQ seq
WAIT_FRAMES n
PLAY_SOUND id
WAIT_SOUND
SET_HOURGLASS frame
SET_SAND state
FLASH color,frames
FADE_IN / FADE_OUT
CLEAR_ACTOR actor
END
```
Скрипты — `const` в холодном банке или подготовленный бинарный ресурс.
Interpreter обязан:
- исполнять один шаг/кадр без блокирующих длинных циклов;
- поддерживать пропуск сцены;
- при пропуске выполнять cleanup и выходить в заранее заданное состояние;
- освобождать PV-ресурсы перед загрузкой игрового тайлсета;
- не разрешать pause menu/QuickSave внутри сцены.
Порядок переноса: intro, 2/6, 4, 8, 9, 12, time expired, ending. Сцена 12
выбирает короткий или обычный вариант по остатку времени.
## 11. Переходы между уровнями
Таблица сценария должна быть отдельна от таблиц механики уровня:
```c
typedef struct {
uint8_t level;
uint8_t pre_cutscene;
uint8_t show_level_number;
uint8_t ending_rule;
} pop_level_flow_t;
```
Особые правила:
- New Game начинает уровень 1;
- перед 2/4/6/8/9/12 запускается сцена;
- 12 -> 13 остаётся бесшовным;
- после победы над Джаффаром переход идёт в 14;
- номер 14 не показывается;
- вход в комнату 5 уровня 14 переводит в `ENDING`;
- значения больше 14 недопустимы и дают диагностическую ошибку, а не
попытку открыть файл.
## 12. Ending и Hall of Fame
Ending:
1. загрузить PV-набор;
2. встреча Kid и Princess;
3. объятие;
4. появление мыши;
5. ending music;
6. финальные story/title экраны;
7. переход в Hall of Fame.
Hall of Fame хранится на HDD в отдельном версионированном `POP.HOF`.
Сохраняются имя и результат; ввод имени использует тот же текстовый/UI слой.
Повреждённый или неизвестный формат означает пустую таблицу, но не мешает
запуску игры. После показа — возврат в `TITLE`.
## 13. Удаление уровня 15
Отдельный ранний этап, чтобы новый flow не наследовал лишний маршрут:
- убрать `res2015.bin` из `LVL_NUMS` и HDD image;
- заменить последний игровой уровень на 14;
- остановить Shift+L и прочую навигацию на 14;
- удалить `POP_POTIONS_LEVEL` и специальный половинный урон синих зелий;
- исключить copy protection из конфигурации и меню;
- добавить тест: после уровня 14 приложение входит в ending и никогда не
запрашивает `res2015.bin`.
## 14. Этапы реализации
Легенда аудита: **✓** — критерий этапа закрыт; **~** — код существует, но
критерий приёмки ещё не закрыт; **○** — не начат. Статус отражает состояние
исходников и последней MAME-проверки на 2026-08-24, а не только наличие
модуля в bank 9.
| этап | статус | результат и фактическое состояние | критерий приёмки |
|---|---|---|---|
| **FG0** | ✓ | `POP_LEVEL_LAST=14`, HDD содержит `res2000..res2014`; `t_flow` отвергает 15 | HDD не содержит res2015; переход выше 14 невозможен |
| **FG1** | ~ | автомат `pop_app` и `t_app` реализованы; сквозной ресурсный lifecycle и контроль EMM/FD ещё не измерены | старт/рестарт/выход проходят без рекурсии и утечки EMM |
| **FG2** | ✓ | QuickSave/QuickLoad с `POP.SAV` и `POP.BAK`; отдельно проверен в MAME 2026-08-22 | критерии `quicksave_plan.md`, включая POP.BAK |
| **FG3** | ✓ | pause menu, Settings, подтверждения и двойной буфер реализованы; меню проверялось в MAME; добавлены SDLPoP-звуки навигации и защита CBL вокруг полного redraw/файловых операций | Resume/Save/Load/Restart/Settings/Quit работают |
| **FG4** | ~ | `pop_timer`, настройка unlimited, F7/F8 и состояние QuickSave реализованы; есть host-тест, но нет буквального сравнения темпа со SDLPoP на всех переходах | совпадение с SDLPoP и корректный save/load |
| **FG5** | ~ | text/full-screen/fade примитивы есть; для входа в первый уровень и границ уровней выбран мгновенный чёрный барьер без fade: CBL и яркая новая палитра включаются только после подготовки обеих страниц; Level 1 проверен в MAME | тестовые экраны и переходы на Sprinter |
| **FG6** | ~ | title-ресурсы и порядок кадров реализованы; Enter на title и Esc на первом story в MAME переводят прямо в `FIRST_LEVEL`, минуя demo; полная cold-boot приёмка fade остаётся в FG5 | основной титул/Presents/название/Mechner идут в точном порядке `show_title()`; Enter/Space/Esc/стрелки прерывают ожидание; story/intro продолжит FG8 |
| **FG7** | ✓ | level 0, исходная таблица `demo_moves`, demo HP=4 и блокировка игрового UI реализованы; исправлены зеркалирование auto-control, боевой AI Кида и завершение после смерти; в MAME demo проходит разворот/зацеп, доходит до боя и возвращается в attract-цикл без повторного убийства | `res2000.bin`, исходная `demo_moves`, demo HP=4; бесконечный attract loop, любой ввод начинает чистую новую игру; Pause/QuickSave/читы/таймер отключены |
| **FG8** | ~ | data-driven interpreter и покадровый PV intro работают; в MAME проверены актёры, факелы, звёзды 1x1, часы/песок, palette-0 lightning и foreground-колонна; Enter/Esc переводят прямо в `FIRST_LEVEL`; временный темп 12,5 FPS и TODO точного pacing записаны в `impl_diff.md` | story/PV intro проходит, любой raw-ввод пропускает его без удержания EMM-страниц |
| **FG9** | ~ | `pop_flow` корректно маршрутизирует 2/4/6/8/9/12 и ветку <=5 минут (`t_flow`); 2/4/6 и длинная 12 уже обновляют часы, песок, факелы и звёзды каждые 5 кадров Sprinter; длительности всех веток сверены с SDLPoP: 2/4/6/12 — 2,6 с, 8 — 6,0 с, 9 — 7,2 с; входная клавиша gameplay/Shift+L поглощается до сцены, а новое нажатие делает skip; анимации мыши/Princess в 8/9 и разворот Princess в короткой 12 ещё статичны | таблица flow переводит в CUTSCENE ровно перед 2/4/6/8/9/12; scene 12 выбирает короткий вариант при <=5 минутах |
| **FG10** | ~ | переход TIME_EXPIRED и экран существуют, но это ещё статическая PV-стадия; сквозной MAME-маршрут не принят | PV-сцена истечения с пропуском, затем возврат на title/attract; новая игра сбрасывает таймер |
| **FG11** | ~ | room 5 уровня 14 переводит в ENDING (`t_flow`); объятие/мышь заменены статической стадией, полный маршрут не принят | room 5 уровня 14 переводит в ENDING; PV-финал и Hail-экран возвращают управление оболочке |
| **FG12** | ~ | версионированный `POP.HOF`, ввод имени и восстановление после повреждённого файла реализованы; нужна сквозная MAME-проверка ending → HOF → title | версионированный `POP.HOF`, ввод имени raw-клавиатурой, повреждённый файл = пустая таблица, затем title/attract |
## 15. Проверки
- Host-тест автомата: все допустимые переходы и отсутствие уровня 15.
- Host-тест cutscene interpreter на синтетическом скрипте и skip в каждой
ожидающей команде.
- Host-тест demo input: одинаковый seed даёт одинаковый поток управления.
- MAME: cold boot -> build info -> title -> demo -> title.
- MAME: новая игра -> принудительный переход по всем pre-level scenes.
- MAME: time expired и пропуск сцены.
- MAME: 13 -> 14 -> room 5 -> ending -> HOF -> title.
- Проверка EMM/FD до и после каждого состояния: число страниц и открытых
файлов возвращается к базовому.
- `make size-check`; крупный cold-код размещать в банках и отдельно следить
за лимитом 16 КБ каждого банка.
## 16. Не входит в план
- уровень 15 и copy protection;
- моды и выбор levelset;
- replay/recording;
- точная эмуляция SDL video/controller options;
- профиль ENHANCED и индивидуальные switches fixes.
+19 -4
View File
@@ -1,8 +1,23 @@
# Интерактивные объекты (кнопки/гейты/пики) + HP/смерть — ПОДРОБНЫЙ план
Статус: **план** (2026-07-20). Реализация — отдельной сессией. Документ
самодостаточный: рассчитан на старт «с чистого листа» (пустой контекст).
Всё сверено с `applications/PoP/SDLPoP/src/` и данными `res2001.bin`.
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Все фазы плана (P0 персистентный
> per-room `room_modif`, S пики, B кнопки+ворота) сделаны и играются:
> `roomtest/pop_trob.c` (trob-диспетчер, `LINKLOC`/`LINKMAP`, ворота, дверь
> уровня, факелы, зелья), `pop_map.c` (HP, смерть на пиках, урон падения),
> `pop_redraw.c` (пометки перерисовки вместо прямых блитов). Ограничения
> из §0 закрыты: тайлы персистентны, HP/смерть есть, loose обобщён в trob;
> L3-вверх (climb-up в комнату сверху) тоже сделан (`pop_leave_dir = 3`).
> Из §5 остаётся открытым только **переход на следующий уровень через дверь
> уровня** — он вынесен в `levels_plan.md`.
>
> **Документ оставлен как СПРАВОЧНИК**, а не как план: §1 (раскладка
> объектов уровня 1 по комнатам, декод связей кнопка→цель) и §2 (точные
> ссылки на механику SDLPoP) продолжают экономить время при отладке.
> Текущие задачи — `../roomtest/TASKS_OPEN.md`.
Составлен 2026-07-20. Документ самодостаточный: рассчитан на старт
«с чистого листа» (пустой контекст). Всё сверено с
`applications/PoP/SDLPoP/src/` и данными `res2001.bin`.
Правило проекта (см. `applications/PoP/CLAUDE.md`): **SDLPoP — источник истины**,
перед кодингом читать соответствующий код seg*.c, не гадать.
@@ -35,7 +50,7 @@ roomtest — живой прототип порта PoP: комната 1 уро
- `pop_room_link(room, side)` — связь (side 0=L,1=R,2=U,3=D; 0=нет).
- `pop_level_start_room/pos/dir()`.
- `pop_bg.c/.h` — отрисовка тайлов (порт seg008 draw_tile), fore-окклюзия над
Kid (`pop_fore_over_kid`, порт set_char_collision+redraw_at_char/char2),
Kid (`pop_fore_over_char`, порт set_char_collision+redraw_at_char/char2),
**loose-полы** (shake/bake/mob). `draw_tile` — статическая, знает
`draw_gate_back` (грань гейта из левой комнаты).
- `pop_kid.c/.h` — анимация Kid (интерпретатор seqtbl `play_seq`, порт seg006),
+142
View File
@@ -0,0 +1,142 @@
# План: модульные тесты движка roomtest под ucsim_z80
Обвязка общая — `testkit/` в корне репозитория (там же объяснение, почему
прогон именно под z80, а не хостовым gcc). Наборы лежат в
`../roomtest/tests-host/`.
Задача плана: **перестать чинить одно и то же дважды**. За два прогона
уровня 1 (2026-08-03) закрыто восемь корней, и часть из них — регрессии
соседней механики, внесённые предыдущим фиксом. Такие вещи ловятся тестом
за миллисекунды, а в MAME — часами ручного вождения Кида.
## Что уже есть
| набор | модуль | статус |
|-------|--------|--------|
| `t_geom` | `pop_geom.c` | 39 проверок, включая побитовую сверку asm-LCG с 32-битной формулой на 128 шагах |
`pop_geom.c` выбран первым, потому что не тянет ничего за собой. Дальше
начинаются швы.
## Фаза 1. Два шва (блокирует всё остальное)
### 1.1 Доступ к странице уровня
`pop_level.c` ходит по абсолютным адресам: `gfx_w0_map(lvl_page)`, затем
разыменование `(uint8_t *)(LVL_DATA_OFF + …)`. В тестовом бинаре это
обращение в никуда.
Нужен макрос `W0PTR(off)`:
- на таргете — `((uint8_t *)(off))`, то есть ровно как сейчас;
- в тестах — смещение в обычном массиве-подложке.
Правка механическая и компайл-таймовая, на размер продукта не влияет.
Заодно снимает магию абсолютных констант из тела функций.
Тестовая подложка должна уметь: загрузить синтетическую комнату (10×3
байта fg + mod) и целый синтетический уровень на 24 комнаты, чтобы
проверять межкомнатные вещи.
### 1.2 Журналирующий рендерер
Вместо `pop_bg.c`/`pop_cdraw.c` в тестовый бинарь линкуется модуль с теми
же прототипами, который **не рисует, а записывает вызовы**: какой тайл
помечен к перерисовке, каким кодом, с каким счётчиком страниц.
Это не обход проблемы, а самостоятельная ценность: `BUG-GATE-ANIM-1` был
ровно такой формы — ворота меняли состояние, но пометка на перерисовку не
ставилась. Проверяется утверждением, а не глазами.
Минимум, который надо перехватывать: `pop_set_redraw`,
`pop_set_redraw_above`, `pop_loose_mob_spawn`, `pop_gate_redraw`.
## Фаза 2. Регрессионные кейсы из `BUGS_CLOSED.md`
После швов `BUGS_CLOSED.md` превращается в готовую спецификацию: у каждой
записи есть симптом и ожидаемое поведение. Кандидаты, которые ловятся
логикой (без отрисовки и без железа):
| баг | что закрепить тестом |
|-----|----------------------|
| `BUG-LVLSTATE-1` | запись тайла переживает выход из комнаты |
| `BUG-RESPAWN-1` | рестарт уровня возвращает ВСЕ тайлы из эталонной копии |
| `BUG-RESPAWN-2` | рестарт возвращает таблицу стражей; убитый снова жив |
| `BUG-GATE-ANIM-1` | смена состояния ворот ставит пометку `POP_RD_GATE`; закрывающиеся — на обе страницы, открывающиеся — на одну |
| `BUG-COLL-1` | `check_collisions` сканирует ряд справа налево и выбирает НАИМЕНЬШУЮ занятую колонку |
| `BUG-STANDUP-1` | `bumped_floor` у трупа (`alive >= 0`) только выравнивает и не трогает последовательность |
| `BUG-DEATH-1` | `hitp_curr == 0` при живом Киде переводит его в «умирает» ровно один раз |
| `BUG-LOOSE-2` | кусок, начавший падать, долетает и кладёт щебень ПОСЛЕ смены комнаты |
| `BUG-CEIL-2` | loose-плита ряда 2 верхнего соседа живёт как «ряд −1» |
`BUG-LOOSE-2` стоит взять первым: он до сих пор помечен в `BUGS_OPEN.md`
как непроверенный именно потому, что гонку «уйти из комнаты раньше, чем
долетит плита» через мост MAME воспроизвести не удалось. На уровне логики
это несколько строк — заспавнить кусок, сменить комнату, тикать до
приземления, проверить щебень в данных уровня.
Не берутся (нужна картинка либо железо): `BUG-DOOR-CLIP`, `BUG-CEIL-1`,
`BUG-CEIL-3`, `BUG-OCCL-1`, `BUG-KBD-4`, `BUG-3`.
## Фаза 3. Сценарные тесты
Сейчас шаг кадра размазан по `main()` в `roomtest.c`. Вынести его в
`pop_frame_tick()` — тогда появляются тесты вида «поставить Кида в
известное состояние, скормить N тиков ввода, проверить итог»:
```
дано: комната 5, Кид на кнопке (0,6)
когда: 40 тиков без ввода
тогда: комната по-прежнему 5, Кид на полу ряда 2
```
Это тот самый BUG-STANDUP-1, который ловили потиковой трассой в MAME.
Ввод подаётся не через `kbd_raw_down()`, а через подменяемый источник —
это же даст возможность проигрывать записанные сценарии.
## Фаза 4. Дифф против SDLPoP
`SDLPoP/src/` лежит в дереве, собирается на хосте, и там **уже стоят
отладочные трассы** (`DBG kidobj tilepos=…` в seg008, `DBG make_loose_fall`
в seg007). Значит эталон можно заставить печатать потиковую трассу
автоматически.
Схема: общий формат скрипта ввода и общий формат трассы (тик, frame, x, y,
room, col, row, action, alive, hp). Гоняем обе реализации, диффим, первое
расхождение — номер тика и есть баг. Это ровно то, что делалось руками
через MAME, только бесплатно и повторяемо: `BUG-COLL-1` и `BUG-STANDUP-1`
такой дифф нашёл бы за секунды.
**Лицензия.** SDLPoP — GPLv3, правило подпроекта — читать и переписывать,
не линковать. Оракул обязан быть **отдельным исполняемым файлом**,
общающимся через файлы трасс, а не слинкованным с нашим кодом в один
бинарь.
Требование к детерминизму: сиды PRNG должны совпадать. У нас
`POP_PRANDOM_EXACT` даёт ту же последовательность, что в оригинале, и это
уже закреплено тестом `geom_lcg_matches_reference`.
## Чего эти тесты не поймают
Отрисовку, банки и W-окна, тайминги, клавиатуру — за этим остаётся MAME.
И отдельный класс: **баги порядка вызовов**. Свежий пример — окно
fore-клипа (`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто
рисует персонажа; когда порядок «Кид/страж» стал переменным, окно осталось
стражьим, и Кид нарисовался поверх передних столбов. Это не «функция
вернула не то», unit-тест такое не видит. Ловится инвариантом,
вкомпилированным в safe-сборку: «в момент `pop_fore_over_char` окно клипа
принадлежит Киду». Отдельный инструмент, дополняющий тесты.
## Порядок работ
1. Шов `W0PTR` + подложка уровня.
2. Журналирующий рендерер.
3. `BUG-LOOSE-2` — закрыть висящий вопрос.
4. Остальные кейсы из таблицы фазы 2.
5. `pop_frame_tick()` + сценарные тесты.
6. Дифф против SDLPoP.
Правило приёмки: тест не считается написанным, пока не проверен мутацией —
сломать проверяемое место и убедиться, что набор краснеет.
+91
View File
@@ -0,0 +1,91 @@
# Идеи и вопросы «на подумать» (PoP)
Не план работ, а список того, что осознанно отложено: каждая запись —
гипотеза с причиной, по которой её стоит проверить, и с тем, что мешает
сделать это прямо сейчас.
## Зелье «переворот экрана» (upside-down)
**Вопрос пользователя (2026-08-01).** Тайлы фона у нас лежат строками, а
кадры Кида/стражей — КОЛОНКАМИ (`transpose_cols` в `pop_pack_kid.py`, ради
бесплатного горизонтального зеркала). Значит вертикальный переворот для
персонажей заметно сложнее, чем для фона. Верно; но прежде чем это чинить,
надо знать три факта.
**Факт 1 — когда оно вообще нужно.** Зелье переворота — тип 4
(`proc_get_object`, `seg006.c:1885``toggle_upside()`). Скан всех уровней
по данным (`res200N.bin`, тайл 10 = зелье, тип в backtable): тип 4
встречается **впервые на уровне 9** (две склянки), и больше нигде. Тип 3
(перо, медленное падение) — уровень 7. То есть **до уровня 9 механика не
нужна вообще**, и «на первом этапе просто не реализовывать» — не компромисс,
а точное соответствие данным уровней 1..8.
**Факт 2 — что именно делает оригинал.** НЕ переворачивает спрайты.
`flip_screen` (`seg009.c:1042`) → `flip_not_ega` (`seg009.c:1023`) меняет
местами СТРОКИ готового offscreen-буфера (top↔bottom, порядок пикселей
внутри строки не трогает — это вертикальное зеркало, не поворот на 180°).
Вызывается вокруг отрисовки кадра целиком (`seg003.c:296..301`): перевернул
буфер → дорисовал → перевернул обратно. Так что в оригинале это
post-process всего экрана, и вопрос «как перевернуть колоночный спрайт»
там просто не возникает.
**Факт 3 — почему нам этот приём не подходит как есть.** У нас нет шага
«готовый offscreen → экран»: рисуем прямо в видеостраницу, а heal берёт фон
из ОЗУ-копии этой же страницы. Переворот всей страницы построчно — это
320×192 Б копирования КАЖДЫЙ кадр, что мимо бюджета на порядок.
**Варианты, которые надо будет взвесить (не сейчас):**
1. **Предпечённые перевёрнутые атласы.** Второй набор кадров
Кида/стража, перевёрнутый по вертикали ещё в `pop_pack_kid.py` (там уже
есть транспонирование — добавляется одной строкой). Рантайм: выбор
набора + зеркальная арифметика Y. Память: ещё ~28 страниц EMM при
бюджете ~3.3 МБ — не проблема. Похоже, самый дешёвый по тактам путь.
2. **Фон рисовать с обратным Y** — для row-major тайлов строка остаётся
непрерывным accel-прогоном, меняется только адрес назначения; цена —
вызов на строку вместо вызова на тайл. Померить, прежде чем закладывать.
3. **Аппаратная помощь** — до проектирования проверить, есть ли у
акселератора направление копирования «вниз» (обратный инкремент адреса);
если есть, вариант 1 может и не понадобиться. Смотреть
`docs/new/06-accel.md` и `docs/reference/accel_r.txt`.
**Почему не сейчас.** Уровни 1..8 этого не требуют, а к уровню 9 у нас уже
будет ответ на вопрос «сколько стоит кадр» (задачи CLIP-1/T-2) — без него
выбирать между вариантами выше бессмысленно.
## Заменить генератор псевдослучайных чисел
Сейчас стоит LCG оригинала, шаг на ассемблере (~1 020 тактов), бит-в-бит
совместимый с SDLPoP. Есть более дешёвые Z80-генераторы (86–148 тактов),
но потолок выигрыша — 2 814 тактов за кадр, 0.65 %, и он растворяется в
обёртках вызова. Тексты процедур, разбор качества и порядок действий —
`prng_alternatives.md`. Первый шаг там не про генератор: слить приведение
к диапазону в ту же asm-процедуру, чтобы на вызов был один `call`, а не три.
## Отключать мышь на время игры
**Гипотеза.** Мышь на Sprinter — источник прерываний (обёртки RST 30h,
см. memory `mouse_api`). Игре она не нужна вообще: управление —
raw-клавиатура (`<kbd_raw.h>`), которую мы и так забираем у DSS целиком.
Значит каждое мышиное прерывание за кадр — украденные такты в бюджете,
который у нас и без того занят на 86 %.
**Откуда взялось (2026-07-30).** При замере бюджета по 100 кадрам три
кадра выбились до 552–647 К тактов (1.28–1.51 кадра) при типичных 371 К.
Причиной оказалось движение мыши на ХОСТЕ: при неподвижной мыши 225
кадров подряд прошли без единого превышения. То есть эффект реальный и
измеримый, просто в тесте он был наведён извне.
**Что проверить.**
1. Есть ли у драйвера мыши (RST 30h) функция «выключить/включить» —
разобрать список из 14 обёрток; если нет явной, посмотреть, что делает
«hide cursor» и снимает ли она обработчик.
2. Сколько тактов реально стоит одно мышиное прерывание на нашем железе
(замер: breakpoint на входе ISR + totalcycles, при движении мыши).
3. Не ломает ли отключение выход в DSS: состояние обязано
восстанавливаться при `exit`, включая аварийный (atexit).
**Почему не сейчас.** Выигрыш проявляется только когда игрок реально
двигает мышью, то есть в норме его нет; а риск оставить систему без мыши
после выхода — заметный. Делать после того, как закроем стражей и
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
возможный возврат `pop_bg` в резидент `--w3`).
+649
View File
@@ -0,0 +1,649 @@
# Осознанные расхождения с SDLPoP
Правило подпроекта (`../CLAUDE.md`): расхождение нашей реализации с
`SDLPoP/src/` — по умолчанию **баг у нас**. Этот файл — список исключений:
мест, где мы сознательно сделали иначе, потому что платформа/ABI/бюджет
кадра требуют другого, а НАБЛЮДАЕМОЕ поведение обязано совпадать.
Формат записи: что делает оригинал → что делаем мы → почему → чем платим и
что проверять при регрессе. Если запись перестала быть верной (портировали
дословно, отказались от обхода) — удалять, а не оставлять «для истории»:
история в git.
---
## D-1. История флагов перекрытия у бокового шва: сдвиг вместо тега комнаты
**Файлы:** `roomtest/pop_map.c` (`pop_coll_shift`, `pop_coll_invalidate`,
`check_collisions`), `roomtest/roomtest.c` (`enter_room_side`).
**Связанный баг:** BUG-GATE-PASS-1 (`roomtest/BUGS_CLOSED.md`).
**Дата:** 2026-08-09.
### Как в оригинале
`check_collisions` (seg004:0004) вместе с `get_row_collision_data`
(seg004:0185) держит **10 слотов** флагов перекрытия и рядом —
**параллельный массив номера комнаты**:
```c
row_coll_flags_ptr[tile_col] = curr_flags; /* tile_col — колонка ВНУТРИ разрешённой комнаты (0..9) */
row_coll_room_ptr [tile_col] = curr_room; /* и номер этой комнаты */
...
for (short column = 9; column >= 0; --column) {
if (curr_row_coll_room[column] >= 0 &&
prev_coll_room[column] == curr_row_coll_room[column]) {
if ((prev_coll_flags[column] & 0x0F) == 0 &&
(curr_row_coll_flags[column] & 0x0F) != 0)
bump_col_left_of_wall = column;
...
```
Ключ слота — пара **(колонка в своей комнате, номер комнаты)**. Решётка
комнаты 8 и до перехода 8→6, и после лежит в слоте 9 с `room = 8`: история
переживает смену комнаты, переход флага 0→1 виден, `bumped()` срабатывает.
Комнату оригинал резолвит на лету через `find_room_of_tile` (seg006:005D),
никакого кэша всех комнат у него нет.
### Что делаем мы
Индекс — **колонка ОТРИСОВАННОЙ комнаты**, диапазон −2…11 (14 слотов,
`COLL_C0`/`COLL_N`/`COLL_IDX`), номер комнаты рядом не хранится. При смене
комнаты тот же физический тайл менял бы слот на ±10, поэтому раньше история
просто выбрасывалась (`pop_coll_invalidate``prev = 3` = «уже
перекрывал» → бампа нет). Именно это и был BUG-GATE-PASS-1.
Теперь при **боковом** переходе история не выбрасывается, а
**перенумеровывается**: `pop_coll_shift(∓10)` сдвигает `coll_curr`,
`coll_above`, `coll_below` на 10 слотов и заполняет освободившиеся
тройками. `enter_room_side` зовёт её сразу после `pop_map_set_edges`.
Корректность держится на том, что `check_leave` двигает `Char.x` ровно на
∓140 = 10 тайлов по 14 px, и координата грани (`pop_x_bump[col + …]`)
сдвигается на те же 140 вместе с габаритом Кида, — **сами флаги
инвариантны**, меняется только номер слота. Сдвигаются `curr/above/below`,
а не `prev`: `prev` на следующем кадре всё равно перезапишет
`move_coll_to_prev`, выбирая источник как раз из этих трёх.
Переходы **вверх/вниз** и все прочие входы в комнату (старт уровня,
респавн, чит-навигация) остаются на полной инвалидации: там колонки не
сдвигаются, но тайлы под ними принадлежат другой комнате — история
действительно недействительна.
### Почему не дословно (вариант A)
Дословный порт — 10 слотов + параллельный массив номера комнаты, индекс по
колонке разрешённой комнаты, бамп только при совпадении номеров; тогда
`pop_coll_invalidate` не нужен вовсе, история сама «не совпадает» там, где
колонка сменила комнату.
Не взяли по одной причине: **десяти слотов нам не хватит**. Оригинал
перебирает узкое окно вокруг Кида (от `col(char_x_left_coll) 1` до
`col(char_x_right_coll) + 2`), поэтому коллизии слотов у него практически
не случаются. Мы держим четырнадцать колонок (−2…11) — при узком окне это
не мешает, а вот в десять слотов колонки −2/−1 и 8/9 сядут поверх 8/9.
**Окно перебора с 2026-08-09 у нас такое же, как в оригинале** (было: все
четырнадцать колонок каждый кадр). Признак годности слота при этом не
массив номеров комнат, как у оригинала, а ГРАНИЦЫ окна — четыре байта,
которые `move_coll_to_prev` переносит в `prev` вместе с флагами; сравнение
идёт по пересечению двух окон. Очистки массивов нет вовсе, то есть это
дешевле оригинала, а смысл тот же (у него слот вне окна помечен
`row_coll_room = 1` и в цикл бампа не попадает). `check_chomped_flags`
тоже ограничен окном — иначе протухшие слоты дали бы фантомный перемол.
### Чем платим
- Расхождение структур: если в будущем понадобится знать, из какой комнаты
пришёл тайл конкретного слота, этого у нас нет — придётся идти в вариант A.
- Границы окна надо переносить везде, где переносятся флаги: `pop_coll_shift`
двигает и их, `move_coll_to_prev` снимает их в `prev`. Забыть один из
переносов = молча потерять или, наоборот, разрешить лишний бамп.
- Сдвиг работает только для чисто горизонтальных переходов на ровно 10
колонок. Любая будущая диагональ/иная ширина комнаты его сломает молча.
- `coll_last_row`: `pop_coll_invalidate` прячет прошлый ряд, чтобы
`pop_coll_shift` мог отменить инвалидацию. Порядок вызовов в
`enter_room_side` (сначала `pop_map_set_edges`, потом `pop_coll_shift`)
стал значимым.
### Что проверять при регрессе
Это сердце коллизии, вокруг которого разбирался BUG-SEAM-PINGPONG. После
любой правки здесь — прогон швов:
1. Уровень 1, комнаты 6 ↔ 8, закрытая решётка, **обе** стороны.
2. Оба режима подхода: мелким шагом (упереться) и с разбега (не пройти
насквозь).
3. Проверить, что пинг-понг у шва не вернулся (экран не перескакивает
туда-сюда на кадре бампа о ворота).
4. `make -C roomtest/tests-host` — наборы `t_wall`/`t_char` ходят по этой же
геометрии.
---
## D-2. Кнопка в шве: перерисовываем, хотя оригинал не перерисовывает
### Что делает оригинал
Тайл-«трансформер» (кнопка, ворота, пика) перерисовывается только если он в
ОТРИСОВАННОЙ комнате: `redraw_11h``redraw_tile_height`
`get_trob_pos_in_drawn_room` (seg007:0258), а та для `trob.room != drawn_room`
возвращает 30 — заведомо несуществующий tilepos, то есть «не рисовать».
Исключение сделано ровно одно — факелы (`animate_torch`, seg007:03CF, ветка
`trob.room == room_L && tilepos % 10 == 9`).
Кнопка соседа слева при этом ВЛИЯЕТ на картинку: `get_tile_to_draw`
(seg008:253) подменяет нажатый `tiles_15_opener` на `tiles_1_floor`, а
`load_leftroom` (seg008:360) кладёт результат в `leftroom_[row]`, откуда он
приходит в `draw_tile` как `tile_left`. У пола правая грань есть, у кнопки
нет — значит в оригинале нажатие кнопки, видимой через левый шов, меняет
картинку только при следующей ПОЛНОЙ отрисовке комнаты.
### Что делаем мы
Перерисовываем шов сразу: `seam_row_sig` (roomtest.c) подмешивает в сигнатуру
ряда бит «кнопка нажата» (`pop_doorlink2(mod) & 0x1F > 1`) для тайлов
`0x0F`/`0x06`, и change-driven редрой `pop_room_redraw_seam_left` срабатывает
на нём так же, как на openness ворот.
### Почему
Голый порт давал видимый залип (SEAM-BUTTON-STALE, roomtest/BUGS_CLOSED.md): кнопка
(1,9) комнаты 11 — она же (1,−1) комнаты 24 — оставалась нарисованной в том
состоянии, в каком была на входе в комнату, хотя связь срабатывала. Сигнатура
шва следила только за `room_modif`, а у кнопки `modif` — это ИНДЕКС LINKLOC,
константа уровня: нажатие живёт в `doorlinks2` и в сигнатуру не приходило
никогда. Добавить кнопку в сигнатуру — те же три сравнения на кадр, что уже
делались для ворот; воспроизводить артефакт оригинала смысла нет.
### Чем платим
- Резидент +200 Б (`_CODE` 24 739 → 24 939), куча W2 1795 → 1595 Б. Если
станет тесно — `seam_row_sig` переносится в банк 7 к
`pop_room_redraw_seam_left`, ценой одного трамплина на кадр.
- Сигнатура ряда стала разнотипной: для кнопки это булев бит, для остальных
тайлов — modif. Значения между собой не сравниваются (сравнивается только
ряд сам с собой), но при добавлении нового типа тайла в шов про это надо
помнить.
### Что проверять при регрессе
Уровень 5, кнопка нижних ворот комнаты 24 (она же (1,9) комнаты 11), оба
направления: нажать её из комнаты 11 и войти в 24; и наоборот — войти в 24
поверху и наступить на неё, стоя в шве. Картинка кнопки обязана совпадать
со статусом ворот в обоих случаях.
---
## Перо (медленное падение) ловит ТОЛЬКО Кида
**Оригинал** (`fall_accel`, seg006:057C): `is_feather_fall` — глобальный флаг,
и медленное падение достаётся ЛЮБОМУ персонажу, который окажется в `Char`,
пока эффект жив. То есть страж, сошедший с уступа в те же секунды, парит
вместе с Кидом, хотя зелье пил не он. SDLPoP считает это багом и чинит
опцией `fix_feather_fall_affects_guards`.
**Мы** берём поведение С ФИКСОМ: `pop_feather` проверяется вместе с
`Char.charid == CHARID_0_KID` — и в `fall_accel` (`pop_map.c`), и в опкоде
`JMP_IF_FEATHER` интерпретатора seqtbl (`pop_kid.c`), чтобы физика и анимация
не разъехались.
**Чем платим.** Сцена, где страж падает при живом пере, будет выглядеть иначе,
чем в DOS-оригинале (у нас он падает нормально, там — парит). На уровне 7,
единственном с этим зельем, такой сцены нет: зелье в комнате 1, стражи — в
других комнатах.
**Что проверять при регрессе.** Уровень 7: выпить зелье в комнате 1, тут же
столкнуть стража в провал — он обязан падать БЫСТРО, а Кид рядом — медленно.
---
## Синее зелье («−HP») не ставит свою вспышку
**Оригинал** (`proc_get_object`, seg006:1892): ветка `case 5` только глушит
звуки, играет `sound_13_kid_hurt` и ставит `hitp_delta`. Экран краснеет не
здесь, а общим механизмом «Кид ранен» (`flash_if_hurt`, seg003:0AFC).
**Мы** раньше ставили в этой ветке ещё и `pop_flash_*` (красную вспышку на 2
кадра) — то есть красили экран дважды: своей вспышкой и кадром урона.
Приведено к оригиналу: ветка правит только `hitp_delta`, краснеет `pop_kid_hurt`.
**Что проверять при регрессе.** Уровень 2, комната 13, зелье `(1,3)`: выпить —
HP убавляется на единицу, экран краснеет РОВНО один раз (без двойного строба).
---
## Переворот (зелье инверсии) применяется НА ГРАНИЦЕ КАДРА, а не мгновенно
**Оригинал** (`toggle_upside`, seg000:15E9): `upside_down = ~upside_down` и
`need_redraw_because_flipped = 1` — флаг переключается прямо в момент глотка,
то есть в середине кадра. Оригиналу это ничего не стоит: он ВСЕГДА рисует в
offscreen неперевёрнутым, а зеркалит только при выводе на экран
(`flip_screen` вокруг `copy_screen_rect`, seg000:939/946). Внутренние
координаты у него от переворота не зависят вообще.
**Мы** offscreen-буфера не имеем (две видеостраницы + теневая ОЗУ-копия на
каждую), поэтому рисуем зеркально сразу — переворот «зашит» в координаты
каждого слоя. Из-за этого момент переключения важен: зелье выпивается из
`play_seq`, то есть в СЕРЕДИНЕ кадра, и остаток кадра рисовался бы уже
зеркально поверх ещё неперевёрнутого фона. Хуже всего пламя факела — оно
ЗАПЕКАЕТСЯ в ОЗУ-копию (`pop_torch_draw`, у него нет heal: каждый следующий
кадр непрозрачно накрывает предыдущий). Кадр пламени, положенный в
зеркальную позицию на старом фоне, оставался там навсегда — по комнате
рассыпались лишние языки огня.
Поэтому у нас два флага: `pop_upside_want` (пишут зелье, смерть Кида, чит U)
и `pop_upside` (читают все слои отрисовки). Переключение — ровно одно место,
начало кадра, вместе с перерисовкой: главный цикл делает
`pop_upside = pop_upside_want` и зовёт `pop_flip_screen`.
Сама перерисовка при этом СОВПАДАЕТ с оригиналом: там на
`need_redraw_because_flipped` вызывается `redraw_screen(0)` — полная
отрисовка, а не отражение уже нарисованного. У нас то же самое —
`pop_flip_screen` рисует комнату заново (и получает чистый фон по
построению), а вторую страницу дабл-буфера отдаёт копией акселератора.
**Что проверять при регрессе.** Уровень 9: выпить зелёное зелье — картинка
переворачивается ровно один раз, лишних языков пламени по комнате нет. Чит
U даёт тот же результат (он идёт тем же путём).
---
## Окклюзия воротами: спрашиваем про рисуемого персонажа, а не жёстко про Кида
**Оригинал** (`draw_tile_fore`, seg008:0D15) первой строкой:
```c
if (tile_left == tiles_4_gate && Kid.curr_row == drawn_row &&
Kid.curr_col == drawn_col - 1 && Kid.room != room_R)
draw_gate_fore();
```
То есть бары ворот попадают в foretable — поверх всего нарисованного — когда
на тайле ворот стоит **именно Кид**. Это следствие устройства оригинала:
foretable ОДНА на весь проход тайлов, персонажи в неё уже добавлены, и
отдельного «переднего слоя на персонажа» там нет.
**Мы** ради скорости не рисуем foretable целиком, а возвращаем куски тайлов
поверх ТОЛЬКО в прямоугольнике персонажа (`pop_fore_over_char`, см. memory
`pop_fore_layer_cost`: полный проход стоил 78 % кадра). Проход идёт по
персонажу, значит и вопрос естественно задавать про него —
`pop_gate_over_char(ch)`, а не про глобального `Kid`.
**Чем платим.** Наш вариант — надмножество оригинального: страж (или тень),
стоящий в проёме ворот, у нас уходит ЗА решётку, а в оригинале остался бы
нарисованным поверх неё, пока на том же тайле нет Кида. Визуально это
правильнее, но формально расхождение. Обратной разницы нет: во всех случаях,
где оригинал рисует бары поверх, рисуем и мы.
**Что проверять при регрессе.** Уровень 10, комната 7, тайл (2,6): Кид,
стоящий в проёме ворот, виден ЗА прутьями. Решение покрыто хост-тестами
(`tests-host/t_char.c`, набор `char_gate_*`) — отрисовка в харнесс не
линкуется, поэтому проверяется предикат.
---
## Тень рисуется спрайтами КИДА, а не XOR-силуэтом
**Файлы:** `roomtest/pop_cdraw.c` (выбор набора атласов по `charid`/`frame`).
**Дата:** 2026-08-18 (решение принималось раньше, записано здесь).
**Оригинал** рисует тень тем же кадром Кида, но ДВАЖДЫ — вторым проходом со
сдвигом на один пиксель и через XOR. Получается тёмный силуэт с контуром,
а не «второй Кид».
**Мы** рисуем тень обычными спрайтами Кида, обычным блиттером — она выглядит
как Кид.
**Почему.** Приём оригинала — read-modify-write по уже нарисованному, а
читать данные из ВИДЕО-ОЗУ (там, где спрайты) на Sprinter нельзя: читается
только ОЗУ-копия. Блочный XOR у акселератора есть и работает как раз по
ОЗУ-копии, но с нашей прозрачностью он несовместим: прозрачность сделана
подавлением записи 0xFF, а XOR прозрачные пиксели тоже смешает — под него
нужен набор с прозрачным 0x00 (замер: memory `accel_block_ops`,
`sprinter_vram_transparency`). То есть «сделать как в оригинале» всё равно
упирается в отдельный набор спрайтов.
**Чем платим.** Тень визуально неотличима от Кида (уровни 4/5/6/12). На
механику не влияет: слот, окна `Char`, ИИ и коллизия у тени свои и от
картинки не зависят.
**План.** Отдельный АТЛАС ТЕНИ (готовый силуэт), а не воспроизведение
XOR-прохода: один набор спрайтов вместо второго пути блита. До тех пор
расхождение сознательное — багом не заводить.
**Что проверять при регрессе.** Уровень 6 комната 1: тень стоит слева
через провал, поза совпадает с позой Кида-в-стойке. Родственная запись —
«Слияние с тенью» ниже.
---
## Слияние с тенью: мигания Кида спрайтами тени нет
**Файлы:** `roomtest/guards.c` (`autocontrol_shadow_level12`),
`roomtest/pop_cdraw.c`.
**Дата:** 2026-08-13.
**Оригинал** (`draw_objtable_item`, seg008:20CA) во время вспышки слияния
(`united_with_shadow` считает 42 → 0) рисует КИДА как тень на чётных
значениях счётчика: тот же кадр уходит не обычным прозрачным блиттером, а
парой OR+XOR со сдвигом на пиксель. Получается мерцание «Кид/тень»
примерно полторы секунды.
**Мы** рисуем всё это время обычного Кида, а само событие обозначаем белой
вспышкой фона (`pop_flash_color = POP_FLASH_WHITE`, 18 кадров) — она в
оригинале тоже есть и ставится тем же кодом.
**Почему.** У нас Кид и соперник рисуются из РАЗНЫХ атласов своими
палитрами (`pop_cdraw.c`), а «тень» — это персонаж слота Guard с палитрой
комнаты; блиттеров OR/XOR в libbgi нет вовсе, прозрачность сделана
0xFF-подавлением записи. Воспроизвести эффект — значит завести Киду второй
набор спрайтов и второй путь блита ради 42 кадров за всю игру.
**Чем платим.** Момент слияния читается только по вспышке и по тому, что
тень исчезла, — без «двоящегося» силуэта. На механику не влияет: счётчик
`pop_united_shadow` тикает и уходит в −1 независимо от отрисовки, а от него
зависят и повторный подъём тени, и появление плит в комнатах 2/13.
**Что проверять при регрессе.** Уровень 12: после слияния экран белеет,
соперник пропал, HP-потолок вырос на единицу, тень в комнате 15 больше не
появляется. Логика покрыта `tests-host/t_shadow.c`.
---
## Отложенный старт падающих плит (уровень 13): фаза 0 у нас «не анимируется»
**Файлы:** `roomtest/pop_map.c` (`pop_check_fall_flo`, `pop_loose_tick`),
`roomtest/pop_trob.c` (`animate_loose`).
**Дата:** 2026-08-13.
**Оригинал** (`check_fall_flo`, seg000:1317) раздаёт шести плитам ряда 2
верхней комнаты модификатор `(prandom(0xFF) & 0x0F)`, то есть 0..−15, и
заводит на каждую trob. Фаза считает вверх, проходит ноль и дальше идёт
обычным отсчётом до провала — плита падает через `n + 11` кадров. Ноль там
безопасен: плиту держит в игре СПИСОК trob, а не значение модификатора.
**Мы** списка trob для loose текущей комнаты не держим — плита анимируется
ровно тогда, когда её фаза не ноль (`pop_loose_modif[pos] != 0`). Значит
счёт, дойдя до нуля, оборвался бы навсегда. Компенсируем двумя правками,
которые работают только в паре:
* тик перескакивает ноль (`if (m == 0) m = 1`);
* стартовое значение берётся на единицу «отрицательнее» (`n1`).
**Чем платим.** Ничем в наблюдаемом поведении: суммарная задержка остаётся
`n + 11` кадров, проверено арифметикой на обоих концах диапазона (n = 0 и
n = 15). Платим связностью — две правки в разных функциях, и убрать любую
одну нельзя.
**Что проверять при регрессе.** Уровень 13, вход в комнату 23 (она же
стартовая): плиты сверху сыплются ВРАЗНОБОЙ, а не разом и не «никогда».
Логика покрыта `tests-host/t_jaffar.c`
(`jaffar_negative_phase_counts_through_to_fall` и парный контроль на другом
уровне).
---
## Чит навигации по комнатам не запускает бесшовный переход уровня
**Файлы:** `roomtest/roomtest_cold.c` (`pop_dbg_roomnav`), `roomtest/roomtest.c`,
`roomtest/pop_state.c` (`pop_nav_hold`).
**Дата:** 2026-08-13.
**Оригинал** (`play_level_2`, seg000:0900) проверяет `Kid.room == 23` КАЖДЫЙ
кадр: уровень 12 кончается самим фактом присутствия Кида в комнате 23, двери
у него нет. Никакого «как он туда попал» там нет и быть не может —
телепорта между комнатами в игре 1989 года не существует.
**Мы** держим этот триггер, пока Кид попал в комнату ЧИТОМ навигации
(`+`/``), и отпускаем на первой же смене комнаты обычным ходом.
**Почему.** Чит перебирает комнаты ПО НОМЕРУ (1..24 с обёрткой), то есть
любой обход уровня 12 неизбежно наступает на 23-ю — и уровень молча
становится 13-м. Поймано на первом же прогоне 2026-08-13: проверяющий час
смотрел «комнату 20 уровня 12», которая на самом деле была комнатой 20
уровня 13, и сравнивал её с картой не того уровня. Комнату 23 уровня 12
читом не посмотреть в принципе. Это ровно та же болезнь чит-телепорта, что
BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
**Чем платим.** Ничем в игре: в обычном прохождении Кид входит в комнату 23
ногами, флаг снят, переход срабатывает как в оригинале. Расхождение видно
ТОЛЬКО при включённых читах.
**Что проверять при регрессе.** Уровень 12: пройти в комнату 23 ногами —
уровень меняется на 13-й без заставки и без сброса HP. Обойти уровень
читом `+` через 23-ю — уровень НЕ меняется.
---
## Чит «убить стража» (K) идёт через штатный путь смерти
**Файлы:** `roomtest/pop_guard.c` (`pop_guard_kill`).
**Дата:** 2026-08-13. Решение пользователя.
**Оригинал** (seg000:786) ставит `guardhp_delta = -guardhp_curr` И
`Guard.alive = 0`. А гейт события смерти в `play_guard` (seg006:1490)
требует `Char.alive < 0` — то есть у оригинала чит убивает стража В ОБХОД
`on_guard_killed`. На 13-м уровне это заметно: победа над Джафаром по читу
не ставит `leveldoor_open = 2`, и выход на 14-й не открывается.
**Мы** `Guard.alive` в чите не трогаем: применённая дельта обнуляет HP, и
`play_guard` сам переводит стража в «умирает», вызвав `on_guard_killed`
брызги, вспышка, флаг выхода. То есть чит даёт ровно «как будто убил Кид».
**Почему.** Отладочный прогон 13-го уровня иначе требует каждый раз честно
выигрывать бой с Джафаром (skill 9, 6 HP) — это дорого по времени, а
проверять надо совсем другое.
**Чем платим.** Ничем в игре: читы включаются флагом `pop_cheats`, в
релизной сборке они выключены. Расхождение наблюдаемо только с читами.
**Что проверять при регрессе.** Уровень 13: `K` на Джафаре → белая вспышка,
уход ВЛЕВО открывает дверь уровня. Честная победа в бою даёт то же самое.
---
## Страж, вытесненный за правый край комнаты и там убитый, не виден нигде
**Не расхождение, а особенность оригинала.** Записано, чтобы вопрос не
возникал повторно (спросил пользователь 2026-08-19: бой шёл в комнате 15,
Кид вытеснил стража вправо — из-за края торчал только меч, — убил его, и
труп не появился ни в комнате 15, ни в соседней справа).
**Почему так.** Три механизма складываются:
1. **комнату страж не менял.** Его физика работает только в полосе
`x ∈ [44, 211)` (`seg000:1254`, у нас то же условие в
`pop_guard_phys_tick`), поэтому своим ходом за край он не уходит —
Кид вытолкнул его туда толчком, а `Guard.room` остался прежним;
2. **мёртвый за Кидом не идёт.** Единственный способ сменить комнату —
`follow_guard` при переходе Кида, и первое же условие там
(`seg002:0346`) — `Guard.alive < 0 && Guard.sword == sword_2_drawn`,
то есть ЖИВОЙ и с вынутым мечом. Мёртвый уходит веткой `leave_guard`,
которая сохраняет его в **`Guard.room`** — в старую комнату. У нас
ровно это же условие, `pop_guard_cold.c` (`pop_guard_follow`);
3. **из соседней комнаты страж не рисуется.** Оригинал при
`Guard.room != drawn_room` просто ГАСИТ слот (`seg000:422`:
`Guard.direction = dir_56_none`). Механизм «видно из-за шва»
(`xpos_in_drawn_room`) работает для коллизий и для Кида, но стража из
чужой комнаты на экран не выводит.
Итог: труп приписан комнате, где страж стоял, а его `guards_x` — за
правым краем. При возврате в ту комнату он честно восстанавливается там
же, то есть за пределами видимого поля; в соседней комнате его нет,
потому что в её данных стража и не было.
**Живой страж в этой ситуации ведёт себя иначе** — при уходе Кида вправо
он идёт следом, если стоит достаточно близко к краю (`Guard.x >= 165`).
Это портировано и работает.
**Чего я НЕ проверял:** живьём в SDLPoP этот сценарий не воспроизводил —
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
край и добить.
## ГСЧ разведён по доменам (у оригинала он ОДИН)
**Оригинал.** `random_seed` один на всё: кладка стены, анимация тайлов,
броски боя, модификаторы падающих плит — всё тянет из одной
последовательности (`seg009` PRNG, 32-битный LCG). Поэтому в оригинале
бой воспроизводим вместе со всем остальным: тот же сид — тот же бой.
**У нас.** Сидов несколько: `pop_t_seed` (кладка, `pop_tile.h`),
`pop_fight_seed` (броски боя, `pop_guard.h`), отдельные у trob и loose.
Сам генератор тот же (`pop_prandom`), таблицы вероятностей —
побайтно те же, что в `data.h`.
**Чем платим.** Конкретный бой у нас и в SDLPoP разойдётся: порядок
бросков другой, значит блоки/удары лягут иначе. Статистически поведение
то же (те же вероятности, тот же генератор), но «сверить бой кадр в кадр
с SDLPoP» нельзя, и QuickSave обязан сохранять ВСЕ сиды, а не один.
**Что проверять при регрессе.** Если страж кажется сильнее/слабее
оригинала — сначала проверить не таблицы (они сверены), а **режим
скорости**: `fight_speed` у оригинала 100 мс, а в нашем FASTEST бой идёт
61,4 мс, то есть в реальном времени на 63 % быстрее, и на глаз это ровно
«страж давит сильнее». Режим NORMAL (дефолт) даёт 102,4 мс — см.
`frame_pacing_plan.md`.
## PV intro: единые 12,5 FPS вместо переменных 10/7,5/8,57 FPS
**Оригинал.** `proc_cutscene_frame()` двигает последовательности через
`cutscene_frame_time`: 6 тиков 60 Гц в начале, 8 после первой речи и 7 во
время заклинания. Это соответственно 10, 7,5 и примерно 8,57 FPS.
**У нас (осознанное временное отличие).** Один логический кадр PV держится
четыре физических кадра Sprinter: номинально 50/4 = 12,5 FPS. Молния живёт
на отдельной физической шкале и не растягивается этим делителем. Если полная
отрисовка пересечёт дополнительный фронт, реальная частота может упасть до
10 FPS — это допустимо на текущем этапе, но должно быть измерено.
**TODO.** Перевести PV-сцену на тот же anchor-based механизм точного темпа,
который gameplay использует через `pop_beam_sample/pop_pace_end`: измерять
число реально прошедших фронтов во время сборки кадра, держать период ровно
четыре фронта при укладывании в бюджет и явно учитывать overrun. После замера
можно вернуть точные переменные интервалы SDLPoP без накопления фазы.
## Межуровневые PV-сцены: сохранён реальный период 100 мс
Это правило не относится к временному темпу основного Princess/Jaffar intro
выше. `reset_cutscene()` SDLPoP задаёт для сцен перед уровнями период
6 кадров при 60 Гц, то есть 100 мс. На Sprinter тот же период получается
ровно как 5 кадров при 50 Гц.
Суммы вызовов `proc_cutscene_frame()` перенесены без изменения реального
времени: сцены 2/4/6 и обе ветки 12 содержат 26 логических кадров (130
физических, 2,6 с), сцена 8 — 60 (300, 6,0 с), сцена 9 — 72 (360, 7,2 с).
Fade in/out в эти числа не входят, как и в оригинале.
## Gameplay: загрузка уровней через чёрный cut, без fade
**Оригинал.** На границах игровых уровней использует fade out/in.
**У нас (решение пользователя 2026-08-24).** Вход в первый уровень и
переход между уровнями выполняются как `старый кадр -> чёрная палитра ->
подготовка -> новый кадр с новой палитрой`. Fade на этих двух маршрутах
отсутствует. Сюжетные title/story/PV переходы сохраняют собственные fade и
left-to-right эффекты.
Чёрная палитра устанавливается до любого HDD I/O. Загрузчики guard и
tileset сами физически правят отдельные цветовые слоты, поэтому после них
чёрный экран подтверждается повторно. Зеркальные атласы уровня 9 готовятся
до финального источника палитры. CBL открывается последним: старый порядок
`level_switch -> CBL open -> BIOS fade` давал скрежет повторяющейся половины
аппаратного буфера на входе в Level 1; после перестановки баг исчез в MAME.
## Тень: кайма силуэта не подкрашивается фоном
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
прозрачным блитом в x и «блиттером XOR» в x+1 (`draw_objtable_item`,
seg008.c:1600). XOR идёт по 24-битному RGB того, что УЖЕ на экране
(`blit_xor`, seg009.c:3190), поэтому там, где спрайт прозрачен в x, но
непрозрачен в x−1, цвет получается как `фон XOR цвет спрайта`.
**У нас.** Пакетный блит наложения на себя не умеет, поэтому результат
запечён в отдельный атлас (`toolchain/pop_pack_shadow.py`,
`docs/shadow_atlas_plan.md`). Запекать пришлось для КОНКРЕТНОГО фона, и
выбран чёрный: на нём `фон XOR цвет == цвет`, то есть запечка точна.
**Чем платим.** Ровно одним: **кайма в один пиксель по ЛЕВЫМ кромкам
силуэта** на НЕчёрном фоне. У оригинала она принимает оттенок фона, у нас
всегда «свой» цвет. Внутренность силуэта и правые кромки совпадают точно —
там первый проход уже закрасил пиксель, и от фона результат не зависит.
**Почему это приемлемо.** Тень бывает на четырёх уровнях, и почти всегда
на чёрном: у зеркала (ур. 4), в проёме (5), над пропастью (6), в бою (12).
**Что проверять при регрессе.** Если Тень окажется на светлом фоне и
кайма станет резать глаз — вариантов два: запечь второй набор под светлый
фон (ещё 32 страницы EMM) или считать эту кайму прозрачной (силуэт станет
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
## QuickSave/QuickLoad: лейбл печатается ДО дисковой операции, а не после
**Как в оригинале.** SDLPoP печатает `QUICKSAVE` / `NO QUICKSAVE` (и пару
для загрузки) уже ПО РЕЗУЛЬТАТУ операции — `process_quicksave` (seg000:497)
сначала делает save/load, потом зовёт `display_text_bottom` и ставит
`text_time_total = 24`. На PC это незаметно: файл пишется мгновенно.
**У нас.** `pop_qsave_process` заявляет строку ПЕРВЫМ действием, ещё до
`mem_alloc_pages`/ESTEX, через `pop_status_show_now()` — та печатает её
немедленно в ВИДИМУЮ страницу, не дожидаясь конца кадра. Отказ уже потом
переписывает строку на `NO QUICKSAVE`/`NO QUICKLOAD` обычной заявкой.
**Зачем.** Запись снимка на диск занимает доли секунды, и всё это время
игра стоит. При порядке оригинала игрок видел сначала необъяснённый фриз,
и только по его окончании — надпись, объясняющую то, что уже прошло.
Решение пользователя, 2026-08-25.
**Чем платим.** Строка успевает мигнуть даже там, где операция потом не
удалась: сначала `QUICKSAVE`, следом `NO QUICKSAVE`. На практике отказ —
редкость (нет места/диска), и «заявка → отказ» читается не хуже.
**Что проверять при регрессе.** Что после неудачной операции на экране
остаётся именно `NO QUICKSAVE`/`NO QUICKLOAD`, а не первая строка: отказ
идёт обычной заявкой и печатается кадровым проходом, то есть на кадр позже.
## Смерть Кида: ждём кнопку и перезапускаем УРОВЕНЬ, а не игру
**Как в оригинале.** `play_kid` (seg006:1383) печатает «Press Button to
Continue» с `text_time_total = 288`. Тик — это логический игровой кадр,
720 тиков = минута, то есть 12 тиков в секунду: 288 тиков = **24 секунды**.
Последние 72 тика (6 секунд) строка мигает с периодом 12 тиков, и на каждом
появлении играет звук 38. Дальше развилок ровно две:
* игрок молчит все 24 секунды — `draw_game_frame` (seg000:958) зовёт
`start_game()`, и игра начинается ЗАНОВО, с title, а не с уровня;
* игрок нажимает **Enter или Shift** (не любую клавишу!) — seg000:584
подменяет их на Ctrl+A: `if (rem_min != 0 && Kid.alive > 6 && (control_shift
|| key == SDL_SCANCODE_RETURN)) key = SDL_SCANCODE_A | WITH_CTRL;` — и
уровень перезапускается. Условия важны: время не должно быть исчерпано
(иначе отработал `expired()`), а `Kid.alive > 6` даёт трупу улечься.
**У нас.** Обе развилки сведены к одной: 24-секундного выхода в начало
игры нет вовсе, строка висит бессрочно (`MSG_HOLD`), а перезапускает уровень
ЛЮБАЯ кнопка, а не только Enter/Shift (решение пользователя).
`pop_start_level()` возвращает игрока на уровень. Место возрождения выбирает сам `pop_start_level` — на части
уровней это не старт, а пройденный чекпойнт. Esc за кнопку продолжения не
считается: он открывает pause menu. Логика ожидания живёт в банке
(`pop_dead_prompt`, pop_status.c) — резидент W1 переполнен.
**Зачем.** Решение пользователя, 2026-08-25: возврат к title после каждой
смерти в отладочной сборке съедает всё время прохода, а прежний вариант
(авто-респавн через 400 кадров либо стрелка вверх) не объяснял игроку, чего
от него ждут.
**Чем платим.** Двумя вещами. Первое: смерть больше не заканчивает
партию — счёт попыток фактически бесконечен, тогда как оригинал через 24
секунды бездействия отправляет в title. Второе: любая клавиша вместо
Enter/Shift означает, что случайное нажатие (например, ещё не отпущенная
после боя клавиша) перезапустит уровень — отсюда требование сперва отпустить
всё. Когда дойдёт до «настоящей» игры, обе развилки придётся выбирать
заново: вернуть таймер на 288 тиков со start_game и сузить клавиши до
Enter/Shift — либо оставить как есть уже осознанно.
**Что проверять при регрессе.** Нажатие принимается только после того, как
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
кадров — труп должен успеть лечь.
+428
View File
@@ -0,0 +1,428 @@
# L9-INVERT — план реализации зелья переворота (уровень 9)
Рабочий план, по которому задача делается с чистого контекста. Доска —
[`../roomtest/TASKS_OPEN.md#l9-invert`](../roomtest/TASKS_OPEN.md); правила
подпроекта — `../CLAUDE.md` (SDLPoP = источник истины, диагноз железа —
только артефактом).
## 0. Что и зачем
Зелья типа 4 на уровне 9 (комната 7 тайл `(1,7)`, комната 10 тайл `(0,4)`)
переворачивают картинку вверх ногами. В оригинале это `toggle_upside()`
(seg000:15E9): `upside_down = ~upside_down`, `need_redraw_because_flipped = 1`.
Больше зелье не делает НИЧЕГО — ни вспышки, ни урона (seg006:1885, ветка
`case 4`). Снимается: смертью Кида (seg000:1224, при `alive >= 0`) и стартом
уровня (seg003:38/188). Управление НЕ инвертируется. Второе зелье
переворачивает обратно.
Оригинал переворачивает готовый offscreen на выводе (`flip_screen` перед
копированием прямоугольников на экран, seg000:939/946). Нам этот путь
закрыт: страниц ровно две (`gfx_set_visible_page` → ESTEX $54 SELPAGE, бит 0),
рабочей третьей нет. Поэтому:
- **уже нарисованное** переворачиваем ОДИН РАЗ построчной копией
акселератора (решение пользователя 2026-08-12);
- **всё, что рисуется дальше**, рисуем зеркально: фон (row-major) — новыми
vflip-блитами, персонажи (column-major) — заранее подготовленными
зеркальными кадрами.
Полоса HP и лейбл комнаты живут в борту (`y = 194 + POP_YOFF`, поле —
`POP_PLAYFIELD_H = 192`) и не переворачиваются, как и в оригинале.
---
# Часть I — libbgi
## I.0 РАЗВЕДКА ЖЕЛЕЗА — ЗАКРЫТА 2026-08-12
**Оба факта подтверждены, ядро и обёртка написаны и проверены в MAME**
(`tests/pageflip`, 5/5 PASS — прямая копия, vflip, целость рамки, широкая
копия 320 = два прохода, неприкосновенность источника).
- **Р1 подтверждён**: Port_Y можно менять между read- и write-триггером,
если ПЕРЕД вторым OUT стоит STOP (`LD B,B`) — он разоружает accel, и fetch
immediate-операнда OUT уже безопасен. Приём не новый: ровно так работает
`_bgi_scroll_cols_raw` (ядро `gfx_scroll_v`), то есть он был в проде
задолго до этой задачи — указал пользователь. Значит и обходной путь
через ОЗУ-буфер не нужен;
- **Р2 подтверждён**: обе страницы адресуемы одновременно, копия между ними
идёт по разнице баз (`_gfx_addr_shadow_base` / `_gfx_addr_base`), строку
выбирает Port_Y.
Сделано: `libbgi/bgi256/_bgi_flip_rows_raw.c` (ядро) +
`libbgi/common/gfx_copy_page.c` (обёртка, режимы `GFX_COPY_DIRECT` /
`GFX_COPY_VFLIP`) + `tests/pageflip`. Пункты I.1 и I.3 ниже — ЗАКРЫТЫ этим
же коммитом; описание оставлено как контракт.
### Исходный текст разведки (для истории)
Два факта, на которых стоит вся схема, сейчас НЕ подтверждены артефактом.
Пока их нет, остальные пункты не начинать (memory `defer_unexplained_quirks`).
**Р1. Смена Port_Y между burst-чтением и burst-записью.** Схема требует:
армировать копию, `LD A,(HL)` (burst-чтение строки src), сменить Y,
`LD (DE),A` (burst-запись в другую строку). Между триггерами НЕЛЬЗЯ
выполнять инструкции, читающие ОПЕРАНД из памяти — fetch операнда
перезабивает буфер акселератора кодом (memory `accel_operand_fetch_retrigger`).
Значит:
- смена порта — только `out (c),a` (ED 79, регистровая форма); `out (#89),a`
(D3 89) читает immediate байт и УБЬЁТ буфер;
- новое значение Y — только из регистра (`ld a,d`), не `ld a,#n` и не из
памяти;
- `C` = 0x89 и оба значения Y должны лежать в регистрах ДО триггера чтения.
Проверка: `tests/accflip` по образцу `tests/accop` — заполнить строку
источника известным паттерном, скопировать в другую строку со сменой Y,
прочитать VRAM побайтно и сравнить. Отрицательный результат — стоп-сигнал:
переворот придётся делать через промежуточный ОЗУ-буфер (строка 320 Б), и
бюджет вырастет примерно вдвое.
**Р2. Адресация двух страниц в одном окне W3.** `gfx.h` утверждает, что
страница 1 «начинается на 320 байт дальше (0xC140)», но это комментарий, а не
замер. Нужно снять дампом: как адрес строки складывается из Port_Y и
смещения в окне, и видны ли обе страницы одновременно. От ответа зависит,
делается ли копия A→B одним проходом или через смену видеобанка.
Итог разведки записать в `libbgi/docs` (или `docs/new/06-accel.md`
дополнением) и в memory — это знание переиспользуется во всех будущих
экранных эффектах.
## I.1 Ядро копии с реверсом Y
`libbgi/bgi256/_bgi_copy_rows_raw.c` умеет только ИНКРЕМЕНТ Port_Y на строку
(`y0` + шаг вперёд; страйды патчатся SMC при входе). Нужен вариант, где
одна сторона идёт вниз, другая вверх.
- предпочтительно: новый leaf `_bgi_flip_rows_raw.c` (правило «1 функция =
1 модуль»), а не флаг в существующем — горячий путь блиттера трогать не
надо, и DCE не потащит лишнего в программы без переворота;
- вход тот же (`__sdcccall(1)`: src→HL, dst→DE, дальше стек), плюс
`y0_src` / `y0_dst` и признак направления;
- DI-окно — одна строка, как в остальных ядрах (арминг в каждой скобке:
в окне EI между чанками CBL-ISR армирует акселератор своим размером,
см. `docs/accel-fill-budget.md`).
## I.2 Публичные vflip-блиты для row-major — СДЕЛАНО 2026-08-12
Ассемблера не потребовалось вовсе: у row-major картинки вертикальное
зеркало — это порядок строк, а он задаётся ЗНАКОМ src-страйда, который
`_bgi_blit_rows_raw` и так принимает знаковым (`int sstride`) и патчит SMC
в adc-цепочку. Обёртки просто дают адрес ПОСЛЕДНЕЙ строки и `-stride`,
цена кадра та же, что у обычного блита.
Написаны `gfx_blit_noclip_vflip`, `gfx_blit_part_noclip_vflip`,
`gfx_blit_part_vflip`; `tests/pageflip` расширен до 8 проверок, все PASS.
Грабли, которые поймал тест: у клипающего варианта первая строка источника
обязана считаться от высоты ДО клипа (`h0`), иначе обрезка сверху экрана
сдвигает картинку — сначала формула брала уже укороченную `h`.
### Контракт (исходное описание)
Все row-major блиты приложения идут ровно через три функции (`pop_tile.c:281`,
`:283`, `:286`, `:325`, `:327`):
| есть | нужен vflip-двойник |
|---|---|
| `gfx_blit_noclip` | `gfx_blit_noclip_vflip` |
| `gfx_blit_part_noclip` | `gfx_blit_part_noclip_vflip` |
| `gfx_blit_part` (с клипом) | `gfx_blit_part_vflip` |
Семантика: `(x,y)` — левый ВЕРХНИЙ угол результата на экране (как у обычных
блитов), строки источника выводятся снизу вверх. Это позволяет вызывающему
не менять арифметику позиции, а только пересчитать `y` под поле.
`gfx_blit_cols*` (column-major) vflip-двойников НЕ получают: реверс идёт
внутри колонки, а accel копирует блок только вперёд. Для персонажей —
часть II.4.
`gfx_heal*` не трогаем: heal симметричен, он восстанавливает прямоугольник из
ОЗУ-копии по экранным координатам, а координаты вызывающий уже даёт
перевёрнутые.
Тест: `tests/blitvflip` — эталонный спрайт, побайтная сверка VRAM.
## I.3 Копия прямоугольника экран→экран
```c
#define GFX_COPY_DIRECT 0
#define GFX_COPY_VFLIP 1
void gfx_copy_rect(uint8_t src_page, int sx, int sy,
uint8_t dst_page, int dx, int dy,
uint16_t w, uint16_t h, uint8_t mode);
```
- `w > 256` режется на burst'ы вызывающим ядром (320 = 160+160 либо 256+64 —
выбрать по замеру, разницы в тактах на строку почти нет);
- **горизонтального флипа не будет**: accel копирует блок только вперёд,
побайтовый реверс дал бы 320 burst'ов на строку вместо двух. Если он
когда-нибудь понадобится — только через ОЗУ-буфер с CPU-реверсом;
- клип не нужен (вызывающий гарантирует границы), но проверка «прямоугольник
в пределах экрана» в safe-варианте библиотеки — обязательна.
Тест: `tests/pagecopy` — прямой режим и VFLIP, побайтная сверка.
## I.4 Чего в списке пользователя не хватало
1. **Размерный регресс**: после каждого шага `make size-check`; новые модули
не должны утянуть за собой рост в программах, которые их не зовут (проверка
на `examples/` и `tests/`).
2. **Обе сборки библиотеки** — fast и safe (`sprinter-cc --safe`): гарды
параметров в safe, критичные — в обеих.
3. **Документация**: `docs/sprite-api-design.md` (раздел про блиттер),
`docs/TODO.md`, справочник API; факты разведки I.0 — в `docs/new/06-accel.md`.
4. **IM2/CBL**: длинные серии коротких DI-окон не должны ломать кадровые
прерывания и клавиатуру — проверить `tests/kbdpoll`-сценарием после
внедрения (клавиатура вычерпывается idle-хуком, а переворот идёт вне
ожидания кадра).
5. **Порядок «страница ↔ ОЗУ-копия»**: копия должна идти банком
`GFX_BANK_NORMAL`, иначе перевёрнутый фон не попадёт в ОЗУ-копию и heal
начнёт восстанавливать старую картинку. Это ключевой инвариант всей
схемы — вынести отдельным утверждением в тест.
---
# Часть II — приложение (roomtest)
## II.1 Состояние и момент переключения — СДЕЛАНО 2026-08-12
Проверено в MAME (уровень 9, комната 7): нажатие чита переворачивает всю
комнату целиком за один кадр, повторное — возвращает. Персонажи и
анимируемые тайлы (факелы) пока рисуются НЕ зеркально — это шаги II.3/II.4.
Что появилось:
- `pop_upside` + `pop_upside_dirty` (порт `upside_down` и
`need_redraw_because_flipped`) в `pop_map.c`;
- ветка `case 4` в `pop_proc_get_object`: только тоггл, без вспышки и урона
(как seg006:1885);
- сброс в `pop_start_level` (seg003:38/188) и по смерти Кида (seg000:1224);
- `pop_flip_screen()` в банке 8: `gfx_copy_page(VFLIP)` в скрытую страницу →
показать её → `gfx_copy_page(DIRECT)` во вторую → инвалидация слотов
персонажей, метки «фон трогали» и полосы HP;
- **чит-клавиша U** — это ПОРТ, а не костыль: в оригинале переворот тоже
висит на чит-клавише (seg000:0793). Без неё эффект проверяется только
честным проходом уровня 9 до комнаты 7 — чит-телепорт до зелья не
дотягивается (оно на `(1,7)`, а навигация ставит Кида на нижний ряд).
Цена: резидент +602 Б (новые функции libbgi линкуются в него), куча
1707 → 1050 Б. Это аргумент за [MEM-COLD2](../roomtest/TASKS_OPEN.md#mem-cold2)
до начала II.4.
### Контракт (исходное описание шага)
- `pop_upside` (uint8_t) — рядом с `pop_feather` в `pop_map.c`, экспорт в
`pop_map.h`;
- тоггл — в `pop_proc_get_object`, ветка `potion_type == 4` (сейчас там TODO,
как было у пера). Ничего кроме тоггла: ни вспышки, ни звука — так в
оригинале;
- сброс: `pop_start_level` (уже переехал в `roomtest_cold.c`) и смерть Кида —
по образцу seg000:1224 (в `kid_phys`/там, где ловится `alive >= 0`);
- переключение (функция `pop_flip_screen()`, банк 8 — холодный код):
1. `gfx_copy_rect(front, …, back, …, GFX_COPY_VFLIP)` — скрытая страница
получает перевёрнутый чистый фон (accel читает ОЗУ-копию, персонажей в
ней нет);
2. флип страниц, чтобы игрок сразу увидел результат;
3. `gfx_copy_rect(new_front, …, new_back, …, GFX_COPY_DIRECT)` — вторая
страница дабл-буфера получает то же;
4. инвалидация: `pop_cd[].valid = 0` для обоих слотов, `pop_mirror_heal`
-состояние, `pop_hp_invalidate()`, метки «фон трогали» (`pop_cd_touch`)
на обе страницы, `seam_sig` — чтобы шов перерисовался.
## II.2 Геометрия — единая точка пересчёта
```c
/* pop_bg.h рядом с POP_YOFF/POP_PLAYFIELD_H */
#define POP_FLIP_Y(y, h) (2 * POP_YOFF + POP_PLAYFIELD_H - (y) - (h))
```
Применяется ТОЛЬКО к тому, что внутри поля. Правило: любая функция,
получающая экранный `y`, либо сама зеркалит его по флагу, либо принимает уже
зеркальный — смешивать нельзя, иначе получим двойной переворот (это самый
вероятный класс багов здесь). Решение: зеркалит ВЫЗЫВАЮЩИЙ, у примитивов
семантика не меняется.
## II.3 Фон — СДЕЛАНО 2026-08-12
Флаг проведён в ЕДИНСТВЕННУЮ точку — `pop_blit_b`/`blit_b_clip`
(`pop_tile.c`): позиция пересчитывается макросом `FLIP_TOP` (он один на весь
слой — дублировать нельзя, иначе двойной переворот), а блит выбирает
vflip-двойник. Тем самым зеркалятся и полная отрисовка комнаты, и точечные
редрои, и анимации trob, и fore-слой, и кладка — все они ходят через эту
функцию.
Тонкость клипованного пути: при зеркале обрезка СВЕРХУ экрана съедает
НИЖНИЕ строки источника, поэтому кусок пересчитывается как
`sy' = h - sy - dh`.
Проверено в MAME (уровень 9, чит U): комната перевёрнута целиком — пол
вверху, дверь уровня и кладка вверх ногами. Персонажи пока обычные (II.4).
Цена: три функции libbgi ушли в резидент, куча 1574 → 450 Б, поэтому тем же
заходом сделан **MEM-COLD2 п.1**`enter_room_side`/`enter_room` уехали в
банк 8 (куча вернулась к **1231 Б**, банк занят на 14.6%). Рабочие массивы
комнаты остались в `_DATA` и видны обеим половинам; `enter_room` обязана быть
`__banked` — её зовёт главный цикл.
### Контракт (исходное описание)
Одна точка: `pop_tile.c` (блит куска атласа) выбирает vflip-двойник по
`pop_upside` и пересчитывает `y`. Тогда автоматически попадают:
- полная отрисовка комнаты (`pop_room.c`);
- точечные редрои (`pop_redraw.c`: ворота, пики, кнопки, дверь уровня,
дрожащие плиты);
- анимации `pop_trob` (факелы, пузырьки зелий);
- fore-слой и оверлеи кромки, кладка стены (`pop_bg.c`);
- падающие куски loose (`pop_room.c`, `pop_loose_mob_*`).
Проверить отдельно: `wall_pattern` (кладка рисуется своей геометрией) и
оверлеи кромки — у них позиция считается от ряда, а не от `y` спрайта.
## II.4 Персонажи (column-major)
Нужны зеркальные кадры: 34 атласа, 228 563 Б (kid0..27 + sword, guard g0..g4).
- реверс колонки — через стек (`pop`/`push`), ~10 тактов номинала на байт;
- источник маппится в W0, приёмник — новая EMM-страница; на спрайт: прочитать
колонки в буфер W2 (максимальный кадр — 40×16 ≈ 640 Б), развернуть, писать
в приёмник (перемаппинг W0 на спрайт — один OUT);
- `pop_cdraw.c` выбирает набор атласов (оригинал/зеркальный) по `pop_upside`;
- **кэш ключуется по СТРАНИЦЕ атласа**, а не по кадру: страница = 8 кадров,
3-10 КБ, ~0.2-0.4 растрового кадра на переворот.
**Стратегия наполнения — ЛЕНИВЫЙ ПОСТРАНИЧНЫЙ** (решение пользователя
2026-08-12): страница переворачивается при первом обращении к ней в
перевёрнутом режиме. Реально в ходу 5-10 страниц из 34, и если игрок зелье не
трогал, не тратится ни такта, ни страницы.
**Время жизни кэша — ДО КОНЦА УРОВНЯ**: освобождаем страницы при смене
уровня, а не при снятии эффекта. На уровне 9 переворот случается минимум
дважды (второе зелье возвращает всё назад), плюс его снимает смерть Кида —
готовить пачку заново каждый раз было бы обидно.
После [UI-SPRITES](../roomtest/TASKS_OPEN.md#ui-sprites) страницы `kid27` и
`g0` станут чисто «полевыми» (деления HP уедут в константы резидента), и
исключений в кэше не останется — эту задачу логично сделать ДО II.4.
## II.5 Клип — ЧАСТЬ СДЕЛАНА 2026-08-12 (поле), остаётся clip_char
**Клип ПОЛЯ сделан.** Симптом (нашёл пользователь): после телепорта в
перевёрнутом виде ниже поля — мусор. Причина: спрайты, которые в обычном
виде торчат ВЫШЕ поля (полоса кладки у потолка, её режет `pop_t_clip_top`),
после отражения торчат НИЖЕ и лезут в борт, где живёт полоса HP.
Фикс в `blit_b_clip`: при перевороте режем по ОБЕИМ границам поля всегда, а
не по `pop_t_clip_top` — зеркальный спрайт может вылезти и там, где в
обычном виде клип не ставили. Плюс в `pop_blit_b` быстрый путь (без клипа)
теперь пропускает в клипующий всё, что выходит за поле.
Проверено в MAME: телепорт по комнатам в перевёрнутом виде — борта чистые,
комнаты рисуются зеркально (факелы, чомперы, пол). То есть **переходы
между комнатами в перевёрнутом виде работают**.
Осталось из этого пункта: `clip_char` (верх поля ↔ низ) и клип падающей
плиты — они про ПЕРСОНАЖЕЙ и падающие объекты, то есть идут вместе с II.4.
### Контракт (исходное описание)
- `clip_char` (`pop_map.c:2287`, таблица `y_clip[5] = {-60,3,66,129,192}`) —
режет верх спрайта по линии ряда; при перевороте линия становится нижней.
Симметрия сама не сойдётся: нужен зеркальный расчёт `y_clip` и обмен
«сверху/снизу» местами;
- то же для зеркала уровня 4 (`pop_map.c:2732`) — на уровне 9 зеркал нет, но
код общий, поэтому ветку надо хотя бы не сломать;
- `pop_clip_sprite` (`pop_room.c:1069`) — клип падающей плиты;
- `pop_room_clip_borders` — борта симметричны (28 сверху и снизу), но
проверить, что чистится именно поле.
## II.6 Проверка
- **хост-тест** (`tests-host`): арифметика `POP_FLIP_Y` и зеркальный `y_clip`
— сцена «Кид у верхней кромки» в обычном и перевёрнутом режиме даёт
симметричные значения клипа. Логику отрисовки хост-тесты не видят, поэтому
здесь проверяем только счёт;
- **MAME**: уровень 9, комната 7 — выпить зелье `(1,7)`, проверить: картинка
перевернулась целиком, Кид ходит и цепляется корректно, факелы анимируются
перевёрнуто, ворота/пики перерисовываются перевёрнуто, полоса HP осталась
внизу и не зеркальная; второе зелье (комната 10) возвращает как было;
смерть Кида снимает эффект;
- **регресс**: обычные уровни (1-8) не должны измениться ни на пиксель —
прогнать пару комнат и сверить скриншоты с прежними.
---
# Часть III — ENTER-ROOM-FAST — СДЕЛАНО 2026-08-12
Комната теперь рисуется ОДИН раз — в скрытую страницу, — тут же
показывается флипом, а вторая страница получает её `gfx_copy_page(DIRECT)`.
Процесс рисования тайлов больше не виден: игрок получает готовый кадр.
Проверено в MAME: старт уровня и чит-переход между комнатами, два
последовательных снимка идентичны (обе страницы синхронны, мерцания нет).
Побочно понадобилось: видимую страницу главный цикл больше не ведёт сам, а
перечитывает у железа в начале кадра (`front = gfx_get_visible_page()`) —
её меняют и вход в комнату, и переворот, причём оба из банка, где локальные
переменные `main` недоступны.
# Часть III (исходное описание)
Идея пользователя, самостоятельная ценность (сейчас при входе в комнату видно,
как рисуются тайлы): рисовать комнату ОДИН раз в скрытую страницу, показать её
флипом, а во вторую страницу залить `gfx_copy_rect(…, GFX_COPY_DIRECT)`.
- сейчас `enter_room_side` рисует комнату дважды (цикл `for (pg = 0; pg < 2)`)
— вторая отрисовка (~полмиллиона тактов) меняется на копию (~115-200 К);
- процесс рисования перестаёт быть виден: игрок видит только готовый кадр;
- делается тем же `gfx_copy_rect`, что и I.3, то есть ничего дополнительно
писать не надо.
Проверять отдельно: состояние ОЗУ-копии второй страницы после копии
(инвариант из I.4 п.5), метки «фон трогали», и что heal на второй странице
работает.
---
# Порядок работ
1. I.0 разведка (`tests/accflip`) → факты в docs + memory.
2. I.1 ядро + I.3 `gfx_copy_rect` (+ `tests/pagecopy`).
3. II.1 состояние и `pop_flip_screen` — уже можно посмотреть в MAME:
картинка обязана перевернуться целиком, дальше она «поедет» по мере
перерисовок (это ожидаемо на этом шаге).
4. Часть III (ENTER-ROOM-FAST) — дешёвый выигрыш на том же кирпиче,
заодно обкатывает копию в бою.
5. I.2 vflip-блиты (+ `tests/blitvflip`) → II.3 фон. После этого сцена
должна выглядеть правильно во всём, кроме персонажей.
6. UI-SPRITES (деления HP в резидент) → II.4 зеркальные кадры персонажей.
7. II.5 клип → II.6 проверка и регресс.
Критерий готовности каждого шага — артефакт (побайтный тест в MAME либо
скриншот сцены), а не «код написан».
# Риски
- **Р1 не подтвердится** (нельзя менять Y между триггерами) — переворот
пойдёт через ОЗУ-буфер строки, бюджет вырастет вдвое (всё ещё ≈0.5
логического кадра), схема в целом выживает;
- **двойной переворот координат** — самый вероятный баг: часть кода зеркалит
`y` сама, часть получает уже зеркальный. Лечится правилом из II.2 и тем,
что зеркалит только вызывающий;
- **кэш зеркальных кадров и память**: 34 страницы EMM в худшем случае; по
`sprinter_emm_budget` на старте свободно 215 — влезает, но проверить
фактический остаток на уровне 9 (там уже загружены атласы фона, Кида,
стража и уровень);
- **fore-слой поверх персонажа**: он рисуется по футпринту спрайта, а
футпринт после переворота другой — проверить именно на сцене с колоннами.
# Решения пользователя (2026-08-12)
1. **Кэш зеркальных кадров — ленивый постраничный**, наполняется по первому
обращению (см. II.4).
2. **Живёт до конца уровня**, освобождается при смене уровня.
3. **ENTER-ROOM-FAST делается сразу**, шагом 4 — на готовом `gfx_copy_rect`,
до того как на копию завяжется переворот.
Открытых вопросов не осталось: план исполняется как есть, новые развилки —
только если разведка I.0 даст отрицательный результат по Р1.
+532
View File
@@ -0,0 +1,532 @@
# roomtest — план v2: размер кода и раскладка по окнам/банкам/страницам
> **Замер 2026-08-08, после MEM-BANK2 (актуальная сборка, ALLOCS=3000).**
> `_CODE` 22 556 Б, данные 4 375, куча 4 301. Банки: 1 (`guards.c`) 2 289,
> 2 (`pop_bg.c`, ГОРЯЧАЯ половина фона) 5 872, 3 (`pop_map.c`) 8 483,
> 4 (`pop_cdraw.c`) 5 664, 5 (`pop_ctrl.c`) 2 040, 6 (`pop_trob.c`) 2 889,
> 7 (`pop_room.c`, ХОЛОДНАЯ половина фона) 6 035 — все из 16 384.
>
> Слой фона разрезан надвое по ЧАСТОТЕ ВЫЗОВА (`TASKS_CLOSED.md#mem-bank2`):
> общие листья — в резидент `pop_tile.c` (W1 замаплен всегда, таблицы видны
> обеим половинам), горячий fore-проход остался в банке 2, холодная отрисовка
> комнаты и точечные перерисовки уехали в банк 7. Стык — три тонкие
> `__banked`-обёртки, чтобы трамплин платил только холодный путь.
> **Банк 2: 90.4 % -> 35.8 %.** Резидент вырос на 2 КБ (листья) — куча
> 6 333 -> 4 301 Б, это плата за то, что таблицы должны быть видны из двух
> банков.
>
> Число банков дублируется: `--bank N=` в Makefile И `const uint8_t n_banks`
> в `roomtest.c`. Расхождение — не ошибка сборки, а зависание до первого
> кадра.
>
> **Замер 2026-08-01 (историческая отметка).** `_CODE` 25 119 Б, `_DATA` 3 709,
> куча ~2.4 КБ. Банки: 1 (`guards.c`) 1 896, 2 (`pop_bg.c`) 13 792,
> 3 (`pop_map.c`) 6 331, 4 (отрисовка стража) 2 236 — все из 16 384.
> **Резидента `--w3` больше нет**: отрисовка уехала в банк 2, и это сняло
> главное ограничение резидента (из банка его было не достать) — банк→банк
> работает, трамплин сохраняет страницу окна на стеке. Отрисовка стража
> вынесена из банка 2 в собственный банк 4, потому что банк 2 подошёл к
> потолку (16 021 из 16 384) — коммит `2f3e854`.
>
> **Свободного места в банке 2 теперь 10.5 КБ**, в банке 7 — 10.3 КБ; туда
> просятся чомперы, зеркало и второй тайлсет (palace). Прежде чем начинать
> `levels_plan.md` §3 — всё равно посчитать, куда это ляжет. Следующий
> свободный номер банка — 8, гранулярность — файл.
>
> Из плана ниже **не сделаны шаги 5 (данные: `room_modif`, `dl1/dl2`,
> `_kbdraw_down`; потенциал ~1.5 КБ) и 7 (дедуп `draw_tile`, отложен по
> решению пользователя)**.
Статус: **план для отдельной сессии**, составлен 2026-07-29 по свежему замеру.
Заменял `size_optimization_plan.md` (v1, 2026-07-21) — тот удалён 2026-08-01
как полностью перекрытый этим документом. Документ самодостаточный
— рассчитан на старт с пустого контекста.
Повод: перед стражами и боёвкой (новый код ~5–8 КБ) надо понять, куда он
поместится, и заранее развести код так, чтобы банкованные модули не упёрлись в
ограничения окна W3.
---
## СТАТУС ВЫПОЛНЕНИЯ (обновлено 2026-07-29, коммит ecf5ecf)
| Шаг | Статус | Факт |
|-----|--------|------|
| 1. `kid_data.h` → EMM-страница + `load_frame`/`cur_frame` | **сделан** | `_CODE` −3 247 Б; попутно найден и обойдён баг кодогенерации SDCC (см. ниже) |
| 2. `pop_geom.c` (дедуп геометрии + PRNG) | **сделан** | 41 Б `_CODE`, −11 Б W3; ценность — не байты, а bank-safe слой |
| 3. `pop_map` без вызовов графики | **сделан** (фазы 1a/1b) | через пометки перерисовки, см. ниже |
| 4. Разгрузка/перебалансировка W3 | **сделан** | резидент = `pop_bg` + `pop_gdraw` (отрисовка стража, 2026-07-29); W3 14 376 → 12 819 (свободно 3 565 Б) |
| 5. Данные (`room_modif`, `dl1/dl2`, `_kbdraw_down`) | не начат | потенциал ~1.5 КБ |
| 6. Контракт банка стражей + пробник | **пробник сделан** | `tests/w3bankgfx` — модель подтверждена в MAME, см. ниже |
| 7. Дедуп семейства `draw_tile` | отложен по решению пользователя | «мороки много, выгода не так велика» |
**Замер сейчас против замера §1:** `_CODE` 24 881 → 24 421, куча W2 2 076 → 2 592 Б,
W3-резидент 14 376 → 11 632 (свободно 2 008 → 4 752 Б). Сумма кода упала
на ~3.2 КБ (данные Kid уехали в EMM), остальное — перераспределение.
**Замер 2026-07-29 (после стража).** Появление стража съело кучу до 805 Б;
разгрузка — вынос ОТРИСОВКИ стража в резидент (`pop_gdraw.c`, `--w3`), логика
и состояние остались в W1/W2, чтобы банк `guards.c` их видел (R2). Итог:
`_CODE` 26 149 → 25 703, куча **805 → 1 245 Б**, W3-резидент 11 656 → 12 819
(свободно 4 728 → 3 565 Б), банк 1 — 236 / 16 384 Б. Граница «что резидент»
теперь формулируется одним правилом: **резидент = только то, что рисует и
зовётся исключительно из главного цикла**; всё, что может понадобиться банку,
остаётся в W1/W2.
### Что сделано вместо §5.3 (вынос loose в W3)
Вместо переноса кода между окнами выбран (по обсуждению с пользователем)
**порт архитектуры оригинала**: логика ставит пометку, отрисовка идёт
отдельным проходом — `set_redraw_*` (seg007) + `redraw_needed` (seg008:0178).
Появился `pop_redraw.c/.h`; `pop_trob` и `pop_map` больше не рисуют тайлы.
Наши самодельные счётчики (`spike_rest`, `button_rest`, `ldoor_rest`,
`loose_bake`, `loose_rest`, `ceil_rest`, `ceil_bake`, `land_bake`) удалены —
их роль (вторая страница дабл-буфера) взял счётчик страниц в пометке.
**Два исключения остались** (обе — функции ТОЛЬКО главного цикла, звать из
банка нельзя):
- `pop_process_trobs` — пламя факела и пузырёк зелья (покадровый оверлей);
- `pop_loose_tick` — падающий кусок (mob): spawn/tick/pos. В оригинале это
отдельная подсистема (`mobs` + `draw_moving`), разделение на логику и
отрисовку — задел следующей фазы.
### Пробник банка (2026-07-29): модель ПОДТВЕРЖДЕНА
`tests/w3bankgfx` (huge + `--w3 res.c` + `--bank 1=bank1.c`, графика 256):
- банк рисует примитивом libbgi НАПРЯМУЮ — работает; страница W3 внутри
банка до блита, после блита и после возврата из вызванной им W1/W2-функции
одна и та же (0xF0), резидент — 0xF3. То есть `_bgi_begin`/`_bgi_end`
корректно возвращают ИМЕННО банковую страницу (правило R4);
- вызов W1/W2-функции из банка работает, и она тоже может рисовать;
- резидент W3 жив и вызывается после возврата из банка (R3).
**Дополнительно выяснено (важно для стражей):** писучие статики
`__banked`-модуля линкуются В СТРАНИЦУ БАНКА (0x1C000+) — снаружи их не
прочитать, из W1/W2 по 0xC000 видна резидентная страница. Значит всё
состояние банкованного кода (позиции стражей, таймеры боя) обязано жить в
W1/W2 как обычные глобалы, а банк — только код.
Ещё одна мина, найденная там же: инлайновый `in a,(#0xE2)` посреди тела
функции затирает A, куда SDCC уже положил параметр (у нас из-за этого цвет
заливки стал номером страницы, и «резидент не рисовал»). Читать порт
отдельной `__naked`-функцией.
### Найденная по дороге ловушка компилятора
`(const T *)КОНСТАНТА + var*K` SDCC 4.5 может собрать неверно: умножение
делает в 16 битах, а потом берёт только младший байт (`ld c,l` / `inc b`).
Кадры Kid с индексом ≥ 52 читались из чужой строки таблицы, у бега/шага
пропадал `FRAME_NEEDS_FLOOR` и персонаж проваливался сквозь пол. Лечение —
считать адрес в `uint16_t` и кастовать один раз. Тот же паттерн в
`pop_level.c` компилируется ПРАВИЛЬНО, т.е. полагаться на «у соседа
работает» нельзя. Подробности: memory `sdcc_z80_const_ptr_index_bug`.
---
## 0. Что уже сделано из v1 (не повторять)
- `--opt-code-size` и `--max-allocs 100000` **уже включены по умолчанию** в
`bin/sprinter-cc` (v1 §2.1 закрыт, выигрыш получен).
- Лишние блиты переднего слоя убраны (v1 §7 п.0): `fore_tile` больше не рисует
`bottom_id`, `_CODE` 388 Б.
- `gfx_blit_noclip` в libbgi (v1 §8 шаг 1): фоновые блиты в 2.9× дешевле.
- `--w3` как резидент окна 3 реализован и обкатан (memory `w3_resident_code`).
---
## 1. ЗАМЕР (сборка 2026-07-29, коммит 1214785)
Команда: `--memory small --gfx 256 --w3 pop_trob.c pop_map.c --w3 pop_bg.c`.
### 1.1 Окна
| Область | Занято | Свободно | Примечание |
|---|---|---|---|
| W1+W2 `_CODE` | 24 881 Б | — | 0x4100…0xA231 |
| W1+W2 `_HOME`+`_GSINIT`+`_DATA`+`_BSS` | ~4 240 Б | — | до 0xB2E4 |
| **W1+W2 куча** | 0 (никто не malloc'ит) | **2 076 Б** | 0xB2E4…0xBB00 |
| W1+W2 стек | — | 1 279 Б | 0xBB00…0xBFFE |
| **W3 резидент** | 14 376 Б | **2 008 Б** | 0xC000…0xF828 |
| EMM-страницы | 37 атласов + 1 уровень | ~215 страниц свободно | `sprinter_emm_budget` |
**Итого запаса до стены: ≈ 4 КБ** (2 КБ в W1/W2 + 2 КБ в W3). Стражи туда
не влезут.
### 1.2 Код по модулям (точно, из `.rel`)
```
W1/W2 (_CODE 24 881): W3 резидент (_W3CODE 14 376):
pop_kid 6 963 pop_bg 11 643
pop_map 6 157 pop_trob 2 733
roomtest 2 583
pop_level 1 425
pop_ctrl 1 145
crt0 333
libc+libbgi ~6 275
```
### 1.3 Крупнейшие функции/данные (из `.lst`)
```
pop_bg : draw_tile 3080, other_overlay_tile 1146, wall_pattern 944,
mob_render 720, mob_tick_one 661, overlay_mid_tile 498,
fore_only_tile 409, climb_overlay_tile 391, tile_table 371
pop_map : check_loose_fall_on_kid 674, check_bumped 567, jump_up_or_grab 413,
get_tile 266, do_knock 243, check_press 238, check_leave 212
pop_kid : kid_seqtbl 2310 + kid_frames 1205 + kid_seq_off 230 = 3745 Б ДАННЫХ
(в _CODE!), собственно кода ~3.2 КБ
roomtest : enter_room+main ~2.1 КБ
pop_level: room_bg_ptr 1084 (+ 515 Б таблиц LINKLOC/LINKMAP в _DATA)
```
### 1.4 `_DATA` (3 710 Б)
```
pop_trob 963 (room_modif[24][30] = 720 + trobs + rest-массивы)
pop_level 515 (копии LINKLOC/LINKMAP уровня)
pop_map 163, roomtest 141, pop_kid 130, pop_bg 115, pop_ctrl 13
libc: _irq_state 818, _kbdraw_state 515, _gfx_pal_buf 256, прочее ~200
```
### 1.5 Находки замера (мелкие, но чинить)
1. **`--w3` берёт ОДИН файл на флаг.** В `Makefile` написано
`--w3 pop_trob.c pop_map.c --w3 pop_bg.c`, и это значит «W3 = pop_trob и
pop_bg», а `pop_map.c` компилируется как обычный исходник в W1/W2. Судя по
`.sprinter-cc-roomtest/w3_pop_map.rel` (устаревший артефакт), когда-то
pop_map был в W3. **Решить осознанно** (см. §4) и записать явно:
`--w3 pop_trob.c --w3 pop_bg.c`.
2. `libc` тянет `_irq_state` 818 Б + `_kbdraw_state` 515 Б в `_DATA`.
`__irq_vec_buf` (513 Б) — таблица векторов IM2; `__kbdraw_down` (512 Б) —
битмап клавиш на 512 скан-кодов. Оба можно ужать (см. §5.4), это ~0.7 КБ
в самом дефицитном окне.
---
## 2. ПРАВИЛА ПЛАТФОРМЫ, ОТ КОТОРЫХ ПЛЯШЕТ РАСКЛАДКА
Это главное, что изменилось по сравнению с v1: модель банкинга уточнена по
`bin/sprinter-cc` (справка `--w3`/`--bank`) и по коду libbgi.
**(R1) Резидент W3 (`--w3`) и банки W3 (`--bank`) делят одно окно.**
Резидент лежит на своей странице 0xC000…0xFFFF; трамплин на время вызова
`__banked` подменяет страницу W3 на банковую и возвращает резидентную назад.
**(R2) Из банка резидент W3 НЕДОСТИЖИМ — и транзитивно тоже.**
Пока исполняется банк, резидентной страницы в адресном пространстве нет.
Значит нельзя не только `bank → pop_bg()`, но и `bank → pop_map() → pop_bg()`.
**Это ключевое ограничение при выборе, что делать банком.**
**(R3) Резидент → банк работает** (через трамплин в W1), резидент → W1/W2 —
тоже.
**(R4) Графические примитивы libbgi звать можно откуда угодно.**
`_bgi_begin` читает текущую страницу W3 из порта 0xE2, а `_bgi_end` её
возвращает — то есть скобка корректна и из банка, и из резидента. Нельзя
только одно: **звать `_bgi_begin`/`_bgi_end` ИЗ кода, который сам лежит в W3**
(после подмены страницы исчезнет исполняемый код — проверено, белый экран).
Поэтому `pop_bg` (резидент W3) обязан пользоваться готовыми примитивами
(`gfx_blit*`, `bar`, …), а батчинг скобки на весь `draw_tile` (v1 §8 шаг 1)
для него **невозможен** без переноса самого `draw_tile` в W1/W2.
**(R5) `--w3` кладёт в W3 код И rodata модуля** (`--codeseg/--constseg
W3CODE`), а писучие статики оставляет в `_DATA` (W2). То есть `const`-таблицы
переносятся в W3 бесплатно вместе с модулем (так уже лежит `tile_table` 371 Б).
**(R6) Данные в EMM-странице читаются, только пока страница в окне.**
`gfx_w0_map(page)` / `gfx_w0_unmap()` — окно W0 (0x0000…0x3FFF), первые 0x100
занимает ISR-стаб. Так уже работает `pop_level`. Цена — пара `OUT` на
маппинг, поэтому годится для «пачками», а не для чтения по байту в горячем
цикле.
---
## 3. ЧТО ДЕЛАТЬ НЕЛЬЗЯ (анти-паттерны, чтобы не потерять время)
- **Нельзя банковать `pop_bg`.** Он вызывается из pop_map, pop_trob, roomtest,
pop_kid — то есть из главного цикла на каждом кадре; плюс он сам держит
`tile_table` и всю отрисовку. Банк дал бы трамплин на каждый блит.
- **Нельзя банковать `pop_map`, пока `pop_map` зовёт `pop_bg`** (R2). Сейчас
зовёт: `pop_loose_tick` и компания (~30 вызовов графики).
- **Нельзя тащить `kid_frames` в EMM «в лоб»**: он читается несколько раз за
кадр из коллизии (`kid_cur_dx`/`kid_cur_flags``dx_weight`,
`char_x_forward_edge`, …). Нужен кэш кадра (см. §5.1) — иначе маппинг
страницы окажется в горячем пути.
- **Нельзя «причёсывать» семейство `draw_tile` ради экономии, не имея
пиксельного теста.** Мы неделю выравнивали слои по SDLPoP; любой рефактор
этой зоны проверять диффом страниц (заморозка кадра клавишей `1` + сравнение
VRAM обеих страниц, приём из memory `mame_mcp_bridge`).
---
## 4. ЦЕЛЕВАЯ РАСКЛАДКА
Принцип: **W3-резидент = «толстая графика, которую зовёт только главный цикл»;
W1/W2 = ядро, которое должно быть достижимо ОТОВСЮДУ (включая банки); банки =
новая холодная логика (стражи, боёвка, будущие уровни)**.
```
W1/W2 (всегда отображено) W3 резидент (стр. 0xC000) Банки W3
────────────────────────── ───────────────────────── ─────────
libc + libbgi pop_bg (отрисовка тайлов) guards.c
pop_kid (интерпретатор+рисование) pop_trob (анимации тайлов) fight.c
pop_map (коллизия/физика/предметы) pop_loose.c (loose+потолок) debug/roomnav
pop_geom (общая геометрия/тайлы) enter_room-часть roomtest?
pop_ctrl (ввод/диспетчер)
roomtest (главный цикл)
```
Почему так:
- **`pop_map` остаётся в W1/W2** — его зовут и главный цикл, и (в будущем)
банк стражей; в W3 его класть нельзя именно из-за R2. Для этого из него надо
вынести графическую часть (loose/потолок) — она уезжает в W3 к `pop_bg`
(§5.3). После выноса `pop_map` становится **чистой логикой без единого
вызова графики** — тот самый bank-safe API.
- **`pop_kid` остаётся в W1/W2**: `play_seq`/`kid_set_seq`/`Kid` нужны и
стражам (у стражей ТА ЖЕ seqtbl), а `kid_draw` зовёт только libbgi (R4).
- **`pop_trob` остаётся резидентом**: его зовёт только главный цикл, и он сам
зовёт `pop_bg` — идеальный житель W3.
- **Банк стражей не зовёт ничего из W3.** Рисование стражей — либо через
libbgi напрямую (R4), либо (лучше) резидентный `guard_draw()` в W1/W2 рядом
с `kid_draw`, а банк только считает состояние. Тот же приём мы уже
используем для `pop_item_taken`/`pop_loose_fell`/`pop_ceil_fell`: банк
выставляет флаг — резидент рисует.
---
## 5. ПЛАН РАБОТ
Порядок выбран так, чтобы каждый шаг был проверяем отдельно и давал место
следующему.
### Шаг 1. `kid_data.h` (3 745 Б) → EMM-страница + порт `load_frame` — **самый большой выигрыш**
Сейчас `kid_seqtbl` (2310) + `kid_frames` (1205) + `kid_seq_off` (230) лежат в
`_CODE` окна W1/W2 — это 15 % всего дефицитного пространства.
Как переносить:
1. `pop_extract_kid_data.py` дополнительно пишет `kid_data.bin` (те же три
таблицы подряд, фиксированные смещения).
2. Грузим её в отдельную EMM-страницу тем же способом, что уровень
(`pop_level_load` — готовый образец), хэндл держим в `pop_kid`.
3. **Порт `load_frame` (seg006) и глобала `cur_frame`** — в оригинале ровно
так и сделано: раз за тик кадр копируется в структуру, а весь остальной код
читает `cur_frame`, а не таблицу. У нас `kid_cur_dx()/kid_cur_flags()`
станут чтением из `cur_frame` (5 байт в `_DATA`).
4. `play_seq` оборачивается в один `gfx_w0_map(kid_data_page)``unmap` на
вызов (в тике, не в отрисовке — конфликта с атласом в W0 нет).
Выигрыш: **3 745 Б из W1/W2**, цена — один маппинг страницы за тик и 5 байт
`_DATA`. Дополнительный бонус: `load_frame`/`cur_frame` — шаг К СХОДСТВУ с
оригиналом, а не отход от него.
Риск: сломать `play_seq` (сердце анимации). Проверка: прогон по комнатам с
эталонными позами (вис, подтягивание, прыжки, подъём меча).
### Шаг 2. Модуль `pop_geom.c` — дедуп + bank-safe фундамент
Сейчас продублировано между модулями:
| что | где | сколько |
|---|---|---|
| `y_to_row`/`y_to_row_mod4` | pop_bg + pop_map | 2 копии |
| `char_dx_forward` | pop_kid + pop_map | 2 копии |
| `x_bump[20]` | pop_kid (uint8) + pop_map (int16) | 20 + 40 Б, РАЗНЫЕ типы |
| `y_land[5]` | pop_kid + pop_map | 10 + 10 Б |
| `tile_is_floor` | pop_map (+ проверка кодов в roomtest) | 2 места |
| 32-битный LCG `prandom` | pop_bg (`prandom`) + pop_trob (`trob_prandom`) | 2 копии по ~60 Б + 2 сида |
Собрать в один W1/W2-модуль `pop_geom.c`: таблицы `x_bump/y_land/dir_front/
dir_behind`, `y_to_row`, `char_dx_forward`, `get_tile_div_mod(_m7)`,
`tile_is_floor`, `prandom`. Выигрыш прямой — сотни байт (оценка 250–400 Б),
но главное — **это и есть тот «чистый» API, который потом сможет звать банк**
(R2): вся геометрия оказывается в W1/W2 по определению.
Осторожно: `prandom` у pop_bg и pop_trob — РАЗНЫЕ последовательности с разными
сидами (стены vs фазы факелов). Объединять функцию можно, **сиды — нет**:
передавать сид указателем/по индексу, иначе поедет раскладка кладки.
### Шаг 3. Вынести loose/потолок из `pop_map` в W3
`pop_map` — единственный модуль W1/W2, который зовёт графику, и делает это
ровно в одном логическом блоке: `pop_loose_tick` + `check_press` + `do_knock` +
`fell_on_your_head` + `check_loose_fall_on_kid` + плита-потолок (~1.4–2 КБ).
Вынести их в `pop_loose.c`, собираемый `--w3` рядом с `pop_bg`/`pop_trob`.
Тогда:
- `pop_map` = чистая логика (bank-safe, R2 соблюдён);
- W1/W2 худеет ещё на ~1.5–2 КБ;
- W3 растёт на столько же — а место там появится после шага 4.
### Шаг 4. Перебалансировка резидента W3
После шага 3 в W3 будет тесно (14.4 + 2 ≈ 16.4 КБ > 16 КБ). Разгружаем:
1. **`wall_pattern` (944 Б) + `mob_render`/`mob_tick_one` (1381 Б)** — кандидаты
на переезд в W1/W2: их зовёт только `pop_bg`/`pop_loose`, но сами они уже
пользуются только libbgi (R4), значит из W1/W2 работают и остаются
достижимыми из банка.
2. `tile_table` и мелкие const-таблицы pop_bg (371 + ~300 Б) можно унести в
EMM-страницу **уровня** (там ~13.8 КБ свободно) — но только если чтение
происходит под уже замапленной страницей. Сейчас `draw_tile` читает
`tile_table` ВНЕ W0-контекста → потребуется явный маппинг на тайл. **Не
делать раньше замера**: 30 тайлов на входе в комнату × map/unmap — терпимо,
а вот в покадровых редроях (пики/loose/кнопка) — уже горячий путь.
3. Если и этого мало — `enter_room` (~2.1 КБ, зовётся только при смене комнаты)
переносится в резидент W3 или в БАНК (он вызывается из главного цикла =
резидента, значит банк допустим по R3).
### Шаг 5. Данные
1. **`room_modif[24][30]` = 720 Б** (pop_trob, `_DATA`). Нужен произвольный
доступ каждый кадр (анимации, ворота) — в EMM не годится. Но 24 комнаты ×
30 байт хранятся ЦЕЛИКОМ, хотя одновременно живут modif'ы только текущей
комнаты и соседей по швам. Вариант: хранить полный массив в EMM-странице
уровня, а в `_DATA` держать кэш на 2–3 комнаты (свою + левого/правого
соседа) с записью обратно при смене комнаты. Выигрыш ~600 Б, цена —
аккуратность на швах (кнопка в одной комнате открывает ворота в другой).
**Делать последним** — это самая «тонкая» правка по семантике.
2. **`dl1[256]`+`dl2[256]` = 512 Б** (pop_level, `_DATA` — копии LINKLOC/
LINKMAP уровня) — читаются при нажатии кнопки
и при отрисовке нажатой кнопки. Кандидат на чтение прямо из страницы
уровня (она и так маппится) — но проверить, что `pop_doorlink2` не зовётся
из отрисовки в тот момент, когда в W0 атлас. Выигрыш ~500 Б.
3. **`_kbdraw_down[512]` 512 Б** (libc): проверено — это **байт на скан-код**
(`libc/kbd/_kbdraw_state.c`), хотя комментарий называет его битовой картой.
Упаковка в биты даёт −448 Б, но добавляет сдвиг/маску в ISR-трамплин и в
`kbd_raw_down`. Трогать осторожно: raw-клавиатура уже дважды была
источником залипаний (memory `kbd_raw_fifo_drain`,
`kbd_overrun_wipe_modifiers`) — правку сопровождать прогоном docs/kbd-games.
4. **`__irq_vec_buf` 513 Б** (libc IM2): таблица векторов обязана быть
выровнена и полна — не трогать.
### Шаг 6. Контракт банка стражей (проектируется ДО написания кода)
Когда дойдём до стражей:
- `guards.c` собирается `--bank 1=guards.c`, режим `huge` (или `big` с
`BANKED=W1`, если W3 окажется тесен для трамплинов).
- **Банк зовёт только:** `pop_map` (чистая логика после шага 3), `pop_kid`
(`play_seq`, `kid_set_seq`, `cur_frame`), `pop_geom`, libc/libbgi.
- **Банк НЕ зовёт:** `pop_bg`, `pop_trob`, `pop_loose` (резидент W3) — ни
прямо, ни через промежуточные функции. Нужна отрисовка — выставляет флаг,
рисует резидент (идиома `pop_item_taken`).
- Первым делом — **пробник** (`tests/` или `--bank` на пустышке): банк зовёт
`pop_map`-функцию, та зовёт libbgi-примитив; убедиться в MAME, что скобка
W3 корректно возвращает банковую страницу (R4) — это проверка модели, а не
веры в неё.
### Шаг 7. Мелкий дедуп в `pop_bg` (после того, как появится тест страниц)
- Пять функций-редроев (`pop_spike_redraw`, `pop_loose_shake_draw`,
`pop_floor_bake`, `pop_button_redraw`, `pop_leveldoor_redraw`) отличаются
только прямоугольником heal, банком и набором тайлов — свести к одному
параметризованному хелперу (оценка −150…250 Б).
- `env_b/wall_b/fore_b/pot_b` — четыре одинаковых обёртки над `blit_b`
(оставить: экономия единицы байт, читаемость дороже).
- `overlay_mid_tile` / `fore_only_tile` / `climb_overlay_tile` / `draw_tile`
— общая структура «взять code/lcode, посчитать x/dmy/dby, разобрать слои».
Тут экономия потенциально сотни байт, но это **та самая зона риска из §3**
только с пиксельным диффом до/после и по одному слою за раз.
---
## 6. Ожидаемый итог
| Шаг | W1/W2 | W3 | Риск |
|---|---|---|---|
| 1. kid_data → EMM + load_frame | **3 745** | — | средний (сердце анимации) |
| 2. pop_geom (дедуп) | 250…400 | — | низкий |
| 3. loose → W3 | 1 500…2 000 | +1 500…2 000 | низкий (перенос как есть) |
| 4. разгрузка W3 (wall_pattern, mob) | +2 300 | 2 300 | низкий |
| 5. данные (room_modif, LINKLOC, kbd) | 1 000…1 600 | — | средний/высокий |
| 7. дедуп редроев pop_bg | — | −150…250 | средний |
Суммарно: **W1/W2 освобождается ~4.5–6 КБ**, W3 остаётся примерно в нынешнем
объёме, но становится «правильно заполненным» — в нём только то, что банк
никогда не позовёт. Плюс открывается путь к банкам: стражи и боёвка получают
до 16 КБ на банк, не трогая резидент.
---
## 7. Как мерить и проверять (обязательно к каждому шагу)
1. **До/после по `.rel`** — точные размеры на модуль:
`for f in .sprinter-cc-roomtest/*.rel; do grep '^A ' $f; done`
(области `_CODE`/`_W3CODE`/`_DATA`). Итоги окон печатает сам `sprinter-cc`.
2. **Функции** — из `.lst` (метки `_name:` и адреса), скрипт в истории этой
сессии; полезно ловить «функция распухла после рефактора».
3. **MAME**: любой перенос кода между окнами/страницами — это класс «молча
ломается» (`sprinter_memory_modes`). Минимум: комната 1 (loose), 12
(вис/подтягивание), 15 (меч), 9 (дверь уровня), 6 (кнопка/ворота).
4. **Пиксельный дифф** для правок отрисовки: заморозить кадр (`1`), сравнить
обе страницы дабл-буфера через `vram` (см. memory `mame_mcp_bridge`) и/или
сверить с эталонным рендером `render_room.py`.
5. **Скорость** — после шагов 1 и 4 замерить кадр маркерами в порт 0xFE
(приём из v1 §8), чтобы маппинг страницы за тик не съел бюджет.
## 8. Ссылки
- `bin/sprinter-cc` — справка по `--w3`, `--bank`, `--memory`, `--memory-manual`.
- `runtime/crt0_banked.s`, `runtime/bank.s` — трамплины и захват резидентной
страницы W3.
- `libbgi/common/_bgi_begin.c` / `_bgi_end.c` — механика скобки W3 (R4).
- memory: `w3_resident_code`, `pop_banking_architecture`, `sdcc_banking`,
`bank_local_data_pattern`, `sprinter_memory_modes`, `memory_modes_implemented`,
`sprinter_emm_budget`, `mame_mcp_bridge`, `avoid_32bit_arith_z80`,
`libc_one_function_per_module`.
- `applications/PoP/roomtest/TASKS_OPEN.md` — что из этого берётся в работу сейчас.
---
## 9. Скорость отрисовки: замеры и запас
Перенесено из удалённого `size_optimization_plan.md` §8 (замер 2026-07-27) —
единственная его часть, которая не была перекрыта этим документом.
Профилирование в MAME: маркеры в порт 0xFE + `wpiset … totalcycles` (приём из
memory `mame_mcp_bridge`); в самом `roomtest.c` для этого уже стоят полосы
бордюра `PROF()`. Кадр Sprinter = **430 080 тактов**.
**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке
`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип,
пересчёт src, нарезка полос >256), а не за пиксели:
| путь (спрайт 32×3) | тактов |
|---|---|
| `gfx_blit` (общее ядро, с клипом) | 13 288 |
| линейное ядро без клипа | 4 617 |
Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 %
кадра**; сам `bar` — только 13 308.
**Сделано:** `gfx_blit_noclip()` в libbgi, фоновые блиты `pop_bg` уходят на
него, когда спрайт целиком на экране (~2.9×, подтверждено в MAME). Позже
тем же приёмом закрыты спрайты персонажей (`gfx_blit_cols_part_noclip`).
**Не закрыт heal** — задача CLIP-1 в `../roomtest/TASKS_CLOSED.md`.
**ВАЖНО:** W3-скобку (`_bgi_begin`/`_bgi_end`) ставит САМА libbgi — вызывать
её из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято
видеобанком и код вызывающего исчезает из адресного пространства (проверено:
белый экран).
**Запас, когда перестанет хватать бюджета кадра:**
1. **Батчинг W3-скобки** — одна `_bgi_begin`/`_bgi_end` на весь `draw_tile`
вместо скобки на блит; нужен публичный batch-API в libbgi.
**Осторожно, и это стало важнее, чем было:** между begin/end стоит `DI`,
длинная серия задержит кадровое прерывание — а по разбору KBD-1
(`../roomtest/TASKS_CLOSED.md`) длинные DI-окна и есть причина потери байт
клавиатуры. Батчинг эту проблему УХУДШИТ, если делать его вслепую.
2. **Решётка ворот одним спрайтом**`draw_gate_back` рисует бары по одному
(до 7 блитов). Сгенерировать в атласе «столб решётки» и выводить одним
`gfx_blit_part` с обрезкой по фазе `gate_bot_y & 7`: 7 блитов → 1.
3. **Не перерисовывать статичные части шва** — грань ворот, пол и кромка при
анимации решётки не меняются (см. OPT-1 в `../roomtest/BUGS_CLOSED.md`
решено не делать, стоимость транзиентная).
4. **T-1 / T-2** (`../roomtest/BUGS_OPEN.md`) — перерисовка пик по причине и
idle-skip Кида: самый большой оставшийся резерв, потому что убирает работу
целиком, а не удешевляет её.
+462
View File
@@ -0,0 +1,462 @@
# Что осталось на уровнях 12, 13, 14, 15 и 0
Статус: разбор по `../SDLPoP/src/`, 2026-08-13. Продолжает
[`levels_plan.md`](levels_plan.md) (машинерия уровней и тайлсеты — уже
сделаны). Здесь только СПЕЦСОБЫТИЯ, которых у нас ещё нет.
Правило проекта: источник истины — SDLPoP; все ссылки ниже даны на функцию и
строку, чтобы порт начинался с чтения, а не с гипотезы.
---
## 0. Что из этой области УЖЕ есть
Проверено грепом по `roomtest/`:
| механика | где у нас | статус |
|---|---|---|
| слот соперника, общий на всех Char | `pop_guard.c`, `pop_cdraw.c` | готово |
| `check_shadow` (спецвход тени) | `guards.c:310` | готово для уровней 4/5/6 |
| `do_init_shad` + таблицы `init_shad_5/6` | `guards.c:284` | готово |
| ИИ тени 4/5/6 | `guards.c:599/658/680` | готово |
| диспетчер `autocontrol_shadow` | `guards.c:711` | ветки 12 НЕТ |
| боёвка (удар/блок/парирование/HP соперника) | `guards.c`, `pop_ctrl.c` | готово |
| `flash_color` / `flash_time` | `pop_map.c`, `roomtest.c` | готово |
| `add_life` | `pop_map.c` | готово |
| таблицы уровней (`guard_type`, `guard_hp`, `entry_pose`, `level_type`) | `pop_level_cold.c:41..52` | готово, включая 12=SHADOW, 13=VIZIER |
| loose-полы, `make_loose_fall`, mob | `pop_map.c`, `pop_room.c` | готово (без спецкейсов ур. 13) |
То есть каркас есть весь; ниже — недостающие спецсобытия.
---
## 1. Уровень 12 — тень: встреча, бой, слияние
### 1.1 Подъём тени в комнате 15 (`check_shadow`, seg002:0070)
```c
if (current_level == 12) {
if (!united_with_shadow && drawn_room == 15) {
Char.room = drawn_room;
if (get_tile(15, 1, 0) == tiles_22_sword) return; // меч ещё лежит
shadow_initialized = 0;
do_init_shad(init_shad_12, 7 /* fall */);
return;
}
}
```
Отличия от наших веток 5/6: тень поднимается **в падении** (seq 7), условием
служит содержимое тайла (меч уже подобран) и флаг `united_with_shadow`.
Таблица `init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0}` — то есть
x=81, y=232, вправо, колонка 0, ряд 0.
**Что добавить:** ветку в `pop_check_shadow` + константу `init_shad_12` +
глобалы `united_with_shadow`, `shadow_initialized`.
### 1.2 ИИ тени (`autocontrol_shadow_level12`, seg002:1184)
Самая содержательная функция уровня. Три режима:
1. **Первый кадр в комнате 15**: пока Кид не подошёл (`Opp.x < 150`) —
`shadow_initialized = 1`; иначе тень ещё раз падает (`do_init_shad`).
2. **Кид с мечом** (`Char.sword >= sword_2_drawn`) → тень дерётся обычным
`autocontrol_guard_active` (у нас есть, `guards.c:535`). Особый случай:
если тень уже ранена (`offguard != 0 && guard_refrac != 0`) — она убирает
меч (`move_4_down`).
3. **Кид убрал меч** → тень тоже убирает и идёт навстречу; на дистанции
`< 10`**СЛИЯНИЕ**:
```c
flash_color = color_15_brightwhite; flash_time = 18;
add_life(); // +1 к максимуму HP
united_with_shadow = 42; // время вспышки Кид-тень
Char.charid = charid_0_kid; savekid(); // Кид ПЕРЕЕЗЖАЕТ на место тени
clear_char(); // тень со сцены
```
Плюс «если Кид бежит к тени — тень бежит к Киду» (кадры бега 3..14 и
шага 127..132).
**Что добавить:** `autocontrol_shadow_level12` в `guards.c` + ветку в
диспетчере `autocontrol_shadow` (там уже три ветки, будет четвёртая).
### 1.3 Общий урон (`do_delta_hp`, seg000:1518)
```c
if (Opp.charid == charid_1_shadow && current_level == 12 && guardhp_delta != 0)
hitp_delta = guardhp_delta; // ранил тень — ранил себя
```
Три строки, но без них бой с тенью теряет смысл. У нас `do_delta_hp`
портирован — добавить условие.
### 1.4 Таймер вспышки (`do_timers`, seg003:503)
```c
if (united_with_shadow > 0) {
--united_with_shadow;
if (united_with_shadow == 0) { --united_with_shadow; /* → -1 */ ... }
}
```
`united_with_shadow` живёт как счётчик, потом как «уже слились» (−1).
На него смотрят `check_shadow` и `check_can_guard_see_kid`.
### 1.5 Луч видимости (`check_can_guard_see_kid`, seg003:702)
```c
if ((Guard.charid != charid_1_shadow || current_level == 12) && ...
```
У нас (`guards.c:103`) условие про тень **нужно сверить**: на уровне 12 тень
ОБЯЗАНА быть видимой (иначе Кид не достанет меч и бой не начнётся).
### 1.6 Меч исчезает (`sword_disappears`, seg002:0536)
```c
if (current_level == 12 && Char.room == 18) {
get_tile(15, 1, 0);
curr_room_tiles[curr_tilepos] = tiles_1_floor;
curr_room_modif[curr_tilepos] = 0;
}
```
Срабатывает при уходе Кида ВПРАВО из комнаты 18 (`leave_room`, ветка 1).
У нас есть `pop_level_set_tile` — порт на пять строк.
### 1.7 Переход 12 → 13: НЕТ двери уровня, есть «бесшовный выход»
Проверено по данным (`res2012.bin` / `res2013.bin`) — портала действительно
нет, уровень кончается **фактом попадания в комнату**:
```c
// play_level_2, seg000:900
} else if (custom->tbl_seamless_exit[current_level] >= 0) {
if (Kid.room == /*23*/ custom->tbl_seamless_exit[current_level]) {
++next_level;
stop_sounds();
seamless = 1;
}
}
```
`tbl_seamless_exit[12] = 23` (пара «уровень, комната» читается из оригинального
`PRINCE.EXE`, options.c:724). Дальше геометрия складывается так:
| | комната | ряд 1 | что происходит |
|---|---|---|---|
| ур. 12 | 13 | `floor bigpil empty empty wall wall …` | Кид бежит ВЛЕВО с колонки 0 |
| ур. 12 | 23 | `empty ×6, floor floor floor floor` | попал сюда → **уровень сменился** |
| ур. 13 | 23 | `floor bigpil floor ×6 bigpil floor` | старт: ряд 1, кол 9, `seq_84_run` |
Связи: `комната 13.left = 23`, и в комнату 23 больше ниоткуда не войти.
Номер комнаты у обоих уровней **один и тот же (23)**, стартовая позиция
уровня 13 — `BP_START = 23, поз 19 (ряд 1, кол 9), dir 0`, а
`tbl_entry_pose[13] = 2` даёт «вбегающий» вход (`seq_84_run`, seg003:172).
То есть Кид вбегает в комнату слева-направо… нет, `Char.direction =
~level.start_dir` — влево, тем же ходом, каким выбежал из уровня 12. Швов не
видно.
Что делает флаг `seamless` (всего два места, оба косметические):
* `start_level`, seg003:158 — **НЕ сбрасывает HP**: Кид уносит на уровень 13
то здоровье, с которым добежал;
* `show_level`, seg008:1861/1870 — не показывает заставку «LEVEL 13» и тут же
гасит флаг.
Двери уровня при этом на карте есть, но не при делах: у уровня 12 она одна
(комната 3, tilepos 23) — это ВХОД (стартовая комната 12-го — 3), а у уровня
13 их две (комната 3 tilepos 13 и комната 5 tilepos 24) — это выходы,
открываемые кнопкой из `Jaffar_exit`.
**Что это значит для нас.** Наш переход уровня сейчас идёт только через
`SEQ_END_LEVEL` в двери. Для 12-го нужен второй триггер — проверка в главном
цикле «`pop_current_level == 12 && cur_room == 23``pop_next_level++`» плюс
флаг `seamless`, который пропустит сброс HP в `pop_start_level`. Обе правки
маленькие и локальные.
---
## 2. Уровень 13 — Джафар
### 2.1 Кто такой Джафар
`tbl_guard_type[13] = 3``VIZIER.DAT` (у нас в `pop_level_cold.c` уже 3).
`tbl_guard_hp[13] = 6`. Отдельного ИИ у него нет:
```c
void autocontrol_Jaffar() { autocontrol_guard(); } // seg002:0697
```
То есть **бой с Джафаром — обычный бой стражи**, отличаются только спрайты,
HP и три спецсобытия ниже. Это хорошая новость: боёвка у нас есть.
### 2.2 Встреча (`meet_Jaffar`, seg002:0544)
```c
if (current_level == 13 && leveldoor_open == 0 && Char.room == 3) {
play_sound(sound_29_meet_Jaffar);
guard_notice_timer = 28; // Джафар ждёт 28/12 ≈ 2.33 с
}
```
Срабатывает при уходе Кида ВПРАВО (`leave_room`, ветка 1).
Пара к нему — в `autocontrol_guard_inactive` (seg002:0734), она у нас уже
портирована (`guards.c:401`), но **без условия по уровню**:
```c
if (can_guard_see_kid) {
if (current_level != 13 || guard_notice_timer == 0) move_down_forw();
}
```
**Что добавить:** глобал `guard_notice_timer` + его тик в `do_timers`
(seg003:509) + оба условия.
### 2.3 Победа (`on_guard_killed`, seg006:1929)
```c
} else if (current_level == 13) {
flash_color = color_15_brightwhite; flash_time = 18;
is_show_time = 1;
leveldoor_open = 2; // ← ключ к выходу
play_sound(sound_43_victory_Jaffar);
}
```
и парная `Jaffar_exit` (seg002:0517), срабатывающая при уходе Кида ВЛЕВО:
```c
if (leveldoor_open == 2) { get_tile(24, 0, 0); trigger_button(0, 0, -1); }
```
То есть смерть Джафара не открывает дверь сама — она ставит флаг, а дверь
открывается кнопкой, «нажатой» при уходе влево. `trigger_button` у нас есть.
### 2.4 Падающие плиты (`check_fall_flo`, seg000:1319)
```c
if (current_level == 13 && (drawn_room == 23 || drawn_room == 16)) {
curr_room = room_A; // комната СВЕРХУ
for (curr_tilepos = 22; curr_tilepos <= 27; ++curr_tilepos)
make_loose_fall(-(prandom(0xFF) & 0x0F)); // ОТРИЦАТЕЛЬНЫЙ модификатор
}
```
Вот это и есть «плиты появляются»: при входе в комнаты 23/16 шесть плит ряда 2
комнаты СВЕРХУ получают отрицательную фазу — то есть отложенный старт, и
сыплются на Кида вразнобой. Зовётся из `check_the_end` при смене комнаты.
**Три спецкейса уровня 13 в loose-механике**, без них это не работает:
| место | что | зачем |
|---|---|---|
| `animate_loose`, seg007:823 | при `modif & 0x80` НЕ останавливать тряску | иначе отрицательная фаза не досчитает до нуля и плита не упадёт |
| `loose_make_shake`, seg007:949 | на уровне 13 сотрясение НЕ трясёт плиты | иначе отложенные плиты сбрасываются в 0x80 |
| `fell_on_your_head`, seg007:1218 | плита бьёт и в БЕГЕ (кадры 5..14) | на прочих уровнях бегущего не задевает |
У нас первый пункт критичен: `pop_loose_tick` трактует бит 7 как «тряска» и
на `>= 0x84` гасит фазу — отрицательный старт умрёт, не начавшись. Заодно
это ровно та же ветка, что мы правили сегодня в
[`LOOSE-ROOM-CHANGE`](../roomtest/BUGS_OPEN.md#loose-room-change), так что
код на виду.
### 2.5 Поза входа
`tbl_entry_pose[13] = 2` — у нас в таблице уже есть; проверить, что режим 2
(`seg003:172`) реализован.
---
## 3. Уровень 14 — принцесса и конец игры
Боя нет вовсе (`tbl_guard_type[14] = -1`). Всё сводится к одному событию:
```c
// check_the_end, seg000:1299
if (current_level == 14 && drawn_room == 5) end_sequence();
```
`end_sequence` (seg001:573) → `end_sequence_anim` (seg001:332): катсцена
«Кид добежал до принцессы» — обнимаются, появляется мышь, затухание, Hall of
Fame. Персонажи там играются ТЕМ ЖЕ интерпретатором `seqtbl`
(`seq_108_princess_turn_and_hug`, `seq_101_mouse_stands_up`), то есть движок
у нас уже подходит — нужны спрайты принцессы (`PRINCESS.DAT`) и раскадровка.
**Оценка:** это не игровая механика, а ролик. Логично делать вместе с интро
и межуровневыми вставками — отдельным банком, как и договаривались
(см. memory `pop_banking_architecture`). На проходимость игры не влияет:
достаточно довести Кида до комнаты 5 и показать заглушку.
---
## 4. Уровень 15 — «уровень зелий» (защита от копирования)
Не часть сюжета. Это экран проверки подлинности из оригинала: после
уровня `copyprot_level` игра подменяет номер на 15, показывает комнату с
**14 зельями, на которых нарисованы буквы**, и требует выпить нужное.
Механика (всё под `USE_COPYPROT` в SDLPoP):
| место | что делает |
|---|---|
| `play_level`, seg003:53 | `level_number == copyprot_level` → грузим 15 |
| `redraw_screen`, seg003:273 | поверх зелий рисуются БУКВЫ (`copyprot_letter`) |
| `load_alter_mod`, seg008:1204 | одно зелье в комнате делается «открытым» (тип 6) |
| `animate_potion`, seg007:259 | на уровне 15 своя ветка перерисовки |
| `up_pressed`/`do_pickup`, seg005:657 | выпитое зелье убирает букву из таблицы |
| `seq` эффект зелья, seg006:1896 | синие зелья на уровне 15 отнимают ПОЛОВИНУ HP |
| выход, seg000:700 | из 15 возвращаемся в `copyprot_level` |
**Рекомендация: не портировать.** Это анти-пиратский экран 1989 года,
требующий книжки-манускрипта; SDLPoP держит его выключенным по умолчанию
(`enable_copyprot`). Единственное, что стоит взять — **половинный урон
синего зелья**, если вдруг захочется полной совместимости; остальное только
съест банк. Если решим делать — это отдельная фича «уровень 15», а не часть
основного прохождения.
---
## 5. Уровень 0 — демо-уровень (аттракт)
`res2000.bin` у нас распакован. Это тот самый ролик, который крутится на
титульном экране: Кид сам бежит, дерётся со стражем и убегает.
Как устроено:
| место | что |
|---|---|
| `do_demo`, seg006:1409 | на уровне 0 вместо чтения клавиатуры зовётся `do_demo()` + `control()` |
| `autocontrol_kid`, seg002:702 | Кид управляется тем же `autocontrol_guard` |
| `do_auto_moves`, seg002:1089 | проигрыватель ЗАПИСИ ходов: таблица `(time, move)` |
| `on_guard_killed`, seg006:1928 | на уровне 0 после убийства стража Кид убегает (`checkpoint = 1`, сброс демо) |
| `demo_index` / `demo_time` | позиция в записи; у нас уже объявлены в `guards.c:267` |
**Хорошая новость:** движок автодвижений (`do_auto_moves`) у нас уже есть —
он нужен был тени на уровнях 4/5/6, и `demo_time`/`demo_index` объявлены там
же. То есть демо-уровень — это в основном таблица ходов + ветка «Кидом
управляет ИИ» в `pop_ctrl`.
**Когда делать:** вместе с интро/титульным экраном, не раньше. На
прохождение не влияет.
---
## 6. Порядок работ
Порядок задан пользователем 2026-08-13: **строго по номерам уровней**, а не
по дешевизне кода — уровень 13 не имеет смысла раньше, чем на него можно
попасть.
1.**Уровень 12** — тень: `init_shad_12`, `autocontrol_shadow_level12`,
`united_with_shadow`, общий урон, «убил тень — убил себя»,
`sword_disappears`, появление плит. Сделано 2026-08-13, хост-набор
`roomtest/tests-host/t_shadow.c` (45 проверок); живой проверки в MAME
ещё не было — доска [`L12-SHADOW`](../roomtest/TASKS_OPEN.md#l12-shadow).
2.**Переход 12 → 13** (§1.7) — room-триггер вместо двери и флаг
`pop_seamless` (не сбрасывать HP). Сделано там же.
3.**Уровень 13** — Джафар: `guard_notice_timer`,
`on_guard_killed`/`Jaffar_exit`, три спецкейса loose (§2.4) и
`check_fall_flo`. Плюс СПРАЙТЫ визиря (VIZIER.DAT) — без них он
рисовался обычным стражем. Сделано 2026-08-13, хост-набор
`roomtest/tests-host/t_jaffar.c` (44 проверки); живой проверки в MAME ещё
не было — доска [`L13-JAFFAR`](../roomtest/TASKS_OPEN.md#l13-jaffar).
4. **Уровень 14** — довести до комнаты 5 и поставить заглушку вместо ролика;
сам ролик — в общую задачу «катсцены».
5. **Уровень 0 и 15** — отложить: аттракт и защита от копирования на
прохождение не влияют.
---
## 7. Сверка констант с ОРИГИНАЛЬНЫМИ данными (сделана 2026-08-13)
Всё ниже снято скриптом прямо с `../SDLPoP/data/LEVELS/res2013.bin` и
`res2012.bin` — не из головы и не из констант SDLPoP.
### 7.1 Падающие плиты уровня 13 — константы сходятся ТОЧНО
Комнаты, у которых в ряду 2 вообще есть loose:
| комната | loose в колонках | комната снизу |
|---|---|---|
| 17 | 2, 3, 4, 5, 6, 7 | **23** |
| 1 | 2, 3, &nbsp;&nbsp; 5, 6, 7 | **16** |
| 14 | 2 | 24 |
Связи: `комната 23: up = 17`, `комната 16: up = 1`. То есть
`loose_tiles_room_1 = 23` и `room_2 = 16` — это ровно те две комнаты, над
которыми лежит ПОЛНАЯ гряда плит, а `first_tile = 22, last_tile = 27` — ровно
ряд 2, колонки 2..7:
```
комната 23 → сверху 17: 22:LOOSE 23:LOOSE 24:LOOSE 25:LOOSE 26:LOOSE 27:LOOSE [6/6]
комната 16 → сверху 1: 22:LOOSE 23:LOOSE 24:empty 25:LOOSE 26:LOOSE 27:LOOSE [5/6]
```
Два вывода:
* диапазон 22..27 — **надмножество**: в комнате 1 тайл 24 пустой, и
`make_loose_fall` его молча пропустит (проверяет тип тайла). Копировать
константы можно как есть;
* пара «комната 14 → 24» НЕ входит в спецсобытие намеренно — одна плита, это
обычный loose.
**Важное следствие, которого не было в плане:** стартовая комната уровня 13 —
**23** (`BP_START = 23`, поз 19, dir 0), а `check_fall_flo` зовётся из
`draw_level_first``check_the_end` (seg003:217). Значит плиты начинают
сыпаться СРАЗУ при входе на уровень, это его первый кадр, а не событие
где-то в середине.
### 7.2 Джафар — один страж со skill 9
В `res2013.bin` заполнен ровно один слот стража:
| комната | тайл | ряд, кол | направление | skill |
|---|---|---|---|---|
| 1 | 7 | 0, 7 | 255 (влево) | **9** |
И это сходится с `meet_Jaffar`: событие срабатывает, когда Кид уходит ВПРАВО
из комнаты 3, а `комната 3: right = 1` — то есть ровно туда, где стоит
Джафар. Skill 9 у нас поддержан: `NUM_GUARD_SKILLS = 12`, все шесть таблиц
вероятностей (`guards.c:411..415`) имеют индекс 9. HP = `tbl_guard_hp[13] = 6`
`pop_level_cold.c` уже стоит).
`Jaffar_exit` тоже проверен: **комната 24, tilepos 0 = `opener`** (кнопка),
модификатор 0 — то есть `trigger_button(0,0,-1)` жмёт реальную кнопку, а не
пустой тайл.
### 7.3 Уровень 12 — меч на месте
`комната 15, tilepos 1 = SWORD` — условие подъёма тени
(`get_tile(15,1,0) == tiles_22_sword`) на наших данных выполняется.
`комната 18: right = 19``sword_disappears` срабатывает при уходе вправо.
Комната 15 целиком (по ней видно всю сцену встречи с тенью):
```
ряд 0: floor SWORD floor torch torch LOOSE LOOSE floor empty empty
ряд 1: wall wall wall empty empty empty empty wall floor floor
ряд 2: wall opener pillar empty empty empty empty wall pillar empty
```
### 7.4 `pop_loose_tick` действительно убьёт отрицательную фазу
Подтверждено чтением кода: `m = ++pop_loose_modif[pos]; if (m & 0x80) { if (m
>= 0x84) → сброс в 0 }`. Для стартового `0xF0..0xFF` первый же тик даёт
`m >= 0x84` → фаза обнуляется, плита не падает. **Это и есть та правка №1 из
таблицы §2.4**, и она обязательна.
### 7.5 `init_shad_12` и байтовое переполнение `y`
`init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0}` → frame 15, x 81,
**y 232**, вправо, колонка 0, ряд 0, action 0 + `seq 7 (fall)`. Поле `y` в
`char_type``byte` (беззнаковое), то есть 232 лежит НИЖЕ поля (192), и
падение уводит его дальше с переполнением байта: тень «падает» из-под экрана и
появляется сверху. У нас `pop_char_t.y` тоже `uint8_t` — поведение
переносится без правок; при портировании просто не «чинить» это как ошибку.
Отдельно про ряд: `curr_row` берётся ИЗ ТАБЛИЦЫ и равен **0**, хотя
`pop_y_to_row(232)` дал бы 1. `do_init_shad` копирует семь полей как есть и
ряд не пересчитывает, так что «согласовать» их — значит разойтись с
оригиналом. Закреплено тестом `shadow12_rises_when_sword_gone`.
+209
View File
@@ -0,0 +1,209 @@
# План: от одного уровня к нескольким (загрузка, переходы, тайлсеты)
Статус: план, 2026-08-01. Продолжает `PORT_PLAN.md` §7 (Фаза 1: «переходы
между экранами» → теперь между УРОВНЯМИ). Текущая точка: `roomtest` играет
уровень 1 целиком в одной комнате-за-комнатой модели, но уровень нельзя
ни выбрать, ни закончить.
Источник истины — `../SDLPoP/src/` (правило `../CLAUDE.md`). Ключевые
места: `seg000.c: load_lev_spr/play_level_2/init_game`, `seg005.c:
up_pressed/go_up_leveldoor`, `seg006.c: play_seq → SEQ_END_LEVEL`,
`seg002.c` (спецсобытия уровней), `data.h:835..850` (потабличные различия
уровней).
---
## 0. Что уже готово (не проектировать заново)
- **Формат и загрузчик уровня.** `pop_level.c/.h` читает сырой
`res200N.bin` (2305 Б) в отдельную EMM-страницу; путь — параметр
`pop_level_load(const char *)`. Мультиуровневость здесь стоит одной
функции формирования имени.
- **Стартовая позиция уровня** уже разобрана: `pop_level_start_room()`,
`pop_level_start_pos()`, `pop_level_start_dir()` — реализованы и пока
НЕ вызываются (см. `../roomtest/TASKS_CLOSED.md` L1-START).
- **Страж по данным уровня**: `pop_level_guard()` (порт `enter_guard`),
сохранение состояния между комнатами (`pop_guard_state_save`).
- **Палитра разложена по слотам ровно как в оригинале** (`pop_pack_kid.py`
`build_palette`): env 0x50, wall 0x60, pot 0x40, kid 0x70, меч 0x80,
страж 0x90. Это тот же раскрой, что `set_pal_arr(0x50/0x60)` в
`seg000.c:1140..1148`, — значит смена тайлсета не требует переиндексации
спрайтов Кида (см. §3).
- **Все 16 файлов уровней распакованы**: `../SDLPoP/data/LEVELS/res2000..
res2015.bin` (0 — демо-уровень).
---
## 1. Что реально различается между уровнями (замер по данным, не по памяти)
Таблицы из `../SDLPoP/src/data.h:840..847` + инвентарь тайлов, снятый прямо
с `res200N.bin` (маска `fg & 0x1F`):
| Ур. | Тайлсет | Страж | Новое против предыдущих |
|-----|---------|-------|--------------------------|
| 1 | dungeon | обычный | — (текущая база) |
| **2** | **dungeon** | **обычный** | **ничего нового: тот же набор объектов минус меч** |
| 3 | dungeon | СКЕЛЕТ | чомперы |
| 4 | palace | обычный | **тайлсет palace**, зеркало (спецсобытие `mirror_level`) — **СДЕЛАНО** |
| 5 | palace | обычный | новых ТАЙЛОВ нет; спецсобытие **тень крадёт зелье** (комната 24) — [L5-SHADOW](../roomtest/TASKS_OPEN.md#l5-shadow) |
| 6 | palace | ТОЛСТЫЙ | падение на входе (спецсобытие) |
| 7 | dungeon | обычный | — |
| 8, 9 | dungeon | обычный | — |
| 10, 11 | palace | обычный | — |
| 12 | dungeon | ТЕНЬ | seamless-выход (комната 23), исчезающий меч |
| 13 | dungeon | ВИЗИРЬ | мышь, особый выход |
| 14 | palace | нет | — |
| 15 | dungeon | нет | финал |
Прямое следствие для порядка работ: **уровень 2 не требует ни одного нового
ассета и ни одной новой механики** — он проверяет ровно машинерию перехода.
Это и есть первый шаг.
Прочие потабличные различия, которые придётся завести массивами по 16:
`tbl_level_type` (тайлсет), `tbl_guard_type` (−1 = стражей нет),
`tbl_guard_hp`, `tbl_level_color` (вариантные палитры, 1.3), `tbl_entry_pose`.
---
## 2. Шаг 1 — машинерия перехода (цель: уровни 1 → 2 → 3) — **СДЕЛАН 2026-08-04**
> **Итог.** Всё в этом разделе портировано и проверено в MAME: уровень 1 →
> Shift+L → уровень 2 (комната 5, дверь захлопывается за спиной, большие
> колонны рисуются) → уровень 3. Разбор что именно сделано и что по
> уровню 2 осталось — `../roomtest/TASKS_CLOSED.md`, запись **L2**.
>
> Сверх плана пришлось доделать две вещи, без которых уровень 2 не играется:
> **`find_start_level_door`** (стартовый тайл уровня 2 — это правая половина
> двери уровня) и **большую склянку** `add_life` (тип зелья 2, комната 20).
Порядок именно такой; каждый пункт проверяем в MAME отдельно.
**2.1 Выход с уровня.** Портировать `up_pressed()` ветку двери
(`seg005.c:410..423`) + `go_up_leveldoor()` (`seg005.c:497`): дверь рядом
(при/за/перед персонажем) И `drawn_room != level.start_room` И створка
открыта полностью (`curr_room_modif >= 42` — вариант `fix_exit_door`) →
`Char.x = x_bump[...] + 10`, направление влево, `seq_70_go_up_on_level_door`.
Затем оживить опкод `0xF1 END_LEVEL` в `play_seq` (`../roomtest/pop_kid.c:418`
— сейчас пустой `break`): `++pop_next_level`, как `seg006.c:662`.
**2.2 Цикл уровня.** В `main()` после тика: `if (pop_next_level !=
pop_current_level) → load_level(pop_next_level)`. Порядок сноса/подъёма
состояния (порт `load_lev_spr` + `play_level_2`):
`pop_level_free` → `pop_level_load("LEVELS\\res200%d.bin")` →
`pop_trob_reset` → `pop_guard_reset` → сброс tile-override'ов
(`ovr_*` в `roomtest.c`) → `enter_room(pop_level_start_room())` →
`kid_init(поза/позиция/направление из данных уровня)`.
**HP через уровень переносится** (в оригинале `hitp_beg_lev`), не сбрасывать
в максимум — сверить с `seg000.c` `init_game`/`play_level_2`.
**2.3 Стражи по уровню.** Завести `tbl_guard_type[16]`/`tbl_guard_hp[16]`;
`-1` = стражей на уровне нет (уровни 14, 15) — `pop_guard_enter` обязан это
понимать, иначе на 14-м полезут стражи из мусора. Для шага 1 (уровни 2, 3)
достаточно обычного стража, но проверку `-1` заложить сразу.
**2.4 Чит «следующий уровень» (Shift+L).** Реализуется ровно тем же
`pop_next_level` — и без него отладка уровней превращается в прохождение
игры руками. Делать в этом же шаге, не позже (см. §4).
**Приёмка шага 1:** дверь уровня 1 → уровень 2 играется целиком → его дверь
→ уровень 3 стартует (чомперы могут быть ещё не портированы — тогда
фиксируем как известное ограничение, а не «баг»).
---
## 3. Шаг 2 — второй тайлсет (palace, уровни 4+) — **СДЕЛАН**
> **Закрыт (ревизия 2026-08-11 по коду).** В дереве есть всё, что
> проектировалось ниже: `pop_pack_bg.py` печёт ДВА набора атласов
> (`pop_*` из VDUNGEON, `pal_*` из VPALACE, каскад с обменом приоритетов),
> `pop_bg_load` принимает тип набора и ставит флаг `pop_palace`, по которому
> `pop_room.c` выбирает дворцовую ветку (`wall_pattern` дворца — сплошная
> заливка + пять mono-полос, `PALACE_WALL_MONO_IDS`), решётчатые тайлы
> 25-29 есть и в `tile_table` (`pop_tile.c`), и в коллизии — `tile_is_floor`
> совпадает с `seg006:0628` тайл в тайл. Уровень 4 проходится smoke-тестом.
>
> Текст ниже оставлен как справочник по раскрою палитры и по тому, почему
> смена тайлсета — это перезапись 32 записей, а не переиндексация спрайтов.
**Ассеты.** `toolchain/pop_pack_bg.py` уже читает PNG каскадом
VDUNGEON→VPALACE (та же логика, что в игре), но печёт ОДИН набор атласов
(`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl` ≈ 75 КБ). Нужен второй
набор из VPALACE (`pal_env*.atl` / `pal_wall.atl`), плюс `torch_debris` —
тайл, который встречается только на palace-уровнях. По EMM это ещё ~6
страниц при бюджете ~3.3 МБ — не проблема.
**Палитра — главный технический вопрос, и он уже решён раскроем.**
Тайлсет живёт в слотах `0x50..0x5F` (env) и `0x60..0x6F` (wall); Кид, меч,
страж, склянки — в других слотах. Значит смена тайлсета = перезапись 32
записей палитры (`gfx_pal_set` на обе страницы, как `flash_bg` в
`roomtest.c`), а НЕ перезагрузка `kid.pal` и не переиндексация спрайтов.
Сделать `pal_dungeon.bin` / `pal_palace.bin` (по 32 записи) и грузить при
смене типа уровня. Проверить артефактом: скриншот palace-комнаты против
рендера `render_room.py` для того же уровня.
**Вариантные цвета уровней** (`tbl_level_color`, `level_var_palettes` — это
уже 1.3, в 1.0 их нет): по той же механике, тот же диапазон слотов. Решение
на будущее — сначала базовые два тайлсета, потом при желании цвета.
**Выбор набора в коде.** `pop_bg_load()` сейчас грузит фиксированные имена;
превратить в `pop_bg_load(type)` с двумя таблицами имён + выгрузка старых
атласов при смене типа (`atlas_free`). Переключение — только на границе
уровня, не в кадре.
---
## 4. Читы SDLPoP: что взять на следующем этапе
Из `../SDLPoP/README.md` (раздел Cheats). У нас уже есть: **K** — убить
стража, **I** — бессмертие (наш, в оригинале нет), **S** — выдать меч (наш),
**+/−** — обход комнат (`ROOMNAV`, наш).
**Брать сразу вместе с переходами уровней** (без них отладка дороже самой
работы):
| Чит | Что даёт | Цена |
|-----|----------|------|
| **Shift+L — следующий уровень** | единственный вменяемый способ тестировать уровни 2..15 | тривиально: `++pop_next_level` из §2.2 |
| **R — воскресить Кида** | у нас респавн по ↑ + таймаут; порт `resurrect` ближе к оригиналу и не мешает управлению | низкая |
| **Shift+S / Shift+T — +1 HP / +максимум** | отладка боёвки без «ровно трёх попыток»; честная замена нашему читу бессмертия | низкая, HP-машинерия уже есть |
| **[ и ] — сдвинуть Кида на пиксель** (debug-чит SDLPoP) | прямо бьёт в наш класс багов «окклюзия/шов на один пиксель» — воспроизведение позы без ловли момента | тривиально |
**Брать во вторую очередь:**
| Чит | Почему позже |
|-----|--------------|
| **H / J / U / N + Ctrl+B — смотреть соседние комнаты** | требует честной модели `drawn_room ≠ Kid.room` (наш S3-straddle, каркас есть: `update_kid_render_dx`). Зато потом заменяет самодельный `ROOMNAV` и попутно закрывает straddle-задачу |
| **Shift+W — медленное падение (feather)** | ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` уже есть, но не проверена ничем — чит станет её единственным тестом |
| **C / Shift+C — номера комнат** | у нас номер рисуется палочками именно потому, что текст тянет 2 КБ знакогенератора в W2 (`roomtest.c`). Ждёт своего шрифта |
**Не брать:** `Shift+I` (переворот экрана), `Shift+B` (blind mode) —
развлекательные, к отладке порта отношения не имеют. `/+` (время) — нужен
таймер уровня, которого у нас нет (Фаза 6).
**Отдельно, дорого, но очень ценно — `F6`/`F9` (quicksave/quickload точного
состояния).** Это сериализация `Char` + `room_modif` всех комнат + trob'ов +
состояния стражей. Даёт то, чего нам сейчас сильно не хватает:
воспроизводимый регресс в MAME («вот кадр, где баг») вместо ручного подхода
к позиции. Кандидат сразу после того, как заработают уровни.
---
## 5. Риски и что проверить артефактом до кодинга
1. **Размер кода.** Замер сборки 2026-08-01: `_CODE` 25 119 Б,
куча ~2.4 КБ, банк 2 (`pop_bg`) 13 792 / 16 384, банк 3 (`pop_map`)
6 331, банк 1 (`guards`) 1 896, банк 4 (`pop_gdraw`) 2 236. Чомперы,
зеркало, скелет и второй тайлсет пойдут в банк 2 — там осталось 2.6 КБ.
**Прежде чем начинать §3, посчитать, куда лягут новые тайлы**, иначе
повторится история «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные
номера банков есть (5+), гранулярность — файл.
2. **Спецсобытия уровней** (`seg002.c`: `level3_set_chkp`, `sword_disappears`,
`Jaffar_exit`, зеркало, мышь) — их НЕ надо портировать заранее. Для
уровней 2 и 3 нужен только чекпойнт уровня 3. Остальное — по мере
подхода к уровню.
3. **Чомперы** (уровень 3 и почти все дальше) — отдельная механика
(`animate_chomper` + коллизия + смерть); шаблон работы тот же, что у
пик/ворот, см. `gates_spikes_plan.md`.
4. **`tbl_guard_type = -1`** на уровнях 14/15: без проверки страж
«появится» из неинициализированных данных.
5. **Уровень 0 (демо)** существует в данных, но в скоуп не входит.
-143
View File
@@ -1,143 +0,0 @@
# Loose floors (проваливающиеся полы) — план порта
Разбор SDLPoP (seg007 loose/trob/mob, seg008 draw_loose). ПЛАН, ещё не
реализовано. Тайл в комнате 1: `[2,6] = 0x0B = tiles_11_loose`.
## 1. Хранение состояния
- **Тип тайла**: `curr_room_tiles[tilepos] & 0x1F == 11` (tiles_11_loose).
Бит `0x20` = «solid» loose (авто-падающий вариант, ур.13 — от шага НЕ
падает). После падения тайл → `0` (tiles_0_empty).
- **Модификатор** `curr_room_modif[tilepos]` = состояние анимации:
- `0` — покой (обычный loose-пол);
- `0x80..0x83`**трясётся** (бит7); за ~4 кадра затухает обратно в 0;
- `1..11`**обратный отсчёт до падения** (на нём что-то стоит);
достигает `loose_floor_delay = 11` → падает.
## 2. Анимация тряски (shake) — когда включается
- **Триггер = do_knock** (seg007:0FE0): на ЖЁСТКОМ приземлении в кадрах
посадки играет `SEQ_KNOCK_DOWN` → взводит `knock``check_knock()`
`do_knock(room, curr_row (knock>0))`.
- `do_knock(room, row)`: по всем колонкам ряда — если тайл loose →
`loose_make_shake()`.
- `loose_make_shake()` (seg007:0FB4): если `modif==0` (и не ур.13) →
`modif = 0x80`, `add_trob(type 1)`.
- **Отсюда кейс пользователя**: Kid падает/приземляется на `[2,4]`
do_knock трясёт ВСЕ loose-тайлы ряда 2 → `[2,6]` трясётся. (Через
knock-смещение ряда может задеть и соседний ряд.)
- `animate_loose` (кадрово): `++modif`; при бите7 трясёт до `>=0x84`
сброс в 0, `trob.type=-1`. `loose_shake()` играет звук
(sound 20/21/22) по таблице `loose_sound[]`.
## 3. Анимация падения (fall) — когда включается
- **Триггер = make_loose_fall(1)** (seg007:0EF6), вызывается когда:
- Kid СТОИТ на loose-тайле — `check_press()` (seg006): кадр с
FRAME_NEEDS_FLOOR над loose → make_loose_fall(1);
- зацеп/подтягивание на loose (`check_grab`, `check_jump_up`);
- пробой сверху: кадр 79 (jumphang) над loose → make_loose_fall(1);
- авто-падающие (ур.13) — `make_loose_fall(-(prandom&0x0F))`.
- `make_loose_fall(modifier)`: если НЕ solid (`tiles & 0x20 == 0`) и
`(sbyte)modif <= 0``modif = modifier`, `add_trob(type 0)`.
- `animate_loose`: `++modif`; когда `modif >= 11` (loose_floor_delay) →
`remove_loose()` (тайл → empty) + `add_mob()` (спавн падающего куска).
## 4. Падающий кусок (mob)
- `add_mob()` кладёт `curmob` в `mobs[]` (до 14). `do_mobs()` каждый
кадр: `move_mob()` (гравитация, y растёт) + `check_loose_fall_on_kid()`
(урон Киду/страже, если попал).
- Приземление куска → тайл под ним `curr_room_tiles[...] = tiles_14_debris`
(seg007 move_mob:1053). Т.е. **loose(11) упал → сверху empty(0), снизу
debris(14)**.
## 5. Отрисовка по статусу
- Куски тайла: `loose_fram_left[]={41,69,41,70,70,41,41,41,70,70,70,0}`,
`loose_fram_right[]={42,71,...}`, `loose_fram_bottom[]={43,73,...}`
(env-спрайты, seg008:518/596/608).
- Индекс кадра = `get_loose_frame(modifier)` (seg008): `0` = ровный
(41/42/43); `1..10` = дрожащие варианты (69–74); при бите7/большой
задержке — низкие индексы.
- **До падения**: рисуем loose с `get_loose_frame(modif)` (0 = ровно,
иначе колеблется). **После**: сверху empty, снизу debris(14) — обычная
статическая отрисовка (у нас уже есть tile 0x0E/14 debris в tile_table).
## 6. Что нужно в нашем движке (сейчас НЕТ)
Наш `pop_bg` рисует комнату СТАТИЧЕСКИ один раз. Loose-полы требуют
**динамического тайлового слоя**:
1. **Массив модификаторов** `room_modif[30]` (у нас есть `bg[30]` — можно
переиспользовать/рядом) — состояние каждого тайла.
2. **Очередь trob** (список анимируемых тайлов) + `animate_loose` пер-кадр
→ перерисовка ТОЛЬКО изменившихся тайлов (как heal-прямоугольник Kid).
3. **make_loose_fall / do_knock / loose_make_shake** — триггеры (из
физики Kid: приземление→knock, стойка на loose→fall).
4. **mob-система** (падающий кусок): минимум 1–2 mob'а, гравитация,
приземление → debris. Урон Киду (`check_loose_fall_on_kid`) — можно
Фазой 2.
5. **Перерисовка тайла**: `draw_tile(row,col)` у нас уже умеет loose
(`code==11`, `loose_fram_*` в env) — нужно вызывать его выборочно с
текущим модификатором (сейчас draw_tile берёт статический bg).
**Порядок реализации (предложение):**
- L1: room_modif[] + выборочная перерисовка тайла по модификатору
(draw_loose с get_loose_frame) — статика→динамика одного тайла.
- L2: trob-очередь + animate_loose (тряска по do_knock на приземлении).
- L3: make_loose_fall (стойка на loose) + отсчёт + remove → empty.
- L4: mob (падающий кусок → debris снизу).
- L5: урон Киду от падающего куска.
## Конкретика из SDLPoP (сверено 2026-07-18, готово к реализации)
Таблицы (seg008.c), индекс = `get_loose_frame(modif)`:
- `loose_fram_left[] = {41,69,41,70,70,41,41,41,70,70,70,0}`
- `loose_fram_right[] = {42,71,42,72,72,42,42,42,72,72,72,0}`
- `loose_fram_bottom[]= {43,73,43,74,74,43,43,43,74,74,74,0}`
- `get_loose_frame(m)`: если `(m&0x80)` (или delay>11) → `m&=0x7F; if(m>10) return 1;``return m;`
- `y_loose_land[] = {2,65,128,191,254}` (mob), `loose_floor_delay = 11`.
Триггеры (call-sites):
- **make_loose_fall(modifier=1)** — из `check_press()` (seg006): когда Kid
СТОИТ на тайле (FRAME_NEEDS_FLOOR, action < hang_climb / turn / bumped) и
`get_tile_at_char()==11`; ИЛИ `frame==79` (прыжок вверх) и
`get_tile_above_char()==11` (пробой сверху). `tile_is_floor(11)==1`
Kid стоит на loose (start_fall НЕ зовётся). Тело:
`if(!(tile&0x20) && (sbyte)modif<=0){ modif=modifier; add_trob(type0); }`
- **do_knock(row)** — на ЖЁСТКОМ приземлении (SEQ_KNOCK_DOWN→check_knock,
seg003): по всем колонкам ряда `if(tile==11) loose_make_shake()`
(`if(modif==0){ modif=0x80; add_trob(type1); }`).
- **animate_loose** (каждый кадр, seg007:816): `++modif`; если `&0x80`
(тряска): `if(modif>=0x84){modif=0; trob=-1;}`; иначе (отсчёт):
`if(modif>=11){ remove_loose(tile→0); trob=-1; add_mob(); } else shake`.
## Интеграция в наш движок (roomtest) — подход
Наш движок ПЕКЁТ комнату один раз (двойной буфер: своя ОЗУ-копия на
страницу). Loose требует динамики + общего состояния pop_map↔pop_bg:
1. **Общая МУТАБЕЛЬНАЯ копия комнаты**: roomtest.c держит `uint8_t
room_fg[30]` (копия room1_fg) и передаёт ОДИН указатель и в
`pop_room_draw`, и в `pop_map_set` → мутации loose видны обоим.
(Сейчас g_fg — `const`; сделать неконстантным.)
2. **Состояние**: `uint8_t pop_loose_modif[30]` (0 / 0x80.. / 1..11).
3. **Модель** (pop_map): `check_press()` в `pop_phys_tick` (make_loose_fall
при стоянии на 11), `animate` каждый кадр.
4. **Перерисовка** (pop_bg, на back-странице каждый кадр):
- тряска/отсчёт (код всё ещё 11): `gfx_heal(tile rect)` (вернуть
печёный фон) + нарисовать loose-кадр (left/right/bottom по
get_loose_frame) банком SPRITE;
- падение (11→0): mutate `g_fg[pos]=0` + «запечь пустоту» на ОБЕИХ
страницах (bake-счётчик 2: чёрный bar + draw_tile empty банком NORMAL
на текущей странице 2 кадра подряд) → дальше heal показывает пусто.
5. **L4 mob**: падающий кусок → debris(14) снизу (y_loose_land); пока
отложено — упавший loose = пусто. **L5** урон — позже.
Риск: перерисовка динамического тайла в двойном буфере (per-page heal +
bake) — единственное тонкое место; остальное — прямой порт логики выше.
## Связанные
Триггеры завязаны на физику Kid ([[pop_hang_state]] check_press/check_grab,
приземление land/SEQ_KNOCK_DOWN). Отрисовка — [[pop_fore_layer]] /
[[pop_background_strategy]] (draw_tile уже знает loose_fram_*).
+529
View File
@@ -0,0 +1,529 @@
# Pause menu и Settings для Sprinter PoP
Статус: **MS0, MS2 и MS4MS8 выполнены** (2026-08-23). Pause menu, CFG,
Settings, диалоги, Controls и build screen находятся в bank 9. Решение о
рендеринге и затемнении — §10.
Связанные документы:
- [`full_game_plan.md`](full_game_plan.md) — автомат состояний, title,
demo, cutscenes и ending;
- [`quicksave_plan.md`](quicksave_plan.md) — состав и восстановление снимка.
## 1. Решения
- Программа работает только с HDD; настройки, QuickSave и Hall of Fame
всегда могут быть постоянными файлами.
- Основной pause menu обязательно содержит QuickSave и QuickLoad.
- QuickSave имеет один слот `POP.SAV`; предыдущая корректная запись хранится
как `POP.BAK`.
- Первая версия имеет один профиль `VANILLA`. Под этим именем пока понимается
**текущее поведение roomtest**, включая уже встроенные исправления.
- Дизайн файла и API предусматривает будущий `ENHANCED`, но аудит и
переключение fixes сейчас не выполняются.
- Уровень 15/copy protection отсутствует.
- Моды, levelsets и меню Mods отложены.
## 2. Что есть в SDLPoP
`src/menu.c` содержит:
- Resume, QuickSave, QuickLoad, Restart Level, Settings, Restart Game, Quit;
- General, Gameplay, Visuals, Mods, Controls;
- toggle/number/key controls, пояснения, scroll и confirmation dialogs;
- большой список fixes/enhancements и custom level options.
На Sprinter не переносятся SDL-специфичные параметры: fullscreen, hardware
acceleration, scaling, aspect ratio, rumble. UI берёт структуру SDLPoP, но
набор настроек соответствует платформе.
## 3. Pause menu первой версии
```text
RESUME
QUICKSAVE (F6)
QUICKLOAD (F9)
RESTART LEVEL
SETTINGS
RESTART GAME
QUIT GAME
```
Поведение:
- `Esc` в `PLAYING` открывает меню; повторный Esc или Resume возвращает игру;
- игра, логический таймер и звуковой насос ставятся на паузу согласованно;
- QuickSave/QuickLoad только взводят запрос, фактическая операция идёт на
безопасной границе кадра;
- QuickLoad disabled/показывает `NO QUICKLOAD`, если нет валидных SAV/BAK;
- перед QuickLoad из меню лёгкий probe проверяет заголовок и checksum обоих
файлов: валидный `POP.BAK` при отсутствующем/битом `POP.SAV` требует
отдельного `LOAD BACKUP?`, а не загружается молча;
- Restart Level и Restart Game выполняются сразу, БЕЗ подтверждения
(2026-08-22): Restart Level перечитывает уровень, Restart Game завершает
gameplay и возвращает к первому экрану title/intro; новая игра создаётся
общим LEVEL_LOAD только после skip/attract;
- Quit требует подтверждения и закрывает файлы/каналы штатным путём;
- меню недоступно в demo, cutscene, time-expired и ending;
- отдельная debug-комбинация немедленного выхода может остаться только в
отладочной сборке.
## 4. Settings первой версии
```text
GENERAL
Sound ON / OFF
Show Sprinter screen ON / OFF
Restore defaults...
GAMEPLAY
Speed NORMAL / FAST / FASTEST
Gameplay profile VANILLA
Cheats ON / OFF
CONTROLS
Show key bindings
BACK
```
`Gameplay profile: VANILLA` показывается read-only: место в модели уже есть,
но пользователь не может выбрать ещё не реализованный ENHANCED.
Изменения применяются немедленно к скорости, читам и звуку, но `POP.CFG`
записывается один раз при Back/Esc. На экране есть итог `SETTINGS SAVED` или
`SAVE ERROR`; во втором случае runtime-значения остаются рабочими.
Отладочные параметры `ROOMNAV`, border profiling, stop-frame и переключение
double buffering не являются пользовательскими Settings. Они остаются
compile-time/debug функциями и скрываются из release UI.
## 5. Модель настроек
Игровой код не должен читать UI-структуры. Единственный runtime-контракт:
```c
typedef enum {
POP_PROFILE_VANILLA = 0,
POP_PROFILE_ENHANCED = 1
} pop_gameplay_profile_t;
typedef struct {
uint8_t sound_enabled;
uint8_t speed_mode;
uint8_t gameplay_profile;
uint8_t cheats_enabled;
uint8_t show_build_info;
uint16_t enhancement_flags;
} pop_settings_t;
```
В первой версии загрузчик принимает только `POP_PROFILE_VANILLA`. Значение
ENHANCED из более нового/ручного файла заменяется на VANILLA с диагностикой,
а не включает частично реализованный режим.
Будущий профиль задаёт маску возможностей централизованно:
```text
VANILLA -> текущий согласованный набор
ENHANCED -> будущий рекомендуемый набор fixes
CUSTOM -> только если позже действительно понадобится
```
До отдельного аудита существующие `fix_exit_door`, feather guard behavior,
jump grab и sound priorities не переключаются и считаются частью текущего
VANILLA.
## 6. Файл POP.CFG
Бинарный, компактный, версионированный формат:
```text
+0 "PCFG" magic, 4 Б
+4 format_version 1 Б
+5 payload_size 2 Б
+7 payload фиксированные поля little-endian
.. checksum 2 Б
```
Требования:
- путь рядом с exe/в выделенном каталоге игры на HDD;
- неизвестная версия, неверная длина или checksum -> defaults;
- неизвестные будущие хвостовые поля можно пропустить по `payload_size`;
- запись только после Apply/выхода из Settings, не на каждый шаг курсора;
- ошибка записи не завершает игру: показать сообщение и оставить runtime
значения;
- Restore defaults меняет RAM только после подтверждения и затем сохраняет.
CFG не содержит состояние уровня, QuickSave или Hall of Fame.
## 7. QuickSave / QuickLoad в меню
Детальный состав снимка и порядок восстановления — в
[`quicksave_plan.md`](quicksave_plan.md). Здесь фиксируется UI и файловая
транзакция.
### Один слот и backup
Файлы:
```text
POP.SAV текущий слот
POP.BAK предыдущий валидный слот
POP.NEW временный файл во время записи
```
Безопасная запись:
1. записать полный снимок в `POP.NEW`;
2. закрыть файл;
3. повторно открыть/прочитать заголовок и checksum;
4. старый валидный `POP.SAV` перенести/скопировать в `POP.BAK`;
5. `POP.NEW` сделать новым `POP.SAV`;
6. при любой ошибке сохранить прежний `POP.SAV`.
Точную последовательность rename/copy выбрать после характеризации DSS.
Если атомарный rename не гарантирован, использовать copy + fsync/close и
никогда не удалять единственную валидную копию до проверки новой.
### Загрузка
1. проверить `POP.SAV`;
2. если он отсутствует/повреждён/несовместим — проверить `POP.BAK`;
3. при валидном BAK показать `LOAD BACKUP?`;
4. несовместимая версия — `INCOMPATIBLE SAVE`, без частичной загрузки;
5. после успеха закрыть menu, перерисовать обе страницы, перезапустить звук.
### Сообщения
Минимальный набор:
```text
QUICKSAVED
QUICKLOADED
NO QUICKLOAD
SAVE ERROR
INCOMPATIBLE SAVE
LOAD BACKUP?
```
Сообщение показывается UI-слоем, но операция завершается до возврата в
игровой кадр.
## 8. Restart Level / Restart Game
Restart Level:
- использует существующий штатный reset текущего уровня;
- не перечитывает CFG;
- не меняет `POP.SAV`;
- сбрасывает состояние, которое сбрасывает текущая реализация roomtest.
Restart Game:
- выполняется сразу, без подтверждения;
- завершить текущий gameplay session и вернуть автомат в TITLE;
- начать title/intro с самого первого экрана;
- создать новую игру с `FIRST_LEVEL` и новым глобальным таймером только
после пользовательского skip либо ввода в attract-demo;
- настройки оставить;
- QuickSave не удалять.
## 9. Controls
Первая версия только показывает активную раскладку. Переназначение клавиш
откладывается: raw PS/2 канал имеет особенности Shift и расширенных кодов,
поэтому generic key-binding UI требует отдельного проекта.
Экран должен перечислить минимум:
- движение и Shift/action;
- Esc/menu;
- F6/F9 QuickSave/QuickLoad;
- Ctrl+S sound;
- P speed;
- доступные cheats, только если они включены: K/Kill Guard, I/Immortal,
Shift+L/Next Level, U/Flip Screen и F7/F8/Time /+ на отдельных понятных
строках. Нижней подсказки `Esc or Enter: Back` нет.
## 10. UI renderer и ввод
### 10.1. Выбор способа отрисовки: текст против спрайт-атласов
Ограничение платформы: стандартный текстовый вывод libbgi (`outtextxy`)
не годится — он тянет системный знакогенератор в `_gfx_font_buf` (2 КБ
статики в W2) плюс жирный резидентный код, а W1/W2 забиты игрой
(тот же вывод зафиксирован комментарием в `roomtest_cold.c`, где отладочный
борд рисуется палочками именно поэтому). Значит, любой вариант требует
СВОЕЙ реализации вывода меню, живущей в отдельном банке (память на банк
есть; скорость не критична — меню работает на паузе).
Рассматривались два подхода.
**Вариант A — текстовые строки + собственный растровый рендерер.**
Плюсы:
- минимальные данные: шрифт 2–4 КБ + таблицы строк по сотни байт на язык;
- весь динамический текст бесплатно: значения опций (ON/OFF,
NORMAL/FAST/FASTEST), сообщения (`QUICKSAVED`, `INCOMPATIBLE SAVE`),
диалоги (`LOAD BACKUP?`), экран Controls, будущий ввод инициалов
Hall of Fame — без текстового движка HoF вообще не сделать;
- правка формулировки = правка C-строки, мгновенные итерации;
- локализация = вторая таблица строк (+ вторая половина глифов);
- **решающий аргумент: так сделано в самом SDLPoP** — см. §10.2.
Минусы:
- надо написать рендерер (блиттер глифа + строка + центрирование +
подсветка) — небольшой, но свой;
- вид определяется качеством шрифта-ассета.
**Вариант B — готовые спрайт-атласы** (атлас главного меню с активными/
неактивными пунктами, атлас вложенного меню, атлас каждой опции
On/Off и т.д.).
Плюсы:
- аутентичный вид: любая типографика/декор запекаются при упаковке;
- вывод = существующий блит атласов, текстовый движок не нужен;
- язык = другой файл атласа с диска, ноль логики.
Минусы:
- комбинаторика ассетов: 7 пунктов × состояния + вложенные меню + значения
всех опций + все сообщения + все диалоги ≈ десятки КБ raw на язык до RLE;
второй язык удваивает;
- любая правка текста = перегенерация ассетов + перекладка ресурсов;
- динамический текст (HoF initials) всё равно потребует шрифтового движка —
получили бы ОБЕ системы сразу.
**Решение (2026-08-22): Вариант A**, шрифт — ассет. Спрайты остаются только
для нетекстового декора (рамка/фон меню, маркер выделения — как arrowheads
в SDLPoP). Титульный экран — полноэкранная картинка, тема `full_game_plan.md`.
### 10.2. Референс: как устроено меню в SDLPoP
`SDLPoP/src/menu.c` + текстовый движок `seg009` — источник структуры:
- **Текстовые строки + встроенный пропорциональный bitmap-шрифт**
`hc_small_font_data[]` (menu.c:2488): символы 32..126, каждый глиф —
монохромное изображение переменной ширины; `font_type`
{first_char, last_char, space_between_chars, height_above_baseline, chtab}.
Никаких per-item атласов, хотя SDL_ttf доступен.
- Вывод — портированный движок оригинального DOS PoP (seg009):
`draw_text_character``method_3_blit_mono(image, x, y, textblit,
textcolor)`; `get_line_width` для центрирования; перенос по словам.
Тем же движком рисуются in-game тексты и copy protection.
- Пункты меню — data-driven C-структуры `{id, previous, next, required,
char text[32]}` + таблицы `pause_menu_items[]` / `settings_menu_items[]`;
`required` — указатель на флаг disabled, такие пункты пропускаются при
навигации (prev/next пересчитываются).
- Выделенный пункт = смена цвета текста (bright-white против обычного) +
рамка-контур `draw_rect_contours(selection_box, lightgray)`; НЕ отдельный
спрайт «активного пункта».
- Фон меню — затемнение замороженного игрового кадра:
`draw_rect_with_alpha(black, alpha=120)`, внизу просвечивает «GAME PAUSED».
- Settings — декларативная таблица `setting_type` со стилями TOGGLE / NUMBER /
TEXT_ONLY / KEY, геттером/сеттером/increase/decrease значения, строкой-
explanation внизу экрана, скроллом длинных списков и фокусом «левая половина
(список) / правая половина (значения)».
- Диалоги — один общий `draw_confirmation_dialog(text)` + обработчик
результата; диалог возвращает решение автомату меню.
- Мини-спрайты только для декора значений (arrowheads up/down/left/right).
- Навигация озвучена (menu tick), ввод клавиатура+мышь, hover по прямоугольникам.
### 10.3. Наша реализация
- Банк 9: код рендерера,
шрифт, таблицы строк, автомат меню. Резидентно — только request-flag и
вызов процесса на границе кадра (паттерн pop_qsave_io).
- Рендерер повторяет минимальный контракт seg009: пропорциональные глифы,
baseline, `draw_string` и центрирование по сумме advance. Блит идёт через
W0-атлас, в `GFX_BANK_SPRITE`: `0xFF` в атласе пропускается, а UI временный
и не портит теневую копию игрового фона. Перед каждым кадром UI `gfx_copy_page` переносит чистый
shadow видимой страницы в скрытую, затем готовый кадр показывается только
на следующем фронте. При выходе чистый фон тем же способом возвращается на
обе страницы и восстанавливается исходная visible-страница. Поэтому
перемещение выделения не показывает поэтапную перерисовку и не оставляет
следов на back buffer.
- Шрифт — АССЕТ из **оригинальных** `hc_small_font_data[]` и
`hc_font_data[]` SDLPoP, не системный ZG и не TTF. Паковщик
`toolchain/pop_extract_font.py` делает `FONT\\font.atl`: 95 ASCII-глифов
малого и 95 крупного шрифта (7667 Б). Номер ленты вычисляется из ASCII,
поэтому это один текстовый движок, а не атлас готовых надписей.
- Двуязычность (eng/rus): строки храним в CP866 — латиница и кириллица одним
байтовым порядком, одна кодировка на оба алфавита. Локаль = пара
(указатель на таблицу строк, файл шрифта); переключатель — одна настройка.
Русские строки длиннее английских ~10–15% — раскладку экранов и ширину
колонок закладывать по русской. Второй язык можно добавить позже без
переделки: сначала eng.
- Визуальная композиция MS4 следует SDLPoP: замороженная сцена остаётся
открытой, поверх неё компактный центрированный список без чёрной карточки,
выбранная строка обведена тонким светло-серым контуром, а крупное
`GAME PAUSED` лежит в нижнем борту. Цвета текста и контура берутся из
стабильного диапазона палитры 0x37..0x3F.
- Фон открытого меню: снимок текущей палитры, затемнение всех слотов кроме
UI 0x37..0x3F и точное восстановление при выходе. Снимок хранится в
свободном хвосте EMM-страницы шрифта, не в W2.
- Навигация MS4: вверх/вниз, Enter/Esc, edge-triggered поверх `kbd_raw`.
Left/right и menu tick добавляются вместе с настройками на MS5.
Первый UI может быть визуально простым. Критично отсутствие потери клавиш,
предсказуемая пауза и отсутствие повреждения игрового back buffer.
### 10.4. Затенение экрана под меню — решение MS4
Режим меню виден сразу: bank 9 делает динамический снимок palette 0,
затемняет RGB-каналы вдвое и пишет одинаковый результат в обе экранные
палитры. Девять стабильных UI-слотов 0x37..0x3F не гасятся. При Resume/Enter
палитра восстанавливается из EMM-снимка. Это выбранный вариант Б ниже;
ступенчатый fade для роликов пока не нужен и остаётся отдельной будущей
задачей, а не причиной раздувать MS4.
**Как сделано в SDLPoP** (`seg009.c`):
- Меню: `draw_rect_with_alpha(&screen_rect, color_0_black, pause_menu_alpha)`
(menu.c:1364) — альфа-заливка чёрным поверх замороженного кадра средствами
SDL; нижняя полоса рисуется с alpha=0, чтобы сквозь неё просвечивало
«GAME PAUSED». Прямого аналога на Sprinter НЕТ (альфа-блендинг в железе
отсутствует) — это SDL-специфика, переносить нечего.
- Ролики/переходы: `fade_in_2/fade_out_2(rows)` (seg009.c:3947+, вызовы из
seg000.c) — ПОШАГОВОЕ затухание ПАЛИТРЫ к чёрному и обратно: палитра
копируется, каждая строка по 16 цветов гасится за несколько кадров
(`which_rows` маской выбирает, какие строки участвуют: 0x800/0x1000/...).
Вот этот механизм на Sprinter воспроизводим один в один.
Отсюда рабочая гипотеза: наш примитив = «снимок текущей палитры → ступенчатое
приближение к затемнённой копии (кроме резервного блока для UI)», статично для
меню и анимированно для роликов/переходов. Варианты:
**Вариант А — единая основная палитра (глобальный рефакторинг палитры).**
1. Собрать ВСЕ палитры игры (уровневые наборы `pal_env*`, kid.pal, палитра
Тени и пр.) в одну общую 256-цветную; использовать её целиком всегда.
Сейчас переиспользования цветов НЕТ — каждая загрузка ассетов перезаписывает
слоты (см. pop_boot: kid.pal затирает тайловые цвета, приходится
восстанавливать `pop_bg_pal_apply`/`pop_shadow_pal_apply`).
2. Для затенения — затемнённая копия основной палитры, КРОМЕ зарезервированного
блока из 16 цветов для самого меню (кандидат — стандартные 16 цветов VGA).
3. Выход из меню — возврат к полной основной палитре.
Плюс: решает попутно существующую боль с перезаписью палитр при загрузках.
Минус: большой разовый рефакторинг упаковщиков и всех загрузчиков атласов;
нужен аудит, что все цвета всех уровней влезают в 256. **Против говорит
план перевода камней подземелья на цвета VGA-версии PoP: там ряд уровней
несёт ДРУГУЮ палитру, отличную от SDLPoP (VDUNGEON/VPALACE каскад,
levels_plan.md), — единая палитра этому прямо противоречит.**
**Вариант Б — динамический снимок текущей палитры (сейчас выглядит
предпочтительным).**
1. При открытии меню прочитать всю текущую палитру, сохранить.
2. Записать затемнённую копию (кроме зарезервированного блока для меню).
3. При выходе — восстановить сохранённую.
Плюс: локальная правка внутри меню, ничего в пайплайне ассетов не меняется;
работает при любой текущей палитре автоматически — включая будущие
уровне-специфичные палитры VGA-камней; тот же примитив ступенями даёт
fade-out/fade-in для роликов и переходов между уровнями (как fade_*_2 в
SDLPoP). Минус: чтение/запись 256 записей палитры при входе/выходе (раз на
открытие — дёшево); затемнение «на глаз» может по-разному выглядеть на разных
уровнях.
Резервный блок 16 цветов нужен в ОБОИХ вариантах; текущий диапазон 0x37..0x3F
(стабильный, проверен) даёт 9 цветов — этого может не хватить на
текст+подсветку+рамку, тогда резервировать отдельный блок.
**Следствие для архитектуры:** работа с цветом/палитрой должна собраться в
ОДИН модуль (сейчас она разбросана: gfx_pal_* вызовы в boot, pop_bg_pal_apply,
pop_shadow_pal_apply, вспышки урона в roomtest.c и т.д.). Модуль палитры —
единственный владелец записи в палитру и предоставляет примитивы, которые
понадобятся и меню, и роликам:
```text
pal_snapshot()/pal_restore() — снимок/восстановление всей палитры
pal_dim(step) / pal_undim(step) — ступени затемнения (кроме резервного блока)
pal_fade_out(rows)/pal_fade_in(rows) — анимированное затухание по строкам
(порт fade_out_2/fade_in_2, seg009)
```
Меню уже использует snapshot+dim локально в bank 9. Когда появятся ролики,
выделить из него общий palette/fade-модуль; вспышка урона сможет переехать
туда же после отдельного аудита.
## 11. Диалоги
Общий диалог подтверждения:
```text
QUIT GAME?
RESTORE DEFAULTS?
LOAD BACKUP?
YES / NO
```
Диалог не выполняет действие напрямую: он возвращает решение автомату меню,
который формирует команду приложению. Так UI не зависит от gameplay-модулей.
По умолчанию выбран `NO`; Up/Down/Left/Right меняют ответ, Enter подтверждает,
Esc отменяет. Реализованы все три вопроса: Quit, Restore defaults и backup
QuickLoad. В Quit-dialog вопрос и `YES / [NO]` заключены в общую рамку;
отдельная строка `Enter: Select Esc: Cancel` не выводится.
## 12. Будущий ENHANCED
Не реализуется сейчас, но дизайн обязан позволять:
- добавить второй профиль без смены всего UI;
- хранить `enhancement_flags` в CFG;
- отличать технические исправления порта (всегда включены) от изменений
оригинальной механики;
- провести аудит уже встроенных исправлений;
- покрыть каждый переключаемый fix host/MAME тестом;
- при необходимости добавить Advanced screen, не раздувая основной menu.
До этого момента нельзя рассыпать проверки `if (enhanced)` по горячему коду.
Сначала составляется реестр и выбирается минимальная битовая модель.
## 13. Этапы реализации
| этап | результат | критерий приёмки |
|---|---|---|
| **MS0** ✓ | определить команды app/menu и структуру settings | UI возвращает команду главному циклу; прямых gameplay-вызовов нет |
| **MS1** | проверить запись/rename/copy на HDD DSS | crash/power-loss сценарий не теряет обе копии save |
| **MS2** ✓ | `POP.CFG`: defaults, load, validate, save | v1 codec, будущий хвост, checksum; повреждённый CFG даёт defaults |
| **MS3** | QuickSave hotkeys + POP.SAV/BAK | полный критерий `quicksave_plan.md` |
| **MS4** ✓ | текстовый рендерер + два шрифта (малый для пунктов, крупный для сообщений) + минимальный pause menu | SDLPoP fonts в одном W0-atlas, центрирование, dim/restore palette и tear-free page flip; все семь пунктов видимы, навигация и Resume/QuickSave работают в MAME |
| **MS5** ✓ | General/Gameplay Settings | значения применяются сразу и после Back/Esc записываются в POP.CFG |
| **MS6** ✓ | dialogs + backup recovery | подтверждения default-NO; QuickLoad спрашивает перед валидным POP.BAK |
| **MS7** ✓ | Controls help | показаны движение, action, menu, save/load, звук, speed и conditional cheats; MAME проверил отдельные K/I и Shift+L/U и возврат Esc ровно на один уровень |
| **MS8** ✓ | build info | CFG читается до первого показа; включаемый build screen получает ID и дату из Make/git |
QuickSave (`MS1/MS3`) можно реализовать раньше визуального menu: сначала
F6/F9 и сообщения, затем подключить те же команды к пунктам UI.
## 14. Тесты
- Host: CFG round-trip, defaults, bad magic/version/size/checksum.
- Host: меню navigation, disabled items, confirmations, команды приложению.
- Host: рендерер строк — вывод глифов обеих локалей, центрирование,
ширина строки для малого и крупного шрифта.
- Host: SAV invalid -> BAK valid; оба invalid -> NO QUICKLOAD.
- MAME: F6, изменение сцены, F9; затем рестарт программы и повторный F9.
- MAME: прервать запись/испортить SAV — BAK остаётся загружаемым.
- MAME: pause на бое/падении, Resume не меняет состояние и таймер; смена
выбранного пункта не показывает промежуточный кадр и после закрытия не
оставляет меню на второй странице.
- MAME: Settings сохраняются после полного выхода и запуска с HDD.
- MAME: включить Show Sprinter screen, перезапустить `roomtest`, увидеть
build ID/date до первого игрового кадра и пропустить экран Esc/Enter/Space.
- Проверка лимита 8 DSS handles на каждом error path.
- `make size-check`; menu/text строки не должны съесть резидентный бюджет.
## 15. Не входит в план
- Mods и выбор levelset;
- уровень 15/copy protection;
- несколько save slots;
- replay/recording;
- key rebinding;
- SDL visual/controller options;
- фактическая реализация ENHANCED и individual fix switches.
+275
View File
@@ -0,0 +1,275 @@
# Слои отрисовки: как устроен оригинал и чего стоит порт
Разбор 2026-08-13, по `../SDLPoP/src/seg008.c`. Повод — семь дефектов
падающих плит на уровне 13, из которых три оказались не багами кода, а
следствием того, что у нас нет слоя, в котором объекты и куски тайлов
сортируются между собой. Решение по этому документу ещё не принято.
---
## 1. Как это работает в оригинале
### 1.1 Три таблицы, а не «слои»
`draw_tables` (seg008:1373) рисует ровно в таком порядке:
```
restore_peels();
draw_wipes(0);
draw_table(0); // BACKTABLE
draw_table(3); // MIDTABLE
draw_wipes(1);
draw_table(1); // FORETABLE
```
Это грубое разделение на три уровня глубины. Куда попадёт кусок тайла,
решает переменная `ptr_add_table`, которую вызывающий переставляет перед
`draw_tile*`: по умолчанию `add_backtable`, в оверлее кромки —
`add_midtable` (`draw_other_overlay`, seg008:1499), а `add_foretable`
вызывается явно и точечно.
### 1.2 Объекты живут НЕ в таблицах, а в objtable — и привязаны к ТАЙЛУ
Персонажи, падающие куски, мечи, брызги попадают в `objtable`, и у каждой
записи есть поле `tilepos` — тайл, которому объект принадлежит.
Ключевое: объекты рисуются **не отдельным проходом после фона**, а ВНУТРИ
обхода тайлов. В `redraw_needed_tile` (seg008:207) стоит:
```c
if (tile_object_redraw[tilepos]) {
if (tile_object_redraw[tilepos] == 0xFF)
draw_objtable_items_at_tile(tilepos - 1);
draw_objtable_items_at_tile(tilepos);
tile_object_redraw[tilepos] = 0;
}
if (redraw_frames_fore[tilepos]) draw_tile_fore();
```
То есть на каждом тайле: сначала его фоновые куски, потом объекты ЭТОГО
тайла, потом его передние куски.
### 1.3 Порядок глубины складывается из ТРЁХ независимых механизмов
| механизм | что даёт |
|---|---|
| порядок обхода тайлов: ряды **2, 1, 0**, колонки 0..9 (seg008:129) | тайл, обойдённый позже, рисуется поверх |
| сортировка объектов ВНУТРИ одного тайла (`sort_curr_objs`, seg008:1553) | кто из объектов одного тайла поверх кого |
| три таблицы back/mid/fore | грубая глубина для кусков тайлов |
Сортировка внутри тайла (`compare_curr_objs`, seg008:1572) — пузырьком, и
правил в ней три:
```
объект типа 1 (ТЕНЬ) — всегда первым;
оба объекта — падающие плиты (0x80): y1 < y2 → по УБЫВАНИЮ y;
любая другая пара: y1 > y2 → по ВОЗРАСТАНИЮ y.
```
Обратный порядок для пары плит — не описка: две плиты из `loose_fall` летят
в 6 пикселях друг от друга, и верхняя обязана лечь поверх нижней.
### 1.4 Что из этого следует
**«Сортируемый midtable» — неточное имя.** Глобальной сортировки среднего
слоя в оригинале нет. Есть привязка объекта к тайлу и сортировка внутри
тайла; всё остальное решает порядок обхода. Это принципиально дешевле
общей сортировки: объектов на один тайл обычно 1-2.
---
## 2. Что делаем мы
Наш кадр — жёсткая последовательность проходов, без привязки объектов к
тайлам:
```
фон: pop_loose_tick (физика кусков) -> pop_process_trobs -> pop_redraw_needed
-> редрой шва
объекты: pop_loose_mob_draw (куски ПОД Kid, отсортированы по y между собой)
pop_char_draw(OPP/KID) (порядок задаёт guard_over_kid)
pop_loose_mob_draw_over (куски ПОВЕРХ Kid)
перед: pop_fore_over_char — ТОЛЬКО в окне вокруг персонажа
```
Отличия, из которых растут все три оставшихся дефекта:
1. **Объект не знает своего тайла.** Глубина «кусок против Кида» считается
отдельной формулой (`pop_room.c`, поле `defer`), а «кусок против КУСКА
ТАЙЛА» не считается вовсе — куски тайлов рисуются раньше всех объектов.
2. **Передний слой считается только вокруг персонажа.** Это наша
оптимизация (memory `pop_fore_layer_cost`: полный проход стоил 78 %
кадра). Падающая плита в чужом углу комнаты передних частей тайлов
поверх себя не получает — отсюда «плита перед колонной».
3. **Оверлей кромки идёт после персонажей** и потому безусловно поверх
всех, тогда как у оригинала он в midtable и сортируется.
---
## 3. Что затрагивает порт
| участок | объём правки |
|---|---|
| `pop_room.c` — куски | привязать к тайлу, убрать `defer`, убрать собственную сортировку |
| `pop_cdraw.c` — персонажи | то же: объект вместо слота, привязка к тайлу |
| `pop_bg.c``overlay_mid_tile`, `fore_only_tile`, `ceil_over_kid_tile` | вызов из обхода тайлов, а не из отдельного прохода |
| `pop_redraw.c` — пометки | добавить «на этом тайле есть объект» (порт `tile_object_redraw`) |
| `roomtest.c` — главный цикл | вместо трёх проходов один: обход тайлов с объектами внутри |
| окно fore-клипа (`pop_t_fclip_*`) | смысл меняется: клип по тайлу, а не по персонажу |
Плюс новая структура objtable и её сортировка — но маленькая, на тайл.
---
## 4. Плюсы
* **Уходят разом** MOB-CLIP-RIGHT, MID-OVERLAY-LAYER и «плита перед
колонной»: все три — следствие отсутствия привязки к тайлу, а не
самостоятельные баги.
* **Уходят подпорки.** Перерисовка соседнего тайла поверх куска,
`defer`, ручная сортировка кусков, отдельный проход `draw_over`
всё это заменяется одним механизмом.
* **Совпадение с оригиналом по построению.** Дальше любой вопрос «что
поверх чего» решается чтением seg008, а не экспериментом в MAME.
* **Возможный выигрыш по кадру.** Сейчас fore-проход считает окно вокруг
персонажа и всё равно перебирает до девяти тайлов; при привязке к тайлу
передние части рисуются только там, где реально есть помеченный объект.
Но это НАДО ЗАМЕРИТЬ, а не обещать.
---
## 5. Минусы и риски
* **Риск регресса широкий.** Трогается порядок отрисовки ВСЕГО: Кид,
соперник, меч, брызги, зеркало, куски, оверлеи, полоса потолка.
Уровни 1-11 приняты и держатся на текущем порядке.
* **Наша оптимизация fore-окна может не пережить порт в прежнем виде.**
Она даёт 3.2x на самом дорогом проходе (memory `pop_fore_layer_cost`).
Если привязка к тайлу заставит рисовать передние части шире — можно
потерять больше, чем выиграть.
* **Дабл-буфер.** У оригинала один экран с dirty-rect, у нас две страницы
со своими копиями фона и heal. Пометка «на тайле есть объект» обязана
быть счётчиком страниц, как остальные наши пометки, — иначе объект
перерисуется на одной странице и не перерисуется на другой.
* **Банки.** Отрисовка размазана по трём банкам (`pop_bg` 2, `pop_cdraw` 4,
`pop_room` 7) плюс резидент. Единый обход тайлов с объектами внутри
означает, что цикл обхода зовёт код из всех трёх — надо проверить, что не
упрёмся в границы банков и трамплины.
* **Объём.** Это не правка, а этап: сопоставимо с тем, что делалось для
fore-слоя.
---
## 6. Развилки
**A. Полный порт** — objtable с привязкой к тайлу, сортировка внутри тайла,
объекты внутри обхода. Максимально близко к оригиналу, максимальный риск и
объём.
**B. Частичный: только привязать КУСКИ к тайлам.** Персонажей оставить как
есть. Закрывает MOB-CLIP-RIGHT и «плиту перед колонной», не трогает
проверенный порядок персонажей. Дешевле и безопаснее; MID-OVERLAY-LAYER
остаётся.
**C. Отложить** до этапа BG-ONCE и делать вместе — там всё равно
пересматриваются слои, и два пересмотра подряд дороже одного.
Рекомендация: **B или C**. Вариант A целиком оправдан только если мы
одновременно берёмся за BG-ONCE — тогда это один пересмотр слоёв вместо
двух, и замер кадра делается один раз.
---
## 7. ЗАМЕРЫ (сделаны 2026-08-13, уровень 13)
Метод — маркеры-пустышки в РЕЗИДЕНТЕ вокруг измеряемого вызова плюс
брейкпоинты с `printf totalcycles` (memory `z80_profiling_method`). В
банковый код брейкпоинт ставить нельзя: 0xC000 — общее окно всех банков.
| что | такты | доля логического кадра |
|---|---|---|
| логический кадр целиком | **1 289 526** | 100 % |
| `pop_redraw_needed` | **978** | 0,08 % |
| **fore-проход, ОДИН персонаж** | **128 778** | **10 %** |
Плюс два счётчика за прогон ~229 логических кадров:
* fore-проход вызван **10 раз** — на 96 % кадров он не выполняется вовсе
(пропуск неизменившегося персонажа, `pop_char_skip_mask`). Средняя цена
по кадру выходит ~0,4 %, но КАК ТОЛЬКО персонаж движется — платим все 10 %
каждый кадр, и при двух персонажах это ~20 %;
* **максимум объектов на одном тайле = 2** (комната 16, пара из
`loose_fall`: плита сбивает плиту, дальше летят обе). В комнате 23, где
гряда падает в пустоту, максимум 1.
### 7.1 Что эти числа меняют в оценке
**Сортировка внутри тайла — бесплатна.** Два объекта, пузырёк на два
элемента. Возражение против варианта A, которое закладывалось в §5, снято.
**`pop_redraw_needed` можно не считать вовсе.** 978 тактов против 128 778 у
fore-прохода — соотношение 131 к 1.
**Единственный настоящий риск порта — окно клипа fore-прохода.** Если
привязка объектов к тайлам заставит рисовать передние части шире нынешнего
окна вокруг персонажа, мы потеряем 10 % кадра, и потеряем их НА ДВИЖЕНИИ,
когда бюджет и так самый напряжённый.
### 7.2 Насколько узко оригинал помечает передний слой — ВЫЯСНЕНО
Пометки `redraw_frames_fore[]` ставит ровно одна функция — `set_redraw_fore`
(seg007:0550), и зовут её из трёх мест. Ни в одном нет «пометить всё».
**Персонаж — `redraw_at_char` (seg003:0576).** Помечается ПРЯМОУГОЛЬНИК
футпринта:
```c
for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row)
for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col)
set_redraw_fore(get_tilepos(tile_col, tile_row), 1);
```
с двумя уточнениями: при вынутом мече прямоугольник расширяется на колонку в
сторону клинка, а для КИДА берётся объединение с футпринтом ПРОШЛОГО кадра
(`prev_char_*`) — чтобы освободившиеся тайлы тоже вернули свои передние
части.
**Падающий кусок — `draw_mob` (seg007:~1147).** Каждый кадр помечается
СОСЕД СПРАВА (`++tile_col`), и второй тайл, если кусок висит на границе
рядов:
```c
++tile_col;
tilepos = get_tilepos(tile_col, tile_row);
set_redraw2(tilepos, 1);
set_redraw_fore(tilepos, 1);
top_row = y_to_row_mod4(ypos - 18);
if (top_row != tile_row) { ... то же для top_row ... }
add_mob_to_objtable(ypos);
```
**Анимация тайла — `draw_trob` (seg007:01E6):** один тайл.
**Вывод: пометка переднего слоя в оригинале НЕ ШИРЕ нашего окна.** Она
пообъектная — футпринт персонажа и 1-2 тайла на кусок. Значит полный порт
objtable **не отнимает** нашу оптимизацию fore-окна, а формализует её:
вместо «окно вокруг персонажа» будет «тайлы, помеченные объектами», что
как минимум не шире, а для одиночного куска заметно уже.
Риск, вокруг которого крутилась вся оценка, снят.
### 7.3 Побочный результат: готовый рецепт для MOB-CLIP-RIGHT
`draw_mob` даёт точный ответ на вопрос, как оригинал прячет правую часть
куска за соседним полом: он НЕ рисует сосед поверх куска (наша подпорка) и
НЕ полагается только на `clip.right`. Он помечает соседний тайл СРАЗУ
двумя пометками — `set_redraw2` (фон) и `set_redraw_fore` (передний слой).
Дальше порядок делает всё сам: фон соседа рисуется ДО куска, его передние
части — ПОСЛЕ.
Это же закрывает и «плиту перед колонной»: передние части соседнего тайла
(колонна) ложатся поверх куска, потому что тайл помечен.
**Рекомендация после разбора: вариант A (полный порт).** Оба возражения
против него сняты замерами и этим разбором — сортировка внутри тайла
бесплатна (максимум 2 объекта), окно переднего слоя не теряется.
+297
View File
@@ -0,0 +1,297 @@
# План: консолидация работы с палитрами + переход уровня через fade
Статус: **этапы A и B реализованы; визуальная приёмка полного маршрута ещё
идёт** (2026-08-24). Палитры выделены в bank 10, а renderer cutscene/intro —
в bank 11, чтобы не переполнять bank 9 оболочки.
Обсуждение велось вокруг `roomtest/` (банк 9 — оболочка, fade из `pop_ui.c`).
---
## 1. Текущее состояние: карта палитры
Палитра Sprinter — 256 записей по 4 байта (B, G, R, 0) = 1 КБ на страницу.
У страниц дабл-буфера ДВЕ раздельные палитры (`gfx_pal_load(0,…)` и
`gfx_pal_load(1,…)` — почти всегда парой). BIOS читает буферы только из
#4000#BFFF: банковую rodata напрямую отдавать нельзя (копия в стек/W2),
см. грабли `pop_guard_set_palette` и `bg_load_tile_pal`.
### 1.1 Игровая палитра `KID\kid.pal` — раскладка слотов
Собирается `toolchain/pop_pack_kid.py build_palette()`, грузится одним
`gfx_pal_fload` (перезаписывает все 256 записей). Атласы запекались под эти
индексы — менять раскладку нельзя без перепаковки ассетов.
| Слоты | Назначение | Источник | Динамика |
|---|---|---|---|
| 0x00 | Цвет фона + **вспышка молнии** (подмена записи 0, `flash_bg` ← do_flash/set_bg_attr SDLPoP) | — | меняется в игре |
| 0x01–0x2F | Не закреплены (нули) | — | свободно |
| 0x300x3F | VGA16 — базовые 16 цветов для mono-блитов: пламя факелов, пузырьки зелья (+12 красный «лечение», +10 зелёный, +9 синий), кровь чомпера (12), дворцовая кладка mono (+6) | `VGA16[]` | статично |
| ↳ 0x37–0x3F | Поддиапазон **UI**: текст/рамка меню; единственное, что `keep_ui` не затемняет (`MENU_BORDER`=0x37) | — | — |
| 0x400x4F | chtab_1 пламя/зелья (`POT_PAL_BASE`) | VDUNGEON res150.pal | статично |
| 0x500x5F | **ENV фон тайлсета** (`POP_PAL_ENV`) | res200.pal набора | **меняется при смене тайлсета** |
| 0x600x6F | **WALL тайлсета** (`POP_PAL_WALL`) | res360.pal набора | **меняется при смене тайлсета** |
| 0x700x7F | Kid (`PAL_BASE`) | KID res400.pal | статично |
| 0x800x8F | Меч chtab_0 (`SWORD_PAL_BASE`) | POT res700.pal | статично |
| 0x900x9F | Страж chtab_5 (`GUARD_PAL_BASE`) | res10.bin guard_palettes | **меняется по КОМНАТАМ** |
| 0xA00xAF | Тень (`POP_SHADOW_PAL_BASE`) | RGB-сетка pop_pack_shadow.py | статично |
| 0xB00xFF | Свободны (5 слотов) | — | — |
Итого динамических зон три: запись 0 (молния), env+wall (тип здания),
стражи (per-room). Всё остальное одинаково всю игру.
### 1.2 Полноэкранные палитры заставок
Каждая перезаписывает ВСЕ 256 записей:
| Файл | Где используется |
|---|---|
| `KID\kid.pal` (+ fallback `a:\kid.pal`) | BOOT и возврат в игру после заставок |
| `TITLE\title.pal` | экран TITLE |
| `PV\story.pal` | INTRO и HALL_OF_FAME (одна палитра на обе фазы) |
### 1.3 Тайлсеты: подземелье ↔ дворец
Оба набора используют ОДНИ И ТЕ ЖЕ слоты 0x500x5F/0x600x6F, заполняя их
разными цветами (атласы обоих наборов запекались под эти индексы).
Переключение = загрузка 64 байт (32 записи env+wall) в обе страницы
(`bg_load_tile_pal`); остальные 224 записи не трогаются.
Какие уровни дворец — `tbl_level_type` (`pop_level_cold.c:44`):
**4, 5, 6, 10, 11, 14**; остальные подземелье.
Палитра дворца `pal_tile.pal` (расшифровка, формат записи B,G,R):
ENV 0x500x5F (пол, ковры, факелы, ворота, пики, арки):
| Слот | RGB | | Слот | RGB |
|---|---|---|---|---|
| 50 | 0,0,0 чёрный | | 58 | 202,190,178 серо-бежевый |
| 51 | 121,89,60 коричневый | | 59 | 153,133,129 серо-лиловый |
| 52 | 161,121,76 светло-коричневый | | 5A | 76,64,56 тёмный серо-бурый |
| 53 | 194,149,89 песочный | | 5B | 153,97,89 кирпично-красный |
| 54 | 230,178,113 яркий песок | | 5C | 137,80,72 тёмный кирпич |
| 55 | 246,202,125 кремовый | | 5D | 48,125,125 бирюзовый |
| 56 | 255,234,170 бледно-кремовый | | 5E | 12,56,89 тёмно-синий |
| 57 | 255,255,255 белый | | 5F | 202,56,28 красно-оранжевый |
WALL 0x600x6F (вся палитра песочная): 61=(218,170,89), 62=(226,165,93),
63=(226,170,97), 64=(218,161,85), 65=белый, 66=(226,165,93), 67=(218,165,89),
68=(226,170,89), 69=(218,170,97), 6A=(255,210,137), 6B=(255,218,149),
6C=(255,210,137), 6D=(255,218,145), 6E=(194,153,80 тёмный песок),
6F=(238,186,117).
Чем рисуется во дворце:
- **Тело стены — НЕ спрайты**, а сплошные заливки; цвет разыгрывается на
комнату prandom'ом (`gen_palace_wall_colors`, `pop_bg.c:140`, порт
seg000:1942): подряды 1 и 3 берут случайный из 0x61–0x64, подряды 0 и 2 —
из 0x66–0x69; соседи по горизонтали не повторяются.
- Декор стен id 3–17 — mono-силуэт цветом VGA16+6 (0x36).
- Верх дверных проёмов дворца — спец-id 78–84 + полоса 145 («полоса под
окнами», pop_room.c:478).
- Остальное (пол, ковры, порталы-факелы, ворота, пики) — env-куски
pal_env*.atl с ENV-таблицей выше.
### 1.4 Стражи (0x900x9F)
Цвет задаётся на КОМНАТУ (`level.guards_color[room-1]`), при входе в
комнату зовётся `pop_guard_set_palette(color)` ДО отрисовки (слоты общие
на экран — смена посреди кадра дала бы стража в новой палитре с полосой HP
в старой). Только для обычных стражей (`tbl_guard_type == 0`): скелет и
Джафар имеют собственную палитру, зашитую в kid.pal; им зовётся с color=0
(не трогать — иначе Джафар на ур.13 покрасился бы в цвет стража своей
комнаты). Внутри одного уровня слоты могут перезаписываться многократно.
## 2. Текущее состояние: механика fade
### 2.1 Наша реализация (`pop_ui.c`, банк 9)
- `pop_ui_palette_snapshot()` — снимок всех 256 записей через
`gfx_pal_get` по 4 чанкам × 64; хранится в хвосте страницы шрифта
FONT.ATL ([0x3C00,0x4000)), map/unmap W0. Требует `font_ready`.
- `pop_ui_palette_dim(step, keep_ui)` — готовит ОБЕ экранные палитры из
снимка. Шкала без умножений (только сдвиги):
| Шаг | Формула на канал | Яркость |
|---|---|---|
| 0 | x | оригинал |
| 1 | `(x>>1)+(x>>2)` | ≈3/4 |
| 2 | `x>>1` | 1/2 |
| 3 | `x>>2` | 1/4 |
| 4 | 0 | чёрный |
`keep_ui` пропускает 0x37–0x3F (меню остаётся ярким).
- `pop_ui_fade_out/in(steps)` — проигрывание ступеней за `steps` кадров
vsync (`step = i*4/steps`, целочисленно): steps=4 — канонический (по кадру
на ступень), steps<4 — перескакивает ступени, steps>4 — повторяет (плавнее),
steps=0 у fade_in — мгновенный restore.
- Контракт map/unmap: обращения к EMM/W0 и BIOS-палитре строго после unmap.
Стоимость одного dim ≈ 15–25 тыс. тактов (~4–7 мс при 3.5 МГц) —
укладывается в кадр vsync, на практике лагов нет.
### 2.2 Как сделано в SDLPoP (seg009.c, USE_FADE/gmMcgaVga)
- fade_out: каждый кадр КАЖДЫЙ ненулевой канал каждой записи −1; до нуля.
- fade_in: `fade_pos` от 0x40 вниз; канал +1, пока меньше оригинала.
- Уровней затемнения до 63–64 (VGA-канал 6 бит), полный фейд ~63 кадра ×
wait_time=2 тика — медленно и кинематографично.
- `which_rows` — битовая маска групп по 16 записей: можно фейдить часть
палитры (в оригинале используется).
- По завершении принудительно восстанавливается оригинал; после out экран
заливается чёрным.
Это осознанное расхождение (скорость/такты vs плавность) — ЗАПИСАТЬ в
`docs/impl_diff.md` (сейчас записи нет).
## 3. Зафиксированные решения
1. **Ступени затемнения: остаются 4.** Вариант 8 ступеней той же сдвиговой
техникой — рассмотреть отдельно, сейчас не внедрять.
2. **Предрасчёт fade-вариантов палитры отклонён.** Аргументы: чтение файла
с диска на порядок дороже вычисления; 3–7 КБ постоянной RAM при
MEMORY=small непозволительны; предрасчёт привязан к конкретным палитрам,
а снимок работает с любой текущей автоматически; keep_ui удвоил бы набор.
3. **Считать на лету**, хранить один снимок (уже есть, бесплатно в хвосте
страницы шрифта).
4. **Буферы на стеке**, не статика (W1/W2 мало) и не 1 КБ: обнулить 64/256
байт дешевле, чем держать килобайт резидентно.
5. **Контракт `gfx_pal_load(pal, start, count, data)`**: count — число
СЛОТОВ, буфер обязан быть `count*4` байт; count=0 означает «все 256».
6. **Leaf-applеры остаются на месте** (`pop_bg_pal_apply` — банк 7 со своими
таблицами, `pop_shadow_pal_apply`, `pop_guard_set_palette`): банковая
rodata чужого банка не видна, перенос сломал бы доступ к данным.
7. **Молния (`flash_bg` в roomtest.c) не переносится** — игровой эффект
записи 0; после вспышки восстановление записи 0 из снимка ложится на API.
8. Модель состояния: разделены «какая палитра логически загружена» (load_*)
и «с какой яркостью показана» (apply/fade). Любой load_* обновляет снимок;
apply/fade показывает его с нужной глубиной. Это позволяет грузить новую
палитру «в темноте» (экран остаётся чёрным, пока не позвали apply/fade_in).
## 4. Целевой API `pop_pal.c/.h` (банк 9)
```c
/* сброс */
void pop_pal_black(void) __banked;
/* все 256 записей ОБЕИХ страниц = 0. Стековый buf[256], обнуление циклом,
* 8 вызовов gfx_pal_load (4 чанка × 2 страницы, паттерн как в dim).
* Зовётся СРАЗУ ПОСЛЕ initgraph в pop_boot (раньше нельзя — нет гарантий
* состояния графического режима): закрывает кейс «мусор/палитра предыдущей
* программы при включении графики». СНИМОК НЕ ТРОГАЕТ (контракт:
* чёрный экран без изменения логической палитры). */
/* загрузка (пишет полную палитру в обе страницы + refresh снимка;
* видимую яркость НЕ трогают — экран меняется только по apply/fade) */
void pop_pal_file_load(const char *name) __banked;
/* gfx_pal_fload + fallback "a:\" + gfx_pal_sync (fallback сегодня
* скопирован в каждом из ~6 мест вызова) */
void pop_pal_game_load(void) __banked;
/* file_load("KID\kid.pal") + pop_bg_pal_apply + pop_shadow_pal_apply.
* Сегодня тройка скопирована 3 раза (roomtest_cold ~958, pop_title ~88,
* pop_intro ~183). Единое место инварианта «kid.pal затирает слоты
* тайлсета 0x50..0x6F и тени 0xA0..0xAF». */
void pop_pal_level_load(uint8_t full) __banked;
/* палитра уровня: kid.pal/shadow + tileset 0x50..0x6F если набор сменился
* (сравнение через pop_level_type()). full=1 — ПРИНУДИТЕЛЬНО перечитать
* kid.pal/shadow (один экспорт с флагом, не две функции — меньше банковых
* точек входа). СТРАЖЕЙ (0x90..0x9F) НЕ включает: это компетенция входа
* в комнату (pop_guard_set_palette до первого draw). */
void pop_pal_story_load(void) __banked; /* PV\story.pal (INTRO и HOF — файл один, функция одна) */
void pop_pal_title_load(void) __banked; /* TITLE\title.pal */
/* отображение */
void pop_pal_snapshot(void) __banked; /* переезд из pop_ui, тело то же */
void pop_pal_apply(uint8_t fade) __banked; /* = dim(fade, 0), 0..4 */
void pop_pal_fade_in(uint8_t steps) __banked; /* переезд из pop_ui */
void pop_pal_fade_out(uint8_t steps) __banked;
/* меню продолжает звать низкоуровневый dim(step, keep_ui=1) — отдельный
* тонкий экспорт, чтобы не тащить флаг в горячий apply. Старые имена
* pop_ui_palette_* / pop_ui_fade_* УДАЛЯЮТСЯ (без алиасов — меньше
* экспорта банка). */
```
Соответствие старое→новое: snapshot→snapshot, restore→apply(0),
fade_out/in→fade_out/in, тройка kid.pal×3→game_load, fload+fallback+sync×6→file_load.
## 5. Этап A: рефакторинг — выполнен (2026-08-24)
1. Создан `roomtest/pop_pal.c/.h` в **bank 10**, добавлен в Makefile.
Он владеет политикой `load logical palette → snapshot → apply brightness`.
Низкоуровневые snapshot/dim/fade остаются физически в `pop_ui.c`: там
владелец страницы FONT.ATL, где лежит снимок; наружу они доступны только
через `pop_pal`.
2. Заменены call-sites:
- `roomtest_cold.c` ~958: black → game_load вместо тройки;
- `pop_title.c` title_restore_game_palette → game_load; загрузка title.pal → title_load;
- `pop_intro.c` intro_load/intro_restore → story_load/game_load;
- `pop_hof.c` (2 × story.pal) → story_load;
- `pop_menu.c`: fade/dim → новые имена (dim с keep_ui — низкоуровневый экспорт);
- `roomtest.c` demo-start (snapshot+dim(4,0)+fade_in(4)) → новый API.
3. Старые вызовы не остаются в коде приложения; внутренние функции `pop_ui`
сохранены как реализации одного владельца памяти снимка.
4. Сборка и host-тесты пройдены. `make size-check` неприменим: меняется
приложение, а не libc/libbgi.
5. MAME smoke-тест полного цикла смен палитр: boot → title (title.pal +
fade) → intro (story/kid) → demo fade-in → игра → HOF (story.pal).
Проверить: отсутствие мусора при включении графики (эффект black),
меню с keep_ui остаётся ярким при затемнении, молния (запись 0)
восстанавливается.
## 6. Этап B: переход уровня через fade — реализован, ждёт визуальной приёмки
Сценарий (обсуждён, детали уточнить по SDLPoP перед реализацией — как
оригинал делает смену уровня, есть ли там fade в DOS-версии):
```
fade_out // последний кадр уровня N темнеет
рисуем комнату 1 уровня N+1 // во ВТОРУЮ страницу, в темноте
pop_pal_level_load(full=0) // новая палитра: железо+снимок обновлены,
// экран всё ещё чёрный
флип + копия второй страницы обратно в первую
fade_in // = анимированный apply 3→2→1→0
```
Экономия: реально переезжают только 32 записи (env/wall) при смене набора
dungeon↔palace; guards_color обновит вход в комнату. Kid/shadow не меняются
— потому full=0.
Реализация находится в `roomtest.c` / `roomtest_cold.c`: последний кадр
уровня N темнеет, `pop_level_switch()` подготавливает первый кадр N+1 и
обновляет логический источник через `pop_pal_level_load(1)`, затем главный
цикл показывает кадр только через fade-in. Восемь ступеней и отдельная
анимация смерти не входят в этот этап.
**Этап B закрывает два открытых бага** (разборы — `roomtest/BUGS_OPEN.md`):
- [PAL-L1-AFTER-INTRO] — вход в игру на уровень 1 после интро с чёрным
экраном (маршрут demo_new_game; корень не установлен, воспроизведение
нестабильно);
- [PAL-DUNGEON-STALE] — переход 3→4 оставляет подземную палитру (корень
ясен: fade_in восстанавливает из снимка, снятого ДО загрузки тайлсета
дворца; быстрый фикс `fade_in_pending` 2026-08-23 сам же и проявляет этот
дефект модели).
Быстрый фикс 2026-08-23 (маршрут CUTSCENE → LEVEL_LOAD → PLAYING,
`fade_in_pending` + `pop_ui_fade_in(4)` после `pop_level_switch`) закрыл
чёрный экран на переходах с pre-cutscene внутри подземелья (1→2), но модель
«кто и когда меняет яркость» остаётся разношёрстной — её и приводит в
порядок этап B.
## 7. Этап C: документирование
- Запись в `docs/impl_diff.md`: наши 4 ступени vs SDLPoP ~64 (что делает
оригинал, что делаем мы — сдвиговая шкала ради тактов, чем платим —
грубее градации, что проверять при регрессе).
- После этапа B — дополнить запись про сам переход.
## 8. Не трогаем
- Молнию (`flash_bg`, roomtest.c) — включая обход SDCC-бага
`gfx_pal_set(0,0,0,0,0)` → ручные `gfx_pal_set(0/1, 0, r,g,b)`;
- leaf-applеры: `pop_bg_pal_apply` (банк 7), `pop_shadow_pal_apply`,
`pop_guard_set_palette` (данные своих модулей);
- хранилище снимка в хвосте страницы шрифта FONT.ATL (бесплатное место,
guard `font_ready`);
- раскладку слотов 0x00–0xAF (зафиксирована атласами).
+335
View File
@@ -0,0 +1,335 @@
# Оптимизация отрисовки — что НЕ сделано (замеры на 2026-08-10)
Список отложенных идей с измеренной ценой. Всё измерено брейкпоинтами в
MAME (`z80_profiling_method`) на роомтесте, уровень 1 комната 1.
Прежде чем брать что-то отсюда — перечитать «Как мерить» ниже: половина
прошлых гипотез не подтвердилась, и подтвердились не те, что казались
очевидными.
## Как мерить (иначе цифры не сходятся)
- **Такт `totalcycles` ≠ номинальный T-такт Z80.** У ОЗУ Sprinter
wait-state'ы, замеренная стоимость ≈ **2,4× справочной** (`get_tile`: 574
против 1 422). Считать по таблице тактов нельзя. Подробности —
memory `sprinter_wait_states_2x`.
- **Растровый кадр = 430 000 тактов.** Главный цикл спейсится тремя
`gfx_wait_vsync`, поэтому работа сверх 430 000 стоит СРАЗУ целый лишний
кадр. Граница дискретная: 3 растровых кадра на логический или 4.
- **Адреса символов меняются после КАЖДОЙ пересборки** (`roomtest.map`,
`bank*_*.sym`). Маркер со старым адресом молча не срабатывает, и разбивка
выглядит правдоподобно, но врёт.
- **Сцена между сессиями не воспроизводится точно**: позиция Кида до
пикселя, состояние плиты (2,6), фаза факелов. Сравнивать «до/после» можно
только по ОДНОЙ функции с одинаковыми входами, а не по общей работе за
кадр.
- Кто делит: брейкпоинт на `__divsint`/`__divuint`/`__divuchar` с печатью
адреса возврата — `bpset <addr>,1,{printf "ret=%04X\n",w@(sp); g}`. Если
адрес возврата в банке (>= 0xC000), поставить тот же брейкпоинт с условием
`w@(sp)==<адрес>` и БЕЗ `g`: машина встанет с нужным банком в окне, и
`dasm` покажет вызывающего.
- **Брейкпоинт по адресу в банке ловит ВСЕ банки.** 0xC000..0xFFFF — общее
окно, и один и тот же адрес есть у семи модулей сразу. Либо ловить через
трамплин (`hl==<адрес>&&(de&0xff)==<банк>`), либо перепроверять, что
срабатывания идут из нужной фазы: иначе в интервал попадает чужой код и
цифры врут (так я намерил несуществующие 134 730 тактов в прологе
`pop_char_fore`).
- Трасса вызовов графики с параметрами: скрипт в истории сессии, ставит
маркеры фаз на трамплин `___sdcc_bcall_ehl` (условие `hl==<адрес>&&(de&0xff)==<банк>`)
и брейкпоинты на листья libbgi с печатью аргументов
(`__sdcccall(1)`: arg1 = HL, arg2 = DE, дальше стек с sp+2).
## Профиль на 2026-08-10
Сцена: комната 1, два факела, стража нет.
| сцена | работа за кадр | период |
|---|---|---|
| Кид в покое, факел не задет (пропуск работает) | 244 026 (57 %) | 3 кадра |
| Кид стоит на факеле (перерисовывается каждый кадр) | 366 240 (85 %) | 3 кадра |
| Кид в щебне (2,4), движется | ~357 000 (83 %) | 3 кадра |
| Кид (0,5) в движении | 421 254 (98 %) | **4 кадра** |
Одна перерисовка персонажа = **~137 000 тактов = 32 % растрового кадра**,
из них полезной работы (heal 20×19 + спрайт 12×41) — меньше трети.
## Профиль дворца (уровень 4) на 2026-08-10
Сцена: уровень 4, Кид НЕПОДВИЖНО стоит на (1,7) (комната с решёткой),
стража нет. Разбивка одного логического кадра брейкпоинтами на границах
фаз (адреса `PROF()` из `roomtest.lst` + базы `_CODE = 0x42AD`):
| фаза | тактов | доля работы |
|---|---:|---:|
| ввод + читы + heal | 59 340 | 11 % |
| логика (kid_tick, phys_tick, страж, боёвка) | 85 356 | 16 % |
| `pop_loose_tick` | 27 396 | 5 % |
| `pop_process_trobs` | 106 107 | 21 % |
| `pop_redraw_needed` | 447 | — |
| шов / смена уровня / вспышка | 5 448 | 1 % |
| `guard_over_kid` + `pop_char_skip_mask` | 4 788 | 1 % |
| `pop_char_draw(KID)` | 52 206 | 10 % |
| соперник + `loose_mob_draw_over` + `hp_draw` | 4 086 | 1 % |
| **`pop_char_fore(KID)`** | **171 693** | **33 %** |
| `pop_room_clip_borders` + прочее | 742 | — |
| **ИТОГО работа** | **517 609** | 120 % растрового кадра → период **4 кадра** |
По цветам бордюра: синий (`PROF(2)`) 144 696 (28 %), зелёный (`PROF(4)`)
139 398 (27 %), циан (`PROF(6)`) 233 515 (45 % работы = 54 % растрового
кадра, начинается на 74 % первого кадра и кончается на 123 %).
## СДЕЛАНО 2026-08-10: метка «фон трогали» стала маской ТАЙЛОВ
Было: один union-прямоугольник на страницу. Три факела трогают по пятну
16x18 в колонках 1, 6 и 8, а их объединение — полоса `x 40..280` на всю
комнату; Кид, стоящий где угодно между крайними факелами, в неё попадал и
перерисовывался каждый кадр со всем fore-проходом.
Стало: `uint16_t pop_cd_dmask[2][3]` — бит на колонку, слово на ряд, набор на
страницу. Колонка берётся сдвигом (`x >> 5`), ряд — цепочкой сравнений;
проверка в `cd_quiet` — три `AND` через резидентный `pop_cd_hit`.
Гранулярность тайла — это гранулярность ОРИГИНАЛА: пометки там тоже по
тайлам (`redraw_frames_anim[tilepos]`, `set_wipe`), персонажи привязаны к
тайлу через `tile_object_redraw[tilepos]`, а единственное подтайловое
уточнение (`wipe_heights`) — по высоте, не по ширине. Полутайл (16 px) не дал
бы ничего: пламя рисуется с отступом 8 px и шириной 16, то есть занимает
середину тайла и задевает обе половины.
Замер на той же сцене, где снимался профиль ниже (Кид неподвижно на (1,7)):
| | было | стало |
|---|---:|---:|
| циан (спрайты + fore) | 233 515 | **24 781** |
| работа за кадр | 517 609 | **306 553** |
| период | 4 растровых кадра | **3** |
---
## СДЕЛАНО 2026-08-10: деление в луче видимости стража (Кид у шва)
`tile_at_kid` (`guards.c`) считала колонку честным `/` и `%`, тогда как везде
уже стоит резидентная таблица `POP_TILE_DIV` (это и есть `tile_div_tbl`
оригинала). У SDCC z80 это `__divsint` плюс `__modsint`, а тот внутри снова
зовёт `__divsint` — ~5 400 тактов на вызов.
Зовут её В ЦИКЛЕ по колонкам между стражем и Кидом
(`check_can_guard_see_kid`, seg003:761). Когда Кид стоит У ШВА, его
`curr_col = 1`, луч тянется через всю комнату, и за кадр набегало ВОСЕМЬ пар
делений — около 43 000 тактов, 10 % растрового кадра, в фазе ЛОГИКИ.
Замер: брейкпоинт на `__divsint` с печатью адреса возврата дал `ret=C033`
восемь раз за кадр; остановка на нём и дизассемблирование с правильным банком
показали `HL65`, `ld de,#14`, `call __divsint` по смещению 0x24 банка 1 —
`tile_at_kid`. После фикса пар `C033` не остаётся ни одной.
**Заодно снята ложная тревога.** В прошлом замере я записал, что на шве
пролог `pop_char_fore` разбухает до 134 730 тактов. Это была ошибка зонда:
брейкпоинт на `fore_tile` стоял по адресу, который совпадает с кодом ДРУГИХ
банков, и в интервал попадали чужие срабатывания. Чистый замер (Кид в
колонке 0, спрайт свисает за левый край, окно `x 8..5`): пролог **16 950**
ровно как в середине комнаты, весь fore-проход 41 803, `pop_room_clip_borders`
14 364. Урок в «Как мерить» выше: адрес в банке нужно либо проверять на
уникальность, либо ловить через трамплин с условием на банк.
---
## 0. Дворцовая кладка в fore-проходе — **СДЕЛАНО 2026-08-10**
Все три шага выполнены; замер после — в конце пункта. Ниже сохранён исходный
разбор: он объясняет, почему предфильтра `fore_tile` мало и откуда взялись
габариты кусков.
**Было: 139 863 такта за кадр (32 % растрового кадра), полезных пикселей —
ровно ноль.**
Замер (уровень 4, Кид на (1,7)). Окно fore-клипа в этот момент —
`x 229..241, y 106..147` (прочитано из `pop_t_fclip_*` брейкпоинтом).
`pop_fore_over_char` обходит 6 тайлов:
| тайл | тактов |
|---|---:|
| (0,6) (0,7) (1,6) (1,7) | 2 800 4 600 каждый |
| **(2,6) — стена** | **74 310** |
| **(2,7) — стена** | **65 553** |
Ряд 2 этой комнаты — стена, и он лежит ПОД ногами Кида, то есть попадает в
его fore-окно всегда. Внутри одного тайла стены `wall_pattern_palace`
делает 6 × `wpp_fill` (≈ 3 100 каждый) + 5 × `pop_wall_b` (≈ 9 760 каждый)
≈ 60 000 тактов.
Ни один кусок в окно не попадает:
- верхняя заливка стоит на `dmy - 59 = 157`, окно кончается на `y = 147`;
все остальные куски ещё ниже;
- тайл (2,6) занимает `x 192..223`, окно начинается с `x = 229` — он
промахивается и по горизонтали тоже.
Тайл всё равно проходит, потому что предфильтр в `fore_tile` (pop_bg.c,
`x0 < fclip_x1 && x0 + 40 > fclip_x0 && y0 - 8 < fclip_y1 && y0 + 70 >
fclip_y0`) намеренно грубый — габарит 40×78 на тайл. Дальше `wpp_fill`
честно режет по окну и выходит с пустым прямоугольником, но 3 100 тактов на
арифметику клипа уже потрачены, а `pop_wall_b` о существовании окна не знает
вовсе: он идёт в `atlas_image` + `gfx_w0_map`, читает `w`/`h` и только там
обнаруживает, что рисовать нечего.
Что делать (по возрастанию объёма):
1. **Ранний выход из `wall_pattern_palace`**: самый верхний пиксель узора —
`dmy - 59`, самый нижний — `dby + высота нижнего декаля`. Один
`if (pop_t_fclip_on && (fclip_y1 <= dmy - 59 || fclip_y0 > dby + h))
return;` плюс такая же проверка по `x` убивает оба тайла целиком почти
даром.
2. Прогнать `pop_wall_b` в этом узоре через ту же проверку окна, что уже
есть у `wpp_fill` (нужны размеры кусков — см. п. 2 ниже, «размеры из
каталога атласа»).
3. Сузить сам предфильтр `fore_tile` до реального габарита узора вместо
40×78 — тогда лечится не только дворец.
Порядок в подземелье тот же, но дешевле: `wall_pattern` в подземелье делает
до 3 блитов и ни одной заливки (~29 000 на тайл против ~60 000). Это же
объясняет, почему после перехода на дворцовый тайлсет период вырос.
Сверено с SDLPoP (`seg008.c:1943 wall_pattern`, ветка
`!is_dungeon && GRAPHICS_VGA`): состав узора у нас дословный — 5
`add_wipetable` + 4 декаля + нижняя заливка + нижний декаль. Расходимся не
составом, а тем, что оригинал складывает всё в `foretable` и рисует одним
`draw_table()`, у которого «посетить тайл» стоит копейки (см. п. 6).
### Что сделано и сколько дало
1. **Ранний выход** из `wall_pattern_palace` по окну fore-клипа (узор целиком
в `x [xh*8, xh*8+32)`, `y [dmy59, dby]`).
2. **Отсев каждого декаля** (`wp_blit`) по реальному габариту вместо
заведомо большего 64×64 в `pop_blit_b`. Размеры сняты из каталогов
атласов: `pal_wall.atl` — группы 3..5 = 8×7, 6..8 и 9..11 = 32×12,
12..14 = 30×5, 15..17 = 32×3.
3. **То же для ПОДЗЕМЕЛЬЯ**: ранний выход `wall_pattern` (габарит там выше —
левая марка уходит на `dby+POP_YOFF67`) плюс `wp_blit` на RNDBLOCK
(32×21), обоих разделителях (9×21) и обеих марках (`pop_wall.atl`:
16/17 = 7×10, 14/15 = 14×5).
Замер: уровень 4, комната 18, Кид сдвигается читом `]` по пикселю (skip
выключен, идёт полный путь), в fore-окне ШЕСТЬ тайлов, ТРИ из них —
дворцовая стена.
| участок | тактов |
|---|---:|
| пролог `pop_char_fore` до первого `fore_tile` | 17 208 |
| обычный тайл | 4 000 – 4 600 |
| **тайл стены (было 60 000 74 000)** | **~12 700** |
| хвост + чистка бортов | 6 055 |
| **fore-проход целиком (было 171 693)** | **70 681** |
Кадр целиком в этой сцене: работа **419 839**, период **3** растровых кадра
(было 517 609 и 4).
Остаток в проходе — пролог 17 208, это уже пункт 1 ниже (футпринт из физики).
Отдельная находка: когда Кид стоит НА ШВЕ (окно `x 8..5`), пролог разбухает
до **134 730** — 86 % прохода; причина не разобрана, см. пункт 1.
---
## 1. Футпринт персонажа — брать из физики, а не считать заново
**Цена: 11 574 такта на каждый fore-проход** (от входа в `char_footprint` до
первого `fore_tile`).
`redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ `char_col_left/right`,
`char_top_row`, `char_bottom_row` — их в этом же кадре посчитала физика
(`set_char_collision`, seg006:0723). У нас `char_footprint` (pop_bg.c)
считает их заново внутри fore-прохода.
Мешает то, что физика (банк 3) держит их в статиках, а слой фона — банк 2.
Надо опубликовать их так же, как уже опубликованы `pop_cd[who].fpx/fpy/fpw/fph`.
**Заодно:** оригинал расширяет футпринт ТОЛЬКО на одну колонку при вынутом
мече и объединяет с футпринтом ПРОШЛОГО кадра (`prev_char_col_left/right`).
Мы вместо этого расширяем окном fore-клипа и посещаем 6 тайлов там, где
оригинал посетил бы 4. Разница видна в замере: fore-проход стоит 58 764
там, где реально рисует, и **112 758 там, где не рисует ничего** — вся
разница в числе посещённых тайлов.
Осторожно: окно клипа заводилось под клинок и брызги (они уходят
вперёд-вверх за габарит кадра). Менять — с прогоном боя и падений.
## 2. Размеры ленты — из каталога атласа, а не через окно 0
**Цена: ~750 тактов на `atlas_image` + часть из 6 396 на «чтение w/h и
арифметика клипа», на КАЖДЫЙ блит фона.**
Сейчас `pop_blit_b`, чтобы узнать размер куска, зовёт `atlas_image` (тот
мапит страницу в W3, читает запись каталога, возвращает W3 назад), потом
`gfx_w0_map` и читает `w`/`h` из шапки ленты.
А размеры **уже лежат в каталоге**: запись 8 байт — `offset u16, fw u8,
fh u8, nx u8, ny u8, резерв u16`, и у всех фоновых лент `nx = ny = 1`, то
есть `fw`/`fh` в точности равны `w`/`h` из шапки (проверено по
`pop_env0.atl`). `atlas_image` их читает и выбрасывает.
Вариант A (0 байт памяти): `atlas_image_wh()` рядом с `atlas_image`
вернуть заодно размер.
Вариант B (без маппинга вовсе): снять каталоги при загрузке в резидентную
таблицу. Объём: фон (env0-4 + wall + fore + pot) = **313 лент**, по 2 байта
= **626 Б**; всё вместе с Кидом и стражем = 600 лент = 1200 Б. Свободной
кучи на 2026-08-10 — 2873 Б.
Ожидаемый выигрыш скромный: ~2 000–3 000 из ~16 000 накладных на блит.
## 3. Один `gfx_w0_map`/`unmap` на группу блитов
**Цена: ~5 500 тактов на блит** (unmap плюс возвраты по цепочке
`pop_pot_b``pop_blit_b` → трамплин).
Куски одного прохода часто лежат на одной странице атласа, а мапим и
размапливаем на каждый. Мешает то, что `pop_blit_b` — общий лист для всех
вызывающих; нужна форма «открыть страницу, N блитов, закрыть».
## 4. Единый проход по тайлам вместо трёх
У оригинала за кадр ОДИН обход тайлов — `redraw_needed_tiles` (seg008):
контекст тайла (`curr_tile`, `curr_modifier`, `draw_xh`, `draw_main_y`)
ставится по разу на тайл в `load_curr_and_left_tile`, а `redraw_needed`
смотрит **семь** независимых счётчиков (`wipe_frames`, `redraw_frames_full`,
`redraw_frames_anim`, `redraw_frames2`, `redraw_frames_floor_overlay`,
`redraw_frames_fore`, `tile_object_redraw`) и делает только помеченное.
У нас **три** обхода: `pop_redraw_needed`, `pop_process_trobs` и
`pop_fore_over_char`. Плюс один `kind` на тайл вместо семи счётчиков — две
разные причины перерисовки одного тайла конфликтуют.
Это большой рефакторинг всего слоя фона; браться только если понадобится
ещё заметный запас.
## 5. objtable: персонажи, привязанные к тайлу
Оригинал кладёт персонажей в `objtable` и рисует их в
`draw_objtable_items_at_tile(tilepos)` во время обхода тайлов — порядок
окклюзии получается сам. У нас отдельный fore-проход НА КАЖДОГО персонажа.
Со вторым персонажем (страж) цена удваивается.
## 6. Отложенные таблицы back/mid/fore
`add_backtable`/`add_midtable`/`add_foretable` только КЛАДУТ запись в массив,
рисование — один `draw_table()` в конце. Поэтому «посетить тайл» у
оригинала стоит копейки. У нас блит идёт сразу из обхода.
## 7. Мелочи с известной ценой
| что | цена | где |
|---|---|---|
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки тайла над головой | 8 892 | `pop_cdraw.c` / `pop_map.c` |
| `pop_loose_tick` при полном отсутствии падающих плит в комнате | 27 438 | `pop_map.c` |
| `obj_x * 8 / 7` — единственное оставшееся `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
## Что уже проверено и НЕ сработало
- **Маска «у тайла есть передний слой» (`FORE_ANY`) + контекст тайла один
раз.** Сделано (коммит `a9f4521`), эффект **нулевой**: в футпринте Кида
тайлы почти всегда С передним слоем, а снятое второе чтение кода съедено
проверкой маски. Оставлено как сближение с оригиналом.
- **«Быстрый путь для окна коллизии целиком внутри комнаты».** Не
срабатывал почти никогда: Кид в колонке 0 даёт окно с −1. Заменён на
разбиение окна на непрерывные пробеги.
- **Флаг «фон трогали» вместо позиционной метки** — нулевой выигрыш,
факелы гасили пропуск для всех сразу (см. `pop_cdraw.h`).
+172
View File
@@ -0,0 +1,172 @@
# ЦИАН фаза (персонажи + передний слой) — анализ и оптимизация
Рабочий документ: живёт между сессиями. Внизу **журнал правок** — каждая
запись с замером до/после. Сцена, рецепт воспроизведения и зонды —
[`perf_l13_room23.md`](perf_l13_room23.md); там же ответ про 8-битные
габариты. Парная фаза — [`perf_green_phase.md`](perf_green_phase.md).
Границы фазы в `roomtest.c`: от `PROF(6)` (строка 620) до `PROF(0)`
(строка 677). Содержимое: `pop_check_mirror`, `pop_loose_mob_draw`,
соперник, `pop_char_draw(KID)`, `pop_loose_mob_draw_over`, `pop_fore_needed`,
`pop_hp_draw`, `pop_char_fore(KID)`, `pop_cd_clear`,
`pop_room_clip_borders`.
**Бюджет: ≤ 400 000 тактов (растровый кадр = 430 000).**
| состояние | было (2026-08-17, `c312e4a`) | стало (`af189a1`) |
|---|---:|---:|
| покой, Кид пропущен | 20 550 | 20 550 |
| Кид перерисовывается, кусков нет | 193 000 | 192 000 |
| **пик каскада (6 кусков + Кид)** | **631 800** | **270 510 ✔** |
**ЦЕЛЬ ФАЗЫ ВЫПОЛНЕНА** (270 510 при бюджете 400 000, запас 32 %).
---
## 1. Что решило дело
### C1. Пометки «фон трогали» в `mob_render` подавлены — 55 000
`pop_loose_mob_tick` помечает **весь коридор** куска одним вызовом, а три
блита внутри `mob_render` метили подмножества того же прямоугольника по
**4 502 такта** каждый. Механизм — `pop_cd_mute()`/`pop_cd_unmute()`
`pop_tile.c`; отдельное значение того же флага `pop_cd_batch`, чтобы у
`pop_cd_touch` на общем пути осталась ОДНА проверка).
Добавлена пометка в `mob_spawn_copy`: кусок, рождённый ВНУТРИ тика
(`loose_fall` сбил плиту), получает слот с начала таблицы, то есть уже
пройденный циклом, — своей пометки в этом кадре он бы не получил, а нарисован
был бы. Без этого пропущенная пометка = стёртый и не перерисованный
персонаж.
### C4. Кусок клипуется САМ, вместо чистки бортов после — −138 000
Самая крупная и самая неожиданная статья. В `mob_render` стоял
`pop_clip_sprite`, то есть кусок рисовался в борт целиком и взводил
`border_dirty`; `pop_room_clip_borders` потом стирал ДВЕ полосы во всю ширину
экрана (320×28 и 320×28) — **150 978 тактов в КАЖДОМ кадре**, пока хоть один
кусок торчит выше поля. А гряда 13-го уровня рождается ровно у потолка
(`y = 2`), то есть почти весь каскад. Стало 1 722.
Теперь окно клипа (`pop_t_win_set(0, POP_YOFF, 320, POP_PLAYFIELD_H)`)
ставится ТОЛЬКО когда кусок реально задевает борт: внутри поля блиты идут
быстрым путём.
### Композит куска: три блита → один — −163 000
Части `env 70 / 74 / 72` складываются в ОДИН getimage-блоб при загрузке
тайлсета (`mob_spr_build` в `pop_room.c`). Мотив прямо из
[[blit_cost_model]]: у блита ~8 800 такта постоянных накладных против ~5 000
на пиксели, а шесть кусков в воздухе давали 18 вызовов = **258 708 такта**,
больше половины фазы.
Тонкости, которые пришлось соблюсти:
- части **перекрываются** (74 и 70 обе идут от `mob_x`), поэтому композит
собирается попиксельно с пропуском `0xFF` — ровно как три прозрачных блита
друг поверх друга;
- габариты частей **читаются**, а не берутся константами: у тайлсетов правая
часть разная (26 px в подземелье, 25 во дворце);
- блоб лежит в обычной памяти (W2), поэтому блит идёт мимо `atlas_image` и
`gfx_w0_map/unmap` — ещё ~1 350 такта на вызов. Новый резидентный лист
`pop_mem_b` (`pop_tile.c`);
- страйд блоба = его ширина; сначала считается точный габарит, потом
копирование. Промежуточная версия объявляла блоб шириной 63 при
фактических 58 и переносила пять прозрачных колонок на каждом кадре;
- собирается на КАЖДУЮ смену тайлсета; резервный путь на три блита остался
(`mob_spr_ok`).
### Общие правки, попавшие и в эту фазу
- `blit_b_clip`: байтовый габарит + file-scope вместо локалей (кадр 22 → 12 Б,
обращений `(ix)` 211 → 51). Подробности — в
[`perf_green_phase.md`](perf_green_phase.md) §G3.
- `pop_blit_b`: аргументы в file-scope (76 → 11 обращений `(ix)`).
---
## 2. Раскладка на 2026-08-17 (до правок) — для истории
Подфазы (три готовых `PROF(6)`: 0x4BCE / 0x4C26 / 0x4C93), пик:
| участок | покой | пик |
|---|---:|---:|
| `pop_check_mirror` + `pop_loose_mob_draw` + соперник | 23 250 | 154 512 |
| `pop_char_draw(KID)` + `pop_loose_mob_draw_over` + `pop_fore_needed` + HP | 3 726 | 445 284 |
| `pop_char_fore(KID)` + `pop_cd_clear` + **чистка бортов** | 1 722 | 150 978 |
Разбор одного `pop_blit_b` (157 замеров быстрого пути, зонды b1..b5):
| участок | такты | доля |
|---|---:|---:|
| `atlas_image` + `gfx_w0_map` + чтение габарита | 1 086 | 7 % |
| ядро блита (libbgi, `gfx_blit_noclip`) | 10 422 | 64 % |
| `pop_cd_touch` — пометка «фон тронут» | 4 502 | 28 % |
| `gfx_w0_unmap` + возврат | 264 | 2 % |
| ИТОГО | 16 273 | |
Клипованный путь тогда же: ядро `blit_b_clip` 14 088, итого 16 409.
После правок: клипованный блит 11 848, быстрый ~13 900 (у него больше
пикселей).
---
## 3. Что осталось в запасе (если понадобится ещё)
Фаза в бюджете, поэтому это задел, а не план.
### C5. Один `gfx_w0_map`/`unmap` на группу блитов
1 086 + 264 на вызов. Для композита куска уже не нужно (он в обычной
памяти), но остаётся для тайлов фона: куски одного тайла часто лежат в одной
странице атласа. Мешает то, что `pop_blit_b` — общий лист для всех
вызывающих; нужна форма «открыть страницу, N блитов, закрыть».
### C6. Размеры ленты — из каталога атласа
`fw`/`fh` уже лежат в записи каталога (8 байт: `offset u16, fw u8, fh u8,
nx u8, ny u8, резерв u16`), и у всех фоновых лент `nx = ny = 1`, то есть они
равны `w`/`h` из шапки. `atlas_image` их читает и выбрасывает.
### C7. objtable вместо отдельного fore-прохода на персонажа
Позиции 5 и 6 старого `perf_backlog.md` — большой рефакторинг. Оригинал
кладёт персонажей и куски в `objtable` и рисует их при обходе тайлов
(`draw_objtable_items_at_tile`), порядок окклюзии получается сам; у нас
отдельный fore-проход НА КАЖДОГО персонажа.
### Снять временную оснастку
Шесть вызовов `pop_dbg_b1..b6` внутри `pop_blit_b` — ~400 такта на блит
(`call` + `ret` × 6), плюс `pop_dbg_kind`/`m16` в `pop_redraw_needed`.
Снимать ПОСЛЕ того, как оптимизация закончена: без них не мерить.
---
## 4. Что НЕ делать
- **Не ставить W3-скобку из кода с `--w3`** — белый экран
(memory `gfx_blit_noclip_fast`).
- **Не ускорять передачу пикселей** — предел железа
(memory `blit_cost_model`).
- **Не возвращать клип куска по `clip.right = 40`** оригинала
(`add_mob_to_objtable`, seg007:1161): единицы этого поля не выяснены,
буквальные 40 экранных пикселей срезают правый задний угол плиты
(прогон 2026-08-13).
- **Не сужать коридор heal «по палаццовому следу»** — габариты частей у
тайлсетов разные; брать высоту СОБРАННОГО композита (так и сделано).
---
## 5. Журнал правок
| дата | что сделано | циан: покой / Кид / пик | коммит |
|---|---|---|---|
| 2026-08-17 | базовый замер | 20 550 / 193 000 / **631 800** | `c312e4a` |
| 2026-08-17 | C1 подавление пометок в `mob_render` + пометка в `mob_spawn_copy` | — / — / **577 050** | `a3c473d` |
| 2026-08-17 | C4 кусок клипуется сам вместо чистки бортов; `blit_b_clip` байты+file-scope | — / — / **438 546** | `a3c473d` |
| 2026-08-17 | `pop_blit_b` аргументы в file-scope | — / — / **435 180** | `b2da0b8` |
| 2026-08-17 | **композит куска: один блит вместо трёх** | — / — / **273 078** | `b2da0b8` |
| 2026-08-17 | точный габарит композита (было 63 при 58) | 20 550 / 192 000 / **270 510 ✔** | `af189a1` |
| 2026-08-17 | замер после фиксов уровня 1 | — / — / **379 482** | `40f0d46` |
| 2026-08-17 | **регресс после фиксов уровня 2** — без изменений | — / — / **379 488 ✔** | `ec1f384` |
+327
View File
@@ -0,0 +1,327 @@
# ЗЕЛЁНАЯ фаза (слой фона) — анализ и оптимизация
Рабочий документ: живёт между сессиями. Внизу **журнал правок** — каждая
запись с замером до/после. Сцена, рецепт воспроизведения и зонды —
[`perf_l13_room23.md`](perf_l13_room23.md); там же ответ про 8-битные
габариты. Парная фаза — [`perf_cyan_phase.md`](perf_cyan_phase.md) (её цель
достигнута).
Границы фазы в `roomtest.c`: от `PROF(4)` (строка 450) до `PROF(6)`
(строка 620). Содержимое: `pop_loose_tick`, `pop_process_trobs`,
`pop_redraw_needed`, шов, смена уровня, сигналы провалов, вспышка.
**Бюджет: ≤ 400 000 тактов (растровый кадр = 430 000).**
| состояние | было (`c312e4a`) | стало (`af189a1`) |
|---|---:|---:|
| покой в комнате 23 | 35 760 | 35 760 |
| дрожат 6 плит-потолков | 366 000 | 335 400 |
| **пик каскада** | **805 000** | **546 900** |
**ЦЕЛЬ ФАЗЫ НЕ ДОСТИГНУТА: 546 900 против 400 000 (1,37×).** Что осталось
сделать и почему это именно раскол `draw_tile` — §3.
---
## 1. Раскладка ПОСЛЕ правок (замер `af189a1`)
Пик — кадры 24-28 (посадки плит), больше не кадры провалов.
| участок | покой | дрожь | пик |
|---|---:|---:|---:|
| `pop_loose_tick` | 36 234 | 36 234 | **156 762** |
| `pop_process_trobs` | 1 050 | 1 050 | 1 050 |
| **`pop_redraw_needed`** | 924 | 288 800 | **380 568** |
| хвост (шов, смена уровня, сигналы, вспышка) | 9 336 | 9 336 | 10 776 |
`pop_loose_tick` изнутри на пике: два цикла по тайлам 9 852,
**`pop_loose_mob_tick` 154 074** (heal шести летящих кусков), остальное мелочь.
### Цена одной перерисовки: было → стало
| вид | было | стало | чем |
|---|---:|---:|---|
| `RDA_CEIL` — дрожащая плита-потолок | 48 785 | **43 536** | G2 + G3 |
| `RDA_CEIL_GONE` — запечь колодец | 251 335 | **138 318** | **G1** (клип полосы) + G2 + G3 |
| `RD_FLOOR` — щебень на месте посадки | 198 805 | **179 914** | G2 + G3 + снятая двойная пометка |
Штук за кадр: `RDA_CEIL` до 6, `RDA_CEIL_GONE` до 2, `RD_FLOOR` до 2.
### Детальный профиль `RD_FLOOR` (179 914) — главная оставшаяся статья
Снят зондами по каждому блиту (`pop_dbg_b1/b5`) и по рамкам
(`pop_bar_black`, `pop_cd_batch_begin/end`):
| участок | такты |
|---|---:|
| вход `pop_floor_bake` + `gfx_set_bank` | 2 382 |
| `pop_bar_black` 60×39 (включая `pop_cd_touch` 4 502) | ~15 500 |
| **контекст `draw_tile` #1** (5 чтений тайлов, `63*row`, индексация таблицы) | **13 584** |
| 4 блита тайла #1 | 50 718 |
| **диспетчер между блитами #1** (все `if (code == …)`) | **11 388** |
| **контекст `draw_tile` #2** | **13 584** |
| 4 блита тайла #2 | ~54 000 |
| **диспетчер между блитами #2** | **11 388** |
| хвост | 6 474 |
Итого: **105 500 — сами блиты (реальные пиксели), 74 400 — накладные**, из
которых 27 168 контекст двух `draw_tile` и 22 776 их диспетчер.
---
## 2. Что сделано (с чем сравнивать)
### G1. Окно клипа для точечной перерисовки — −90 000
`pop_t_win_set(x, ytop, w, h)` / `pop_t_win_clear()` в `pop_tile.c`: ставит
уже существующее окно `pop_t_fclip_*` на прямоугольник, который перерисовка
восстанавливает. Работает в обе стороны — и предфильтр `pop_blit_b`
отсеивает куски мимо окна ДО `atlas_image`/`gfx_w0_map`, и `blit_b_clip`
режет остальные по нему.
Где сработало: `pop_ceil_bake_empty` — куски ряда 0 высотой 63 px рисовались
целиком, хотя восстановить надо девять строк полосы. **Блит 21 447 → 7 619**,
вся перерисовка 251 335 → 138 318.
Где НЕ сработало — см. §4, отрицательные результаты.
Побочно: пока окно стоит, `pop_blit_b` не ставит пометку «фон трогали»
(признак fore-прохода), поэтому вызывающий обязан пометить прямоугольник сам.
В `pop_ceil_shake_draw` добавлен явный `pop_cd_touch` на область heal'а; в
`pop_ceil_bake_empty` и `pop_floor_bake` метит `pop_bar_black`, а лишний
второй вызов на ту же область снят.
### G2. Контекст тайла — file-scope, а не локали `draw_tile` — 55 000
Порт `load_curr_and_left_tile` (seg008:0339): у оригинала это
`curr_tile`/`curr_modifier`/`draw_xh`/`draw_main_y`/`draw_bottom_y`
переменные модуля, а не локали.
Причина в кодогене: в `draw_tile` **57 вызовов**, и каждое живое через вызов
значение SDCC спиливал в стековый кадр — 26 байт кадра и **513 обращений
`-N(ix)`** (при ~46 замеренных тактах на обращение это ~23 600, что и
намерено). Стало **33 обращения**, кадра нет, банк 7 −703 Б.
### G3. `blit_b_clip` — байтовый габарит + file-scope
Два шага, и важен порядок наблюдений:
1. **Байтового габарита ОДНОГО НЕ ХВАТИЛО.** `sx/sy/dw/dh``uint8_t`
(корректно: кадры атласов ≤ 56×63) дало 211 → 173 обращения, а
22-байтовый кадр остался: значений, живых через шесть вызовов ядер libbgi,
всё равно больше, чем регистров у Z80.
2. **Решило вынесение из локалей** (`bc_*`): 51 обращение, кадр 22 → 12 Б.
Клипованный блит 14 088 → 11 848. Заодно `blit_b_oversize` больше не ходит
через `blit_b_clip` (там теперь байтовый габарит) — рисует напрямую
`gfx_blit_part`; это путь под полноэкранные подложки интро/финала.
### G4. Мелочи
- `pop_blit_b`: аргументы в file-scope (третий и дальше SDCC передаёт стеком,
каждое чтение шло через `-N(ix)`) — 76 → 11 обращений.
- `pop_loose_mob_tick`: пометки всех кусков ОДНИМ пакетом
(`pop_cd_batch_begin/end`) — было по 4 502 такта на кусок.
176 772 → 168 600.
- Коридор heal куска — по фактической высоте СОБРАННОГО композита (было 24
строки константой, стало 20). 168 600 → 156 762.
---
## 3. Что осталось: раскол `draw_tile` (позиция G5)
**Оставшийся разрыв: −147 000.** Он весь в двух местах.
### G5. Расколоть `draw_tile` на узкие части, как в оригинале
**Ожидание: 50 000 … 60 000.**
У оригинала `draw_tile` (seg008:01C7) — это девять независимых вызовов:
`draw_tile_floorright`, `draw_tile_anim_topright`, `draw_tile_right`,
`draw_tile_anim_right`, `draw_tile_bottom`, `draw_loose`, `draw_tile_base`,
`draw_tile_anim`, `draw_tile_fore`. Для ряда −1 он зовёт шесть из них
(`draw_tile_aboveroom`, seg008:01F2), для полосы у потолка — те же шесть плюс
`draw_tile_wipe(3)` (`redraw_needed_above`, seg008:02C1).
У нас всё это — ветки `if (row >= 0)` ВНУТРИ одной функции, то есть контекст
(13 584) и диспетчер (11 388) оплачиваются целиком всегда. Расколов, каждая
точечная перерисовка сможет звать только нужные части:
- `pop_floor_bake`: вместо второго полного `draw_tile(row, col+1)` — только
его правую грань и базу;
- `pop_ceil_shake_draw` / `pop_ceil_bake_empty`: дословный
`draw_tile_aboveroom`;
- `pop_loose_shake_draw`, `pop_spike_redraw`, `pop_gate_redraw` — то же.
Риск средний: у `draw_tile` собрано много инвариантов (BUG-LOOSE-3,
BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1), проверять придётся прогонами всех
уровней. Поэтому делать отдельным заходом, а не хвостом другой правки.
### G6. Меньше блитов в `RD_FLOOR`
**Ожидание: неизвестно, надо мерить.** 105 500 из 179 914 — это 7,6 блита,
и они рисуют настоящие пиксели. Сократить можно только сократив то, что
восстанавливается: бар сейчас 60×39 от `yb+26`, а плита занимает по вертикали
меньше (её куски: левая грань `POP_LOOSE_FRAM_LEFT` 32×13 на `dmy = yb+62`,
низ `POP_LOOSE_FRAM_BOTTOM` 32×3 на `dby = yb+65`, правая грань в соседе
26×16 на `dby1`). То есть плита живёт в `yb+47 .. yb+65`, а бар начинается с
`yb+26`**21 лишняя строка сверху**.
Проверять осторожно: бар заодно стирает и то, что рисует ДРУГИЕ куски тайла
(орнаментная лента `stripe_id` на `dmy27 = yb+35` попадает как раз в
«лишнюю» часть). Сузишь бар — надо убедиться, что ничего не осталось.
### G7. heal летящих кусков — 154 074 (28 % фазы)
Шесть кусков × ~25 700: сам heal 64×20 (по модели ~19 700) + пакетная
пометка + накладные `mob_tick_one` (16-байтовый кадр, 99 обращений `(ix)`).
**Сам heal у предела железа** — это 1 280 пикселей на кусок, оптимизировать
нечего, кроме площади. Площадь уже подрезана до габарита композита.
Остаётся `mob_tick_one` (~4 500 на кусок = 27 000 на кадр) — то же лечение
file-scope, что у `draw_tile`.
### G8. Пометка соседа — узкой полосой, а не полным тайлом (идея пользователя)
**Ожидание: заметное, но не мерено. Взять ПОСЛЕ обхода всех уровней**
(решение пользователя 2026-08-17: пока идёт отлов багов слоёв, каждая правка
добавляет переменных в картину).
Когда плита (1,8) падает, помечаются ДВА тайла:
| пометка | что делает |
|---|---|
| `(1,8)``RD_LOOSE_GONE` | бар 40 на своём x, бар 32 на соседе, `draw_tile(1,8)` + `draw_tile(1,9)` |
| `(1,9)``RD_FLOOR` | бар **60** на x соседа, `draw_tile(1,9)` ЕЩЁ РАЗ |
То есть сосед перезапекается ЦЕЛИКОМ и ПОВТОРНО, хотя потревожили у него
только левые 28 пикселей — там, куда свисает правая грань упавшего тайла.
`draw_tile(1,9)` при этом вызывается дважды на одну пометку.
Что такое эти числа (чтобы не сузить лишнего):
- **60 = 32 свой тайл + 28 СОБСТВЕННЫЙ свес.** Правая грань пола (кадр 42,
26 px) рисуется в клетке соседа с `x+32`, занимая `x+32..x+57`. Для запечки
САМОГО тайла 60 уже минимальны — сужать их нельзя;
- сузить можно только тот случай, когда тайл помечен ПОТОМУ ЧТО ИЗМЕНИЛСЯ ЕГО
ЛЕВЫЙ СОСЕД: тогда нужна полоса 28 px у левого края, а не весь тайл.
Почему выигрыш не символический: `pop_floor_bake` стоит **179 914** тактов,
из них 105 500 — сами блиты. Узкая полоса срезала бы и площадь бара
(60×39 → 28×39), и часть блитов — окно клипа там теперь стоит обязательным
(см. §4), так что отсев достаётся даром.
**Условия, из-за которых это не «просто уменьшить число»:**
1. `pop_floor_bake` — ОБЩАЯ функция: её же зовут кнопка (`pop_button_redraw`),
зеркало, подобранный предмет и щебень на месте посадки. Там меняется сам
тайл и 60 нужны целиком. Значит нужен отдельный вход (напр.
`pop_floor_bake_edge(row, col)`) или параметр-прямоугольник — именно под
пометку «изменился мой левый сосед».
2. Прежде чем выкидывать вторую пометку целиком, сверить ВЕРТИКАЛЬНЫЕ
диапазоны: `pop_loose_bake_empty` кроет `63*row+46 .. +65` (20 строк), а
`pop_floor_bake``yb+26 .. yb+64` (39 строк). То есть сосед покрыт
ВТОРЫМ баром не полностью, и просто снять пометку нельзя.
3. Ширина полосы = свес ЛЕВОГО тайла, а он зависит от типа тайла (у loose это
8 px по комментарию в `pop_loose_bake_empty`, у пола 26). Брать по
максимуму (28) — безопасно.
### G9. Снять временную оснастку
Шесть `pop_dbg_b1..b6` внутри `pop_blit_b` — ~400 такта на блит; при 15
блитах зелёной это 6 000 на кадр. Плюс `pop_dbg_kind`/`m16` (2 вызова на
перерисовку) и `pop_dbg_m5..m15`. Снимать ПОСЛЕ окончания оптимизации: без
них не мерить.
---
## 4. Копия второй страницы — и почему она ТРЕБУЕТ окна клипа
Точечные запечки ставятся с `pages = 2`, срабатывают два кадра подряд (по разу
на страницу дабл-буфера) и оба раза считают одно и то же. После первого раза
нужный прямоугольник уже лежит в ОЗУ-копии первой страницы, и его можно
скопировать: `gfx_copy_page` берёт источником ОЗУ-копию НЕактивной страницы
(то есть ЧИСТЫЙ фон — спрайты рисуются банком без тени и в копию не попадают),
а приёмник обновляет и в видео-ОЗУ, и в ОЗУ-копии. Идея пользователя: тот же
приём, что при перевороте экрана (зелёное зелье), только без зеркала.
| | полная запечка | копия |
|---|---:|---:|
| щебень / кнопка (60×39) | 179 914 | ~35 000 |
| колодец полосы потолка (64×9) | 138 318 | ~17 500 |
**ДВА УСЛОВИЯ КОРРЕКТНОСТИ.** Оба нарушались и оба дали видимые баги.
1. **Запечка обязана быть ОГРАНИЧЕНА копируемым прямоугольником.**
`draw_tile` рисует тайлы ЦЕЛИКОМ, то есть пишет ШИРЕ бара; копия переносит
ровно бар, и всё, что легло вне него, на второй странице остаётся прежним —
страницы расходятся, это видно как МЕРЦАНИЕ через кадр. У полосы потолка
окно стояло с самого начала (G1), у `pop_floor_bake` — нет, и он мерцал
торцами полов, плит и кнопок (найдено пользователем 2026-08-17: уровень 1,
комната 6, Кид на кнопке (0,2)). Поэтому в `pop_floor_bake` окно теперь
стоит КАК УСЛОВИЕ КОРРЕКТНОСТИ, хотя по скорости само по себе убыточно
(см. §5) — снимать его нельзя.
2. **Копия годится только если содержимое тайла между двумя кадрами не
изменилось.** У анимированного тайла (кнопка с идущим таймером связи)
пометка обновляется КАЖДЫЙ кадр и картинка каждый раз другая. Поэтому
`pop_set_redraw`/`pop_set_redraw_above` гасят слот копии при ПЕРЕпометке
(`pop_bake_slot_reset*`).
Плюс слот `bake_pg`/`bake_pg_above` помнит, НА КАКОЙ странице сделана первая
запечка: копируем только если первая была на ДРУГОЙ странице и дабл-буфер
включён. Это покрывает переплетение двух запечек в одном кадре, однобуфер
(чит SPACE) и смену комнаты (`pop_bake_forget`).
---
## 5. Отрицательные результаты — НЕ повторять
### Окно клипа в `pop_floor_bake` — по СКОРОСТИ проверено ТРИ раза, каждый раз хуже
**Но оно всё равно стоит на месте: без него ломается копия второй страницы
(§4).** Ниже — только про скорость самого окна.
| попытка | было | стало |
|---|---:|---:|
| до G3 | 187 266 | 198 279 |
| после G3 | 182 124 | 188 460 |
| после `pop_blit_b` file-scope, с детальным зондом | 179 914 | 188 417 |
Третья попытка объяснила причину: клипованный путь стоит **+1 500 такта на
КАЖДОМ** из 7,6 блитов (+11 400), а режет он только редкие высокие куски — в
трассе такие нашлись (34 878 → 25 872 и 28 818 → 24 090, всего 13 700), но в
среднем по 12 перерисовкам их нет. Запись стоит в коде.
### Прочее (проверено раньше)
- **Не откладывать запекание на другой кадр** — запечка пишет ОЗУ-копию, из
которой восстанавливает heal; отложенная даёт призрак плиты на месте дыры.
- **Не батчить смежные колонки в `pop_ceil_shake_draw`** — плиты стартуют со
случайными задержками, в кадре дрожат разрозненные колонки, пробег почти
всегда длиной в одну.
- **Не ускорять передачу пикселей** — предел железа (3+3 такта на байт,
memory `blit_cost_model`). В пиковом кадре «железный» минимум всех блитов
≈150 000 из 916 458.
- **`LOOSE-SHAKE-RUNS`** (пометки по сменам кадра): наивный вариант выигрыша
НЕ даёт — пять смен × две страницы = те же десять перерисовок. Работает
только версия «пары и тройки», ~20 % и только на дрожащих плитах; оценка
2026-08-13, не перемерена. Подробности —
`roomtest/TASKS_OPEN.md#loose-shake-runs`.
---
## 6. Журнал правок
| дата | что сделано | зелёная: покой / дрожь / пик | коммит |
|---|---|---|---|
| 2026-08-17 | базовый замер | 35 760 / 366 000 / **805 000** | `c312e4a` |
| 2026-08-17 | G2 контекст тайла в file-scope | — / — / **747 954** | `a3c473d` |
| 2026-08-17 | G1 окно клипа в `pop_ceil_bake_empty` | — / — / **663 250** | `a3c473d` |
| 2026-08-17 | G3 `blit_b_clip` байты + file-scope | — / 335 400 / **575 730** | `a3c473d` |
| 2026-08-17 | `pop_blit_b` file-scope; пакетная пометка кусков | — / — / **557 706** | `b2da0b8` |
| 2026-08-17 | коридор heal по высоте композита | 35 760 / 335 400 / **546 900** | `9a50ab2` |
| 2026-08-17 | **копия второй страницы вместо второй запечки** | — / — / **423 558** | `5ef721e` |
| 2026-08-17 | `mob_tick_one` в file-scope; снята оснастка из горячих путей | 35 760 / 326 130 / **414 456** | `18ee60e` |
| 2026-08-17 | фикс мерцания: окно клипа в `pop_floor_bake` как условие корректности копии | замер после фикса — ниже | `35b7cd5` |
| 2026-08-17 | замер после фиксов уровня 1 (мерцание торцов, потолочный fore, сосед под плитой, блеск меча) | 35 760 / — / **417 630** | `40f0d46` |
| 2026-08-17 | **регресс после фиксов уровня 2** (чёрные бары, чит бессмертия) — в пределах шума | — / — / **419 526** | `ec1f384` |
+238
View File
@@ -0,0 +1,238 @@
# Сцена и замер: факел + чомпер + страж, уровень 11 комната 15
Вторая целевая сцена для оптимизации (первая — [`perf_l13_room23.md`](perf_l13_room23.md),
каскад плит). Здесь узкое место другое: не разовый пик на каскаде, а
**постоянная** цена статичной комнаты, в которой одновременно живут два
факела, чомпер и страж.
Такты — `totalcycles` MAME (не такты Z80, ≈2,4× номинала, memory
`sprinter_wait_states_2x`). **Растровый кадр = 430 000.** Хвост кадра — три
`gfx_wait_vsync`, поэтому логический кадр занимает 3 растра, пока работа
укладывается в один; при работе 1..2 растра период становится 4.
## 1. Сцена
Уровень 11, комната 15. Проверено чтением состояния машины:
`pop_current_level` = 0x0B, `cur_room` = 15.
| кто | где | кадр |
|---|---|---|
| Кид | (0,2), x=98 | 15 (стойка с мечом) |
| чомпер | (0,3) | застывший (trob мёртв) |
| факел | (0,2) — пламя рисуется в ячейке (0,3) | анимируется каждый кадр |
| факел | (0,7) — пламя в ячейке (0,8) | анимируется каждый кадр |
| страж | (0,8), x=170 | 171 (боевая стойка) |
Ни Кид, ни страж не двигаются: сцена статична, разброс замера — сотни тактов
на 800 000.
## 2. Замер (`09f32ce`, база модуля `roomtest.c` = 0x42AD)
867 кадров, зонды A/C/D/E; детализация — тремя отдельными прогонами
(m1, m5..m7, M/F). **Период кадра: 4 растра во всех 866 интервалах.**
| участок | зонды | медиана | доля работы |
|---|---|---:|---:|
| **синяя: ввод + heal** | A→m1 | 141 048 | 17,6 % |
| **синяя: логика** | m1→C | 152 190 | 19,0 % |
| синяя, всего | A→C | **293 238** | 36,6 % |
| зелёная: `pop_loose_tick` | C→m5 | 28 872 | 3,6 % |
| **зелёная: `pop_process_trobs`** | m5→m6 | 92 448 | 11,5 % |
| **зелёная: `pop_redraw_needed`** | m6→m7 | 190 260 | 23,7 % |
| зелёная: шов/ворота соседа | m7→D | 9 336 | 1,2 % |
| зелёная, всего | C→D | **320 916** | 40,0 % |
| циан: `check_mirror` + `loose_mob_draw` | D→M | 37 458 | 4,7 % |
| **циан: Кид + страж + fore + HP** | M→F | 148 566 | 18,5 % |
| циан: `char_fore(KID)` + борта | F→E | 1 740 | 0,2 % |
| циан, всего | D→E | **187 758** | 23,4 % |
| **работа** | A→E | **801 768** | 1,86 растра |
Циан здесь **не** выделяется: 187 758 — это 0,44 растрового кадра, а разброс
за 867 кадров всего 174 такта (187 674..187 848). Впечатление «циан ~150 %
кадра» на глаз не подтвердилось — при периоде 4 растра полосы бордюра
размазаны по кадрам и на глаз не читаются.
## 3. Главная находка: чомпер справа от факела — 190 260 тактов/кадр
`pop_dbg_rdmax_tot` = **1**: за кадр перерисовывается РОВНО ОДИН тайл, и
стоит он все 190 260 тактов зелёной фазы.
> **ПОПРАВКА 2026-08-19 (после реализации P1).** Механизм ниже описан
> верно, но ГЛАВНЫМ источником 190 260 тактов он НЕ был. Зонд
> `pop_dbg_kind` показал, что все 312 перерисовок в прогоне — вид
> `POP_RD_CHOMP` (полная), и ни одной от факела: собственная пометка
> чомпера просто перебивала пометку соседа. Настоящая причина — в §6.
> Урок ровно тот, что уже записан в `defer_unexplained_quirks`: механизм,
> который правдоподобно объясняет цифру, ещё не доказан цифрой.
Цепочка:
1. `TORCH_ANIM_DIV = 1` — факел меняет кадр пламени КАЖДЫЙ логический кадр;
2. пламя запекается в фон (`pop_torch_draw`, `GFX_BANK_NORMAL`), а канвас
пламени 16×18 лежит **в ячейке правого соседа** (seg008:560) — то есть
поверх чомпера;
3. поэтому `pop_process_trobs` метит соседа: `if (trob_rcode[i] == TILE_CHOMP)
pop_set_redraw(tp + 1, POP_RD_CHOMP, 1)` (порт `set_redraw_anim_right`);
4. `pop_chomp_redraw` отвечает на пометку **heal 32×64 + полный `draw_tile`**.
Расхождение с оригиналом именно в шаге 4. `set_redraw_anim_right` метит
слой **anim**, и оригинал возвращает только его (`draw_tile_anim_topright`
`draw_tile_anim_right``draw_tile_anim`) — одну графику чомпера поверх
огня. Мы вместо этого стираем и пересобираем тайл целиком, со всеми слоями
(`draw_tile_right`, `base`, `bottom`, `loose`), которые пламя вообще не
трогало.
Цена по модели блита (`blit_cost_model`, 8791 + 198·h + 5,96·w·h):
heal 32×64 ≈ 33 700, значит на один `draw_tile` уходит ≈ 156 000 — сходится
с известным замером «полная запечка щебня 179 914».
Чомпер при этом **застывший**: своей анимации у него нет, поза не меняется,
возвращать нужно ровно ту же графику поверх свежего пламени.
## 4. Что это даёт и куда смотреть дальше
Ранжирование по цене (доля от 801 768):
| # | участок | такты | что делать |
|---|---|---:|---|
| 1 | `redraw_needed`: чомпер под факелом | 190 260 | вернуть только слой anim, как в оригинале — без heal и без остальных слоёв |
| 2 | синяя: логика двух Char | 152 190 | графики нет вообще; разобрать `pop_check_can_guard_see_kid` и два `play_seq` |
| 3 | циан: Кид + страж | 148 566 | оба будятся каждый кадр — метки фона от чомпера/факела накрывают обоих |
| 4 | синяя: heal двух Char | 141 048 | следствие того же: skip не срабатывает ни разу |
| 5 | `process_trobs`: два факела | 92 448 | ≈46 000 на факел при блите пламени 16×18 ≈ 14 000 — разобрать накладные |
| 6 | `loose_tick` | 28 872 | в комнате нет ни одной loose-плиты |
Пункты 3 и 4 — одна тема: пока фон трогают каждый кадр, `pop_char_skip_mask`
не может пропустить ни Кида, ни стража. Пламя метит узко (16×18), а вот
`pop_chomp_redraw` метит весь тайл со свесом — то есть пункт 1 чинит и часть
пунктов 3/4.
Связанные задачи: `HEAL-WIDTH` и G8 в [`perf_green_phase.md`](perf_green_phase.md)
— та же болезнь (полный тайл там, где хватает полосы), но на другом
материале.
## 6. Настоящая причина 190 260 тактов: перерисовка неизменной позы
Найдено при реализации P1, сверкой с `animate_chomper` (seg007:0448).
Функция оригинала заканчивается так:
```c
if ((curr_modifier & 0x7F) < 6) {
redraw_at_trob();
}
```
То есть чомпер перерисовывается **только пока фаза меньше 6** — пять кадров
из пятнадцати (`POP_CHOMPER_SPEED = 15`). Это не оптимизация оригинала, а
следствие таблицы поз: `chomper_fram1 = {3,2,0,1,4,3,3}`, и начиная с фазы 5
и до конца круга поза одна и та же — 3. Рисовать её десять кадров подряд
значит рисовать ровно ту же картинку.
Мы же метили тайл БЕЗУСЛОВНО, каждый кадр, пока trob жив — то есть платили
полный `draw_tile` плюс heal 32×64 за неизменную картинку в двух третях
кадров. А trob у чомпера живёт, пока Кид в том же РЯДУ (`animate_chomper`
снимает его только при фазе ≥ 6 и ушедшем Киде) — в 11/15 Кид стоит в (0,2),
чомпер в (0,3), ряд один.
**Что сделано:**
1. пометка только при фазе < 6, и на фазе 5 — на ОБЕ страницы дабл-буфера
(она последняя рисуемая, её поза обязана лечь на обе; вторую страницу
пометка догоняет в кадре фазы 6, где поза та же самая);
2. пометка от факела (`set_redraw_anim_right`) переведена на новый вид
`POP_RD_CHOMP_ANIM``pop_chomp_anim_draw`: три блита графики чомпера
поверх свежего пламени, без heal и без остальных слоёв — порт ветки
`redraw_frames_anim` (seg008:0211);
3. приоритет полной перерисовки над anim в `pop_set_redraw`у оригинала
это два независимых счётчика, и `full` побеждает.
**Результат** (замер, 552 кадра): полная перерисовка теперь в **40 %**
кадров, лёгкий возврат челюстей — в 60 %. Зелёная фаза: 291 888 в дорогом
кадре против 183 414 в дешёвом.
| | работа | зелёная |
|---|---:|---:|
| до P1 | 768 684 | 294 510 |
| после P1, медиана | **657 882** | **183 420** |
| после P1, дорогой кадр (40 %) | 765 936 | 291 888 |
**−110 802 на медиане** при ожидании −160 000. Разница в том, что 40 %
кадров по-прежнему платят полную цену: там поза реально меняется, и это уже
не лишняя работа, а честная. Дальше её можно резать только раскладом
`draw_tile` на части (P7) или сужением heal (P8).
## 5. Журнал правок по этой сцене
| дата | правка | работа | синяя | зелёная | циан |
|---|---|---:|---:|---:|---:|
| 2026-08-19 | базовый замер (`09f32ce`) | 801 768 | 293 238 | 320 916 | 187 758 |
| 2026-08-19 | **P5**: гейты холостого хода в `pop_loose_tick` | **767 928** | 285 864 | 294 384 | 187 764 |
| | | 33 840 | 7 374 | 26 532 | +6 |
| 2026-08-19 | зонды для замера P2/P6 (временные) | 768 684 | 286 518 | 294 510 | 187 761 |
| 2026-08-19 | **P1**: чомпер — перерисовка только при фазе < 6 (медиана) | **657 882** | 286 503 | 183 420 | 187 761 |
| | | 110 802 | 15 | 111 090 | 0 |
Оснастка P2/P6 стоит 756 тактов на кадр — замеры до и после сопоставимы.
Циан не изменился (+6 тактов — шум), и это ожидаемо: `loose_tick` целиком
лежит в зелёной. Синяя просела на 7 374 без прямой причины в правке —
скорее всего перераскладка кода банка 3 компилятором; проверять отдельно
не стали, знак верный.
## 7. ТЯЖЁЛАЯ позиция: Кид на шаг правее [замер 2026-08-19]
Поставлена пользователем: один осторожный шаг вправо (x = 106 вместо 99,
колонка та же). Спрайт Кида начинает пересекаться с тайлом (0,3), где
одновременно чомпер и пламя факела — и `skip_mask` перестаёт его
пропускать.
| фаза | лёгкая | **тяжёлая** | разница |
|---|---:|---:|---:|
| синяя | 259 500 | 257 520 | 1 980 |
| зелёная (медиана) | 181 068 | 180 870 | 198 |
| **циан** | 187 761 | **319 842** | **+132 081** |
| **работа (медиана)** | 628 542 | **758 358** | **+129 816** |
| работа (максимум) | 744 384 | **876 612** | |
**Период кадра: 4 растра в 335 кадрах, 5 растров в 123 (27 %).** Это уже
не «стабильно медленно», а рывки: каждый четвёртый кадр длиннее соседних.
Разбор циана показывает, куда ушли 132 тысячи:
| участок | лёгкая | тяжёлая |
|---|---:|---:|
| `check_mirror` | 3 198 | 3 198 |
| `mob_draw` + `guard_over_kid` + `skip_mask` | 34 374 | 18 672 |
| **`pop_char_draw(KID)`** | **204** | **54 738** |
| соперник: `char_draw` + `char_fore` | 145 896 | 149 748 |
| **`fore_needed` + `char_fore(KID)` + борта** | **4 356** | **93 486** |
То есть Кид из «пропущен за 204 такта» превращается в полноценного
персонажа за ~144 000 — ровно столько же, сколько стоит страж.
**Главный вывод замера: самая дорогая единичная статья кадра — это
fore-проход персонажа.** 62 778 у стража и ~89 000 у Кида, вместе около
**152 000, то есть 20 % работы кадра**. У Кида он дороже потому, что в его
футпринте лежит чомпер, а у чомпера есть собственный передний слой
(`POP_CHOMP_FRAM_FOR`), который перерисовывается поверх персонажа каждый
кадр.
## 8. После P15 (точная метка «фон трогали»)
| фаза | лёгкая до | лёгкая после | тяжёлая до | тяжёлая после |
|---|---:|---:|---:|---:|
| синяя | 259 050 | **223 902** | 257 520 | **245 808** |
| зелёная | 181 494 | 181 494 | 180 870 | 181 761 |
| циан | 194 262 | **59 406** | 319 842 | **192 090** |
| **работа** | 628 542 | **464 796** | 758 358 | **617 487** |
В лёгкой позиции не рисуется НИ ОДИН персонаж (циан 59 406 — это уже только
`check_mirror`, проверки и передний слой по пометкам). В тяжёлой рисуется
один Кид: он действительно стоит под пламенем, а страж — нет.
**Пятирастровые кадры в тяжёлой позиции исчезли** (было 27 %), период стал
ровно 4.
**Полная очередь оптимизаций с оценками — [`perf_registry.md`](perf_registry.md).**
Там же разложена цена одного блита фона по этапам (замер 2026-08-19, 1603
блита) и модель зелёной фазы этой сцены.
+271
View File
@@ -0,0 +1,271 @@
# Сцена и метод замера: каскад плит, уровень 13 комната 23
Общий документ для двух фазовых: [`perf_green_phase.md`](perf_green_phase.md)
(слой фона) и [`perf_cyan_phase.md`](perf_cyan_phase.md) (персонажи + передний
слой). Здесь — как воспроизвести сцену, чем мерить, сводка по кадрам и
разбор габаритов спрайтов (он общий для обеих фаз).
Сцена: старт уровня 13. Комната 23 стартовая, ряд 2 комнаты СВЕРХУ (17) —
шесть loose-плит в колонках 2..7 (`res2013.bin`: коды `11` в позициях 22..27),
`check_fall_flo` раздаёт им отложенный старт `0xF0..0xFF`, и они сыплются
вразнобой. Кид стоит у правого края и не двигается.
Все числа — такты `totalcycles` MAME (системный клок ~21,5 МГц, **НЕ** такты
Z80: у ОЗУ Sprinter wait-state'ы, ≈2,4× номинала — memory
`sprinter_wait_states_2x`). **Растровый кадр = 430 000.** Логический кадр
спейсится тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 стоит сразу
целый лишний растровый кадр.
Сборка: `make LEVEL=13` на `c312e4a`, `_CODE = 0x4100`, база модуля
`roomtest.c` = **0x42AD**. **Адреса зондов меняются после КАЖДОЙ
пересборки** — брать заново из `.sprinter-cc-roomtest/roomtest.map` и
`roomtest.lst`.
---
## 1. Как воспроизвести сцену
**Только перезапуском программы.** Проверено и отвергнуто:
- **выход из комнаты и возврат** (чит `+`/`-`) — не работает: провалившаяся
плита-потолок уходит в страницу уровня насовсем (`pop_level_set_tile` в
`roomtest.c` по сигналу `pop_ceil_fell`, плюс `animate_loose` в
`pop_trob.c`), и при повторном входе `check_fall_flo` не находит ни одной
`TILE_LOOSE`;
- **рестарт уровня** (`pop_kid_dead = 1` + чит навигации) — не работает по
другой причине: `pop_start_level()` сам заходит в стартовую комнату 23,
взводит гряду, и она доваливается ЗАОЧНО (через `trob` комнаты 17), пока
телепорт уносит Кида в комнату 24;
- **поставить сцену руками** (записать `pop_ceil_modif[2..7]` и копию ряда
сверху `pop_t_above[2..7]` отладчиком) — записи ложатся, но пока машина
БЕЖИТ, их успевает обнулить тот же доваливающийся `trob`.
Рабочий рецепт (идея пользователя, самый чистый): **`ESC` → зонды →
`roomtest`**. `ESC` выходит в DSS, запуск заново стартует уровень 13 с нуля,
Кид сразу в комнате 23, каскад начинается через ~5 логических кадров после
отрисовки комнаты. Зонды обязаны стоять **ДО** набора `roomtest` — за время
набора (9 клавиш ≈ 1,8 с) и загрузки атласов каскад успевает пройти целиком.
## 2. Канал вывода замеров
`printf` из действия брейкпоинта в `error.log` **не** попадает. Читается
verb'ом **`clog N`** плагина `mamebridge` — а его нет в MCP-обёртке
(`mame_mcp.py` знает только `cmd`). Годится прямой файловый IPC:
положить `/tmp/mame_mcp/req_<ЧИСЛО>.txt` с телом команды и прочитать
`resp_<ЧИСЛО>.txt`. **Имя обязано содержать ЧИСЛО** (`init.lua`:
`entry:match("^req_(%d+)%.txt$")`) — с буквенным id запрос молча не
обслуживается.
Скрипты сессии (в scratchpad, при необходимости пересоздать): `mrpc.py`
клиент IPC; `run.sh` — цикл «`bpclear``ESC` → зонды → `roomtest``clog`»;
`parse*.py` — разбор трассы по кадрам.
Форма зонда: `bpset <addr>,1,{printf "<метка> %d",totalcycles; g}`.
Для `pop_dbg_kind``printf "K %d %d",a,totalcycles` (аргумент `uint8_t`
приходит в `A`, `__sdcccall(1)`).
## 3. Зонды
Адреса `out (_io_border), a` (полосы бордюра) из `roomtest.lst` плюс
однобайтовые пустышки `pop_dbg_*` из резидентного `pop_state.c`. Резидент
важен принципиально: у банковых функций один адрес 0xC000+ есть у восьми
модулей сразу, и брейкпоинт ловит все банки (так в прошлой сессии намерили
несуществующие 134 730 тактов).
| зонд | адрес | что |
|---|---|---|
| A | 0x43E8 | `PROF(2)` — начало кадра (ввод + heal) |
| — | 0x46AF | `PROF(2)` — начало логики |
| C | 0x47D1 | `PROF(4)` — начало слоя фона (**зелёная**) |
| D | 0x4BCE | `PROF(6)` — начало спрайтов (**циан**) |
| M | 0x4C26 | `PROF(6)` — кадр Кида |
| F | 0x4C93 | `PROF(6)` — fore поверх Кида |
| E | 0x4CB7 | `PROF(0)` — конец работы, ждём vsync |
| m5/m6/m7 | 0x4DC6 / C7 / C8 | границы внутри зелёной |
| m9..m12 | 0x4DCA..CD | внутренности `pop_loose_tick` |
| m13/m14/m15 | 0x4DCE / CF / D0 | `pop_ceil_shake_draw`: вход / heal / draw_tile |
| kind / m16 | 0x4DD1 / D2 | вид и цена одной перерисовки в `pop_redraw_needed` |
| b1..b5 | 0x4DD3..D7 | участки одного `pop_blit_b` |
Полезные адреса состояния (из `roomtest.map`): `pop_t_room` 0x95F2,
`pop_current_level` 0x9945, `pop_ceil_modif` 0x9C9E, `pop_t_above` 0x95EE
(указатель), `pop_kid_dead` 0x9C4E, `pop_dbg_rdmax` 0x95B1.
Запись в память через MCP — по адресу `0x10000 | addr` (логический вид Z80);
присваивание выражением дебаггера (`print b@... = 1`) **не работает**.
**Грабли:** проверять, что запущен РОВНО ОДИН MAME (`pgrep -f mame.arm | wc -l`).
Мост говорит с одним, замеры собираются с другого, и точки «не срабатывают».
---
## 4. Сводка по кадрам
**Базовый замер (`c312e4a`, ДО оптимизации):**
| фаза каскада | работа | синяя | зелёная | циан | период (растр.) |
|---|---:|---:|---:|---:|---:|
| покой в комнате 23 | 190 860 | 134 550 | 35 760 | 20 550 | **3** |
| дрожат 6 плит | 537 400 | 142 700 | 366 000 | 28 700 | **4** |
| провалы + полёт, ПИК | **1 437 150** | 142 700 | **663 250** | **631 200** | **56** |
| максимум по секции | | 142 830 | **805 000** | **631 800** | |
**После оптимизации (`af189a1`, 2026-08-17):**
| максимум по секции | работа | синяя | зелёная | циан |
|---|---:|---:|---:|---:|
| было | 1 437 150 | 142 830 | 805 000 | 631 800 |
| стало | **916 458** | 142 830 | **546 900** | **270 510** |
| | 36 % | — | 32 % | 57 % |
**Регресс после обхода уровней 1-2 (`ec1f384`, 2026-08-17), 418 кадров:**
| максимум по секции | работа | синяя | зелёная | циан |
|---|---:|---:|---:|---:|
| после оптимизации (`af189a1`) | 916 458 | 142 830 | 546 900 | 270 510 |
| после фиксов ур. 1 (`40f0d46`) | 873 930 | 158 874 | 417 630 | 379 482 |
| **после фиксов ур. 2 (`ec1f384`)** | **878 550** | **158 880** | **419 526** | **379 488** |
Фиксы второго уровня (чёрные бары, чит бессмертия) на бюджет не повлияли:
разница с предыдущим замером +4 620 работы и +1 896 зелёной — шум прогона.
**Регресс после обхода уровней 3-7 (`3bcaf51`, 2026-08-18), 2435 кадров:**
| максимум по секции | работа | синяя | зелёная | циан |
|---|---:|---:|---:|---:|
| после фиксов ур. 2 (`ec1f384`) | 878 550 | 158 880 | 419 526 | 379 488 |
| **после фиксов ур. 3-7 (`3bcaf51`)** | **880 170** | **159 774** | **419 520** | **380 244** |
| разница | +1 620 | +894 | 6 | +756 |
| | +0,2 % | +0,6 % | 0,0 % | +0,2 % |
Все четыре секции — в пределах шума прогона (сравнить с +4 620 / +1 896
выше, которые уже признаны шумом). Зелёная совпала с точностью до 6 тактов.
Что за это время добавилось в горячий путь: `pop_spike_frame` и
`pop_chomp_pose` (SPIKE-BAKED) — один резидентный `call` на слой и ТОЛЬКО на
тайлах-ловушках, в этой комнате их нет; и снятие раннего выхода для трупа
(DIED-ON-BUTTON) — цепочка физики на мёртвом Киде, а он тут жив. Замер это
подтверждает: цена не сдвинулась.
Распределение периода тоже совпало с эталоном кадр в кадр: **3 растра в
2408 кадрах, 4 в 24, 5 в одном** — против «3 в 392, 4 в 24, 5 в одном»
у `ec1f384` (кадров в этом прогоне больше просто потому, что дольше стояли в
покое после каскада). То есть за бюджет вылезает ровно тот же кусок сцены и
ровно на столько же кадров.
**Регресс после обхода уровней 8-9 (`0dd2f6a`, 2026-08-18), 2701 кадр:**
| максимум по секции | работа | синяя | зелёная | циан |
|---|---:|---:|---:|---:|
| после фиксов ур. 3-7 (`3bcaf51`) | 880 170 | 159 774 | 419 520 | 380 244 |
| **после фиксов ур. 8-9** | **880 272** | **159 822** | **419 562** | **380 202** |
| разница | +102 | +48 | +42 | 42 |
Разброс ±100 тактов на 880 000 — это 0,01 %, то есть чистый шум прогона
(циан вообще ушёл в минус). Период снова совпал кадр в кадр: 4 растра в
24 кадрах, 5 в одном.
Что добавилось за это время и почему не подорожало: фиксы стража
(`c40ae3f`) правят только вход в комнату — кода в кадре не прибавилось; фикс
боя у шва (`a498255`) добавил два сравнения в `check_leave`, а в этой сцене
Кид неподвижен и до порогов не доходит. Отладочная трасса `DBG_KIDOBJ`
выключена дефайном и в сборку не попадает.
Скачок циан на фиксах ПЕРВОГО уровня (270 510 → 379 482) объяснён там же:
восстановлены потерянные половины слоёв (`set_redraw2`, ряд 1 foretable), то
есть это плата за корректность, а не регрессия.
Период кадра по прогону: **3 растра в 392 кадрах, 4 в 24, 5 в одном** — то
есть за бюджет вылезает только сам каскад.
Цель — каждая секция ≤ 400 000. **Синяя и циан в бюджете**; зелёная 419 526,
то есть 1,05× цели (и ниже растрового кадра 430 000), остаток разобран в
[`perf_green_phase.md`](perf_green_phase.md) §3 (нужен раскол `draw_tile` на
узкие части, как в оригинале) и в идее G8 (сузить инвалидацию соседнего тайла
до 28-пиксельной полосы).
Где что расходуется и как это чинить — в фазовых документах:
[зелёная](perf_green_phase.md), [циан](perf_cyan_phase.md).
### Общий вывод по пиковому кадру базового замера (1 437 150)
| | такты | доля |
|---|---:|---:|
| блиты (все 27–28 вызовов `pop_blit_b`) | 488 100 | 34 % |
| из них «железный» минимум пикселей (модель `198*h + 5,96*w*h`) | ~150 000 | 10 % |
| синяя (ввод + heal + логика) | 142 700 | 10 % |
| **наши накладные: `draw_tile`, IX-кадры, диспетчер, пометки** | **~1 150 000** | **~80 %** |
Узкое место — НЕ передача пикселей (она на пределе железа, 3+3 такта на байт,
memory `blit_cost_model`), а 16-битная арифметика в стековых кадрах.
---
## 5. Габариты спрайтов: можно ли всё перевести на `uint8_t`
Просканированы каталоги ВСЕХ `.atl` (109 файлов) и исходные PNG наборов
`TITLE`/`PV` — тех, что понадобятся для интро, финала и роликов между
уровнями.
**Игровой кадр — весь укладывается в байт:**
| набор | максимум |
|---|---|
| фон подземелья/дворца (`*_env*`, `*_wall`, `*_fore`, `pop_pot`) | **48 × 63** |
| Кид (`kid0..27`, `sword`) | **56 × 63** (kid3, idx 0) |
| страж / скелет / Джафар | **53 × 42** |
| спрайты комнаты принцессы (`PV.DAT`: персонажи, песочные часы, факел, звёзды) | **49 × 60** |
**Больше 255 — только полноэкранные подложки титров и сюжетных экранов.**
Их восемь, и все рисуются ОДИН раз при показе экрана:
| ресурс | размер | где (`data.h`, `full_image[]`) |
|---|---|---|
| `TITLE/res51` | 320 × 200 | `TITLE_MAIN`, xpos 0 ypos 0 |
| `TITLE/res41` | 320 × 200 | `STORY_FRAME`, xpos 0 ypos 0 |
| `PV/res951` | 320 × 200 | фон комнаты принцессы (`chtab_9_princessbed`) |
| `TITLE/res42..res45` | 272 / 267 / 264 / **256** × 134..142 | «presents», «Prince of Persia», «Mechner» |
| `TITLE/res54` | 272 × 65 | заголовок Hall of Fame |
Высота нигде не превышает 200 — в байт лезет. По ширине не лезут ровно эти
восемь, и ни одна из них не участвует в игровом кадре.
**Вывод: горячий путь можно переводить на 8-битные габариты целиком.**
Для подложек — решение пользователя (2026-08-17): работу с роликами вынести в
отдельный банк с версиями блита под большие спрайты либо звать libbgi напрямую
— клип и проверка выхода за экран им не нужны (рисуются в x = 0/24/48/96,
заведомо внутри 320×200). Ширина 320 всё равно потребует ДВУХ burst-скобок
акселератора на строку — как уже сделано в `pop_vflip`.
Существующая страховка уже есть и остаётся: `pop_blit_b` уводит кадр с
`img[1] | img[3] != 0` на общий путь `blit_b_oversize`.
**Регресс после дня оптимизации 11/15 (`d0ac4b1`, 2026-08-19), 2367 кадров:**
| максимум по секции | эталон `mob-order-B-done` | сейчас | разница |
|---|---:|---:|---:|
| работа | 913 848 | **911 862** | 1 986 |
| синяя | 159 810 | **149 106** | 10 704 |
| зелёная | 440 418 | **436 494** | 3 924 |
| циан | 393 000 | **382 770** | 10 230 |
Период: **3 растра в 2341 кадре, 4 в 23, 5 в 2** — как в эталоне.
Почему сумма минусов по фазам не равна минусу по работе: максимумы разных
фаз достигаются В РАЗНЫХ КАДРАХ (пик синей — не тот кадр, где пик зелёной),
а «работа» здесь — максимум СУММЫ, а не сумма максимумов.
Что из правок 11/15 сюда дошло: P16 и P2b дали синюю и циан (они про
проверки и луч видимости, а те работают в любой сцене), HEAL-WIDTH дал
зелёную (плита 64 → 58 на шести heal'ах кадра).
**Зелёная по-прежнему выше растрового кадра** (436 494 против 430 000).
Главный оставшийся кандидат именно для этой сцены — **P9 (G8)**: при
падении плиты помечаются ДВА тайла, и соседний перезапекается целиком и
повторно (`draw_tile` соседа дважды на одну пометку), хотя потревожены у
него только левые 28 пикселей. При шести падающих плитах это умножается
на шесть.
**ВАЖНО ДЛЯ ПРОЦЕССА.** Этот прогон вскрыл регрессию, которую не поймали
ни хост-тесты, ни сцена 11/15: гейт `loose_any` (позиция P5) не взводился
в `check_fall_flo`, и плиты уровня 13 дрожали, не падая. Сцену 13/23 надо
прогонять после КАЖДОЙ правки loose-механики, а не только когда меняешь её
сознательно.
+871
View File
@@ -0,0 +1,871 @@
# Реестр оптимизаций: всё отложенное, в одном списке
Собрано 2026-08-19 из [`perf_green_phase.md`](perf_green_phase.md) (G1-G9),
[`perf_cyan_phase.md`](perf_cyan_phase.md) (C1-C7),
[`perf_backlog.md`](perf_backlog.md) (позиции 1-7),
[`../roomtest/TASKS_OPEN.md`](../roomtest/TASKS_OPEN.md) (HEAL-WIDTH) и из
свежего разбора сцены [`perf_l11_room15.md`](perf_l11_room15.md).
**База для процентов — работа кадра в 11/15: 801 768 тактов** (замер
`09f32ce`). Где эффект относится к другой сцене, это сказано явно.
Оценки помечены: **[замер]** — измерено; **[модель]** — посчитано по
измеренным составляющим; **[гипотеза]** — не мерено, нужен прогон.
---
## 1. Цена одного блита фона — разложена [замер 2026-08-19]
Метод: брейкпоинты на резидентных адресах внутри `pop_blit_b` (0x5C50),
`temp0` на входе, разница `totalcycles` на каждом вызове. 1603 блита.
| этап | такты | постоянство |
|---|---:|---|
| пролог + аргументы + грубый отсев | **810** | ровно, всегда |
| `atlas_image` | **672** | ровно, всегда |
| `gfx_w0_map` + чтение шапки ленты + арифметика клипа | **2 400** | ровно, всегда |
| ядро блита (пиксели) | 4 380 … 26 592 | по размеру кадра |
| `pop_cd_touch` | **2 069** (пакетный путь) / 4 115 (настоящая пометка) | почти ровно |
| `gfx_w0_unmap` + эпилог | **175** | ровно, всегда |
| **весь блит** | 10 458 … 32 718, медиана **16 674** | |
**Фиксированная накладная = 6 126 тактов на любой блит, хоть 8×8.**
У самого дешёвого блита (10 458) это **59 % цены**; у пламени факела 16×18
пиксели тянут ~1 700 из ~14 000, то есть **12 %**.
Это и есть ответ на вопрос «почему маленький блит стоит 14 000». Причины
ровно те, о которых спрашивал пользователь:
- **810 на пролог**`call ___sdcc_enter_ix`, IX-фрейм и шесть чтений
`N(ix)`: третий и четвёртый аргументы (`int x`, `int ybottom`) идут
СТЕКОМ, каждое обращение 19 тактов Z80;
- **2 069 на `pop_cd_touch`** даже по пакетному пути, где вся работа — четыре
сравнения. Сигнатура `(int x, int y, int w, int h)` = 8 байт аргументов,
два из них через стек; `w`/`h` никогда не больше 64, `y` не больше 255,
то есть три из четырёх могли быть `uint8_t`;
- **2 400 на map + шапку**`gfx_w0_map` (1 086) + `unmap` (264, платится в
конце) + ~1 000 на чтение четырёх байт заголовка и арифметику;
- **672 на `atlas_image`** — маппинг W3, чтение записи каталога, возврат W3;
размеры при этом читаются и выбрасываются (см. §3, C6).
**Блитов за кадр в 11/15: 8** [замер] — все в зелёной фазе (6 на `draw_tile`
чомпера, 2 на пламя факелов). Синяя и циан через `pop_blit_b` не ходят
вовсе: heal и персонажи идут своими путями. Значит фиксированные накладные
блита стоят сцене **8 × 6 126 = 49 000 тактов/кадр (6,1 % работы)**.
---
## 2. Модель зелёной фазы 11/15 [модель, сходится с 320 916 замера]
| статья | такты | доля фазы |
|---|---:|---:|
| 8 блитов фона (из них 6 126×8 = 49 000 накладных) | 133 400 | 41 % |
| диспетчер `draw_tile` (один тайл чомпера) | 56 200 | 18 % |
| цикл `pop_process_trobs` без блитов пламени | 59 100 | 18 % |
| `pop_loose_tick` (плит в комнате НЕТ) | 28 872 | 9 % |
| heal 32×64 в `pop_chomp_redraw` | 34 000 | 11 % |
| шов / ворота соседа | 9 336 | 3 % |
Больше половины фазы — не пиксели, а обвязка вокруг них.
---
## 3. Список приёмов, отсортированный по эффекту
### P1. Чомпер: перерисовка неизменной позы ✅ СДЕЛАНО 2026-08-19 — 110 802
**Диагноз, с которым позиция заводилась, оказался неполным.** Я приписал
190 260 тактов пометке от факела; зонд `pop_dbg_kind` показал, что все 312
перерисовок прогона — вид `POP_RD_CHOMP` (полная), а пометку соседа она
просто перебивала. Настоящая причина нашлась сверкой с `animate_chomper`
(seg007:0448): оригинал перерисовывает чомпер **только при фазе < 6**, пять
кадров из пятнадцати, потому что с фазы 5 поза не меняется
(`chomper_fram1 = {3,2,0,1,4,3,3}`). Мы метили тайл каждый кадр.
Сделано три вещи: условие фазы (с пометкой обеих страниц на фазе 5), новый
вид `POP_RD_CHOMP_ANIM``pop_chomp_anim_draw` (три блита поверх огня, порт
ветки `redraw_frames_anim`) и приоритет полной перерисовки над anim в
`pop_set_redraw`. Обе половины работают: замер даёт 40 % полных
перерисовок и 60 % лёгких.
Работа 768 684 → **657 882** (медиана), зелёная 294 510 → **183 420**.
В 40 % кадров цена осталась прежней — там поза действительно меняется, и это
уже честная работа; резать её дальше только через P7 (раскол `draw_tile`)
или P8 (ширина heal).
Полный разбор — [`perf_l11_room15.md`](perf_l11_room15.md) §6.
<details><summary>Исходная (неполная) постановка</summary>
Разбор в [`perf_l11_room15.md`](perf_l11_room15.md) §3. Сейчас пометка от
факела обрабатывается как `heal 32×64 + полный draw_tile` (190 260 тактов на
единственный перерисованный тайл кадра); оригинал в этом случае рисует
ТОЛЬКО `draw_tile_anim` — графику чомпера поверх свежего пламени.
Останется 1-2 блита челюстей ≈ 20 000-33 000. **Риск низкий**: это
сближение с оригиналом, а не отход от него. Побочно снимает широкую пометку
«фон трогали» вокруг тайла чомпера — см. P3.
</details>
### P2. Синяя фаза разложена [ЗАМЕР 2026-08-19] — гипотеза не подтвердилась
Замер зондами внутрь обеих половин синей (286 518 тактов):
| участок | такты | доля работы |
|---|---:|---:|
| ввод + читы | 14 154 | 1,8 % |
| три спецсобытия уровней (skel / mouse / killed_shadow) | **1 650** | 0,2 % |
| `pop_frame_timers` + **луч видимости стража** | **37 032** | 4,8 % |
| `pop_ctrl_tick` | 18 648 | 2,4 % |
| `skip_mask` + **heal двух Char** | **67 734** | 8,8 % |
| `mirror_heal` + `fore_heal` | 2 040 | 0,3 % |
| `kid_tick` (play_seq) | 10 098 | 1,3 % |
| **`pop_phys_tick`** (физика Кида) | **61 266** | 8,0 % |
| `pop_guard_tick` (логика стража) | 19 932 | 2,6 % |
| **`pop_guard_phys_tick`** (физика стража) | **43 980** | 5,7 % |
| боёвка (`sword_hurting` / `sword_hurt` / `delta_hp`) | 9 558 | 1,2 % |
| `guard_fallout` + уход из комнаты | 366 | — |
**Что оказалось не так, как ждали.** Я предполагал, что дорогие тут
банковые трамплины на спецсобытиях (по аналогии с `pop_clip_char_top`,
8 892 такта за трамплин ради одной проверки). Замер это отверг: три
спецсобытия уровней вместе стоят **1 650** — они гейтятся внутри и на
уровне 11 честно выходят сразу.
**Настоящие статьи — три:**
1. **Физика двух Char — 105 246** (61 266 + 43 980), при том что оба
персонажа СТОЯТ и кадр позы не меняется. Это 13,7 % работы кадра и
самая крупная статья синей. Нужен ещё один уровень разбора — внутрь
`pop_phys_tick` (позиция **P2a**, отдельным заходом).
2. **Луч видимости стража — до 37 032** (вместе с `pop_frame_timers`, но тот
заведомо копеечный: три счётчика). Считается КАЖДЫЙ кадр, хотя ни Кид,
ни страж не сдвинулись. Кандидат на гейт «пересчитывать только при
смене позиции или комнаты любого из двоих» — позиция **P2b**,
ожидание −30 000, риск низкий.
3. **heal двух Char — 67 734.** Отдельной правки не требует: он платится
ровно потому, что `skip_mask` никого не пропускает, и уйдёт вместе с
P1/P3.
Новое, найдено 2026-08-19.
### P15. Точность метки «фон трогали» ✅ СДЕЛАНО 2026-08-19 — 163 746 / 140 871
Постановка пользователя: не перерисовывать стража, пока он не двигается.
**Две правки, и вторая оказалась решающей:**
1. **метка**: вместо «маска колонок × три ряда по 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 —
и прямоугольник «спрайт + клинок» цеплял метку углом.
**Без второй правки первая дала почти ноль** (632 676 против 628 542 до
неё) — это стоит помнить: точность структуры бесполезна, пока запрос к ней
остаётся грубым.
| | лёгкая позиция | тяжёлая позиция |
|---|---:|---:|
| синяя | 259 050 → **223 902** | 257 520 → **245 808** |
| зелёная | 181 494 → 181 494 | 180 870 → 181 761 |
| циан | 194 262 → **59 406** | 319 842 → **192 090** |
| **работа** | 628 542 → **464 796** | 758 358 → **617 487** |
В тяжёлой позиции вдобавок исчезли пятирастровые кадры (было 27 %).
Проверено в MAME: статика чистая, динамика (пробежка, бой, переход в
соседнюю комнату) без хвостов и просвечивания; хост-тесты зелёные.
Побочно исправлены два собственных дефекта первой редакции: обе страницы
обновлялись по условию, проверяющему только страницу 0 (после
`pop_cd_clear(0)` метка второй переставала расти), и отсутствовала явная
инициализация — пустая колонка обозначается `ymin = 255`, а нули от crt0
читались бы как «затронута строка 0».
### P16. Цианные проверки ✅ СДЕЛАНО 2026-08-19 — 25 818
Раскладка остатка цианной фазы (59 406) показала, что 47 883 из них — три
вызова, а не отрисовка:
| вызов | было | стало |
|---|---:|---:|
| `pop_loose_mob_draw` | 978 | 978 (гейт `mobs_live` работает) |
| `guard_over_kid` | 14 424 | **0** |
| `pop_char_skip_mask` | 28 605 | ~21 000 |
Три правки:
1. **`cd_sig_same`** — сравнение снимка БЕЗ построения структуры.
`cd_sig_make` записывал тринадцать полей в стековый кадр (через
`-n(ix)`), и лишь потом шёл побайтовый цикл; теперь сравнение идёт прямо
с источником и выходит на первом расхождении. **8 016**;
2. **`guard_over_kid` по условию** — вопрос «кто поверх кого» не имеет
смысла, когда не рисуется никто. Вызов перенесён ПОСЛЕ `skip_mask` и
идёт только при `skip != 3`. **14 118**;
3. **`pop_cd_hit_slot`** — проверка «задет ли слот» брала пять аргументов,
три из них стеком (45 % тактов на IX). Теперь координаты берутся из
`pop_cd`, а сравнение вынесено в `hit_rect` с file-scope аргументами.
**3 684**.
**Отрицательный результат внутри третьей правки** (не повторять): первая
версия была обёрткой, которая внутри всё равно звала `pop_cd_hit` с пятью
аргументами — стало ХУЖЕ (1799 тактов Z80 вместо 1318). Снимать аргументы
со стека надо у того, кто их читает, а не этажом выше.
### Отрицательные результаты 2026-08-19 — НЕ ПОВТОРЯТЬ
Три попытки подряд сделали ХУЖЕ. Общая ошибка в двух из них — я оценивал
правку по СУММЕ ТАКТОВ ИНСТРУКЦИЙ в листинге, а не по реально исполняемому
пути.
**1. `cd_sig_same` блоком вместо тринадцати сравнений.** Снимок был
переложен так, чтобы сравнивать непрерывные 10 байт начала `pop_char_t`
циклом `do { if (*a++ != *b++) return 0; } while (--i)`. По листингу
функция стала короче (1939 → 1290 тактов), а на машине **стало хуже:
438 978 → 450 426 (+11 448)**.
Причина: сумма по листингу считает каждую инструкцию ОДИН раз, а тело
цикла исполняется ДЕСЯТЬ раз. Тринадцать линейных сравнений выполняются по
разу каждое и выходят раньше на первом же расхождении. **Урок: короткий
листинг ≠ быстрый код; цикл надо разворачивать в уме.**
**2. `cd_touch_pb` — пометка «для блита» из file-scope.** `pop_cd_touch`
зовётся из `pop_blit_b` с четырьмя аргументами, хотя тот держит те же
значения в `pb_x`/`pb_top`/`pb_w`/`pb_h`. Специализированный вход без
аргументов дал **438 978 → 442 242 (+3 264)**.
Причина: в зелёной фазе блиты идут ПАКЕТНЫМ путём (`draw_tile` открывает
`pop_cd_batch`), а там нужны все четыре значения сразу — и в регистрах
(`x`, `y` приходят в HL/DE) они дешевле, чем чтение из статиков.
**Снятие аргументов со стека помогает не всегда: если значение и так живёт
в регистре, статик его туда ещё и загружать заставит.**
**3. Обёртка `pop_cd_hit_slot` поверх `pop_cd_hit`** (описана в P16):
внутри всё равно звала функцию с пятью аргументами и добавила свои — стало
1799 тактов вместо 1318. Помогло только когда сравнение переехало внутрь.
### P14. Fore-проход персонажа — от 4 122 до 117 570 [замеры 2026-08-19]
**Самая НЕСТАБИЛЬНАЯ статья кадра.** Замеры на одной и той же сцене:
| ситуация | fore-проход |
|---|---:|
| персонаж пропущен (`skip`) | 4 122 |
| стоящий страж | 62 778 |
| живой Кид у чомпера | ~89 000 |
| **труп Кида в челюстях** | **117 570** |
Растёт от двух вещей: ширины футпринта (широкий кадр смерти, вынутый меч
добавляет колонку) и числа тайлов с передним слоем внутри футпринта (здесь
чомпер со своими зубьями). Отсюда практический вывод: **в бою проход будет
ближе к сотне тысяч, чем к шестидесяти** — кадры выпадов и ударов широкие.
Замер трупа сделан по просьбе пользователя. Сама по себе эта ситуация не
игровая («когда Кид — труп, игры нет»), но именно она показала верхнюю
границу цены.
**РАЗБОР 2026-08-19: P14 сводится к P4.** Fore-проход Кида в тяжёлой
позиции (89 268) разложен зондами:
| участок | такты |
|---|---:|
| вход + `pop_fore_set_clip` + `char_footprint` | 10 872 |
| арифметика границ окна | 3 786 |
| шов ворот + overlay-цикл | 3 294 |
| **цикл `fore_tile` по тайлам** | **67 854** (76 %) |
| `pop_gate_over_char` + хвост | 3 462 |
А счётчик показал, что цикл обходит **всего 4 тайла**, и 3 из них реально
рисуют (`FORE_ANY != 0`). То есть 67 854 — это НЕ перебор лишних тайлов
(их четыре) и не проверки, а **цена самих блитов переднего слоя**: около
четырёх блитов по ~16 000, из которых 6 765 на каждом — фиксированная
накладная (см. §1).
**Отсюда вывод для плана:** отдельной «оптимизации fore-прохода» почти нет.
Срезать там можно ровно три вещи, и только первая крупная:
1. **цену блита (P4)** — 4 блита × 6 765 накладных = ~27 000 из 67 854;
2. `char_footprint` из физики (**P10**) — часть от 10 872;
3. слияние двух трамплинов в банк 2 — ~4 000.
Иначе говоря, **P4 ускоряет и зелёную фазу (8 блитов), и fore-проход
(4 блита), то есть работает и в статике, и в динамике** — в отличие от
P14, который я считал самостоятельной позицией.
`pop_char_fore` = два трамплина в банк 2 (`pop_fore_set_clip` +
`pop_fore_over_char`) плюс обход тайлов футпринта, в каждом `fore_tile`.
У Кида дороже, чем у стража, потому что в его футпринте лежит чомпер, а у
чомпера есть собственный передний слой (`POP_CHOMP_FRAM_FOR`), который
перерисовывается поверх персонажа каждый кадр.
Что можно пробовать, по возрастанию радикальности:
1. слить два трамплина в один вызов (мелочь, ~4 000);
2. **P10** — брать футпринт из физики, а не считать заново (−11 574 на
проход, то есть до −23 000 на двоих);
3. гейт по сигнатуре: пропускать проход, если не изменились ни кадр
персонажа, ни тайлы его футпринта. **Это расхождение с оригиналом**
он рисует foretable безусловно;
4. **P13** — objtable и отложенные таблицы: у оригинала «посетить тайл»
стоит копейки именно потому, что таблицы только копят записи.
### P3. Персонажи будятся каждый кадр ✅ ЧАСТИЧНО СБЫЛОСЬ
**В лёгкой позиции — да:** после P1 `pop_char_draw(KID)` стоит 204 такта,
метка от чомпера до Кида больше не дотягивается.
**В тяжёлой позиции — нет:** стоит Киду шагнуть на 7 пикселей вправо, и его
спрайт пересекается с тайлом чомпера и пламени, `skip_mask` перестаёт
пропускать, и он снова стоит ~144 000 (54 738 draw + ~89 000 fore). То
есть выигрыш P3 держится только пока персонаж не подошёл к анимированному
тайлу — а в игре он к нему подходит постоянно.
Исходная оценка (−70 000) была:
heal 141 048 + отрисовка 148 566 = 290 000 тактов (36 % работы) уходят на то,
что `pop_char_skip_mask` не может пропустить ни Кида, ни стража: фон трогают
каждый кадр.
- **Кида** спасает P1: широкая пометка вокруг чомпера исчезнет;
- **стража спасти нельзя** — пламя правого факела (0,7) рисуется в ячейке
(0,8), где он и стоит. Там фон честно меняется, и оригинал персонажа тоже
перерисовывает.
### P17. 16 бит там, где хватает 8 ✅ 2026-08-19 (замечание пользователя)
**1. Границы экрана — беззнаковыми сравнениями.** Проверка «спрайт целиком
на экране» стояла как четыре ЗНАКОВЫХ 16-битных сравнения, а знаковое у
SDCC z80 разворачивается в `sbc` плюс `jp PO / xor 0x80 / jp P`.
Беззнаковая форма делает то же двумя: отрицательная координата становится
очень большой и проваливает условие так же, как `>= 0`. **378.**
**2. Габариты спрайтов в байтах.** `w`/`h`, `ow`/`oh`, `fpw`/`fph`,
`cw`/`ch` в `pop_cdraw_t`, параметры `cd_overlay_add`/`cd_clip_add`, локали
в `pop_char_draw`/`cd_splash` и чтение габарита из шапки ленты были
`uint16_t`, хотя спрайты атласов не крупнее 64×64 (memory
`pop_sprite_size_limits`). **−276 в статике, −1 134 в циане динамики**,
плюс 24 байта `_DATA`.
### P6a. Кэш указателя модификаторов ✅ 2026-08-19 — 840 (ждали 20 000)
`pop_trob_modif` объявлен `__banked`, а звался на КАЖДЫЙ trob внутри цикла
`pop_process_trobs`, хотя комната у них в подавляющем большинстве кадров
одна. Указатель теперь кэшируется между итерациями.
Цикл trobs 78 726 → **74 964**, работа кадра 438 324 → **437 484**.
**Оценка в реестре была завышена в двадцать раз**, и стоит понять почему:
я перенёс её по аналогии с лучом видимости (P2b), где трамплин звался
ДЕВЯТЬ раз за кадр. Здесь trob'ов в комнате всего несколько, и кэш
экономит два-три вызова. **Урок: «тот же паттерн» не означает «тот же
порядок величины» — считать надо число вызовов, а не узнавать шаблон.**
### P6b. Кэш префетча кодов тайлов — НЕ ДЕЛАЛОСЬ
Префетч (`pop_level_access_begin/end` плюс чтение кода на каждый trob)
стоит **11 058** за кадр. Кэшировать мешает инвалидация: код тайла меняет
`pop_level_set_tile` (кнопка → пол, loose → empty), вход в комнату и
добавление trob'а — пропустить хоть один источник значит получить
застывшую анимацию. С учётом того, что P6a дал 840 вместо 20 000,
ожидаемый выигрыш тут тоже стоит считать скромным, а риск он несёт
несоразмерный.
### P10. Футпринт из физики — РАЗБОР 2026-08-19 (без реализации)
Идея из `perf_backlog.md` §1: `redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ
`char_col_left/right`, `char_top_row`, `char_bottom_row`, посчитанные в том
же кадре физикой (`set_char_collision`, seg006:0723), а наш
`char_footprint` (pop_bg.c) считает их заново внутри fore-прохода.
**Разбор показал, что «просто передать» не получится: величины разные.**
| | `char_footprint` (банк 2, fore) | `set_char_collision` (банк 3, физика) |
|---|---|---|
| ширина | габарит КАДРА `w` из атласа, `wh = (w+1)/2` | то же `fpw`, но затем **`FRAME_THIN` сдвигает края на ±4** |
| меч | расширяет диапазон на колонку (`sword >= DRAWN`) | не расширяет |
| колонки | `cLraw` до клампа (нужен для шва), затем кламп 0..9 | `coll_xl`/`coll_xr` в пикселях, колонки считает уже `calc_coll_window` |
| ряды | `rT`/`rB` от ВЕРХА и НИЗА спрайта, с форсом `rT = rB-1` | `Char.curr_row` — опорный ряд, это другое |
То есть у оригинала обе задачи пользуются ОДНИМИ величинами, потому что он
считает их один раз в `set_char_collision`. У нас они исторически
разошлись: коллизии считают своё окно (с поправкой `FRAME_THIN`), fore —
своё (габарит кадра плюс колонка под меч).
**Значит P10 — это не «передать готовое», а сперва СВЕСТИ обе величины к
одной, как в оригинале.** Работа не механическая: `FRAME_THIN` влияет на
коллизии осознанно (узкие кадры не должны цеплять стену), а fore-проходу
нужен полный габарит, иначе передние грани в крайней колонке не
перерисуются.
**Чего не хватает для решения:** отдельного замера самого
`char_footprint`. Сейчас известно только «вход + `pop_fore_set_clip` +
`char_footprint` = 10 872», а оценка 11 574 в backlog взята из старого
замера другой сборки. Первым шагом нужен зонд между `set_clip` и
`char_footprint`.
**Оценка приоритета:** низкая. Даже если `char_footprint` окажется всеми
10 872, он платится только когда персонаж рисуется (в статике fore-прохода
нет), а сведение двух геометрий к одной — это риск для коллизий, то есть
для физики, которая сейчас работает правильно.
### P18. Метка «фон трогали» огрублена по X — ОТЛОЖЕНО (решение пользователя)
**Найдено 2026-08-19 пользователем:** Кид перерисовывается, хотя с пламенем
не пересекается; на пиксель левее — перестаёт.
Разбор по памяти машины. Кид `x = 156`, спрайт занимает **x 213..224**,
экранные y 43..83. Метка колонки 7 — y 33..50 (пламя правого факела).
Колонка считается как `x >> 5`, то есть по 32 пикселя, и спрайт достаёт до
224 — ровно первый пиксель колонки 7. По вертикали пересечение с меткой
настоящее (43..50), поэтому слот считается задетым.
А по горизонтали пересечения НЕТ: пламя лежит в колонке 7 на x 232..247,
между ним и Кидом восемь пикселей зазора. На пиксель левее спрайт
кончается на 223, `223 >> 5 = 6`, колонка 7 не задета — и перерисовка
пропадает.
То есть P15 исправил огрубление по Y и оставил его по X.
**Почему отложено (аргументы пользователя):**
- x лежит в 0..319 и в байт не влезает — нужен `uint16_t` на границу, то
есть 4 байта на колонку (80 байт на две страницы), и **16-битные
сравнения в горячем пути**. А они у SDCC z80 дороги ровно настолько,
что могут съесть весь выигрыш (см. отрицательные результаты выше);
- огрубить x вдвое (`x >> 1`, диапазон 0..159 влезает в байт) — это лишний
сдвиг и при записи, и при проверке, плюс точность падает до 2 пикселей.
**Непроверенная идея на будущее:** хранить границы НЕ в экранных x, а как
смещение ВНУТРИ колонки (0..31, пять бит). Тогда байта хватает и сравнение
8-битное, но запись усложняется: прямоугольник, пересекающий несколько
колонок, даёт частичные диапазоны у крайних и полные у средних.
**Когда браться:** если после других позиций бюджет всё ещё не сойдётся.
Выигрыш будет именно в пограничных положениях, а их в игре много —
персонаж почти всегда стоит рядом с чем-то анимированным.
### P4. Накладные блита — ОТКАЧЕНО
**Правка сделана и отменена по решению пользователя.** Критерий: если
выигрыш получен ценой сильно усложнённого кода — откатывать.
Что было: `atlas_image_w0` в libbgi читал каталог из уже подключённой в W0
страницы. **−408 на кадре** при ожидании −5 400.
Почему откачено: цена — вторая публичная функция в API libbgi с НЕЯВНЫМ
контрактом («страница обязана быть подключена до вызова»), которую легко
вызвать неправильно и молча получить мусор, плюс дублирование чтения
каталога. 408 тактов — 0,09 % кадра, меньше разброса между прогонами.
**Что осталось знанием:** сам `gfx_w0_map` стоит всего **324** такта, а 672
у `atlas_image` — это почти целиком вызов функции и арифметика `idx * 8`.
Значит непробованная часть P4 («один map на группу блитов») имеет потолок
~2 600 за кадр, а не 10 000, как считалось.
Ожидание было −5 400 (672 такта × 8 блитов зелёной фазы), и оно НЕ
оправдалось: цена блита 16 107 → 16 005, то есть −102. Причина в том, что
эти 672 — почти целиком вызов функции и арифметика `idx * 8`, а не само
переключение окна. Замер после правки показывает, что работа просто
переехала между статьями:
| этап | до | после |
|---|---:|---:|
| пролог + отсев | 810 | 762 |
| `gfx_w0_map` | (в составе 2 400) | **324** |
| каталог + шапка ленты + клип | | **2 694** |
| ядро | 8 508 | 8 508 |
| `cd_touch` + `unmap` + эпилог | 2 883 | 2 883 |
| **фиксированная накладная** | **6 765** | **6 663** |
Правка оставлена: не вредит, убирает лишнее переключение W3 и делает
контракт честнее (страница мапится один раз). Но как способ снять
накладные она не работает.
**Что осталось непробованным** (и во что я теперь верю меньше): один
`gfx_w0_map` на ГРУППУ блитов — судя по замеру, сам map стоит 324, так что
потолок этой правки ~2 600 за кадр, а не 10 000, как считалось.
<details><summary>Исходная постановка (модель 28 000)</summary>
| правка | на блит | источник |
|---|---:|---|
| `pop_cd_touch`: `uint8_t` вместо `int` для `y`/`w`/`h`, ранний выход пакетного пути | ~−1 300 | новое |
| один `gfx_w0_map`/`unmap` на ГРУППУ блитов | ~1 350 | C5 / backlog §3 |
| размеры ленты из каталога, без `atlas_image` и чтения шапки | ~670 | C6 / backlog §2 |
| `pop_blit_b`: аргументы в 8 бит, где хватает | ~−400 | новое |
Все четыре — низкий риск, механическая работа. Вместе снимают ~3 700 из
6 126 фиксированных.
</details>
### P5. `pop_loose_tick` при пустой комнате — 28 872 → 2 760 ✅ СДЕЛАНО 2026-08-19
**Получено −26 112 внутри функции, −33 840 на кадре** (замер до/после в
11/15). Оценка была 28 000.
Раскладка холостого хода (замер зондами m9..m12) и что с ней стало:
| участок | было | стало |
|---|---:|---:|
| два цикла по тайлам (30 + 10 позиций) | 9 852 | **132** |
| `pop_loose_mob_tick` (обход 14 слотов) | 12 090 | **996** |
| `check_loose_fall_on_kid` (трамплин + обход) | 5 868 | **546** |
| вход + хвост | 1 062 | 1 086 |
| **итого** | **28 872** | **2 760** |
Сделано двумя гейтами:
- `loose_any` (статик `pop_map.c`) — «идёт ли анимация плит». Ставится в
пяти местах записи ненулевой фазы, снимается САМИМ циклом по факту
прохода, где не осталось ни одной живой фазы;
- `pop_mob_busy` (резидент `pop_state.c`) — «занят ли хоть один слот
падающего куска» (`active` или дочистка `clean`). Ставит `mob_alloc`,
снимает обход по факту пустой таблицы. В резиденте, а не в `pop_room.c`,
потому что читает его `pop_map` из банка 3.
**Важно про границу:** гейт отвечает не на «есть ли в комнате плиты», а на
«идёт ли анимация». У лежащей плиты-потолка фаза 0, и крутить нечего —
вопрос пользователя 2026-08-19. Асимметрия намеренная: ложная единица
стоит одного холостого прохода, ложный ноль — застывшей навсегда плиты,
поэтому взвод стоит рядом с КАЖДОЙ записью, а снятие только по факту.
Покрытие: `phys_loose_floor_breaks` (взвод от шага и сотрясения) и новый
`phys_loose_gate_survives_room_change` — на пятое место взвода
(фаза восстановлена входом в комнату), которое не покрывал никто.
Мутационная проверка: со снятым взводом тест падает.
### P6. `pop_process_trobs` разложен [ЗАМЕР 2026-08-19] — 89 784
| участок | такты |
|---|---:|
| вход + префетч кодов тайлов (маппинг окна 0) | **11 058** |
| цикл: два `pop_torch_draw` | ~36 000 |
| цикл: обход самих trob'ов | ~43 000 |
Цена одного `pop_pot_b` (пламя факела) измерена отдельно, брейкпоинтами на
резидентных адресах: **17 346 тактов**, и это ЕДИНСТВЕННАЯ группа в
распределении — то есть `pop_pot_b` в кадре зовут только два факела. При
канвасе пламени 16×18 сами пиксели там 1 716, то есть **10 % цены**; всё
остальное — накладные (см. §1) плюс ~6 900 сверх `pop_blit_b` на самом
`pop_pot_b`.
Направления:
- **P6a**: `pop_trob_modif(room)` зовётся банковым вызовом на КАЖДЫЙ trob
внутри цикла, хотя комната у них одна и та же — вынести наружу;
- **P6b**: префетч 11 058 маппит окно 0 каждый кадр, а коды тайлов trob'ов
меняются редко — кэшировать с инвалидацией по смене тайла/комнаты;
- **P6c**: цена факела — это цена блита, то есть позиция P4.
Новое, найдено 2026-08-19.
### P7. G5. Раскол `draw_tile` на узкие части [оценка дока: −50 000 … 60 000]
Диспетчер + контекст оплачиваются целиком всегда; у оригинала это девять
независимых функций. В 11/15 это те самые 56 200 на один тайл — но если
сделан P1, `draw_tile` чомпера вообще не вызывается, и здесь эффект пропадёт.
Ценность приёма — в ДРУГИХ сценах (13/23, любая комната с плитами).
**Риск средний**: в `draw_tile` собрано много инвариантов (BUG-LOOSE-3,
BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1) — только отдельным заходом с прогоном
всех уровней.
### P8. HEAL-WIDTH — ширина heal'ов по фактическому следу [оценка: 5-6 % цены heal'ов]
ОБЯЗАТЕЛЬНАЯ по решению пользователя (2026-08-18). След плиты 58 px в
подземелье / 57 во дворце против используемых 60 и 64.
Постановка — `TASKS_OPEN.md`, якорь `heal-width`.
### P9. G8 — пометку СОСЕДА ставить узкой полосой (28 px), а не тайлом [гипотеза]
Парная к HEAL-WIDTH. Относится к сценам с падающими плитами (13/23), в 11/15
не играет. Разбор — `perf_green_phase.md` §G8, там же три условия, из-за
которых это не «просто уменьшить число».
### P10. Футпринт персонажа — из физики, а не считать заново [замер: −11 574 на fore-проход]
`backlog` §1. С двумя персонажами — ~23 000 за кадр. Мешает то, что физика
(банк 3) держит `char_col_left/right` в статиках, а слой фона — банк 2.
**Риск средний**: окно fore-клипа заводилось под клинок и брызги.
### P11. Мелочи с известной ценой [замер, `backlog` §7]
| что | цена | где |
|---|---:|---|
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки | 8 892 | `pop_cdraw.c` |
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
| `obj_x * 8 / 7` — последнее `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
### P12. G9 — снять временную оснастку [замер: −6 000]
`pop_dbg_b1..b6` в `pop_blit_b` (~400 на блит), `pop_dbg_kind`/`m16`,
`pop_dbg_m5..m15`, счётчик `rd_cnt` в `pop_redraw_needed`.
**Только ПОСЛЕ окончания оптимизации** — без них не мерить.
### P13. Крупные рефакторинги — брать, только если понадобится ещё запас
**Чем P13 НЕ является (вопрос пользователя 2026-08-19).** Это не «рисовать
комнату заново каждый кадр в скрытый буфер». Такой вариант исключён
арифметикой: 30 тайлов по 5-6 спрайтов при цене блита 16 674 (и 179 914 за
полную запечку одного тайла) дают порядка **3 000 000 тактов — семь
растровых кадров**. Оригинал так тоже не делает: у него та же
инкрементальная схема с пометками (`redraw_frames_full` / `_anim` /
`_fore`), перерисовываются только помеченные тайлы.
Разница не в объёме отрисовки, а в цене ПОСЕЩЕНИЯ тайла: у нас
`fore_tile(r, c)` сразу блитит (со всеми 6 126 фиксированных накладных), а
у оригинала `add_backtable`/`add_midtable`/`add_foretable` только кладут
запись в массив, и рисует один `draw_table()` в конце. Плюс у него ОДИН
обход тайлов за кадр против наших трёх.
- **C7 / backlog §5-6: objtable + отложенные таблицы back/mid/fore.** У
оригинала «посетить тайл» стоит копейки, потому что таблицы только копят
записи, а рисует один `draw_table()` в конце. У нас блит идёт сразу из
обхода, и fore-проход отдельный НА КАЖДОГО персонажа.
- **backlog §4: единый проход по тайлам вместо трёх** (`pop_redraw_needed`,
`pop_process_trobs`, `pop_fore_over_char`) и семь счётчиков причин
перерисовки вместо одного `kind`.
- **G6: меньше блитов в `RD_FLOOR`**, **G7: `mob_tick_one` в file-scope**.
---
## 4. ПЛАН РАБОТ — состояние между сессиями
Рабочий чеклист. Правило: одна позиция = один заход = один коммит с замером
до/после на сцене 11/15. Замер обязателен даже когда «очевидно» — из семи
закрытых позиций ТРИ дали не то, что ожидалось (P1 — вдвое меньше, P2a —
почти ничего, таблицы порогов — регресс).
### Закрыто
| # | что | факт |
|---|---|---|
| P15 | точность метки «фон трогали» + раздельная проверка клинка | **163 746** лёгкая / **140 871** тяжёлая |
| P16 | цианные проверки: снимок без структуры, `guard_over_kid` по условию, `hit_slot` без пяти аргументов | **25 818** |
| P5 | `loose_tick`: гейты холостого хода | **33 840** (ждали 28 000) |
| P1 | чомпер: перерисовка только при фазе < 6 | **110 802** (ждали 160 000) |
| P2b | луч видимости: колонки + один банковый вызов | **26 448** (ждали 30 000) |
| P2a | `coll_scan` в 8 бит + снят с IX | **−2 892** (крупной статьи в физике нет) |
| P3 | Кид перестал будиться каждый кадр | сбылось само после P1 — но только в ЛЁГКОЙ позиции |
| P2/P6 | замеры синей фазы и `process_trobs` | гипотеза «трамплины на спецсобытиях» отвергнута |
| — | замер цианной фазы | крупного лишнего в отрисовке персонажа нет |
### Осталось, по убыванию ожидаемого эффекта
| # | что | ожидание | риск | комментарий |
|---|---|---:|---|---|
| P4 | накладные блита — 6 663 на КАЖДЫЙ блит | частично сделано: **−408** | низкий | из четырёх правок сработала слабо; разбор ниже |
| P14 | fore-проход персонажа | сводится к P4 + P10 | — | разбор ниже: цикл обходит всего 4 тайла |
| P11 | мелочи с известной ценой | −26 000 | низкий | `clip_char_top` 8 658 подтверждён замером |
| P10 | футпринт персонажа из физики | ? (нужен замер) | **высокий** | разбор ниже: величины физики и fore РАЗНЫЕ |
| P6a/P6b | `trob_modif` из цикла, кэш префетча | −20 000 | низкий | тот же паттерн трамплина в цикле |
| P7 | раскол `draw_tile` (G5) | 50 000 в 13/23 | средний | в 11/15 не играет |
| ~~P8~~ | HEAL-WIDTH | ✅ сделано: плита 64→58, чомпер 64→61 | — | эффект ждёт прогона 13/23 |
| P9 | G8 — пометка соседа полосой | не оценено | средний | для сцен с плитами |
| P13 | objtable + отложенные таблицы, единый проход по тайлам | не оценено | очень высокий | большой рефакторинг слоя фона |
| P12 | снять оснастку | −6 000 | нулевой | **последней**: без неё не мерить |
### Текущее состояние бюджета
| | работа | синяя | зелёная | циан | период |
|---|---:|---:|---:|---:|---:|
| до оптимизации | 801 768 | 293 238 | 320 916 | 187 758 | 4 растра |
| после P5 | 767 928 | 285 864 | 294 384 | 187 764 | 4 |
| после P1 (медиана) | 657 882 | 286 503 | 183 420 | 187 761 | 4 |
| после P2a | 654 990 | 283 215 | 183 798 | 187 812 | 4 |
| **после P2b (лёгкая позиция)** | **628 542** | 259 500 | 181 068 | 187 761 | 4 |
| ТЯЖЁЛАЯ позиция (Кид на шаг правее) | 758 358 | 257 520 | 180 870 | 319 842 | **4 и 5** |
| **после P15, лёгкая** | **464 796** | 223 902 | 181 494 | **59 406** | 4 |
| после P15, тяжёлая | 617 487 | 245 808 | 181 761 | 192 090 | **4 везде** |
| после P16, лёгкая | 438 978 | 218 052 | 181 494 | 39 438 | 4 |
| ~~после P4~~ | ~~438 570~~ | | | | правка **ОТКАЧЕНА** |
| после P17, лёгкая | 438 324 | 217 590 | 181 098 | 39 192 | 4 |
| **после P6a, лёгкая** | **437 484** | 217 704 | 180 252 | 39 306 | 4 |
| **после P17, тяжёлая** | **603 684** | 241 956 | 181 464 | 180 270 | 4 |
Итог восьми позиций: **801 768 → 464 796 в лёгкой позиции (−42 %)** и
**758 358 → 617 487 в тяжёлой (−19 %)**. Отдельно важно: в тяжёлой позиции
исчезли пятирастровые кадры (было 27 %), период стал ровно 4 — рывки ушли.
### Достижима ли цель — арифметика на 2026-08-19
Цель: работа ≤ 430 000, тогда период станет 3 растра (хвост кадра — три
`gfx_wait_vsync`).
- в ЛЁГКОЙ позиции снять надо **7 484**;
- в ТЯЖЁЛОЙ — **173 684**.
**Лёгких путей больше не осталось.** За 2026-08-19 отвергнуто ЧЕТЫРЕ
правки подряд (три с регрессом, одна почти без эффекта), и все они целили
в накладные проверок и блита. Фиксированная часть блита 6 663 держится
ядром `gfx_w0_map`/`cd_touch`/чтения шапки, а не «лишними» вызовами.
Всё оставшееся в списке, кроме P13, даёт по оценкам **порядка 100 000** — и
это оптимистично. **Арифметика не сходится:** сцена с двумя персонажами,
чомпером и двумя факелами в три растра не укладывается без одного из трёх
решений:
1. **P13** — переход на objtable и отложенные таблицы, как в оригинале
(единственный резерв нужного размера, но это переписывание слоя фона);
2. **осознанное расхождение с оригиналом** — например, не перерисовывать
передний слой персонажа, пока не изменились ни персонаж, ни тайлы под
ним (гейт по сигнатуре футпринта);
3. **принять 4 растра** как рабочий режим для сцен такой плотности и
выравнивать период, чтобы не было рывков 4/5.
Решение за пользователем — это выбор между точностью порта и скоростью.
### Как воспроизвести сцену (важно для следующей сессии)
Сборка стартует прямо в ней: `make` (дефолты `LEVEL=11 ROOM=15 POS=2`) →
`make hdd`**полный рестарт MAME** (`chdman -f` даёт новый inode, memory
`mame_hdd_rebuild_restart`) → в DSS набрать `d:` и `roomtest`. Кид встаёт в
(0,2) лицом к чомперу, справа факел и страж — та самая сцена замеров.
Штатный старт уровня возвращается через `make ROOM=`.
Проверка, что программа ЖИВА, обязательна перед любым чтением памяти:
`cur_room` (0x97AA) должен лежать в 1..24 — на этом уже был сорван один
замер (прочитаны два случайных байта остановленной машины).
### Метод замера
Зонды — `out (_io_border)` в `roomtest.c` (база модуля 0x42AD) плюс
резидентные пустышки `pop_dbg_m*` из `pop_state.c`. Адреса брать ЗАНОВО из
`.sprinter-cc-roomtest/roomtest.map` после каждой пересборки. Скрипты
сессии: `perfrun.py <out> <сек> tag=addr ...` и `parseseq.py <файл> ПОСЛЕД`.
Цену отдельной РЕЗИДЕНТНОЙ функции можно снять вообще без пересборки:
`bpset <вход>,1,{temp0=totalcycles; g}` плюс `bpset <точка>,1,{printf "…
%d",totalcycles-temp0; g}`. Так разложен блит в §1.
---
## 5. Сводка: что сколько даёт в 11/15
| # | приём | эффект | тип оценки | риск |
|---|---|---:|---|---|
| P1 | чомпер: только anim-слой | −160 000 | модель | низкий |
| P3 | Кид перестанет будиться | −70 000 | модель | следствие P1 |
| P2 | логика двух Char | 40 000 … 70 000 | гипотеза | ? |
| P4 | накладные блита (4 правки) | −28 000 | модель | низкий |
| P5 | `loose_tick` без плит | ✅ −33 840 | ФАКТ | сделано |
| P6 | цикл `process_trobs` | 20 000 … 40 000 | гипотеза | ? |
| P10 | футпринт из физики | −23 000 | замер | средний |
| P11 | мелочи (4 штуки) | −26 000 | замер | низкий |
| P12 | снять оснастку | −6 000 | замер | нулевой |
| P7 | раскол `draw_tile` | 0 здесь (50 000 в 13/23) | оценка | средний |
| P8/P9 | HEAL-WIDTH / G8 | 0 здесь (сцены с плитами) | оценка | низкий/средний |
Верхняя часть списка (P1 + P3 + P4 + P5) — **около 286 000 из 801 768, то
есть 36 % работы кадра**, и вся она низкого риска. Этого хватит, чтобы
сцена ушла с 1,86 растрового кадра до ~1,2 — но НЕ хватит, чтобы период
кадра упал с 4 растров до 3: для этого работа должна уложиться в 430 000,
то есть нужны ещё ~90 000 сверху (P2 или P6).
---
## 5б. Цианная фаза разложена [ЗАМЕР 2026-08-19]
Фаза 188 004 тактов, и она НЕ менялась ни от P1, ни от P5, ни от P2b.
| участок | такты |
|---|---:|
| `check_mirror` | 3 198 |
| `loose_mob_draw` + `guard_over_kid` + `skip_mask` | **34 374** |
| **`pop_char_draw(KID)`** | **204** |
| **соперник: `char_draw` + `char_fore`** | **145 896** |
| `fore_needed` + `hp_draw` | 2 598 |
| `char_fore(KID)` + борта | 1 758 |
**Кид уже пропускается** — 204 такта, то есть надежда P3 всё-таки сбылась
после P1: метка от чомпера до него больше не дотягивается. А страж
перерисовывается каждый кадр, и это ЧЕСТНО: пламя правого факела (0,7)
рисуется в ячейке (0,8), где он стоит, и реально накрывает ему голову
(пламя занимает y 5..22, страж 12..62).
Отрисовка стража (148 302) по частям:
| участок | такты | доля |
|---|---:|---:|
| **`pop_char_fore`** (два трамплина в банк 2 + обход тайлов) | **62 778** | 42 % |
| клинок: `pop_sword_draw` + `cd_overlay_add` + `cd_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` (8 658), и убрать его непросто: функции нужны
`get_tile` и таблицы деления из банка 3, а перенос в резидент вернёт тот же
трамплин внутрь. Всё остальное — работа, которую персонаж действительно
делает: рисует себя, клинок и передний слой поверх себя.
## 6. Иерархия референсов (уточнена 2026-08-19)
Сравнение трёх реализаций луча видимости показало, что источники не
равноценны, и это важно для ЛЮБОЙ будущей оптимизации:
| источник | что берём | чего НЕ берём |
|---|---|---|
| **Apple II** (`Prince-of-Persia-Apple-II`) | как это делается на 8 битах: таблицы вместо делений, борьба за такты | ничего — но код на 6502, читать сложнее |
| **SDLPoP** | эталон ПОВЕДЕНИЯ (декомпиляция DOS-версии) | реализацию: она нарочно «расслаблена» под 32 бита |
| **mininim** | разбор краевых случаев, второе мнение о замысле | алгоритмы — переписан с нуля, механика местами своя |
Доказательство на конкретном месте: `get_tile_div_mod` в SDLPoP содержит
комментарий
```c
// DOS PoP does this:
// obj_xl = tile_mod_tbl[xpos];
// return tile_div_tbl[xpos];
```
а вместо этого делает `x % TILE_SIZEX` и `x / TILE_SIZEX`. Таблицы в файле
лежат, но нужны только для эмуляции чтения DOS-версии ЗА ГРАНИЦЕЙ массива.
Apple II (`CTRLSUBS.S`, `GETBLOCKX`) читает ровно `BlockTable[x]`.
**Правило:** сверять поведение по SDLPoP, а реализацию под 8 бит — по
Apple II и по комментариям вида «DOS PoP does this» в самом SDLPoP.
## 7. Повторяющийся источник цены: банковый трамплин в цикле
Уже трижды крупнейшей статьёй оказывался не алгоритм, а вызов `__banked`-
функции ИЗ ЦИКЛА, идущего в другом банке:
| место | цена | лечение |
|---|---:|---|
| луч видимости: `pop_tile_at` по колонке (P2b) | 36 786 → 13 002 | один вызов на весь отрезок |
| `pop_clip_char_top` — банк 4 → банк 3 ради одной проверки | 8 892 | не сделано (P11) |
| `pop_trob_modif(room)` на каждый trob в цикле | не мерено | не сделано (P6a) |
**Что проверять в первую очередь при новом «дорогом» месте:** не сколько
там арифметики, а сколько раз за кадр пересекается граница банка.
## Фиксированный логический кадр (2026-08-19) — МЕНЯЕТ ВСЕ ЦЕЛЕВЫЕ ЧИСЛА
Период логического кадра больше не `ceil(W) + 2`, а `max(n, ceil(W))`
(`roomtest/pop_pace.c`, разбор — `frame_pacing_plan.md`). Поэтому:
- **Бюджет кадра вырос с 430 080 до 1 290 240 тактов** (n = 3, режим
FASTEST по умолчанию). Все записи этого реестра, где «работа сверх
430 000 стоит сразу целого растра», СЧИТАТЬ УСТАРЕВШИМИ.
- 13/23 (максимум работы 911 862) теперь укладывается в период 3 растра —
проверено, ни одного кадра длиннее. Прежний профиль был 3/4/5.
- Оптимизация из спешной стала плановой: смысл резать такты остался
(режим NORMAL при n=4 и бой при n=5 дают ещё больше запаса, а FASTEST —
верхнюю планку скорости), но «свалиться за растр» больше не обрыв.
- Цена самого пейсинга — ≈4 000 тактов на кадр (0,9 %), замерено A/B.
Приоритет P9 (G8) и остальных позиций от этого не меняется, но их
СРОЧНОСТЬ падает: они больше не спасают от скачка периода.
+148
View File
@@ -0,0 +1,148 @@
# Генераторы псевдослучайных чисел: запасные варианты
Что сейчас стоит в порте, какие есть альтернативы и сколько на них реально
можно выиграть. Заготовка на случай, если упрёмся в бюджет кадра —
**сейчас менять ничего не нужно**.
## Что стоит сейчас
`pop_geom.c`, ветка `POP_PRANDOM_EXACT=1` (по умолчанию) — LCG оригинала
`s = s*214013 + 2531011`, шаг написан на Z80-ассемблере (единственное такое
место в порте). Схема Горнера по разреженной записи константы:
```
214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3
```
17 удвоений, три сложения, одно вычитание; величина `3*s`, нужная в конце,
попадается по дороге на втором шаге. Тело — **≈1 020 тактов** по статическому
подсчёту. Бит-в-бит совместим с SDLPoP, поэтому по картинке можно сверяться
с эталоном.
Вторая ветка, `POP_PRANDOM_EXACT=0` — xorshift16 + шаг Вейля на C.
Совместимость теряется.
Замер в MAME, комната 3, 175 кадров (медиана кадра):
| вариант | кадр | prandom → torch_draw |
|---|---|---|
| C, бит-в-бит (16-битные половины) | 403 632 | 10 933 |
| C, xorshift16 + Вейль | 397 986 | 7 927 |
| **asm, бит-в-бит (сейчас)** | **400 800** | **9 331** |
## Вариант A — комбинированный LFSR + LCG, ~148 тактов
Период > 4 млрд (lcm(65536, 65535) ≈ 4.29e9), младшие биты не вырождены.
```z80
prng16:
seed1=$+1
ld hl, 9999
ld b, h
ld c, l
add hl, hl
add hl, hl
inc l
add hl, bc
ld (seed1), hl
seed2=$+1
ld hl, 987
add hl, hl
sbc a, a
and 101101b
xor l
ld l, a
ld (seed2), hl
add hl, bc
ret
```
Устройство: `seed1` — LCG `x = 5x + 1` (по модулю 2^16; `inc l` вместо
`inc hl` — экономия байта, на период не влияет). `seed2` — 16-битный
LFSR Галуа: сдвиг влево, и если выехала единица, XOR младшего байта с маской
`0x2D` (примитивный многочлен `x^16 + x^5 + x^3 + x^2 + 1`). На выходе
сумма обоих состояний — она и разрушает регулярность младших бит LCG.
**Что мешает взять как есть:** сиды зашиты в код (SMC), а нам нужны ДВЕ
независимые последовательности — раскладка кладки и анимация тайлов.
Пришлось бы передавать состояние через указатель, как сейчас у `pop_prandom`
(это +20…40 тактов, не принципиально).
## Вариант B — xorshift(7,9,8), ~86 тактов
Самый быстрый, период 65535.
```z80
xrnd:
ld hl, 1 ; seed must not be 0
ld a, h
rra
ld a, l
rra
xor h
ld h, a
ld a, l
rra
ld a, h
rra
xor l
ld l, a
xor h
ld h, a
ld (xrnd+1), hl
ret
```
**Две оговорки.** Ноль — неподвижная точка, а сид раскладки кладки у нас
считается как `номер комнаты + смещение ряда + колонка` и вполне может
оказаться нулём: нужен либо guard, либо шаг Вейля поверх. И тот же SMC-сид,
что в варианте A.
## Чего НЕ брать: RND из Apple II
Оригинальный `Prince-of-Persia-Apple-II`:
```
RNDseed := (5 * RNDseed + 23) mod 256
```
```asm
RND
lda RNDseed
asl
asl
clc
adc RNDseed
clc
adc #23
sta RNDseed
rts
```
Полный период 256 (`a ≡ 1 mod 4`, `c` нечётное), и для своего движка он
работал. Нам не годится: у LCG по модулю 256 младшие биты вырождены — бит 0
просто чередуется. Наши вызовы это увидят: раскладка кладки берёт
`prandom(1)` (ОДИН бит) и `prandom(4)`, то есть вместо шума получилась бы
аккуратная шахматка.
## Сколько реально можно выиграть
Меньше, чем кажется по числам 86/148 против 1 020. Тело генератора — уже не
весь расход: остаются обёртка `pop_prandom`, приведение к диапазону
`pop_rnd_fit` и ABI вызова. Верхняя граница выигрыша видна из замера выше:
между нынешним asm-LCG и самым дешёвым из проверенных вариантов разница
**2 814 тактов за кадр (0.65 %)** при двух вызовах за кадр, и это ПОТОЛОК —
любой из вариантов A/B ниже него не опустится.
Порядок действий, если понадобится:
1. Сначала убрать обёртки: слить `pop_rnd_fit` в ту же asm-процедуру, чтобы
на вызов приходился один `call`, а не три. Это ничего не ломает и не
трогает совместимость с эталоном.
2. И только если этого мало — менять генератор, начиная с варианта A
(качество последовательности у него не хуже LCG, в отличие от B).
Важно помнить: число вызовов вырастет с боёвкой. Сейчас их два за кадр
(факелы), а `guard_advance` / `guard_block` / `guard_strike` дёргают
`prandom(255)` каждый по разу за кадр боя — то есть при драке станет 5–6, и
цена вопроса вырастет во столько же раз.
+328
View File
@@ -0,0 +1,328 @@
# QuickSave / QuickLoad — разбор оригинала и план реализации
Статус: **РЕАЛИЗОВАНО и проверено в MAME** (2026-08-22; F6/F9, POP.SAV +
POP.BAK — см. коммит `v0.6-pop-quicksave`). Документ оставлен как
справочник по формату снимка и разбору. Задача на доске —
[`../roomtest/TASKS_OPEN.md#qsave`](../roomtest/TASKS_OPEN.md#qsave).
---
## 0. Важная оговорка об «оригинале»
**В оригинальном PoP 1989 года (DOS/Apple II) QuickSave/QuickLoad НЕТ.**
Там вообще нет сохранения посреди уровня: игра рассчитана на один заход в
60 минут, а «продолжение» — это только пароль/чекпоинт уровня 7. Поэтому
`Prince-of-Persia-Apple-II/` и `MSDOS/` тут не источники — искать в них
нечего.
Источник истины — **SDLPoP**, где быстрое сохранение добавлено как
enhancement: `seg000.c`, блок `#ifdef USE_QUICKSAVE`, клавиши **F6** (save)
и **F9** (load). Ниже разобран именно он. Это значит, что правило
«расхождение с SDLPoP = баг у нас» здесь работает мягче: мы не обязаны
повторять его байт-в-байт, но обязаны повторить его **устройство**, потому
что оно решает ровно те проблемы, которые возникнут и у нас.
---
## 1. Как это устроено в SDLPoP
### 1.1 Точка вызова — отдельная фаза кадра, не обработчик клавиши
Клавиша только взводит флаг (`need_quick_save` / `need_quick_load`,
`seg000.c:558`), а вся работа делается в `check_quick_op()` — она вызывается
из главного цикла **между кадрами**, когда движок в согласованном состоянии.
Это принципиально: загрузка посреди тика переписала бы `Char` под ногами у
`play_seq`.
Отказ штатный, не фатальный: `quick_save()`/`quick_load()` возвращают
успех/неуспех, и игра печатает `QUICKSAVE` / `NO QUICKLOAD` внизу экрана и
продолжается.
### 1.2 Формат — плоская последовательность переменных, без структуры
```c
#define process(x) ok = ok && process_func(&(x), sizeof(x))
```
Один макрос и один и тот же список обходится **и на запись, и на чтение**
(`quick_process(process_save)` / `quick_process(process_load)`). Поля
пишутся встык, без имён и тегов; совместимость держится ровно одним
средством — **строкой версии в начале файла**:
```c
const char quick_version[] = "V1.16b4 ";
```
При загрузке она сравнивается, и при несовпадении файл просто отвергается
(`quick_load`, возврат 0). То есть формат нарочно хрупкий и нарочно
одноразовый — это снимок конкретной сборки, а не сейв-формат.
**Это стоит перенять целиком.** Мы платим за версионирование одним байтом
и получаем право менять состав снимка при каждой правке движка.
### 1.3 Что именно сохраняется
Полный список — `quick_process`, `seg000.c:257-366`. По смыслу он делится
на пять групп:
| группа | поля |
|---|---|
| уровень | `level` (2305 Б целиком), `checkpoint`, `upside_down`, `drawn_room`, `current_level`, `next_level`, `leveldoor_open` |
| анимируемые объекты | `mobs_count`, `mobs[14]`, `trobs_count`, `trobs[30]` |
| Кид | `Kid`, `hitp_curr/max/beg_lev`, `grab_timer`, `holding_sword`, `united_with_shadow`, `have_sword`, `kid_sword_strike`, `pickup_obj_type`, `offguard` |
| соперник | `Guard`, `Char`, `Opp`, `guardhp_curr/max`, `demo_index`, `demo_time`, `curr_guard_color`, `guard_notice_timer`, `guard_skill`, `shadow_initialized`, `guard_refrac`, `justblocked`, `droppedout`, `is_guard_notice`, `can_guard_see_kid` |
| прочее | кэш коллизии (`*_row_coll_room/flags`, `prev_collision_row`), вспышка (`flash_color/time`), звук (`is_screaming`, `is_feather_fall`, …), **`random_seed`**, время (`rem_min`, `rem_tick`), весь блок управления (`control_*`, `ctrl1_*`) |
Два наблюдения, важные для нас:
1. **Состояние ОТРИСОВКИ не сохраняется вообще.** Ни экранных буферов, ни
пометок перерисовки, ни того, что уже нарисовано. Вместо этого при
загрузке комната перерисовывается с нуля. Это резко упрощает задачу и
ровно то, что нам нужно при дабл-буфере.
2. **`random_seed` сохраняется.** Без него загрузка не воспроизводима:
после неё факелы, чомперы и `prandom` в боёвке пойдут иначе.
### 1.4 Что делается при загрузке
`restore_room_after_quick_load()` (`seg000.c:395`) — это и есть вся
«сложность» операции:
- `load_lev_spr(current_level)`**перезагрузка графики уровня** (тайлсет
мог смениться: подземелье/дворец);
- `different_room = 1`, `next_room = drawn_room = Kid.room` — принудительно
«мы в другой комнате», чтобы движок перерисовал всё;
- `load_room_links()` — связи комнат заново;
- `draw_game_frame()` — отрисовать кадр (важно для состояния падения);
- `hitp_delta = guardhp_delta = 1` — принудительный редрой полос HP;
- если `Guard.room != drawn_room` — стража «выключить» (`direction =
dir_56_none`, `guardhp_curr = 0`), как в `clear_char()`;
- `loadkid_and_opp()` — восстановить окно `Char`/`Opp`;
- сбросить таймеры текста и `exit_room_timer`.
Плюс визуальный приём: перед загрузкой экран заливается чёрным на 5 тиков —
чтобы переход читался глазом и не выглядел «дёрганием».
### 1.5 Чего в SDLPoP решили НЕ восстанавливать
- звуки — просто `stop_sounds()`;
- перо (`is_feather_fall`) — без фикса `fix_quicksave_during_feather`
сохранение под пером запрещено вовсе, а при загрузке эффект гасится;
- есть опциональный **штраф**: `USE_QUICKLOAD_PENALTY` отнимает минуту
игрового времени за квиклоад. Нам не нужен (у нас пока нет игрового
таймера).
---
## 2. Чем наша архитектура отличается
| | SDLPoP | у нас | следствие для задачи |
|---|---|---|---|
| уровень в памяти | `level_type` в ОЗУ, 2305 Б, мутабельный | EMM-страница (`pop_lvl_page`), плюс рабочая копия комнаты в W2 | снимок читает страницу через W0-маппинг, а не `memcpy` |
| модификаторы тайлов | внутри `level.bg` | отдельный `room_modif[24][30]` в `pop_trob.c` (**static**) | нужен экспортируемый сериализатор из банка 6 |
| код | один бинарник | 8 банков + резидент | сериализатор обязан жить там же, где данные, и зваться через трамплин |
| экран | один буфер | **дабл-буфер**, у каждой страницы своя теневая копия | после загрузки перерисовать ОБЕ страницы, иначе через кадр мелькнёт старое |
| ОЗУ | сколько угодно | куча 2969 Б, стек 1279 Б | буфер снимка целиком в ОЗУ не положить — писать потоком |
| диск | `fopen` | DSS: 8 манипуляторов, 9-й ВЕШАЕТ систему ([[dss_fd_limit]]) | закрывать файл гарантированно, гард уже есть в libc |
| ГСЧ | один `random_seed` | **три** независимых: `pop_t_seed`, `trob_seed`, `pop_fight_seed` | сохранять все три, иначе загрузка невоспроизводима |
---
## 3. Инвентаризация нашего состояния
Собрано по `.sprinter-cc-roomtest/roomtest.map` (данные всех модулей, включая
банковые, лежат в W2 — банк влияет только на код). Отмечено, что глобально
(видно снаружи), а что `static` и требует аксессора.
### 3.1 Мутабельные данные уровня
| что | где | размер | доступ |
|---|---|---|---|
| тайлы `fg` (провалившиеся плиты, открытые двери, съеденные предметы) | EMM-страница уровня | 720 Б | `pop_level_set_tile` пишет; чтения наружу нет — **нужен аксессор** |
| `room_modif[24][30]` | `pop_trob.c`, static | 720 Б | **нужен сериализатор** (банк 6) |
| `room_seen[24]` | `pop_trob.c`, static | 24 Б | там же |
| `trobs[30]` + `trobs_count` | `pop_trob.c`, static | 91 Б | там же |
| `trob_seed` | `pop_trob.c`, static | 4 Б | там же |
| `mobs[14]` | `pop_room.c`, **глобален** | 210 Б | напрямую |
| `mobs_live` | `pop_room.c`, static | 1 Б | аксессор |
### 3.2 Персонажи и бой
`Kid`, `Char`, `Opp` (`pop_kid.c`), `Guard` (`pop_guard.c`) — по 16 Б,
все глобальные. Рядом: `hitp_curr/max/beg_lev/delta`, `guardhp_curr/max/delta`,
`guard_skill`, `guard_refrac`, `justblocked`, `kid_sword_strike`, `offguard`,
`holding_sword`, `can_guard_see_kid`, `is_guard_notice`,
`pop_guard_notice_timer`, `pop_guard_hurt`, `pop_united_shadow`,
`pop_shadow_init`, `pop_fight_seed`, `knock`.
### 3.3 Прогресс и физика
`pop_current_level`, `pop_next_level`, `pop_checkpoint`, `pop_have_sword`,
`pop_item_taken`, `pop_leveldoor_open`, `pop_leveldoor_right`,
`pop_leveldoor_ybottom`, `pop_kid_dead`, `pop_kid_hurt`, `pop_feather`,
`pop_upside` / `pop_upside_want`, `pop_flash_time` / `pop_flash_color`,
`pop_droppedout`, `pop_fell_out`, `pop_leave_dir`, `pop_leave_timer`,
`pop_loose_*`, `pop_ceil_modif`, `pop_ceil_fell`, `pop_debris_at`,
`pop_seamless`, `pop_jumped_mirror`.
### 3.4 Ввод
`control_x/y/shift/forward/backward/up/down/shift2` (`pop_state.c`) — как в
SDLPoP, сохраняются.
### 3.5 Что НЕ сохранять (восстанавливается перерисовкой)
`room_fg/room_bg`, `lcol_*`/`rcol_*`/`below_fg`/`above_*`, `seam_*`,
`cur_room`, `pop_t_*` (весь кэш слоя фона, окна клипа, `pop_cd_*`),
`trob_drawn`, `mob_spr`, слоты `pop_cd`, метки `pop_redraw`, запечки
(`bake_pg`). Всё это — производное; после загрузки оно обязано быть
сброшено и пересчитано, а не восстановлено.
**Оценка объёма снимка: ≈ 1,9 КБ** (720 + 720 + 210 + 91 + 64 + ~60
скаляров + запас).
---
## 4. Куда писать снимок: HDD-файл, а не EMM-страница
Решение: **один основной слот `POP.SAV` на HDD; предыдущая
валидная запись хранится в `POP.BAK`.** EMM-слота нет: программа
работает только с HDD, а главный сценарий QuickSave обязан переживать
перезапуск игры.
> Пересмотрено 2026-08-21 по вопросу пользователя «почему EMM, а не файл».
> Первая редакция плана рекомендовала EMM — это была ошибка: она взвешивала
> скорость и недооценивала главный сценарий использования. Разбор оставлен
> целиком, потому что довод переносится и на другие «положить в память
> вместо диска» решения.
**Решающий довод: EMM-страница не переживает рестарт программы,** а именно
рестарт — тот случай, ради которого QuickSave и нужен. Пример из этого же
проекта: сцену каскада плит на 13/23 воспроизводит ТОЛЬКО `ESC` → запуск
заново ([`perf_l13_room23.md`](perf_l13_room23.md) §1, где перечислено,
почему не годятся ни возврат в комнату, ни рестарт уровня, ни запись
состояния отладчиком). Тем более снимок в ОЗУ не переживает перезапуск
MAME, обязательный после каждой пересборки образа.
| сценарий | EMM | файл |
|---|---|---|
| «переиграть это место ещё раз» | работает, мгновенно | работает, на HDD быстро |
| «вернуться к багу после рестарта» | **не работает** | **работает** |
Второй сценарий не закрывается ничем другим; первый закрывается обоими, и
разница в скорости там некритична — 1,9 КБ на HDD ([[mame_hdd_test_disk]] —
быстрый путь против дискеты) не заметны на фоне полной перерисовки комнаты,
которая при загрузке делается в любом случае и стоит дороже.
Доводы за EMM, которые при перепроверке оказались слабыми: лимит
манипуляторов DSS ни при чём (открываем и закрываем ровно один файл, гард
`_fd_guard` в libc и так стоит), а «не нужен путь и права» — экономия одной
строки.
Обход состояния всё равно писать с абстракцией чтения/записи, как у SDLPoP
через `process_func`, но второй EMM-слот в scope не входит.
**Проверить ДО кодинга:** пишется ли `test_hdd.chd` из-под MAME. Если образ
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
работ придётся менять. Проверка дешёвая — записать пробный файл на `D:` из
roomtest.
---
## 5. Формат снимка
```
+0 "PQS1" 4 Б магия
+4 версия сборки 1 Б (инкремент при ЛЮБОМ изменении состава)
+5 pop_current_level 1 Б
+6 длина полезной части 2 Б (контроль, что обход совпал)
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
.. checksum 2 Б (заголовок + payload)
```
Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких
тегов и выравнивания: снимок одноразовый и живёт ровно одну сборку.
Обход — один список и один макрос, как `process(x)`:
```c
static void qs_walk(qs_io_t io) /* io = запись или чтение */
{
QS(pop_current_level); QS(pop_checkpoint); ...
}
```
Так состав нельзя рассинхронизировать между сохранением и загрузкой —
единственная реальная опасность плоского формата.
---
## 6. Что делать при загрузке (наш аналог `restore_room_after_quick_load`)
Порядок важен, каждый пункт закрывает конкретный отказ:
1. **Сменился уровень?**`pop_level_load_num()`, `pop_bg_load(tileset)`,
атласы стража по типу. Это дорого, но ровно тот же путь, что при
переходе уровня (`pop_level_switch`), — переиспользовать его, а не писать
заново.
2. Залить экран чёрным (приём SDLPoP: переход должен читаться глазом).
3. Восстановить состояние обходом `qs_walk`.
4. **Сбросить всё производное:** `pop_trob_reset` (но НЕ трогая
восстановленные `room_modif`/`trobs` — нужен отдельный «мягкий» сброс,
только `trob_drawn` + метки), `pop_redraw_reset`, слоты `pop_cd`,
`pop_bake_forget`, `pop_cd_clear`, сигнатуры пропуска перерисовки.
5. `pop_room_load(Kid.room)` — рабочая копия комнаты и срезы соседей.
6. **Полная отрисовка комнаты в ОБЕ страницы дабл-буфера.** Это наше
главное отличие от SDLPoP: одной перерисовки мало, вторая страница
останется со старой картинкой и мигнёт через кадр.
7. Принудительный редрой полос HP (`hitp_delta = guardhp_delta = 1`).
8. Если `Guard.room != Kid.room` — выключить стража
(`Guard.direction = DIR_56_NONE`, `guardhp_curr = 0`), как `clear_char`.
9. `pop_loadkid_and_opp()` — согласовать окно `Char`/`Opp`.
---
## 7. Разбиение на шаги
| шаг | что | критерий готовности |
|---|---|---|
| **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из roomtest) | файл создался и читается обратно после рестарта программы |
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
| **QS2** | Ядро: `qs_walk` + `POP.SAV`, магия/версия/checksum, безопасная замена с предыдущей валидной копией в `POP.BAK` | сохранение и загрузка **в той же комнате, без движения**; порча SAV не портит BAK |
| **QS3** | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
| **QS4** | Клавиши **F6/F9** (или свободные из `pop_cheat.h`) через `<kbd_raw.h>`, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` |
| **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без
QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета
замаскирует ошибки восстановления.
**Главный критерий приёмки всей задачи:** сохранить состояние, выйти по
`ESC`, запустить roomtest заново, загрузить — и оказаться там же. Именно
этого сценария сейчас нет ничем, и ради него задача и делается.
---
## 8. Риски и открытые вопросы
1. **`static` в банковых модулях.** Их нет в карте символов, то есть
отладчиком снимок не проверить. Возможно, стоит сделать `room_modif` и
`trobs` НЕ-static — так же, как уже сделано с `mobs` в `pop_room.c` и
ровно по той же мотивации (там это записано прямым комментарием).
2. **Место в банке 6.** `pop_trob` занимает 3802/16384 — запас есть, но
сериализатор лучше писать компактным обходом, а не 30 отдельными
вызовами.
3. **Три ГСЧ.** Проверить, что сохранены ВСЕ: пропуск любого даст
«загрузилось, но играется иначе» — самый неприятный класс бага, потому
что выглядит как случайность.
4. **Согласованность `Char` и `Kid`.** У нас окно `Char` — отдельная копия;
если сохранить их рассогласованными (снимок посреди тика), загрузка
воскресит рассогласование. Отсюда требование QS4: только между кадрами.
Урок свежий — ровно на этом стыке жил
[BUG-CHEAT-IMM-1](../roomtest/BUGS_CLOSED.md#bug-cheat-imm-1).
5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую
страницу. Симптом — мерцание через кадр
(см. `roomtest/CLAUDE.md`, раздел про дабл-буфер).
6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику
rename/replace. Если атомарная замена не гарантирована, писать через
`POP.NEW`, проверять его после close и не удалять единственную валидную копию
до завершения новой.
+111
View File
@@ -0,0 +1,111 @@
# Бюджет резидента W1/W2: как мерить и как освобождать
Резидент huge-режима — окно `0x4100..0xBB00` (стек с 0xBB00): код в W1,
данные в W2, между концом данных и стеком остаётся куча. Всё, что туда не
влезло, живёт в банках.
## Как СМОТРЕТЬ, а не гадать
**Карта линкера врёт.** File-static SDCC в неё не попадает, и «дырка» между
двумя именованными символами приписывается предыдущему целиком. По карте
выходило, что у `pop_bg` 1529 Б данных (на деле 289), а у `pop_t_win_clear`
1282 Б кода — при том, что это однострочник, а 1282 Б это два статических
помощника соседнего `pop_blit_b`.
Точный источник — объектные файлы: строки `A <area> size <n> flags <f>` в
`.rel` дают ровный размер каждой области модуля, а `S <sym> Def/Ref` — кто
символ определяет и кто на него ссылается.
**Частоту вызовов мерить в MAME счётчиком**, а не оценивать по смыслу:
```
bpset <frame_probe>,1,{printf "F %d ...",temp0,...; temp0=0;...; g}
bpset <func_addr>,1,{temp0=temp0+1; g}
```
Обязательна **канарейка** — счётчик заведомо горячей функции в том же
прогоне. Дважды спасала: один раз показала, что перехода комнаты в окне
замера не было (все нули), другой — что зонды вообще не встали (в zsh
`set -- $pair` НЕ разбивает строку на слова, и адрес уезжал в мусор).
Полную перерисовку комнаты форсировать читом `+`/`-`, ходьбой ненадёжно.
## Сделано
### 1. malloc вон из резидента (−613 Б)
`cbl_open` держал `malloc`/`free` в мёртвой ветке `CBL_UNDERRUN_SILENCE`, а
линкер тянет `.rel` целиком — и куча приезжала каждому приложению. Разведены
две публичные точки входа (`cbl_open` / `cbl_open_silence`) поверх общего
`_cbl_open_raw`; `cbl_close` больше не зовёт `free`.
### 2. Разрез pop_tile: холодная половина в банк 5 (−1788 Б)
`pop_tile.c` был крупнейшим жильцом резидента (5 972 Б кода). Целиком он не
уедет: его const-таблицы (`POP_TILE_DIV/MOD`, `pop_tile_table`, таблицы
кадров) читают банки 2, 3, 7 и 8, а таблица в чужом банке не видна.
Отбирали ЗАМЕРОМ, на двух тайлсетах (подземелье ур. 1 и дворец ур. 4 —
`pop_mem_b` рисует композитный кусок и мог оказаться дворцовым). Порог —
пик не больше 3 вызовов на кадр.
| уехало в банк 5 | пик/кадр | | осталось в резиденте | пик/кадр |
|---|---:|---|---|---:|
| `pop_mem_b` | 0 | | `pop_tile_code` | 296 |
| `pop_cd_hit` (+`hit_rect`) | 0 | | `pop_cd_touch` | 198 |
| `pop_t_win_set/clear` | 0..1 | | `pop_blit_b` (+2 статика) | 184 |
| `pop_heal_off` | 0..1 | | `pop_wall_modifier` | 101 |
| `pop_potion_flask` | 0..1 | | `pop_env_b` | 73 |
| `pop_room_set_above/below` | 1 | | `pop_tile_mod` | 70 |
| `pop_cd_init/clear` | 1 | | `pop_cd_batch_end` | 40 |
| `pop_bar_black` | 3 | | `pop_fore_set_clip` | 2 |
| `pop_cd_hit_slot` | 2..3 | | все const-таблицы | — |
`pop_fore_set_clip` (88 Б) оставлен намеренно: не стоит отказа от прямого
вызова из банка 4, ради которого он и заводился.
**Цена трамплина замерена**: 252 такта пролог + 84 эпилог + ~50 на стороне
вызывающего = **~410 тактов** на вызов. Итого ~1 000 тактов на кадр покоя
(0,2 % работы) и ~3 700 на кадр редрава (0,009 растра).
**Ключ, почему это безопасно:** вызов банк → резидент ПРЯМОЙ, трамплин не
нужен (W1/W2 замаплены всегда). Поэтому `blit_b_clip` просто перестал быть
`static` и объявлен в `_pop_tile.h`, а не переехал следом за `pop_mem_b`.
### Итог
| | было | стало |
|---|---:|---:|
| `_CODE` резидента | 24 329 | **21 928** |
| свободно до стека | **129 Б** | **2 535 Б** |
| BANK5 | 2 080 (13 %) | 3 954 (24 %) |
Проверено в MAME: уровень 1 (подземелье) и уровень 4 (дворец), переходы
комнат читом `+`, ходьба — фон, факелы, решётки, гобелены, колонны без
искажений.
## ЛОВУШКА: данные банка в его страницу — НЕ ДЕЛАТЬ без разбора
Отдельная попытка (`--bank-data=SRC`, коммиты 3545826/9025573) **откачена**:
перенос писучих данных банкового модуля в его 16-КБ страницу давал цветной
мусор блоками и ронял DSS.
У `pop_trob` причина найдена: `pop_trob_modif()` ВОЗВРАЩАЕТ УКАЗАТЕЛЬ на
`room_modif[24][30]`, а зовут её из банков 2, 3, 7 и резидента — после
переноса они пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода.
Def/Ref-анализ такого не видит: снаружи ссылки на символ нет, есть ссылка на
функцию, отдающую его адрес. Но и `pop_room`, у которого утечки указателя
найти не удалось, ломался так же — механизм понят не до конца.
Нулевая инициализация при этом ни при чём: `mkexe -p 0` был проверен по
образу (прогон нулей 14 304 Б, самый длинный прогон 0xFF — 14).
**Перенос КОДА в банк — штатный путь, на нём стоят все наши банки. Ломался
именно перенос ДАННЫХ.**
## Что осталось
- `roomtest.c` 2 508 Б и `pop_kid.c` 2 418 Б — следующие по величине, но оба
горячие (главный цикл и `play_seq`).
- `pop_level.c` 956 Б кода + 660 Б данных (из них `pop_dl1`/`pop_dl2` по
256 Б — таблицы дверных связей).
- BANK7 на 77 %: если понадобится место в нём — выносить `pop_redraw.c`.
+20 -2
View File
@@ -1,5 +1,21 @@
# PoP roomtest — модель `kid_room ≠ drawn_room` (баг #4)
> **Статус: ЖИВОЙ ПЛАН, сделан частично (сверено 2026-08-01).**
> - **S1 — сделан:** `kid_room` заведён отдельно от `cur_room`,
> `update_kid_render_dx()` (`roomtest.c`) даёт рендер-смещение ∓140, а
> `pop_kid_set_render_dx` применяет его в отрисовке. Фактически это пока
> каркас: `enter_room` держит `kid_room == cur_room`, так что смещение
> всегда 0.
> - **S2/S3/S4 — не сделаны и не срочны.** Исходный повод (баг #4,
> пинг-понг у шва) закрыт иначе — поправкой odd-pixel в
> `char_x_forward_edge` + `pop_leave_timer` (разбор корня —
> `../roomtest/BUGS_CLOSED.md`, BUG-SEAM-PINGPONG).
>
> **Зачем документ остаётся.** Полная straddle-модель понадобится для:
> (а) читов осмотра соседних комнат `H/J/U/N` (`levels_plan.md` §4),
> (б) сцен, где Кид и страж в разных комнатах кадра, (в) остатков окклюзии у
> шва (S4). Брать из `../roomtest/TASKS_OPEN.md`, когда дойдёт очередь.
Порт straddle-модели SDLPoP: персонаж может находиться в СОСЕДНЕЙ комнате,
пока на экране ещё ТЕКУЩАЯ (drawn_room). Источник истины — SDLPoP.
@@ -62,7 +78,9 @@ drawn_room с `curr_col=-1/10` + снапшоты соседей `g_lcol/g_rcol`
### S4. Полировка
- Окклюзия/ceiling у шва при straddle, BUG-OCCL-1 (глубина), правый край.
## Связанные баги (bug_list.md)
## Связанные баги — все ЗАКРЫТЫ (`../roomtest/BUGS_CLOSED.md`)
BUG-CEIL-1 (руки при прыжке вверх), BUG-CEIL-2 (loose в потолке),
BUG-CEIL-3 (потолок над анимируемыми воротами), BUG-OCCL-1 (тень дальней
колонны). Memory: `pop_seam_room_model`.
колонны) — починены без полной straddle-модели. То есть S4 «полировка
окклюзии» осталась актуальной только для окклюзии У ШВА при straddle.
Memory: `pop_seam_room_model`.
+65
View File
@@ -0,0 +1,65 @@
# Комнаты для отладочного телепорта (`+` / `-`)
Считано скриптом прямо по `../SDLPoP/data/LEVELS/res20NN.bin`, 2026-08-13.
Задача: обход комнат читом идёт последовательно, и в КАЖДОЙ комнате Киду
должно найтись место для материализации без падения и смерти.
## Критерии пропуска
| причина | как определяется |
|---|---|
| **недостижима** | до комнаты нельзя дойти от стартовой обходом связей (BFS по left/right/up/down) |
| **нет пола** | ни одного тайла, на котором можно стоять (`tile_is_floor`, seg004) |
| **пол опасный** | стоять есть на чём, но обычного пола (код 1) нет — только пики, расшатанные плиты и прочее |
Про достижимость важно: считать «сколько комнат на неё ссылаются» НЕ
годится. На уровне 1 комнаты **13** и **18** ссылаются только друг на
друга (13.down = 18, 18.up = 13), то есть входящая связь у каждой есть, а
из остального уровня в них не попасть. Ловит это только обход от старта.
Чит ищет место снизу вверх: сперва обычный пол (код 1), потом любой
проходимый тайл (`pop_dbg_roomnav`, `roomtest_cold.c`). Поэтому «пол
опасный» — это комнаты, где он свалится на fallback и Кид может погибнуть.
## Таблица по уровням
| ур. | старт | достижимо | пропускать |
|---|---|---|---|
| 1 | 1 | 21/24 | **13** (недостижима), **18** (недостижима), **24** (недостижима) |
| 2 | 5 | 24/24 | **14** (пол опасный), **17** (пол опасный) |
| 3 | 9 | 22/24 | **17** (пол опасный), **19** (пол опасный), **20** (нет пола), **21** (пол опасный), **23** (недостижима/нет пола), **24** (недостижима/нет пола) |
| 4 | 1 | 24/24 | **19** (нет пола), **20** (нет пола) |
| 5 | 7 | 19/24 | **1** (недостижима), **3** (недостижима), **5** (недостижима), **6** (недостижима), **19** (недостижима/нет пола), **21** (пол опасный), **22** (нет пола) |
| 6 | 24 | 14/24 | **2** (пол опасный), **3** (нет пола), **4** (недостижима), **7** (нет пола), **8** (недостижима/нет пола), **11** (пол опасный), **13** (недостижима), **14** (недостижима), **16** (недостижима), **17** (недостижима), **19** (недостижима/нет пола), **20** (недостижима), **21** (недостижима/пол опасный), **22** (недостижима), **23** (нет пола) |
| 7 | 17 | 24/24 | **3** (пол опасный), **17** (нет пола), **21** (пол опасный) |
| 8 | 1 | 21/24 | **9** (нет пола), **10** (пол опасный), **11** (недостижима), **15** (недостижима), **17** (пол опасный), **19** (недостижима), **20** (пол опасный), **21** (нет пола) |
| 9 | 11 | 24/24 | **8** (нет пола), **18** (пол опасный) |
| 10 | 1 | 19/24 | **3** (нет пола), **4** (пол опасный), **6** (недостижима), **9** (нет пола), **11** (пол опасный), **13** (нет пола), **18** (нет пола), **20** (нет пола), **21** (недостижима), **22** (недостижима), **23** (недостижима), **24** (недостижима) |
| 11 | 6 | 23/24 | **3** (нет пола), **5** (нет пола), **9** (нет пола), **10** (нет пола), **11** (нет пола), **12** (нет пола), **17** (нет пола), **18** (недостижима), **23** (нет пола) |
| 12 | 3 | 24/24 | **5** (нет пола), **6** (пол опасный), **8** (пол опасный), **10** (нет пола), **11** (нет пола), **17** (нет пола), **18** (пол опасный), **19** (пол опасный), **22** (нет пола) |
| 13 | 23 | 11/24 | **2** (нет пола), **4** (пол опасный), **5** (недостижима), **6** (недостижима/пол опасный), **7** (недостижима), **8** (недостижима/пол опасный), **9** (недостижима), **12** (недостижима), **14** (недостижима), **15** (недостижима), **18** (недостижима/пол опасный), **19** (недостижима/пол опасный), **20** (недостижима), **21** (недостижима), **22** (недостижима/нет пола) |
| 14 | 4 | 6/24 | **7** (недостижима/нет пола), **8** (недостижима), **9** (недостижима), **10** (недостижима), **11** (недостижима), **12** (недостижима), **13** (недостижима), **14** (недостижима/пол опасный), **15** (недостижима), **16** (недостижима), **17** (недостижима/пол опасный), **18** (недостижима), **19** (недостижима), **20** (недостижима), **21** (недостижима), **22** (недостижима), **23** (недостижима), **24** (недостижима) |
| 15 | 6 | 6/24 | **1** (недостижима), **2** (недостижима), **7** (нет пола), **9** (недостижима/пол опасный), **10** (недостижима/пол опасный), **11** (недостижима/пол опасный), **12** (недостижима/пол опасный), **13** (недостижима/пол опасный), **14** (недостижима/пол опасный), **15** (недостижима/пол опасный), **16** (недостижима/пол опасный), **17** (недостижима/пол опасный), **18** (недостижима/пол опасный), **19** (недостижима/пол опасный), **20** (недостижима/пол опасный), **21** (недостижима/пол опасный), **22** (недостижима/пол опасный), **23** (недостижима/пол опасный), **24** (недостижима/пол опасный) |
## Спецкомнаты — пропускать независимо от таблицы
| ур. | комн. | почему |
|---|---|---|
| 12 | 23 | **seamless exit**: попадание МЕНЯЕТ УРОВЕНЬ на 13-й. Чит-триггер придержан (`pop_nav_hold`), но в обходе комнате делать нечего |
| 6 | 1 | **falling exit**: Кид проваливается вниз и уходит на 7-й уровень |
| 7 | 17 | **falling entry**: экран сразу переводится на комнату НИЖЕ (в таблице уже «нет пола») |
| 7 | 14 | проходится СВЕРХУ ВНИЗ; чит для неё уже особый — ставит Кида в ряд 0 |
| 5 | 24 | тень ждёт в колонке −1 ряда 0; посадка рядом начинает схватку, которой там быть не должно |
| 13 | 23, 16 | вход роняет гряду плит (`check_fall_flo`) — материализация под падающей плитой |
## Уровень 15
Экран защиты от копирования, не часть сюжета (см.
[`levels_12_15_plan.md`](levels_12_15_plan.md) §4). Портировать не
планируем — **пропускать целиком**.
## Как применять
Список — данные, а не логика: держать таблицей в отладочном коде рядом с
`pop_dbg_roomnav` и пропускать помеченные комнаты, чтобы `+` всегда попадал
в пригодную. Пересчитывать скриптом, если поменяются данные уровней.
+266
View File
@@ -0,0 +1,266 @@
# Атлас Тени — разбор и план
Дата: 2026-08-20. Статус: **ЗАКРЫТО.** Ш0-Ш4 сделаны, прогон
пользователем на уровнях **4, 5, 6 и 12** — всё корректно.
Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража
(в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид
даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не
умеет, поэтому запекаем результат в отдельный атлас заранее.
## 1. Что делает оригинал (проверено по SDLPoP, не по памяти)
`draw_objtable_item`, `seg008.c:1600`:
```c
case 1: // shadow
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl, obj_y, blitters_2_or, 1);
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);
```
Тот же самый спрайт кладётся ДВАЖДЫ: первый проход в x, второй в x+1.
Два уточнения, которые меняют алгоритм запекания:
1. **`blitters_2_or` — это НЕ побитовое ИЛИ.** В SDLPoP он реализован
обычным блитом с colour key = индекс 0 (`method_6_blit_img_to_scr`,
`seg009.c:3306`: `SDL_SetColorKey(image, SDL_TRUE, 0)`). То есть
первый проход — наш обычный прозрачный блит, один в один.
2. **`blitters_3_xor` работает по 24-битному RGB, а не по индексам
палитры** (`blit_xor`, `seg009.c:3190`: конвертация в 24 бита, затем
`*p_dest ^= *p_src` побайтно). Прозрачности у него нет вообще —
XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это
чёрный 0x000000, а XOR с нулём ничего не меняет.
Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой
палитры даёт цвет, которого в ней нет.
### Каким набором спрайтов рисуется Тень (проверено 2026-08-20)
Сначала я решил, что в боевых кадрах Тень рисуется спрайтами СТРАЖА, и
записал это в план. **Это было неверно, поправка ниже.**
Набор выбирает НЕ charid. `load_frame_to_obj` (`seg008.c:1752`):
```c
word chtab_base = id_chtab_2_kid; // жёстко Кид
obj_chtab = chtab_base + (cur_frame.sword >> 6); // старшие 2 бита кадра
```
то есть набор берётся из САМИХ ДАННЫХ КАДРА, поле `sword`: младшие 6 бит —
картинка меча, старшие два — смещение chtab относительно Кида
(`types.h:361`). Проверил обе таблицы:
- `frame_table_kid`**все 241 кадра** имеют `sword & 0xC0 == 0` → chtab_2.
Значит **Кид всегда рисуется своими спрайтами**, боевые кадры 150..189 не
исключение;
- `frame_tbl_guard` — все кадры имеют `0xC0` → chtab_5.
Тень берёт `frame_tbl_guard` для кадров 150..189 (`seg006.c:533`), значит в
бою она идёт через chtab_5. **Но chtab_5 — это не «страж», это «соперник
уровня»**: он грузится из `tbl_guard_dat[tbl_guard_type[уровень]]`
(`seg000.c:1117`), а `tbl_guard_type[12] == 4`**SHADOW.DAT**.
Открыл SHADOW.DAT: его палитра **побайтно равна палитре Кида** (у GUARD.DAT
там серая рампа под перекраску), а спрайты — Кид в боевых позах, не страж.
Отрендерил тройками «Кид / SHADOW.DAT / GUARD.DAT» для кадров
151/153/158/161/167: первые две колонки — один и тот же персонаж, третья —
серый страж в тюрбане.
**Вывод: Тень ВСЕГДА выглядит Кидом, и ощущение пользователя верно.**
Спрайты при этом лежат в двух файлах: не-боевые кадры в chtab_2 (KID),
боевые — в chtab_5, заполненном SHADOW.DAT.
### Можно ли взять для боя собственные кадры Кида
Механически да: `frame_table_kid` покрывает 150..189 со своей геометрией,
и хватило бы снять спецветку для `charid_1_shadow` в `load_frame`. Но
кадры НЕ совпадают: из 34 боевых у 31 отличается габарит (на 1-6 px), у 3
отличаются пиксели. Это разная графика, а не одна и та же в двух файлах.
Поэтому берём SHADOW.DAT — он и есть «кадры Кида для Тени», подготовленные
авторами.
## 2. Единственное место, где запечка отличается от оригинала
XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона.
Разбор по пикселям показывает, что зависимость узкая:
| пиксель | первый проход | второй проход | зависит от фона? |
|---|---|---|---|
| спрайт непрозрачен в x | закрашен цветом спрайта | XOR с цветом из x−1 | **нет** |
| прозрачен в x, непрозрачен в x−1 | фон | фон XOR цвет | **да** |
| прозрачен в обоих | фон | фон | нет (не рисуем) |
То есть от фона зависит только **кайма в один пиксель по левым кромкам
силуэта**. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у
зеркала, уровень 6, бой на 12-м) `фон XOR цвет == цвет`, и запечка точна.
На светлом фоне оригинал подкрасит эту кайму, мы — нет.
**Это осознанное расхождение, в `impl_diff.md` при реализации.**
## 3. Замеры на реальных ассетах
Прогон алгоритма по правильным наборам (`SDLPoP/data/KID` +
`SDLPoP/data/SHADOW`):
| набор | кадров | макс. габарит | разных цветов |
|---|---:|---|---:|
| Кид (chtab_2) | 219 | 56×57 | 44 |
| SHADOW.DAT (chtab_5 на ур. 12) | 32 | 49×38 | 22 |
| **вместе** | **251** | 57×57 (с учётом +1 px сдвига) | **45** |
Цветов вместе почти столько же, сколько у одного Кида: SHADOW.DAT сидит на
той же палитре, новых сочетаний XOR почти не даёт. Всего пикселей во всех
кадрах Тени — 91 045.
### Насколько заметна подмена
«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился
цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB
(максимум возможного — 441). Пробовал три стратегии подбора:
- **A** — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
- **B** — взвешенный k-means по всем цветам (двигает вообще все);
- **C** — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
| палитра | стратегия | изменено px | заметно (40..90) | сильно (>90) | худшая |
|---|---|---:|---:|---:|---:|
| 16 | A самые частые | 959 (1,05 %) | 569 | 381 | 128 |
| **16** | **C гибрид 5+11** | 6 984 (7,67 %) | 1 078 | **76** | 114 |
| 32 | A самые частые | 50 (0,05 %) | 40 | 4 | 113 |
| 32 | C гибрид 28+4 | 73 (0,08 %) | 58 | **0** | 90 |
Читается так. При 32 цветах всё практически идеально: 73 пикселя на 251
кадр, грубых промахов нет вовсе. При 16 цветах выбор стратегии виден:
«самые частые» трогает меньше пикселей (959), но 381 из них уезжает СИЛЬНО;
гибрид размазывает ошибку — грубых остаётся 76, то есть примерно **0,3
пикселя на кадр**.
### Решение: **16 цветов, стратегия C (гибрид 5 + 11)**
Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в
трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: **отличий
не видно**. Объяснение в самих числах: перцептивно значимых пикселей при
16 цветах гибридом — 1 154 на 251 кадр, это ~4,6 пикселя на кадр при
~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.
Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие
наборы — принцесса, визирь, мышь), а разница неразличима.
**Но стратегия обязана быть гибридной.** При 16 цветах «взять самые
частые» впятеро хуже по грубым промахам (381 пиксель против 76), и это
единственное место, где выбор стратегии виден. Гибрид: 5 самых частых
берём ТОЧНО, оставшиеся 11 слотов отдаём под взвешенные кластеры хвоста.
Если в реальной игре кайма всё же будет резать глаз — переход на 32 цвета
это одна константа в упаковщике и один блок палитры, данные не меняются.
## 4. Палитра: что занято и куда класть
| блок | кто | примечание |
|---|---|---|
| 0x30..0x3F | VGA-16 | общая; из неё цвет вспышки, пузырьки зелий, отладочная метка |
| 0x40..0x4F | chtab_1 | зелья, пламя |
| 0x50..0x5F | env тайлсета | **меняется** подземелье/дворец |
| 0x60..0x6F | wall тайлсета | **меняется** подземелье/дворец |
| 0x70..0x7F | chtab_2 | Кид |
| 0x80..0x8F | chtab_0 | меч в руке |
| 0x90..0x9F | chtab_5 | страж, перезаливается цветом стража |
Занято 112 слотов из 256, **свободно 144** — девять выровненных блоков по
16. Оговорки: 0xFF в наших атласах это маркер прозрачности, а запись 0
правит `flash_bg`, так что блоки 0x00 и 0xF0 лучше не трогать.
**Берём 0xA0..0xAF** (16 слотов) — сразу за стражем, персонажи остаются
сгруппированы, а блок 0xB0 остаётся свободным (под 32 цвета Тени, если
понадобится, или под будущие наборы).
Важно: в наших атласах прозрачность кодируется байтом 0xFF, а исходный
индекс 0 в них означает «прозрачно». У Тени **чёрный — настоящий цвет**
(это XOR-погашенная середина силуэта, 51 % всех её пикселей), поэтому у
неё маппинг свой: «нет пикселя» → 0xFF, цвет k → 0xA0 + k, и слот 0xA0 =
чёрный НЕПРОЗРАЧНЫЙ.
## 5. Объём
251 кадр против 219 у Кида — по страницам EMM примерно как нынешний
набор Кида (28 страниц), плюс пара на кадры из SHADOW.DAT. При 215 свободных
страницах на старте (memory `sprinter_emm_budget`) это не проблема.
Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько
страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м
уровне умирает.
## 6. План работ
**Ш0. Довезти SHADOW.DAT.** Его у нас нет вовсе (см. BUG-SHADOW-SET) —
добавить каталог ассетов и правило в Makefile рядом с GUARD/SKEL/VIZIER.
**Ш1. Упаковщик** `toolchain/pop_pack_shadow.py`: прогнать оба набора
через алгоритм §1, собрать 16-цветную палитру гибридом (10 точных + 6
кластеров хвоста), выдать
`poc/res/shadow/shadow0..N.atl` + `shadow.pal` + `pop_shadow_atlas.h`.
Критерий: предпросмотр PNG совпадает с видом Тени в SDLPoP.
**Ш2. Загрузка**: `pop_shadow_load()` рядом с `pop_kid_load`, палитра в
0xA0..0xAF, и обе страницы дабл-буфера (как `bg_load_tile_pal`).
Грузить ЛЕНИВО — только когда на уровне есть Тень (4, 5, 6, 12), иначе
28 страниц EMM висят зря.
**Ш3. Отрисовка**: одно место — `pop_cdraw.c:551..568`, где выбирается
`pages`. Сейчас там для соперника берётся `gp`, а для не-боевых кадров
Тени подменяется на `kidp`; станет «charid == CHARID_1_SHADOW → shadowp»
БЕЗ подмены: обе половины (кадры Кида и кадры SHADOW.DAT) лежат в ОДНОМ
атласе Тени, так что ветка становится проще нынешней.
**Ш4. Проверка в MAME**: уровень 4 (Тень у зеркала), уровень 6 (Тень
крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то
есть проверяется вторая половина набора).
## 7. Что проверить артефактом до Ш3
1. Индекс кадра для Тени в боевых кадрах: `frame_tbl_guard` адресуется
как `frame + add_frame - 149` (`seg006.c:535`), то есть у нашего
атласа Тени нумерация двух половин должна совпадать с тем, что уже
делает `pop_frame_tbl_is_guard`.
2. Перекраска Тени НЕ нужна: SHADOW.DAT приходит уже в палитре Кида, а
`curr_guard_color` у не-стражей равен 0 (`seg002:183`). Ветку
`pop_guard_set_palette` для типа 4 звать нельзя — она затрёт палитру
Тени палитрой стража.
---
# 8. Как сделали (2026-08-20)
- `toolchain/pop_pack_shadow.py` — запекает обе половины, собирает
16-цветную палитру гибридом (получилось 5 частых точно + 11 кластеров
хвоста), пишет `poc/res/shadow/sk0..27.atl`, `sf0..3.atl` и
`roomtest/pop_shadow_atlas.h`. Итог: **251 спрайт, 32 EMM-страницы,
225 178 Б**.
- `roomtest/pop_shadow.c/.h` (банк 8) — загрузка. **Грузим один раз при
старте**, а не по уровням: страниц EMM с запасом, а забыть перезагрузку
на границе легко — ровно так и появился BUG-SHADOW-SET.
- `pop_cdraw.c` — одна ветка выбора атласа; заодно брызги урона теперь
берут атлас отдельным указателем (`spl`), потому что у оригинала они
всегда из «своего» chtab, а у Тени кадр может прийти из другой
половины набора.
## Грабля, стоившая одного прогона
Набор загрузился, силуэт нарисовался правильной формы — и **целиком
чёрный**. Причина: `gfx_pal_fload("KID\\kid.pal")` заливает ВСЕ 256
записей палитры и затирает любые слоты, выставленные до него. Ровно та
же беда уже была с тайлсетом — сразу за этим вызовом стоит
`pop_bg_pal_apply`. Поэтому палитра Тени вынесена в отдельный
`pop_shadow_pal_apply()` и зовётся там же, а не внутри загрузки атласов.
**Правило на будущее: любой новый набор палитровых слотов красится ПОСЛЕ
kid.pal, рядом с pop_bg_pal_apply.**
## Проверено
Уровни 4 (рождение из зеркала), 5 (кража зелья), 6 (прыжок через
пропасть) и 12 (бой — там работает вторая половина набора, `sf*`) —
прогон пользователя 2026-08-20, расхождений не найдено.
Расхождение из §2 (кайма в один пиксель по левым кромкам на НЕчёрном
фоне) записано в `impl_diff.md`.
+160
View File
@@ -0,0 +1,160 @@
# Отрисовка Тени (charid_1_shadow) — изыскания, отложено
Статус на 2026-08-11: **отложено по решению пользователя.** Тень пока
рисуется как обычный персонаж — простой копией из атласов Кида
(`pop_cdraw.c`, банк 0x5C, аппаратная прозрачность `#FF`). Вернуться к
«правильному» виду, когда будут сделаны все уровни: тогда будет известно,
какими именно кадрами тень вообще пользуется.
Этот файл собирает всё, что уже выяснено, чтобы не переоткрывать.
---
## 1. Как тень выглядит в оригинале
Тень рисуется **двумя блитами ОДНОГО И ТОГО ЖЕ спрайта Кида**
(`seg008:1602`, `add_objtable`):
```c
case 1: // shadow
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl, obj_y, blitters_2_or, 1);
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);
```
OR на месте, XOR со сдвигом на пиксель вправо. XOR гасит совпавшее,
остаются края — отсюда «контурный» вид. Это ЗАМЫСЕЛ оригинала, а не
артефакт SDLPoP: подтверждено печатью из живого SDLPoP (метка `DBGMIRROR`
в `add_objtable`) — и тень, и отражение идут из `chtab=2` (собственные
спрайты Кида), `swordbits=0`, обычными кадрами:
```
type=4 chtab=2 img=40 dir=0 clipL=137 clipT=3 charid=0 frame=41 <- отражение
type=1 chtab=2 img=41 dir=0 clipL=137 clipT=3 charid=1 frame=42 <- тень
```
Единственное различие между отражением и тенью — блиттер.
## 2. Чем мы располагаем
Блочные AND/OR/XOR/NOT акселератора подняты в libbgi 2026-08-11 (полный
набор строками и колонками, `gfx_blit_op` / `gfx_blit_part_op` /
`gfx_blit_cols_op` / `gfx_blit_cols_part_wx_op`; регресс — `tests/accop`,
10/10 PASS). Механика и ловушки — memory `accel_block_ops` и шапка
`libbgi/common/_gfx_blit_full_op.c`. То есть примитивов достаточно, дело
не в них.
## 3. Две причины, по которым «в лоб» не получается
### 3.1 XOR несовместим с нашей прозрачностью `#FF`
Аппаратная прозрачность (бит 3 видеобанка) подавляет запись байта `#FF`,
то есть смотрит на **результат** операции:
| операция | прозрачный пиксель источника | итог |
|---|---|---|
| AND | `#FF & bg = bg` | работает даром |
| OR | `#FF \| bg = #FF`, запись подавляется | работает даром |
| XOR | `#FF ^ bg = ~bg`, подавления нет | **инверсия фона по всему футпринту** |
Совпадение с «ничего не делать» у XOR получается только там, где фон равен
0 (`#FF ^ 0 = #FF` → подавляется). В DOS-оригинале прозрачный индекс = 0 —
нейтральный и для OR, и для XOR, поэтому там оба блиттера работают на одном
наборе спрайтов. У нас прозрачный `0xFF` (`pop_pack_kid.py`: `0 -> 0xFF`,
`i -> 0x70 + i`).
Замаскировать `#FF` внутри операции нельзя в принципе: побитовые AND/OR/XOR
не умеют «выбрать по условию», а `#FF` — нейтраль только для AND. Значит
источнику XOR-прохода нужен **прозрачный `0x00`**, то есть отдельный набор
спрайтов.
### 3.2 Операция читает ОЗУ-копию экрана, а не видео-ОЗУ
Чтение страниц `#50..#5F` всегда отдаёт ОЗУ-копию (memory
`sprinter_vram_transparency`), а персонажи рисуются банком `0x5C` («не
писать в копию» — на этом держится даровой heal). Поэтому второй проход
**не увидит результат первого**: два блита оригинала выродились бы в
«просто XOR», контурного эффекта не будет.
Лечится не банком `0x50` (он ломает heal — копия перестанет быть чистым
фоном), а **однопроходным композитом**: всё складывается в буфере
акселератора за один проход по колонке j футпринта
```
буфер := s[j] ; спрайт
буфер |= bg[j] ; вертикальное чтение экрана
буфер ^= s[j-1] ; тот же спрайт, предыдущая колонка = сдвиг на +1 px
запись ; вертикальная запись колонки
```
что **точно эквивалентно** двум блитам оригинала (крайние колонки: `x`
только OR, `x+w` — только XOR) и вдобавок дешевле их: 4 burst'а на колонку
против 6. Такому композиту тоже нужен источник с прозрачным `0x00` — уже
на обоих шагах.
## 4. Сколько стоит подготовить источник с прозрачным `0x00`
Замер 2026-08-11 (`tests/convbench`, watchpoint по IO-записи в MAME, кадр =
430 080 тактов). Цикл безветвочный (`ADD A,A / SBC A,A / CPL / AND`
маска из бита 7: прозрачный `#FF` отличается от цветов Кида `0x70..0x7F`
именно им), 59 номинальных T-states на байт, по факту **145.3 такта/байт**
(2.5× wait-state'ов ОЗУ):
| объём | кадров | секунд |
|---|---|---|
| 1 страница атласа, 16 КБ | 5.5 | 0.11 |
| весь атлас Кида, 28 страниц × 16 КБ = 448 КБ | 155 | 3.2 |
| он же **по реальному размеру данных (186 КБ)** | 64 | **1.3** |
Последняя строка — замечание пользователя: 28 атласов занимают 186 КБ, а не
448 КБ; обрабатывать по фактическому размеру ленты вместо целой страницы
даёт 2.4× (ценой проверки границы в цикле). Потолок разгона самого цикла —
ещё примерно вдвое (раскрутка убирает `djnz`, чтение через SP парами +
таблица 256 Б вместо арифметики), то есть **~0.7 с** на 186 КБ. Порядок
величины при этом не меняется.
**Окна:** источник и приёмник — разные EMM-страницы, а окно под атласы одно
(W0), поэтому конвертация гоняется «страница-источник в W0 →
страница-приёмник в W3» целыми страницами; побайтно переключать окно нельзя.
EMM-бюджет: +28 страниц (448 КБ) из ~3440 КБ свободных — не проблема
(memory `sprinter_emm_budget`), и он одинаков в любом из вариантов.
## 5. Варианты (когда вернёмся)
1. **Конвертация в рантайме при загрузке уровня с тенью** (4, 5, 6, 12):
диск и упаковщик не трогаем, цена — 1.3 с (или 0.7 с после разгона) на
загрузку такого уровня.
2. **Лениво, постранично** — 0.11 с (5.5 кадра) при первом обращении тени к
странице; рывок один раз на страницу, суммарно меньше, чем вариант 1.
3. **Второй набор `.atl` от упаковщика** (`pop_pack_kid.py`, прозрачный
`0x00`): 0 с рантайма, +186 КБ на образе и вторая ветка в загрузчике
атласов.
4. **Только OR-проход** (то, чем можно обойтись бесплатно): OR с нашим
`#FF`-атласом работает как есть, тень получается сплошным силуэтом в
палитре Кида, без контурного эффекта. Расхождение с оригиналом — тогда
записью в `docs/impl_diff.md`.
**Ключ к выбору — какие кадры тень вообще использует.** Предположение
пользователя: только бег, длинный прыжок (из зеркала), питьё зелья и
боёвка; прыжки с места и подтягивания — нет. Если так, конвертировать
(или паковать) нужно единицы страниц, а не 28, и разница между вариантами
почти исчезает. Список снимать по факту — когда уровни 5/6/12 будут
проходиться.
## 6. Что ещё придётся проверить глазами
Палитра. У нас индексы разложены группами по 16 (`pop_pack_bg.py`):
`0x30` VGA16, `0x40` chtab_1, `0x50` env, `0x60` wall, `0x70` kid, `0x80`
sword, `0x90` guard. Отсюда ожидания (аналитические, в MAME НЕ
проверялись):
- OR-проход ложится удачно: `0x7X | 0x5Y = 0x7Z` — результат остаётся в
палитре Кида, а младший ниббл получается ровно тот же, что дал бы DOS
(там OR шёл по 4-битным индексам внутри одной палитры);
- XOR-проход уводит результат в группы `0x0Z` (поверх OR-результата) и
`0x2Z` (по чистому фону) — **обе группы палитры у нас не заполнены**, то
есть контур рискует оказаться просто чёрным.
Значит к «посмотреть глазами» добавляется вопрос, чем заполнять `0x00..0x0F`
и `0x20..0x2F` — по сути это и будет выбор цветов тени. В DOS такого
вопроса не было: XOR двух 4-битных индексов всегда оставался внутри той же
16-цветной палитры.
@@ -1,335 +0,0 @@
# roomtest — план оптимизации по размеру + переход на huge/banking
Статус: **план для отдельной сессии** (2026-07-21). Документ самодостаточный
(рассчитан на старт с пустого контекста). Цель — освободить место: сейчас
`applications/PoP/roomtest` в режиме `small` почти упёрся в потолок 32 КБ.
Правило проекта (`applications/PoP/CLAUDE.md`): механику/раскладку памяти
сверять с исходником и с memory (`sprinter_memory_modes`, `sdcc_banking`,
`bank_local_data_pattern`, `pop_banking_architecture`). Перед оптимизацией —
`make size-check`-подобный замер до/после (здесь — руками по `.map`).
---
## 0. Как мерить
- Сборка: `cd applications/PoP/roomtest && make roomtest.exe` (режим `small`,
`--gfx 256`). Карта символов — `.sprinter-cc-roomtest/roomtest.map`
(адреса сдвигаются при каждой пересборке!).
- Размеры областей — из `.map` (`_CODE`, `_DATA`, `_BSS`).
- Вклад модулей в `_CODE` — атрибуция диапазонов между символами по модулю
(скрипт-однострочник на python в истории; группировать символы `.map` по
3-й колонке-модулю и суммировать `addr[i+1]-addr[i]`).
- MAME-проверка после изменений раскладки ОБЯЗАТЕЛЬНА (режимы памяти —
типовой источник «молча ломается», см. `sprinter_memory_modes`).
## 1. ТЕКУЩЕЕ СОСТОЯНИЕ (замер 2026-07-21)
Режим `small` = единое пространство **W1+W2 = 0x4000..0xBFFF (32 КБ)**; CODE с
0x4100, DATA/BSS/heap цепляются ЗА CODE автоматически (`--data-loc 0` =
linker chains), стек — вверху W2.
| Область | Размер | Диапазон |
|---------|--------|----------|
| `_CODE` | ~27 250 Б (0x6A6F) | 0x41000xAB6F |
| `_HOME` | 227 Б | 0xAB6F |
| `_DATA` | 3 449 Б (0x0D79) | 0xAC780xB9F1 |
| `_BSS` | 290 Б | |
**Образ ≈ 31.2 КБ; до верха W2 (0xBFFF) остаётся ≈ 1.3 КБ на кучу+стек.**
Куча в roomtest почти не используется (атласы/уровень — в EMM-страницах),
но запас критично мал.
### Вклад модулей в _CODE (по .map, приблизительно)
```
7003 pop_bg (вся отрисовка тайлов/слоёв/wall_pattern)
6326 pop_kid (из них ~3745 Б — СТАТ. ТАБЛИЦЫ kid_data.h, см. ниже)
3762 pop_map (коллизия/физика/пики)
1455 pop_trob (кнопки/ворота/пики-каркас)
1224 pop_level (загрузка уровня, doorlink)
914 roomtest (главный цикл)
~7000 libc/libbgi (gfx_blit*, atlas_load, kbd_raw, open/read, irq, div/mul…)
```
### Крупные СТАТИЧЕСКИЕ данные (сейчас в _CODE как `const`)
- **`kid_data.h` — самый большой кусок, ~3.7 КБ**, живёт в _CODE (атрибутируется
pop_kid):
- `kid_seqtbl[2310]` — байткод последовательностей (play_seq).
- `kid_frames[241]` × 5 Б = 1205 Б — таблица кадров (image,dx,dy,flags,sword).
- `kid_seq_off[115]` × 2 Б = 230 Б — смещения seq.
- `pop_bg`: `tile_table[31]`×12 = 372 Б + ~20 мелких const-таблиц (COL_XH,
WALL_FRAM_*, SPIKES_FRAM_{RIGHT,LEFT,FORE}, LOOSE_FRAM_*, DOOR_FRAM_SLICE,
BLUELINE_*, LPOS/RPOS, FLOOR_LEFT_OVERLAY) — суммарно ~0.5–0.7 КБ.
- `pop_map`: `x_bump[20]`, `y_land[5]`, `wall_dl/dr`, `dir_front/behind` — ~100 Б.
- В `_DATA` (W2, не CODE): `room_modif[24][30]`=720 Б + копии LINKLOC/LINKMAP=512 Б
(pop_trob/pop_level) + рабочие массивы roomtest.
---
## 2. ПУТЬ A — оптимизация КОДА (без смены модели)
1. **Компиляторные флаги** (`bin/sprinter-cc`): попробовать `--opt-code-size`
у SDCC и подобрать `--max-allocs` (сейчас дефолт 100000; меньше = мельче код,
но медленнее компиляция; см. `mdview2_size_budget` — там `--max-allocs`
давал −1.4 КБ). Замерить каждый модуль отдельно.
2. **Дедуп подстановки нажатой кнопки**: логика `opener→floor / closer→stuck`
по таймеру связи ПРОДУБЛИРОВАНА в `draw_tile` и `fore_tile` (pop_bg.c).
Вынести в `static inline`/helper `subst_pressed_button(code,mod)`.
3. **wall_pattern / prandom** (pop_bg): 32-битный LCG (`unsigned long`) —
пользователь не любит 32-бит (см. `avoid_32bit_arith_z80`); но это PRNG
оригинала (нужен для совпадения раскладки стен) — трогать осторожно, только
если найдётся 16-битный эквивалент, дающий ТУ ЖЕ последовательность.
4. **Ревизия дублей**: `y_to_row` определён в pop_bg И pop_map; мелкие
геометрические хелперы дублируются — свести в один internal-модуль.
5. `/simplify`-проход по последним правкам Фазы B (pop_trob/pop_bg).
Ожидаемый выигрыш пути A: единицы–первые сотни байт на пункт; в сумме,
оптимистично, ~1–2 КБ. Недостаточно как единственная мера.
---
## 3. ПУТЬ B — вынос СТАТ. ДАННЫХ в EMM-страницы (с атласами / с level)
**Идея (по замечанию пользователя):** EMM-страницы атласов и уровня
использованы лишь частично (страница 16 КБ, данных меньше), в «хвосте» —
свободное место. Часть `const`-таблиц можно хранить ТАМ, а не в _CODE/_DATA,
если таблица читается ИМЕННО ТОГДА, когда нужная страница уже в W0.
**Механика W0:** атласы блитятся из W0 (`_gfx_w0_state`: `_gfx_w0_cur`
спрайт-страница в W0; ISR-стаб `_gfx_w0_isr` возвращает её после прерывания).
Уровень (pop_level) маппит свою страницу в W0 на время извлечения
(`gfx_w0_map`/`gfx_w0_unmap`). → пока страница в W0, CPU может читать и
данные из неё по адресам 0x0000..0x3FFF.
**Категоризация таблиц по W0-контексту (задача сессии — уточнить по каждой):**
- **(a) Читается, когда в W0 АТЛАС** → хранить в свободном хвосте атлас-страницы.
Кандидаты — таблицы, которые нужны В МОМЕНТ блита конкретного атласа.
ГРАБЛИ: `draw_tile` читает `tile_table`/`COL_XH` ДО блита (чтобы решить, какой
спрайт/куда) — в этот момент в W0 может быть ДРУГАЯ страница (DSS/предыдущий
атлас). Т.е. большинство draw-таблиц читаются ВНЕ W0-атлас-контекста →
«в лоб» не переносятся. Нужен аудит КАЖДОГО чтения: гарантирована ли нужная
страница в W0 в этот тик.
- **(b) Читается, когда в W0 LEVEL** → хранить с уровнем (в его странице; там
~13.7 КБ свободно из 16). Кандидаты: константы декода doorlink, разбор
комнат — всё, что pop_level делает под `gfx_w0_map(lvl_page)`.
- **(c) Нужна и там, и там** → дублировать в обеих страницах ЛИБО оставить
резидентной (если дубли дороже экономии).
- **(d) Читается в чистой ЛОГИКЕ (W0 не важен)** → перенос требует ЯВНОГО
`gfx_w0_map` на каждое чтение (дорого, особенно в горячих циклах) → как
правило оставить резидентной.
**Отдельно `kid_data.h` (3.7 КБ — самый жирный кандидат):**
- `kid_frames`/`kid_seqtbl` читаются в `play_seq` (ЧИСТАЯ логика, каждый тик) И
в `kid_draw` (блит из kid-атласа, kid-страница в W0). Т.е. частично (a),
частично (d). Перенос всей таблицы в kid-атлас-страницу заставит `play_seq`
делать `gfx_w0_map` на каждый шаг байткода → замерить стоимость (может убить
бюджет спрайтов, см. `sprite_engine_perf`). Вариант: держать в EMM отдельной
страницей данных Kid и маппить один раз на кадр вокруг kid_tick+kid_draw.
- Это самый большой одиночный выигрыш (−3.7 КБ из _CODE), но и самый рискованный
по скорости — приоритетный к ПРОТОТИПИРОВАНИЮ и замеру.
**Паттерн переноса writable/const данных в банк/страницу:** см. memory
`bank_local_data_pattern` (--codeseg/--constseg/--dataseg BANKn + trampoline-fix
+ mkexe -p 0) и `sdcc_static_storage_gotcha`.
### 3.1 Свободное место в страницах (замер 2026-07-21, страница = 16384 Б)
```
BG-атласы: размер свободно
pop_env0.atl 10578 5806
pop_env1.atl 12449 3935 <- САМАЯ ТЕСНАЯ из bg
pop_env2.atl 10798 5586
pop_env3.atl 5032 11352 <- много места
pop_env4.atl 8498 7886
pop_wall.atl 11543 4841
pop_fore.atl 7763 8621
Kid-атласы (28 стр): free min=6161 max=15452 avg=9722
Level (res2001.bin): данные 2305, свободно ~13823 (16384 0x100 стаб 2305)
```
**Выводы по вместимости:**
- **Макс. данных в ОДНОМ атлас-банке = свободный хвост ЭТОЙ страницы** (см.
таблицу). Связывающее ограничение — самая тесная нужная страница (env1 =
3935 Б; не перегружать её).
- Если страница будет маппиться в **W0** — минус ~0x100 Б на ISR-стаб (как
level). Атлас-страницы стаб УЖЕ содержат (atlas_load патчит) → данные класть
в хвост ПОСЛЕ атласа.
- **`kid_data.h` (3.7 КБ) влезает в kid-страницу** (min free 6161) или в
отдельную выделенную страницу данных Kid — предпочтительно отдельную (маппить
раз на кадр, не конфликтуя с kid-атласами блита).
- **Level-таблицы** — вагон места в level-странице (~13.8 КБ).
- **BG draw-таблицы** (~0.7 КБ) влезут в env3/fore/env4 (много free), НО см.
граблю W0-контекста в §3(a) — читаются ли они, когда нужная страница в W0.
- **Выделенная страница ТОЛЬКО под данные** (не делить с атласом) = до ~16 КБ
(−0x100 стаб при W0-маппинге). EMM-бюджет это позволяет (см.
`sprinter_emm_budget`: 215/3440 КБ free на старте).
- **Принудительно уменьшать макс. атлас (репак мельче) — КРАЙНИЙ случай:** это
резко поднимет число атлас-банков (сейчас 5 env-страниц адресуются как id>>5;
дробление ломает эту адресацию и множит страницы). Сначала использовать
СУЩЕСТВУЮЩИЙ свободный хвост и отдельные data-страницы.
---
## 4. ПУТЬ C — переход на huge (banked code)
### 4.1 Что такое huge сейчас (`bin/sprinter-cc`, `runtime/crt0_banked`)
- `--memory huge`: `MODE_CODE_LOC=0x4100`, **`MODE_DATA_LOC=0x8000` (ФИКС.)**,
banked code в W3. crt0_banked, как crt0_small, авто-детектит W2.
Помечено `[TODO]` — не обкатано.
- Отличие от small: small цепляет DATA сразу за CODE (`--data-loc 0`); huge
ФИКСИРУЕТ DATA на 0x8000.
### 4.2 ТРЕБОВАНИЕ (по пользователю): huge должен переносить DATA динамически
Сейчас huge жёстко кладёт DATA на 0x8000. Если РЕЗИДЕНТНЫЙ CODE вылезет за
0x8000 (W1 = только 0x4000..0x7FFF ≈ 16 КБ; резидент > 16 КБ лезет в W2) →
коллизия с DATA. **Надо научить huge класть DATA динамически ЗА резидентным
CODE (как small: `--data-loc 0` + crt0 считает старт), а не на фикс 0x8000.**
Тогда huge = «small-раскладка резидента (W1+W2, DATA за CODE) + ДОП. код в
банках W3». Это первый пункт работ по huge.
### 4.3 КОНФЛИКТ: графика тоже хочет W3 (ключевой риск)
`pop_banking_architecture` прямо говорит: **графику нельзя в W3** (блиты/атласы
используют окна; см. §4.5). Поэтому в банки W3 можно выносить ТОЛЬКО
НЕ-графические блоки, и такой банк НЕ должен во время своего исполнения держать
графику в W3. Если W3-банкованная функция ЗОВЁТ графику (которой нужен W3),
трамплин обязан сохранить/восстановить банк вокруг вызова (проверить, что
banking-ABI это делает — `sdcc_banking`). Альтернатива без этого риска —
**big + BANK_W1** (банк кода в W1, не W3), рекомендованная в
`pop_banking_architecture` именно из-за W3-графики. Сессия должна выбрать:
huge(W3) с аккуратным save/restore ИЛИ big(BANK_W1).
### 4.4 Какие блоки МОЖНО вынести (не работают с графикой напрямую)
Замер graphics-ref по модулям (grep `gfx_|blit|env_b|wall_b|fore_b|setfillstyle|
bar(|GFX_BANK|initgraph`):
```
pop_bg.c : 83 — РЕЗИДЕНТ (вся отрисовка)
roomtest.c : 23 — РЕЗИДЕНТ (главный цикл + флип страниц)
pop_level.c : 17 — использует gfx_w0_map (W0, не W3-блиты) — ПОГРАНИЧНЫЙ
pop_kid.c : 12 — kid_draw = графика; НО play_seq — чистая логика (можно split)
pop_ctrl.c : 0 — КАНДИДАТ В БАНК (ввод/диспетчер control)
pop_map.c : 0 — КАНДИДАТ В БАНК (коллизия/физика, ~3.8 КБ) — лучший по объёму
pop_trob.c : 0 — КАНДИДАТ В БАНК (кнопки/ворота/пики-логика)
```
- **Лучшие кандидаты в W3-банк(и): pop_map + pop_trob + pop_ctrl** (нет прямой
графики; вместе ~5.3 КБ CODE). Освобождают резидент → он влезает в W1.
- **Осторожно с межбанковыми вызовами:** pop_map/pop_trob ЗОВУТ pop_bg
(перерисовка loose/пик/кнопок/шва) и pop_kid (play_seq/kid_set_seq). Это
кросс-банк вызовы через трамплин (`sdcc_banking`: стек +3 байта, виртуальный
24-битный адрес). Правило `pop_banking_architecture`: «один файл = один банк
= прямые вызовы», main резидентен. Проверить, что трамплин сохраняет W3
вокруг вызова в графический pop_bg (см. §4.3).
- **pop_kid split** (по желанию): вынести play_seq/seqtbl-интерпретатор
(логика + таблицы kid_data.h) в банк, оставить kid_draw/kid_heal резидентными.
Даёт и −код, и −данные из резидента, но требует аккуратного разделения TU
(1 функция = 1 модуль, см. `libc_one_function_per_module`).
- **pop_level: пограничный** — не блитит, но маппит уровень в W0; банковать
можно, если W0-логика совместима с трамплином (проверить ISR-стаб взаимодействие).
### 4.5 Почему графику нельзя в W3 (контекст)
Блиттер держит спрайт-страницу атласа в **W0** (`_gfx_w0_state`,
`_gfx_w0_isr`). Ускоритель/адресация видео — отдельная тема (см.
`sprinter_accelerator`, `sprinter_graphics`). W3 в banked-раскладке — окно
кода-банка; смешивать с окном, которое графика перемапливает, нельзя без
save/restore. Детально — `pop_banking_architecture`, `graphics_constraints`.
---
## 5. РЕКОМЕНДУЕМЫЙ ПОРЯДОК РАБОТ (для след. сессии)
1. **Замер-базлайн** (CODE/DATA/BSS + per-module) — зафиксировать до.
2. **Путь A** дешёвые пункты (флаги, дедуп кнопки, дедуп y_to_row) — быстрый 1..2 КБ.
3. **huge §4.2**: научить huge класть DATA динамически (как small) — инфраструктурный
пререквизит, без него банкинг не даст гибкости. Обкатать в MAME на текущем
резиденте (пока без выноса — просто huge-раскладка = small + пустой W3).
4. **huge §4.4**: вынести pop_map (+pop_trob, +pop_ctrl) в W3-банк(и); проверить
кросс-банк вызовы в pop_bg (§4.3) в MAME. ЛИБО выбрать big+BANK_W1.
5. **Путь B** (по остатку нужды): прототип выноса `kid_data.h` в EMM-страницу
Kid с маппингом раз на кадр; замерить скорость (`sprite_engine_perf`).
Затем аудит draw-таблиц по W0-контексту (§3 a/b/c/d).
## 6. Ссылки
- `bin/sprinter-cc` (§162+ — резолв memory-mode → CODE_LOC/DATA_LOC).
- `runtime/crt0_small.*`, `runtime/crt0_banked.*`, `runtime/bank.s`.
- memory: `sprinter_memory_modes`, `memory_modes_implemented`,
`setwin2_for_w2_alloc`, `sdcc_banking`, `bank_local_data_pattern`,
`pop_banking_architecture`, `avoid_32bit_arith_z80`,
`libc_one_function_per_module`, `sprite_engine_perf`, `mdview2_size_budget`.
- `applications/PoP/roomtest/bug_list.md` — открытые баги Фазы B (не блокируют
оптимизацию, но держать в уме при рефакторе pop_map/pop_bg).
---
## 7. Лишние блиты в горячем пути (добавлено 2026-07-27)
Найдено при разборе окклюзии по эталону SDLPoP: **наш «передний слой» рисовал
спрайты, которых в оригинале там нет** — это и артефакты, и лишняя работа
каждый кадр. Исправлено: `fore_tile` (вызывается для КАЖДОГО тайла футпринта
Kid, обычно 2–4 за кадр) рисовал ещё и `bottom_id` — переднюю кромку пола; в
оригинале `draw_tile_fore` (seg008:690) добавляет только `add_foretable`-часть,
а `bottom` идёт через `draw_tile_bottom` в backtable (ПОД персонажем).
Итог: −2..4 блита за кадр, `_CODE` −388 Б, ушла «тень» у основания колонны.
**Что проверить тем же методом (по одному вопросу к каждому месту: а есть ли
этот спрайт в оригинале в ЭТОЙ таблице?):**
1. `pop_room_draw`/`draw_tile` — вызовы на входе в комнату не критичны по
скорости, но по ним стоит сверить состав слоёв (backtable vs foretable).
2. `overlay_mid_tile` — сейчас точный порт midtable-части `draw_tile2`;
проверить, не рисуем ли `base_id` там, где оригинал его не рисует
(loose: base=0, потому что кадр плиты идёт через `draw_loose` в backtable).
3. `pop_loose_mob_tick` — перерисовка соседнего тайла (`draw_tile(mob_row,
mob_col+1)`) КАЖДЫЙ кадр падения: в оригинале это `set_redraw_full` на
один кадр; можно ограничить только тайлом, который реально пересекается
с куском.
4. `pop_ceil_shake_draw` — heal 64×8 + два `draw_tile(-1,·)` на кадр тряски;
проверить, нужен ли второй тайл (правую грань loose в полосе потолка
оригинал не рисует вовсе — `draw_tile_aboveroom` без `draw_tile_anim_right`).
5. `fore_only_tile` для полосы потолка: вызывается для всех колонок габарита,
а оригинал (`redraw_needed_above`) — только для колонок с флагом
`redraw_frames_above`; сузить до колонок, реально задетых спрайтом.
6. `wall_pattern` внутри fore/overlay — тяжёлая (PRNG + до 4 блитов); проверить,
не зовём ли её там, где оригинал ограничивается `wall_fram_main`.
---
## 8. Скорость отрисовки: замеры и запас (2026-07-27)
Профилирование в MAME (маркеры в порт 0xFE + `wpiset … totalcycles`, приём из
memory `mame_mcp_bridge`). Кадр Sprinter = **430 080 тактов**.
**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке
`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип,
пересчёт src, нарезка полос >256), а не за пиксели:
| путь (спрайт 32×3) | тактов |
|---|---|
| `gfx_blit` (общее ядро, с клипом) | 13 288 |
| линейное спрайтовое ядро без клипа (`putsprite` при `gfx_sprite_clip(0)`) | 4 617 |
Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 %
кадра**; сам `bar` — только 13 308.
**СДЕЛАНО (шаг 1):** в libbgi добавлен `gfx_blit_noclip()`
(`common/gfx_blit_noclip.c`, прототип в `include/gfx.h`) — блит без клипа в
ТЕКУЩЕМ банке через линейное ядро; `pop_bg.blit_b` уходит на него, когда
спрайт целиком на экране и не нужен `g_clip_top`. Выигрыш ~2.9× на каждом
фоновом блите (подтверждено в MAME).
**ВАЖНО:** W3-скобку (`_bgi_begin/_bgi_end`) ставит САМА libbgi — вызывать её
из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято
видеобанком и код вызывающего исчезает из адресного пространства (проверено:
белый экран).
**ЗАПАС (шаг 2), когда перестанет хватать бюджета кадра:**
1. **Батчинг W3-скобки** — одна `_bgi_begin/_bgi_end` на весь `draw_tile`
вместо скобки на блит; нужен публичный batch-API в libbgi (как у
спрайтового движка). Осторожно: между begin/end стоит `DI` — длинная
серия задержит кадровое прерывание.
2. **Решётка ворот одним спрайтом**`draw_gate_back` рисует бары по одному
(`env 52`, до 7 блитов). Сгенерировать в атласе «столб решётки» (повтор
бара на высоту тайла) и выводить одним `gfx_blit_part` с обрезкой по фазе
`gate_bot_y & 7`: 7 блитов → 1.
3. **Не перерисовывать статичные части шва** — грань ворот (env 47, 26×62),
пол (41) и кромка (43) при анимации решётки не меняются; если стирать
только полосу баров, уйдут ещё 3 блита из 9.
4. См. также §7 (лишние блиты, которых нет в оригинале).
+937
View File
@@ -0,0 +1,937 @@
# Звук в порте PoP — разбор и план
Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
(уточнение 2026-08-20; см. §1а — оказалось, что они и так все в WAV). Ниже — что реально лежит в ассетах, что умеет железо, и
почему получившийся план вышел проще, чем ожидалось.
## 1. Главный вывод
**Ни MIDI разбирать, ни ноты сочинять не придётся, и ресэмплировать тоже.**
- Эффекты уже лежат **8-битным беззнаковым PCM на 11 000 Гц**, а у CBL есть
режим **10 937,5 Гц** — расхождение 0,6 %, на слух неразличимо. Формат
сэмпла совпадает с нашим CBL байт в байт (`cbl.h`: 8 бит, беззнаковый,
центр 0x80). То есть данные играются **как есть**, без конверсии.
- Музыка есть в виде **списков нот PC-спикера — 7 КБ на всю игру**, а нота
там задана прямо в ГЕРЦАХ. Пересчёт в делитель AY — одно деление.
- AY и COVOX на Sp2000 сведены в **один ЦАП TDA1543** (док Ивана Мака,
§5), значит музыка на AY и эффекты через CBL звучат ОДНОВРЕМЕННО, и
смешивать их программно не надо.
## 1а. Уточнение после разбора ВСЕХ наборов MS-DOS версии (2026-08-20)
Пользователь попросил, чтобы эффекты были не PC-спикером, а WAV, и заодно
посмотреть `mt32snd[1-2].dat`. Разобрал все восемь `.dat` из `MSDOS/`.
Ответ короткий: **эффекты И ТАК все до одного есть в WAV, а вот у музыки
WAV нет ни в одном наборе.**
| набор | формат | какие звуки | сколько |
|---|---|---|---|
| `digisnd1..3` | **WAV**, 8 бит PCM | эффекты 0..23, 44..49, 51 | **31** |
| `mt32snd1..2` | MIDI для Roland MT-32 | ТЕ ЖЕ эффекты 0..23, 44..51 | 31 |
| `midisnd1..2` | MIDI (AdLib/GM) | **музыка** 24..43, 50, 52..56 | 22 |
| `ibm_snd1..2` | ноты PC-спикера | **всё подряд, 0..56** | 57 |
Здесь пряталась ловушка: `mt32snd` по имени похож на «музыку получше», а
на деле это набор ЭФФЕКТОВ для владельцев MT-32 — те же id, что у
`digisnd`. Музыки в нём нет вовсе.
Частоты WAV: 28 звуков на 11 000 Гц, по одному на 8 200, 14 000 и 2 750.
Итого 112 922 сэмпла = **11,4 с, ~110 КБ ≈ 6,7 EMM-страниц**.
Только PC-спикером, без альтернатив, остаются четыре id: 31, 34, 42
(пустые) и **38 `blink`** — четыре ноты. То есть на весь звук игры
спикер нужен ровно для одного писка.
### Музыка: WAV нет, есть три пути
| путь | данные | что получится | цена |
|---|---|---|---|
| **A. Ноты PC-спикера на AY** | 7 КБ | один квадратный голос — ровно то, что слышали на IBM PC 1989 | секвенсор на полсотни строк |
| **B. MIDI -> AY, три голоса** | 27 КБ исходника | богаче: бас + мелодия + арпеджио | разбор MIDI + раскладка по каналам |
| **C. MIDI -> WAV на хосте, стрим через CBL** | см. ниже | настоящее звучание, любое | нужен синтезатор на хосте + место |
Про объём для пути C (замерено по длительностям треков):
| группа | треков | длительность | WAV 11 кГц |
|---|---:|---:|---|
| звучат ПО ХОДУ игры (гимн уровня, смерть, зелья, перо, победа) | 12 | 74,7 с | **803 КБ = 50 EMM-страниц** |
| заставки и титры | 10 | 248,1 с | 2 665 КБ = 167 страниц |
Игровая половина в EMM **влезает** (при ~215 свободных страницах), а
заставочная — нет, её пришлось бы стримить с диска. Но заставки идут
тогда, когда игра ничего не рисует, так что стрим там как раз уместен.
**Предложение:** начинать с A (7 КБ, работает сразу, ноль рисков), а C
держать как отдельную фазу — она ортогональна: проигрыватель WAV для
музыки это тот же `cbl_push`, что и для эффектов, только длиннее буфер.
B имеет смысл только если C окажется неподъёмным по месту.
## 1б. РЕШЕНИЯ (пользователь, 2026-08-20)
1. **Эффекты — WAV через CBL, 8 бит, МОНО, единая частота.** Проверено по
`convert_digi_sound` (`seg009.c:2358`): один байт на кадр, то есть
моно, и байт беззнаковый (`(b | b<<8) - 32768`), центр 0x80 — ровно
формат нашего CBL. Стерео в данных нет вовсе: каналы у оригинала
размножаются уже на выходе (`digi_audiospec->channels`).
2. **Музыка, первый заход — путь A** (ноты спикера на AY).
3. **Заставки и титры — потом WAV.** Конфликта с эффектами там нет:
одновременно они не звучат.
4. **Музыка ПО ХОДУ игры** (она может совпасть с эффектом) — открыто, два
варианта: либо тоже WAV с ГАШЕНИЕМ эффектов на время музыки (музыка
важнее — **проверить на слух**), либо путь B (MIDI -> три голоса AY).
5. **Все эффекты привести к одной частоте.**
6. Синтезатор для MIDI -> WAV — решать ближе к делу; годятся и онлайн-
конвертеры, хоть вручную, если fluidsynth/timidity не поставится.
### Про единую частоту (замер)
Приводим не к 11 000, а ровно к **10 937,5 Гц — частоте CBL**
(`CBL_FREQ_10K9`). Тогда тон точен, а не «на 0,6 % ниже»: проигрывание
11 000 Гц данных на 10 937,5 даёт сдвиг **−9,9 цента**, что на коротком
эффекте не слышно, но бесплатно избавиться от него всё равно приятно —
пересчитывать три файла всё равно придётся.
| id | звук | было | станет | дельта |
|---|---|---|---|---:|
| 15 | `leveldoor_sliding` | 2 750 Гц, 4 436 сэмплов | 17 643 | **+13 207 Б** |
| 23 | `footstep` | 8 200 Гц, 996 | 1 329 | +333 Б |
| 51 | `princess_door_opening` | 14 000 Гц, 6 188 | 4 834 | 1 354 Б |
| — | остальные 28 (11 000 Гц) | — | ×0,9943 | 588 Б |
Итог: **112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц.** Рост целиком от
`leveldoor_sliding`: источник у него 2 750 Гц, вчетверо реже целевой, и
апсэмплинг не улучшит звучание — только уравняет формат. Платим 0,8
страницы за то, что **CBL открывается ОДИН раз и частоту менять не надо
никогда** — ни между эффектами, ни при переходе на музыку-WAV.
(Альтернатива для него — хранить как есть и повторять каждый сэмпл
четырежды в рантайме: 2 750 × 4 = 11 000 ровно. Это код в `fill()` ради
13 КБ; не стоит того, но если место когда-нибудь прижмёт — вариант есть.)
## 1в. Бюджет памяти EMM (живой замер 2026-08-20)
Замерено `mem_info` из работающей программы (уровень 1), а не посчитано на
бумаге: инструментовка ставилась временно и откатана.
| | страниц | КБ |
|---|---:|---:|
| всего в машине | 256 | 4 096 |
| система (DSS) + сам exe: база + 8 банков кода | **43** | 688 |
| наши ассеты | **81** | 1 296 |
| **занято** | **124** | 1 984 |
| **свободно** | **132** | **2 112 (2,06 МБ)** |
Разбивка 81 страницы ассетов (сходится точно):
| набор | страниц |
|---|---:|
| **Тень** (`sk*` 28 + `sf*` 4) | **32** |
| Кид (`kid0..27`) | 28 |
| фон тайлсета (env 10 + wall 1 + fore 1) | 12 |
| страж | 5 |
| зелья (chtab_1), меч, `kid_data.bin`, страница уровня | по 1 |
Самый крупный потребитель теперь — **набор Тени, 32 страницы**, больше
самого Кида. Если место когда-нибудь прижмёт, там есть очевидный резерв
(кадры смерти и позы, в которых Тень не бывает), но при 132 свободных
страницах трогать незачем.
### Что из этого следует для звука
| статья | страниц | останется свободно |
|---|---:|---:|
| эффекты WAV, все 31, 10 937,5 Гц | **8** | 124 |
| музыка ПО ХОДУ игры в WAV (путь C, 12 треков) | 50 | 74 |
| заставки и титры в WAV (10 треков, 248 с) | 167 | **не влезает** |
То есть эффекты — капля, игровая музыка в WAV тоже поместится, а
заставочную придётся стримить с диска в любом случае (что и планировалось:
во время заставок игра ничего не рисует).
Оговорка: 132 свободных страницы — это на уровне 1. На уровне 9 добавятся
зеркальные наборы (`pop_vflip_load_all`: 28 Кид + 5 страж + меч = 34
страницы), останется ~98. Проверять запас надо ИМЕННО ТАМ.
## 1г. MSDOS против SDLPoP: чем отличаются наборы (сверено 2026-08-20)
У нас лежат ДВЕ копии звука — оригинальные `.dat` в `MSDOS/` (версия
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
есть, и она влияет на выбор источника.
### Оцифровка: берём MSDOS
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP:
| id | звук | MSDOS | SDLPoP |
|---|---|---:|---:|
| 10 | `sword_vs_sword` | 5 020 сэмплов | 3 504 |
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики,
где источник наоборот SDLPoP.
### MIDI: если дойдём до музыки — брать SDLPoP
Здесь всё наоборот. Содержимое музыкально то же (деление 480, те же
каналы 0..7 плюс ударные), но:
| | MSDOS | SDLPoP |
|---|---|---|
| формат MIDI | **0** — всё слито в ОДНУ дорожку | **1** — 8-9 дорожек |
| размер (звук 24) | 327 Б | 448 Б |
| размер (звук 56) | 13 587 Б | 12 773 Б |
Формат 1 с отдельной дорожкой на инструмент — это готовое разделение
голосов. Для пути B (MIDI -> три канала AY) оно решает половину задачи:
дорожки можно выбирать напрямую (бас / мелодия / гармония), а не
разбирать слитый поток и догадываться, что чем было.
**Итог: эффекты из MSDOS, музыка (когда дойдёт) из SDLPoP.**
## 2. Что лежит в ассетах (замерено, а не по памяти)
Звук в PoP адресуется как ресурс `10000 + N`, N = 0..56 — 57 звуков
(`load_sound`, `seg009.c:2289`). Наборов три, и они ПАРАЛЛЕЛЬНЫЕ: один и
тот же звук есть в нескольких видах.
| набор | что это | объём | покрытие |
|---|---|---:|---|
| `DIGISND1..3.DAT` | оцифровка, 8 бит PCM | 103 941 Б | **31 звук** (эффекты) |
| `MIDISND1..2.DAT` | MIDI-музыка | 27 776 Б | музыка |
| `IBM_SND1..2` (распакованы) | ноты PC-спикера | **7 КБ** | **все 57** |
### 2.1 Оцифровка (эффекты)
Разбор контейнера: индекс по 8 байт на запись (id, offset, size), **первый
байт ресурса — контрольная сумма**, тело за ней (спецификация
`POP-DAT-FormatSpecifications`, §3.1.2 — на этом я сначала споткнулся и
читал мусор). Тело — `digi_type`: `word rate, word count, word unk,
byte size`, дальше сэмплы.
- 31 звук, все 8-битные;
- частоты: **28 звуков на 11 000 Гц**, по одному на 8 200 и 14 000;
- 103 701 сэмпл = **9,4 секунды**, **103 941 Б ≈ 6,3 EMM-страницы**.
### 2.2 Ноты PC-спикера (и эффекты, и музыка)
Формат: `byte type(=0), word tempo`, дальше тройки `word frequency,
byte length`; `frequency <= 1` — пауза, `0x12` — конец. Ключевое, что
пришлось смотреть в `play_speaker_sound`/`speaker_callback` (`seg009.c`):
- **`frequency` — это ГЕРЦЫ напрямую** (`generate_square_wave(stream,
(float)note->frequency, ...)`), а не делитель PIT, как кажется по
маленьким числам;
- длительность ноты = `length / tempo` СЕКУНД.
Замеры по всем 57 звукам: **2212 нот**, частоты 16..65507 Гц,
длительности 1,74..2571 мс.
Из них 23 звука — те, у которых оцифровки НЕТ, то есть вся музыка:
заставки, гимны уровней, смерть, победа, титры (id 24..43, 50..56).
**1469 нот**, и вот их длительности:
| длительность ноты | нот | доля |
|---|---:|---:|
| < 3 мс | 0 | 0 % |
| 3..12 мс | 2 | 0,1 % |
| 12..25 мс | 140 | 9,5 % |
| > 25 мс | 1327 | 90,3 % |
Это число решает вопрос про таймер — см. §4.
## 3. Что умеет железо (док Ивана Мака §5 + MAME)
- **AY-3-8910/8912** в ПЛМ, «программируется по стандартным описаниям» —
то есть ZX-порты; в MAME он заведён как `AY8910(config, "ay8912",
X_SP/24)`, то есть **тактовая 1,75 МГц**. Период канала = 109375 /
частота(Гц), 12 бит (макс 4095) → снизу берутся частоты от ~27 Гц.
Точные Z80-адреса портов идут через таблицу DCP, а не напрямую —
**проверить артефактом до кодинга** (ожидаем ZX-стандарт 0xFFFD/0xBFFD).
- **CBL** — COVOX с буфером 256 Б, две половины по 128; бит 7 порта 0xFE
показывает играющую половину, порт управления 0x4E. Прерывание —
когда половина сменилась.
- **Бипер** (бит 5 порта 0xFE) — туда же в ЦАП. Нам не нужен.
- Всё это **сведено в один ЦАП**, поэтому AY и CBL звучат вместе.
У нас уже есть готовая обвязка CBL (`libc/include/cbl.h`): callback
`fill(n)`, выдача блока через `cbl_push_otir()` (порт 0x4F) или через
акселератор, коды частот, счётчик недоливов. Своего кольца библиотека не
держит — данные пропихиваются прямо из наших EMM-страниц.
## 4. Предлагаемая архитектура
```
эффекты (31 шт, 8 бит 11 кГц) музыка (23 шт, ноты)
│ │
EMM-страницы (6,3) 7 КБ нот в банке
│ │
cbl_push_otir из fill() запись 3 регистров AY
│ │
CBL (10,9 кГц) ──────┐ ┌────────── AY (1,75 МГц)
▼ ▼
TDA1543 (аппаратное смешивание)
```
**Один источник прерываний — CBL.** Его callback приходит каждые 128
сэмплов = **11,7 мс** при 10,9 кГц, и он же двигает секвенсор музыки.
Отдельный таймер (CTC) НЕ нужен: по таблице из §2.2 короче 12 мс всего
2 ноты из 1469 — они растянутся на один тик, чего не слышно.
Почему это важно: `irq_ctc_install` сейчас требует кода в W2 (tiny/big),
а roomtest — huge, и попадёт ли туда CTC-трамплин, зависит от раскладки.
Обойтись без него — значит не открывать этот фронт вовсе.
**Пейсинг кадра при этом не страдает.** Наш темп считается ПО ЛУЧУ
(`pop_pace.h`), а не по кадровым прерываниям, поэтому то, что CBL-ветка
трамплина делает приватный RETI и съедает кадровые прерывания, нам
безразлично. Если бы пейсинг остался на прерываниях — звук бы его сломал.
## 5. Объём работ
| фаза | что | оценка |
|---|---|---|
| **З1** | распаковщик `pop_pack_sound.py`: DAT → `.snd`-страницы EMM (эффекты) + `.not` (ноты музыки) | формат уже разобран |
| **З2** | `pop_sfx.c`: `cbl_open` + `fill()`, таблица «звук → страница/смещение/длина», `pop_sfx_play(id)` | ядро |
| **З3** | развесить вызовы: 66 мест `play_sound` в оригинале; у нас часть уже помечена TODO (`pop_ctrl.c:408`, `guards.c:992`, `pop_map.c:3035/3107`, …). **Плюс бесплатный кусок**: опкод `SEQ_SOUND` в seqtbl уже разбирается нашим `play_seq` (`pop_kid.c:326`) — шаги, приземления и прочее поедут сами | механическая |
| **З4** | `pop_music.c`: секвенсор нот на AY (путь A), тик из CBL-callback | небольшая |
| **З6** | заставки и титры — WAV-музыка потоком (эффекты в это время не звучат) | после З1-З4 |
| **З7** | музыка по ходу игры: WAV с гашением эффектов ЛИБО путь B — решать по итогам З6 | открыто |
| **З5** | приоритеты и вытеснение: у оригинала `play_sound` глушит предыдущий (`stop_sounds`), музыка и эффект — разные каналы | правила из seg009 |
## 6. Что проверить артефактом ДО кодинга
1. **Порты AY на Sprinter** — записать в 0xFFFD/0xBFFD и убедиться, что
MAME отдаёт звук (порты идут через DCP-таблицу, «стандартные ZX» —
это ожидание, а не факт).
2. **CBL в режиме huge.** Шапка `cbl.h` говорит «код/данные в W2
(tiny/big)», но это скорее всего устаревшая оговорка: IM2-трамплин
давно переделан на all-modes, и наш кадровый путь в huge работает.
Проверить `cbl_open` из roomtest.
3. **Совместное владение портом 0xFE.** Бит 5 (луч) у нас держится через
`_cbl_port_ref` «немым» кодом частоты; когда откроется НАСТОЯЩИЙ CBL,
владение переходит к нему. Убедиться, что пейсинг переживает
`cbl_open`/`cbl_close`.
4. **Цена fill() в кадре.** 128 байт через `cbl_push_otir` раз в 11,7 мс
— замерить тем же способом, что и остальное (брейкпоинт + totalcycles).
## 7. Про MIDI — почему не он
Музыка в MIDISND — настоящие MThd/MTrk чанки, 27 КБ. Чтобы играть их на
AY, нужен разбор MIDI, раскладка каналов на три голоса и таблица
инструментов — это отдельный проект, и звучать он будет НЕ так, как
оригинал на PC. А набор PC-спикера — это ровно то, что слышал игрок на
IBM PC 1989 года: один квадратный голос. Он у нас есть целиком, весит
7 КБ и ложится на AY напрямую.
Если позже захочется богаче — материал уже будет разобран, и можно
разложить те же мелодии на три канала AY (бас/мелодия/арпеджио), не трогая
ни данные, ни секвенсор.
---
## 8. Разводка вызовов по коду (сделано 2026-08-20)
Портированы ВСЕ места `play_sound()` SDLPoP, у которых есть оцифровка
(id 0..23, 44..49, 51 — остальные id это музыка, у них в
`pop_sound_tbl.h` длина 0, и вызов просто глушит текущий эффект).
| id | что | где у нас | оригинал |
|----|-----|-----------|----------|
| 0 | разбился насмерть | `pop_map.c` land | seg005 |
| 1 | крик падения | `pop_map.c` do_fall | seg005:39 |
| 2 | плита рухнула | `pop_room.c` (обе ветки посадки) | seg007 |
| 3 | кнопка нажата | `pop_trob.c` | seg007 |
| 4/5/6/7 | ворота: закрываются / открываются / рухнули / стоп | `pop_trob.c` | seg007 |
| 8 | удар о стену | `pop_map.c` bumped_fall/bumped_floor + seqtbl | seg004/seg006 |
| 9 | зацеп за карниз | `pop_map.c` check_grab | seg006 |
| 10 | клинок о клинок | `roomtest.c` после `check_sword_hurt`, если один из бойцов в кадре 167 | seg000:1353 |
| 11 | свист клинка мимо | `guards.c` check_hurting | seg002:0DAE |
| 12/13 | ранен соперник / Кид | `guards.c` hurt_by_sword | seg002:0C1F |
| 13 | Кид ранен зельем | `pop_map.c` ветка «злого» зелья | seg006:1894 |
| 14/15 | дверь уровня: закрывается / едет | `pop_trob.c` | seg007 |
| 16 | средняя посадка; толчок о стража | `pop_map.c` land / bump_into_opponent | seg005/seg003:0654 |
| 17 | мягкая посадка | `pop_map.c` land | seg005 |
| 18 | пьёт | seqtbl (SEQ_SOUND) | seg006 |
| 19 | вынул меч | `pop_ctrl.c` | seg005:945 |
| 20/21/22 | дрожит плита | `pop_map.c` loose_shake | seg007:0E55 |
| 23 | шаг | seqtbl (SEQ_SOUND) | seg006 |
| 44 | скелет оживает | `guards.c` pop_check_skel | seg002:106D |
| 45 | прыжок в зеркало | `pop_map.c` jump_through_mirror | seg003:0617 |
| 46 | сожрал чомпер | `pop_map.c` | seg004 |
| 47 | чомпер щёлкнул | `pop_trob.c` (кадр 2) | seg007 |
| 48 | напоролся на пики | `pop_map.c` | seg005 |
| 49 | пики пошли | `pop_map.c` start_anim_spike | seg007:08F6 |
Что осталось не разведено — только МУЗЫКА (24/28 смерть, 25 презентация,
26 объятия,
27/35/40 заставки, 29 встреча Джафара, 30/33 зелья, 32/41 конец уровня,
36 время вышло, 37 победа, 43 смерть Джафара, 50/52/53 сюжетные вставки)
и 51 (дверь принцессы, тоже из заставки). Их черёд — фаза «музыка».
**Квирк, за которым следить.** Звук ворот у оригинала звучит не всегда, а
по условию видимости (`play_door_sound_if_visible`, seg007:1250): либо
ворота в комнате слева и стоят в 9-й колонке, либо ворота в НАРИСОВАННОЙ
комнате и колонка не 9-я. У нас это параметр `audible` у `animate_door`.
**Тряска плиты — свой домен prandom.** Оригинал берёт номер сэмпла (20/21/22)
из общего генератора и вдобавок «сжигает» один бросок ради совместимости с
DOS-версией; у нас последовательности разведены по доменам
(`impl_diff.md`), поэтому у тряски свой сид, а холостой бросок не делаем —
на розыгрыши физики и кладки это не влияет.
## 9. Цена звука в тактах (замер 2026-08-20, MAME)
Вопрос был поставлен так: звук идёт по прерываниям, значит размазан по всем
фазам кадра — и если фазы укладываются, всё хорошо? Да, но проверять это
надо не по фазам, а по двум числам, потому что **нагрузка от звука
постоянная и от сцены не зависит вовсе**.
### 9.1 Одно прерывание CBL
Зонды: `bpset` на входе трамплина (`_irq_tramp`) и на `reti` ветки CBL,
разница `totalcycles`. 398 замеров в сцене 11/15.
| величина | значение |
|---|---:|
| цена одного прерывания | **7 825 тактов ровно**, с разбросом до 8 299 (среднее 8 001) |
| период между прерываниями | 245 759 тактов (= 128 сэмплов на 10 937,5 Гц) |
| **доля процессорного времени** | **8 001 / 245 759 = 3,26 %** |
Цена постоянная, потому что работа фиксированная: OTIR ровно 128 байт плюс
скобка сохранения контекста. Ветвлений по данным в насосе нет.
### 9.2 Дрожание обслуживания — риск для ЗВУКА, не для кадра
Период плавает 228 804 … 262 734, то есть прерывание опаздывает максимум на
**~17 000 тактов = 0,8 мс**. Это самая длинная DI-скобка в коде
(акселератор режется по 16 строк, memory `sprinter_wait_states_2x`).
Буфер CBL — 128 сэмплов = **11,7 мс**, запас **14×**. Недолива быть не
может; счётчик `cbl_underruns()` это подтверждает косвенно (наш `fill`
всегда возвращает 1, поэтому он ловит только отсутствие данных, не
опоздание).
### 9.3 A/B в одном прогоне (Ctrl+S), сцена 11/15
Один и тот же кадр, звук выключается на ходу — сравнение чистое.
| | работа min | работа max | работа avg | прерываний CBL на кадр |
|---|---:|---:|---:|---:|
| звук ВКЛ | 453 132 | 572 532 | **498 064** | 1,40 |
| звук ВЫКЛ | 444 348 | 559 434 | **487 372** | 0,00 |
| разница | +8 784 | +13 098 | **+10 692 (+2,2 %)** | |
Разница на лёгком кадре (+8 784) — ровно одно прерывание, сходится с §9.1.
`CBL/кадр = 0` при выключенном звуке подтверждает, что Ctrl+S реально
ЗАКРЫВАЕТ CBL, а не глушит сэмпл: иначе насос продолжал бы отдавать блоки
тишины и платить те же 3,26 %.
### 9.4 Укладываемся ли
Логический кадр (`pop_pace.h`): NORMAL = 4 растра вне боя = **1 720 000
тактов**, FASTEST = 3 растра = **1 290 000**.
| | работа | доля NORMAL | доля FASTEST |
|---|---:|---:|---:|
| 11/15, обычная позиция | 498 064 | 29 % | 39 % |
| 11/15, тяжёлая позиция (Кид на 7 px правее) | 750 066 макс | 44 % | 58 % |
Период кадра за все прогоны: 1 719 936 … 1 720 752 — ровно 4 растра, ни
одного проскока. **Звук занимает 1,1 % бюджета NORMAL и 1,5 % FASTEST.**
### 9.5 Где 3,26 % МОГЛИ БЫ стоить дорого
Ответ «всё размазано, если фазы влезли — ок» верен с одной оговоркой.
Пейсинг квантован растром: работа 1,00 растра и 1,02 растра дают РАЗНЫЙ
период кадра (3 против 4 интервалов), то есть скачок сразу на 20 мс.
Значит звук опасен ровно в одной ситуации — когда сцена стоит в пределах
~8 000 тактов НИЖЕ кратного растру порога. Сейчас ближайший запас — 540 000
тактов до порога FASTEST, то есть в 60 раз больше цены звука. Проверять
эту оговорку заново стоит только если работа кадра подберётся к 430 000 или
860 000 вплотную.
## 10. Мусор при включении и щелчок на выходе (разбор 2026-08-20)
Жалоба: «при старте, когда разрешается звук, проходит кусок мусора».
Разобрано записью выхода MAME в WAV (`-wavwrite`) — по огибающей и
автокорреляции, а не на слух.
### 10.1 На старте мусора НЕТ; это настоящие звуки
От `cbl_open` до первого эффекта в записи **точная цифровая тишина**
(размах 1 при разрешении 16 бит). Дальше — два штатных звука:
| что | когда | длительность |
|---|---|---|
| `gate_closing_fast` (6) — решётка в комнате СЛЕВА | +0,30 с после `cbl_open` | обрывается на 80 мс |
| `soft_land` (17) — Кид приземляется | +0,38 с | 383 мс |
Опознаны корреляцией огибающих с оригинальными сэмплами `digisnd`:
звук 6 даёт +0,72 с начала записи, звук 17 — +0,32 со сдвигом 80 мс.
Приземление на старте КОРРЕКТНО: `start_pos` уровня 1 — тайл (0,0), а он
`space`, то есть Кид падает на ряд ниже, на площадку с факелами.
Обрыв первого звука вторым — тоже поведение оригинала, а не наш дефект:
`play_digi_sound` (seg009.c:2402) начинается с `stop_digi()`, голос ОДИН.
Ощущение «мусора» даёт именно 80-мс огрызок скрежещущей решётки.
Звук кнопки (3) при этом не слышен: `pop_sfx_play(3)` случается ДО
`cbl_open` и глохнет. С SDLPoP совпадает (там на старте тоже только
решётка), но держится это на порядке вызовов — если поднимать звук раньше
`pop_start_level`, щелчок кнопки станет слышен.
### 10.2 Незалитый буфер CBL — дефект есть, но в MAME он немой
Буфер CBL (256 слотов) железо не чистит ни сбросом, ни записью в порт
управления, а эта запись сразу пускает воспроизведение с нулевого слота.
Значит первые 256 сэмплов (23,4 мс) — то, что лежало раньше. Разбор по
`sprinter.cpp`: `case 0x89` делает `m_cbl_cnt = 0; m_cbl_wa = 0`, а
прерывание «долей половину» приходит только на 128-м слоте и ставит
указатель на ПРОТИВОПОЛОЖНУЮ половину — своими данными звук идёт лишь с
третьей половины.
В MAME это не слышно: эмулируемый буфер стартует нулями, а ЦАП
двухдополнительный, то есть 0 = середина шкалы. На ЖЕЛЕЗЕ там
неинициализированное ОЗУ — ровно тот мусор, который ловился ещё на
тестовых примерах CBL. Лечение — `_cbl_prime` в `cbl_open`: сразу после
включения 256 записей байта тишины в порт данных (заранее нельзя, запись
проходит только при поднятом bit7). Стоит 6 400 тактов один раз за
открытие; за это время таймер уходит на два-три слота.
### 10.3 Щелчок на выходе — ГОЛОДАНИЕ насоса, вылечено
На выходе по ESC в записи было **ровно 11 мс шума на полной громкости**
(размах 33 671 — громче всего в прогоне), потом мгновенная тишина. 11 мс
= один блок CBL (128 сэмплов = 11,7 мс), то есть один пропущенный долив:
`pop_shutdown` звал `closegraph`/`pop_bg_free`/`pop_kid_free` (а это
ESTEX на каждый атлас) ПРИ ОТКРЫТОМ звуке, насос не успевал, и железо
доигрывало несвежую половину.
Лечение: `pop_sfx_close()` первым действием `pop_shutdown`. Проверено
второй записью — всплеска на выходе больше нет. Механизм тот же, из-за
которого звук глушится на время загрузки уровня.
## 11. Приоритеты и перебиваемость: звук у оригинала НЕ «всегда перебивать»
Пользователь услышал расхождение: у нас решётка обрывалась приземлением
Кида, в SDLPoP — доигрывала до звонкого конца, а приземления не было
слышно вовсе. Разбор исходника показал, что мы упустили ЦЕЛЫЙ МЕХАНИЗМ.
### 11.1 Модель оригинала
```
play_sound(id) seg000:12C5 — только НОМИНИРУЕТ кандидата на кадр:
if next < 0 || prio[id] <= prio[next]: next = id
play_next_sound() seg000:1304 — раз в кадр решает, запускать ли:
if next >= 0:
if !играет_что_то ||
(перебиваем[текущий] && prio[next] <= prio[текущий]):
текущий = next; запустить
next = -1 // НЕ запустили -> номинант ВЫБРОШЕН, очереди нет
```
Три следствия, каждое слышно:
- **Неперебиваемый звук доигрывает целиком.** У `gate_closing_fast` (6)
`interruptible = 0`, поэтому приземление Кида (17) в этот момент
пропадает совсем — не откладывается, а именно теряется.
- **Внутри кадра выживает важнейший.** Меньше `prio` — важнее; при
равенстве побеждает ПОСЛЕДНИЙ (сравнение `<=`).
- **Два источника не «чередуются как получится».** Челюсти (47, prio
0x10) всегда важнее решётки (4, prio 0x32): решётка не может перебить
укус, а укус решётку — может. Отсюда и картина на ур. 9 к. 9, где
решётка звучит только в паузах между укусами.
### 11.2 Что сделано у нас
`pop_sfx_play` теперь только номинирует; запуск — в `pop_sfx_tick`,
который зовётся раз в кадр в конце отрисовки (там же, где оригинал зовёт
`play_next_sound`, seg000:954). Таблицы `snd_prio` (57 байт) и битовая
карта `snd_intr` (8 байт) — в резиденте, значения из SDLPoP С УЧЁТОМ
`fix_sound_priorities()`: в `config.h` SDLPoP `FIX_SOUND_PRIORITIES`
определён безусловно, значит сравниваемся мы с исправленным вариантом
(звук 10 → 0x0D, 48 → 0x15, 49 перебиваем).
Створка двери уровня (15) — единственная запись, которую оригинал правит
на ходу: перебиваема при закрытии, нет при открытии (seg007:442/464).
Держим отдельным байтом `pop_sfx_slide_intr`, чтобы таблица осталась в
ПЗУ. Там же добавлен пропущенный `stop_sounds()` на завершении открытия
двери (seg007:455) — без него неперебиваемый съезд (1,6 с) блокировал бы
очередь.
Звуки без оцифровки (музыка, длина 0) не номинируются вовсе — порт
проверки `if (NULL == sound_pointers[id]) return;`. Раньше такой id
глушил живой эффект.
### 11.3 Проверка
Записью MAME, старт уровня 1:
| | всплески | что это |
|---|---|---|
| до | 135 мс + 210 мс | решётка, обрезанная приземлением на 80 мс |
| после | **один, 455 мс** | решётка целиком, корреляция огибающей со звуком 6 **+0,889** |
Цена: резидент +~250 Б (таблицы + логика), куча ужалась с 347 до 134 Б —
довод в пользу давно назревшей реорганизации базовой памяти.
## 12. Ворота: гейт слышимости и «решётка встала» (2026-08-20)
Проверка на сцене, которую предложил пользователь — уровень 9, комната 9:
кнопка (1,8), челюсти (1,2), а ворота **в комнате 4, тайл (1,9)**, то есть
в комнате СЛЕВА. Нашлись три расхождения сразу.
### 12.1 Гейт слышимости был неверный
У нас стояло `audible = (room == cur_room)`. У оригинала
(`play_door_sound_if_visible`, seg007:1239) правило другое:
- ворота в комнате СЛЕВА и в колонке 9 — СЛЫШНЫ (створка видна в шве);
- ворота в отрисованной комнате и НЕ в колонке 9 — слышны;
- особый случай: уровень 3, комната 2 — слышны всегда.
Сцена 9/9 попадает ровно в первый пункт, поэтому спуск решётки у нас
молчал. Подъём при этом совпадал с оригиналом — потому что звук открытия
(5) идёт БЕЗ гейта (seg007:386, прямой `play_sound`). Эта асимметрия и была
подсказкой.
Взят вариант под `FIX_GATE_SOUNDS` (условия через ИЛИ): в config.h SDLPoP
он определён безусловно.
### 12.2 Потерян звук «решётка встала» (7)
`gate_stop()` (seg007:05E3) зовётся из ТРЁХ мест `animate_door` и каждый раз
играет звук 7 через гейт слышимости: конец закрытия, открытие насовсем и
ветка «уже 0xFF». У нас во всех трёх стояло только `*type = -1` без звука.
Добавлено. Лязг после ОБЫЧНОГО открытия (seg007:395) остаётся без гейта —
там оригинал зовёт `play_sound` напрямую.
### 12.3 Кнопка: у оригинала есть параметр playsound
`trigger_button(playsound, ...)` — в трёх местах он нулевой: вход на уровень
(seg003:170), выход Джаффара (seg002:520) и зелье «открыть» (seg006:1890, у
нас не портировано). Мы играли щелчок всегда. Добавлен параметр `snd`.
### 12.4 Почему щелчок кнопки слышно через раз — это НЕ баг
Бюджет сцены 9/9 (длительности после пересчёта на 10 937,5 Гц):
| звук | длительность | prio |
|---|---:|---:|
| челюсти (47) | 465 мс | 0x10 |
| решётка вниз (4) | 97 мс | 0x32 |
| решётка вверх (5) | 123 мс | 0x37 |
| решётка встала (7) | 75 мс | 0x30 |
| кнопка (3) | 106 мс | 0x66 |
Цикл челюстей — 15 кадров = 1229 мс (замерено брейкпоинтом на номинации:
25 805 000 тактов между укусами). Значит укус занимает 465 мс, пауза 764 мс.
Кнопка (prio 0x66) перебить челюсти не может (0x66 > 0x10), поэтому слышна
только если нажатие попало в паузу — примерно в 6 случаях из 10.
Подтверждено пользователем на живой сцене.
### 12.5 Грабли сцены
Если игра стартует ПРЯМО в комнате с челюстями, они не заводятся сами:
нужно сходить Кидом на левую кнопку и вернуться. Это поведение оригинала
(trob челюстей создаётся событием), а не наш дефект — учитывать при
постановке автотестов.
## 13. Повторный аудит игрового звука (2026-08-24)
Проверены три независимых слоя: содержимое атласов, места вызова и живой
тракт `play -> tick -> CBL`.
- В восьми `SND*.ATL` есть все 31 оцифрованных ресурса: `0..23`, `44..49`
и `51`; ненулевая страница/длина есть у каждой записи таблицы.
- Для всех 30 PCM-эффектов, которые могут возникать непосредственно в игре,
есть место вызова. Последним пропуском был звук 10 при столкновении
клинков; условие перенесено буквально из `check_sword_vs_sword` SDLPoP.
Звук 51 относится к сцене с принцессой, а не к игровому циклу.
- Звук 19 «Кид достал меч» проверен в MAME брейкпоинтами. В момент вызова
предыдущий PCM уже закончился (`sfx_left=0`), номинация дошла до
`pop_sfx_tick`, после чего курсор получил id 19, страницу 5, смещение
`0x1500` и длину 2816 байт. В этом прогоне его не подавляли решётка,
плиты, шаги или приоритеты. Если он всё ещё субъективно не слышен, искать
надо после выбора эффекта — в непрерывности CBL/громкости самого сэмпла.
- После продолжительного прогона title/intro/demo/menu CBL обслужил 3303
блока и сообщил 0 программных недоливов (`cbl_requests=0x0CE7`,
`cbl_underruns=0`). Это исключает возврат `fill=0`, но само по себе не
измеряет запоздание прерывания внутри слишком длинной секции `DI`.
- Регрессия full-game на входе в Level 1 оказалась именно запозданием CBL:
`pop_level_switch` открывал его ДО блокирующего BIOS fade-in. Demo был
чистым, потому что включал палитру без fade. Теперь загрузчик оставляет
CBL закрытым, а caller открывает его после окончательной палитры/QuickLoad;
скрежет на Level 1 исчез в MAME. Однократный щелчок самого первого
`cbl_open` за всю MAME-сессию остаётся отдельной низкоприоритетной задачей.
- Открытие pause menu у SDLPoP беззвучно; движение играет 21, вход/выход
из подменю — 22, изменение настройки — 10. Эти вызовы перенесены. На
время полного копирования страницы и файловых операций CBL закрывается,
после flip открывается снова: аппаратная половина не должна повторять
старые данные и давать «скрежет».
Отдельно остаётся игровая музыка и сигнальные мелодии без PCM: смерть
(`24/28`), начало/появление Shadow (`25`), встреча Jaffar (`29`), большое
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает.
## 4. МУЗЫКА: решение пересмотрено (2026-08-25) — путь C вместо A
В §1б первым заходом был выбран **путь A** (ноты PC-спикера на AY), а путь
C (запись -> WAV -> CBL) стоил дорого из-за строки «нужен синтезатор на
хосте». Это обстоятельство отпало: у пользователя есть **готовые записи
DOS-версии** — `applications/PoP/PoP1_DOS_music` (flac/mp3/ogg/ogg_MT-32,
22 трека). Синтезировать нечего, остаётся `ffmpeg -ac 1 -ar 10937 -f u8`.
### Что померено по этим записям
| группа | длительность | PCM 10 937,5 Гц |
|---|---:|---:|
| всё вместе (22 трека) | 339 с | **3 625 КБ = 227 EMM-страниц** |
| игровые джинглы (10) | 57 с | 608 КБ |
| заставки и титры | 282 с | 3 017 КБ |
| финальный `won` один | 115 с | 1 233 КБ |
Свободной EMM на старте ~3 440 КБ, так что вся музыка разом в память не
влезает и не должна: трек грузится под сцену и освобождается после.
### Что сделано
`toolchain/pop_pack_music.py` -> один файл `MUS\m<id>.bin` на трек
(читается порциями по 16 КБ из одного открытого fd) + каталог
`pop_music_tbl.h`. Длина трека хранится **порциями по 128 байт**,
а не байтами: 169 КБ в uint16 не влезает, 1350 блоков — легко.
`pop_music.c` (банк 9) грузит трек в EMM и ставит курсор; насос
`pop_sfx_fill` (резидент) получил **третий источник**: эффект важнее
музыки, музыка важнее тишины. Эффект музыку не сбрасывает — её курсор
стоит, пока эффект доигрывает, и она продолжается с места.
Проверено в MAME записью звука: трек `story_1_absence` на первом экране
истории, корреляция огибающих с эталоном **0,836**, RMS 22,6 против 18,3
(разница — 8-битное квантование). Эффекты двери в PV-сцене после него
звучат как прежде, то есть освобождение страниц и возврат к набору
эффектов работают.
### Цена и что осталось
* CBL один: пока играет музыка, эффектов нет. Для заставок это не важно
(их там не бывает), для игровых джинглов — открытый вопрос §1б.4.
* Резидент вырос на 83 байта (третий источник в насосе) плюс 25 байт
данных под курсор и таблицу страниц; запас W2 — 151 байт. Кучи в
приложении нет (`malloc` не слинкован), так что это чистый запас роста.
* Банк 9 занят на 88 %. Следующий модуль туда уже не влезет — либо
переносить, либо заводить банк 12.
* `won` (77 страниц) в POP_MUS_PAGES=20 не помещается: финал придётся либо
резать, либо стримить кусками по ходу.
## 5. СКРЕЖЕТ ПРИ BIOS-ВЫЗОВАХ: причина и решение (2026-08-25)
Симптом: во время затемнения (fade) звук хрипел — одинаково с играющей
музыкой и в тишине. Пользователь заметил ключевое: **повтор тишины обязан
звучать тишиной**, значит дело не в недоливе буфера.
### Как искали
Отладочные клавиши, каждая делает ровно один кусок fade:
| клавиша | что делала | результат |
|---|---|---|
| H | только ожидание 8 кадров | чисто |
| V | только чтение палитры | скрежет |
| G | затемнение целиком | скрежет |
| J | только запись палитры | скрежет |
| L | 512 раз `bios_get_place()` — видео не трогает | **скрежет** |
`L` и решил вопрос: виновата не палитра, а **любой вызов BIOS**.
### Причина
Вход в BIOS — это `rst 8`, то есть `out ($7C),a`. В драйвере MAME он
правит `m_rom_sys` и вызывает `update_memory()`, которая перестраивает
**окно 0**: `m_pages[0]` + `m_bank_view0.select(1)`. ПЗУ ложится ПОВЕРХ
страничного регистра.
Насос CBL брал окно взаймы именно у W0 (`_io_page_w0 = phys` + `OTIR` по
адресу < 0x4000). Пока BIOS работает, запись в порт `0x82` ничего не
меняет, и `OTIR` вычитывает ПЗУ, отдавая его в звук.
Побочно выяснилось, почему `DI` вокруг BIOS помогал лишь иногда: он не
даёт войти в ISR (тогда блок просто пропускается, что неслышно), но в
обработчиках BIOS есть `EI`, так что защита негарантированная.
### Решение
Насос переведён на **W3** (идея пользователя): это окно управляется только
портом `0xE2`, подмену из прерывания никто не перекрывает, а BIOS во время
нашего ISR не исполняется — окно возвращается до выхода.
```c
saved = _io_page_w3;
_io_page_w3 = phys;
cbl_push_otir((const void *)(0xC000u + ptr), n);
_io_page_w3 = saved;
```
После этого BIOS безопасен везде: и палитра, и любые другие функции.
Временный обход палитры мимо BIOS (`gfx_pal_write`) стал не нужен — он
остался в libbgi как более быстрый примитив (2,5 тыс. тактов на 64 цвета
против 10,8 тыс. у BIOS), но игра его не зовёт.
Бонус: в W3 нет стаба восстановления окна, который в W0 занимал начало
страницы, — звуковые страницы можно использовать целиком.
## 6. ВСЯ ЗАСТАВКА ОЗВУЧЕНА (2026-08-25)
К `story_1_absence` добавлены остальные четыре трека заставки:
**54** intro_theme (титры), **50** story_2_princess, **53**
story_3_Jaffar_enters, **52** story_4_Jaffar_leaves (сцена с принцессой).
`MUS_IDS` в Makefile — 50 52 53 54 55, всего 1056 КБ на образе.
### Два слота вместо одного
Реплики оригинала идут ВСТЫК: следующая начинается там, где кончилась
предыдущая, паузы под загрузку нет. Поэтому `pop_music` держит два слота
EMM: `pop_music_load*` всегда пишет в НЕ играющий, `pop_music_play`
подменяет резидентную таблицу страниц и отпускает прошлый слот. Своей
копии таблицы слот не хранит — её и так держит блок EMM, `mem_get_page`
отдаёт номер по индексу (иначе −40 байт W2 у игры, а там их нет).
Плюс **постраничная загрузка**: `pop_music_load_begin` / `_load_step`
читают по одной странице за вызов. Страница стоит 33 мс — четверть
логического кадра заставки (133 мс), поэтому подкачка следующей реплики
прямо посреди анимации не видна. Кто может позволить себе паузу (титры,
чёрный экран между сценами) — зовёт прежний `pop_music_load`.
### Тайминги приведены к шкале оригинала
Все длительности сцен взяты из SDLPoP в его тиках (60 Гц), а ждём мы
кадрами луча (~50 Гц). Пока сцены были немыми, разбег в 20 % не был
виден; с музыкой он слышен сразу — реплика кончается раньше картинки.
Введён `POP_T60(t)` (pop_cutscene.h), и на него переведены титры,
`intro_before_pv`, хвост после PV и пейсинг самой PV-сцены (счётчик
потраченных кадров луча против `POP_T60(tick)`, вместо прежних жёстких
четырёх кадров на логический).
**Паузы-реплики.** Там, где оригинал ждёт конца сэмпла, у нас теперь
стоит реальная длина нашей записи: m50 — 831 тик, m53 — 985. Отсюда
новая шкала PV: конец m50 на 846, вход Джафара (m53) на 1046, уход
(m52) на 2073, конец сцены 2500 тиков (было 1959).
**Fade перед PV** (вопрос пользователя: наш fade короче, 4 ступени против
64). Совпасть должен момент полной темноты, считая от пуска m55:
у SDLPoP это 80 (transition) + 600 (wait) + 128 (fade_out_2: 0x40 шагов
по 2 тика); у нас переход занимает 80 кадров луча = 96 тиков, а fade —
5 тиков. Отсюда `WAIT = 80 + 600 + 128 96 5 = 707` тиков. Дальше и
там и там экран уже чёрный, а трек доигрывает: этой паузой заставка и
стыкуется с PV.
**Музыка переживает смену сцен.** m54 звучит с титров и до первого
экрана истории (`pop_intro_show` больше не глушит CBL на входе), m52
начинается в PV и доигрывает уже на экране «свадьбы» — как seg000:2051.
Проверено в MAME: цепочка 54 → 55 → 50 → 53 → 52 отыгрывается целиком,
курсор `pop_mus_id` меняется ровно на своих кадрах, интро доходит до
демо-режима. Слуховая проверка (нет ли хрипа от диска при играющей
музыке) — за пользователем.
## 7. МУЗЫКА ПО ХОДУ ИГРЫ (2026-08-26)
Звуки 24..43 в наборе оцифровки ПУСТЫЕ — в оригинале это Adlib-музыка, и
в digisnd её нет вовсе. На этом и построено подключение: `pop_sfx_play`
для звука с нулевой длиной не пытается его играть, а кладёт НОМЕР в
`pop_mus_req` (один байт). Заявку разбирает `pop_music_service()` — один
вызов на кадр из любого цикла (игрового, интро, катсцены); всё чтение с
диска живёт там.
**Стриминг вместо загрузки.** Ждать полной загрузки джингла нельзя — это
фриз на треть секунды посреди игры. `pop_music_stream` читает ПЕРВУЮ
страницу (33 мс) и сразу пускает трек: она звучит 1,5 с, а следующая
читается те же 33 мс — запас сорокакратный. Остальные доливаются по
одной за кадр, пока `pop_music_loading()`. Номера страниц известны сразу
после `mem_alloc_pages`, поэтому таблица для насоса заполняется целиком —
данные появятся раньше, чем насос до них дойдёт.
**Что и где играет** (номера и места — из SDLPoP):
| трек | событие | место у нас |
|------|---------|-------------|
| 24 / 28 / 32 | смерть: обычная / в бою / от руки тени | `ctrl_kid_death` |
| 25 | вступление 1-го уровня (Кид сидит), тень 6-го | `control_crouched`, `guards.c` |
| — | НА ДЕМО-УРОВНЕ музыки нет вовсе: там одни эффекты | гейт в `pop_music_service` |
| 27 / 35 / 40 | сцены перед 2/4/6/12, 8/9, «времени мало» | `pop_pre_cutscene_show` |
| 29 | встреча с Джафаром | `pop_meet_jaffar` |
| 30 / 33 | большая склянка / малая | `pop_proc_get_object` |
| 36 | время вышло | `pop_time_expired_show` |
| 37 / 43 | меч найден, страж убит / смерть Джафара | `pop_proc_get_object`, `on_guard_killed` |
| 39 | перо (медленное падение) | `pop_proc_get_object` |
| 41 / 32 | конец уровня / конец 4-го (тень) | опкод SND_LEVEL в `play_seq` |
| 26 | встреча с принцессой | `cut_ending` |
**Вступление первого уровня — автомат, а не «звук при приседе»** (seg005:02EB).
Пока `need_level1_music` не ноль, `control_crouched` НЕ ЧИТАЕТ управление:
Кид сидит, тема играет, и лишь когда она смолкла, он может встать. Наша
первая версия просто играла трек при первом приседе — и тема догоняла
игрока посреди уровня (пробежал, спрыгнул, присел — заиграла). Признак
«ещё звучит» берём у курсора насоса `pop_mus_left`: он резидентный, и если
музыка выключена, курсор остаётся нулём — Кид просто встаёт.
Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень
6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал
для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это
есть свои два байта.
**ГДЕ КОНЧАЕТСЯ МУЗЫКА СЦЕНЫ** (уточнено 2026-08-26). Сначала мы отдали
трек «доигрывать в игре»: у оригинала load_intro после сцены просто гасит
экран и возвращает управление. На слух оказалось хуже, чем в оригинале —
музыка спотыкается: загрузка уровня (ESTEX плюс сборка комнаты) не даёт
насосу долить блок вовремя. У DOS-версии этой проблемы нет, там звук
живёт своей жизнью на аппаратуре.
Поэтому дослушиваем ПОД ЧЁРНЫМ ЭКРАНОМ, до отрисовки уровня: следующий
load_intro у оригинала и так начинается с ожидания тишины (seg001:681), то
есть к новому уровню трек в любом случае смолкает. Пропуск сцены обрывает
и музыку — игрок нажал клавишу, чтобы идти дальше.
**ДЛИТЕЛЬНОСТЬ FADE.** fade_in_1/fade_out_1 — это 64 шага палитры по два
тика, 128 тиков = 2,13 с; сцена перед уровнем 2 занимает с ними около семи
секунд. Наши четыре ступени укладывались в восемь сотых секунды, и сцена
выходила втрое короче. Теперь `INTRO_FADE = POP_T60(128)`, а ступеней в
`pop_ui_palette_dim` тридцать две вместо четырёх: на четырёх растянутых
ступенях затемнение выглядело бы скачками. Половина от оригинальных 64 —
на глаз от них не отличается (ступень каждые 66 мс), а вот шестнадцать уже
видно. Сумма «fade in + сцена + fade out» при этом совпадает с оригиналом
сама собой: длительность каждого fade та же, что у fade_*_1.
**Цена ступени** (замеры в MAME 2026-08-26, такты 21 МГц; кадр 430 000):
| версия | такты | что изменилось |
|--------|-------|----------------|
| исходная | 2 440 000 | снимок копировался побайтовым циклом на C |
| + таблица яркости на стеке | 1 250 000 | 768 умножений uint16 заменены 256 сложениями |
| + memcpy для снимка | 487 000 | LDIR вместо цикла — главный выигрыш |
Из оставшихся 487 тысяч 136 тысяч — заливка палитры через BIOS (8 вызовов
`gfx_pal_load` по 17 000). Дальше можно было бы хранить готовые таблицы
яркости файлом, но при 1,2 мс на построение это уже незаметно.
ВАЖНО: ступень пересчитывается только когда она СМЕНИЛАСЬ. Наивный цикл
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
десяти с лишним секунд.
**Чего пока нет.** Финальный `won` (56): 115 с, 1,2 МБ, 78 страниц EMM —
в `POP_MUS_PAGES` (20) он не помещается даже теоретически. Ему нужен
КОЛЬЦЕВОЙ стриминг: держать в памяти несколько страниц и дочитывать в те,
что насос уже прошёл. Это отдельная задача — курсор насоса придётся
научить заворачиваться, а загрузчик — держать дистанцию от него.
+218
View File
@@ -0,0 +1,218 @@
# Текст в нижней статус-строке (строке HP) — полная инвентаризация SDLPoP
Разбор `SDLPoP/src/` на 2026-08-25. Цель — знать ВЕСЬ набор сообщений,
которые оригинал печатает в ту же полосу, где нарисованы деления HP,
и правила их появления/исчезновения. Это входные данные для порта:
у нас пока туда пишется только `GAME PAUSED`.
---
## 1. Геометрия: одна полоса на HP и на текст
```
rect_bottom_text = { top 193, left 70, bottom 202, right 250 } // data.h:217
display_text_bottom: draw_rect(чёрным) + show_text(halign_center, valign_bottom)
```
* Деления HP **Кида** — от `x = 0` вправо, шаг 7, максимум 10 → занимают `x 0..69`.
* Деления HP **стража** — от `x = 314` влево, шаг 7, максимум 10 → занимают `x 245..320`.
* Текст живёт РОВНО в промежутке `x 70..250` и по X с делениями не пересекается.
* По Y деления на `y = 194..200`, текст (`valign_bottom` к 202) — на `y = 195..201`,
то есть на строку ниже. Именно поэтому в оригинале текст выглядит «сидящим»
чуть ниже стрелок HP.
**У нас**: `POP_HP_Y = 194` (`pop_cdraw.h`), экран сдвинут на `POP_YOFF = 28`,
базовая линия крупного шрифта `POP_YOFF + POP_HP_Y + 8 = 230` — силуэт
ложится на `223..229`, то есть та же картинка.
## 2. Два примитива и два таймера
| Имя | Что делает |
|-----|------------|
| `display_text_bottom(text)` (seg008:2644) | стереть прямоугольник цветом 0 и напечатать текст по центру |
| `erase_bottom_text(arg)` (seg008:266D) | стереть прямоугольник; при `arg != 0` ещё и обнулить оба таймера |
| `text_time_remaining` | сколько игровых тиков сообщение ещё висит; 0 — ничего не висит |
| `text_time_total` | **идентификатор сообщения**, а не только его длительность |
Обработка тика — в `draw_game_frame`/`idle` (seg000:956). Комментарий в
оригинале прямой: *«Note: texts are identified by their total time!»* Значения
`text_time_total`, у которых есть особое поведение:
| `total` | Смысл | Что происходит по истечении |
|---------|-------|------------------------------|
| 12 | «1 SECOND LEFT» | обычное стирание |
| 24 | обычное короткое сообщение | обычное стирание |
| 36 | смерть на демо-уровне (0) или на уровне зелий (15) — **текста нет** | `start_game()` — рестарт игры |
| 288 | «Press Button to Continue» | `start_game()` — рестарт игры |
| 1188 | защита от копирования (уровень 15) | **не убывает и не исчезает** |
Мигание: при `total == 288` и `remaining < 72` сообщение мигает с периодом 12
тиков — 4 тика видно (`blink_frame <= 3`), 8 нет; в кадре `blink_frame == 3`
заново печатается текст и играет звук 38 (`sound_38_blink`).
Сброс: `init_game()` (seg003:32) обнуляет оба таймера и `is_show_time` — то есть
любое сообщение умирает на старте уровня.
---
## 3. Полный список сообщений
### 3.1. Состояние программы
| Текст | Где | Таймер |
|-------|-----|--------|
| `GAME PAUSED` | seg000:1769, пока `is_paused` | **без таймера**: печатается на входе в паузу, `erase_bottom_text(1)` на выходе (seg000:1784) |
### 3.2. Уровень и оставшееся время (`show_level` / `show_time`, seg008)
| Текст | Условие | `total` |
|-------|---------|---------|
| `LEVEL %d` | `show_level()` при старте уровня; только `1..12` (`hide_level_number_from_level = 14`), не при `seamless`; уровень 13 показывается как **12** (`level_13_level_number`) | 24, дальше сразу `is_show_time = 1` |
| `%d MINUTES LEFT` | каждая минута, кратная 5, и каждая из последних 5 | 24 |
| `%d SECONDS LEFT` | последняя минута, раз в 12 тиков | 24 |
| `1 SECOND LEFT` | остался 1 с | **12** |
| `TIME HAS EXPIRED!` | `rem_min == 0` | 24 |
| `%d MINUTES PASSED` / `1 MINUTE PASSED` | только SDLPoP (`ALLOW_INFINITE_TIME`), при отрицательном таймере | 24 |
Что взводит `is_show_time` (все → следующий кадр печатает время):
* **Space** — seg000:612, штатная клавиша оригинала «сколько осталось»;
* читы **`-`/`+` numpad** (изменение времени) — seg000:762 / 777, при этом
таймеры сообщения обнуляются, чтобы новое напечаталось немедленно;
* **смерть Джафара**`on_guard_killed()` seg006:1936, уровень 13
(`jaffar_victory_level`): вспышка + показать время;
* истечение очередной минуты — seg008:1796;
* сразу после `show_level()`.
Обнуляет `is_show_time`: `play_kid()` при смерти (seg006:1365) и
`show_copyprot(1)` (seg000:2385).
### 3.3. Смерть Кида
| Текст | Где | `total` |
|-------|-----|---------|
| `Press Button to Continue` | `play_kid()` seg006:1383 — умер на обычном уровне | **288** (мигает, затем рестарт игры) |
| *(без текста)* | тот же код, но уровень 0 (демо) или 15 (зелья) | **36** (тихая пауза, затем рестарт игры) |
Стирается: `fell_out()` (seg006:1342, упал из комнаты 0) и чит **R**
(воскрешение, seg000:783) — оба зовут `erase_bottom_text(1)`.
### 3.4. Сохранение и загрузка
| Текст | Клавиша | `total` |
|-------|---------|---------|
| `GAME SAVED` / `UNABLE TO SAVE GAME` | Ctrl+G (`save_game`, seg000:2211) | `total` не ставится, `remaining = 24` |
| `QUICKSAVE` / `NO QUICKSAVE` | F6 (расширение SDLPoP, seg000:497) | 24 |
| `QUICKLOAD` / `NO QUICKLOAD` | F9 (расширение SDLPoP, seg000:514) | 24 |
### 3.5. Ответы на клавиши (`answer_text``need_show_text`, все `total = 24`)
| Текст | Клавиша |
|-------|---------|
| `SOUND ON` / `SOUND OFF` | Ctrl+S |
| `KEYBOARD MODE` | Ctrl+K |
| `JOYSTICK MODE` / `JOYSTICK NOT FOUND` / `JOYSTICK UNAVAILABLE` | Ctrl+J |
| `PRINCE OF PERSIA V1.0` (в SDLPoP заменено на `SDLPoP v%s`) | Ctrl+V |
| `SDL COMP v… LINK v…` | Ctrl+C — только SDLPoP |
### 3.6. Отладочные читы (`cheats_enabled`, `total = 24`)
| Текст | Клавиша | Смысл |
|-------|---------|-------|
| `S%d L%d R%d A%d B%d` | `C` | номер отрисованной комнаты и её соседей L/R/A/B |
| `AL%d AR%d BL%d BR%d` | Shift+`C` | диагональные соседи |
### 3.7. Защита от копирования (только уровень 15)
| Текст | Где | `total` |
|-------|-----|---------|
| `WORD %d LINE %d PAGE %d` | `show_copyprot(1)` seg000:2389 | **1188** — висит, пока не сменится уровень |
### 3.8. Только SDLPoP, в оригинале 1989 отсутствует
| Текст | Где |
|-------|-----|
| `RECORDING`, `REPLAY SAVED`, `REPLAY CANCELED` | replay.c:599/626/628 |
| имя файла скриншота | screenshot.c:62 |
---
## 4. Что из этого касается нашего порта
Реализовано (`pop_status.c`, таймер 24 тика как у оригинала):
* `GAME PAUSED` — без таймера, рисует само меню (`pop_menu.c`);
* `QUICKSAVE` / `NO QUICKSAVE`, `QUICKLOAD` / `NO QUICKLOAD` — заявка стоит
в `pop_qsave_process`, то есть в единственном месте, где известно, что
именно делали. Лейбл печатается ДО дисковой операции — осознанное
расхождение, см. `impl_diff.md`;
* `SOUND ON` / `SOUND OFF` — Ctrl+S;
* `LEVEL %d` — порт `show_level()` целиком: демо-уровень 0 и номера от 14
молчат, тринадцатый показывается двенадцатым, бесшовный переход 12→13
пропускается и гасит флаг за собой.
* вся группа времени — `N MINUTES LEFT`, `N SECONDS LEFT`, `1 SECOND LEFT`,
`TIME HAS EXPIRED!`. Флаг `pop_show_time` (порт `is_show_time`) взводит
само ядро таймера на круглых пятёрках и каждую секунду последней минуты,
а также старт уровня и читы времени; значение 2 означает «перебить
текущую строку», как оригинал делает в последнюю минуту;
* `Press Button to Continue` — висит бессрочно (`MSG_HOLD`), уровень
перезапускает кнопка. Расхождение с оригиналом, см. `impl_diff.md`.
Пока НЕ печатается:
* номера комнат (`C`/Shift+`C`) — у нас отдельная отладочная строка;
* copy protection и SDLPoP-расширения (replay, скриншоты) — не нужны.
Отладочная строка вдобавок показывает оставшееся время `##:##` у правого
края. На табло уходит `minutes-1`: у оригинала `rem_min` — это НОМЕР идущей
минуты, а не остаток целых (старт 60 при `rem_tick` 719 = «почти 60:00»).
Секунды считаются делением раз в 12 кадров, а не каждый кадр.
Нам не нужно: copy protection (уровень 15 исключён из порта — см.
`full_game_plan.md`), joystick-режимы, replay, скриншоты.
Механика, которую придётся портировать целиком, если брать группу времени:
пара таймеров `text_time_total`/`text_time_remaining` с семантикой
«идентификатор сообщения» — иначе не воспроизвести ни мигание, ни рестарт по
истечении 36/288.
---
## 5. Цена вывода и что делать, если упрёмся
Блит одного глифа стоит ~4,6 тыс. тактов почти независимо от размера — это
цена вызова, а не пикселей (memory `blit_cost_model`). Полсотни символов =
полкадра. Что уже сделано в `pop_status.c` / `pop_ui.c`:
* **change-driven**: пока показанное не изменилось, не рисуем вовсе;
* **по полям**: смена комнаты — две цифры (~9 тыс. тактов, 2% кадра), а не
вся строка; подписи рисуются только при полной инвалидации;
* **пробелы не блитятся**: их глиф целиком прозрачен, а стоит как буква —
на полной отладочной строке это девять сэкономленных блитов;
* **заливка только поля** при входе в комнату (`pop_screen_fill_field`):
борта от комнаты к комнате не меняются, это и четверть заливки, и то, что
обе полосы вход переживают.
Запас, если бюджета всё же не хватит (идеи пользователя, 2026-08-25):
1. **Растянуть вывод на несколько кадров, не показывая полуготовую строку.**
Печатать по нескольку букв за кадр, держа цвет шрифта чёрным (отдельный
индекс палитры), а по готовности подменить этот индекс на белый — строка
появится целиком и мгновенно. Стоит ноль байт памяти и укладывается в
нашу же технику «два разных чёрных» (`POP_COL_OUTSIDE`).
2. **Собирать строку в один спрайт** в свободном хвосте страницы шрифта и
блитить одним вызовом. Дороже по подготовке (~35 тыс. тактов на
копирование), но выгодно там, где строка ЦЕЛИКОМ меняется каждый раз.
Для меню этот путь уже рассматривался и был отвергнут; для статус-строк
он имеет смысл только вместе с п.1.
Про QuickSave/QuickLoad оптимизация не нужна вовсе: там игра и так стоит на
время дисковой операции.
## 6. Ловушка: свисающие глифы
Зона стирания текста обязана захватывать строку НИЖЕ базовой линии. В малом
шрифте `'p'` имеет высоту 7 при ascent 5, `','` — 6: они свисают под базовую
линию. Стирание ровно до неё оставляло от хвоста «p» в «Speed:» одинокую
точку (поймано в MAME 2026-08-25).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28 -15
View File
@@ -2,33 +2,46 @@
* Сгенерировано toolchain/pop_pack_bg.py НЕ править вручную.
*
* Прямая адресация (ноль remap-таблиц в W2):
* ENV фон id N -> atlas env_bg[N>>5], idx N&31
* ENV фон id N -> atlas env_bg[N>>4], idx N&15
* WALL id N -> atlas wall, idx N
* FORE id N -> atlas fore, idx N
*
* ДВА ТАЙЛСЕТА (порт tbl_envir_ki[tbl_level_type], seg000:1108):
* 0 = подземелье (pop_*), 1 = дворец (pal_*). Набор id и раскладка
* у них ОДНИ И ТЕ ЖЕ меняются только файлы и 32 записи палитры
* (env 0x50..0x5F, wall 0x60..0x6F), см. *tile.pal.
*/
#ifndef POP_BG_ATLAS_H
#define POP_BG_ATLAS_H
#define POP_ENV_SHIFT 5
#define POP_ENV_MASK 31
#define POP_ENV_PAGES 5
#define POP_ENV_SHIFT 4
#define POP_ENV_MASK 15
#define POP_ENV_PAGES 10
#define POP_TILESETS 2
/* Палитра: env-слоты, wall-слоты (сприйт-пиксель i -> база+i). */
/* Палитра: env-слоты, wall-слоты (спрайт-пиксель i -> база+i). */
#define POP_PAL_ENV 0x50
#define POP_PAL_WALL 0x60
/* Имена файлов атласов (грузятся atlas_load). */
static const char *const pop_env_atl[POP_ENV_PAGES] = {
"pop_env0.atl",
"pop_env1.atl",
"pop_env2.atl",
"pop_env3.atl",
"pop_env4.atl",
/* Имена файлов атласов по тайлсету (грузятся atlas_load). Таблицы
* видны только тому, кто попросил POP_BG_ATLAS_NAMES: иначе копия
* строк уедет в каждый включивший заголовок модуль. */
#ifdef POP_BG_ATLAS_NAMES
static const char *const pop_env_atl[POP_TILESETS][POP_ENV_PAGES] = {
{ "pop_env0.atl", "pop_env1.atl", "pop_env2.atl", "pop_env3.atl", "pop_env4.atl", "pop_env5.atl", "pop_env6.atl", "pop_env7.atl", "pop_env8.atl", "pop_env9.atl" },
{ "pal_env0.atl", "pal_env1.atl", "pal_env2.atl", "pal_env3.atl", "pal_env4.atl", "pal_env5.atl", "pal_env6.atl", "pal_env7.atl", "pal_env8.atl", "pal_env9.atl" },
};
#define POP_WALL_ATL "pop_wall.atl"
#define POP_FORE_ATL "pop_fore.atl"
#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья */
static const char *const pop_wall_atl[POP_TILESETS] = { "pop_wall.atl", "pal_wall.atl" };
static const char *const pop_fore_atl[POP_TILESETS] = { "pop_fore.atl", "pal_fore.atl" };
static const char *const pop_tile_pal[POP_TILESETS] = { "pop_tile.pal", "pal_tile.pal" };
#endif /* POP_BG_ATLAS_NAMES */
#define POP_POT_ATL "pop_pot.atl" /* chtab_1: зелья, от набора не зависит */
#define POP_PAL_POT 0x40
/* Пузырёк зелья: красный набор = id 16..22 (кадры оригинала),
зелёный (перо/переворот) и синий (вред/открыть) = те же кадры
под id 30..36 и 40..46 (draw_tile_anim, seg008:652). */
#define POP_POT_BUBB_GREEN 30
#define POP_POT_BUBB_BLUE 40
#define POP_BG_PAL "pop_bg.pal"
#endif
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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