Compare commits

...

57 Commits

Author SHA1 Message Date
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
71 changed files with 4491 additions and 375 deletions
+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 — да).
+91
View File
@@ -439,3 +439,94 @@ BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
**Что проверять при регрессе.** Уровень 13: `K` на Джафаре → белая вспышка, **Что проверять при регрессе.** Уровень 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`.
## Тень: кайма силуэта не подкрашивается фоном
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
прозрачным блитом в 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) или считать эту кайму прозрачной (силуэт станет
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
+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
блита) и модель зелёной фазы этой сцены.
+32
View File
@@ -237,3 +237,35 @@ memory `blit_cost_model`), а 16-битная арифметика в стеко
Существующая страховка уже есть и остаётся: `pop_blit_b` уводит кадр с Существующая страховка уже есть и остаётся: `pop_blit_b` уводит кадр с
`img[1] | img[3] != 0` на общий путь `blit_b_oversize`. `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) и остальных позиций от этого не меняется, но их
СРОЧНОСТЬ падает: они больше не спасают от скачка периода.
+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`.
+296
View File
@@ -0,0 +1,296 @@
# Звук в порте PoP — разбор и план
Дата: 2026-08-20. Статус: **разбор, кода нет.**
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
найдётся способ. Эффекты — **обязательно 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. Проверять запас надо ИМЕННО ТАМ.
## 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 (бас/мелодия/арпеджио), не трогая
ни данные, ни секвенсор.
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.
+68
View File
@@ -3337,3 +3337,71 @@ draw_tile_anim(); draw_tile_bottom(0); draw_loose(0);
`draw_tile_anim_right` — то есть анимированной правой грани СОСЕДА (пики, `draw_tile_anim_right` — то есть анимированной правой грани СОСЕДА (пики,
ворота, loose). Симптома на это пока не видели; если всплывёт «правая грань ворота, loose). Симптома на это пока не видели; если всплывёт «правая грань
соседа под персонажем/куском» — смотреть сюда. соседа под персонажем/куском» — смотреть сюда.
### Вариант B: порт корзины 30 (2026-08-18)
Реализовано то, что разобрано в коммите `272cf8f`. Кусок, у которого ряд
объекта вне 0..2 (`y_to_row_mod4` даёт −1 и «выше потолка», и «ниже пола»),
считается объектом ТАЙЛА 30 — а такие оригинал рисует ПЕРВЫМИ, до всего
обхода тайлов. Два следствия:
- `defer = 0` — кусок идёт под всем, включая Кида (раньше сравнение рядов
давало обратное: «−1 обходится последним» читалось как «поверх»);
- оверлею отдаётся ориентир **3** («раньше любого ряда обхода 2,1,0»)
вместо сырого −1, и в набор перекрываемых тайлов добавляется **своя
клетка** — у куска в обычном ряду её перерисовывать нельзя, там объект
вливается в midtable уже после частей своего тайла.
**Замер 13/23 (3032 кадра, зонды фаз + детализация зелёной):**
| секция | тег `mob-order-B-start` | после B | дельта |
|---|---:|---:|---:|
| работа | 888 984 | **913 848** | +24 864 |
| синяя | 159 804 | 159 810 | +6 |
| зелёная | 427 242 | **440 418** | +13 176 |
| циан | 378 864 | 393 000 | +14 136 |
Период кадра не изменился: 4 растра в 23 кадрах, 5 в двух — как и до правки.
**Зелёная вышла за растровый кадр** (440 418 против 430 000) при цели
400 000. Детализация показывает, где именно:
| участок зелёной | максимум |
|---|---:|
| `pop_loose_tick` целиком | 185 826 |
| — из них `pop_loose_mob_tick` | **168 180** |
| — циклы по тайлам | 23 736 |
| — `check_loose_fall_on_kid` | 5 868 |
| тробы + `pop_redraw_needed` | 337 800 |
| шов и прочее | 11 964 |
(максимумы взяты по разным кадрам, поэтому не складываются в общий).
Основной кандидат на возврат этих тактов — задача
[HEAL-WIDTH](TASKS_OPEN.md#heal-width): heal'ы кусков и плит берут ширину по
клеткам, а фактический след — 58/57.
### BUG-DBGSTART-LEVEL. Отладочный старт ломал переход между уровнями (закрыт 2026-08-20)
**Симптом** (найден пользователем на сборке `make LEVEL=6 ROOM=1 POS=18`):
Кид проваливается в комнате 1 шестого уровня, уровень меняется на 7-й — и
Кид появляется не там: приземляется в комнате 1 на 2,8 вместо того, чтобы
продолжать падение по своей колонке.
**Корень.** Отладочный старт (`DBG_START_ROOM`/`DBG_START_POS`,
`roomtest_cold.c`) подменял комнату и позицию БЕЗУСЛОВНО, а
`pop_start_level` зовётся не только на первом запуске, но и **на каждой
смене уровня**. В результате на 7-м уровне стартовой становилась
отладочная комната 1 вместо комнаты 17 из данных — и спецсобытие «вход
падением» (`set_start_pos`, seg003:0196: уровень 7, комната 17 ->
`goto_other_room(3)`) не срабатывало вовсе, потому что его условие
сравнивает именно `start_room`.
Само спецсобытие было портировано и работало; сломан был вход в него.
**Починка:** подмена применяется только пока `pop_current_level ==
FIRST_LEVEL`. Рестарт ТОГО ЖЕ уровня (смерть, чекпойнт) отладочную
позицию сохраняет — ради этого она и перебивает чекпойнт.
**Проверено в MAME:** после провала на 6-м `pop_current_level` = 7, Kid.room
= 16, то есть спецсобытие отработало (старт в 17, переход вниз).
+57
View File
@@ -23,6 +23,8 @@
| [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз | | [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз |
| [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | открыт: подозрение на клавиатурный модуль, не на физику | | [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | открыт: подозрение на клавиатурный модуль, не на физику |
| [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** | | [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** |
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
| [BUG-CORPSE-AIR](#bug-corpse-air) | труп Кида висит в воздухе, когда убившая его плита улетела вниз | физика | открыт |
| [LOOSE-ROOM-CHANGE](#loose-room-change) | расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату | порт | закрыт 2026-08-13, кроме [LOOSE-SEAM-ANIM](#loose-seam-anim) | | [LOOSE-ROOM-CHANGE](#loose-room-change) | расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату | порт | закрыт 2026-08-13, кроме [LOOSE-SEAM-ANIM](#loose-seam-anim) |
| [JUMP-FLOOR-LEGS](#jump-floor-legs) | в прыжке с пола ноги Кида рисуются ПОВЕРХ кромки пола, а должны за ней | окклюзия | **корень найден и починен, ждёт проверки в MAME** | | [JUMP-FLOOR-LEGS](#jump-floor-legs) | в прыжке с пола ноги Кида рисуются ПОВЕРХ кромки пола, а должны за ней | окклюзия | **корень найден и починен, ждёт проверки в MAME** |
| [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 | | [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 |
@@ -836,3 +838,58 @@ if (modif)
`draw_leveldoor`, либо запечка. Проверять на уровне 1 (подземелье, ширина `draw_leveldoor`, либо запечка. Проверять на уровне 1 (подземелье, ширина
39, сдвиг 2) И на уровне 4 (дворец, 48, сдвиг 0) — иначе поймаем только 39, сдвиг 2) И на уровне 4 (дворец, 48, сдвиг 0) — иначе поймаем только
половину. половину.
### <a id="bug-shadow-set"></a>BUG-SHADOW-SET. Тень в бою рисуется спрайтами стража
**Симптом** (найдено пользователем 2026-08-20): в бою Тень выглядит не
Кидом. В SDLPoP она Кид всегда.
**Корень.** Набор спрайтов выбирает не charid, а поле `sword` САМОГО
КАДРА: `load_frame_to_obj` (`seg008.c:1752`) держит `chtab_base` жёстко
равным `id_chtab_2_kid` и прибавляет `cur_frame.sword >> 6`. У всех 241
кадра `frame_table_kid` эти биты нулевые, у всех кадров `frame_tbl_guard`
`0xC0`. Тень берёт таблицу стража для кадров 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 там серая рампа под перекраску).
**У нас** `pop_guard_load` (`pop_cdraw.c:98`) разбирает только типы 2
(скелет) и 3 (визирь), а всё остальное — включая тип 4 — уводит в
`guard_names`. SHADOW.DAT в ассетах отсутствует, каталога нет, в
Makefile правила нет.
**Починка** — часть работы по атласу Тени, см.
[`../docs/shadow_atlas_plan.md`](../docs/shadow_atlas_plan.md) Ш0/Ш1:
довезти SHADOW.DAT и запечь его во вторую половину атласа Тени. Отдельно
чинить смысла нет: как только Тень поедет своим атласом, ветка выбора
набора для типа 4 исчезнет вместе с проблемой.
**Оговорка.** `pop_guard_set_palette` для типа 4 звать НЕЛЬЗЯ — она
затрёт палитру Тени палитрой стража (`curr_guard_color` у не-стражей = 0,
`seg002:183`).
### <a id="bug-corpse-air"></a>BUG-CORPSE-AIR. Труп висит в воздухе после падения плиты
**Симптом** (найдено пользователем 2026-08-20, ур. 12 к. 15, старт с
`POS=15`): Кид встал на проваливающийся пол (0,6), плита обрушилась и
убила его, но ТЕЛО осталось лежать на уровне исчезнувшего пола, а плита
улетела дальше вниз. Труп висит в пустоте.
**Где смотреть.** Кадр смерти — `actions_5_bumped` (см. seq в
`kid_data`), а гравитация в `check_action` (seg006:05CD) для действия 5
включается только на кадрах приседа: у оригинала есть отдельные фиксы
`FIX_DEAD_FLOATING_IN_AIR` (кадры 177..185) именно про этот случай —
значит **в оригинале 1989 года труп в воздухе тоже висит**, и SDLPoP
чинит это опциональным фиксом, по умолчанию выключенным.
**Отсюда вопрос к решению**, а не сразу правка: чинить ли вообще. Если
чинить — портировать `fix_dead_floating_in_air` и записать как
осознанное расхождение в `../docs/impl_diff.md` (мы уже отказались от
двух других опциональных фиксов SDLPoP в `bump_into_opponent`).
**Приоритет низкий:** ситуация возникает только когда труп остаётся на
проваливающемся полу, играбельности не мешает.
+26 -4
View File
@@ -35,7 +35,7 @@ MEMORY ?= huge # small-раскладка + банки кода в W
# ВАЖНО: любое сравнение занятости банков имеет смысл только при ОДНОМ и том # ВАЖНО: любое сравнение занятости банков имеет смысл только при ОДНОМ и том
# же ALLOCS — иначе сравниваются не правки, а уровни оптимизации. # же ALLOCS — иначе сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 3000 ALLOCS ?= 3000
EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 4=pop_kdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c --bank 7=pop_redraw.c --bank 8=roomtest_cold.c --bank 8=pop_level_cold.c --bank 8=pop_kboot.c --bank 8=pop_guard_cold.c $(PROF_FLAGS) EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 4=pop_kdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c --bank 7=pop_redraw.c --bank 8=roomtest_cold.c --bank 8=pop_level_cold.c --bank 8=pop_kboot.c --bank 8=pop_guard_cold.c --bank 8=pop_shadow.c $(PROF_FLAGS)
# Профилирование полосами бордюра (см. PROF() в roomtest.c) — ВКЛЮЧЕНО по # Профилирование полосами бордюра (см. PROF() в roomtest.c) — ВКЛЮЧЕНО по
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает. # умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте # Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
@@ -45,9 +45,19 @@ PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого. # Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого.
# Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1. # Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1.
# Константа объявлена в pop_tune.h (её видят обе половины главного цикла). # Константа объявлена в pop_tune.h (её видят обе половины главного цикла).
LEVEL ?= 9 LEVEL ?= 11
PROF_FLAGS += -DFIRST_LEVEL=$(LEVEL) PROF_FLAGS += -DFIRST_LEVEL=$(LEVEL)
EXTRA_SRCS := pop_vflip.c pop_state.c pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c # Стартовая КОМНАТА и позиция Кида в ней (отладка): make ROOM=15 POS=2 —
# начать прямо в целевой комнате оптимизации, минуя проход уровня. POS —
# тайл 0..29, то есть row*10+col. Дефолт — сцена 11/15 (Кид в 0,2), по
# которой сняты замеры в docs/perf_l11_room15.md. make ROOM= (пусто)
# возвращает штатный старт из данных уровня.
ROOM ?= 15
POS ?= 2
ifneq ($(strip $(ROOM)),)
PROF_FLAGS += -DDBG_START_ROOM=$(ROOM) -DDBG_START_POS=$(POS)
endif
EXTRA_SRCS := pop_vflip.c pop_pace.c pop_state.c pop_draw.c pop_tile.c pop_kid.c pop_level.c pop_geom.c pop_guard.c
BG_DIR := $(CURDIR)/../poc/res/bg BG_DIR := $(CURDIR)/../poc/res/bg
KID_DIR := $(CURDIR)/../poc/res/kid KID_DIR := $(CURDIR)/../poc/res/kid
@@ -102,8 +112,16 @@ VIZIER_DIR := $(CURDIR)/../poc/res/vizier
VIZIER_DATA := $(VIZIER_DIR)/g0.atl $(VIZIER_DIR)/g1.atl $(VIZIER_DIR)/g2.atl \ VIZIER_DATA := $(VIZIER_DIR)/g0.atl $(VIZIER_DIR)/g1.atl $(VIZIER_DIR)/g2.atl \
$(VIZIER_DIR)/g3.atl $(VIZIER_DIR)/g4.atl $(VIZIER_DIR)/g3.atl $(VIZIER_DIR)/g4.atl
# ТЕНЬ — свой набор, запечённый упаковщиком (наложение спрайта на себя со
# сдвигом; наш блит так не умеет, см. ../docs/shadow_atlas_plan.md). Две
# половины в одном наборе: sk* — кадры вне боя (спрайты Кида), sf* — кадры
# 150..189 (SHADOW.DAT, это тоже графика Кида, а не стража).
SHADOW_DIR := $(CURDIR)/../poc/res/shadow
SHADOW_DATA := $(foreach n,0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27,$(SHADOW_DIR)/sk$(n).atl) \
$(foreach n,0 1 2 3,$(SHADOW_DIR)/sf$(n).atl)
EXTRA_DATA := $(BG_DATA) $(KID_DATA) $(KID_BIN) $(GUARD_DATA) $(SKEL_DATA) \ EXTRA_DATA := $(BG_DATA) $(KID_DATA) $(KID_BIN) $(GUARD_DATA) $(SKEL_DATA) \
$(VIZIER_DATA) $(LVL_DATA) $(VIZIER_DATA) $(SHADOW_DATA) $(LVL_DATA)
include $(PROJ_ROOT)/app.mk include $(PROJ_ROOT)/app.mk
# Образ HDD для MAME: ресурсы раскладываются ПО КАТАЛОГАМ (8.3, как в DSS), # Образ HDD для MAME: ресурсы раскладываются ПО КАТАЛОГАМ (8.3, как в DSS),
@@ -120,6 +138,7 @@ hdd: $(EXAMPLE).exe
$(foreach f,$(GUARD_DATA),GUARD:$(f)) \ $(foreach f,$(GUARD_DATA),GUARD:$(f)) \
$(foreach f,$(SKEL_DATA),SKEL:$(f)) \ $(foreach f,$(SKEL_DATA),SKEL:$(f)) \
$(foreach f,$(VIZIER_DATA),VIZIER:$(f)) \ $(foreach f,$(VIZIER_DATA),VIZIER:$(f)) \
$(foreach f,$(SHADOW_DATA),SHADOW:$(f)) \
$(foreach f,$(LVL_DATA),LEVELS:$(f)) $(foreach f,$(LVL_DATA),LEVELS:$(f))
TC := $(PROJ_ROOT)/applications/PoP/toolchain TC := $(PROJ_ROOT)/applications/PoP/toolchain
@@ -134,6 +153,9 @@ $(SKEL_DATA): $(TC)/pop_pack_guard.py
$(VIZIER_DATA): $(TC)/pop_pack_guard.py $(VIZIER_DATA): $(TC)/pop_pack_guard.py
cd $(TC) && python3 pop_pack_guard.py VIZIER cd $(TC) && python3 pop_pack_guard.py VIZIER
$(SHADOW_DATA) pop_shadow_atlas.h: $(TC)/pop_pack_shadow.py $(TC)/pop_pack_bg.py $(TC)/pop_pack_kid.py
cd $(TC) && python3 pop_pack_shadow.py
$(KID_DATA): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py $(KID_DATA): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py
cd $(TC) && python3 pop_pack_kid.py cd $(TC) && python3 pop_pack_kid.py
kid_data.h $(KID_BIN): $(TC)/pop_extract_kid_data.py kid_data.h $(KID_BIN): $(TC)/pop_extract_kid_data.py
+1 -1
View File
@@ -55,7 +55,7 @@ ESC выход.
## Статус (2026-08-18) ## Статус (2026-08-18)
**Уровни 1-3 приняты, 4-9 прошли предварительный тест.** Работает: комнаты и **Уровни 1-3 приняты, 4-12 прошли предварительный тест.** Работает: комнаты и
переходы, Kid (анимация, управление, коллизия/падение, зацеп/подтягивание/ переходы, Kid (анимация, управление, коллизия/падение, зацеп/подтягивание/
спуск, окклюзия), кнопки и ворота, пики, чомперы, проваливающиеся полы, дверь спуск, окклюзия), кнопки и ворота, пики, чомперы, проваливающиеся полы, дверь
уровня и переход на следующий, меч и бой, стражи с ИИ (включая скелета и уровня и переход на следующий, меч и бой, стражи с ИИ (включая скелета и
+149 -3
View File
@@ -44,7 +44,10 @@
| 7 | **предварительно пройден** (2026-08-18) | [SPIKE-BAKED](BUGS_CLOSED.md#spike-baked) — выдвинутые пики консервировались в фон запечкой соседней кнопки и оставались навсегда (`980d48c`); [DIED-ON-BUTTON](BUGS_CLOSED.md#died-on-button) — смерть на кнопке не ломала её насовсем: до `check_press` труп не доходил, самой `died_on_button` не было, а таймер связи переживал респавн (`6feab5d`) | | 7 | **предварительно пройден** (2026-08-18) | [SPIKE-BAKED](BUGS_CLOSED.md#spike-baked) — выдвинутые пики консервировались в фон запечкой соседней кнопки и оставались навсегда (`980d48c`); [DIED-ON-BUTTON](BUGS_CLOSED.md#died-on-button) — смерть на кнопке не ломала её насовсем: до `check_press` труп не доходил, самой `died_on_button` не было, а таймер связи переживал респавн (`6feab5d`) |
| 8 | **предварительно пройден** (2026-08-18) | [GUARD-RESPAWN-COL0](BUGS_CLOSED.md#guard-respawn-col0) — при возврате в комнату страж телепортировался в колонку 0 и падал насмерть: колонку несёт `guards_x`, а не тайл (`c40ae3f`); [SEAM-FIGHT-FLICKER](BUGS_CLOSED.md#seam-fight-flicker) — бой у шва перерисовывал комнату туда-обратно: `leave_room` запрещает уход ещё и на кадрах меча (`a498255`, сверено с SDLPoP покадрово) | | 8 | **предварительно пройден** (2026-08-18) | [GUARD-RESPAWN-COL0](BUGS_CLOSED.md#guard-respawn-col0) — при возврате в комнату страж телепортировался в колонку 0 и падал насмерть: колонку несёт `guards_x`, а не тайл (`c40ae3f`); [SEAM-FIGHT-FLICKER](BUGS_CLOSED.md#seam-fight-flicker) — бой у шва перерисовывал комнату туда-обратно: `leave_room` запрещает уход ещё и на кадрах меча (`a498255`, сверено с SDLPoP покадрово) |
| 9 | **предварительно пройден** (2026-08-18) | багов не найдено | | 9 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 10+ | не начат | | | 10 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 11 | **предварительно пройден** (2026-08-18) | [MOB-NEIGHBOUR-ROOM](BUGS_CLOSED.md#mob-neighbour-room) — плиты нижнего ряда пропадали без кадров падения (кусок в соседней комнате не рисовался, `ed5615a`); порядок куска относительно соседней плиты и Кида — порт корзины 30 (`4db60c7`) |
| 12 | **предварительно пройден** (2026-08-18) | багов не найдено |
| 13+ | не начат | |
«Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по «Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по
сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности
@@ -227,8 +230,10 @@ fore-слоя, и клип (`clip_char`), и брызги; полоса HP и л
| # | Задача | Что | Блокирует | | # | Задача | Что | Блокирует |
|---|--------|-----|-----------| |---|--------|-----|-----------|
| — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3**. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях | | — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3**. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
| | [L1-SPEED](#l1-speed) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент | | | [L1-SPEED](#l1-speed) | **закрыта 2026-08-19** режимами скорости (NORMAL = 4/5 растров, как оригинал) | — |
| — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности | | — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности |
| — | [TUNE-2](#tune-2) | параметры СТРАЖЕЙ (6 таблиц по 12 градаций мастерства + HP) → cfg-файл | подбор сложности боя без пересборки |
| — | [SND](#snd) | звук: эффекты через CBL, музыка на AY | разбор готов — `../docs/sound_plan.md` |
| — | [MEM](#mem-next) | следующий шаг разгрузки W1/W2 | берётся по факту нехватки места | | — | [MEM](#mem-next) | следующий шаг разгрузки W1/W2 | берётся по факту нехватки места |
Сделанное — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md): L4-MIRROR, L3-CHOMP, Сделанное — в [`TASKS_CLOSED.md`](TASKS_CLOSED.md): L4-MIRROR, L3-CHOMP,
@@ -258,6 +263,64 @@ MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
## P0 — делаем сейчас ## P0 — делаем сейчас
### <a id="char-partial-redraw"></a>CHAR-PARTIAL-REDRAW. Неподвижный персонаж перерисовывается зря: метка «фон трогали» слишком груба — ОБЯЗАТЕЛЬНО
**Постановка (пользователь, 2026-08-19).** Проверять, нужна ли отрисовка
стража, когда он НЕ ДВИГАЕТСЯ. Если движется — лишние ~150 000 тактов
приемлемы: в оригинале во время боя число физических кадров на логический
тоже растёт на единицу.
**Измерено** (сцена 11/15, чтение `pop_cd` из памяти машины):
| объект | x | y |
|---|---|---|
| страж, спрайт | 257..284 | 18..56 |
| страж, клинок (накладной) | 241..261 | 31..37 |
| пламя факела (колонка 6 → рисуется в ячейке 7) | 232..247 | 5..22 |
**Физического перекрытия НЕТ.** По x клинок и пламя пересекаются
(241..247), но по y расходятся: пламя кончается на 22, клинок начинается с
31 — девять пикселей чистого зазора. Пользователь увидел это на экране
раньше, чем я в числах: «страж полностью правее пламени, только меч может
пересекаться».
**Почему тогда он перерисовывается.** Метка «фон трогали» (`pop_cd_touch`
в `pop_tile.c`) хранится как битовая маска КОЛОНОК по 32 px, отдельно на
каждый из ТРЁХ рядов по 63 px (`cd_row_of`). Пламя и клинок попадают в
один ряд 0 и в одну колонку 7 — и `cd_quiet` считает слот задетым.
Расплата: **148 302 такта, 23 % работы кадра** (85 524 спрайт с клинком и
снимком + 62 778 fore-проход) за перекрытие, которого нет.
**Решение — поднять вертикальную точность метки.** Вместо «маска колонок ×
3 ряда» хранить на каждую колонку ДИАПАЗОН y (`ymin`/`ymax`): 10 колонок ×
2 байта × 2 страницы = 40 байт. `pop_cd_touch` расширяет диапазон
затронутых колонок, `pop_cd_hit` проверяет пересечение диапазонов.
Проверка решения на обоих случаях сцены:
- **страж:** клинок в колонках 7-8. В колонке 7 у метки лежит y 5..22
(пламя), у клинка 31..37 — не пересекаются, колонку 8 пламя не трогало.
Слот остаётся «тихим» → экономия 148 302;
- **Кид в тяжёлой позиции:** пламя левого факела y 5..22, Кид y 15..55 —
пересечение НАСТОЯЩЕЕ, и он честно перерисовывается. Так и должно быть.
Вариант с 8-пиксельными полосами вместо диапазонов тоже работает
(24 полосы), но 16-пиксельные УЖЕ НЕТ: пламя и клинок снова слипаются в
одной полосе. Диапазон на колонку точнее и не требует битовой возни.
**Чего делать НЕ надо** (проверено расчётом, не повторять): частичную
перерисовку персонажа по пересечению и обрезку фона под ним. Обе правки
решают задачу, которой нет — перекрытия не существует.
**Проверка:** замер 11/15 в обеих позициях Кида (лёгкая x = 99, тяжёлая
x = 106; разбор — [`../docs/perf_l11_room15.md`](../docs/perf_l11_room15.md)
§7), плюс визуальный прогон боя и прохода Кида под факелами: персонаж не
должен оставлять хвостов и не должен просвечивать сквозь пламя.
**Место в очереди — [`../docs/perf_registry.md`](../docs/perf_registry.md),
позиция P15.** Смежное: P14 (fore-проход — вторая половина той же цены).
### <a id="heal-width"></a>HEAL-WIDTH. Ширина точечных heal'ов не совпадает со следом перерисовки — ОБЯЗАТЕЛЬНО ### <a id="heal-width"></a>HEAL-WIDTH. Ширина точечных heal'ов не совпадает со следом перерисовки — ОБЯЗАТЕЛЬНО
**Постановка (пользователь, 2026-08-18).** Ширины heal'ов взяты «на глаз по **Постановка (пользователь, 2026-08-18).** Ширины heal'ов взяты «на глаз по
@@ -303,6 +366,18 @@ MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
прогон комнат с плитами, пиками и чомперами: сужение heal'а — самый прямой прогон комнат с плитами, пиками и чомперами: сужение heal'а — самый прямой
способ получить «недочищенный хвост». способ получить «недочищенный хвост».
**Родня — [G8](../docs/perf_green_phase.md) (пометка соседа узкой полосой).**
Это две половины одной темы, и брать их логично вместе: G8 про ширину
ЗАПЕЧКИ соседнего тайла (60 вместо 28 нужных, да ещё `draw_tile` соседа
дважды на пометку), HEAL-WIDTH — про ширину HEAL'ов (64 вместо 58/57).
Числа там уже разобраны: 60 = 32 свой тайл + 28 собственный свес, и для
запечки САМОГО тайла 60 минимальны — сужать можно только пометку СОСЕДА.
**Место в общей очереди — [`../docs/perf_registry.md`](../docs/perf_registry.md)
(позиция P8).** Там же собраны ВСЕ отложенные оптимизации из трёх фазовых
доков, отсортированные по измеренному эффекту, и разложена цена одного блита
(6 126 тактов фиксированной накладной на любой блит, независимо от размера).
### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: встреча с тенью, бой, слияние ### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: встреча с тенью, бой, слияние
@@ -1047,7 +1122,17 @@ F6/F9). Значит повторяем не букву, а устройств
**Критерий приёмки:** сохранить, выйти по `ESC`, запустить roomtest заново, **Критерий приёмки:** сохранить, выйти по `ESC`, запустить roomtest заново,
загрузить — и оказаться там же. загрузить — и оказаться там же.
### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) ### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) — ЗАКРЫТА 2026-08-19
**Закрыта** режимами скорости (`pop_pace.h`, клавиша **P**): FASTEST 3/3,
FAST 3/4, **NORMAL 4/5 = 81,9 / 102,4 мс** против 83,3 / 100 мс у
оригинала. Условие боя взято буквально из SDLPoP (`seg003.c:363`):
`Kid.sword == SWORD_2_DRAWN`. Дефолт — FASTEST (решение пользователя).
Проверено в MAME: период ровно 4 и ровно 5 растров. Разбор и замеры —
`../docs/frame_pacing_plan.md`. Осталось из исходной формулировки только
сверить NORMAL секундомером рядом с живым SDLPoP.
Исходная запись:
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
@@ -1085,6 +1170,67 @@ HP/минуты, номера «особых» комнат и уровней (
**Оговорка по памяти.** Парсер и таблица параметров — холодный код, **Оговорка по памяти.** Парсер и таблица параметров — холодный код,
исполняется один раз при старте: кандидат в банк, а не в резидент W1. исполняется один раз при старте: кандидат в банк, а не в резидент W1.
### <a id="tune-2"></a>TUNE-2. Параметры СТРАЖЕЙ — в CFG-файл
Частный, но самый востребованный срез [TUNE-1](#tune-1): сейчас весь ИИ
стража зашит константами, и чтобы попробовать «а если он будет блокировать
пореже» — надо пересобирать.
**Что настраивать.** Всё, что индексируется мастерством (12 градаций,
`NUM_GUARD_SKILLS`), — таблицы в `guards.c`:
| таблица | что решает | дефолт для навыка 4 |
|---|---|---:|
| `STRIKEPROB` | вероятность удара | 61 |
| `RESTRIKEPROB` | добить из своего парирования | 5 |
| `BLOCKPROB` | заблокировать замах | 200 |
| `IMPBLOCKPROB` | то же, когда Кид только что пробил блок | 100 |
| `ADVPROB` | наступать | 255 |
| `REFRACTIMER` | «отдышка» после ранения, кадров | 8 |
| `extrastrength` | надбавка HP (сейчас зашита как `skill == 4 ? 1 : 0`, `pop_guard_cold.c`) | 1 |
Плюс не зависящее от навыка: `tbl_guard_hp[16]` (`pop_level_cold.c`) — база
HP по уровню, и навык скелета (`SKEL_SKILL`) / тени (`SHADOW_SKILL`).
**Формат — как у SDLPoP**, чтобы правки переносились в обе стороны без
перевода: секции `[Skill 0]`..`[Skill 11]`, внутри `strikeprob=`,
`blockprob=` и т.д. (`options.c:439`, пример — `SDLPoP/SDLPoP.ini:562`).
Имена ключей брать ЕГО, регистр игнорировать.
**Чего НЕ делать.** Навык конкретного стража живёт в данных уровня
(`level.guards_skill[комната-1]`, читаем в `pop_level_guard`) — его в CFG
не тащим, иначе разъедется с файлами уровней. Переопределение «в комнате
N навык M» — отдельная надобность, если понадобится, то как явная секция
`[Level N]`, а не подмена данных.
**Зачем это стоит сделать.** Прогон 2026-08-20 (11/22, навык 4) дал
ощущение «страж тяжелее, чем в SDLPoP». Сверка кода расхождений не нашла
(протокол — в `../docs/impl_diff.md` и в ответе сессии: дистанция,
диспетчер, три ветки ИИ, шесть таблиц, `justblocked`, `check_hurting`,
`parry`, `swordfight`, порядок тиков, откат seq_74 = −10 — всё совпало
побайтно). Проверить такую гипотезу можно только подбором чисел на живом
прогоне, а для этого их надо крутить без пересборки.
**Оговорка по памяти** — та же, что в TUNE-1: парсер холодный, в банк.
### <a id="snd"></a>SND. Звук — эффекты через CBL, музыка на AY
Полный разбор с замерами — [`../docs/sound_plan.md`](../docs/sound_plan.md).
Коротко, почему это оказалось дешевле, чем выглядело:
- эффекты уже лежат **8-битным PCM на 11 000 Гц**, а у CBL есть режим
10 937,5 Гц (расхождение 0,6 %) и ровно тот же формат сэмпла —
**играются как есть, без конверсии**; 31 звук, 103 941 Б ≈ 6,3
EMM-страницы;
- музыка есть **нотами PC-спикера, 7 КБ на всю игру**, причём нота задана
прямо в ГЕРЦАХ — пересчёт в период AY это одно деление; MIDI разбирать
не нужно;
- AY и COVOX сведены в один ЦАП, поэтому играют одновременно;
- отдельный таймер не нужен: секвенсор музыки двигает CBL-callback
(11,7 мс), а короче 12 мс во всей музыке всего 2 ноты из 1469.
Фазы З1..З5 и список «проверить артефактом до кодинга» — в доке.
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2 ### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча `pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
+61 -65
View File
@@ -56,39 +56,16 @@ static uint8_t tile_is_floor(uint8_t t)
} }
} }
/* Тайл в ряду Кида по X-координате персонажа (порт get_tile_at_kid, /* get_tile_at_kid (seg003:761) СНЯТ вместе с переходом луча на колонки:
* seg003:761): xpos 7 делится на ширину тайла с округлением вниз. */ * единственным его вызывающим был этот луч, а он больше не работает с
static int8_t tile_col; /* колонка последнего tile_at_kid */ * X-координатами (разбор в теле pop_check_can_guard_see_kid ниже).
* Таблица POP_TILE_DIV осталась в резиденте, ею пользуется физика. */
static uint8_t tile_at_kid(int16_t xpos) /* Буфер тайлов луча — file-scope, а не локальный массив: локальный уезжает
{ * в стековый кадр, и каждое чтение из него стоит `-n(ix)` (19 тактов
/* get_tile_div_mod_m7 (seg006:697): колонка = floor((xpos 7 58)/14), * номинала, ~46 с wait-state'ами). Тот же приём, что у draw_tile и
* СРАЗУ в координатах комнаты (58 = x_bump[FIRST_ONSCREEN_COLUMN]). * pop_blit_b. Размер колонки 1..10 плюс запас. */
* Оригинал берёт её из tile_div_tbl и мы теперь тоже: резидентная static uint8_t row_t[12];
* POP_TILE_DIV[] это ровно она (индекс = xpos, значение = floor((xpos58)/14)),
* поэтому здесь индексируем её на xpos7.
*
* Было честное `/` и `%`: у SDCC z80 это __divsint плюс __modsint, а тот
* внутри снова зовёт __divsint около 5 400 тактов на вызов. А зовут
* нас В ЦИКЛЕ по колонкам между стражем и Кидом
* (check_can_guard_see_kid, seg003:761). Когда Кид у шва, его
* curr_col = 1, луч тянется через всю комнату, и за кадр набегало ВОСЕМЬ
* пар делений ~43 000 тактов, 10 % растрового кадра, в фазе логики
* (замер MAME 2026-08-10, уровень 1 комната 2).
*
* Медленный хвост остаётся для xpos за пределами таблицы (страж и Кид в
* разных комнатах: логическая X уезжает на ±140). */
int16_t x = xpos - 7;
if ((uint16_t)x < 256u) {
tile_col = POP_TILE_DIV[x];
} else {
int16_t v = x - SCREENSPACE_X;
int16_t col = v / TILE_SIZEX;
if (v % TILE_SIZEX < 0) --col;
tile_col = (int8_t)col;
}
return pop_tile_at(tile_col, Kid.curr_row);
}
/* check_can_guard_see_kid (seg003:688) — луч видимости по ряду. /* check_can_guard_see_kid (seg003:688) — луч видимости по ряду.
* Результат в can_guard_see_kid: 0 не видит / 1 видит, но не пойдёт / * Результат в can_guard_see_kid: 0 не видит / 1 видит, но не пойдёт /
@@ -96,7 +73,6 @@ static uint8_t tile_at_kid(int16_t xpos)
void pop_check_can_guard_see_kid(void) __banked void pop_check_can_guard_see_kid(void) __banked
{ {
uint8_t kid_frame = Kid.frame; uint8_t kid_frame = Kid.frame;
int16_t left_pos, right_pos;
uint8_t tile; uint8_t tile;
/* МЫШЬ Кида «не видит» (seg003:0965): иначе он выхватит меч на зверька, /* МЫШЬ Кида «не видит» (seg003:0965): иначе он выхватит меч на зверька,
@@ -122,34 +98,53 @@ void pop_check_can_guard_see_kid(void) __banked
} }
can_guard_see_kid = 2; can_guard_see_kid = 2;
left_pos = pop_x_bump[Kid.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX; /* ПО КОЛОНКАМ, а не по X-координатам.
right_pos = pop_x_bump[Guard.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX; *
if (left_pos > right_pos) { * Оригинал (и SDLPoP, seg003:688) идёт по x с шагом TILE_SIZEX и на
int16_t t = left_pos; left_pos = right_pos; right_pos = t; * каждом шаге переводит x в колонку потому что x у него уже под рукой.
} * Но начальные x это РОВНО центры тайлов персонажей
/* Чомпер стоит у ЛЕВОГО края тайла — свой тайл он не перекрывает. */ * (x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX), а перевод обратно
if (tile_at_kid(left_pos) == TILE_CHOMPER) * даёт ту же колонку: floor((58 + col*14 - 58) / 14) == col. Значит
left_pos += TILE_SIZEX; * весь проход эквивалентен обходу колонок, и ни одна x-координата тут не
/* Ворота — у ПРАВОГО края, поэтому крайний правый тайл не считается. */ * нужна: уходят 16-битный шаг, 16-битное сравнение и индексация таблицы
if (tile_at_kid(right_pos) == TILE_GATE) * POP_TILE_DIV на каждой итерации.
right_pos -= TILE_SIZEX; *
* Тайлы отрезка забираем ОДНИМ банковым вызовом. Раньше на каждую
* колонку шёл свой pop_tile_at (__banked, банк 1 -> банк 3): на сцене
* 11/15 это девять трамплинов и 36 786 тактов на весь луч (замер
* 2026-08-19). */
{
int8_t lcol = Kid.curr_col, rcol = Guard.curr_col, col, lcol0;
while (left_pos <= right_pos) { if (lcol > rcol) { int8_t t = lcol; lcol = rcol; rcol = t; }
tile = tile_at_kid(left_pos); lcol0 = lcol; /* БАЗА индексации буфера — фиксируем
/* Сквозь это не видно вовсе. */ * ДО сдвигов ниже: row_t[0] это
if (tile == TILE_WALL || tile == TILE_DOORTOP_FLOOR || tile == TILE_DOORTOP) { * тайл lcol0, и сдвиг lcol/rcol на
can_guard_see_kid = 0; * чомпере и воротах её менять не
return; * должен. */
pop_row_tiles(Kid.curr_row, lcol, rcol, row_t);
/* Чомпер стоит у ЛЕВОГО края тайла — свой тайл он не перекрывает. */
if (row_t[0] == TILE_CHOMPER) lcol++;
/* Ворота — у ПРАВОГО края, поэтому крайний правый тайл не считается. */
if (row_t[rcol - lcol0] == TILE_GATE) rcol--;
for (col = lcol; col <= rcol; col++) {
tile = row_t[col - lcol0];
/* Сквозь это не видно вовсе. */
if (tile == TILE_WALL || tile == TILE_DOORTOP_FLOOR || tile == TILE_DOORTOP) {
can_guard_see_kid = 0;
return;
}
/* Видно, но страж не пойдёт: провалится / попадёт под чомпер /
* упрётся в неоткрытые ворота / шагнёт в дыру. */
if (tile == TILE_LOOSE || tile == TILE_CHOMPER || !tile_is_floor(tile)) {
can_guard_see_kid = 1;
} else if (tile == TILE_GATE &&
pop_gate_modif(col, Kid.curr_row) < 112) {
can_guard_see_kid = 1; /* ворота подняты не до конца */
}
} }
/* Видно, но страж не пойдёт: провалится / попадёт под чомпер /
* упрётся в неоткрытые ворота / шагнёт в дыру. */
if (tile == TILE_LOOSE || tile == TILE_CHOMPER || !tile_is_floor(tile)) {
can_guard_see_kid = 1;
} else if (tile == TILE_GATE &&
pop_gate_modif(tile_col, Kid.curr_row) < 112) {
can_guard_see_kid = 1; /* ворота подняты не до конца */
}
left_pos += TILE_SIZEX;
} }
} }
@@ -978,14 +973,15 @@ static void hurt_by_sword(void)
* нет ни одной управление зависало намертво: Кид стоял в боевой * нет ни одной управление зависало намертво: Кид стоял в боевой
* стойке без меча и не разворачивался к сопернику (BUG-CHEAT-IMM-1). * стойке без меча и не разворачивался к сопернику (BUG-CHEAT-IMM-1).
* *
* Ветку глушим на время вызова, а не правим pop_take_hp: тот общий * Костыля «снять бессмертие на время вызова» здесь БОЛЬШЕ НЕТ:
* с физикой (падения/пики/чомперы), и там бессмертие обязано * pop_take_hp гасит только урон меньше 100, а тут ровно 100. */
* работать при любом положении меча. */
uint8_t imm = pop_immortal;
pop_immortal = 0;
pop_take_hp(100); pop_take_hp(100);
pop_immortal = imm;
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
} else if (Char.charid == CHARID_0_KID && pop_immortal) {
/* ЧИТ, уровень 1 и выше: в боевой стойке удары не отнимают HP.
* Кадр «получил удар» оставляем иначе бой перестаёт читаться,
* да и оригинал на выживший удар ставит ровно его. */
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
} else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) { } else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) {
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */ pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */
} else { } else {
+25
View File
@@ -14,6 +14,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include "pop_bg.h" #include "pop_bg.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "_pop_bg.h" /* контракт с холодной половиной (pop_room.c, банк 7) */ #include "_pop_bg.h" /* контракт с холодной половиной (pop_room.c, банк 7) */
#include "pop_tile.h" #include "pop_tile.h"
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */ /* общие листья слоя фона (резидент): блит, тайлы, таблица */ #include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */ /* общие листья слоя фона (резидент): блит, тайлы, таблица */
@@ -57,6 +58,7 @@ static void ov_mark(int x, int y, int w, int h)
void pop_fore_heal(void) __banked void pop_fore_heal(void) __banked
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t pg = gfx_get_draw_page() & 1; uint8_t pg = gfx_get_draw_page() & 1;
if (!(ov_pending & (1 << pg))) return; if (!(ov_pending & (1 << pg))) return;
pop_heal_off(ov_x0, ov_y0, ov_x1 - ov_x0, ov_y1 - ov_y0); pop_heal_off(ov_x0, ov_y0, ov_x1 - ov_x0, ov_y1 - ov_y0);
@@ -197,6 +199,7 @@ static const uint8_t WR_RULE[4][6] = {
static void wall_rnd(int row, int col) static void wall_rnd(int row, int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Кэшируем только штатную сетку: колонка вне 0..9 дала бы индекс /* Кэшируем только штатную сетку: колонка вне 0..9 дала бы индекс
* соседнего РЯДА и чужую раскладку (тихий баг вместо промаха). */ * соседнего РЯДА и чужую раскладку (тихий баг вместо промаха). */
int idx = (col >= 0 && col < 10) ? (row + 1) * 10 + col : -1; int idx = (col >= 0 && col < 10) ? (row + 1) * 10 + col : -1;
@@ -282,6 +285,7 @@ static uint8_t wpp_d[5]; /* пять prandom(2), 0..2 */
static void wall_rnd_palace(int row, int col) static void wall_rnd_palace(int row, int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int idx = (col >= 0 && col < 10) ? (row + 1) * 10 + col : -1; int idx = (col >= 0 && col < 10) ? (row + 1) * 10 + col : -1;
pop_rnd_t saved; pop_rnd_t saved;
uint8_t i; uint8_t i;
@@ -366,6 +370,7 @@ static void wpp_fill(int x, int ytop, int w, int h, uint8_t colour)
static void wall_pattern_palace(int row, int col, int which_part) static void wall_pattern_palace(int row, int col, int which_part)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int xh = POP_COL_XH[col]; int xh = POP_COL_XH[col];
int dbys = 63 * row + 65; /* база БЕЗ POP_YOFF — для блитов */ int dbys = 63 * row + 65; /* база БЕЗ POP_YOFF — для блитов */
int dmys = dbys - 3; int dmys = dbys - 3;
@@ -411,6 +416,7 @@ static void wall_pattern_palace(int row, int col, int which_part)
static void wall_pattern(int row, int col, int which_part) static void wall_pattern(int row, int col, int which_part)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int xh = POP_COL_XH[col]; int xh = POP_COL_XH[col];
int dby = 63 * row + 65; int dby = 63 * row + 65;
uint8_t parts; uint8_t parts;
@@ -450,6 +456,7 @@ static void wall_pattern(int row, int col, int which_part)
/* Решётка ворот (portcullis) из ЛЕВОЙ комнаты (seg008.c:1110). */ /* Решётка ворот (portcullis) из ЛЕВОЙ комнаты (seg008.c:1110). */
static void draw_gate_back(uint8_t modl, int xh, int dby, int dmy) static void draw_gate_back(uint8_t modl, int xh, int dby, int dmy)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int x = xh * 8; int x = xh * 8;
int gate_top_y = dby - 62; int gate_top_y = dby - 62;
int openness = (((modl > 188) ? 188 : modl) >> 2) + 1; int openness = (((modl > 188) ? 188 : modl) >> 2) + 1;
@@ -480,6 +487,7 @@ static void draw_gate_back(uint8_t modl, int xh, int dby, int dmy)
* Клип по прямоугольнику персонажа делает pop_t_fclip (см. pop_tile.h). */ * Клип по прямоугольнику персонажа делает pop_t_fclip (см. pop_tile.h). */
static void draw_gate_fore(uint8_t modl, int xh, int dby, int dmy) static void draw_gate_fore(uint8_t modl, int xh, int dby, int dmy)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int x = xh * 8; int x = xh * 8;
int gate_top_y = dby - 62; int gate_top_y = dby - 62;
int openness = (((modl > 188) ? 188 : modl) >> 2) + 1; int openness = (((modl > 188) ? 188 : modl) >> 2) + 1;
@@ -576,6 +584,7 @@ static const uint8_t FORE_ANY[32] = {
/* Контекст ft_* обязан быть проставлен вызывающим. */ /* Контекст ft_* обязан быть проставлен вызывающим. */
static void fore_only_tile(int row, int col) static void fore_only_tile(int row, int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Нажатая кнопка → пол/stuck (get_tile_to_draw) — это уже учтено в ft_code /* Нажатая кнопка → пол/stuck (get_tile_to_draw) — это уже учтено в ft_code
* (его ставит pop_tile_code_drawn), иначе поверх Kid лезет передняя грань * (его ставит pop_tile_code_drawn), иначе поверх Kid лезет передняя грань
* КНОПКИ. */ * КНОПКИ. */
@@ -622,6 +631,7 @@ static void fore_only_tile(int row, int col)
* backtable под персонажем. */ * backtable под персонажем. */
static void ceil_over_kid_tile(int col) static void ceil_over_kid_tile(int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t code = pop_tile_code(-1, col); uint8_t code = pop_tile_code(-1, col);
const piece *t = &pop_tile_table[code]; const piece *t = &pop_tile_table[code];
int x = POP_COL_XH[col] * 8; int x = POP_COL_XH[col] * 8;
@@ -670,7 +680,9 @@ static uint8_t tile_in_fclip(int row, int col)
static void fore_tile(int row, int col) /* передний слой одного тайла */ static void fore_tile(int row, int col) /* передний слой одного тайла */
{ {
pop_dbg_rdmax[1]++; /* ВРЕМЕННО: сколько тайлов обошли */
if (!tile_in_fclip(row, col)) return; if (!tile_in_fclip(row, col)) return;
pop_dbg_rdmax[2]++; /* ВРЕМЕННО: прошли окно клипа */
/* draw_loose (seg008:0A38) — единственный кусок ТАЙЛА, который оригинал /* draw_loose (seg008:0A38) — единственный кусок ТАЙЛА, который оригинал
* кладёт В ОБЕ таблицы БЕЗУСЛОВНО: нижняя грань loose-плиты идёт и в * кладёт В ОБЕ таблицы БЕЗУСЛОВНО: нижняя грань loose-плиты идёт и в
* backtable, и в foretable. Значит передняя грань плиты рисуется ПОВЕРХ * backtable, и в foretable. Значит передняя грань плиты рисуется ПОВЕРХ
@@ -681,6 +693,7 @@ static void fore_tile(int row, int col) /* передний слой одно
* кромке loose над дырой от соседней упавшей плиты). */ * кромке loose над дырой от соседней упавшей плиты). */
ft_code = pop_tile_code_drawn(row, col); /* код тайла — ОДИН раз на тайл */ ft_code = pop_tile_code_drawn(row, col); /* код тайла — ОДИН раз на тайл */
if (!FORE_ANY[ft_code]) return; /* нечего класть в передний слой */ if (!FORE_ANY[ft_code]) return; /* нечего класть в передний слой */
pop_dbg_rdmax[3]++; /* ВРЕМЕННО: реально рисуем */
ft_x = POP_COL_XH[col] * 8; ft_x = POP_COL_XH[col] * 8;
ft_dmy = 63 * row + 62; ft_dmy = 63 * row + 62;
if (row >= 0 && ft_code == 11) if (row >= 0 && ft_code == 11)
@@ -708,6 +721,7 @@ static void other_overlay_tile(int row, int col); /* fwd (draw_floor_overlay -
static void climb_overlay_tile(int row, int col, uint8_t fidx) static void climb_overlay_tile(int row, int col, uint8_t fidx)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* тип — ПОДСТАВЛЕННЫЙ (нажатая кнопка = пол): иначе кромка, за которую /* тип — ПОДСТАВЛЕННЫЙ (нажатая кнопка = пол): иначе кромка, за которую
* Kid спускается/подтягивается, закрашивается целым тайлом */ * Kid спускается/подтягивается, закрашивается целым тайлом */
uint8_t code; uint8_t code;
@@ -744,6 +758,7 @@ static void climb_overlay_tile(int row, int col, uint8_t fidx)
* draw_tile_base, draw_tile_anim (свои пики) и draw_tile_bottom. */ * draw_tile_base, draw_tile_anim (свои пики) и draw_tile_bottom. */
static void overlay_mid_tile(int row, int col) static void overlay_mid_tile(int row, int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t code = pop_tile_code_drawn(row, col); uint8_t code = pop_tile_code_drawn(row, col);
uint8_t lcode = pop_tile_code_drawn(row, col - 1); uint8_t lcode = pop_tile_code_drawn(row, col - 1);
const piece *t; const piece *t;
@@ -806,6 +821,7 @@ static void overlay_mid_tile(int row, int col)
* сразу), поэтому порядок эмулируется явной проверкой. */ * сразу), поэтому порядок эмулируется явной проверкой. */
static void other_overlay_tile(int row, int col) static void other_overlay_tile(int row, int col)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t code, left; uint8_t code, left;
if (!tile_in_fclip(row, col)) return; if (!tile_in_fclip(row, col)) return;
code = pop_tile_code(row, col); code = pop_tile_code(row, col);
@@ -850,6 +866,7 @@ static void other_overlay_tile(int row, int col)
void pop_mob_overlay_tile(int row, int col, int8_t oref_row, int8_t oref_col, void pop_mob_overlay_tile(int row, int col, int8_t oref_row, int8_t oref_col,
int mx, int my, uint8_t mh) __banked int mx, int my, uint8_t mh) __banked
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int8_t saved_row = pop_bg_obj_row, saved_col = pop_bg_obj_col; int8_t saved_row = pop_bg_obj_row, saved_col = pop_bg_obj_col;
pop_bg_obj_row = oref_row; pop_bg_obj_row = oref_row;
pop_bg_obj_col = oref_col; pop_bg_obj_col = oref_col;
@@ -949,6 +966,7 @@ static void char_footprint(int obj_x, int obj_y, uint16_t w, uint16_t h,
* ничем не режется. */ * ничем не режется. */
void pop_fore_tile_b(int row, int col) __banked void pop_fore_tile_b(int row, int col) __banked
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t saved = pop_t_fclip_on; uint8_t saved = pop_t_fclip_on;
pop_t_fclip_on = 0; pop_t_fclip_on = 0;
fore_tile(row, col); fore_tile(row, col);
@@ -971,6 +989,7 @@ void pop_fore_tile_b(int row, int col) __banked
* странице её надо будет восстановить (pop_fore_heal). */ * странице её надо будет восстановить (pop_fore_heal). */
void pop_ceil_fore_tile_b(int col) __banked void pop_ceil_fore_tile_b(int col) __banked
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int x = POP_COL_XH[col] * 8; int x = POP_COL_XH[col] * 8;
/* ov_mark ЗДЕСЬ НЕ НУЖЕН, и это важно для бюджета. Передние куски потолка /* ov_mark ЗДЕСЬ НЕ НУЖЕН, и это важно для бюджета. Передние куски потолка
* рисуются от dby = 2, то есть занимают строки 0..2 поля, а пометку ставит * рисуются от dby = 2, то есть занимают строки 0..2 поля, а пометку ставит
@@ -996,10 +1015,13 @@ void pop_fore_pass_end(void) __banked { gfx_set_bank(GFX_BANK_NORMAL); }
void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y, void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
uint16_t w, uint16_t h) __banked uint16_t w, uint16_t h) __banked
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
int8_t cL, cR, cLraw, rT, rB, rTraw, r, c; int8_t cL, cR, cLraw, rT, rB, rTraw, r, c;
uint8_t frame = ch->frame, action = ch->action; uint8_t frame = ch->frame, action = ch->action;
pop_t_fclip_on = 1; /* окно = прямоугольник спрайта */ pop_t_fclip_on = 1; /* окно = прямоугольник спрайта */
char_footprint(obj_x, obj_y, w, h, ch->direction, ch->sword); char_footprint(obj_x, obj_y, w, h, ch->direction, ch->sword);
pop_dbg_b1(); /* ЗАМЕР: char_footprint */
pop_dbg_rdmax[1] = pop_dbg_rdmax[2] = pop_dbg_rdmax[3] = 0; /* ВРЕМЕННО */
/* Футпринт считается по КАДРУ персонажа (char_x_left/right, seg006:1021) /* Футпринт считается по КАДРУ персонажа (char_x_left/right, seg006:1021)
* и потому УЖЕ спрайта, а рядом с ним рисуются ещё клинок и «брызги» * и потому УЖЕ спрайта, а рядом с ним рисуются ещё клинок и «брызги»
* удара они уходят ВПЕРЁД-ВВЕРХ и запросто попадают в соседнюю * удара они уходят ВПЕРЁД-ВВЕРХ и запросто попадают в соседнюю
@@ -1043,6 +1065,7 @@ void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
} }
} }
} }
pop_dbg_b2(); /* ЗАМЕР: границы окна */
cL = fp_cL; cR = fp_cR; cLraw = fp_cLraw; cL = fp_cL; cR = fp_cR; cLraw = fp_cLraw;
rT = fp_rT; rB = fp_rB; rTraw = fp_rTraw; rT = fp_rT; rB = fp_rB; rTraw = fp_rTraw;
/* set_objtile_at_char (seg006:1833): тайл, при обработке которого спрайт /* set_objtile_at_char (seg006:1833): тайл, при обработке которого спрайт
@@ -1109,6 +1132,7 @@ void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
} }
} }
} }
pop_dbg_b3(); /* ЗАМЕР: оверлеи сделаны */
/* draw_tile_fore (seg008:690) — foretable, рисуется ПОСЛЕ midtable, т.е. /* draw_tile_fore (seg008:690) — foretable, рисуется ПОСЛЕ midtable, т.е.
* ПОСЛЕ оверлеев выше: передняя грань колонны перекрывает скос пола * ПОСЛЕ оверлеев выше: передняя грань колонны перекрывает скос пола
* (floor_left_overlay), а не наоборот. Раньше fore шёл первым, и тёмный * (floor_left_overlay), а не наоборот. Раньше fore шёл первым, и тёмный
@@ -1125,6 +1149,7 @@ void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
* Один вызов через трамплин на персонажа за кадр не в тайловом цикле: * Один вызов через трамплин на персонажа за кадр не в тайловом цикле:
* тайл-кандидат всегда ровно один, а fore-проход и так самый горячий * тайл-кандидат всегда ровно один, а fore-проход и так самый горячий
* кусок кадра (memory pop_fore_layer_cost). */ * кусок кадра (memory pop_fore_layer_cost). */
pop_dbg_b4(); /* ЗАМЕР: цикл fore_tile */
if (pop_gate_over_char(ch)) { if (pop_gate_over_char(ch)) {
int8_t gr = ch->curr_row, gc = (int8_t)(ch->curr_col + 1); int8_t gr = ch->curr_row, gc = (int8_t)(ch->curr_col + 1);
if (tile_in_fclip(gr, gc)) { if (tile_in_fclip(gr, gc)) {
+11
View File
@@ -124,6 +124,17 @@ void pop_spike_redraw(int row, int col) __banked;
* поэтому соседа перерисовывать не надо). */ * поэтому соседа перерисовывать не надо). */
void pop_chomp_redraw(int row, int col) __banked; void pop_chomp_redraw(int row, int col) __banked;
/* Вернуть ТОЛЬКО слой anim чомпера — его собственную графику поверх свежего
* пламени соседнего факела. Порт ветки redraw_frames_anim в redraw_needed
* (seg008:0211): оригинал на такую пометку делает ровно три вызова
* draw_tile_anim_topright / draw_tile_anim_right / draw_tile_anim и НЕ
* делает ни wipe, ни полного draw_tile (wipe у него отдельный счётчик).
*
* Из этих трёх нам нужен один: topright про верх ВОРОТ под пустой
* клеткой, к чомперу не относится, а anim_right (пламя левого соседа) у нас
* уже нарисован pop_torch_draw запекает его в фон ДО redraw_needed. */
void pop_chomp_anim_draw(int row, int col) __banked;
/* Анимация факела/зелья (динамика каждый кадр; фон запечён без них): /* Анимация факела/зелья (динамика каждый кадр; фон запечён без них):
* modif живой room_modif тайла. Для факела col колонка САМОГО факела * modif живой room_modif тайла. Для факела col колонка САМОГО факела
* (пламя рисуется в ячейке правого соседа). */ * (пламя рисуется в ячейке правого соседа). */
+110 -43
View File
@@ -23,6 +23,9 @@
#include "_pop_kdraw.h" #include "_pop_kdraw.h"
#include "pop_vflip.h" /* зеркальные страницы атласов (зелье инверсии) */ /* меч + блит по id — сосед по банку 4 */ /* атласы/кадр Кида, pop_sword_draw, окна Char */ #include "pop_vflip.h" /* зеркальные страницы атласов (зелье инверсии) */ /* меч + блит по id — сосед по банку 4 */ /* атласы/кадр Кида, pop_sword_draw, окна Char */
#include "pop_cdraw.h" #include "pop_cdraw.h"
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "pop_state.h" /* ВРЕМЕННО: зонды pop_dbg_b* */
#include "pop_bg.h" /* POP_YOFF, pop_fore_over_char, pop_fore_set_clip */ #include "pop_bg.h" /* POP_YOFF, pop_fore_over_char, pop_fore_set_clip */
#include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */ #include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */
#include "pop_map.h" /* hitp_curr/hitp_max — HP Кида; clip_char */ #include "pop_map.h" /* hitp_curr/hitp_max — HP Кида; clip_char */
@@ -257,7 +260,7 @@ static int scr_x(int x)
* страницы. Оба мелкие и в одном кадре встречаются редко их объединение * страницы. Оба мелкие и в одном кадре встречаются редко их объединение
* дешевле третьего heal-слота. */ * дешевле третьего heal-слота. */
static void cd_overlay_add(pop_cdraw_t *s, uint8_t dp, int x, int y, static void cd_overlay_add(pop_cdraw_t *s, uint8_t dp, int x, int y,
uint16_t w, uint16_t h) uint8_t w, uint8_t h)
{ {
if (!s->ovalid[dp]) { if (!s->ovalid[dp]) {
s->ox[dp] = x; s->oy[dp] = y; s->ow[dp] = w; s->oh[dp] = h; s->ox[dp] = x; s->oy[dp] = y; s->ow[dp] = w; s->oh[dp] = h;
@@ -272,14 +275,14 @@ static void cd_overlay_add(pop_cdraw_t *s, uint8_t dp, int x, int y,
if (x + (int)w > x1) x1 = x + (int)w; if (x + (int)w > x1) x1 = x + (int)w;
if (y + (int)h > y1) y1 = y + (int)h; if (y + (int)h > y1) y1 = y + (int)h;
s->ox[dp] = x0; s->oy[dp] = y0; s->ox[dp] = x0; s->oy[dp] = y0;
s->ow[dp] = (uint16_t)(x1 - x0); s->oh[dp] = (uint16_t)(y1 - y0); s->ow[dp] = (uint8_t)(x1 - x0); s->oh[dp] = (uint8_t)(y1 - y0);
} }
} }
/* Добавить прямоугольник в окно fore-клипа слота (объединение «спрайт + /* Добавить прямоугольник в окно fore-клипа слота (объединение «спрайт +
* накладные»): по нему fore-проход возвращает куски тайлов и по нему же * накладные»): по нему fore-проход возвращает куски тайлов и по нему же
* расширяет перебор колонок/рядов. */ * расширяет перебор колонок/рядов. */
static void cd_clip_add(pop_cdraw_t *s, int x, int y, uint16_t w, uint16_t h) static void cd_clip_add(pop_cdraw_t *s, int x, int y, uint8_t w, uint8_t h)
{ {
if (!s->cw) { if (!s->cw) {
s->cx = x; s->cy = y; s->cw = w; s->ch = h; s->cx = x; s->cy = y; s->cw = w; s->ch = h;
@@ -291,8 +294,8 @@ static void cd_clip_add(pop_cdraw_t *s, int x, int y, uint16_t w, uint16_t h)
if (y < s->cy) s->cy = y; if (y < s->cy) s->cy = y;
if (x + (int)w > x1) x1 = x + (int)w; if (x + (int)w > x1) x1 = x + (int)w;
if (y + (int)h > y1) y1 = y + (int)h; if (y + (int)h > y1) y1 = y + (int)h;
s->cw = (uint16_t)(x1 - s->cx); s->cw = (uint8_t)(x1 - s->cx);
s->ch = (uint16_t)(y1 - s->cy); s->ch = (uint8_t)(y1 - s->cy);
} }
} }
@@ -343,6 +346,34 @@ static void cd_sig_make(uint8_t who, cd_sig_t *g)
g->dx = pop_cd[who].render_dx; g->dx = pop_cd[who].render_dx;
} }
/* Совпадает ли снимок страницы с ТЕКУЩИМ состоянием персонажа.
*
* Отдельно от cd_sig_make намеренно: тот СТРОИТ структуру, и для проверки
* это лишняя работа тринадцать записей в стековый кадр (а значит через
* `-n(ix)`), после которых идёт побайтовый цикл сравнения. А зовут проверку
* четыре раза за кадр: pop_char_skip_mask дважды, и в ней по два слота.
* Здесь сравниваем поля прямо с источником, и первое же расхождение
* заканчивает работу у ДВИГАЮЩЕГОСЯ персонажа это обычно первое поле. */
static uint8_t cd_sig_same(uint8_t who, uint8_t p)
{
const pop_char_t *ch = (who == POP_CH_KID) ? &Kid : &Guard;
const cd_sig_t *g = &cd_sig[who][p];
if (g->frame != ch->frame) return 0;
if (g->x != ch->x) return 0;
if (g->y != ch->y) return 0;
if (g->dir != ch->direction) return 0;
if (g->action != ch->action) return 0;
if (g->ccol != ch->curr_col) return 0;
if (g->crow != ch->curr_row) return 0;
if (g->sword != ch->sword) return 0;
if (g->charid != ch->charid) return 0;
if (g->room != ch->room) return 0;
if (g->hurt != (uint8_t)((who == POP_CH_KID) ? pop_kid_hurt : pop_guard_hurt))
return 0;
if (g->dx != pop_cd[who].render_dx) return 0;
return 1;
}
/* Габарит слота на странице: спрайт + накладные (клинок/брызги). y — /* Габарит слота на странице: спрайт + накладные (клинок/брызги). y —
* КОМНАТНЫЙ (как в pop_cd), перевод в экранный делает вызывающий. */ * КОМНАТНЫЙ (как в pop_cd), перевод в экранный делает вызывающий. */
static void cd_bbox(uint8_t who, uint8_t p, int *x0, int *y0, int *x1, int *y1) static void cd_bbox(uint8_t who, uint8_t p, int *x0, int *y0, int *x1, int *y1)
@@ -365,9 +396,6 @@ static void cd_bbox(uint8_t who, uint8_t p, int *x0, int *y0, int *x1, int *y1)
* ни перепроверяй за кадр. */ * ни перепроверяй за кадр. */
static uint8_t cd_quiet(uint8_t who, uint8_t p) static uint8_t cd_quiet(uint8_t who, uint8_t p)
{ {
cd_sig_t g;
const uint8_t *a, *b;
uint8_t i;
/* Соперника на сцене нет и на ЭТОЙ странице от него ничего не осталось — /* Соперника на сцене нет и на ЭТОЙ странице от него ничего не осталось —
* стирать и рисовать нечего, слот «тихий». Без этого пустой слот каждый * стирать и рисовать нечего, слот «тихий». Без этого пустой слот каждый
* кадр честно проходил heal + вход в pop_char_draw + fore-проход и стоил * кадр честно проходил heal + вход в pop_char_draw + fore-проход и стоил
@@ -377,17 +405,20 @@ static uint8_t cd_quiet(uint8_t who, uint8_t p)
return 1; return 1;
if (!pop_cd[who].valid[p]) return 0; if (!pop_cd[who].valid[p]) return 0;
if (pop_cd_dirty & (1 << p)) { /* фон правили — но задели ли нас? */ if (pop_cd_dirty & (1 << p)) { /* фон правили — но задели ли нас? */
int x0, y0, x1, y1; /* СПРАЙТ И НАКЛАДНОЙ (клинок, брызги) — ДВУМЯ ОТДЕЛЬНЫМИ проверками,
cd_bbox(who, p, &x0, &y0, &x1, &y1); * а не объединённым bbox. Объединение включает пустой угол между
y0 += POP_YOFF; y1 += POP_YOFF; /* метка фона — в ЭКРАННЫХ */ * ними, и он ловит касания, которых на самом деле нет: у стоящего
/* cd_bbox отдаёт x1/y1 ИСКЛЮЧИТЕЛЬНО, pop_cd_hit ждёт включительно. */ * стража спрайт лежит в колонке 8, клинок уходит влево в колонку 7 на
if (pop_cd_hit(p, x0, y0, x1 - 1, y1 - 1)) return 0; * y 59..65, а пламя факела метит колонку 7 на y 33..50. Прямоугольник
* «спрайт + клинок» (x 241..284, y 46..84) пересекается с этой меткой
* углом и персонаж перерисовывался целиком за 148 302 такта
* (замер 2026-08-19, сцена 11/15).
*
* Для skip_mask объединение по-прежнему годится: там вопрос «рядом ли
* два слота», и загрубление в бОльшую сторону безопасно. */
if (pop_cd_hit_slot(who, p)) return 0;
} }
cd_sig_make(who, &g); return cd_sig_same(who, p);
a = (const uint8_t *)&g; b = (const uint8_t *)&cd_sig[who][p];
for (i = 0; i < sizeof(cd_sig_t); i++)
if (*a++ != *b++) return 0;
return 1;
} }
/* Запас вокруг габарита соседа: за кадр персонаж проходит заметно меньше /* Запас вокруг габарита соседа: за кадр персонаж проходит заметно меньше
@@ -440,7 +471,7 @@ static void cd_splash(pop_cdraw_t *s, uint8_t who, const atlas_t *pages,
const atlas_t *ap; const atlas_t *ap;
uint8_t aidx; uint8_t aidx;
const uint8_t *img; const uint8_t *img;
uint16_t w, h; uint8_t w, h;
int qx = fp_x, qy = obj_y; int qx = fp_x, qy = obj_y;
int fw = (Char.direction < 0) ? -5 : 5; /* obj_dx_forward(5) */ int fw = (Char.direction < 0) ? -5 : 5; /* obj_dx_forward(5) */
@@ -462,8 +493,12 @@ static void cd_splash(pop_cdraw_t *s, uint8_t who, const atlas_t *pages,
qx = scr_x(qx + fw); /* calc_screen_x_coord */ qx = scr_x(qx + fw); /* calc_screen_x_coord */
img = (const uint8_t *)atlas_image(ap, aidx); img = (const uint8_t *)atlas_image(ap, aidx);
gfx_w0_map(ap->page); gfx_w0_map(ap->page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8)); /* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8)); * (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
if (w && h) { if (w && h) {
int top = qy - (int)h + 1; int top = qy - (int)h + 1;
uint8_t flip = (uint8_t)(Char.direction >= 0); uint8_t flip = (uint8_t)(Char.direction >= 0);
@@ -474,15 +509,16 @@ static void cd_splash(pop_cdraw_t *s, uint8_t who, const atlas_t *pages,
if (top + rows > 192) rows = 192 - top; if (top + rows > 192) rows = 192 - top;
if (rows <= 0) return; if (rows <= 0) return;
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
if (pop_onscreen_cols(qx, top + POP_YOFF, w, (uint16_t)rows)) pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
if (pop_onscreen_cols(qx, top + POP_YOFF, w, (uint8_t)rows))
gfx_blit_cols_part_noclip(qx, top + POP_YOFF, img, flip, gfx_blit_cols_part_noclip(qx, top + POP_YOFF, img, flip,
(uint8_t)skip, (uint8_t)rows); (uint8_t)skip, (uint8_t)rows);
else else
gfx_blit_cols_part(qx, top + POP_YOFF, img, flip, skip, rows); gfx_blit_cols_part(qx, top + POP_YOFF, img, flip, skip, rows);
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
dp = gfx_get_draw_page() & 1; dp = gfx_get_draw_page() & 1;
cd_overlay_add(s, dp, qx, top, w, (uint16_t)rows); cd_overlay_add(s, dp, qx, top, w, (uint8_t)rows);
cd_clip_add(s, qx, top, w, (uint16_t)rows); cd_clip_add(s, qx, top, w, (uint8_t)rows);
} }
} }
@@ -492,9 +528,13 @@ void pop_char_draw(uint8_t who) __banked
const pop_char_t *ch; const pop_char_t *ch;
const kframe *fr; const kframe *fr;
const atlas_t *pages; const atlas_t *pages;
/* Атлас БРЫЗГ отдельно от кадра: у оригинала они всегда из «своего»
* chtab (Кид chtab_2 image 218, прочие chtab_5 image 1,
* seg006:2019), а у Тени кадр может прийти из другой половины набора. */
const atlas_t *spl;
const uint8_t *img; const uint8_t *img;
uint8_t npages, page, idx, dp, flip, hurt, lskip; uint8_t npages, page, idx, dp, flip, hurt, lskip;
uint16_t w, h, vis_w; uint8_t w, h, vis_w;
int obj_x, obj_y, fp_x, fwd, top, bx, skip, rows, ct, cr; int obj_x, obj_y, fp_x, fwd, top, bx, skip, rows, ct, cr;
int bcut; /* срез СНИЗУ (clip_char в перевёрнутом виде) */ int bcut; /* срез СНИЗУ (clip_char в перевёрнутом виде) */
@@ -514,19 +554,31 @@ void pop_char_draw(uint8_t who) __banked
if (who == POP_CH_KID) { if (who == POP_CH_KID) {
pop_loadkid(); pop_loadkid();
ch = &Kid; fr = &kid_frame; pages = kidp; npages = kid_npages; ch = &Kid; fr = &kid_frame; pages = kidp; npages = kid_npages;
spl = kidp;
hurt = pop_kid_hurt; hurt = pop_kid_hurt;
} else { } else {
pop_loadshad(); pop_loadshad();
ch = &Guard; fr = &pop_gframe; pages = gp; ch = &Guard; fr = &pop_gframe; pages = gp;
npages = g_ok ? GUARD_PAGES : 0; npages = g_ok ? GUARD_PAGES : 0;
spl = gp;
hurt = pop_guard_hurt; hurt = pop_guard_hurt;
if (Guard.charid == 0) return; /* соперника на сцене нет */ if (Guard.charid == 0) return; /* соперника на сцене нет */
/* ТЕНЬ (ур. 4) — это зеркальный Кид: вне боевых кадров 150..189 она /* Набор атласов обязан идти за ТАБЛИЦЕЙ КАДРОВ — условие берём из
* ходит по таблице КИДА (seg006:0532), а `image` из неё индексирует * того же места, что load_frame (seg006:0532), чтобы они не
* спрайты Кида, а не стража. Набор атласов обязан идти за таблицей * разъехались: вне боевых кадров 150..189 Тень ходит по таблице
* условие берём из того же места, что load_frame, чтобы они не * КИДА, и `image` из неё индексирует спрайты Кида. */
* разъехались. */ if (Guard.charid == CHARID_1_SHADOW && pop_shadow_kid_n) {
if (!pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) { /* У Тени СВОЙ запечённый набор (pop_shadow.h): её вид даёт
* наложение спрайта на себя со сдвигом, а пакетный блит так не
* умеет. Обе половины лежат в нём же, поэтому подмены на kidp
* здесь уже не нужно. */
if (pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) {
pages = pop_shadow_fgt; npages = pop_shadow_fgt_n;
} else {
pages = pop_shadow_kid; npages = pop_shadow_kid_n;
}
spl = pop_shadow_fgt; /* брызги — из половины chtab_5 */
} else if (!pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) {
pages = kidp; npages = kid_npages; pages = kidp; npages = kid_npages;
} }
} }
@@ -596,8 +648,12 @@ void pop_char_draw(uint8_t who) __banked
} }
gfx_w0_map(vpg); gfx_w0_map(vpg);
} }
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8)); /* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8)); * (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
dp = gfx_get_draw_page() & 1; dp = gfx_get_draw_page() & 1;
if (w && h) { if (w && h) {
/* Спрайты нарисованы ЛИЦОМ ВЛЕВО (как в оригинале); seg008:864 — /* Спрайты нарисованы ЛИЦОМ ВЛЕВО (как в оригинале); seg008:864 —
@@ -654,7 +710,7 @@ void pop_char_draw(uint8_t who) __banked
if (cr) { if (cr) {
int avail = cr - bx; int avail = cr - bx;
if (avail <= 0) vis_w = 0; /* весь спрайт за косяком */ if (avail <= 0) vis_w = 0; /* весь спрайт за косяком */
else if (avail < (int)w) vis_w = (uint16_t)avail; else if (avail < (int)w) vis_w = (uint8_t)avail;
} }
/* КЛИП ТЕНИ СЛЕВА (seg008:1699): на уровне зеркала она может /* КЛИП ТЕНИ СЛЕВА (seg008:1699): на уровне зеркала она может
* показываться ТОЛЬКО СПРАВА от него * показываться ТОЛЬКО СПРАВА от него
@@ -676,6 +732,7 @@ void pop_char_draw(uint8_t who) __banked
if (top < 0) { skip += -top; rows += top; top = 0; } if (top < 0) { skip += -top; rows += top; top = 0; }
if (top + rows > 192) rows = 192 - top; if (top + rows > 192) rows = 192 - top;
gfx_set_bank(GFX_BANK_SPRITE); /* видео-ОЗУ, без теневой копии */ gfx_set_bank(GFX_BANK_SPRITE); /* видео-ОЗУ, без теневой копии */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
if (rows > 0 && vis_w) { /* +YOFF: центрирование */ if (rows > 0 && vis_w) { /* +YOFF: центрирование */
/* noclip-ядро ширину не ограничивает, поэтому при обрезке справа /* noclip-ядро ширину не ограничивает, поэтому при обрезке справа
* идём общим путём: это редкие кадры (подъём в двери уровня раз * идём общим путём: это редкие кадры (подъём в двери уровня раз
@@ -683,7 +740,7 @@ void pop_char_draw(uint8_t who) __banked
if (lskip) /* тень у зеркала: срез слева */ if (lskip) /* тень у зеркала: срез слева */
gfx_blit_cols_part_wx(bx, top + POP_YOFF, img, flip, skip, rows, gfx_blit_cols_part_wx(bx, top + POP_YOFF, img, flip, skip, rows,
lskip, (uint8_t)(vis_w - lskip)); lskip, (uint8_t)(vis_w - lskip));
else if (vis_w == w && pop_onscreen_cols(bx, top + POP_YOFF, w, (uint16_t)rows)) else if (vis_w == w && pop_onscreen_cols(bx, top + POP_YOFF, w, (uint8_t)rows))
gfx_blit_cols_part_noclip(bx, top + POP_YOFF, img, flip, gfx_blit_cols_part_noclip(bx, top + POP_YOFF, img, flip,
(uint8_t)skip, (uint8_t)rows); (uint8_t)skip, (uint8_t)rows);
else else
@@ -694,13 +751,14 @@ void pop_char_draw(uint8_t who) __banked
/* heal чистит ТОЛЬКО нарисованное: иначе стирается кромка пола над /* heal чистит ТОЛЬКО нарисованное: иначе стирается кромка пола над
* срезом (мусор/дыра в кладке на второй странице дабл-буфера). */ * срезом (мусор/дыра в кладке на второй странице дабл-буфера). */
s->x[dp] = bx + lskip; s->y[dp] = top; s->x[dp] = bx + lskip; s->y[dp] = top;
s->w[dp] = (uint16_t)(vis_w - lskip); s->h[dp] = (uint16_t)rows; s->w[dp] = (uint8_t)(vis_w - lskip); s->h[dp] = (uint8_t)rows;
s->valid[dp] = (uint8_t)(rows != 0); s->valid[dp] = (uint8_t)(rows != 0);
if (rows) cd_clip_add(s, bx, top, vis_w, (uint16_t)rows); if (rows) cd_clip_add(s, bx, top, vis_w, (uint8_t)rows);
s->fpw = w; s->fph = h; /* габарит КАДРА (не обрезанный): */ s->fpw = w; s->fph = h; /* габарит КАДРА (не обрезанный): */
/* по нему считается футпринт */ /* по нему считается футпринт */
} }
if (hurt) cd_splash(s, who, pages, fp_x, obj_y); pop_dbg_m15(); /* ЗАМЕР: снимок прямоугольника сделан */
if (hurt) cd_splash(s, who, spl, fp_x, obj_y);
/* add_sword_to_objtable (seg006:1798): клинок отдельным спрайтом поверх /* add_sword_to_objtable (seg006:1798): клинок отдельным спрайтом поверх
* персонажа со своим прямоугольником heal. */ * персонажа со своим прямоугольником heal. */
{ {
@@ -710,6 +768,7 @@ void pop_char_draw(uint8_t who) __banked
cd_clip_add(s, sx, sy, sw, sh); cd_clip_add(s, sx, sy, sw, sh);
} }
} }
pop_dbg_m16(); /* ЗАМЕР: splash + клинок сделаны */
gfx_w0_unmap(); gfx_w0_unmap();
/* Что именно нарисовано на ЭТОЙ странице — снимок для пропуска /* Что именно нарисовано на ЭТОЙ странице — снимок для пропуска
* следующих кадров (DRAW-COST). Только если кадр РЕАЛЬНО рисовали: * следующих кадров (DRAW-COST). Только если кадр РЕАЛЬНО рисовали:
@@ -738,6 +797,7 @@ void pop_char_fore(uint8_t who) __banked
if (s->fpw) if (s->fpw)
pop_fore_over_char(who == POP_CH_KID ? &Kid : &Guard, pop_fore_over_char(who == POP_CH_KID ? &Kid : &Guard,
s->fpx, s->fpy, s->fpw, s->fph); s->fpx, s->fpy, s->fpw, s->fph);
pop_dbg_b6(); /* ЗАМЕР: pop_char_fore целиком */
} }
/* ---- ОТРАЖЕНИЕ В ЗЕРКАЛЕ (check_mirror, seg003:0798) ---------------- * /* ---- ОТРАЖЕНИЕ В ЗЕРКАЛЕ (check_mirror, seg003:0798) ---------------- *
@@ -774,7 +834,7 @@ void pop_mirror_draw(int clip_top) __banked
const kframe *fr = &kid_frame; const kframe *fr = &kid_frame;
const uint8_t *img; const uint8_t *img;
uint8_t page, idx, flip; uint8_t page, idx, flip;
uint16_t w, h, vis_w; uint8_t w, h, vis_w;
int fwd, obj_x, obj_y, fp_x, bx, top, skip = 0, rows, cl; int fwd, obj_x, obj_y, fp_x, bx, top, skip = 0, rows, cl;
uint8_t lskip = 0; uint8_t lskip = 0;
@@ -792,8 +852,12 @@ void pop_mirror_draw(int clip_top) __banked
img = (const uint8_t *)atlas_image(&kidp[page], idx); img = (const uint8_t *)atlas_image(&kidp[page], idx);
gfx_w0_map(kidp[page].page); gfx_w0_map(kidp[page].page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8)); /* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8)); * (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
if (!w || !h) { gfx_w0_unmap(); return; } if (!w || !h) { gfx_w0_unmap(); return; }
flip = (uint8_t)(Char.direction >= 0); flip = (uint8_t)(Char.direction >= 0);
bx = flip ? obj_x - (int)w : obj_x; bx = flip ? obj_x - (int)w : obj_x;
@@ -821,6 +885,7 @@ void pop_mirror_draw(int clip_top) __banked
} }
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
if (lskip) if (lskip)
gfx_blit_cols_part_wx(bx, top + POP_YOFF, img, flip, skip, rows, gfx_blit_cols_part_wx(bx, top + POP_YOFF, img, flip, skip, rows,
lskip, (uint8_t)(vis_w - lskip)); lskip, (uint8_t)(vis_w - lskip));
@@ -914,16 +979,18 @@ void pop_hp_draw(void) __banked
if (g_ok && Guard.charid != 0 && Guard.charid != CHARID_4_SKELETON && if (g_ok && Guard.charid != 0 && Guard.charid != CHARID_4_SKELETON &&
Guard.charid != CHARID_24_MOUSE && gd) { Guard.charid != CHARID_24_MOUSE && gd) {
const uint8_t *img = (const uint8_t *)atlas_image(&gp[0], 0); const uint8_t *img = (const uint8_t *)atlas_image(&gp[0], 0);
uint16_t w, h; uint8_t w, h;
gfx_w0_map(gp[0].page); gfx_w0_map(gp[0].page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8)); w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8)); h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
if (w && h) { if (w && h) {
/* Атлас стража тоже column-major — блит колоночный. */ /* Атлас стража тоже column-major — блит колоночный. */
n = (uint8_t)(gd > HP_MAXDRAW ? HP_MAXDRAW : gd); n = (uint8_t)(gd > HP_MAXDRAW ? HP_MAXDRAW : gd);
for (i = 0; i < n; i++) for (i = 0; i < n; i++) {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
gfx_blit_cols_part_noclip(314 - (int)i * HP_STEP, gfx_blit_cols_part_noclip(314 - (int)i * HP_STEP,
HP_Y + POP_YOFF, img, 0, 0, 0); HP_Y + POP_YOFF, img, 0, 0, 0);
}
} }
gfx_w0_unmap(); gfx_w0_unmap();
} }
+13 -5
View File
@@ -51,21 +51,21 @@ typedef struct {
* видео-ОЗУ и своя ОЗУ-копия, поэтому heal обязан стирать спрайт именно * видео-ОЗУ и своя ОЗУ-копия, поэтому heal обязан стирать спрайт именно
* той страницы, в которую сейчас рисуем */ * той страницы, в которую сейчас рисуем */
int x[2], y[2]; int x[2], y[2];
uint16_t w[2], h[2]; uint8_t w[2], h[2]; /* габарит: спрайты игры не крупнее 64x64 */
uint8_t valid[2]; uint8_t valid[2];
/* НАКЛАДНЫЕ спрайты (клинок + брызги урона) — свой прямоугольник, не /* НАКЛАДНЫЕ спрайты (клинок + брызги урона) — свой прямоугольник, не
* объединение с персонажем: объединение сильно больше суммы двух * объединение с персонажем: объединение сильно больше суммы двух
* (клинок уходит вперёд-вверх), а heal стоит ровно по площади */ * (клинок уходит вперёд-вверх), а heal стоит ровно по площади */
int ox[2], oy[2]; int ox[2], oy[2];
uint16_t ow[2], oh[2]; uint8_t ow[2], oh[2];
uint8_t ovalid[2]; uint8_t ovalid[2];
/* габарит кадра для fore-прохода: fpx — ЛОГИЧЕСКАЯ X (до ×8/7), /* габарит кадра для fore-прохода: fpx — ЛОГИЧЕСКАЯ X (до ×8/7),
* fpy низ спрайта; fpw == 0 в этом кадре рисовать было нечего */ * fpy низ спрайта; fpw == 0 в этом кадре рисовать было нечего */
int fpx, fpy; int fpx, fpy;
uint16_t fpw, fph; uint8_t fpw, fph;
/* окно fore-клипа слота (объединение «спрайт + накладные»), КОМНАТНЫЙ y */ /* окно fore-клипа слота (объединение «спрайт + накладные»), КОМНАТНЫЙ y */
int cx, cy; int cx, cy;
uint16_t cw, ch; uint8_t cw, ch;
/* straddle: рендерное смещение по ЛОГИЧЕСКОЙ X, когда комната персонажа /* straddle: рендерное смещение по ЛОГИЧЕСКОЙ X, когда комната персонажа
* не совпадает с отрисованной (порт xpos_in_drawn_room) */ * не совпадает с отрисованной (порт xpos_in_drawn_room) */
int render_dx; int render_dx;
@@ -121,11 +121,19 @@ void pop_char_set_render_dx(uint8_t who, int dx) __banked;
* Резидент (pop_tile.c): зовёт и банковый слой фона, и резидентные листья, * Резидент (pop_tile.c): зовёт и банковый слой фона, и резидентные листья,
* а трамплин на КАЖДЫЙ кусок фона стоил бы дороже самой метки. */ * а трамплин на КАЖДЫЙ кусок фона стоил бы дороже самой метки. */
extern uint8_t pop_cd_dirty; /* биты страниц: метка взведена */ extern uint8_t pop_cd_dirty; /* биты страниц: метка взведена */
extern uint16_t pop_cd_dmask[2][3]; /* [страница][ряд] = биты колонок 0..9 */ /* [страница][колонка] = диапазон затронутых ЭКРАННЫХ y; пусто = ymin 255,
* ymax 0. Раньше тут была маска колонок на три ряда по 63 px она
* склеивала касания внутри ряда и заставляла перерисовывать нетронутого
* персонажа (разбор в шапке pop_cd_touch, pop_tile.c). */
extern uint8_t pop_cd_ymin[2][10], pop_cd_ymax[2][10];
/* Задели ли прямоугольник (ЭКРАННЫЕ координаты, x1/y1 включительно) то, /* Задели ли прямоугольник (ЭКРАННЫЕ координаты, x1/y1 включительно) то,
* что трогали на странице p. Резидент (pop_tile.c). */ * что трогали на странице p. Резидент (pop_tile.c). */
uint8_t pop_cd_hit(uint8_t p, int x0, int y0, int x1, int y1); uint8_t pop_cd_hit(uint8_t p, int x0, int y0, int x1, int y1);
/* То же для СЛОТА персонажа: спрайт и накладной, координаты изнутри pop_cd
* (см. тело там про цену пяти аргументов). */
uint8_t pop_cd_hit_slot(uint8_t who, uint8_t p);
void pop_cd_clear(uint8_t p); /* снять метку страницы целиком */ void pop_cd_clear(uint8_t p); /* снять метку страницы целиком */
void pop_cd_init(void); /* пустые диапазоны на обеих страницах */
void pop_cd_touch(int x, int y, int w, int h); void pop_cd_touch(int x, int y, int w, int h);
#define POP_CD_TOUCH_ALL() pop_cd_touch(0, 0, 320, 256) #define POP_CD_TOUCH_ALL() pop_cd_touch(0, 0, 320, 256)
+3 -1
View File
@@ -19,7 +19,9 @@
extern uint8_t pop_cheats; /* 0 = выключены, иначе включены */ extern uint8_t pop_cheats; /* 0 = выключены, иначе включены */
#define KBD_CHEAT_KILL 0x42 /* K (PS/2 set 2) — kill guard */ #define KBD_CHEAT_KILL 0x42 /* K (PS/2 set 2) — kill guard */
#define KBD_CHEAT_IMMO 0x43 /* I (PS/2 set 2) — бессмертие Кида (toggle) */ #define KBD_CHEAT_IMMO 0x43 /* I (PS/2 set 2) — бессмертие Кида по кругу
* 0 -> 1 (только бой) -> 2 (и мелкий урон) -> 0;
* уровни расписаны в pop_state.h */
#define KBD_CHEAT_SWORD 0x1B /* S (PS/2 set 2) — выдать Киду меч */ #define KBD_CHEAT_SWORD 0x1B /* S (PS/2 set 2) — выдать Киду меч */
#define KBD_CHEAT_NEXTLVL 0x4B /* Shift+L (PS/2 set 2) — следующий уровень */ #define KBD_CHEAT_NEXTLVL 0x4B /* Shift+L (PS/2 set 2) — следующий уровень */
#define KBD_CHEAT_XDEC 0x54 /* [ (PS/2 set 2) — сдвинуть Кида на 1 px влево */ #define KBD_CHEAT_XDEC 0x54 /* [ (PS/2 set 2) — сдвинуть Кида на 1 px влево */
+3
View File
@@ -18,12 +18,15 @@
#include <stdint.h> #include <stdint.h>
#include <gfx.h> #include <gfx.h>
#include "_pop_draw.h" #include "_pop_draw.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
void pop_heal_fast(int x, int y, int w, int h) void pop_heal_fast(int x, int y, int w, int h)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча, см. pop_pace.h */
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
if (pop_onscreen_cols(x, y, (uint16_t)w, (uint16_t)h)) if (pop_onscreen_cols(x, y, (uint16_t)w, (uint16_t)h))
gfx_heal_noclip(x, y, (uint8_t)w, (uint8_t)h); gfx_heal_noclip(x, y, (uint8_t)w, (uint8_t)h);
else else
gfx_heal(x, y, w, h); gfx_heal(x, y, w, h);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */ /* и ПОСЛЕ: широкий heal сам по себе ~70 000 тактов */
} }
+8 -1
View File
@@ -85,7 +85,14 @@ void pop_guard_kill(void)
uint8_t pop_take_hp(uint8_t count) uint8_t pop_take_hp(uint8_t count)
{ {
if (Char.charid == CHARID_0_KID) { if (Char.charid == CHARID_0_KID) {
if (pop_immortal) return 0; /* ЧИТ БЕССМЕРТИЯ, уровень 2 (pop_state.h): гасим ТОЛЬКО мелкий урон
* падение с двух этажей, придавливание плитой. Мгновенная смерть
* (пики, чомпер, падение с трёх этажей, удар вне боевой стойки)
* приходит с count = 100 и проходит всегда: у читa нет задачи
* отменять смерть, у него задача отменить «минус одно деление».
* Уровень 1 сюда не вмешивается вовсе он живёт в hurt_by_sword и
* гасит только удары мечом. */
if (pop_immortal >= 2 && count < 100) return 0;
if (count >= hitp_curr) { hitp_delta = (int8_t)-hitp_curr; return 1; } if (count >= hitp_curr) { hitp_delta = (int8_t)-hitp_curr; return 1; }
hitp_delta = (int8_t)-(int8_t)count; hitp_delta = (int8_t)-(int8_t)count;
} else { } else {
+3
View File
@@ -17,6 +17,7 @@
#include <sprite.h> #include <sprite.h>
#include "pop_kid.h" #include "pop_kid.h"
#include "_pop_kdraw.h" #include "_pop_kdraw.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "pop_guard.h" /* CHARID_* — клинок есть и у стража */ #include "pop_guard.h" /* CHARID_* — клинок есть и у стража */
#include "pop_bg.h" /* POP_YOFF — вертикальное центрирование */ #include "pop_bg.h" /* POP_YOFF — вертикальное центрирование */
#include "_pop_draw.h" /* pop_onscreen_cols */ #include "_pop_draw.h" /* pop_onscreen_cols */
@@ -103,6 +104,7 @@ uint8_t pop_sword_draw(const pop_char_t *ch, const kframe *fr,
if (top + rows > 192) rows = 192 - top; if (top + rows > 192) rows = 192 - top;
if (rows <= 0) return 0; if (rows <= 0) return 0;
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Клинок почти всегда целиком на экране — noclip-путь (см. gfx.h: /* Клинок почти всегда целиком на экране — noclip-путь (см. gfx.h:
* подготовка клипающего варианта стоит ~5.6 К тактов на вызов). */ * подготовка клипающего варианта стоит ~5.6 К тактов на вызов). */
if (pop_onscreen_cols(bx, top + POP_YOFF, w, (uint16_t)rows)) if (pop_onscreen_cols(bx, top + POP_YOFF, w, (uint16_t)rows))
@@ -121,6 +123,7 @@ uint8_t pop_sword_draw(const pop_char_t *ch, const kframe *fr,
* Кида, а держать вторую копию раскладки в pop_cdraw ни к чему. */ * Кида, а держать вторую копию раскладки в pop_cdraw ни к чему. */
void pop_kid_img_blit(uint8_t image, int x, int y) void pop_kid_img_blit(uint8_t image, int x, int y)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
uint8_t pg = (uint8_t)(image >> 3); /* раскладка атласов: 8 спрайтов/стр. */ uint8_t pg = (uint8_t)(image >> 3); /* раскладка атласов: 8 спрайтов/стр. */
uint8_t idx = (uint8_t)(image & 7); uint8_t idx = (uint8_t)(image & 7);
const uint8_t *img; const uint8_t *img;
+252 -102
View File
@@ -19,6 +19,7 @@
#include <string.h> /* memcpy = LDIR: цикл на C тут стоил ~390 тактов на байт */ #include <string.h> /* memcpy = LDIR: цикл на C тут стоил ~390 тактов на байт */
#include "pop_kid.h" #include "pop_kid.h"
#include "pop_map.h" #include "pop_map.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "pop_ctrl.h" /* pop_ctrl_shift_held() — для check_grab */ #include "pop_ctrl.h" /* pop_ctrl_shift_held() — для check_grab */
#include "pop_geom.h" /* общая геометрия (x_bump/y_land/y_to_row) */ #include "pop_geom.h" /* общая геометрия (x_bump/y_land/y_to_row) */
#include "pop_tile.h" /* POP_TILE_DIV/MOD — деление на ширину тайла таблицей */ #include "pop_tile.h" /* POP_TILE_DIV/MOD — деление на ширину тайла таблицей */
@@ -330,6 +331,38 @@ uint8_t pop_tile_at(int8_t col, int8_t row) __banked
return get_tile(col, row); return get_tile(col, row);
} }
/* Тайлы ОТРЕЗКА ряда одним вызовом — для луча видимости стража.
*
* Зачем: pop_tile_at объявлен __banked, а луч живёт в guards.c (банк 1) и
* звал его ПО ОДНОЙ КОЛОНКЕ. На сцене 11/15 (Кид в колонке 2, страж в 8)
* это девять трамплинов банк 1 -> банк 3 за кадр, и весь луч стоил 36 786
* тактов 5,6 % работы кадра при том, что делает он девять чтений байта
* (замер 2026-08-19). Цена трамплина здесь та же, что уже измерена у
* pop_clip_char_top (8 892 такта ради одной проверки тайла).
*
* Колонки за пределами 0..9 разрешает сам get_tile (шов с соседней
* комнатой), поэтому диапазон отдаём как есть, без клипа.
*
* out обязан вмещать c1 - c0 + 1 байт; вызывающий даёт буфер на 12
* (колонки 1..10 плюс запас). */
void pop_row_tiles(int8_t row, int8_t c0, int8_t c1, uint8_t *out) __banked
{
int8_t col;
/* БЫСТРЫЙ ПУТЬ — отрезок целиком внутри комнаты. get_tile для ряда
* 0..2 и колонки 0..9 сводится ровно к `g_fg[row*10+col] & 0x1F`
* (см. его тело выше), а всё остальное там разбор швов и краёв
* уровня. Идём указателем: иначе на каждую колонку заново считается
* row*10 + col. */
if (row >= 0 && row <= 2 && c0 >= 0 && c1 <= 9 && g_fg) {
const uint8_t *p = g_fg + (int)row * 10 + c0;
for (col = c0; col <= c1; col++)
*out++ = (uint8_t)(*p++ & 0x1F);
return;
}
for (col = c0; col <= c1; col++)
*out++ = get_tile(col, row);
}
/* can_bump_into_gate (seg004:373): ОТКРЫТЫЕ ворота проходимы — Kid не бампит и /* can_bump_into_gate (seg004:373): ОТКРЫТЫЕ ворота проходимы — Kid не бампит и
* идёт сквозь них (на шве уходит в соседнюю комнату; внутри комнаты просто * идёт сквозь них (на шве уходит в соседнюю комнату; внутри комнаты просто
* проходит по полу под поднятыми барами). ВАЖНО: ворота остаются тайлом 4 (в * проходит по полу под поднятыми барами). ВАЖНО: ворота остаются тайлом 4 (в
@@ -1769,18 +1802,67 @@ static void calc_coll_window(void)
* Замер одной итерации в MAME: пустая колонка 750 тактов, колонка-стена * Замер одной итерации в MAME: пустая колонка 750 тактов, колонка-стена
* ~1 700 (там две 16-битные знаковые сверки граней). */ * ~1 700 (там две 16-битные знаковые сверки граней). */
static uint8_t scan_n; /* сколько колонок осталось */ static uint8_t scan_n; /* сколько колонок осталось */
static int scan_left; /* левая грань текущей колонки */ static uint8_t scan_left; /* левая грань текущей колонки */
static void coll_scan(const uint8_t *src, uint8_t *dst) /* ПОРОГИ СРАВНЕНИЯ, предпосчитанные на кадр (по типу стены 1..5).
*
* В теле цикла стояло `wall_dl[wt] + scan_left < coll_xr` и
* `scan_left - wall_dr[wt] + TILE_RIGHTX > coll_xl` четыре 16-битных
* операции на КАЖДУЮ колонку, при том что от колонки зависит ровно один
* операнд (scan_left). Переносим всё остальное в порог:
*
* scan_left < coll_xr - wall_dl[wt] = thr_l[wt]
* scan_left > coll_xl + wall_dr[wt] - TILE_RIGHTX = thr_r[wt]
*
* Оригинал считает это в лоб (get_left_wall_xpos / get_right_wall_xpos,
* seg004:0226) он писался под 386, где 16-битная арифметика бесплатна.
*
* ПОЧЕМУ 8 БИТ КОРРЕКТНЫ (доказательство относится и к границам, и к
* слагаемым в самом цикле). scan_left = x_bump[col + 5] + TILE_MIDX, а
* колонка окна лежит в COLL_C0 .. COLL_C0+COLL_N-1, то есть 2..11:
* значит scan_left [x_bump[3]+7, x_bump[16]+7] = [37, 219], и после
* последней колонки максимум 233. Ни одного выхода за uint8_t.
* Сами пороги считаются в int и КЛИПУЮТСЯ к 0..255 это не приближение,
* а точное сохранение результата: при пороге ниже 37 условие «меньше» не
* выполнится никогда (клип к 0 даёт то же), при пороге выше 219 оно
* выполнится всегда (клип к 255 даёт то же), и симметрично для «больше». */
static uint8_t coll_xr8, coll_xl8;
static uint8_t clip_thr(int v)
{
if (v < 0) return 0;
if (v > 255) return 255;
return (uint8_t)v;
}
/* Две границы персонажа, приведённые к 8 битам. ТАБЛИЦЫ ПОРОГОВ ПО ТИПУ
* СТЕНЫ ЗДЕСЬ БЫЛА И ОКАЗАЛАСЬ ХУЖЕ: предпосчёт пяти пар порогов стоил
* дороже, чем экономил, потому что колонок в окне всего четыре-пять, а
* типов стен пять (замер 2026-08-19: check_collisions 33 846 -> 36 570,
* синяя фаза +10 269). Классическая ошибка кэшировать больше, чем
* потребляешь. */
static void coll_thresholds(void)
{
coll_xr8 = clip_thr(coll_xr);
coll_xl8 = clip_thr(coll_xl);
}
static uint8_t *scan_dst;
static void coll_scan(const uint8_t *src)
{ {
do { do {
uint8_t wt = wall_type_tbl[*src++ & 0x1F]; uint8_t wt = wall_type_tbl[*src++ & 0x1F];
uint8_t f = 0; uint8_t f = 0;
if (wt) { if (wt) {
if (wall_dl[wt] + scan_left < coll_xr) f = 1; /* ВСЯ арифметика в 8 битах — см. доказательство диапазонов
if (scan_left - wall_dr[wt] + TILE_RIGHTX > coll_xl) f |= 2; * над coll_thresholds: scan_left [37, 233], wall_dl [1, 10],
* wall_dr [0, 13], значит обе суммы лежат в [36, 246] и
* переполниться не могут. */
if ((uint8_t)(scan_left + wall_dl[wt]) < coll_xr8) f = 1;
if ((uint8_t)(scan_left - wall_dr[wt] + TILE_RIGHTX) > coll_xl8) f |= 2;
} }
*dst++ = f; *scan_dst++ = f;
scan_left += TILE_SIZEX; scan_left += TILE_SIZEX;
} while (--scan_n); } while (--scan_n);
} }
@@ -1789,14 +1871,18 @@ static void coll_scan(const uint8_t *src, uint8_t *dst)
* в теле ряда, и пролог одного ряда стоил тысячи тактов при том, что сам * в теле ряда, и пролог одного ряда стоил тысячи тактов при том, что сам
* цикл по четырём-пяти колонкам около трёх. */ * цикл по четырём-пяти колонкам около трёх. */
static uint8_t scan_n0; static uint8_t scan_n0;
static int scan_left0; static uint8_t scan_left0;
static uint8_t scan_off; /* индекс первого слота в flags[] */ static uint8_t scan_off; /* индекс первого слота в flags[] */
static void coll_scan_prepare(void) static void coll_scan_prepare(void)
{ {
scan_left0 = pop_x_bump[win_lo + FIRST_ONSCREEN_COLUMN] + TILE_MIDX; scan_left0 = (uint8_t)(pop_x_bump[win_lo + FIRST_ONSCREEN_COLUMN] + TILE_MIDX);
scan_n0 = (uint8_t)(win_hi - win_lo + 1); scan_n0 = (uint8_t)(win_hi - win_lo + 1);
scan_off = (uint8_t)COLL_IDX(win_lo); scan_off = (uint8_t)COLL_IDX(win_lo);
coll_thresholds(); /* здесь же, чтобы порог и окно всегда были от
* ОДНОГО персонажа та же причина, по которой
* тут считается scan_left0 (см. BUG-GUARD-IX-1
* в шапке get_row_collision_data). */
} }
/* Базы ряда: указатели, которые индексируются НОМЕРОМ КОЛОНКИ (в том числе /* Базы ряда: указатели, которые индексируются НОМЕРОМ КОЛОНКИ (в том числе
@@ -1838,7 +1924,7 @@ static void coll_row(uint8_t *flags)
last = win_hi < -1 ? win_hi : -1; last = win_hi < -1 ? win_hi : -1;
n = (uint8_t)(last - lo + 1); n = (uint8_t)(last - lo + 1);
scan_n = n; /* coll_scan обнулит */ scan_n = n; /* coll_scan обнулит */
coll_scan(rb_lft ? rb_lft + lo : wall_row, dst); scan_dst = dst; coll_scan(rb_lft ? rb_lft + lo : wall_row);
dst += n; dst += n;
lo = (int8_t)(last + 1); lo = (int8_t)(last + 1);
} }
@@ -1846,13 +1932,13 @@ static void coll_row(uint8_t *flags)
last = win_hi < 9 ? win_hi : 9; last = win_hi < 9 ? win_hi : 9;
n = (uint8_t)(last - lo + 1); n = (uint8_t)(last - lo + 1);
scan_n = n; scan_n = n;
coll_scan(rb_own ? rb_own + lo : wall_row, dst); scan_dst = dst; coll_scan(rb_own ? rb_own + lo : wall_row);
dst += n; dst += n;
lo = (int8_t)(last + 1); lo = (int8_t)(last + 1);
} }
if (lo <= win_hi) { /* комната СПРАВА */ if (lo <= win_hi) { /* комната СПРАВА */
scan_n = (uint8_t)(win_hi - lo + 1); scan_n = (uint8_t)(win_hi - lo + 1);
coll_scan(rb_rgt ? rb_rgt + lo : wall_row, dst); scan_dst = dst; coll_scan(rb_rgt ? rb_rgt + lo : wall_row);
} }
} }
@@ -1908,12 +1994,18 @@ static void check_collisions(void)
return; return;
} }
pop_dbg_p2(); /* ЗАМЕР: вход в тело */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
set_char_collision(); set_char_collision();
pop_dbg_p3(); /* ЗАМЕР: set_char_collision */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
move_coll_to_prev(Char.curr_row); /* заодно снимет окно prev */ move_coll_to_prev(Char.curr_row); /* заодно снимет окно prev */
coll_prev_row = Char.curr_row; coll_prev_row = Char.curr_row;
calc_coll_window(); calc_coll_window();
coll_lo = win_lo; coll_hi = win_hi; coll_lo = win_lo; coll_hi = win_hi;
coll_scan_prepare(); coll_scan_prepare();
pop_dbg_p4(); /* ЗАМЕР: окно посчитано */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Порядок важен: prev уже забран, теперь три ряда пересчитываются /* Порядок важен: prev уже забран, теперь три ряда пересчитываются
* НА ЭТОТ кадр (в оригинале ровно так же, seg004:0004). Ряд здесь * НА ЭТОТ кадр (в оригинале ровно так же, seg004:0004). Ряд здесь
* гарантированно 0..2, поэтому соседние ряды это те же базы ±10, без * гарантированно 0..2, поэтому соседние ряды это те же базы ±10, без
@@ -1947,6 +2039,8 @@ static void check_collisions(void)
} }
coll_row(coll_above); coll_row(coll_above);
} }
pop_dbg_p5(); /* ЗАМЕР: три ряда просканированы */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Обход СВЕРХУ ВНИЗ, как в оригинале (9→0): побеждает МЛАДШАЯ колонка, /* Обход СВЕРХУ ВНИЗ, как в оригинале (9→0): побеждает МЛАДШАЯ колонка,
* в которой флаг перешёл 01. Только по ПЕРЕСЕЧЕНИЮ окон: вне его * в которой флаг перешёл 01. Только по ПЕРЕСЕЧЕНИЮ окон: вне его
* сравнивать нечего (у оригинала там prev_coll_room != curr_row_coll_room * сравнивать нечего (у оригинала там prev_coll_room != curr_row_coll_room
@@ -2133,6 +2227,26 @@ static void check_bumped(void)
* make_loose_fall/check_press. Перерисовка в pop_bg (shake/bake). */ * make_loose_fall/check_press. Перерисовка в pop_bg (shake/bake). */
uint8_t pop_loose_modif[30]; /* публично: читает pop_bg при отрисовке */ uint8_t pop_loose_modif[30]; /* публично: читает pop_bg при отрисовке */
/* Гейт холостого хода pop_loose_tick. В подавляющем большинстве кадров
* (и в целых комнатах) НИ ОДНА плита не анимируется, а два цикла по 30 и 10
* позициям всё равно отрабатывают: замер 11/15 9 852 такта на комнату,
* где loose-плит нет вовсе.
*
* Ставится ПРИ ВЗВОДЕ фазы все ШЕСТЬ мест записи ненулевого значения лежат
* в этом файле (make_loose_fall, ветка потолка в check_press, do_knock для
* обоих рядов, восстановление из room_modif в pop_loose_reset и раздача
* отложенного старта в check_fall_flo уровень 13).
*
* ИМЕННО ЗДЕСЬ УЖЕ ОШИБЛИСЬ ОДИН РАЗ: check_fall_flo забыли, и каскад
* уровня 13 перестал падать плиты дрожали и застывали. Добавляя новое
* место записи фазы, добавляй и взвод. Снимает его
* САМ цикл, когда прошёл оба массива и не встретил ни одной ненулевой фазы.
*
* Асимметрия намеренная: ложная единица стоит одного холостого прохода,
* ложный ноль застывшей навсегда плиты. Поэтому взвод обязан стоять
* рядом с КАЖДОЙ записью, а снятие только по факту пустого прохода. */
static uint8_t loose_any;
/* Плита-ПОТОЛОК: состояние/сигналы — см. объявления перед get_tile. */ /* Плита-ПОТОЛОК: состояние/сигналы — см. объявления перед get_tile. */
/* make_loose_fall (seg007:0EF6): взвести отсчёт падения, если ещё не идёт. */ /* make_loose_fall (seg007:0EF6): взвести отсчёт падения, если ещё не идёт. */
@@ -2141,8 +2255,10 @@ static void make_loose_fall(int pos, uint8_t modifier)
if (pos < 0 || pos >= 30) return; if (pos < 0 || pos >= 30) return;
if ((g_fg[pos] & 0x1F) != TILE_LOOSE) return; if ((g_fg[pos] & 0x1F) != TILE_LOOSE) return;
if ((g_fg[pos] & 0x20)) return; /* «solid» loose — не от шага */ if ((g_fg[pos] & 0x20)) return; /* «solid» loose — не от шага */
if ((int8_t)pop_loose_modif[pos] <= 0) /* покой/тряска, не отсчёт */ if ((int8_t)pop_loose_modif[pos] <= 0) { /* покой/тряска, не отсчёт */
pop_loose_modif[pos] = modifier; pop_loose_modif[pos] = modifier;
loose_any = 1;
}
} }
/* died_on_button (seg007:0776): на кнопке КТО-ТО УМЕР — эффект становится /* died_on_button (seg007:0776): на кнопке КТО-ТО УМЕР — эффект становится
@@ -2213,6 +2329,7 @@ static void check_press(void)
(g_above[c] & 0x1F) == TILE_LOOSE && !(g_above[c] & 0x20) && (g_above[c] & 0x1F) == TILE_LOOSE && !(g_above[c] & 0x20) &&
(int8_t)pop_ceil_modif[c] <= 0) { (int8_t)pop_ceil_modif[c] <= 0) {
pop_ceil_modif[c] = 1; /* make_loose_fall(1) */ pop_ceil_modif[c] = 1; /* make_loose_fall(1) */
loose_any = 1;
is_guard_notice = 1; /* seg006:1734 */ is_guard_notice = 1; /* seg006:1734 */
} }
} else if (get_tile_above_char() == TILE_LOOSE) { } else if (get_tile_above_char() == TILE_LOOSE) {
@@ -2266,16 +2383,20 @@ static void do_knock(int tile_row)
if (tile_row == -1) { /* ряд 2 комнаты сверху — плиты-потолки */ if (tile_row == -1) { /* ряд 2 комнаты сверху — плиты-потолки */
if (!g_above || !g_link_u) return; if (!g_above || !g_link_u) return;
for (col = 0; col < 10; col++) for (col = 0; col < 10; col++)
if ((g_above[col] & 0x1F) == TILE_LOOSE && pop_ceil_modif[col] == 0) if ((g_above[col] & 0x1F) == TILE_LOOSE && pop_ceil_modif[col] == 0) {
pop_ceil_modif[col] = 0x80; pop_ceil_modif[col] = 0x80;
loose_any = 1;
}
return; return;
} }
if (tile_row < 0 || tile_row > 2) return; if (tile_row < 0 || tile_row > 2) return;
for (col = 0; col < 10; col++) { for (col = 0; col < 10; col++) {
pos = tile_row * 10 + col; pos = tile_row * 10 + col;
if ((g_fg[pos] & 0x1F) != TILE_LOOSE) continue; if ((g_fg[pos] & 0x1F) != TILE_LOOSE) continue;
if (pop_loose_modif[pos] == 0) if (pop_loose_modif[pos] == 0) {
pop_loose_modif[pos] = 0x80; pop_loose_modif[pos] = 0x80;
loose_any = 1;
}
} }
} }
@@ -2393,11 +2514,13 @@ void pop_loose_reset(void) __banked
for (i = 0; i < 30; i++) { for (i = 0; i < 30; i++) {
pop_loose_modif[i] = (mod && g_fg && (g_fg[i] & 0x1F) == TILE_LOOSE) pop_loose_modif[i] = (mod && g_fg && (g_fg[i] & 0x1F) == TILE_LOOSE)
? mod[i] : 0; ? mod[i] : 0;
if (pop_loose_modif[i]) loose_any = 1; /* взвод гейта — см. loose_any */
} }
mod = (g_above && g_link_u) ? pop_trob_modif(g_link_u) : 0; mod = (g_above && g_link_u) ? pop_trob_modif(g_link_u) : 0;
for (i = 0; i < 10; i++) { for (i = 0; i < 10; i++) {
pop_ceil_modif[i] = (mod && (g_above[i] & 0x1F) == TILE_LOOSE) pop_ceil_modif[i] = (mod && (g_above[i] & 0x1F) == TILE_LOOSE)
? mod[20 + i] : 0; ? mod[20 + i] : 0;
if (pop_ceil_modif[i]) loose_any = 1; /* взвод гейта — см. loose_any */
/* ...и СНЯТЬ заочный trob: плиту-потолок отсюда снова ведёт /* ...и СНЯТЬ заочный trob: плиту-потолок отсюда снова ведёт
* pop_loose_tick. Без этого её крутили бы ДВОЕ trob по * pop_loose_tick. Без этого её крутили бы ДВОЕ trob по
* room_modif и мы по pop_ceil_modif, и она проваливалась бы * room_modif и мы по pop_ceil_modif, и она проваливалась бы
@@ -2483,6 +2606,12 @@ void pop_check_fall_flo(void) __banked
if ((int8_t)pop_ceil_modif[col] > 0) continue; if ((int8_t)pop_ceil_modif[col] > 0) continue;
pop_ceil_modif[col] = pop_ceil_modif[col] =
(uint8_t)(-(int8_t)(pop_prandom(&loose_seed, 255) & 0x0F) - 1); (uint8_t)(-(int8_t)(pop_prandom(&loose_seed, 255) & 0x0F) - 1);
loose_any = 1; /* ШЕСТОЕ место взвода гейта — см. loose_any.
* Пропуск именно здесь стоил каскада на
* уровне 13: плиты получали отложенный
* старт, но цикл их не досчитывал, и они
* дрожали, не падая (нашёл пользователь
* 2026-08-19). */
pop_set_redraw_above(col, POP_RDA_CEIL, 1); pop_set_redraw_above(col, POP_RDA_CEIL, 1);
} }
} }
@@ -2525,6 +2654,10 @@ static void check_loose_fall_on_kid(void)
{ {
int mcol, my; int mcol, my;
if (pop_kid_dead) return; if (pop_kid_dead) return;
/* Ни одного куска в воздухе — не платим за банковый трамплин в
* pop_loose_mob_pos с обходом 14 слотов (замер 11/15: 5 868 тактов на
* комнату, где не падает ничего). */
if (!pop_mob_busy) return;
if (!pop_loose_mob_pos(&mcol, &my)) return; if (!pop_loose_mob_pos(&mcol, &my)) return;
/* Своё окно Char, как в оригинале (seg007:1199 — loadkid/savekid внутри /* Своё окно Char, как в оригинале (seg007:1199 — loadkid/savekid внутри
* самой функции): зовут нас из pop_loose_tick, а тот идёт в главном * самой функции): зовут нас из pop_loose_tick, а тот идёт в главном
@@ -2557,102 +2690,111 @@ void pop_loose_tick(void) __banked
* Инкремент+сравнение считают row/col без деления. */ * Инкремент+сравнение считают row/col без деления. */
uint8_t row = 0, col = 0; uint8_t row = 0, col = 0;
pop_dbg_m9(); /* ЗАМЕР: вход pop_loose_tick */ pop_dbg_m9(); /* ЗАМЕР: вход pop_loose_tick */
for (pos = 0; pos < 30; pos++, lm++) { pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
if (*lm != 0) { /* Гейт холостого хода: если ни одна фаза не взведена, оба цикла (30 + 10
uint8_t m = ++*lm; * позиций) пропускаем целиком замер 11/15 дал 9 852 такта на комнату,
/* Отложенный старт уровня 13 досчитал до нуля. Ноль у нас * где loose-плит нет вовсе. Флаг ставят места записи фазы, снимаем его
* означает «не анимируется», и плита застряла бы навсегда * здесь и только по факту прохода, в котором не осталось ни одной живой
* перескакиваем на 1. Стартовое значение выбрано с учётом * фазы (см. объявление loose_any). */
* этого перескока (см. pop_check_fall_flo). */ if (loose_any) {
if (m == 0) m = *lm = 1; uint8_t found = 0;
if (m & 0x80) { /* тряска (do_knock) */ for (pos = 0; pos < 30; pos++, lm++) {
/* СПЕЦКЕЙС УРОВНЯ 13 (seg007:823): фазу со старшим битом НЕ if (*lm != 0) {
* гасим и не трясём. На нём check_fall_flo раздаёт плитам uint8_t m = ++*lm;
* ОТРИЦАТЕЛЬНЫЙ старт (0xF0..0xFF) как отложенный таймер, и /* Отложенный старт уровня 13 досчитал до нуля. Ноль у нас
* обычная ветка «тряска кончилась на 0x84» убила бы его в * означает «не анимируется», и плита застряла бы навсегда
* первом же кадре плита не упала бы никогда. Пропущенная * перескакиваем на 1. Стартовое значение выбрано с учётом
* инкрементом фаза сама дойдёт до 0x00, там старший бит * этого перескока (см. pop_check_fall_flo). */
* снимется, и дальше пойдёт обычный отсчёт до провала. */ if (m == 0) m = *lm = 1;
if (pop_current_level == POP_LOOSE_TILES_LEVEL) { if (m & 0x80) { /* тряска (do_knock) */
/* ждём молча */ /* СПЕЦКЕЙС УРОВНЯ 13 (seg007:823): фазу со старшим битом НЕ
} else if (m >= 0x84) { /* конец тряски: сброс + покой */ * гасим и не трясём. На нём check_fall_flo раздаёт плитам
* ОТРИЦАТЕЛЬНЫЙ старт (0xF0..0xFF) как отложенный таймер, и
* обычная ветка «тряска кончилась на 0x84» убила бы его в
* первом же кадре плита не упала бы никогда. Пропущенная
* инкрементом фаза сама дойдёт до 0x00, там старший бит
* снимется, и дальше пойдёт обычный отсчёт до провала. */
if (pop_current_level == POP_LOOSE_TILES_LEVEL) {
/* ждём молча */
} else if (m >= 0x84) { /* конец тряски: сброс + покой */
*lm = 0;
/* modif уже 0 → кадр покоя обоих тайлов (col и col+1:
* правая грань loose живёт в соседе). ОБЕ страницы
* иначе на одной застывает дрожащий кадр мерцание. */
pop_set_redraw(pos, POP_RD_LOOSE, 2);
} else {
pop_set_redraw(pos, POP_RD_LOOSE, 1); /* дрожащий кадр */
}
} else if (m >= 11 && pop_loose_fell) {
/* Сигнал прошлого провала ещё не разобран главным циклом (он
* ОДИН на кадр) придержим плиту кадр, откатив фазу к 10.
* Иначе из двух плит, провалившихся в одном кадре, дыру
* запекала бы только вторая, а первая оставалась нарисованной
* целой (уровень 13: check_fall_flo роняет гряду разом). */
*lm = 10;
} else if (m >= 11) { /* loose_floor_delay — падение */
/* remove_loose (seg007:0EB8): тайл → EMPTY (не debris!).
* Debris появляется ТОЛЬКО там, где приземлился падающий
* кусок (mob), т.е. рядом ниже. Loose в нижнем ряду (2,6)
* кусок улетает из комнаты вниз, на месте остаётся пусто
* (стена-вниз от тайла снизу + чёрный фон). */
g_fg[pos] = TILE_EMPTY;
/* remove_loose возвращает ТИП УРОВНЯ, и вызывающий кладёт
* его модификатором пустой клетки (seg007:846/1083): по нему
* draw_tile_right выбирает blueline_fram1 у дыры. */
pop_trob_modif(g_room)[pos] = pop_palace;
*lm = 0; *lm = 0;
/* modif уже 0 → кадр покоя обоих тайлов (col и col+1: pop_set_redraw(pos, POP_RD_LOOSE_GONE, 2); /* запечь пусто, обе стр. */
* правая грань loose живёт в соседе). ОБЕ страницы /* ...и СОСЕДА СПРАВА: передний торец пола заезжает в его
* иначе на одной застывает дрожащий кадр мерцание. */ * клетку, и без этой пометки он оставался висеть над пустым
pop_set_redraw(pos, POP_RD_LOOSE, 2); * тайлом. То же, что делает pop_skel_wake_tile. */
} else { if ((pos % 10) < 9)
pop_set_redraw(pos, POP_RD_LOOSE, 1); /* дрожащий кадр */ pop_set_redraw((uint8_t)(pos + 1), POP_RD_FLOOR, 2);
pop_loose_mob_spawn(row, col); /* отрыв падающего куска */
pop_loose_fell = (uint8_t)(pos + 1); /* сигнал приложению */
} else { /* отсчёт 1..10 — дрожащий кадр */
pop_set_redraw(pos, POP_RD_LOOSE, 1);
} }
} else if (m >= 11 && pop_loose_fell) { if (*lm) found = 1; /* фаза ещё жива — гейт держим */
/* Сигнал прошлого провала ещё не разобран главным циклом (он }
* ОДИН на кадр) придержим плиту кадр, откатив фазу к 10. if (++col == 10) { col = 0; row++; } /* row/col без деления */
* Иначе из двух плит, провалившихся в одном кадре, дыру }
* запекала бы только вторая, а первая оставалась нарисованной /* Плиты-ПОТОЛКИ (ряд 2 комнаты сверху): тот же animate_loose, но кадры
* целой (уровень 13: check_fall_flo роняет гряду разом). */ * рисуются в полосе у потолка, а провалившаяся плита становится EMPTY в
*lm = 10; * НАШЕЙ копии ряда (g_above) сверху открывается колодец. */
} else if (m >= 11) { /* loose_floor_delay — падение */ lm = pop_ceil_modif; /* тот же указательный обход */
/* remove_loose (seg007:0EB8): тайл → EMPTY (не debris!). for (col = 0; col < 10; col++, lm++) {
* Debris появляется ТОЛЬКО там, где приземлился падающий if (*lm != 0) {
* кусок (mob), т.е. рядом ниже. Loose в нижнем ряду (2,6) uint8_t m = ++*lm;
* кусок улетает из комнаты вниз, на месте остаётся пусто if (m == 0) m = *lm = 1; /* см. pop_loose_tick выше */
* (стена-вниз от тайла снизу + чёрный фон). */ if (m & 0x80) { /* тряска от сотрясения (do_knock) */
g_fg[pos] = TILE_EMPTY; /* Тот же спецкейс уровня 13, и здесь он ГЛАВНЫЙ: плиты,
/* remove_loose возвращает ТИП УРОВНЯ, и вызывающий кладёт * которым check_fall_flo раздал отложенный старт, лежат
* его модификатором пустой клетки (seg007:846/1083): по нему * именно в ряду 2 комнаты СВЕРХУ, то есть ведёт их этот
* draw_tile_right выбирает blueline_fram1 у дыры. */ * цикл, а не соседний. */
pop_trob_modif(g_room)[pos] = pop_palace; if (pop_current_level == POP_LOOSE_TILES_LEVEL) {
*lm = 0; /* ждём молча */
pop_set_redraw(pos, POP_RD_LOOSE_GONE, 2); /* запечь пусто, обе стр. */ } else if (m >= 0x84) { *lm = 0;
/* ...и СОСЕДА СПРАВА: передний торец пола заезжает в его pop_set_redraw_above(col, POP_RDA_CEIL, 2); }
* клетку, и без этой пометки он оставался висеть над пустым else pop_set_redraw_above(col, POP_RDA_CEIL, 1);
* тайлом. То же, что делает pop_skel_wake_tile. */ } else if (m >= 11 && pop_ceil_fell) {
if ((pos % 10) < 9) *lm = 10; /* сигнал занят — кадр подождём */
pop_set_redraw((uint8_t)(pos + 1), POP_RD_FLOOR, 2); } else if (m >= 11) { /* loose_floor_delay — плита рушится */
pop_loose_mob_spawn(row, col); /* отрыв падающего куска */ *lm = 0;
pop_loose_fell = (uint8_t)(pos + 1); /* сигнал приложению */ if (g_above) g_above[col] = TILE_EMPTY; /* remove_loose */
} else { /* отсчёт 1..10 — дрожащий кадр */ pop_set_redraw_above(col, POP_RDA_CEIL_GONE, 2); /* колодец, обе стр. */
pop_set_redraw(pos, POP_RD_LOOSE, 1); pop_loose_mob_spawn(-1, col); /* кусок отрывается от потолка */
pop_ceil_fell = (uint8_t)(col + 1); /* сигнал приложению */
} else {
pop_set_redraw_above(col, POP_RDA_CEIL, 1); /* отсчёт 1..10 */
}
if (*lm) found = 1; /* фаза ещё жива — гейт держим */
} }
} }
if (++col == 10) { col = 0; row++; } /* row/col без деления */ loose_any = found;
} }
/* Плиты-ПОТОЛКИ (ряд 2 комнаты сверху): тот же animate_loose, но кадры
* рисуются в полосе у потолка, а провалившаяся плита становится EMPTY в
* НАШЕЙ копии ряда (g_above) сверху открывается колодец. */
lm = pop_ceil_modif; /* тот же указательный обход */
for (col = 0; col < 10; col++, lm++) {
if (*lm != 0) {
uint8_t m = ++*lm;
if (m == 0) m = *lm = 1; /* см. pop_loose_tick выше */
if (m & 0x80) { /* тряска от сотрясения (do_knock) */
/* Тот же спецкейс уровня 13, и здесь он ГЛАВНЫЙ: плиты,
* которым check_fall_flo раздал отложенный старт, лежат
* именно в ряду 2 комнаты СВЕРХУ, то есть ведёт их этот
* цикл, а не соседний. */
if (pop_current_level == POP_LOOSE_TILES_LEVEL) {
/* ждём молча */
} else if (m >= 0x84) { *lm = 0;
pop_set_redraw_above(col, POP_RDA_CEIL, 2); }
else pop_set_redraw_above(col, POP_RDA_CEIL, 1);
} else if (m >= 11 && pop_ceil_fell) {
*lm = 10; /* сигнал занят — кадр подождём */
} else if (m >= 11) { /* loose_floor_delay — плита рушится */
*lm = 0;
if (g_above) g_above[col] = TILE_EMPTY; /* remove_loose */
pop_set_redraw_above(col, POP_RDA_CEIL_GONE, 2); /* колодец, обе стр. */
pop_loose_mob_spawn(-1, col); /* кусок отрывается от потолка */
pop_ceil_fell = (uint8_t)(col + 1); /* сигнал приложению */
} else {
pop_set_redraw_above(col, POP_RDA_CEIL, 1); /* отсчёт 1..10 */
}
}
}
pop_dbg_m10(); /* ЗАМЕР: оба цикла по тайлам пройдены */
pop_loose_mob_tick(); /* продвинуть+нарисовать падающий кусок (после тайлов) */ pop_loose_mob_tick(); /* продвинуть+нарисовать падающий кусок (после тайлов) */
pop_dbg_m11(); /* ЗАМЕР: pop_loose_mob_tick сделан */
check_loose_fall_on_kid(); /* seg007:1192 — плита падает Киду на голову */ check_loose_fall_on_kid(); /* seg007:1192 — плита падает Киду на голову */
pop_dbg_m12(); /* ЗАМЕР: check_loose_fall_on_kid сделан */
/* Отложенная допечка ПРОШЛОГО приземления на вторую страницу — ПЕРЕД /* Отложенная допечка ПРОШЛОГО приземления на вторую страницу — ПЕРЕД
* разбором нового: иначе при двух плитах, севших подряд, land_bake * разбором нового: иначе при двух плитах, севших подряд, land_bake
* затирался новым и на одной странице оставался старый пол. */ * затирался новым и на одной странице оставался старый пол. */
@@ -3027,9 +3169,15 @@ static void kid_phys(void)
determine_col(); determine_col();
bump_into_opponent(); /* seg003: безоружный Кид отскакивает от стража */ bump_into_opponent(); /* seg003: безоружный Кид отскакивает от стража */
check_collisions(); /* seg004: флаги перекрытия по колонкам ряда */ check_collisions(); /* seg004: флаги перекрытия по колонкам ряда */
pop_dbg_p6(); /* ЗАМЕР: check_collisions целиком */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
check_bumped(); /* удержать у стены до check_action (порядок PoP) */ check_bumped(); /* удержать у стены до check_action (порядок PoP) */
check_action(); check_action();
pop_dbg_p7(); /* ЗАМЕР */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
check_press(); /* seg006: стойка/пробой loose → make_loose_fall */ check_press(); /* seg006: стойка/пробой loose → make_loose_fall */
pop_dbg_p8(); /* ЗАМЕР */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
check_spike_below(); /* seg006: над колонкой с пиками → выдвинуть пики */ check_spike_below(); /* seg006: над колонкой с пиками → выдвинуть пики */
check_spiked(); /* seg006: напоролся на вредные пики → смерть */ check_spiked(); /* seg006: напоролся на вредные пики → смерть */
check_chomped_kid(); /* seg004: перемололо в сомкнутых челюстях → смерть */ check_chomped_kid(); /* seg004: перемололо в сомкнутых челюстях → смерть */
@@ -3089,6 +3237,8 @@ void pop_phys_tick(void) __banked
* оригинале: кадр смерти не двигается сам (dx/dy нулевые, sequence * оригинале: кадр смерти не двигается сам (dx/dy нулевые, sequence
* кончился), а уход из комнаты и сотрясение отсечены внутри kid_phys. */ * кончился), а уход из комнаты и сотрясение отсечены внутри kid_phys. */
pop_loadkid_and_opp(); pop_loadkid_and_opp();
pop_dbg_p1(); /* ЗАМЕР: окно Char загружено */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
kid_phys(); kid_phys();
pop_savekid_and_opp(); pop_savekid_and_opp();
} }
+4
View File
@@ -58,6 +58,10 @@ void pop_map_set_room(uint8_t room) __banked;
* (guards.c): он идёт по ряду между Кидом и стражем. */ * (guards.c): он идёт по ряду между Кидом и стражем. */
uint8_t pop_tile_at(int8_t col, int8_t row) __banked; uint8_t pop_tile_at(int8_t col, int8_t row) __banked;
/* Тайлы отрезка ряда c0..c1 одним банковым вызовом (см. тело в pop_map.c):
* луч видимости стража иначе платит по трамплину за КАЖДУЮ колонку. */
void pop_row_tiles(int8_t row, int8_t c0, int8_t c1, uint8_t *out) __banked;
/* determine_col (seg006:014D): Kid.curr_col = m7(dx_weight()). Наружу — для /* determine_col (seg006:014D): Kid.curr_col = m7(dx_weight()). Наружу — для
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */ * pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
void pop_determine_col(void) __banked; void pop_determine_col(void) __banked;
+100
View File
@@ -0,0 +1,100 @@
/*
* pop_pace.c фиксированный логический кадр по лучу. Зачем именно так,
* а не по счётчику кадровых прерываний в шапке pop_pace.h (там же
* условие точности и замеры).
*
* Модуль НЕ банковый (EXTRA_SRCS): pop_beam_sample зовётся из банков
* рисования, а базовый код в W1 доступен им прямым call трамплин на
* каждую выборку съел бы весь смысл.
*/
#include <stdint.h>
#include <gfx.h>
#include <kbd_raw.h>
#include "pop_pace.h"
uint8_t pop_speed_mode; /* crt0 зануляет _DATA → NORMAL */
volatile uint8_t pop_frame_tick;
/* Бит 5 порта 0xFE на прошлой выборке: 0 или 0x20. На виду у asm-тела. */
uint8_t pop_beam_prev;
static uint8_t pace_anchor; /* тик на начало текущего логического кадра */
static uint8_t pace_ok; /* 0 — луч не работает, откат на gfx_wait_vsync */
/* ---- выборка луча --------------------------------------------------- *
* in a,(0xFE) кладёт A на старший байт адреса (выбор ряда клавиатуры)
* биты 5/7 от него не зависят, так же читает и gfx_wait_vsync.
* Быстрый путь (состояние не изменилось) 40 T + вызов. */
void pop_beam_sample(void) __preserves_regs(b, c, d, e) __naked
{
__asm
in a, (#0xFE)
and a, #0x20
ld hl, #_pop_beam_prev
cp a, (hl)
ret Z ; состояние то же выходим сразу
ld (hl), a
or a, a
ret NZ ; 01: вошли в бланк, это не граница
ld hl, #_pop_frame_tick
inc (hl) ; 10: vpos = 0, начало кадра
ret
__endasm;
}
uint8_t pop_pace_arm(void)
{
uint16_t tries;
uint8_t t0;
/* Взводит cbl_mode (_cbl_port_ref) и заодно ставит нас на фронт. */
gfx_wait_vsync();
/* Затравка beam_prev без ложного фронта: что бы луч ни показал сейчас,
* первая выборка либо совпадёт (ранний выход), либо запишет 0x20 и
* уйдёт по ветке «вошли в бланк». */
pop_beam_prev = 0;
pop_beam_sample();
t0 = pop_frame_tick;
for (tries = 0; tries < 20000; tries++) {
pop_beam_sample();
if ((uint8_t)(pop_frame_tick - t0) >= 2) {
pace_anchor = pop_frame_tick;
pace_ok = 1;
return 1;
}
}
pace_ok = 0; /* бит не шевелится — прежнее поведение */
return 0;
}
void pop_wait_edge(void)
{
uint8_t t;
if (!pace_ok) { gfx_wait_vsync(); return; }
t = pop_frame_tick;
do {
pop_beam_sample();
kbd_raw_poll(); /* KBD-1: опрос обязан быть плотным */
} while (pop_frame_tick == t);
}
void pop_pace_end(uint8_t n)
{
if (!pace_ok) { /* откат: как было — добрать ожиданиями */
while (--n) gfx_wait_vsync();
return;
}
while ((uint8_t)(pop_frame_tick - pace_anchor) < n)
pop_wait_edge();
pace_anchor = pop_frame_tick; /* якорь ПО ФАКТУ — фаза не копится */
}
uint8_t pop_pace_n(uint8_t fight)
{
if (pop_speed_mode == POP_SPEED_NORMAL) return fight ? 5 : 4;
if (pop_speed_mode == POP_SPEED_FAST) return fight ? 4 : 3;
return 3; /* FASTEST: и вне боя, и в бою */
}
+102
View File
@@ -0,0 +1,102 @@
/*
* pop_pace.h фиксированный логический кадр (frame pacing) по ЛУЧУ.
*
* Задача: период логического кадра = max(n, ceil(W)) вместо нынешнего
* ceil(W) + 2, где W работа в растровых кадрах. Сегодня главный цикл
* ждёт три gfx_wait_vsync ПОСЛЕ отрисовки, поэтому реальный бюджет кадра
* один растр (430 080 тактов), и превышение его на такт стоит целого
* лишнего растра. При n=3 бюджет становится 1 290 240.
*
* ПОЧЕМУ НЕ СЧЁТЧИК КАДРОВЫХ ПРЕРЫВАНИЙ (замеры 2026-08-19, MAME, полный
* разбор в docs/frame_pacing_plan.md). Импульс запроса кадрового
* прерывания живёт 32 такта неразогнанного клока = 9,14 мкс, а ядра
* акселератора держат di на весь чанк блита (~0,29 мс). Импульс,
* попавший в такое окно, теряется насовсем ядро z80 уровневое, защёлки
* нет (то же и на настоящем Spectrum). Замерено: в сцене 11/15 теряется
* от 0 до 2,8 % прерываний в зависимости от ФАЗЫ рендера относительно
* луча, а на полной перерисовке комнаты три подряд. Сейчас фаза
* плавает (период то 3 растра, то 4) и потери размазаны; при жёстком
* пейсинге фаза застынет, и сцена может залипнуть в плохой это ровные
* 25 % скорости, невидимые в профиле тактов.
*
* ЧТО ВМЕСТО. Бит 5 порта 0xFE это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА (1, когда
* vpos >= 272; MAME sprinter.cpp kbd_fe_r), а не событие: ни импульса,
* ни очереди, ни защёлки. Такой сигнал невозможно потерять можно
* только не посмотреть. Считаем фронты 10 (это vpos = 0, ровно тот же
* момент, которого ждёт gfx_wait_vsync) программно, выборкой из точек,
* которые код и так проходит.
*
* УСЛОВИЕ ТОЧНОСТИ ОДНО: между соседними выборками должно проходить
* меньше 64 512 тактов (длина окна «бит 5 = 1»: 48 строк из 320). Тогда
* в каждый бланк попадает хотя бы одна выборка, а в промежуток между
* бланками (365 568 тактов) тем более; значит каждый фронт засчитан
* ровно один раз. Двойной счёт невозможен: инкремент только на переходе,
* и beam_prev тут же обновляется. Условие не про нагрузку, не про di,
* не про прерывания и не про звук только про зазор.
*
* Запас: самый длинный неделимый кусок без выборки одно di-окно, то
* есть один вызов _bgi_blit_rows_raw, а он по контракту режется на чанки
* 16 строк. Даже если считать грубо одна выборка на целый блит, а
* самый дорогой замеренный блит 32 073 такта зазор вдвое меньше окна.
*
* ВАЖНО: бит 5 читается осмысленно ТОЛЬКО при включённом cbl_mode (в
* MAME `data &= ~0xa0` стоит внутри `if (cbl_mode())`, иначе бит выставлен
* в 1 намертво). Его лениво взводит сам gfx_wait_vsync через
* _cbl_port_ref, поэтому pop_pace_arm обязан один раз позвать
* gfx_wait_vsync и ПРОВЕРИТЬ, что фронты реально идут.
*/
#ifndef POP_PACE_H
#define POP_PACE_H
#include <stdint.h>
/* Режимы скорости. Растровый кадр Sprinter — 20,48 мс (48,83 Гц).
* Оригинал (SDLPoP/src/seg003.c:363): base_speed 5 тиков при 60 Гц =
* 83,3 мс, fight_speed 6 = 100 мс. Условие боя у оригинала одно и
* буквальное «у Кида ВЫНУТ МЕЧ» (Kid.sword == sword_2_drawn), не
* «идёт бой» и не «рядом страж»; проверяется в верху главного цикла. */
/* Нумерация НЕ произвольная: 0 обязан быть дефолтом (crt0 зануляет _DATA),
* а обход по кругу инкрементом даёт ровно NORMAL -> FAST -> FASTEST.
* Заодно номер режима = число палочек в отладочной метке минус один. */
#define POP_SPEED_NORMAL 0 /* 4/5 — 81,9 / 102,4 мс; повтор оригинала */
#define POP_SPEED_FAST 1 /* 3/4 — 61,4 / 81,9 мс; бой как в оригинале */
#define POP_SPEED_FASTEST 2 /* 3/3 — 61,4 мс; не успели — подтормаживаем */
#define POP_SPEED_MODES 3
extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */
/* Клавиша смены режима по кругу (PS/2 set 2). P — свободна: заняты
* K/I/S/L/U/[/] (читы, pop_cheat.h) и +/- (обход комнат). */
#define KBD_SPEED_MODE 0x4D /* P */
/* Делитель для текущего режима. fight = «у Кида вынут меч». */
uint8_t pop_pace_n(uint8_t fight);
/* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы
* ожидания перечитывать обязаны каждый оборот. */
extern volatile uint8_t pop_frame_tick;
/* ВЫБОРКА ЛУЧА. Дёшево (~57 T с вызовом), не трогает bc/de/ix.
* Звать отовсюду, где иначе получился бы зазор длиннее 64 512 тактов:
* из обёрток рисования и с границ фаз. Модуль НЕ банковый, поэтому из
* банков зовётся прямым call, без трамплина. */
void pop_beam_sample(void) __preserves_regs(b, c, d, e);
/* Однократная подготовка: взвести cbl_mode, снять первое состояние бита
* и убедиться, что фронты идут. 0 луч не работает (тогда пейсинг
* молча откатывается на прежнее поведение: n ожиданий gfx_wait_vsync). */
uint8_t pop_pace_arm(void);
/* Ждать ОДИН фронт луча. Внутри — тот же плотный опрос клавиатуры, что
* сейчас висит idle-хуком на gfx_wait_vsync (KBD-1: без опроса раз в
* ~0,5 мс теряются нажатия и залипают клавиши). */
void pop_wait_edge(void);
/* Добрать до n фронтов ОТ ЯКОРЯ и переставить якорь ПО ФАКТУ.
* Вызывающий обязан перед этим сделать хотя бы один pop_wait_edge()
* на нём же и происходит своп страниц (tear-free). Якорь ставится
* по факту, а не anchor += n: догонять пропущенное время нельзя, иначе
* после тяжёлого кадра игра рванёт вперёд. */
void pop_pace_end(uint8_t n);
#endif
+14 -1
View File
@@ -88,7 +88,18 @@ void pop_set_redraw(uint8_t tilepos, uint8_t kind, uint8_t pages) __banked
if (tilepos >= NTILES) return; if (tilepos >= NTILES) return;
if (!rd_cnt[tilepos]) rd_pending++; if (!rd_cnt[tilepos]) rd_pending++;
else pop_bake_slot_reset(tilepos); /* перепометка — копия запечки не годится */ else pop_bake_slot_reset(tilepos); /* перепометка — копия запечки не годится */
rd_kind[tilepos] = kind; /* ПРИОРИТЕТ ПОЛНОЙ ПЕРЕРИСОВКИ НАД слоем anim. У оригинала это не
* конфликт видов, а два независимых счётчика, и полная побеждает
* (redraw_needed, seg008:0211: `if (redraw_frames_full) draw_tile();
* else if (redraw_frames_anim) {...}`). У нас вид ОДИН на тайл, и без
* этой проверки исход решал бы порядок trob'ов в списке: чомпер, который
* СЕЙЧАС смыкает челюсти, ставит POP_RD_CHOMP (ему нужен heal поза
* меняется), а факел слева тут же метил бы тот же тайл как
* POP_RD_CHOMP_ANIM, и heal пропал бы новая поза легла бы поверх
* старой. */
if (!(kind == POP_RD_CHOMP_ANIM && rd_kind[tilepos] == POP_RD_CHOMP &&
rd_cnt[tilepos]))
rd_kind[tilepos] = kind;
if (pages > rd_cnt[tilepos]) rd_cnt[tilepos] = pages; if (pages > rd_cnt[tilepos]) rd_cnt[tilepos] = pages;
} }
@@ -112,6 +123,7 @@ void pop_redraw_needed(void) __banked
if (!rd_pending) return; if (!rd_pending) return;
for (i = 0; i < NTILES; i++) { for (i = 0; i < NTILES; i++) {
if (rd_cnt[i]) { if (rd_cnt[i]) {
pop_dbg_kind(rd_kind[i]); /* ВРЕМЕННО: какой вид перерисовки */
switch (rd_kind[i]) { switch (rd_kind[i]) {
case POP_RD_SPIKE: pop_spike_redraw(row, col); break; case POP_RD_SPIKE: pop_spike_redraw(row, col); break;
case POP_RD_LOOSE: pop_loose_shake_draw(row, col); break; case POP_RD_LOOSE: pop_loose_shake_draw(row, col); break;
@@ -120,6 +132,7 @@ void pop_redraw_needed(void) __banked
case POP_RD_LEVELDOOR: pop_leveldoor_redraw(row, col); break; case POP_RD_LEVELDOOR: pop_leveldoor_redraw(row, col); break;
case POP_RD_GATE: pop_gate_redraw(row, col); break; case POP_RD_GATE: pop_gate_redraw(row, col); break;
case POP_RD_CHOMP: pop_chomp_redraw(row, col); break; case POP_RD_CHOMP: pop_chomp_redraw(row, col); break;
case POP_RD_CHOMP_ANIM: pop_chomp_anim_draw(row, col); break;
default: break; default: break;
} }
cnt++; /* ВРЕМЕННО: замер */ cnt++; /* ВРЕМЕННО: замер */
+1
View File
@@ -31,6 +31,7 @@
#define POP_RD_LEVELDOOR 5 /* створка двери уровня (ПРАВАЯ половина) */ #define POP_RD_LEVELDOOR 5 /* створка двери уровня (ПРАВАЯ половина) */
#define POP_RD_GATE 6 /* решётка ворот (tilepos = САМИ ворота) */ #define POP_RD_GATE 6 /* решётка ворот (tilepos = САМИ ворота) */
#define POP_RD_CHOMP 7 /* чомпер: кадр смыкания челюстей */ #define POP_RD_CHOMP 7 /* чомпер: кадр смыкания челюстей */
#define POP_RD_CHOMP_ANIM 8 /* чомпер: вернуть ТОЛЬКО слой anim поверх огня */
/* Виды перерисовки полосы у потолка (по колонке). */ /* Виды перерисовки полосы у потолка (по колонке). */
#define POP_RDA_CEIL 1 /* плита-потолок: дрожащий кадр / покой */ #define POP_RDA_CEIL 1 /* плита-потолок: дрожащий кадр / покой */
+119 -56
View File
@@ -17,6 +17,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include "pop_bg.h" #include "pop_bg.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "_pop_bg.h" /* контракт с горячей половиной (pop_bg.c, банк 2) */ #include "_pop_bg.h" /* контракт с горячей половиной (pop_bg.c, банк 2) */
#include "pop_tile.h" /* общие листья слоя фона (резидент): блит, тайлы, таблица */ #include "pop_tile.h" /* общие листья слоя фона (резидент): блит, тайлы, таблица */
#include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */ #include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */
@@ -618,14 +619,62 @@ void pop_loose_shake_draw(int row, int col) __banked
* NB: печёный кадр-0 может слегка просвечивать по кромкам чистое * NB: печёный кадр-0 может слегка просвечивать по кромкам чистое
* решение (bake тайла как empty + рисовать loose всегда динамически) * решение (bake тайла как empty + рисовать loose всегда динамически)
* отложено; wipe-в-чёрный делал плиту невидимой не годится. */ * отложено; wipe-в-чёрный делал плиту невидимой не годится. */
pop_heal_off(x, 63 * row + 46, 64, 24); /* Ширина 58, а не 64 = «две клетки»: реальный след дрожащей плиты —
* свой тайл (верх/левая грань 32 px) плюс правая грань, которая уходит
* в соседнюю клетку на 26 px (25 во дворце). Габариты сняты из
* каталогов атласов: 41/69/70 = 32x13-14, 43/73/74 = 32x3,
* 42/71/72 = 26x15-16. Задача HEAL-WIDTH. */
pop_heal_off(x, 63 * row + 46, 58, 24);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
draw_tile(row, col); /* loose: лево+низ (анимир.) */ draw_tile(row, col); /* loose: лево+низ (анимир.) */
if (col + 1 < 10) if (col + 1 < 10)
draw_tile(row, col + 1); /* сосед: правая грань loose (анимир.) + его пол */ draw_tile(row, col + 1); /* сосед: правая грань loose (анимир.) + его пол */
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
} }
/* Вернуть ТОЛЬКО слой anim чомпера — порт ветки redraw_frames_anim в
* redraw_needed (seg008:0211). Контракт и разбор в шапке объявления
* (pop_bg.h).
*
* Чем это отличается от pop_chomp_redraw и почему нужна отдельная функция.
* Пометку ставит АНИМАЦИЯ ФАКЕЛА: пламя лежит в ячейке ПРАВОГО соседа
* (seg008:560), то есть поверх чомпера, и запекается в фон каждый кадр.
* Возвращать после него надо ровно графику чомпера а pop_chomp_redraw
* делал heal 32x64 плюс ПОЛНЫЙ draw_tile, то есть пересобирал тайл со всеми
* слоями (правая грань, база, низ, loose), которых пламя не касалось.
* Замер 11/15 (2026-08-19): 190 260 тактов на этот единственный тайл, 24 %
* работы кадра.
*
* heal не нужен: поза чомпера здесь НЕ меняется (свою анимацию ведёт
* pop_chomp_redraw по своей пометке), стирать нечего рисуем ту же
* графику поверх свежего пламени. Оригинал на пометку anim wipe тоже не
* делает: у него это отдельный счётчик wipe_frames.
*
* Передний слой (зубья, POP_CHOMP_FRAM_FOR) сюда НЕ входит как и в
* оригинале, где fore идёт по своему счётчику redraw_frames_fore. Геометрия
* это подтверждает: пламя занимает 18 строк с низом на 63*row+22, передние
* зубья на dmy = 63*row+62, то есть на 40 строк ниже; пересекается с огнём
* только ВЕРХНЯЯ челюсть (подъём до 0x32 = 50 px), а она в back-слое. */
void pop_chomp_anim_draw(int row, int col) __banked
{
int x = POP_COL_XH[col] * 8;
int dmy = 63 * row + 62; /* dby - 3, как в draw_tile */
uint8_t cm = pop_tile_mod(row, col);
uint8_t pose = pop_chomp_pose(cm);
gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_cd_batch_begin(); /* одна пометка на все куски */
pop_env_b(CHOMP_FRAM_BOT[pose], x, dmy);
if (cm & 0x80) /* кого-то перемололо */
pop_env_b((uint8_t)(pose + 114), x + 8, dmy - 6);
if (CHOMP_FRAM_TOP[pose])
pop_env_b(CHOMP_FRAM_TOP[pose], x, dmy - CHOMP_FRAM_Y[pose]);
pop_cd_batch_end();
gfx_set_bank(GFX_BANK_NORMAL);
}
/* Перерисовать пики тайла (row,col) на back-странице по ЖИВОМУ modif /* Перерисовать пики тайла (row,col) на back-странице по ЖИВОМУ modif
* (pop_trob). Кадр выдвижения рисует ПРАВЫЙ сосед (draw_tile ветка lcode==2 * (pop_trob). Кадр выдвижения рисует ПРАВЫЙ сосед (draw_tile ветка lcode==2
* читает lmod = pop_t_bg[row*10+col] = живой modif пики). pop_t_bg должен указывать * читает lmod = pop_t_bg[row*10+col] = живой modif пики). pop_t_bg должен указывать
@@ -645,6 +694,7 @@ void pop_spike_redraw(int row, int col) __banked
* (63*row+34 .. +74); ширина 64 = обе ячейки. */ * (63*row+34 .. +74); ширина 64 = обе ячейки. */
pop_heal_off(x, 63 * row + 34, 64, 40); pop_heal_off(x, 63 * row + 34, 64, 40);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
draw_tile(row, col); draw_tile(row, col);
if (col + 1 < 10) if (col + 1 < 10)
draw_tile(row, col + 1); draw_tile(row, col + 1);
@@ -658,8 +708,17 @@ void pop_spike_redraw(int row, int col) __banked
void pop_chomp_redraw(int row, int col) __banked void pop_chomp_redraw(int row, int col) __banked
{ {
int x = POP_COL_XH[col] * 8; int x = POP_COL_XH[col] * 8;
pop_heal_off(x, 63 * row + 2, 32, 64); /* РОВНО 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 «на всю высоту тайла» четыре лишние строки. */
pop_heal_off(x, 63 * row + 3, 32, 60);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
draw_tile(row, col); draw_tile(row, col);
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
} }
@@ -812,6 +871,7 @@ void pop_ceil_shake_draw(int col) __banked
* heal'а, он её и стирает. */ * heal'а, он её и стирает. */
pop_cd_touch(x, POP_YOFF, 64, CEIL_BAND_H); pop_cd_touch(x, POP_YOFF, 64, CEIL_BAND_H);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_t_clip_top = POP_YOFF; pop_t_clip_top = POP_YOFF;
pop_t_win_set(x, POP_YOFF, 64, CEIL_BAND_H); /* восстанавливаем ровно полосу */ pop_t_win_set(x, POP_YOFF, 64, CEIL_BAND_H); /* восстанавливаем ровно полосу */
draw_tile(-1, col); draw_tile(-1, col);
@@ -921,6 +981,7 @@ void pop_potion_draw(int row, int col, uint8_t modif) __banked
} }
pop_heal_off(x - 1, yb - 10, 8, 12); pop_heal_off(x - 1, yb - 10, 8, 12);
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_pot_b(POT_MASK_ID, x, yb); /* маска (под пузырьком) */ pop_pot_b(POT_MASK_ID, x, yb); /* маска (под пузырьком) */
pop_pot_b(bubb, x, yb); /* кадр пузырька */ pop_pot_b(bubb, x, yb); /* кадр пузырька */
gfx_set_bank(GFX_BANK_NORMAL); gfx_set_bank(GFX_BANK_NORMAL);
@@ -1184,7 +1245,10 @@ static mob_t *mob_alloc(void)
{ {
uint8_t i; uint8_t i;
for (i = 0; i < MOB_MAX; i++) for (i = 0; i < MOB_MAX; i++)
if (!mobs[i].active && !mobs[i].clean) return &mobs[i]; if (!mobs[i].active && !mobs[i].clean) {
pop_mob_busy = 1; /* гейт холостого хода — см. pop_state.h */
return &mobs[i];
}
return 0; return 0;
} }
@@ -1203,13 +1267,6 @@ static void mob_spawn(uint8_t room, int row, int col)
m->defer = 0; m->defer = 0;
m->prev_y[0] = m->prev_y[1] = MOB_Y_NONE; m->prev_y[0] = m->prev_y[1] = MOB_Y_NONE;
m->draw_y = MOB_Y_NONE; m->draw_y = MOB_Y_NONE;
{ /* ВРЕМЕННО: журнал решения оверлея — копим за весь полёт */
uint8_t k = (uint8_t)(m - mobs);
pop_dbg_pass[k] = 0;
pop_dbg_ovl[k * 4 + 2] = 0;
pop_dbg_ovl[k * 4 + 3] = 0;
pop_dbg_tiles[k] = 0;
}
} }
/* Отрыв куска в ТЕКУЩЕЙ комнате (обычный случай — pop_loose_tick). */ /* Отрыв куска в ТЕКУЩЕЙ комнате (обычный случай — pop_loose_tick). */
@@ -1497,7 +1554,18 @@ static void mob_tick_one(mob_t *m, uint8_t pg)
{ {
int8_t mrow = pop_y_to_row((int16_t)mt_m->draw_y); /* y_to_row_mod4 */ int8_t mrow = pop_y_to_row((int16_t)mt_m->draw_y); /* y_to_row_mod4 */
uint8_t over; uint8_t over;
if (mrow != pop_bg_obj_row) over = (mrow < pop_bg_obj_row); /* ряды идут 2,1,0 */ if (mrow < 0 || mrow > 2) {
/* КОРЗИНА 30 (get_tilepos_nominus, seg006:110). Ряд объекта вне
* комнаты: y_to_row_mod4 берёт остаток по 4, поэтому «выше
* потолка» и «ниже пола» дают одно и то же 1, а оригинал сводит
* оба в тайл 30. Объекты этого тайла рисуются ПЕРВЫМИ
* draw_objtable_items_at_tile(30) стоит в redraw_needed_tiles до
* всего обхода тайлов. Значит кусок под ВСЕМ, включая Кида; до
* сих пор сравнение рядов давало обратное («1 обходится
* последним» = поверх). */
over = 0;
}
else if (mrow != pop_bg_obj_row) over = (mrow < pop_bg_obj_row); /* ряды идут 2,1,0 */
else if (mt_m->col != pop_bg_obj_col) over = (mt_m->col > pop_bg_obj_col); else if (mt_m->col != pop_bg_obj_col) over = (mt_m->col > pop_bg_obj_col);
else over = (mt_m->draw_y > pop_cd[POP_CH_KID].fpy); else over = (mt_m->draw_y > pop_cd[POP_CH_KID].fpy);
/* РИСОВАНИЯ ЗДЕСЬ БОЛЬШЕ НЕТ — только решение, в какой проход кусок /* РИСОВАНИЯ ЗДЕСЬ БОЛЬШЕ НЕТ — только решение, в какой проход кусок
@@ -1586,30 +1654,30 @@ static void mob_overlay_neighbour(const mob_t *m)
{ {
int8_t r = pop_y_to_row((int16_t)m->draw_y); int8_t r = pop_y_to_row((int16_t)m->draw_y);
int8_t rt = pop_y_to_row((int16_t)(m->draw_y - 18)); int8_t rt = pop_y_to_row((int16_t)(m->draw_y - 18));
int8_t c = (int8_t)(m->col + 1); /* КОРЗИНА 30 оригинала (get_tilepos_nominus, seg006:110): ряд объекта вне
* комнаты. y_to_row_mod4 берёт остаток по 4, поэтому «выше потолка» и
* «ниже пола» дают одно и то же 1 оригинал сводит оба случая в тайл 30
* и рисует такие объекты ПЕРВЫМИ, до всего обхода тайлов
* (draw_objtable_items_at_tile(30) в redraw_needed_tiles).
*
* Отсюда два отличия от обычного куска:
* - ориентир «тайл объекта» = 3, то есть РАНЬШЕ любого ряда обхода
* (2,1,0). Сюда уходил сырой 1, и гейт other_overlay_tile
* `row > pop_bg_obj_row` читал его наоборот «объект поверх всего»
* и соседа не возвращал вовсе (тело плиты пропадало, оставался
* только её торец из переднего слоя);
* - перекрыть кусок должна и СВОЯ клетка, а не только сосед справа:
* после объекта у оригинала рисуются ВСЕ тайлы. У куска в обычном
* ряду своя клетка не перерисовывается там объект вливается в
* midtable уже ПОСЛЕ частей своего тайла. */
uint8_t b30 = (uint8_t)(r < 0 || r > 2);
int8_t oref = b30 ? (int8_t)3 : r;
int8_t c, cend = (int8_t)(m->col + 1);
int ytop, ybot; int ytop, ybot;
uint8_t *dbg = &pop_dbg_ovl[(m - mobs) * 4]; /* ВРЕМЕННО: журнал решения */ if (cend > 9) cend = 9;
{ /* ВРЕМЕННО: перебор ВСЕХ тайлов, чьи габариты пересекаются со спрайтом /* Габарит куска — по ЭКРАННОЙ координате: у куска, видимого из соседней
* куска чтобы понять, скольким тайлам реально нужно лечь поверх. */ * комнаты, draw_y отличается от y на 192. */
int8_t rr, kk; ytop = m->draw_y - (int)mob_spr[2] + 1;
int t0 = m->draw_y - (int)mob_spr[2] + 1, t1 = m->draw_y;
for (rr = -1; rr <= 2; rr++)
for (kk = 0; kk <= 1; kk++) {
int8_t cc = (int8_t)(m->col + kk);
if (cc > 9) continue;
if (pop_tile_code(rr, cc) &&
t1 >= 63 * rr + 2 && t0 <= 63 * rr + 66)
pop_dbg_tiles[m - mobs] |= (uint8_t)(1 << ((rr + 1) * 2 + kk));
}
}
dbg[0] = (uint8_t)(r + 128); dbg[1] = (uint8_t)(rt + 128);
dbg[2] |= 1;
if (c > 9) return;
dbg[2] |= 2;
/* Габарит куска — по ЭКРАННОЙ координате, как и ряд выше: у куска,
* видимого из соседней комнаты, draw_y отличается от y на 192, и
* сравнение с габаритом тайла по y давало заведомо ложный ответ. */
ytop = m->draw_y - (int)mob_spr[2] + 1; /* верх спрайта куска */
ybot = m->draw_y; ybot = m->draw_y;
/* ПРЕДУСЛОВИЯ ПРОВЕРЯЕМ ЗДЕСЬ, до вызова в банк 2. pop_mob_overlay_tile /* ПРЕДУСЛОВИЯ ПРОВЕРЯЕМ ЗДЕСЬ, до вызова в банк 2. pop_mob_overlay_tile
* живёт в банке 2, то есть каждый вызов из банка 7 платит межбанковый * живёт в банке 2, то есть каждый вызов из банка 7 платит межбанковый
@@ -1620,21 +1688,15 @@ static void mob_overlay_neighbour(const mob_t *m)
* ничего не рисовало. * ничего не рисовало.
* *
* Габарит тайла берём ПОЛНЫМ (63*row+2 .. +66) какая именно часть * Габарит тайла берём ПОЛНЫМ (63*row+2 .. +66) какая именно часть
* соседа окажется поверх куска, решит уже окно клипа внутри. Сужать по * соседа окажется поверх куска, решит уже окно клипа внутри. */
* реальной высоте кусков нельзя: у колонн и зеркала база высокая. */ for (c = b30 ? (int8_t)m->col : cend; c <= cend; c++) {
if (r >= 0 && r <= 2 && pop_tile_code(r, c)) dbg[2] |= 4; if (c < 0) continue;
if (r >= 0 && r <= 2 && ybot >= 63 * r + 2 && ytop <= 63 * r + 66) dbg[2] |= 8; if (r >= 0 && r <= 2 && pop_tile_code(r, c) &&
if (r >= 0 && r <= 2 && pop_tile_code(r, c) && ybot >= 63 * r + 2 && ytop <= 63 * r + 66)
ybot >= 63 * r + 2 && ytop <= 63 * r + 66) { pop_mob_overlay_tile(r, c, oref, (int8_t)m->col, m->x, m->draw_y, mob_spr[2]);
dbg[2] |= 0x10; dbg[3]++; if (rt != r && rt >= 0 && rt <= 2 && pop_tile_code(rt, c) &&
pop_mob_overlay_tile(r, c, r, (int8_t)m->col, m->x, m->draw_y, mob_spr[2]); ybot >= 63 * rt + 2 && ytop <= 63 * rt + 66)
} pop_mob_overlay_tile(rt, c, oref, (int8_t)m->col, m->x, m->draw_y, mob_spr[2]);
if (rt != r && rt >= 0 && rt <= 2 && pop_tile_code(rt, c)) dbg[2] |= 0x20;
if (rt != r && rt >= 0 && rt <= 2 && ybot >= 63 * rt + 2 && ytop <= 63 * rt + 66) dbg[2] |= 0x40;
if (rt != r && rt >= 0 && rt <= 2 && pop_tile_code(rt, c) &&
ybot >= 63 * rt + 2 && ytop <= 63 * rt + 66) {
dbg[2] |= 0x80; dbg[3]++;
pop_mob_overlay_tile(rt, c, r, (int8_t)m->col, m->x, m->draw_y, mob_spr[2]);
} }
} }
@@ -1650,10 +1712,6 @@ static void mob_draw_pass(uint8_t pg, uint8_t want_defer)
mob_t *m = &mobs[i]; mob_t *m = &mobs[i];
/* Отбор по draw_y, а НЕ по комнате: кусок, только что провалившийся /* Отбор по draw_y, а НЕ по комнате: кусок, только что провалившийся
* в комнату снизу, ещё виден из нашей (см. mob_tick_one). */ * в комнату снизу, ещё виден из нашей (см. mob_tick_one). */
pop_dbg_pass[i] |= 1;
if (m->active) pop_dbg_pass[i] |= 2;
if (m->draw_y != MOB_Y_NONE) pop_dbg_pass[i] |= 4;
if ((uint8_t)(m->defer != 0) == want_defer) pop_dbg_pass[i] |= 8;
if (!m->active || m->draw_y == MOB_Y_NONE) continue; if (!m->active || m->draw_y == MOB_Y_NONE) continue;
if ((uint8_t)(m->defer != 0) != want_defer) continue; if ((uint8_t)(m->defer != 0) != want_defer) continue;
for (j = n; j > 0 && mobs[order[j - 1]].draw_y < m->draw_y; j--) for (j = n; j > 0 && mobs[order[j - 1]].draw_y < m->draw_y; j--)
@@ -1670,17 +1728,15 @@ static void mob_draw_pass(uint8_t pg, uint8_t want_defer)
* в кадр пересечения границы пометка указывала на прежний тайл, и * в кадр пересечения границы пометка указывала на прежний тайл, и
* передняя часть колонны кусок не перекрывала ровно ОДИН кадр, * передняя часть колонны кусок не перекрывала ровно ОДИН кадр,
* как и наблюдалось (прогон 2026-08-13, комната 23, колонка 8). */ * как и наблюдалось (прогон 2026-08-13, комната 23, колонка 8). */
pop_dbg_pass[order[i]] |= 0x10;
mob_mark_neighbour(&mobs[order[i]]); mob_mark_neighbour(&mobs[order[i]]);
mob_render(&mobs[order[i]], pg); mob_render(&mobs[order[i]], pg);
pop_dbg_pass[order[i]] |= 0x20;
mob_overlay_neighbour(&mobs[order[i]]); mob_overlay_neighbour(&mobs[order[i]]);
} }
} }
void pop_loose_mob_tick(void) __banked void pop_loose_mob_tick(void) __banked
{ {
uint8_t pg = gfx_get_draw_page() & 1, i, live = 0; uint8_t pg = gfx_get_draw_page() & 1, i, live = 0, busy = 0;
/* Слоты обходим УКАЗАТЕЛЕМ и пустые отсеиваем ЗДЕСЬ, а не внутри /* Слоты обходим УКАЗАТЕЛЕМ и пустые отсеиваем ЗДЕСЬ, а не внутри
* mob_tick_one. Индексная запись mobs[i] заставляла SDCC умножать i на * mob_tick_one. Индексная запись mobs[i] заставляла SDCC умножать i на
* sizeof(mob_t)=15 заново под каждое поле (.active/.clean/.x/.y), и 14 * sizeof(mob_t)=15 заново под каждое поле (.active/.clean/.x/.y), и 14
@@ -1688,6 +1744,10 @@ void pop_loose_mob_tick(void) __banked
* (замер 2026-08-13). Гард в самом mob_tick_one оставлен: функция * (замер 2026-08-13). Гард в самом mob_tick_one оставлен: функция
* зовётся и из других мест. */ * зовётся и из других мест. */
mob_t *m = mobs; mob_t *m = mobs;
/* Ни одного занятого слота — обходить 14 штук незачем (замер 11/15:
* 12 090 тактов холостого хода). Гейт снимается ниже, по факту прохода,
* в котором не осталось ни active, ни дочистки. */
if (!pop_mob_busy) return;
/* Пометки всех кусков — ОДНИМ пакетом: настоящий pop_cd_touch стоит 4 502 /* Пометки всех кусков — ОДНИМ пакетом: настоящий pop_cd_touch стоит 4 502
* такта (замер 2026-08-17), а кусков в кадре каскада шесть. В пакете они * такта (замер 2026-08-17), а кусков в кадре каскада шесть. В пакете они
* копят общий прямоугольник четырьмя сравнениями, и настоящая пометка одна. * копят общий прямоугольник четырьмя сравнениями, и настоящая пометка одна.
@@ -1722,9 +1782,11 @@ void pop_loose_mob_tick(void) __banked
pop_cd_touch(MOB_X0(m->x), m->prev_y[pg] - 27 + POP_YOFF, MOB_W, 64); pop_cd_touch(MOB_X0(m->x), m->prev_y[pg] - 27 + POP_YOFF, MOB_W, 64);
mob_tick_one(m, pg); mob_tick_one(m, pg);
if (m->active) live++; if (m->active) live++;
if (m->active || m->clean) busy++; /* слот ещё занят — гейт держим */
} }
pop_cd_batch_end(); pop_cd_batch_end();
mobs_live = live; mobs_live = live;
pop_mob_busy = busy;
} }
static void mob_render(mob_t *m, uint8_t pg) static void mob_render(mob_t *m, uint8_t pg)
@@ -1733,6 +1795,7 @@ static void mob_render(mob_t *m, uint8_t pg)
* left(41) на obj_y-3, bottom(43) на obj_y, right(42) на obj_x+4 (=col+1, * left(41) на obj_y-3, bottom(43) на obj_y, right(42) на obj_x+4 (=col+1,
* заходит в тайл СПРАВА) на obj_y-1. */ * заходит в тайл СПРАВА) на obj_y-1. */
gfx_set_bank(GFX_BANK_SPRITE); gfx_set_bank(GFX_BANK_SPRITE);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* КЛИПА ОБЪЕКТА ЗДЕСЬ НЕТ. У оригинала он есть — add_mob_to_objtable /* КЛИПА ОБЪЕКТА ЗДЕСЬ НЕТ. У оригинала он есть — add_mob_to_objtable
* (seg007:1161) ставит куску clip.right = 40, но единицы этого поля я * (seg007:1161) ставит куску clip.right = 40, но единицы этого поля я
* НЕ выяснил: буквальные 40 экранных пикселей срезают правый задний угол * НЕ выяснил: буквальные 40 экранных пикселей срезают правый задний угол
+88
View File
@@ -0,0 +1,88 @@
/*
* pop_shadow.c загрузка запечённого набора спрайтов Тени. Зачем набор
* отдельный и почему половин две в шапке pop_shadow.h.
*
* Код ХОЛОДНЫЙ (раз на уровень), поэтому живёт в банке 8 вместе с прочей
* загрузкой. Сами atlas_t в W2: их читает отрисовка из банка 4, а
* данные видны из любого банка.
*/
#include <stdint.h>
#include <gfx.h>
#include <sprite.h>
#include "pop_shadow.h"
#include "pop_shadow_atlas.h"
atlas_t pop_shadow_kid[POP_SHADOW_KID_PAGES];
atlas_t pop_shadow_fgt[POP_SHADOW_FGT_PAGES];
uint8_t pop_shadow_kid_n, pop_shadow_fgt_n;
/* ГРУЗИМ ОДИН РАЗ НА ЗАПУСК, а не по уровням.
*
* Тень бывает на ЧЕТЫРЁХ уровнях: 4 рождается прыжком сквозь зеркало
* (jump_through_mirror, seg003:0811, ставит Char.charid = charid_1_shadow
* прямо из Кида), 5 крадёт зелье, 6 прыгает через пропасть, 12
* дерётся. Читается как три, потому что таблиц init_shad_N всего три
* (5/6/12): четвёртой нет на 4-м Тень копируется с Кида, а не поднимается
* из таблицы. Так вот, напрашивается
* грузить набор только там, как это сделано с перевёрнутыми наборами
* (pop_vflip_load_all зовётся лишь на POP_UPSIDE_LEVEL). Решение
* пользователя грузить сразу, и оно правильное по двум причинам:
* страниц EMM с запасом (32 из ~215 свободных), а вот забыть перезагрузку
* на границе уровня легко ровно так и появился BUG-SHADOW-SET. Разовая
* загрузка убирает целый класс ошибок и заодно не тормозит переход между
* уровнями на 32 файла. */
int pop_shadow_load(void) __banked
{
uint8_t i;
if (pop_shadow_kid_n || pop_shadow_fgt_n) return 0; /* уже загружен */
for (i = 0; i < POP_SHADOW_KID_PAGES; i++) {
if (atlas_load(&pop_shadow_kid[i], pop_shadow_kid_atl[i]) != 0) {
pop_shadow_free();
return -1;
}
pop_shadow_kid_n = (uint8_t)(i + 1);
}
for (i = 0; i < POP_SHADOW_FGT_PAGES; i++) {
if (atlas_load(&pop_shadow_fgt[i], pop_shadow_fgt_atl[i]) != 0) {
pop_shadow_free();
return -1;
}
pop_shadow_fgt_n = (uint8_t)(i + 1);
}
pop_shadow_pal_apply();
return 0;
}
/* ПАЛИТРА ОТДЕЛЬНО ОТ ЗАГРУЗКИ, и это не косметика.
*
* `gfx_pal_fload("KID\\kid.pal")` заливает ВСЕ 256 записей это единая
* игровая палитра, и затирает любые слоты, выставленные до него. Ровно
* та же беда уже была с тайлсетом: сразу за ним стоит pop_bg_pal_apply,
* который возвращает цвета env/wall. Наши 0xA0..0xAF из той же оперы, и
* в первом заходе я на это наступил: набор грузился, силуэт рисовался
* правильной формы, но ЦЕЛИКОМ ЧЁРНЫЙ (поймано пользователем на 6-м
* уровне). Поэтому вызов живёт рядом с pop_bg_pal_apply, а не внутри
* загрузки атласов.
*
* Обе страницы дабл-буфера: у них свои палитры. Через буфер в W2
* gfx_pal_load отдаёт указатель BIOS ($A4), а тот читает только
* #4000-#BFFF и банковую rodata не увидит. */
void pop_shadow_pal_apply(void) __banked
{
uint8_t buf[64], k;
if (!pop_shadow_kid_n) return; /* набора нет — нечего красить */
for (k = 0; k < 64; k++) buf[k] = pop_shadow_pal[k];
gfx_pal_load(0, POP_SHADOW_PAL_BASE, 16, buf);
gfx_pal_load(1, POP_SHADOW_PAL_BASE, 16, buf);
}
void pop_shadow_free(void) __banked
{
uint8_t i;
for (i = 0; i < pop_shadow_kid_n; i++) atlas_free(&pop_shadow_kid[i]);
for (i = 0; i < pop_shadow_fgt_n; i++) atlas_free(&pop_shadow_fgt[i]);
pop_shadow_kid_n = pop_shadow_fgt_n = 0;
}
+55
View File
@@ -0,0 +1,55 @@
/*
* pop_shadow.h запечённый набор спрайтов ТЕНИ.
*
* Оригинал спрайтов Тени не хранит: он кладёт обычный спрайт дважды в x
* прозрачным блитом и в x+1 «блиттером XOR» (draw_objtable_item,
* seg008.c:1600). Пакетный блит так не умеет, поэтому результат запечён
* заранее (toolchain/pop_pack_shadow.py); разбор и замеры
* ../docs/shadow_atlas_plan.md.
*
* ПОЛОВИН ДВЕ, и это не наша выдумка. Набор спрайтов у оригинала выбирает
* не персонаж, а поле `sword` САМОГО КАДРА (load_frame_to_obj,
* seg008.c:1752: chtab_base жёстко = chtab_2, плюс `sword >> 6`). У всех
* кадров таблицы Кида эти биты нулевые, у всех кадров таблицы стража
* 0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533),
* значит идёт через chtab_5 а chtab_5 на 12-м уровне грузится из
* SHADOW.DAT (tbl_guard_type[12] == 4), и это графика КИДА в боевых позах.
* Отсюда:
*
* pop_shadow_kid[] кадры вне боя (запечены из спрайтов Кида);
* pop_shadow_fgt[] кадры 150..189 (запечены из SHADOW.DAT).
*
* Какая половина нужна, отрисовка уже знает тем же условием, что
* выбирает таблицу кадров (pop_frame_tbl_is_guard).
*
* СПРАЙТ ТЕНИ НА ПИКСЕЛЬ ШИРЕ исходного: второй проход оригинала кладётся
* в x+1, суммарный след занимает [x, x+w]. Рисовать по тем же
* координатам, что и Кида сдвиг уже внутри спрайта.
*/
#ifndef POP_SHADOW_H
#define POP_SHADOW_H
#include <stdint.h>
#include <sprite.h>
extern atlas_t pop_shadow_kid[]; /* половина «вне боя» */
extern atlas_t pop_shadow_fgt[]; /* половина «в бою» (SHADOW.DAT) */
/* Сколько страниц реально загружено; 0 = набора нет (уровень без Тени
* или загрузка не удалась) отрисовка тогда идёт прежним путём. */
extern uint8_t pop_shadow_kid_n, pop_shadow_fgt_n;
/* Загрузить набор. Звать ОДИН РАЗ при старте: набор не зависит от
* уровня, а перезагрузка на границе лишний повод её забыть (почему
* именно так в шапке pop_shadow.c). Повторный вызов no-op.
* Возврат: 0 OK, -1 ошибка загрузки. */
int pop_shadow_load(void) __banked;
/* Залить 16 слотов палитры Тени. ЗВАТЬ ПОСЛЕ ЛЮБОЙ заливки общей
* палитры (gfx_pal_fload kid.pal): та кладёт все 256 записей и затирает
* наши см. развёрнуто в pop_shadow.c. Рядом с pop_bg_pal_apply. */
void pop_shadow_pal_apply(void) __banked;
/* Освободить страницы; звать на выходе из программы. */
void pop_shadow_free(void) __banked;
#endif
@@ -0,0 +1,71 @@
/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.
* СГЕНЕРЕНО toolchain/pop_pack_shadow.py НЕ править вручную.
*
* Две половины, потому что и у оригинала их две (см. шапку
* упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),
* кадры 150..189 chtab_5, а он на 12-м уровне это SHADOW.DAT.
* Какая половина нужна, приложение уже знает тем же условием,
* что выбирает таблицу кадров (pop_frame_tbl_is_guard).
*
* Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход
* оригинала кладётся в x+1, и суммарный след занимает [x, x+w].
* Рисовать по тем же координатам, что и Кида сдвиг уже внутри.
*/
#ifndef POP_SHADOW_ATLAS_H
#define POP_SHADOW_ATLAS_H
#include <stdint.h>
#define POP_SHADOW_SHIFT 3
#define POP_SHADOW_MASK 7
#define POP_SHADOW_PAL_BASE 0xA0 /* слоты Sprinter под Тень */
/* Половина «вне боя» — кадры Кида (chtab_2). */
#define POP_SHADOW_KID_PAGES 28
/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */
#define POP_SHADOW_FGT_PAGES 4
static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {
"SHADOW\\sk0.atl",
"SHADOW\\sk1.atl",
"SHADOW\\sk2.atl",
"SHADOW\\sk3.atl",
"SHADOW\\sk4.atl",
"SHADOW\\sk5.atl",
"SHADOW\\sk6.atl",
"SHADOW\\sk7.atl",
"SHADOW\\sk8.atl",
"SHADOW\\sk9.atl",
"SHADOW\\sk10.atl",
"SHADOW\\sk11.atl",
"SHADOW\\sk12.atl",
"SHADOW\\sk13.atl",
"SHADOW\\sk14.atl",
"SHADOW\\sk15.atl",
"SHADOW\\sk16.atl",
"SHADOW\\sk17.atl",
"SHADOW\\sk18.atl",
"SHADOW\\sk19.atl",
"SHADOW\\sk20.atl",
"SHADOW\\sk21.atl",
"SHADOW\\sk22.atl",
"SHADOW\\sk23.atl",
"SHADOW\\sk24.atl",
"SHADOW\\sk25.atl",
"SHADOW\\sk26.atl",
"SHADOW\\sk27.atl",
};
static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {
"SHADOW\\sf0.atl",
"SHADOW\\sf1.atl",
"SHADOW\\sf2.atl",
"SHADOW\\sf3.atl",
};
/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */
static const uint8_t pop_shadow_pal[64] = {
0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00,
};
#endif
+11
View File
@@ -13,6 +13,7 @@
#include "pop_state.h" #include "pop_state.h"
#include "pop_ctrl.h" /* объявления шины control_* (живёт здесь) */ #include "pop_ctrl.h" /* объявления шины control_* (живёт здесь) */
uint8_t pop_mob_busy;
uint8_t pop_loose_landed; uint8_t pop_loose_landed;
/* Кусок loose УШЁЛ ВНИЗ из комнаты (порт хвоста move_loose, seg007:1126: /* Кусок loose УШЁЛ ВНИЗ из комнаты (порт хвоста move_loose, seg007:1126:
* mob_down_a_row переносит его в комнату снизу; у нас симуляция там не * mob_down_a_row переносит его в комнату снизу; у нас симуляция там не
@@ -179,6 +180,16 @@ void pop_dbg_b4(void) { } /* pop_cd_touch */
void pop_dbg_b5(void) { } /* gfx_w0_unmap (конец) */ void pop_dbg_b5(void) { } /* gfx_w0_unmap (конец) */
void pop_dbg_b6(void) { } /* взведён = пошли в gfx_blit_noclip, а не в _part */ void pop_dbg_b6(void) { } /* взведён = пошли в gfx_blit_noclip, а не в _part */
/* ВРЕМЕННО (разбор pop_phys_tick 2026-08-19): звенья цепочки kid_phys. */
void pop_dbg_p1(void) { } /* loadkid_and_opp сделан */
void pop_dbg_p2(void) { } /* fall_accel + fall_speed */
void pop_dbg_p3(void) { } /* determine_col */
void pop_dbg_p4(void) { } /* bump_into_opponent */
void pop_dbg_p5(void) { } /* check_collisions */
void pop_dbg_p6(void) { } /* check_bumped */
void pop_dbg_p7(void) { } /* check_action */
void pop_dbg_p8(void) { } /* check_press */
/* ВРЕМЕННО (регрессия цены блита 2026-08-13): w и h упакованы в один /* ВРЕМЕННО (регрессия цены блита 2026-08-13): w и h упакованы в один
* 16-битный аргумент (arg1 -> HL при __sdcccall(1)), брейкпоинт логирует * 16-битный аргумент (arg1 -> HL при __sdcccall(1)), брейкпоинт логирует
* HL дальше цена раскладывается как a + b*h + c*w*h, где b и есть * HL дальше цена раскладывается как a + b*h + c*w*h, где b и есть
+25
View File
@@ -9,6 +9,16 @@
#include <stdint.h> #include <stdint.h>
/* Есть ли хоть один ЗАНЯТЫЙ слот падающего куска (active или дочистка
* clean). Гейт холостого хода: пока кусков нет, ни обход 14 слотов в
* pop_loose_mob_tick, ни поиск куска над головой Кида делать не нужно
* (замер 11/15: 12 090 + 5 868 тактов на комнату, где не падает ничего).
*
* Живёт в резиденте, а не в pop_room.c, потому что читает его pop_map
* (банк 3), а писучие статики банкового модуля наружу не видны. Ставит
* отрыв куска, снимает сам обход по факту пустой таблицы. */
extern uint8_t pop_mob_busy;
/* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе /* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе
* tilepos+1 тайла, на который он лёг. Ставит отрисовка mob (банк), * tilepos+1 тайла, на который он лёг. Ставит отрисовка mob (банк),
* разбирает логика loose-полов (pop_map, W1/W2). */ * разбирает логика loose-полов (pop_map, W1/W2). */
@@ -35,6 +45,15 @@ extern uint8_t pop_nav_hold;
/* Чит «бессмертие» (I): Кида бьют, но HP не убавляется. Читают пути урона /* Чит «бессмертие» (I): Кида бьют, но HP не убавляется. Читают пути урона
* (боёвка в банке, пики в pop_map), переключает главный цикл. */ * (боёвка в банке, пики в pop_map), переключает главный цикл. */
/* ЧИТ БЕССМЕРТИЯ, три состояния (клавиша I по кругу):
* 0 выключено;
* 1 только БОЙ: удары мечом не отнимают HP (pop_take_hp тут ни при
* чём, ветка живёт в hurt_by_sword, guards.c);
* 2 бой И мелкий урон: не проходят «минус одно деление» от падения с
* двух этажей и от падающей плиты (pop_take_hp гасит count < 100).
* МГНОВЕННАЯ СМЕРТЬ остаётся на любом уровне: пики, чомпер, падение с
* трёх этажей, удар мечом вне боевой стойки все они приходят с
* count = 100. Чит отменяет потерю HP, а не смерть. */
extern uint8_t pop_immortal; extern uint8_t pop_immortal;
/* Габариты ПОСЛЕДНЕЙ нарисованной двери уровня — порт глобалов /* Габариты ПОСЛЕДНЕЙ нарисованной двери уровня — порт глобалов
@@ -95,6 +114,12 @@ void pop_dbg_m15(void);
void pop_dbg_kind(uint8_t k); void pop_dbg_m16(void); void pop_dbg_kind(uint8_t k); void pop_dbg_m16(void);
void pop_dbg_b1(void); void pop_dbg_b2(void); void pop_dbg_b3(void); void pop_dbg_b1(void); void pop_dbg_b2(void); void pop_dbg_b3(void);
void pop_dbg_b4(void); void pop_dbg_b5(void); void pop_dbg_b6(void); void pop_dbg_b4(void); void pop_dbg_b5(void); void pop_dbg_b6(void);
/* ВРЕМЕННО (разбор pop_phys_tick 2026-08-19, позиция P2a): физика Кида —
* 61 266 тактов на НЕПОДВИЖНОМ персонаже. По одному зонду на звено
* цепочки kid_phys. */
void pop_dbg_p1(void); void pop_dbg_p2(void); void pop_dbg_p3(void);
void pop_dbg_p4(void); void pop_dbg_p5(void); void pop_dbg_p6(void);
void pop_dbg_p7(void); void pop_dbg_p8(void);
void pop_dbg_wh(uint16_t wh); void pop_dbg_wh(uint16_t wh);
/* ВРЕМЕННО: трасса kidobj (см. pop_state.c). Порядок байт: /* ВРЕМЕННО: трасса kidobj (см. pop_state.c). Порядок байт:
+139 -63
View File
@@ -14,6 +14,7 @@
#include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */ #include "pop_map.h" /* pop_upside — переворот (зелье инверсии) */
#include "pop_cdraw.h" /* POP_CD_TOUCH — фон тронут, персонажей перерисовать */ #include "pop_cdraw.h" /* POP_CD_TOUCH — фон тронут, персонажей перерисовать */
#include "pop_tile.h" #include "pop_tile.h"
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "pop_level.h" /* pop_doorlink2 — подстановка нажатой кнопки */ #include "pop_level.h" /* pop_doorlink2 — подстановка нажатой кнопки */
#include "_pop_draw.h" /* pop_heal_fast */ #include "_pop_draw.h" /* pop_heal_fast */
@@ -195,43 +196,34 @@ uint8_t pop_spike_frame(uint8_t m)
* и перерисовывался каждый кадр со всем fore-проходом (замер: циан 231 829 * и перерисовывался каждый кадр со всем fore-проходом (замер: циан 231 829
* тактов против 20 071, период 4 растровых кадра против 3). * тактов против 20 071, период 4 растровых кадра против 3).
* *
* Гранулярность тайла (32 x 63) вместо точного прямоугольника осознанное * ГРАНУЛЯРНОСТЬ: колонка 32 px по горизонтали, ТОЧНЫЙ диапазон y по
* огрубление: факел помечает всю свою колонку по высоте ряда. Персонаж и * вертикали. Раньше по вертикали стоял номер ряда (три полосы по 63 px), и
* так занимает бОльшую часть высоты ряда, зато касания перестают * это склеивало касания, разнесённые внутри ряда. Найдено пользователем на
* склеиваться. См. docs/impl_diff.md. */ * сцене 11/15 (2026-08-19): пламя факела занимает y 5..22, клинок стоящего
* стража y 31..37, между ними девять пикселей чистого зазора, а метка
* считала слот задетым, потому что оба попадают в ряд 0 и колонку 7.
* Стоило это 148 302 такта в кадр 23 % работы на перерисовку персонажа,
* которого никто не трогал.
*
* Почему диапазон, а не более мелкие полосы: полосы по 16 px эту пару всё
* равно склеивают (пламя кончается в полосе 1, клинок в ней же начинается),
* а 8-пиксельные потребовали бы 24 маски на страницу. Пара ymin/ymax на
* колонку 40 байт на обе страницы, точнее любых полос и без битовой возни.
*
* У ОРИГИНАЛА такой метки нет вовсе: и Apple II (FRAMEADV.S RedBlockFast с
* шестью буферами wipebuf/redbuf/movebuf/floorbuf/halfbuf/objbuf), и SDLPoP
* (set_redraw_fore в redraw_at_char) метят ЦЕЛЫМИ тайлами, но им это не
* мешает персонаж у них рисуется каждый кадр безусловно, а пометки нужны
* только фону. Пропуск неизменившегося персонажа наша добавка, поэтому и
* точность метки нужна выше оригинальной. См. docs/impl_diff.md. */
uint8_t pop_cd_dirty; uint8_t pop_cd_dirty;
uint16_t pop_cd_dmask[2][3]; /* [страница][колонка] — диапазон затронутых экранных y. Пусто = ymin > ymax
* (заполняется как ymin = 255, ymax = 0). */
uint8_t pop_cd_ymin[2][10], pop_cd_ymax[2][10];
/* CD_LOW[n] = n младших единиц: маска пробега колонок c0..c1 считается как /* CD_LOW, cd_row_of и cd_cols_of СНЯТЫ вместе с переходом на диапазон y:
* CD_LOW[c1+1] & ~CD_LOW[c0] два чтения таблицы вместо цикла сдвигов. */ * колонка теперь считается прямым сдвигом (x >> 5), а вертикаль сравнением
static const uint16_t CD_LOW[11] = { * отрезков битовые маски больше не нужны. */
0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F,
0x003F, 0x007F, 0x00FF, 0x01FF, 0x03FF
};
/* Экранный y -> ряд комнаты 0..2 с клампом (полоса кладки у потолка и низ
* стены ложатся на крайние ряды). Цепочкой сравнений, а не делением на 63:
* у SDCC z80 одно деление ~5 400 тактов (memory sdcc_z80_division_hoisting). */
static uint8_t cd_row_of(int y)
{
y -= POP_YOFF;
if (y < 63) return 0;
if (y < 126) return 1;
return 2;
}
/* Колонки прямоугольника [x..x1] (включительно) -> битовая маска. Тайл ровно
* 32 px и начинается с x=0, поэтому колонка просто сдвиг. */
static uint16_t cd_cols_of(int x, int x1)
{
uint8_t c0, c1;
if (x < 0) x = 0;
if (x1 > 319) x1 = 319;
if (x1 < x) return 0; /* весь прямоугольник вне экрана */
c0 = (uint8_t)(x >> 5);
c1 = (uint8_t)(x1 >> 5);
return (uint16_t)(CD_LOW[c1 + 1] & ~CD_LOW[c0]);
}
/* Обе страницы помечаются сразу (персонаж чинится на каждой в свой кадр), /* Обе страницы помечаются сразу (персонаж чинится на каждой в свой кадр),
* поэтому цикл развёрнут: индекс-переменная заставляла SDCC считать адрес * поэтому цикл развёрнут: индекс-переменная заставляла SDCC считать адрес
@@ -274,8 +266,6 @@ void pop_cd_unmute(void) { pop_cd_batch = 0; }
void pop_cd_touch(int x, int y, int w, int h) void pop_cd_touch(int x, int y, int w, int h)
{ {
uint16_t cols;
uint8_t r0, r1;
if (pop_cd_batch) { /* копим, не разбирая на колонки/ряды */ if (pop_cd_batch) { /* копим, не разбирая на колонки/ряды */
if (pop_cd_batch == CD_BATCH_MUTE) return; /* область помечена вызывающим */ if (pop_cd_batch == CD_BATCH_MUTE) return; /* область помечена вызывающим */
int x1 = x + w - 1, y1 = y + h - 1; int x1 = x + w - 1, y1 = y + h - 1;
@@ -285,18 +275,30 @@ void pop_cd_touch(int x, int y, int w, int h)
if (y1 > cdb_y1) cdb_y1 = y1; if (y1 > cdb_y1) cdb_y1 = y1;
return; return;
} }
cols = cd_cols_of(x, x + w - 1); {
if (!cols) return; int8_t c0, c1, c;
r0 = cd_row_of(y); uint8_t y0, y1;
r1 = cd_row_of(y + h - 1); int xr = x + w - 1;
pop_cd_dmask[0][r0] |= cols; if (xr < 0 || x > 319) return; /* весь прямоугольник вне поля */
pop_cd_dmask[1][r0] |= cols; if (x < 0) x = 0;
if (r1 != r0) { if (xr > 319) xr = 319;
pop_cd_dmask[0][r1] |= cols; c0 = (int8_t)(x >> 5);
pop_cd_dmask[1][r1] |= cols; c1 = (int8_t)(xr >> 5);
if (r1 - r0 > 1) { /* прямоугольник накрыл все три ряда */ /* Клип по вертикали: экранные y не выходят за байт, а всё, что выше
pop_cd_dmask[0][1] |= cols; * поля или ниже его, персонажам всё равно не принадлежит. */
pop_cd_dmask[1][1] |= cols; if (y < 0) y = 0;
if (y > 255) return;
y0 = (uint8_t)y;
y1 = (y + (int)h - 1 > 255) ? 255 : (uint8_t)(y + h - 1);
/* Страницы обновляются НЕЗАВИСИМО. Общее условие по странице 0
* («если ей стало теснее записать в обе») ломается сразу после
* pop_cd_clear(0): страница 1 хранит свои старые границы, условие по
* нулевой уже не выполняется, и её метка перестаёт расти. */
for (c = c0; c <= c1; c++) {
if (y0 < pop_cd_ymin[0][c]) pop_cd_ymin[0][c] = y0;
if (y1 > pop_cd_ymax[0][c]) pop_cd_ymax[0][c] = y1;
if (y0 < pop_cd_ymin[1][c]) pop_cd_ymin[1][c] = y0;
if (y1 > pop_cd_ymax[1][c]) pop_cd_ymax[1][c] = y1;
} }
} }
pop_cd_dirty = 3; pop_cd_dirty = 3;
@@ -305,27 +307,87 @@ void pop_cd_touch(int x, int y, int w, int h)
/* Задел ли прямоугольник (ЭКРАННЫЕ координаты, x1/y1 ВКЛЮЧИТЕЛЬНО) то, что /* Задел ли прямоугольник (ЭКРАННЫЕ координаты, x1/y1 ВКЛЮЧИТЕЛЬНО) то, что
* трогали на странице p. Резидент: зовёт pop_cdraw.c из банка 4 прямым * трогали на странице p. Резидент: зовёт pop_cdraw.c из банка 4 прямым
* вызовом, без трамплина. */ * вызовом, без трамплина. */
/* Прямоугольник слота против метки — БЕЗ передачи пяти аргументов.
*
* pop_cd_hit принимает (p, x0, y0, x1, y1): три последних идут стеком, и
* функция целиком уезжает в IX-фрейм 45 % её тактов уходит на `-n(ix)`
* (замер asm 2026-08-19). А зовут её из cd_quiet дважды на слот, то есть
* до восьми раз за кадр. Здесь координаты берутся прямо из pop_cd, и
* аргументов остаётся два.
*
* Спрайт и накладной проверяются РАЗДЕЛЬНО объединённый bbox ловит
* касания углом, которых нет (разбор в cd_quiet, pop_cdraw.c). */
static int hit_x, hit_y; /* аргументы hit_rect — file-scope, не стек */
static uint16_t hit_w, hit_h;
static uint8_t hit_p;
static uint8_t hit_rect(void)
{
int8_t c0, c1, c;
uint8_t ya, yb;
int x1 = hit_x + (int)hit_w - 1;
int y1 = hit_y + (int)hit_h - 1;
if (!hit_w || !hit_h) return 0;
if (x1 < 0 || hit_x > 319) return 0;
if (y1 < 0 || hit_y > 255) return 0;
ya = (uint8_t)(hit_y < 0 ? 0 : hit_y);
yb = (uint8_t)(y1 > 255 ? 255 : y1);
c0 = (int8_t)((hit_x < 0 ? 0 : hit_x) >> 5);
c1 = (int8_t)((x1 > 319 ? 319 : x1) >> 5);
for (c = c0; c <= c1; c++)
if (ya <= pop_cd_ymax[hit_p][c] && yb >= pop_cd_ymin[hit_p][c]) return 1;
return 0;
}
uint8_t pop_cd_hit_slot(uint8_t who, uint8_t p)
{
const pop_cdraw_t *s = &pop_cd[who];
if (!(pop_cd_dirty & (1 << p))) return 0;
hit_p = p;
hit_x = s->x[p]; hit_y = s->y[p] + POP_YOFF;
hit_w = s->w[p]; hit_h = s->h[p];
if (hit_rect()) return 1;
if (!s->ovalid[p]) return 0;
hit_x = s->ox[p]; hit_y = s->oy[p] + POP_YOFF;
hit_w = s->ow[p]; hit_h = s->oh[p];
return hit_rect();
}
uint8_t pop_cd_hit(uint8_t p, int x0, int y0, int x1, int y1) uint8_t pop_cd_hit(uint8_t p, int x0, int y0, int x1, int y1)
{ {
uint16_t cols; int8_t c0, c1, c;
uint8_t r0, r1; uint8_t ya, yb;
if (!(pop_cd_dirty & (1 << p))) return 0; if (!(pop_cd_dirty & (1 << p))) return 0;
cols = cd_cols_of(x0, x1); if (x1 < 0 || x0 > 319) return 0;
if (!cols) return 0; if (x0 < 0) x0 = 0;
r0 = cd_row_of(y0); if (x1 > 319) x1 = 319;
r1 = cd_row_of(y1); if (y1 < 0 || y0 > 255) return 0;
if (pop_cd_dmask[p][r0] & cols) return 1; ya = (uint8_t)(y0 < 0 ? 0 : y0);
if (r1 != r0) { yb = (uint8_t)(y1 > 255 ? 255 : y1);
if (pop_cd_dmask[p][r1] & cols) return 1; c0 = (int8_t)(x0 >> 5);
if (r1 - r0 > 1 && (pop_cd_dmask[p][1] & cols)) return 1; c1 = (int8_t)(x1 >> 5);
} /* Пересечение отрезков [ya,yb] и [ymin,ymax] хотя бы в одной колонке.
* Пустая колонка держит ymin = 255, ymax = 0 условие ниже её отсеет
* само, отдельной проверки «пусто» не нужно. */
for (c = c0; c <= c1; c++)
if (ya <= pop_cd_ymax[p][c] && yb >= pop_cd_ymin[p][c]) return 1;
return 0; return 0;
} }
/* Страница приведена в порядок — снять с неё метку целиком. */ /* Страница приведена в порядок — снять с неё метку целиком. */
/* Начальное состояние обеих страниц — «ничего не трогали». ЯВНО, а не
* расчётом на обнуление _DATA: пустая колонка обозначается ymin = 255,
* ymax = 0, а нули от crt0 читались бы как «затронута строка 0». */
void pop_cd_init(void)
{
pop_cd_clear(0);
pop_cd_clear(1);
}
void pop_cd_clear(uint8_t p) void pop_cd_clear(uint8_t p)
{ {
pop_cd_dmask[p][0] = pop_cd_dmask[p][1] = pop_cd_dmask[p][2] = 0; uint8_t c;
for (c = 0; c < 10; c++) { pop_cd_ymin[p][c] = 255; pop_cd_ymax[p][c] = 0; }
pop_cd_dirty = (uint8_t)(pop_cd_dirty & ~(1 << p)); pop_cd_dirty = (uint8_t)(pop_cd_dirty & ~(1 << p));
} }
@@ -504,6 +566,7 @@ static const uint8_t *pb_img;
void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom) void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
{ {
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча, см. pop_pace.h */
/* Аргументы сразу в file-scope: третий и дальше SDCC передаёт СТЕКОМ, и /* Аргументы сразу в file-scope: третий и дальше SDCC передаёт СТЕКОМ, и
* дальше каждое их чтение `-N(ix)` (замер 2026-08-17: 76 таких * дальше каждое их чтение `-N(ix)` (замер 2026-08-17: 76 таких
* обращений в теле, ~3 500 такта из 14 500 цены блита). То же лечение, * обращений в теле, ~3 500 такта из 14 500 цены блита). То же лечение,
@@ -536,6 +599,7 @@ void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
* общий путь выше. */ * общий путь выше. */
if (pb_img[1] | pb_img[3]) { /* кадр больше 255 — редкий путь */ if (pb_img[1] | pb_img[3]) { /* кадр больше 255 — редкий путь */
blit_b_oversize(pb_img, pb_x, pb_yb); blit_b_oversize(pb_img, pb_x, pb_yb);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
gfx_w0_unmap(); gfx_w0_unmap();
return; return;
} }
@@ -557,12 +621,23 @@ void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
* целиком внутри поля: иначе он залезет в борт (см. blit_b_clip). */ * целиком внутри поля: иначе он залезет в борт (см. blit_b_clip). */
if (pop_upside && (pb_top < POP_YOFF || pb_top + (int)pb_h > POP_CLIP_BOTTOM)) { if (pop_upside && (pb_top < POP_YOFF || pb_top + (int)pb_h > POP_CLIP_BOTTOM)) {
blit_b_clip(pb_img, pb_x, pb_top, pb_w, pb_h); blit_b_clip(pb_img, pb_x, pb_top, pb_w, pb_h);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
gfx_w0_unmap(); gfx_w0_unmap();
return; return;
} }
/* pb_w<256 && pb_h<256 больше не проверяем — гарантировано типом. */ /* pb_w<256 && pb_h<256 больше не проверяем — гарантировано типом. */
if (pb_x >= 0 && pb_top >= 0 && /* «Целиком на экране?» — ДВУМЯ беззнаковыми сравнениями вместо
pb_x + (int)pb_w <= 320 && pb_top + (int)pb_h <= 256) { * четырёх знаковых. Отрицательная координата в беззнаковом виде
* становится очень большой и проваливает то же условие, что
* проверка `>= 0`, а верхняя граница переносится в правую часть,
* так что сложение уходит вместе с ней. Знаковое сравнение у
* SDCC z80 стоит дорого: пара `sbc` плюс `jp PO / xor 0x80 / jp P`
* на каждое (видно в листинге).
*
* Границы правой части неотрицательны по построению: pb_w и pb_h
* не больше 255, значит 320-pb_w >= 65 и 256-pb_h >= 1. */
if ((unsigned int)pb_x <= (unsigned int)(320 - (int)pb_w) &&
(unsigned int)pb_top <= (unsigned int)(256 - (int)pb_h)) {
if (pop_upside) gfx_blit_noclip_vflip(pb_x, pb_top, pb_img); if (pop_upside) gfx_blit_noclip_vflip(pb_x, pb_top, pb_img);
else gfx_blit_noclip(pb_x, pb_top, pb_img); else gfx_blit_noclip(pb_x, pb_top, pb_img);
} else { } else {
@@ -574,6 +649,7 @@ void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
blit_b_clip(pb_img, pb_x, pb_top, pb_w, pb_h); blit_b_clip(pb_img, pb_x, pb_top, pb_w, pb_h);
} }
} }
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
gfx_w0_unmap(); gfx_w0_unmap();
} }
+48 -7
View File
@@ -633,13 +633,33 @@ void pop_process_trobs(uint8_t cur_room) __banked
pop_level_access_end(); pop_level_access_end();
} }
pop_dbg_m8(); /* ЗАМЕР: префетч кодов тайлов сделан */
/* Указатель на модификаторы КОМНАТЫ кэшируется между итерациями.
*
* pop_trob_modif объявлен __banked, и вызов его на КАЖДЫЙ trob это
* трамплин из банка 6 в банк 6 же... нет: из горячего цикла в банковую
* функцию, то есть полный переход через резидент. А комната у trob'ов
* в подавляющем большинстве кадров ОДНА (все они из cur_room, чужие
* появляются лишь у брошенных плит соседней комнаты). Тот же паттерн
* «трамплин в цикле», что дал 23 784 на луче видимости стража
* (P2b) и 14 118 на guard_over_kid (P16).
*
* Сбрасывается на каждом кадре: room_seen внутри pop_trob_modif живёт
* дольше, но указатель на строку room_modif может смениться при
* перезагрузке комнаты, поэтому кэш локальный для одного прохода. */
{
uint8_t mod_room = 0; /* 0 = «ещё не брали» (комнаты 1..24) */
uint8_t *mod = 0;
for (i = 0; i < trobs_count; i++) { for (i = 0; i < trobs_count; i++) {
uint8_t room = trobs[i].room; uint8_t room = trobs[i].room;
uint8_t tp = trobs[i].tilepos; uint8_t tp = trobs[i].tilepos;
uint8_t code = trob_code[i]; uint8_t code = trob_code[i];
uint8_t *mod = pop_trob_modif(room);
int8_t type = trobs[i].type; int8_t type = trobs[i].type;
if (room != mod_room) { mod = pop_trob_modif(room); mod_room = room; }
switch (code) { switch (code) {
case TILE_SPIKE: case TILE_SPIKE:
animate_spike(&mod[tp], &type); animate_spike(&mod[tp], &type);
@@ -728,7 +748,7 @@ void pop_process_trobs(uint8_t cur_room) __banked
* случай «застыл справа от факела» в BUGS_OPEN.md как открытый: * случай «застыл справа от факела» в BUGS_OPEN.md как открытый:
* пики и меч рядом с факелом на уровнях 1-4 не встретились. */ * пики и меч рядом с факелом на уровнях 1-4 не встретились. */
if (trob_rcode[i] == TILE_CHOMP) if (trob_rcode[i] == TILE_CHOMP)
pop_set_redraw((uint8_t)(tp + 1), POP_RD_CHOMP, 1); pop_set_redraw((uint8_t)(tp + 1), POP_RD_CHOMP_ANIM, 1);
} }
} }
if (room == cur_room && code == TILE_SPIKE) { if (room == cur_room && code == TILE_SPIKE) {
@@ -747,11 +767,31 @@ void pop_process_trobs(uint8_t cur_room) __banked
pop_set_redraw(tp, POP_RD_SPIKE, (uint8_t)(type < 0 ? 2 : 1)); pop_set_redraw(tp, POP_RD_SPIKE, (uint8_t)(type < 0 ? 2 : 1));
} }
if (room == cur_room && code == TILE_CHOMP) { if (room == cur_room && code == TILE_CHOMP) {
/* Кадр меняется каждый тик, пока trob жив — как у пик, метим /* ТОЛЬКО ПОКА ФАЗА < 6 — как в оригинале (animate_chomper,
* текущую страницу; последний кадр (челюсти встали) обе, * seg007:0448 заканчивается `if ((curr_modifier & 0x7F) < 6)
* иначе на второй странице дабл-буфера застынет предыдущая поза * redraw_at_trob();`).
* и чомпер дрожит через кадр. */ *
pop_set_redraw(tp, POP_RD_CHOMP, (uint8_t)(type < 0 ? 2 : 1)); * Это не оптимизация оригинала, а точное следствие таблицы поз:
* CHOMP_FRAM1 = {3,2,0,1,4,3,3}, то есть с фазы 5 и до конца
* круга (POP_CHOMPER_SPEED = 15) поза одна и та же 3.
* Перерисовывать её десять кадров подряд значит рисовать ровно
* ту же картинку, а стоит это 190 260 тактов НА КАДР: полный
* draw_tile плюс heal 32x64, 24 % работы кадра в 11/15
* (замер 2026-08-19).
*
* На фазе 5 метим ОБЕ страницы дабл-буфера: она последняя
* рисуемая, и её поза обязана лечь на обе, иначе на второй
* останется поза фазы 4 и чомпер будет дрожать через кадр.
* Сходится и по кадрам: вторую страницу эта пометка догоняет в
* кадре фазы 6, где поза та же самая (CHOMP_FRAM1[6] == 3).
*
* Про type < 0 отдельной ветки больше нет: trob снимается сам
* только при frame >= 6 (см. animate_chomper выше), то есть
* когда перерисовки уже не идут, а на экране с фазы 5 лежит
* финальная поза на обеих страницах. */
uint8_t ph = (uint8_t)(mod[tp] & 0x7F);
if (ph < 6)
pop_set_redraw(tp, POP_RD_CHOMP, (uint8_t)(ph == 5 ? 2 : 1));
} }
if (room == cur_room && code == TILE_GATE) { if (room == cur_room && code == TILE_GATE) {
/* draw_trob (seg007:01E6), которым заканчивается animate_door: /* draw_trob (seg007:01E6), которым заканчивается animate_door:
@@ -797,6 +837,7 @@ void pop_process_trobs(uint8_t cur_room) __banked
} }
} }
} }
} /* конец блока с кэшем mod/mod_room */
/* compact: удалить завершённые (type < 0). */ /* compact: удалить завершённые (type < 0). */
for (i = 0; i < trobs_count; i++) for (i = 0; i < trobs_count; i++)
+75 -9
View File
@@ -23,6 +23,7 @@
#include "pop_cheat.h" #include "pop_cheat.h"
#include "pop_tune.h" #include "pop_tune.h"
#include "pop_vflip.h" /* зеркальный набор персонажей (зелье инверсии) */ #include "pop_vflip.h" /* зеркальный набор персонажей (зелье инверсии) */
#include "pop_pace.h" /* фиксированный логический кадр по лучу */
#include "roomtest_cold.h" /* холодная половина цикла — в банке */ #include "roomtest_cold.h" /* холодная половина цикла — в банке */
__sfr __at (0xFE) io_border; __sfr __at (0xFE) io_border;
@@ -225,6 +226,7 @@ int main(void)
* по умолчанию (без мерцания); SPACE переключает в однобуфер для * по умолчанию (без мерцания); SPACE переключает в однобуфер для
* отладки багов рисования (edge по sp_prev, чтоб не дёргать). */ * отладки багов рисования (edge по sp_prev, чтоб не дёргать). */
uint8_t front = 0, back, dbuf = 1, sp_prev = 0, frozen = 0; uint8_t front = 0, back, dbuf = 1, sp_prev = 0, frozen = 0;
uint8_t pace_prev = 0; /* edge клавиши смены режима скорости */
uint8_t skip = 0; /* маска «слот перерисовывать не надо» */ uint8_t skip = 0; /* маска «слот перерисовывать не надо» */
uint16_t dead_frames = 0; uint16_t dead_frames = 0;
#ifdef ROOMNAV #ifdef ROOMNAV
@@ -243,6 +245,9 @@ int main(void)
return 1; return 1;
} }
gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */ gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */
/* Темп кадра по лучу. Если бит 5 порта 0xFE не шевелится (иное
* железо), pop_pace_* молча откатываются на прежние gfx_wait_vsync. */
pop_pace_arm();
while (!kbd_raw_down(KBD_ESC)) { while (!kbd_raw_down(KBD_ESC)) {
/* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один /* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один
@@ -260,6 +265,18 @@ int main(void)
} }
sp_prev = sp; sp_prev = sp;
/* P — переключить режим скорости по кругу (FASTEST -> FAST ->
* NORMAL). Edge, чтоб одно нажатие = один шаг. Дефолт FASTEST
* (crt0 зануляет _DATA). Что означают режимы pop_pace.h. */
{
uint8_t pk = kbd_raw_down(KBD_SPEED_MODE);
if (pk && !pace_prev) {
uint8_t m = (uint8_t)(pop_speed_mode + 1);
pop_speed_mode = (uint8_t)(m >= POP_SPEED_MODES ? 0 : m);
}
pace_prev = pk;
}
/* Отладочный стоп-кадр: '1' (PS/2 0x16) — заморозить, '2' (0x1E) — /* Отладочный стоп-кадр: '1' (PS/2 0x16) — заморозить, '2' (0x1E) —
* продолжить. В заморозке пропускаем весь tick/draw/flip: картинка * продолжить. В заморозке пропускаем весь tick/draw/flip: картинка
* стоит, можно спокойно разбирать позу Kid и окклюзию. */ * стоит, можно спокойно разбирать позу Kid и окклюзию. */
@@ -295,7 +312,10 @@ int main(void)
uint8_t xd = kbd_raw_down(KBD_CHEAT_XDEC); uint8_t xd = kbd_raw_down(KBD_CHEAT_XDEC);
uint8_t xi = kbd_raw_down(KBD_CHEAT_XINC); uint8_t xi = kbd_raw_down(KBD_CHEAT_XINC);
if (pop_cheats && k && !cheat_prev) pop_guard_kill(); if (pop_cheats && k && !cheat_prev) pop_guard_kill();
if (pop_cheats && i && !immo_prev) pop_immortal = !pop_immortal; /* Бессмертие по кругу: 0 выкл -> 1 «только бой» -> 2 «бой и
* мелкий урон» -> 0. Что означают уровни pop_state.h. */
if (pop_cheats && i && !immo_prev)
pop_immortal = (uint8_t)(pop_immortal >= 2 ? 0 : pop_immortal + 1);
if (pop_cheats && s && !sword_prev) pop_have_sword = 1; if (pop_cheats && s && !sword_prev) pop_have_sword = 1;
if (pop_cheats && nl && !nlvl_prev && pop_current_level < POP_LAST_LEVEL) if (pop_cheats && nl && !nlvl_prev && pop_current_level < POP_LAST_LEVEL)
pop_next_level = (uint8_t)(pop_current_level + 1); pop_next_level = (uint8_t)(pop_current_level + 1);
@@ -317,7 +337,10 @@ int main(void)
} }
if (frozen) { if (frozen) {
PROF(0); /* чёрный: ждём vsync */ PROF(0); /* чёрный: ждём vsync */
gfx_wait_vsync(); /* Заморозка — тоже полноценный логический кадр: иначе якорь
* пейсинга уедет и первый кадр после разморозки будет рваным. */
pop_wait_edge();
pop_pace_end(pop_pace_n((uint8_t)(Kid.sword == SWORD_2_DRAWN)));
continue; continue;
} }
@@ -361,12 +384,19 @@ int main(void)
back = dbuf ? (uint8_t)(gfx_get_visible_page() ^ 1) : 0; back = dbuf ? (uint8_t)(gfx_get_visible_page() ^ 1) : 0;
gfx_set_draw_page(back); gfx_set_draw_page(back);
} }
pop_dbg_m13(); /* ЗАМЕР: ввод и читы разобраны */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_check_skel(); /* спецсобытие ур.3: скелет встаёт */ pop_check_skel(); /* спецсобытие ур.3: скелет встаёт */
pop_check_mouse(); /* спецсобытие ур.8: мышь жмёт кнопку */ pop_check_mouse(); /* спецсобытие ур.8: мышь жмёт кнопку */
pop_check_killed_shadow(); /* спецсобытие ур.12: смерть тени = смерть Кида */ pop_check_killed_shadow(); /* спецсобытие ур.12: смерть тени = смерть Кида */
pop_frame_timers(); /* timers (seg003:0735): вспышка слияния */ pop_frame_timers(); /* timers (seg003:0735): вспышка слияния */
pop_dbg_m9(); /* ЗАМЕР: pop_frame_timers сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_check_can_guard_see_kid(); /* луч видимости — ДО логики персонажей */ pop_check_can_guard_see_kid(); /* луч видимости — ДО логики персонажей */
pop_dbg_m14(); /* ЗАМЕР: спецсобытия + луч видимости */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_ctrl_tick(); /* ввод -> control(): смена seq */ pop_ctrl_tick(); /* ввод -> control(): смена seq */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Кто в этом кадре не изменился с прошлой отрисовки ЭТОЙ страницы — /* Кто в этом кадре не изменился с прошлой отрисовки ЭТОЙ страницы —
* тот на ней уже нарисован правильно: ни стирать, ни рисовать заново * тот на ней уже нарисован правильно: ни стирать, ни рисовать заново
* не надо (DRAW-COST, контракт в pop_cdraw.h). Решение * не надо (DRAW-COST, контракт в pop_cdraw.h). Решение
@@ -378,18 +408,25 @@ int main(void)
* skip-маске он не участвует (рисуется/стирается безусловно, живёт * skip-маске он не участвует (рисуется/стирается безусловно, живёт
* только пока Кид стоит на тайле зеркала). */ * только пока Кид стоит на тайле зеркала). */
pop_mirror_heal(); pop_mirror_heal();
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_fore_heal(); /* + следы оверлея (шире прямоугольника Kid) */ pop_fore_heal(); /* + следы оверлея (шире прямоугольника Kid) */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
PROF(2); /* логика (тот же синий) */ PROF(2); /* логика (тот же синий) */
pop_dbg_m1(); /* ЗАМЕР: начало логики */ pop_dbg_m1(); /* ЗАМЕР: начало логики */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
kid_tick(); /* play_seq: следующий кадр */ kid_tick(); /* play_seq: следующий кадр */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_phys_tick(); /* K3: падение/приземление/стена */ pop_phys_tick(); /* K3: падение/приземление/стена */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_guard_tick(); /* логика стража — В БАНКЕ (guards.c) */ pop_guard_tick(); /* логика стража — В БАНКЕ (guards.c) */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_guard_phys_tick(); /* и его физика — та же, что у Кида (play_guard_frame) */ pop_guard_phys_tick(); /* и его физика — та же, что у Кида (play_guard_frame) */
/* Боёвка, порядок как в play_frame (seg000:872): сначала пометить /* Боёвка, порядок как в play_frame (seg000:872): сначала пометить
* попадания с обеих сторон, потом применить, потом свести HP. */ * попадания с обеих сторон, потом применить, потом свести HP. */
pop_check_sword_hurting(); pop_check_sword_hurting();
pop_check_sword_hurt(); pop_check_sword_hurt();
pop_do_delta_hp(); pop_do_delta_hp();
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_guard_fallout(); /* страж провалился ниже комнаты — убрать (seg002:241) */ pop_guard_fallout(); /* страж провалился ниже комнаты — убрать (seg002:241) */
if (pop_fell_out) { /* Kid пересёк нижнюю границу комнаты */ if (pop_fell_out) { /* Kid пересёк нижнюю границу комнаты */
uint8_t down = pop_room_link(cur_room, LINK_DOWN); uint8_t down = pop_room_link(cur_room, LINK_DOWN);
@@ -457,10 +494,12 @@ int main(void)
PROF(4); /* слой фона */ PROF(4); /* слой фона */
pop_dbg_m2(); /* ЗАМЕР: конец логики / начало фона */ pop_dbg_m2(); /* ЗАМЕР: конец логики / начало фона */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_loose_tick(); /* loose-полы: анимация + перерисовка (СЛОЙ ФОНА */ pop_loose_tick(); /* loose-полы: анимация + перерисовка (СЛОЙ ФОНА */
/* — ДО Kid, чтобы Kid всегда был поверх плиты) */ /* — ДО Kid, чтобы Kid всегда был поверх плиты) */
pop_dbg_m5(); /* ЗАМЕР: pop_loose_tick сделан */ pop_dbg_m5(); /* ЗАМЕР: pop_loose_tick сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_process_trobs(cur_room); /* пики/ворота/дверь: продвинуть СОСТОЯНИЕ (порт */ pop_process_trobs(cur_room); /* пики/ворота/дверь: продвинуть СОСТОЯНИЕ (порт */
/* process_trobs — рисования внутри нет) */ /* process_trobs — рисования внутри нет) */
if (pop_neigh_dirty) { if (pop_neigh_dirty) {
@@ -488,8 +527,10 @@ int main(void)
} }
} }
pop_dbg_m6(); /* ЗАМЕР: pop_process_trobs сделан */ pop_dbg_m6(); /* ЗАМЕР: pop_process_trobs сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_redraw_needed(); pop_redraw_needed();
pop_dbg_m7(); /* ЗАМЕР: pop_redraw_needed сделан */ /* и разобрать пометки перерисовки тайлов (порт */ pop_dbg_m7(); /* ЗАМЕР: pop_redraw_needed сделан */ /* и разобрать пометки перерисовки тайлов (порт */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* redraw_needed, seg008:0178) — СЛОЙ ФОНА, до Kid */ /* redraw_needed, seg008:0178) — СЛОЙ ФОНА, до Kid */
{ /* ворота соседа слева (room8) видны в col0 — */ { /* ворота соседа слева (room8) видны в col0 — */
uint8_t lft = pop_room_link(cur_room, 0); /* редрой шва ТОЛЬКО при изменении openness */ uint8_t lft = pop_room_link(cur_room, 0); /* редрой шва ТОЛЬКО при изменении openness */
@@ -627,6 +668,7 @@ int main(void)
update_kid_render_dx(); /* straddle: смещение спрайта, если kid_room≠cur_room */ update_kid_render_dx(); /* straddle: смещение спрайта, если kid_room≠cur_room */
PROF(6); /* спрайты: страж, Kid, fore поверх */ PROF(6); /* спрайты: страж, Kid, fore поверх */
pop_dbg_m3(); /* ЗАМЕР: конец фона / начало спрайтов */ pop_dbg_m3(); /* ЗАМЕР: конец фона / начало спрайтов */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* check_mirror (seg003:0798) — ПЕРВАЯ строка draw_people /* check_mirror (seg003:0798) — ПЕРВАЯ строка draw_people
* (seg008:228A), до отрисовки кого бы то ни было: если Кид только что * (seg008:228A), до отрисовки кого бы то ни было: если Кид только что
* пролетел сквозь зеркало, тень должна родиться ДО того, как слот * пролетел сквозь зеркало, тень должна родиться ДО того, как слот
@@ -636,15 +678,28 @@ int main(void)
* фон кадра (перепечатки тайлов, шов) обязан быть готов ДО любого * фон кадра (перепечатки тайлов, шов) обязан быть готов ДО любого
* спрайта, иначе перепечатка ложится поверх уже нарисованного куска * спрайта, иначе перепечатка ложится поверх уже нарисованного куска
* так окно задней стены и передняя грань пола оказывались на плите. */ * так окно задней стены и передняя грань пола оказывались на плите. */
pop_dbg_m10(); /* ЗАМЕР: check_mirror сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_loose_mob_draw(); pop_loose_mob_draw();
pop_dbg_m11(); /* ЗАМЕР: pop_loose_mob_draw сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
{ /* Кто позже — тот поверх; порядок задаёт обход тайлов, а не роль { /* Кто позже — тот поверх; порядок задаёт обход тайлов, а не роль
* персонажа (см. guard_over_kid). Отрисовка одна на всех Char * персонажа (см. guard_over_kid). Отрисовка одна на всех Char
* (pop_cdraw.c), различает их только слот. */ * (pop_cdraw.c), различает их только слот. */
uint8_t g_after = guard_over_kid();
/* Пересчёт после тика: слот, за который heal не платили, мог /* Пересчёт после тика: слот, за который heal не платили, мог
* всё-таки сдвинуться тогда бит снимется, а прошлый кадр * всё-таки сдвинуться тогда бит снимется, а прошлый кадр
* сотрёт сам pop_char_draw (страховка cd_heal). */ * сотрёт сам pop_char_draw (страховка cd_heal). */
uint8_t g_after;
skip = pop_char_skip_mask(); skip = pop_char_skip_mask();
pop_dbg_m12(); /* ЗАМЕР: skip_mask сделан */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
/* Порядок «кто поверх кого» нужен, только если кого-то РИСУЕМ.
* При skip == 3 оба слота тихие, рисовать некого а вызов стоит
* трамплина в банк 8 и двух objtile_at_char, 14 424 такта
* (замер 11/15, 2026-08-19). Перестановка безопасна: обе
* функции только читают, и читают разное skip_mask снимок
* cd_sig, guard_over_kid габариты pop_cd прошлого кадра. */
g_after = (skip == 3) ? 0 : guard_over_kid();
if (!g_after && !(skip & 2)) { pop_char_draw(POP_CH_OPP); pop_char_fore(POP_CH_OPP); } if (!g_after && !(skip & 2)) { pop_char_draw(POP_CH_OPP); pop_char_fore(POP_CH_OPP); }
PROF(6); /* кадр Кида (тот же циан) */ PROF(6); /* кадр Кида (тот же циан) */
if (!(skip & 1)) pop_char_draw(POP_CH_KID); /* спрайт + брызги + клинок */ if (!(skip & 1)) pop_char_draw(POP_CH_KID); /* спрайт + брызги + клинок */
@@ -669,7 +724,9 @@ int main(void)
* оригинала (draw_tables, seg008:1373). Пометки ставят сами * оригинала (draw_tables, seg008:1373). Пометки ставят сами
* объекты вокруг себя (pop_redraw.h). */ * объекты вокруг себя (pop_redraw.h). */
pop_fore_needed(); pop_fore_needed();
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_hp_draw(); /* полоса HP — только при изменении */ pop_hp_draw(); /* полоса HP — только при изменении */
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
PROF(6); /* fore поверх Kid + борта */ PROF(6); /* fore поверх Kid + борта */
/* Fore-слой Кида — В КОНЦЕ, после плиты и полосы HP: окно fore-клипа /* Fore-слой Кида — В КОНЦЕ, после плиты и полосы HP: окно fore-клипа
* одно на всех, и его ставит каждый, кто рисовал персонажа, поэтому * одно на всех, и его ставит каждый, кто рисовал персонажа, поэтому
@@ -682,11 +739,18 @@ int main(void)
* взводит бит и обязан быть учтён. Всё, что рисуется ниже * взводит бит и обязан быть учтён. Всё, что рисуется ниже
* (mob поверх Кида, борта), взводит его заново само. */ * (mob поверх Кида, борта), взводит его заново само. */
pop_cd_clear(back); pop_cd_clear(back);
pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_room_clip_borders(); /* почистить борта — только в кадрах падения (гейт) */ pop_room_clip_borders(); /* почистить борта — только в кадрах падения (гейт) */
/* Пейсинг 3 кадра, затем tear-free флип на vblank: draw уже /* ФИКСИРОВАННЫЙ ЛОГИЧЕСКИЙ КАДР (pop_pace.h): период = max(n,
* сделан (скрытая страница), wait_vsync ловит начало кадра, * ceil(W)) вместо прежнего ceil(W) + 2. Раньше здесь стояли три
* set_visible_page переключает во время бланка (gfx.h паттерн). */ * gfx_wait_vsync ПОДРЯД они отсчитывались от КОНЦА работы, из-за
* чего бюджет кадра был один растр, а превышение его на такт стоило
* целого лишнего. Теперь ждём от ЯКОРЯ начала кадра.
*
* Первый фронт всегда: на нём и происходит tear-free своп (до
* начала картинки остаётся верхний бордер, 16 строк). Строб вспышки
* снимается ровно между первым фронтом и остальными, как и было. */
PROF(0); /* чёрный: ждём vsync */ PROF(0); /* чёрный: ждём vsync */
@@ -698,13 +762,15 @@ int main(void)
#endif #endif
pop_dbg_m4(); /* ЗАМЕР: конец спрайтов */ pop_dbg_m4(); /* ЗАМЕР: конец спрайтов */
gfx_wait_vsync(); pop_beam_sample(); /* ТЕМП КАДРА: выборка луча (pop_pace.h) */
pop_wait_edge();
if (flash_on) { /* строб: цвет держался один кадр растра */ if (flash_on) { /* строб: цвет держался один кадр растра */
flash_bg(0, 0, 0); flash_bg(0, 0, 0);
flash_on = 0; flash_on = 0;
} }
gfx_wait_vsync(); /* Условие боя — буквально как у оригинала (SDLPoP seg003.c:363):
gfx_wait_vsync(); * не «идёт бой» и не «рядом страж», а «у Кида ВЫНУТ МЕЧ». */
pop_pace_end(pop_pace_n((uint8_t)(Kid.sword == SWORD_2_DRAWN)));
if (dbuf) { if (dbuf) {
gfx_set_visible_page(back); gfx_set_visible_page(back);
front = back; front = back;
+127 -14
View File
@@ -23,8 +23,10 @@
#include <sprite.h> /* gfx_copy_page */ #include <sprite.h> /* gfx_copy_page */
#include "pop_tune.h" #include "pop_tune.h"
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */ #include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */ #include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */ #include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
#include <stdio.h> /* puts/printf — сообщения об ошибках загрузки */ #include <stdio.h> /* puts/printf — сообщения об ошибках загрузки */
#include <kbd_raw.h> #include <kbd_raw.h>
@@ -101,6 +103,33 @@ void pop_start_level(void) __banked
* и разворачивается seq_5 */ * и разворачивается seq_5 */
} }
#ifdef DBG_START_ROOM
/* ОТЛАДОЧНЫЙ СТАРТ: сразу в целевую комнату оптимизации, минуя проход
* уровня. Задаётся сборкой (make ROOM=15 POS=2), по умолчанию сцена
* 11/15 из docs/perf_l11_room15.md: Кид в (0,2), справа чомпер под
* факелом, дальше второй факел и страж.
*
* Ставится ПОСЛЕ чекпойнта намеренно: отладочная позиция должна
* перебивать и его тоже, иначе рестарт уровня уводил бы Кида из
* измеряемой комнаты. Направление и позу входа не трогаем они из
* данных уровня, как у обычного старта. */
/* ...НО ТОЛЬКО НА СВОЁМ УРОВНЕ. pop_start_level зовётся ещё и на
* СМЕНЕ уровня, и без этой проверки отладочная позиция перехватывала
* переход: на 6-м Кид проваливается в комнате 1, а на 7-м обязан
* появиться в комнате 17 и продолжить падение вниз (спецсобытие
* «вход падением», см. ниже). Вместо этого он оказывался в
* DBG_START_ROOM/POS, спецсобытие не срабатывало вовсе, и переход 6->7
* выглядел сломанным (поймано пользователем 2026-08-20 на сборке
* make LEVEL=6 ROOM=1 POS=18).
*
* Рестарт ТОГО ЖЕ уровня (смерть, чекпойнт) отладочную позицию
* сохраняет ради этого она и перебивает чекпойнт выше. */
if (pop_current_level == FIRST_LEVEL) {
start_room = DBG_START_ROOM;
pos = DBG_START_POS;
}
#endif
/* Рестарт уровня = load_level() заново (play_level, seg003:57): в /* Рестарт уровня = load_level() заново (play_level, seg003:57): в
* исходное возвращаются И модификаторы (пики/ворота), И сами тайлы * исходное возвращаются И модификаторы (пики/ворота), И сами тайлы
* разбитые плиты целы, выпитые зелья на месте (BUG-RESPAWN-1), и * разбитые плиты целы, выпитые зелья на месте (BUG-RESPAWN-1), и
@@ -146,6 +175,26 @@ void pop_start_level(void) __banked
enter_room(start_room); enter_room(start_room);
kid_init(seq, (int8_t)(pos % 10), (int8_t)(pos / 10), dir); kid_init(seq, (int8_t)(pos % 10), (int8_t)(pos / 10), dir);
#ifdef DBG_START_ROOM
/* ...и поправить x: kid_init ставит `x_bump[col] + TILE_SIZEX`, а это
* ЛЕВАЯ ГРАНИЦА СЛЕДУЮЩЕЙ колонки (порт set_start_pos в данных уровня
* стартовые позиции подобраны под такую договорённость). Отладочный
* старт называет тайл, В КОТОРОМ Кид должен оказаться, поэтому сдвигаем
* его внутрь названного: с границы физика относит Кида к колонке правее,
* и в 11/15 он стартовал прямо в челюстях (найдено пользователем
* 2026-08-19).
*
* Отступ ровно 2, а не «половина тайла»: колонку определяет не сам x, а
* ВЕСОВАЯ ТОЧКА кадра (determine_col -> dx_weight: dx позы минус
* weight-биты, с учётом направления), поэтому геометрически ровной
* середины тут нет. Число снято замером живой сцены: x = 98 при позе
* стойки даёт curr_col = 2, x = 94 уже 1. */
if (pop_current_level == FIRST_LEVEL) { /* см. условие выше */
Kid.x = (uint8_t)(pop_x_bump[(pos % 10) + FIRST_ONSCREEN_COLUMN] +
TILE_SIZEX - 2);
Kid.curr_col = (int8_t)(pos % 10);
}
#endif
/* Спецсобытие «вход падением» (set_start_pos, seg003:0196): на 7-м /* Спецсобытие «вход падением» (set_start_pos, seg003:0196): на 7-м
* уровне Кид ставится в комнату 17, а экран тут же переводится на * уровне Кид ставится в комнату 17, а экран тут же переводится на
* комнату ПОД ней (`goto_other_room(3)`: y = 189, ряд пересчитать) * комнату ПОД ней (`goto_other_room(3)`: y = 189, ряд пересчитать)
@@ -259,27 +308,86 @@ uint8_t pop_dbg_roomnav(uint8_t dir) __banked
return 1; return 1;
} }
/* ---- ОТЛАДКА: номер отрисованной комнаты в верхнем борте ------------- */ /* ---- ОТЛАДКА: состояние прогона в верхнем борте ---------------------- *
*
* Четыре блока ПАЛОЧКАМИ, каждый своим цветом (outtextxy не годится: текст
* тянет системный знакогенератор, _gfx_font_buf 2 КБ статики в W2, и
* сажает кучу). Двузначные числа двумя группами: слева десятки, справа
* единицы, || |||| = 24.
*
* жёлтый x 10.. 85 уровень (1..15)
* белый x 100..175 комната (1..24)
* голубой x 190..205 скорость (1 палочка NORMAL, 2 FAST, 3 FASTEST)
* красный x 220..230 бессмертие (1 палочка = только бой, 2 = и мелкий урон)
*
* ЦВЕТА индексы ИГРОВОЙ палитры, и выбраны они не по вкусу, а по двум
* ограничениям.
*
* Первое: записи палитры лежат в ФОРМАТЕ BGR (BIOS $A4), а не RGB в
* kid.pal 0x3E это `55 ff ff`, то есть жёлтый, а не голубой. Читать файл
* «как привычно» нельзя, цвета выйдут переставленными (на этом я и
* попался в первом заходе).
*
* Второе, важнее: часть палитры ПЕРЕЗАПИСЫВАЕТСЯ на ходу 0x40..0x4F
* зелья, 0x50..0x5F env, 0x60..0x6F стена (эти два меняются при смене
* ТАЙЛСЕТА, подземелье/дворец), 0x90..0x9F страж. Прежняя метка комнаты
* рисовалась цветом 0x57, то есть из env-диапазона: белой она была только
* в подземелье, а во дворце брала случайный цвет тайлсета. Здесь берём
* только 0x3A..0x3F их не трогает никто:
* 0x3E жёлтый (255,255,85), 0x3F белый, 0x3B голубой (85,255,255),
* 0x3C красный (226,0,48) тот же, которым игра даёт вспышку урона.
*
* Перерисовка change-driven: пока ничего из показанного не изменилось, в
* борт не пишем вовсе. Счётчик 2 обе страницы дабл-буфера. */
static uint8_t room_label_sig = 0xFF; /* последняя показанная комната */ static uint8_t room_label_sig = 0xFF; /* последняя показанная комната */
static uint8_t lvl_label_sig = 0xFF; /* ...уровень */
static uint8_t spd_label_sig = 0xFF; /* ...режим скорости */
static uint8_t imm_label_sig = 0xFF; /* ...бессмертие */
static uint8_t room_label_redraw; /* остаток кадров (2 = обе страницы) */ static uint8_t room_label_redraw; /* остаток кадров (2 = обе страницы) */
/* Группа палочек: n штук от x вправо, шаг 5, ширина 2. */
static void label_bars(int x, uint8_t n)
{
uint8_t i;
for (i = 0; i < n; i++) bar(x + i * 5, 0, x + 1 + i * 5, 5);
}
void pop_dbg_room_label(uint8_t room) __banked void pop_dbg_room_label(uint8_t room) __banked
{ {
uint8_t prev = room_label_sig, i; /* СНАЧАЛА сохранить, потом сравнивать — обход sdcc_z80_cmp_store_a_bug
room_label_sig = room; /* СНАЧАЛА сохранить (sdcc_z80_cmp_store_a_bug) */ * (`if (n != g) g = n;` пишет разность вместо значения). */
if (room != prev) room_label_redraw = 2; uint8_t prev_room = room_label_sig, prev_lvl = lvl_label_sig;
uint8_t prev_spd = spd_label_sig, prev_imm = imm_label_sig;
uint8_t lvl = pop_current_level, spd = pop_speed_mode, imm = pop_immortal;
room_label_sig = room;
lvl_label_sig = lvl;
spd_label_sig = spd;
imm_label_sig = imm;
if (room != prev_room || lvl != prev_lvl || spd != prev_spd || imm != prev_imm)
room_label_redraw = 2;
if (!room_label_redraw) return; if (!room_label_redraw) return;
/* Номер ПАЛОЧКАМИ, а не outtextxy: текст тянет системный знакогенератор
* (_gfx_font_buf, 2 КБ статики в W2) и сажает кучу. Слева десятки,
* справа единицы: || |||| = 24. */
setfillstyle(SOLID_FILL, BLACK); setfillstyle(SOLID_FILL, BLACK);
bar(100, 0, 175, 5); bar(10, 0, 230, 5);
/* 0x57, а НЕ WHITE: палитра игровая, запись 15 в ней чёрная
* (kid.pal: 0x0F = 0,0,0; чистый белый = 0x57). */ setfillstyle(SOLID_FILL, 0x3E); /* уровень — жёлтый */
setfillstyle(SOLID_FILL, 0x57); label_bars(10, (uint8_t)(lvl / 10));
for (i = 0; i < (uint8_t)(room / 10); i++) bar(100 + i * 5, 0, 101 + i * 5, 5); label_bars(40, (uint8_t)(lvl % 10));
for (i = 0; i < (uint8_t)(room % 10); i++) bar(130 + i * 5, 0, 131 + i * 5, 5);
setfillstyle(SOLID_FILL, 0x3F); /* комната — белый */
label_bars(100, (uint8_t)(room / 10));
label_bars(130, (uint8_t)(room % 10));
/* Скорость: номер режима + 1, то есть NORMAL 1, FAST 2, FASTEST 3 —
* чем больше палочек, тем быстрее (см. нумерацию в pop_pace.h). */
setfillstyle(SOLID_FILL, 0x3B); /* скорость — голубой */
label_bars(190, (uint8_t)(spd + 1));
if (imm) { /* бессмертие — красный */
setfillstyle(SOLID_FILL, 0x3C);
label_bars(220, imm); /* палочек = уровень (1 или 2) */
}
room_label_redraw--; room_label_redraw--;
} }
@@ -648,6 +756,7 @@ int pop_boot(void) __banked
puts("pop_bg_load failed"); puts("pop_bg_load failed");
return -1; return -1;
} }
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
pop_cheats = 1; /* режим разработки: читы включены */ pop_cheats = 1; /* режим разработки: читы включены */
pop_guard_reset(); pop_guard_reset();
if (pop_kid_data_load("KID\\kid_data.bin") != 0) { /* кадры+seqtbl в EMM-странице */ if (pop_kid_data_load("KID\\kid_data.bin") != 0) { /* кадры+seqtbl в EMM-странице */
@@ -656,6 +765,7 @@ int pop_boot(void) __banked
} }
pop_sword_load(); /* меч в руке (без него кадры 229+ без клинка) */ pop_sword_load(); /* меч в руке (без него кадры 229+ без клинка) */
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */ pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
if (pop_kid_load(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */ if (pop_kid_load(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed"); puts("kid atlas load failed");
return -1; return -1;
@@ -678,6 +788,8 @@ int pop_boot(void) __banked
* дворцовых уровнях без этого рисуется дворцовая геометрия подземельными * дворцовых уровнях без этого рисуется дворцовая геометрия подземельными
* красками (сине-серое вместо песочного). */ * красками (сине-серое вместо песочного). */
pop_bg_pal_apply(); pop_bg_pal_apply();
/* ...и палитру Тени: kid.pal затёр и её 16 слотов (pop_shadow.c). */
pop_shadow_pal_apply();
/* Фон комнаты рисуется в ОБЕ графические страницы: у каждой своя /* Фон комнаты рисуется в ОБЕ графические страницы: у каждой своя
* ОЗУ-копия/видео-ОЗУ, а heal берёт чистый фон из ОЗУ-копии ТОЙ * ОЗУ-копия/видео-ОЗУ, а heal берёт чистый фон из ОЗУ-копии ТОЙ
@@ -760,6 +872,7 @@ void pop_shutdown(void) __banked
closegraph(); closegraph();
pop_bg_free(); pop_bg_free();
pop_kid_free(); pop_kid_free();
pop_shadow_free();
pop_guard_free(); pop_guard_free();
pop_kid_data_free(); pop_kid_data_free();
pop_level_free(); pop_level_free();
@@ -249,6 +249,38 @@ TC_TEST(phys_loose_survives_room_change)
TC_EQ(pop_loose_modif[pos], 0); TC_EQ(pop_loose_modif[pos], 0);
} }
TC_TEST(phys_loose_gate_survives_room_change)
{
/* GUARD против гейта холостого хода (loose_any, pop_map.c). Гейт
* пропускает оба цикла pop_loose_tick, пока ни одна фаза не взведена, и
* взводится ПЯТЬЮ местами записи. Четыре из них взвод дрожи от шага и
* сотрясения уже прогоняет phys_loose_floor_breaks; пятое (фаза
* ВОССТАНОВЛЕНА входом в комнату) не покрывал никто, а именно оно даёт
* самый тихий из возможных отказов: плита, к которой Кид вернулся,
* застыла бы на полудроже навсегда.
*
* Поэтому проверяем не флаг (он статик модуля), а НАБЛЮДАЕМОЕ следствие:
* после возврата в комнату тик обязан ДВИГАТЬ фазу. */
uint8_t pos = 1 * 10 + 4; /* loose-плита сцены room_loose */
uint8_t phase = 3; /* середина отсчёта до провала (1..10) */
/* Начинаем со снятого гейта: старт уровня забывает все фазы, и ни одно
* место взвода после этого не срабатывает. */
sc_room(room_loose, 1);
pop_loose_forget();
pop_loose_tick(); /* холостой проход — гейт снят */
/* Плита осталась недодрожавшей в room_modif, Кид возвращается в комнату. */
pop_loose_modif[pos] = phase;
pop_loose_leave_room();
pop_loose_reset();
TC_EQ(pop_loose_modif[pos], phase);
/* И вот теперь тик обязан её ДВИНУТЬ, а не пропустить по снятому гейту. */
pop_loose_tick();
TC_EQ(pop_loose_modif[pos], (uint8_t)(phase + 1));
}
TC_TEST(phys_running_jump_over_3tile_gap) TC_TEST(phys_running_jump_over_3tile_gap)
{ {
/* BUG-RJUMP-1. Разбег влево от колонки 8, дальше Up — разбег-прыжок. /* BUG-RJUMP-1. Разбег влево от колонки 8, дальше Up — разбег-прыжок.
@@ -327,6 +359,7 @@ void main(void)
TC_RUN(phys_run_off_ledge); TC_RUN(phys_run_off_ledge);
TC_RUN(phys_loose_floor_breaks); TC_RUN(phys_loose_floor_breaks);
TC_RUN(phys_loose_survives_room_change); TC_RUN(phys_loose_survives_room_change);
TC_RUN(phys_loose_gate_survives_room_change);
TC_RUN(phys_running_jump_over_3tile_gap); TC_RUN(phys_running_jump_over_3tile_gap);
TC_RUN(phys_feather_fall_is_slow_and_harmless); TC_RUN(phys_feather_fall_is_slow_and_harmless);
} }
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
pop_pack_shadow.py упаковщик спрайтов ТЕНИ.
ЗАЧЕМ ОТДЕЛЬНЫЙ НАБОР. Оригинал не хранит спрайтов Тени вовсе: он кладёт
обычный спрайт ДВАЖДЫ в x обычным прозрачным блитом, и в x+1 «блиттером
XOR» (draw_objtable_item, seg008.c:1600). Наш пакетный блит так не умеет,
поэтому результат запекается заранее, здесь.
ЧТО ИМЕННО ДЕЛАЕТ ОРИГИНАЛ (проверено по исходнику, не по памяти):
* `blitters_2_or` НЕ побитовое ИЛИ. В SDLPoP это обычный блит с
colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306), то
есть один в один наш прозрачный блит.
* `blitters_3_xor` работает по 24-битному RGB, а не по индексам палитры
(blit_xor, seg009.c:3190: конвертация в 24 бита, затем `*p_dest ^=
*p_src` побайтно). Прозрачности у него нет XOR'ится весь
прямоугольник, но прозрачный пиксель это чёрный 0x000000, а XOR с
нулём ничего не меняет.
Отсюда своя палитра: XOR двух цветов игровой палитры даёт цвет, которого в
ней нет.
ЕДИНСТВЕННОЕ РАСХОЖДЕНИЕ. XOR считается по тому, что УЖЕ на экране, то
есть зависит от фона. Разбор по пикселям: от фона зависит только кайма в
один пиксель по ЛЕВЫМ кромкам силуэта (там, где спрайт прозрачен в x, но
непрозрачен в x1). На чёрном фоне `фон XOR цвет == цвет`, и запечка
точна; Тень почти всегда на чёрном. Мы печём именно этот случай.
ДВА НАБОРА В ОДНОМ АТЛАСЕ. Набор спрайтов у оригинала выбирает НЕ
персонаж, а поле `sword` самого кадра: load_frame_to_obj (seg008.c:1752)
держит chtab_base жёстко равным chtab_2 и прибавляет `sword >> 6`. У всех
кадров frame_table_kid эти биты нулевые, у всех кадров frame_tbl_guard
0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533), то
есть идёт через chtab_5, а chtab_5 на 12-м уровне грузится из SHADOW.DAT
(tbl_guard_type[12] == 4, seg000.c:1117) и это графика КИДА в боевых
позах, а не стража. Поэтому печём обе половины:
KID data/KID/res(401+id).png, id 0..218 -> sk0..skN.atl
SHADOW data/SHADOW/res(751+id).png, id 0..31 -> sf0..sfM.atl
Выход: poc/res/shadow/sk*.atl + sf*.atl + pop_shadow_atlas.h (палитра
внутри заголовка 16 записей, её заливает приложение через gfx_pal_load).
"""
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import pop_pack_bg as B
from pop_pack_kid import transpose_cols
DATA = os.path.join(HERE, "..", "SDLPoP", "data")
OUT_DIR = os.path.join(HERE, "..", "poc", "res", "shadow")
PAL_BASE = 0xA0 # слоты Sprinter под Тень: 0xA0..0xAF
NCOL = 16 # столько цветов в палитре Тени
SHIFT = 3 # sk[id>>3], idx id&7 — как у Kid (кадры крупные)
MASK = (1 << SHIFT) - 1
# Половины набора: (каталог, база ресурса, префикс выходных файлов).
# Off-by-one тот же, что у Kid и стража: images[0] = res(база+1), значит
# frame.image = N -> res(база+1+N) (см. pop_pack_kid.load_kid).
HALVES = (("KID", 400, "sk"),
("SHADOW", 750, "sf"))
def frames(dirname, base):
"""Все кадры половины: [(id, w, h, [[rgb|None]...])] — уже после
наложения XOR, в честном RGB."""
out = []
id = 0
while True:
path = os.path.join(DATA, dirname, f"res{base + 1 + id}.png")
if not os.path.exists(path):
break
im = Image.open(path)
if im.mode != "P":
B.die(f"{path}: mode {im.mode}, ожидался P")
pal = im.getpalette()[:NCOL * 3]
rgb = [tuple(pal[i * 3:i * 3 + 3]) for i in range(NCOL)]
w, h = im.size
px = im.load()
grid = []
for y in range(h):
# Ширина на один больше: второй проход кладётся в x+1, и
# суммарный след занимает [x, x+w].
row = [None] * (w + 1)
for x in range(w): # проход 1: обычный блит
i = px[x, y]
if i:
row[x] = rgb[i]
for x in range(w): # проход 2: XOR в x+1
i = px[x, y]
if not i:
continue # чёрный: XOR — не операция
s = rgb[i]
t = x + 1
row[t] = s if row[t] is None else tuple(a ^ b for a, b in zip(row[t], s))
grid.append(row)
out.append((id, w + 1, h, grid))
id += 1
return out
def histogram(halves):
h = {}
for _, fr in halves:
for _, _, _, grid in fr:
for row in grid:
for c in row:
if c is not None:
h[c] = h.get(c, 0) + 1
return h
def _d2(a, b):
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
def _kmeans(pts, k, init):
"""Взвешенный Ллойд по СПИСКУ ЦВЕТОВ с весами (их полсотни, не пиксели)."""
cent = list(init)
for _ in range(80):
groups = [[] for _ in cent]
for c, n in pts:
groups[min(range(len(cent)), key=lambda j: _d2(c, cent[j]))].append((c, n))
new = []
for j, g in enumerate(groups):
if not g:
new.append(cent[j])
continue
w = sum(n for _, n in g)
new.append(tuple(int(round(sum(c[i] * n for c, n in g) / w)) for i in range(3)))
if new == cent:
break
cent = new
return cent
def build_palette(hist):
"""16 цветов ГИБРИДОМ: часть слотов — самые частые цвета ТОЧНО, остаток
взвешенные кластеры хвоста.
Почему не «просто самые частые»: тогда редкие цвета уезжают в далёкий
ближайший, и грубых промахов (сдвиг >90 по евклиду в RGB при максимуме
441) получается впятеро больше 381 пиксель против 76 на все 251
кадр (замер 2026-08-20). Почему не чистый k-means: он двигает вообще
все цвета, включая массовые.
Разделение слотов подбираем перебором по критерию «сначала минимум
грубых промахов, потом заметных, потом худшая ошибка»."""
items = sorted(hist.items(), key=lambda kv: -kv[1])
def stats(pal):
bad = worst = mid = big = 0
for c, n in items:
e = min(_d2(c, p) for p in pal) ** 0.5
if e > 0:
bad += n
worst = max(worst, e)
if e >= 90:
big += n
elif e >= 40:
mid += n
return bad, mid, big, worst
best = None
for m in range(1, NCOL): # m слотов под хвост
keep = [c for c, _ in items[:NCOL - m]]
rest = [(c, n) for c, n in items[NCOL - m:]]
if not rest:
continue
pal = keep + _kmeans(rest, m, [c for c, _ in rest[:m]])
bad, mid, big, worst = stats(pal)
score = (big, mid, worst)
if best is None or score < best[0]:
best = (score, m, pal, (bad, mid, big, worst))
_, m, pal, st = best
# Чёрный обязан быть ТОЧНЫМ и лежать в слоте 0: это XOR-погашенная
# середина силуэта, больше половины всех пикселей Тени.
j = min(range(len(pal)), key=lambda i: _d2((0, 0, 0), pal[i]))
pal[j] = (0, 0, 0)
pal[0], pal[j] = pal[j], pal[0]
bad, mid, big, worst = st
tot = sum(hist.values())
print(f" палитра: {NCOL - m} частых точно + {m} кластеров хвоста; "
f"из {len(items)} цветов изменено {bad} px из {tot} "
f"({100.0 * bad / tot:.2f} %), заметно {mid}, сильно {big}, худшая {worst:.0f}")
return pal
def encode(grid, w, h, pal):
"""RGB-сетка -> байты атласа: нет пикселя -> 0xFF, цвет -> PAL_BASE + k.
Чёрный НАСТОЯЩИЙ цвет Тени (слот 0), а не прозрачность."""
row = bytearray()
for y in range(h):
for x in range(w):
c = grid[y][x]
if c is None:
row.append(0xFF)
else:
row.append(PAL_BASE + min(range(len(pal)), key=lambda k: _d2(c, pal[k])))
return transpose_cols(w, h, bytes(row))
def write_header(path, counts, pal):
vals = []
for (r, g, b) in pal:
vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
lines = [
"/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
" * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.",
" *",
" * Две половины, потому что и у оригинала их две (см. шапку",
" * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
" * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.",
" * Какая половина нужна, приложение уже знает — тем же условием,",
" * что выбирает таблицу кадров (pop_frame_tbl_is_guard).",
" *",
" * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход",
" * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].",
" * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.",
" */",
"#ifndef POP_SHADOW_ATLAS_H",
"#define POP_SHADOW_ATLAS_H",
"",
"#include <stdint.h>",
"",
"#define POP_SHADOW_SHIFT %d" % SHIFT,
"#define POP_SHADOW_MASK %d" % MASK,
"#define POP_SHADOW_PAL_BASE 0x%02X /* слоты Sprinter под Тень */" % PAL_BASE,
"",
"/* Половина «вне боя» — кадры Кида (chtab_2). */",
"#define POP_SHADOW_KID_PAGES %d" % counts["sk"],
"/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
"#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
"",
"static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
]
lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
lines += [
"};",
"",
"static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
]
lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
lines += [
"};",
"",
"/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
"static const uint8_t pop_shadow_pal[64] = {",
" " + ",".join("0x%02X" % v for v in vals) + ",",
"};",
"",
"#endif",
"",
]
with open(path, "w") as f:
f.write("\n".join(lines))
print(f" pop_shadow_atlas.h -> {path}")
def main():
os.makedirs(OUT_DIR, exist_ok=True)
halves = [(prefix, frames(d, base)) for d, base, prefix in HALVES]
for (d, base, prefix), (_, fr) in zip(HALVES, halves):
if not fr:
B.die(f"нет спрайтов в data/{d} (SHADOW.DAT распакован?)")
print(f" {d}: {len(fr)} кадров")
pal = build_palette(histogram(halves))
counts, total, nspr = {}, 0, 0
for prefix, fr in halves:
pages = {}
for id, w, h, grid in fr:
pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, encode(grid, w, h, pal))
nspr += 1
npg = max(pages) + 1
counts[prefix] = npg
for pg in range(npg):
nm = f"{prefix}{pg}.atl"
cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
total += sz
print(f" {prefix}*.atl: {len(fr)} спрайтов, {npg} EMM-страниц")
write_header(os.path.join(HERE, "..", "roomtest", "pop_shadow_atlas.h"), counts, pal)
print(f"ИТОГО: {nspr} спрайтов Тени, "
f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}")
if __name__ == "__main__":
main()