Compare commits

...

37 Commits

Author SHA1 Message Date
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
25 changed files with 2119 additions and 332 deletions
+41
View File
@@ -439,3 +439,44 @@ BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
**Что проверять при регрессе.** Уровень 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 этот сценарий не воспроизводил —
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
край и добить.
+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` уводит кадр с
`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-механики, а не только когда меняешь её
сознательно.
+853
View File
@@ -0,0 +1,853 @@
# Реестр оптимизаций: всё отложенное, в одном списке
Собрано 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) |
**Что проверять в первую очередь при новом «дорогом» месте:** не сколько
там арифметики, а сколько раз за кадр пересекается граница банка.
+43
View File
@@ -3337,3 +3337,46 @@ draw_tile_anim(); draw_tile_bottom(0); draw_loose(0);
`draw_tile_anim_right` — то есть анимированной правой грани СОСЕДА (пики,
ворота, 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.
+11 -1
View File
@@ -45,8 +45,18 @@ PROF_FLAGS := -DPROF_BORDER=$(PROF)
# Стартовый уровень (отладка): make LEVEL=9 — начать сразу с девятого.
# Дефолт держим на отлаживаемом сейчас уровне; для «настоящей» игры — LEVEL=1.
# Константа объявлена в pop_tune.h (её видят обе половины главного цикла).
LEVEL ?= 9
LEVEL ?= 11
PROF_FLAGS += -DFIRST_LEVEL=$(LEVEL)
# Стартовая КОМНАТА и позиция Кида в ней (отладка): 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_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
+1 -1
View File
@@ -55,7 +55,7 @@ ESC выход.
## Статус (2026-08-18)
**Уровни 1-3 приняты, 4-9 прошли предварительный тест.** Работает: комнаты и
**Уровни 1-3 приняты, 4-12 прошли предварительный тест.** Работает: комнаты и
переходы, Kid (анимация, управление, коллизия/падение, зацеп/подтягивание/
спуск, окклюзия), кнопки и ворота, пики, чомперы, проваливающиеся полы, дверь
уровня и переход на следующий, меч и бой, стражи с ИИ (включая скелета и
+74 -1
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`) |
| 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) | багов не найдено |
| 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 пройдены прогоном по
сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности
@@ -258,6 +261,64 @@ MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
## 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'ов не совпадает со следом перерисовки — ОБЯЗАТЕЛЬНО
**Постановка (пользователь, 2026-08-18).** Ширины heal'ов взяты «на глаз по
@@ -303,6 +364,18 @@ MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
прогон комнат с плитами, пиками и чомперами: сужение 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: встреча с тенью, бой, слияние
+54 -59
View File
@@ -56,39 +56,16 @@ static uint8_t tile_is_floor(uint8_t t)
}
}
/* Тайл в ряду Кида по X-координате персонажа (порт get_tile_at_kid,
* seg003:761): xpos 7 делится на ширину тайла с округлением вниз. */
static int8_t tile_col; /* колонка последнего tile_at_kid */
/* get_tile_at_kid (seg003:761) СНЯТ вместе с переходом луча на колонки:
* единственным его вызывающим был этот луч, а он больше не работает с
* X-координатами (разбор в теле pop_check_can_guard_see_kid ниже).
* Таблица POP_TILE_DIV осталась в резиденте, ею пользуется физика. */
static uint8_t tile_at_kid(int16_t xpos)
{
/* get_tile_div_mod_m7 (seg006:697): колонка = floor((xpos 7 58)/14),
* СРАЗУ в координатах комнаты (58 = x_bump[FIRST_ONSCREEN_COLUMN]).
* Оригинал берёт её из tile_div_tbl и мы теперь тоже: резидентная
* 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);
}
/* Буфер тайлов луча — file-scope, а не локальный массив: локальный уезжает
* в стековый кадр, и каждое чтение из него стоит `-n(ix)` (19 тактов
* номинала, ~46 с wait-state'ами). Тот же приём, что у draw_tile и
* pop_blit_b. Размер колонки 1..10 плюс запас. */
static uint8_t row_t[12];
/* check_can_guard_see_kid (seg003:688) — луч видимости по ряду.
* Результат в 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
{
uint8_t kid_frame = Kid.frame;
int16_t left_pos, right_pos;
uint8_t tile;
/* МЫШЬ Кида «не видит» (seg003:0965): иначе он выхватит меч на зверька,
@@ -122,34 +98,53 @@ void pop_check_can_guard_see_kid(void) __banked
}
can_guard_see_kid = 2;
left_pos = pop_x_bump[Kid.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
right_pos = pop_x_bump[Guard.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
if (left_pos > right_pos) {
int16_t t = left_pos; left_pos = right_pos; right_pos = t;
}
/* Чомпер стоит у ЛЕВОГО края тайла — свой тайл он не перекрывает. */
if (tile_at_kid(left_pos) == TILE_CHOMPER)
left_pos += TILE_SIZEX;
/* Ворота — у ПРАВОГО края, поэтому крайний правый тайл не считается. */
if (tile_at_kid(right_pos) == TILE_GATE)
right_pos -= TILE_SIZEX;
/* ПО КОЛОНКАМ, а не по X-координатам.
*
* Оригинал (и SDLPoP, seg003:688) идёт по x с шагом TILE_SIZEX и на
* каждом шаге переводит x в колонку потому что x у него уже под рукой.
* Но начальные x это РОВНО центры тайлов персонажей
* (x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX), а перевод обратно
* даёт ту же колонку: floor((58 + col*14 - 58) / 14) == col. Значит
* весь проход эквивалентен обходу колонок, и ни одна x-координата тут не
* нужна: уходят 16-битный шаг, 16-битное сравнение и индексация таблицы
* POP_TILE_DIV на каждой итерации.
*
* Тайлы отрезка забираем ОДНИМ банковым вызовом. Раньше на каждую
* колонку шёл свой 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) {
tile = tile_at_kid(left_pos);
/* Сквозь это не видно вовсе. */
if (tile == TILE_WALL || tile == TILE_DOORTOP_FLOOR || tile == TILE_DOORTOP) {
can_guard_see_kid = 0;
return;
if (lcol > rcol) { int8_t t = lcol; lcol = rcol; rcol = t; }
lcol0 = lcol; /* БАЗА индексации буфера — фиксируем
* ДО сдвигов ниже: row_t[0] это
* тайл lcol0, и сдвиг lcol/rcol на
* чомпере и воротах её менять не
* должен. */
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;
}
}
+8
View File
@@ -670,7 +670,9 @@ static uint8_t tile_in_fclip(int row, int col)
static void fore_tile(int row, int col) /* передний слой одного тайла */
{
pop_dbg_rdmax[1]++; /* ВРЕМЕННО: сколько тайлов обошли */
if (!tile_in_fclip(row, col)) return;
pop_dbg_rdmax[2]++; /* ВРЕМЕННО: прошли окно клипа */
/* draw_loose (seg008:0A38) — единственный кусок ТАЙЛА, который оригинал
* кладёт В ОБЕ таблицы БЕЗУСЛОВНО: нижняя грань loose-плиты идёт и в
* backtable, и в foretable. Значит передняя грань плиты рисуется ПОВЕРХ
@@ -681,6 +683,7 @@ static void fore_tile(int row, int col) /* передний слой одно
* кромке loose над дырой от соседней упавшей плиты). */
ft_code = pop_tile_code_drawn(row, col); /* код тайла — ОДИН раз на тайл */
if (!FORE_ANY[ft_code]) return; /* нечего класть в передний слой */
pop_dbg_rdmax[3]++; /* ВРЕМЕННО: реально рисуем */
ft_x = POP_COL_XH[col] * 8;
ft_dmy = 63 * row + 62;
if (row >= 0 && ft_code == 11)
@@ -1000,6 +1003,8 @@ void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
uint8_t frame = ch->frame, action = ch->action;
pop_t_fclip_on = 1; /* окно = прямоугольник спрайта */
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)
* и потому УЖЕ спрайта, а рядом с ним рисуются ещё клинок и «брызги»
* удара они уходят ВПЕРЁД-ВВЕРХ и запросто попадают в соседнюю
@@ -1043,6 +1048,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;
rT = fp_rT; rB = fp_rB; rTraw = fp_rTraw;
/* set_objtile_at_char (seg006:1833): тайл, при обработке которого спрайт
@@ -1109,6 +1115,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, т.е.
* ПОСЛЕ оверлеев выше: передняя грань колонны перекрывает скос пола
* (floor_left_overlay), а не наоборот. Раньше fore шёл первым, и тёмный
@@ -1125,6 +1132,7 @@ void pop_fore_over_char(const pop_char_t *ch, int obj_x, int obj_y,
* Один вызов через трамплин на персонажа за кадр не в тайловом цикле:
* тайл-кандидат всегда ровно один, а fore-проход и так самый горячий
* кусок кадра (memory pop_fore_layer_cost). */
pop_dbg_b4(); /* ЗАМЕР: цикл fore_tile */
if (pop_gate_over_char(ch)) {
int8_t gr = ch->curr_row, gc = (int8_t)(ch->curr_col + 1);
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;
/* Вернуть ТОЛЬКО слой 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 колонка САМОГО факела
* (пламя рисуется в ячейке правого соседа). */
+79 -35
View File
@@ -23,6 +23,7 @@
#include "_pop_kdraw.h"
#include "pop_vflip.h" /* зеркальные страницы атласов (зелье инверсии) */ /* меч + блит по id — сосед по банку 4 */ /* атласы/кадр Кида, pop_sword_draw, окна Char */
#include "pop_cdraw.h"
#include "pop_state.h" /* ВРЕМЕННО: зонды pop_dbg_b* */
#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_map.h" /* hitp_curr/hitp_max — HP Кида; clip_char */
@@ -257,7 +258,7 @@ static int scr_x(int x)
* страницы. Оба мелкие и в одном кадре встречаются редко их объединение
* дешевле третьего heal-слота. */
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]) {
s->ox[dp] = x; s->oy[dp] = y; s->ow[dp] = w; s->oh[dp] = h;
@@ -272,14 +273,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 (y + (int)h > y1) y1 = y + (int)h;
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-проход возвращает куски тайлов и по нему же
* расширяет перебор колонок/рядов. */
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) {
s->cx = x; s->cy = y; s->cw = w; s->ch = h;
@@ -291,8 +292,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 (x + (int)w > x1) x1 = x + (int)w;
if (y + (int)h > y1) y1 = y + (int)h;
s->cw = (uint16_t)(x1 - s->cx);
s->ch = (uint16_t)(y1 - s->cy);
s->cw = (uint8_t)(x1 - s->cx);
s->ch = (uint8_t)(y1 - s->cy);
}
}
@@ -343,6 +344,34 @@ static void cd_sig_make(uint8_t who, cd_sig_t *g)
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 —
* КОМНАТНЫЙ (как в pop_cd), перевод в экранный делает вызывающий. */
static void cd_bbox(uint8_t who, uint8_t p, int *x0, int *y0, int *x1, int *y1)
@@ -365,9 +394,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)
{
cd_sig_t g;
const uint8_t *a, *b;
uint8_t i;
/* Соперника на сцене нет и на ЭТОЙ странице от него ничего не осталось —
* стирать и рисовать нечего, слот «тихий». Без этого пустой слот каждый
* кадр честно проходил heal + вход в pop_char_draw + fore-проход и стоил
@@ -377,17 +403,20 @@ static uint8_t cd_quiet(uint8_t who, uint8_t p)
return 1;
if (!pop_cd[who].valid[p]) return 0;
if (pop_cd_dirty & (1 << p)) { /* фон правили — но задели ли нас? */
int x0, y0, x1, y1;
cd_bbox(who, p, &x0, &y0, &x1, &y1);
y0 += POP_YOFF; y1 += POP_YOFF; /* метка фона — в ЭКРАННЫХ */
/* cd_bbox отдаёт x1/y1 ИСКЛЮЧИТЕЛЬНО, pop_cd_hit ждёт включительно. */
if (pop_cd_hit(p, x0, y0, x1 - 1, y1 - 1)) return 0;
/* СПРАЙТ И НАКЛАДНОЙ (клинок, брызги) — ДВУМЯ ОТДЕЛЬНЫМИ проверками,
* а не объединённым bbox. Объединение включает пустой угол между
* ними, и он ловит касания, которых на самом деле нет: у стоящего
* стража спрайт лежит в колонке 8, клинок уходит влево в колонку 7 на
* 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);
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;
return cd_sig_same(who, p);
}
/* Запас вокруг габарита соседа: за кадр персонаж проходит заметно меньше
@@ -440,7 +469,7 @@ static void cd_splash(pop_cdraw_t *s, uint8_t who, const atlas_t *pages,
const atlas_t *ap;
uint8_t aidx;
const uint8_t *img;
uint16_t w, h;
uint8_t w, h;
int qx = fp_x, qy = obj_y;
int fw = (Char.direction < 0) ? -5 : 5; /* obj_dx_forward(5) */
@@ -462,8 +491,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 */
img = (const uint8_t *)atlas_image(ap, aidx);
gfx_w0_map(ap->page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
/* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
* (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
if (w && h) {
int top = qy - (int)h + 1;
uint8_t flip = (uint8_t)(Char.direction >= 0);
@@ -474,15 +507,15 @@ static void cd_splash(pop_cdraw_t *s, uint8_t who, const atlas_t *pages,
if (top + rows > 192) rows = 192 - top;
if (rows <= 0) return;
gfx_set_bank(GFX_BANK_SPRITE);
if (pop_onscreen_cols(qx, top + POP_YOFF, w, (uint16_t)rows))
if (pop_onscreen_cols(qx, top + POP_YOFF, w, (uint8_t)rows))
gfx_blit_cols_part_noclip(qx, top + POP_YOFF, img, flip,
(uint8_t)skip, (uint8_t)rows);
else
gfx_blit_cols_part(qx, top + POP_YOFF, img, flip, skip, rows);
gfx_set_bank(GFX_BANK_NORMAL);
dp = gfx_get_draw_page() & 1;
cd_overlay_add(s, dp, qx, top, w, (uint16_t)rows);
cd_clip_add(s, qx, top, w, (uint16_t)rows);
cd_overlay_add(s, dp, qx, top, w, (uint8_t)rows);
cd_clip_add(s, qx, top, w, (uint8_t)rows);
}
}
@@ -494,7 +527,7 @@ void pop_char_draw(uint8_t who) __banked
const atlas_t *pages;
const uint8_t *img;
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 bcut; /* срез СНИЗУ (clip_char в перевёрнутом виде) */
@@ -596,8 +629,12 @@ void pop_char_draw(uint8_t who) __banked
}
gfx_w0_map(vpg);
}
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
/* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
* (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
dp = gfx_get_draw_page() & 1;
if (w && h) {
/* Спрайты нарисованы ЛИЦОМ ВЛЕВО (как в оригинале); seg008:864 —
@@ -654,7 +691,7 @@ void pop_char_draw(uint8_t who) __banked
if (cr) {
int avail = cr - bx;
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): на уровне зеркала она может
* показываться ТОЛЬКО СПРАВА от него
@@ -683,7 +720,7 @@ void pop_char_draw(uint8_t who) __banked
if (lskip) /* тень у зеркала: срез слева */
gfx_blit_cols_part_wx(bx, top + POP_YOFF, img, flip, skip, rows,
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,
(uint8_t)skip, (uint8_t)rows);
else
@@ -694,12 +731,13 @@ void pop_char_draw(uint8_t who) __banked
/* heal чистит ТОЛЬКО нарисованное: иначе стирается кромка пола над
* срезом (мусор/дыра в кладке на второй странице дабл-буфера). */
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);
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; /* габарит КАДРА (не обрезанный): */
/* по нему считается футпринт */
}
pop_dbg_m15(); /* ЗАМЕР: снимок прямоугольника сделан */
if (hurt) cd_splash(s, who, pages, fp_x, obj_y);
/* add_sword_to_objtable (seg006:1798): клинок отдельным спрайтом поверх
* персонажа со своим прямоугольником heal. */
@@ -710,6 +748,7 @@ void pop_char_draw(uint8_t who) __banked
cd_clip_add(s, sx, sy, sw, sh);
}
}
pop_dbg_m16(); /* ЗАМЕР: splash + клинок сделаны */
gfx_w0_unmap();
/* Что именно нарисовано на ЭТОЙ странице — снимок для пропуска
* следующих кадров (DRAW-COST). Только если кадр РЕАЛЬНО рисовали:
@@ -738,6 +777,7 @@ void pop_char_fore(uint8_t who) __banked
if (s->fpw)
pop_fore_over_char(who == POP_CH_KID ? &Kid : &Guard,
s->fpx, s->fpy, s->fpw, s->fph);
pop_dbg_b6(); /* ЗАМЕР: pop_char_fore целиком */
}
/* ---- ОТРАЖЕНИЕ В ЗЕРКАЛЕ (check_mirror, seg003:0798) ---------------- *
@@ -774,7 +814,7 @@ void pop_mirror_draw(int clip_top) __banked
const kframe *fr = &kid_frame;
const uint8_t *img;
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;
uint8_t lskip = 0;
@@ -792,8 +832,12 @@ void pop_mirror_draw(int clip_top) __banked
img = (const uint8_t *)atlas_image(&kidp[page], idx);
gfx_w0_map(kidp[page].page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
/* Габарит БАЙТАМИ: спрайты персонажей и накладных не крупнее 64x64
* (memory pop_sprite_size_limits весь игровой кадр PoP <= 56x63),
* а 16-битные w/h заставляли SDCC держать их в стековом кадре и
* считать каждое сравнение парой загрузок. */
w = img[0];
h = img[2];
if (!w || !h) { gfx_w0_unmap(); return; }
flip = (uint8_t)(Char.direction >= 0);
bx = flip ? obj_x - (int)w : obj_x;
@@ -914,7 +958,7 @@ void pop_hp_draw(void) __banked
if (g_ok && Guard.charid != 0 && Guard.charid != CHARID_4_SKELETON &&
Guard.charid != CHARID_24_MOUSE && gd) {
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);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
+13 -5
View File
@@ -51,21 +51,21 @@ typedef struct {
* видео-ОЗУ и своя ОЗУ-копия, поэтому heal обязан стирать спрайт именно
* той страницы, в которую сейчас рисуем */
int x[2], y[2];
uint16_t w[2], h[2];
uint8_t w[2], h[2]; /* габарит: спрайты игры не крупнее 64x64 */
uint8_t valid[2];
/* НАКЛАДНЫЕ спрайты (клинок + брызги урона) — свой прямоугольник, не
* объединение с персонажем: объединение сильно больше суммы двух
* (клинок уходит вперёд-вверх), а heal стоит ровно по площади */
int ox[2], oy[2];
uint16_t ow[2], oh[2];
uint8_t ow[2], oh[2];
uint8_t ovalid[2];
/* габарит кадра для fore-прохода: fpx — ЛОГИЧЕСКАЯ X (до ×8/7),
* fpy низ спрайта; fpw == 0 в этом кадре рисовать было нечего */
int fpx, fpy;
uint16_t fpw, fph;
uint8_t fpw, fph;
/* окно fore-клипа слота (объединение «спрайт + накладные»), КОМНАТНЫЙ y */
int cx, cy;
uint16_t cw, ch;
uint8_t cw, ch;
/* straddle: рендерное смещение по ЛОГИЧЕСКОЙ X, когда комната персонажа
* не совпадает с отрисованной (порт xpos_in_drawn_room) */
int render_dx;
@@ -121,11 +121,19 @@ void pop_char_set_render_dx(uint8_t who, int dx) __banked;
* Резидент (pop_tile.c): зовёт и банковый слой фона, и резидентные листья,
* а трамплин на КАЖДЫЙ кусок фона стоил бы дороже самой метки. */
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 включительно) то,
* что трогали на странице p. Резидент (pop_tile.c). */
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_init(void); /* пустые диапазоны на обеих страницах */
void pop_cd_touch(int x, int y, int w, int h);
#define POP_CD_TOUCH_ALL() pop_cd_touch(0, 0, 320, 256)
+242 -102
View File
@@ -330,6 +330,38 @@ uint8_t pop_tile_at(int8_t col, int8_t row) __banked
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 не бампит и
* идёт сквозь них (на шве уходит в соседнюю комнату; внутри комнаты просто
* проходит по полу под поднятыми барами). ВАЖНО: ворота остаются тайлом 4 (в
@@ -1769,18 +1801,67 @@ static void calc_coll_window(void)
* Замер одной итерации в MAME: пустая колонка 750 тактов, колонка-стена
* ~1 700 (там две 16-битные знаковые сверки граней). */
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 {
uint8_t wt = wall_type_tbl[*src++ & 0x1F];
uint8_t f = 0;
if (wt) {
if (wall_dl[wt] + scan_left < coll_xr) f = 1;
if (scan_left - wall_dr[wt] + TILE_RIGHTX > coll_xl) f |= 2;
/* ВСЯ арифметика в 8 битах — см. доказательство диапазонов
* над 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;
} while (--scan_n);
}
@@ -1789,14 +1870,18 @@ static void coll_scan(const uint8_t *src, uint8_t *dst)
* в теле ряда, и пролог одного ряда стоил тысячи тактов при том, что сам
* цикл по четырём-пяти колонкам около трёх. */
static uint8_t scan_n0;
static int scan_left0;
static uint8_t scan_left0;
static uint8_t scan_off; /* индекс первого слота в flags[] */
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_off = (uint8_t)COLL_IDX(win_lo);
coll_thresholds(); /* здесь же, чтобы порог и окно всегда были от
* ОДНОГО персонажа та же причина, по которой
* тут считается scan_left0 (см. BUG-GUARD-IX-1
* в шапке get_row_collision_data). */
}
/* Базы ряда: указатели, которые индексируются НОМЕРОМ КОЛОНКИ (в том числе
@@ -1838,7 +1923,7 @@ static void coll_row(uint8_t *flags)
last = win_hi < -1 ? win_hi : -1;
n = (uint8_t)(last - lo + 1);
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;
lo = (int8_t)(last + 1);
}
@@ -1846,13 +1931,13 @@ static void coll_row(uint8_t *flags)
last = win_hi < 9 ? win_hi : 9;
n = (uint8_t)(last - lo + 1);
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;
lo = (int8_t)(last + 1);
}
if (lo <= win_hi) { /* комната СПРАВА */
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 +1993,15 @@ static void check_collisions(void)
return;
}
pop_dbg_p2(); /* ЗАМЕР: вход в тело */
set_char_collision();
pop_dbg_p3(); /* ЗАМЕР: set_char_collision */
move_coll_to_prev(Char.curr_row); /* заодно снимет окно prev */
coll_prev_row = Char.curr_row;
calc_coll_window();
coll_lo = win_lo; coll_hi = win_hi;
coll_scan_prepare();
pop_dbg_p4(); /* ЗАМЕР: окно посчитано */
/* Порядок важен: prev уже забран, теперь три ряда пересчитываются
* НА ЭТОТ кадр (в оригинале ровно так же, seg004:0004). Ряд здесь
* гарантированно 0..2, поэтому соседние ряды это те же базы ±10, без
@@ -1947,6 +2035,7 @@ static void check_collisions(void)
}
coll_row(coll_above);
}
pop_dbg_p5(); /* ЗАМЕР: три ряда просканированы */
/* Обход СВЕРХУ ВНИЗ, как в оригинале (9→0): побеждает МЛАДШАЯ колонка,
* в которой флаг перешёл 01. Только по ПЕРЕСЕЧЕНИЮ окон: вне его
* сравнивать нечего (у оригинала там prev_coll_room != curr_row_coll_room
@@ -2133,6 +2222,26 @@ static void check_bumped(void)
* make_loose_fall/check_press. Перерисовка в pop_bg (shake/bake). */
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. */
/* make_loose_fall (seg007:0EF6): взвести отсчёт падения, если ещё не идёт. */
@@ -2141,8 +2250,10 @@ static void make_loose_fall(int pos, uint8_t modifier)
if (pos < 0 || pos >= 30) return;
if ((g_fg[pos] & 0x1F) != TILE_LOOSE) return;
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;
loose_any = 1;
}
}
/* died_on_button (seg007:0776): на кнопке КТО-ТО УМЕР — эффект становится
@@ -2213,6 +2324,7 @@ static void check_press(void)
(g_above[c] & 0x1F) == TILE_LOOSE && !(g_above[c] & 0x20) &&
(int8_t)pop_ceil_modif[c] <= 0) {
pop_ceil_modif[c] = 1; /* make_loose_fall(1) */
loose_any = 1;
is_guard_notice = 1; /* seg006:1734 */
}
} else if (get_tile_above_char() == TILE_LOOSE) {
@@ -2266,16 +2378,20 @@ static void do_knock(int tile_row)
if (tile_row == -1) { /* ряд 2 комнаты сверху — плиты-потолки */
if (!g_above || !g_link_u) return;
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;
loose_any = 1;
}
return;
}
if (tile_row < 0 || tile_row > 2) return;
for (col = 0; col < 10; col++) {
pos = tile_row * 10 + col;
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;
loose_any = 1;
}
}
}
@@ -2393,11 +2509,13 @@ void pop_loose_reset(void) __banked
for (i = 0; i < 30; i++) {
pop_loose_modif[i] = (mod && g_fg && (g_fg[i] & 0x1F) == TILE_LOOSE)
? 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;
for (i = 0; i < 10; i++) {
pop_ceil_modif[i] = (mod && (g_above[i] & 0x1F) == TILE_LOOSE)
? mod[20 + i] : 0;
if (pop_ceil_modif[i]) loose_any = 1; /* взвод гейта — см. loose_any */
/* ...и СНЯТЬ заочный trob: плиту-потолок отсюда снова ведёт
* pop_loose_tick. Без этого её крутили бы ДВОЕ trob по
* room_modif и мы по pop_ceil_modif, и она проваливалась бы
@@ -2483,6 +2601,12 @@ void pop_check_fall_flo(void) __banked
if ((int8_t)pop_ceil_modif[col] > 0) continue;
pop_ceil_modif[col] =
(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);
}
}
@@ -2525,6 +2649,10 @@ static void check_loose_fall_on_kid(void)
{
int mcol, my;
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;
/* Своё окно Char, как в оригинале (seg007:1199 — loadkid/savekid внутри
* самой функции): зовут нас из pop_loose_tick, а тот идёт в главном
@@ -2557,102 +2685,110 @@ void pop_loose_tick(void) __banked
* Инкремент+сравнение считают row/col без деления. */
uint8_t row = 0, col = 0;
pop_dbg_m9(); /* ЗАМЕР: вход pop_loose_tick */
for (pos = 0; pos < 30; pos++, lm++) {
if (*lm != 0) {
uint8_t m = ++*lm;
/* Отложенный старт уровня 13 досчитал до нуля. Ноль у нас
* означает «не анимируется», и плита застряла бы навсегда
* перескакиваем на 1. Стартовое значение выбрано с учётом
* этого перескока (см. pop_check_fall_flo). */
if (m == 0) m = *lm = 1;
if (m & 0x80) { /* тряска (do_knock) */
/* СПЕЦКЕЙС УРОВНЯ 13 (seg007:823): фазу со старшим битом НЕ
* гасим и не трясём. На нём check_fall_flo раздаёт плитам
* ОТРИЦАТЕЛЬНЫЙ старт (0xF0..0xFF) как отложенный таймер, и
* обычная ветка «тряска кончилась на 0x84» убила бы его в
* первом же кадре плита не упала бы никогда. Пропущенная
* инкрементом фаза сама дойдёт до 0x00, там старший бит
* снимется, и дальше пойдёт обычный отсчёт до провала. */
if (pop_current_level == POP_LOOSE_TILES_LEVEL) {
/* ждём молча */
} else if (m >= 0x84) { /* конец тряски: сброс + покой */
/* Гейт холостого хода: если ни одна фаза не взведена, оба цикла (30 + 10
* позиций) пропускаем целиком замер 11/15 дал 9 852 такта на комнату,
* где loose-плит нет вовсе. Флаг ставят места записи фазы, снимаем его
* здесь и только по факту прохода, в котором не осталось ни одной живой
* фазы (см. объявление loose_any). */
if (loose_any) {
uint8_t found = 0;
for (pos = 0; pos < 30; pos++, lm++) {
if (*lm != 0) {
uint8_t m = ++*lm;
/* Отложенный старт уровня 13 досчитал до нуля. Ноль у нас
* означает «не анимируется», и плита застряла бы навсегда
* перескакиваем на 1. Стартовое значение выбрано с учётом
* этого перескока (см. pop_check_fall_flo). */
if (m == 0) m = *lm = 1;
if (m & 0x80) { /* тряска (do_knock) */
/* СПЕЦКЕЙС УРОВНЯ 13 (seg007:823): фазу со старшим битом НЕ
* гасим и не трясём. На нём 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;
/* modif уже 0 → кадр покоя обоих тайлов (col и col+1:
* правая грань loose живёт в соседе). ОБЕ страницы
* иначе на одной застывает дрожащий кадр мерцание. */
pop_set_redraw(pos, POP_RD_LOOSE, 2);
} else {
pop_set_redraw(pos, POP_RD_LOOSE, 1); /* дрожащий кадр */
pop_set_redraw(pos, POP_RD_LOOSE_GONE, 2); /* запечь пусто, обе стр. */
/* ...и СОСЕДА СПРАВА: передний торец пола заезжает в его
* клетку, и без этой пометки он оставался висеть над пустым
* тайлом. То же, что делает pop_skel_wake_tile. */
if ((pos % 10) < 9)
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) {
/* Сигнал прошлого провала ещё не разобран главным циклом (он
* ОДИН на кадр) придержим плиту кадр, откатив фазу к 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;
pop_set_redraw(pos, POP_RD_LOOSE_GONE, 2); /* запечь пусто, обе стр. */
/* ...и СОСЕДА СПРАВА: передний торец пола заезжает в его
* клетку, и без этой пометки он оставался висеть над пустым
* тайлом. То же, что делает pop_skel_wake_tile. */
if ((pos % 10) < 9)
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);
if (*lm) found = 1; /* фаза ещё жива — гейт держим */
}
if (++col == 10) { col = 0; row++; } /* row/col без деления */
}
/* Плиты-ПОТОЛКИ (ряд 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 */
}
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_dbg_m11(); /* ЗАМЕР: pop_loose_mob_tick сделан */
check_loose_fall_on_kid(); /* seg007:1192 — плита падает Киду на голову */
pop_dbg_m12(); /* ЗАМЕР: check_loose_fall_on_kid сделан */
/* Отложенная допечка ПРОШЛОГО приземления на вторую страницу — ПЕРЕД
* разбором нового: иначе при двух плитах, севших подряд, land_bake
* затирался новым и на одной странице оставался старый пол. */
@@ -3027,9 +3163,12 @@ static void kid_phys(void)
determine_col();
bump_into_opponent(); /* seg003: безоружный Кид отскакивает от стража */
check_collisions(); /* seg004: флаги перекрытия по колонкам ряда */
pop_dbg_p6(); /* ЗАМЕР: check_collisions целиком */
check_bumped(); /* удержать у стены до check_action (порядок PoP) */
check_action();
pop_dbg_p7(); /* ЗАМЕР */
check_press(); /* seg006: стойка/пробой loose → make_loose_fall */
pop_dbg_p8(); /* ЗАМЕР */
check_spike_below(); /* seg006: над колонкой с пиками → выдвинуть пики */
check_spiked(); /* seg006: напоролся на вредные пики → смерть */
check_chomped_kid(); /* seg004: перемололо в сомкнутых челюстях → смерть */
@@ -3089,6 +3228,7 @@ void pop_phys_tick(void) __banked
* оригинале: кадр смерти не двигается сам (dx/dy нулевые, sequence
* кончился), а уход из комнаты и сотрясение отсечены внутри kid_phys. */
pop_loadkid_and_opp();
pop_dbg_p1(); /* ЗАМЕР: окно Char загружено */
kid_phys();
pop_savekid_and_opp();
}
+4
View File
@@ -58,6 +58,10 @@ void pop_map_set_room(uint8_t room) __banked;
* (guards.c): он идёт по ряду между Кидом и стражем. */
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()). Наружу — для
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
void pop_determine_col(void) __banked;
+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 (!rd_cnt[tilepos]) rd_pending++;
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;
}
@@ -112,6 +123,7 @@ void pop_redraw_needed(void) __banked
if (!rd_pending) return;
for (i = 0; i < NTILES; i++) {
if (rd_cnt[i]) {
pop_dbg_kind(rd_kind[i]); /* ВРЕМЕННО: какой вид перерисовки */
switch (rd_kind[i]) {
case POP_RD_SPIKE: pop_spike_redraw(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_GATE: pop_gate_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;
}
cnt++; /* ВРЕМЕННО: замер */
+1
View File
@@ -31,6 +31,7 @@
#define POP_RD_LEVELDOOR 5 /* створка двери уровня (ПРАВАЯ половина) */
#define POP_RD_GATE 6 /* решётка ворот (tilepos = САМИ ворота) */
#define POP_RD_CHOMP 7 /* чомпер: кадр смыкания челюстей */
#define POP_RD_CHOMP_ANIM 8 /* чомпер: вернуть ТОЛЬКО слой anim поверх огня */
/* Виды перерисовки полосы у потолка (по колонке). */
#define POP_RDA_CEIL 1 /* плита-потолок: дрожащий кадр / покой */
+111 -56
View File
@@ -618,7 +618,12 @@ void pop_loose_shake_draw(int row, int col) __banked
* NB: печёный кадр-0 может слегка просвечивать по кромкам чистое
* решение (bake тайла как empty + рисовать loose всегда динамически)
* отложено; 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);
draw_tile(row, col); /* loose: лево+низ (анимир.) */
if (col + 1 < 10)
@@ -626,6 +631,47 @@ void pop_loose_shake_draw(int row, int col) __banked
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_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
* (pop_trob). Кадр выдвижения рисует ПРАВЫЙ сосед (draw_tile ветка lcode==2
* читает lmod = pop_t_bg[row*10+col] = живой modif пики). pop_t_bg должен указывать
@@ -658,7 +704,15 @@ void pop_spike_redraw(int row, int col) __banked
void pop_chomp_redraw(int row, int col) __banked
{
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);
draw_tile(row, col);
gfx_set_bank(GFX_BANK_NORMAL);
@@ -1184,7 +1238,10 @@ static mob_t *mob_alloc(void)
{
uint8_t 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;
}
@@ -1203,13 +1260,6 @@ static void mob_spawn(uint8_t room, int row, int col)
m->defer = 0;
m->prev_y[0] = m->prev_y[1] = 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). */
@@ -1497,7 +1547,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 */
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 over = (mt_m->draw_y > pop_cd[POP_CH_KID].fpy);
/* РИСОВАНИЯ ЗДЕСЬ БОЛЬШЕ НЕТ — только решение, в какой проход кусок
@@ -1586,30 +1647,30 @@ static void mob_overlay_neighbour(const mob_t *m)
{
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 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;
uint8_t *dbg = &pop_dbg_ovl[(m - mobs) * 4]; /* ВРЕМЕННО: журнал решения */
{ /* ВРЕМЕННО: перебор ВСЕХ тайлов, чьи габариты пересекаются со спрайтом
* куска чтобы понять, скольким тайлам реально нужно лечь поверх. */
int8_t rr, kk;
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; /* верх спрайта куска */
if (cend > 9) cend = 9;
/* Габарит куска — по ЭКРАННОЙ координате: у куска, видимого из соседней
* комнаты, draw_y отличается от y на 192. */
ytop = m->draw_y - (int)mob_spr[2] + 1;
ybot = m->draw_y;
/* ПРЕДУСЛОВИЯ ПРОВЕРЯЕМ ЗДЕСЬ, до вызова в банк 2. pop_mob_overlay_tile
* живёт в банке 2, то есть каждый вызов из банка 7 платит межбанковый
@@ -1620,21 +1681,15 @@ static void mob_overlay_neighbour(const mob_t *m)
* ничего не рисовало.
*
* Габарит тайла берём ПОЛНЫМ (63*row+2 .. +66) какая именно часть
* соседа окажется поверх куска, решит уже окно клипа внутри. Сужать по
* реальной высоте кусков нельзя: у колонн и зеркала база высокая. */
if (r >= 0 && r <= 2 && pop_tile_code(r, c)) dbg[2] |= 4;
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) &&
ybot >= 63 * r + 2 && ytop <= 63 * r + 66) {
dbg[2] |= 0x10; dbg[3]++;
pop_mob_overlay_tile(r, c, r, (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]);
* соседа окажется поверх куска, решит уже окно клипа внутри. */
for (c = b30 ? (int8_t)m->col : cend; c <= cend; c++) {
if (c < 0) continue;
if (r >= 0 && r <= 2 && pop_tile_code(r, c) &&
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]);
if (rt != r && rt >= 0 && rt <= 2 && pop_tile_code(rt, c) &&
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]);
}
}
@@ -1650,10 +1705,6 @@ static void mob_draw_pass(uint8_t pg, uint8_t want_defer)
mob_t *m = &mobs[i];
/* Отбор по draw_y, а НЕ по комнате: кусок, только что провалившийся
* в комнату снизу, ещё виден из нашей (см. 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 ((uint8_t)(m->defer != 0) != want_defer) continue;
for (j = n; j > 0 && mobs[order[j - 1]].draw_y < m->draw_y; j--)
@@ -1670,17 +1721,15 @@ static void mob_draw_pass(uint8_t pg, uint8_t want_defer)
* в кадр пересечения границы пометка указывала на прежний тайл, и
* передняя часть колонны кусок не перекрывала ровно ОДИН кадр,
* как и наблюдалось (прогон 2026-08-13, комната 23, колонка 8). */
pop_dbg_pass[order[i]] |= 0x10;
mob_mark_neighbour(&mobs[order[i]]);
mob_render(&mobs[order[i]], pg);
pop_dbg_pass[order[i]] |= 0x20;
mob_overlay_neighbour(&mobs[order[i]]);
}
}
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 на
* sizeof(mob_t)=15 заново под каждое поле (.active/.clean/.x/.y), и 14
@@ -1688,6 +1737,10 @@ void pop_loose_mob_tick(void) __banked
* (замер 2026-08-13). Гард в самом mob_tick_one оставлен: функция
* зовётся и из других мест. */
mob_t *m = mobs;
/* Ни одного занятого слота — обходить 14 штук незачем (замер 11/15:
* 12 090 тактов холостого хода). Гейт снимается ниже, по факту прохода,
* в котором не осталось ни active, ни дочистки. */
if (!pop_mob_busy) return;
/* Пометки всех кусков — ОДНИМ пакетом: настоящий pop_cd_touch стоит 4 502
* такта (замер 2026-08-17), а кусков в кадре каскада шесть. В пакете они
* копят общий прямоугольник четырьмя сравнениями, и настоящая пометка одна.
@@ -1722,9 +1775,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);
mob_tick_one(m, pg);
if (m->active) live++;
if (m->active || m->clean) busy++; /* слот ещё занят — гейт держим */
}
pop_cd_batch_end();
mobs_live = live;
pop_mob_busy = busy;
}
static void mob_render(mob_t *m, uint8_t pg)
+11
View File
@@ -13,6 +13,7 @@
#include "pop_state.h"
#include "pop_ctrl.h" /* объявления шины control_* (живёт здесь) */
uint8_t pop_mob_busy;
uint8_t pop_loose_landed;
/* Кусок loose УШЁЛ ВНИЗ из комнаты (порт хвоста move_loose, seg007:1126:
* 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_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 упакованы в один
* 16-битный аргумент (arg1 -> HL при __sdcccall(1)), брейкпоинт логирует
* HL дальше цена раскладывается как a + b*h + c*w*h, где b и есть
+16
View File
@@ -9,6 +9,16 @@
#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 = нет, иначе
* tilepos+1 тайла, на который он лёг. Ставит отрисовка mob (банк),
* разбирает логика loose-полов (pop_map, W1/W2). */
@@ -95,6 +105,12 @@ void pop_dbg_m15(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_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);
/* ВРЕМЕННО: трасса kidobj (см. pop_state.c). Порядок байт:
+134 -63
View File
@@ -195,43 +195,34 @@ uint8_t pop_spike_frame(uint8_t m)
* и перерисовывался каждый кадр со всем fore-проходом (замер: циан 231 829
* тактов против 20 071, период 4 растровых кадра против 3).
*
* Гранулярность тайла (32 x 63) вместо точного прямоугольника осознанное
* огрубление: факел помечает всю свою колонку по высоте ряда. Персонаж и
* так занимает бОльшую часть высоты ряда, зато касания перестают
* склеиваться. См. docs/impl_diff.md. */
* ГРАНУЛЯРНОСТЬ: колонка 32 px по горизонтали, ТОЧНЫЙ диапазон y по
* вертикали. Раньше по вертикали стоял номер ряда (три полосы по 63 px), и
* это склеивало касания, разнесённые внутри ряда. Найдено пользователем на
* сцене 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;
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[c1+1] & ~CD_LOW[c0] два чтения таблицы вместо цикла сдвигов. */
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]);
}
/* CD_LOW, cd_row_of и cd_cols_of СНЯТЫ вместе с переходом на диапазон y:
* колонка теперь считается прямым сдвигом (x >> 5), а вертикаль сравнением
* отрезков битовые маски больше не нужны. */
/* Обе страницы помечаются сразу (персонаж чинится на каждой в свой кадр),
* поэтому цикл развёрнут: индекс-переменная заставляла SDCC считать адрес
@@ -274,8 +265,6 @@ void pop_cd_unmute(void) { pop_cd_batch = 0; }
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 == CD_BATCH_MUTE) return; /* область помечена вызывающим */
int x1 = x + w - 1, y1 = y + h - 1;
@@ -285,18 +274,30 @@ void pop_cd_touch(int x, int y, int w, int h)
if (y1 > cdb_y1) cdb_y1 = y1;
return;
}
cols = cd_cols_of(x, x + w - 1);
if (!cols) return;
r0 = cd_row_of(y);
r1 = cd_row_of(y + h - 1);
pop_cd_dmask[0][r0] |= cols;
pop_cd_dmask[1][r0] |= cols;
if (r1 != r0) {
pop_cd_dmask[0][r1] |= cols;
pop_cd_dmask[1][r1] |= cols;
if (r1 - r0 > 1) { /* прямоугольник накрыл все три ряда */
pop_cd_dmask[0][1] |= cols;
pop_cd_dmask[1][1] |= cols;
{
int8_t c0, c1, c;
uint8_t y0, y1;
int xr = x + w - 1;
if (xr < 0 || x > 319) return; /* весь прямоугольник вне поля */
if (x < 0) x = 0;
if (xr > 319) xr = 319;
c0 = (int8_t)(x >> 5);
c1 = (int8_t)(xr >> 5);
/* Клип по вертикали: экранные y не выходят за байт, а всё, что выше
* поля или ниже его, персонажам всё равно не принадлежит. */
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;
@@ -305,27 +306,87 @@ void pop_cd_touch(int x, int y, int w, int h)
/* Задел ли прямоугольник (ЭКРАННЫЕ координаты, x1/y1 ВКЛЮЧИТЕЛЬНО) то, что
* трогали на странице 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)
{
uint16_t cols;
uint8_t r0, r1;
int8_t c0, c1, c;
uint8_t ya, yb;
if (!(pop_cd_dirty & (1 << p))) return 0;
cols = cd_cols_of(x0, x1);
if (!cols) return 0;
r0 = cd_row_of(y0);
r1 = cd_row_of(y1);
if (pop_cd_dmask[p][r0] & cols) return 1;
if (r1 != r0) {
if (pop_cd_dmask[p][r1] & cols) return 1;
if (r1 - r0 > 1 && (pop_cd_dmask[p][1] & cols)) return 1;
}
if (x1 < 0 || x0 > 319) return 0;
if (x0 < 0) x0 = 0;
if (x1 > 319) x1 = 319;
if (y1 < 0 || y0 > 255) return 0;
ya = (uint8_t)(y0 < 0 ? 0 : y0);
yb = (uint8_t)(y1 > 255 ? 255 : y1);
c0 = (int8_t)(x0 >> 5);
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;
}
/* Страница приведена в порядок — снять с неё метку целиком. */
/* Начальное состояние обеих страниц — «ничего не трогали». ЯВНО, а не
* расчётом на обнуление _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)
{
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));
}
@@ -561,8 +622,18 @@ void pop_blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
return;
}
/* 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);
else gfx_blit_noclip(pb_x, pb_top, pb_img);
} else {
+48 -7
View File
@@ -633,13 +633,33 @@ void pop_process_trobs(uint8_t cur_room) __banked
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++) {
uint8_t room = trobs[i].room;
uint8_t tp = trobs[i].tilepos;
uint8_t code = trob_code[i];
uint8_t *mod = pop_trob_modif(room);
int8_t type = trobs[i].type;
if (room != mod_room) { mod = pop_trob_modif(room); mod_room = room; }
switch (code) {
case TILE_SPIKE:
animate_spike(&mod[tp], &type);
@@ -728,7 +748,7 @@ void pop_process_trobs(uint8_t cur_room) __banked
* случай «застыл справа от факела» в BUGS_OPEN.md как открытый:
* пики и меч рядом с факелом на уровнях 1-4 не встретились. */
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) {
@@ -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));
}
if (room == cur_room && code == TILE_CHOMP) {
/* Кадр меняется каждый тик, пока trob жив — как у пик, метим
* текущую страницу; последний кадр (челюсти встали) обе,
* иначе на второй странице дабл-буфера застынет предыдущая поза
* и чомпер дрожит через кадр. */
pop_set_redraw(tp, POP_RD_CHOMP, (uint8_t)(type < 0 ? 2 : 1));
/* ТОЛЬКО ПОКА ФАЗА < 6 — как в оригинале (animate_chomper,
* seg007:0448 заканчивается `if ((curr_modifier & 0x7F) < 6)
* redraw_at_trob();`).
*
* Это не оптимизация оригинала, а точное следствие таблицы поз:
* 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) {
/* 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). */
for (i = 0; i < trobs_count; i++)
+14 -1
View File
@@ -361,11 +361,14 @@ int main(void)
back = dbuf ? (uint8_t)(gfx_get_visible_page() ^ 1) : 0;
gfx_set_draw_page(back);
}
pop_dbg_m13(); /* ЗАМЕР: ввод и читы разобраны */
pop_check_skel(); /* спецсобытие ур.3: скелет встаёт */
pop_check_mouse(); /* спецсобытие ур.8: мышь жмёт кнопку */
pop_check_killed_shadow(); /* спецсобытие ур.12: смерть тени = смерть Кида */
pop_frame_timers(); /* timers (seg003:0735): вспышка слияния */
pop_dbg_m9(); /* ЗАМЕР: pop_frame_timers сделан */
pop_check_can_guard_see_kid(); /* луч видимости — ДО логики персонажей */
pop_dbg_m14(); /* ЗАМЕР: спецсобытия + луч видимости */
pop_ctrl_tick(); /* ввод -> control(): смена seq */
/* Кто в этом кадре не изменился с прошлой отрисовки ЭТОЙ страницы —
* тот на ней уже нарисован правильно: ни стирать, ни рисовать заново
@@ -636,15 +639,25 @@ int main(void)
* фон кадра (перепечатки тайлов, шов) обязан быть готов ДО любого
* спрайта, иначе перепечатка ложится поверх уже нарисованного куска
* так окно задней стены и передняя грань пола оказывались на плите. */
pop_dbg_m10(); /* ЗАМЕР: check_mirror сделан */
pop_loose_mob_draw();
pop_dbg_m11(); /* ЗАМЕР: pop_loose_mob_draw сделан */
{ /* Кто позже — тот поверх; порядок задаёт обход тайлов, а не роль
* персонажа (см. guard_over_kid). Отрисовка одна на всех Char
* (pop_cdraw.c), различает их только слот. */
uint8_t g_after = guard_over_kid();
/* Пересчёт после тика: слот, за который heal не платили, мог
* всё-таки сдвинуться тогда бит снимется, а прошлый кадр
* сотрёт сам pop_char_draw (страховка cd_heal). */
uint8_t g_after;
skip = pop_char_skip_mask();
pop_dbg_m12(); /* ЗАМЕР: skip_mask сделан */
/* Порядок «кто поверх кого» нужен, только если кого-то РИСУЕМ.
* При 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); }
PROF(6); /* кадр Кида (тот же циан) */
if (!(skip & 1)) pop_char_draw(POP_CH_KID); /* спрайт + брызги + клинок */
+33
View File
@@ -101,6 +101,20 @@ void pop_start_level(void) __banked
* и разворачивается seq_5 */
}
#ifdef DBG_START_ROOM
/* ОТЛАДОЧНЫЙ СТАРТ: сразу в целевую комнату оптимизации, минуя проход
* уровня. Задаётся сборкой (make ROOM=15 POS=2), по умолчанию сцена
* 11/15 из docs/perf_l11_room15.md: Кид в (0,2), справа чомпер под
* факелом, дальше второй факел и страж.
*
* Ставится ПОСЛЕ чекпойнта намеренно: отладочная позиция должна
* перебивать и его тоже, иначе рестарт уровня уводил бы Кида из
* измеряемой комнаты. Направление и позу входа не трогаем они из
* данных уровня, как у обычного старта. */
start_room = DBG_START_ROOM;
pos = DBG_START_POS;
#endif
/* Рестарт уровня = load_level() заново (play_level, seg003:57): в
* исходное возвращаются И модификаторы (пики/ворота), И сами тайлы
* разбитые плиты целы, выпитые зелья на месте (BUG-RESPAWN-1), и
@@ -146,6 +160,24 @@ void pop_start_level(void) __banked
enter_room(start_room);
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. */
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-м
* уровне Кид ставится в комнату 17, а экран тут же переводится на
* комнату ПОД ней (`goto_other_room(3)`: y = 189, ряд пересчитать)
@@ -648,6 +680,7 @@ int pop_boot(void) __banked
puts("pop_bg_load failed");
return -1;
}
pop_cd_init(); /* метка «фон трогали» — пустые диапазоны */
pop_cheats = 1; /* режим разработки: читы включены */
pop_guard_reset();
if (pop_kid_data_load("KID\\kid_data.bin") != 0) { /* кадры+seqtbl в EMM-странице */
@@ -249,6 +249,38 @@ TC_TEST(phys_loose_survives_room_change)
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)
{
/* BUG-RJUMP-1. Разбег влево от колонки 8, дальше Up — разбег-прыжок.
@@ -327,6 +359,7 @@ void main(void)
TC_RUN(phys_run_off_ledge);
TC_RUN(phys_loose_floor_breaks);
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_feather_fall_is_slow_and_harmless);
}