From 085a198c20087e9f35bb489f5765bb4b34942c16 Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Tue, 18 Aug 2026 11:48:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8:=20=D0=BA=D0=B0=D0=B4=D1=80=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=D0=B0,=20=D0=B0=20=D1=81=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20Guard.frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тень на уровне 6 стояла на двух страницах дабл-буфера в разных позах. Отрисовка брала image из кэша kid_frame/pop_gframe, который наполняет тик, а снимок пропуска кадра писала по Char.frame — расхождение застревало навсегда, потому что снимок совпадал и страница больше не перерисовывалась. Кадр теперь грузит сама отрисовка, как в оригинале (add_*_to_objtable → load_fram_det_col, seg008:22F0/2324). Разбор — BUGS_CLOSED.md. Co-Authored-By: Claude Opus 5 --- applications/PoP/roomtest/BUGS_CLOSED.md | 79 ++++++++++++++++++++++++ applications/PoP/roomtest/pop_cdraw.c | 37 +++++++++-- applications/PoP/roomtest/pop_kid.c | 26 +++++--- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/applications/PoP/roomtest/BUGS_CLOSED.md b/applications/PoP/roomtest/BUGS_CLOSED.md index f0748a4..1ffee1f 100644 --- a/applications/PoP/roomtest/BUGS_CLOSED.md +++ b/applications/PoP/roomtest/BUGS_CLOSED.md @@ -2785,3 +2785,82 @@ if (custom->tbl_level_type[current_level]) leveldoor_right += 8; функции оригинал в СТАРТОВОЙ комнате кладёт затирающий прямоугольник вместо лестницы (и там своя дворцовая/подземная разница 48/39 и сдвиг 2 px), а мы рисуем марш 144 безусловно. Заведено отдельным багом. + +--- + + +## SHADOW-STALE-FRAME. Тень мигала между двумя РАЗНЫМИ позами на страницах дабл-буфера — ЗАКРЫТ 2026-08-18 + +**Наблюдение (пользователь, 2026-08-18).** Уровень 6, комната 1 (Кид и через +большой провал — тень). «У Тени на двух экранах отрисованы разные позиции — +одна стоящая, одна бегущая; увидел сразу, как вошёл в комнату. Повторяется +нечасто, но очень неприятно». Гипотеза пользователя — «в один из экранов +попала случайная позиция Тени» — оказалась дословно верной. + +**Улика (живое состояние, снято через мост MAME до перезапуска).** Слот +соперника `pop_cd[POP_CH_OPP]`: + +``` +стр.0: x=41 y=78 w=12 h=41 valid=1 <- кадр 15 «стойка» +стр.1: x=25 y=85 w=34 h=38 valid=1 <- ЧТО-ТО ДРУГОЕ +cd_sig[OPP][0] == cd_sig[OPP][1] == { frame=15, x=81, y=118, dir=0, charid=1 } +``` + +Обе страницы «тихие» (снимок совпал с живым `Guard`), поэтому ни одна больше +не перерисовывается — мигание навсегда. Прямоугольник страницы 1 +восстанавливается однозначно: `bx = scr_x(obj_x) − w` и `top = obj_y − h + 1` +при `Guard.x = 81`, `Guard.y = 118`, `dir = 0` дают `dx = 3`, `dy = 4`, +`flags ≥ 0x80` и картинку 34×38. В таблице кадров ровно одна такая запись — +**`frame_tbl_guard[36]`, то есть кадр 185 «страж мёртв»** (`image = 33`, +`dx = 3`, `dy = 4`, `flags = 0xC9`; 34×38 — это размер `image 33` в атласе +КИДА, что сходится: набор спрайтов выбирается по `Guard.frame = 15`, а +`image` приходил из кэша). + +**Корень.** Оригинал грузит кадр В САМОЙ ОТРИСОВКЕ: `add_kid_to_objtable` и +`add_guard_to_objtable` (seg008:22F0/2324) первым делом после +`loadkid`/`loadshad` зовут `load_fram_det_col()`. У нас `kid_frame` / +`pop_gframe` были КЭШЕМ, который наполняет тик (`play_seq` → `load_frame`), а +отрисовка брала готовое. Кэш отстаёт от `Char.frame` у всех, кто пишет кадр +мимо `play_seq` — например `do_init_shad` (`guards.c`) кладёт тени +`frame = 15` прямой записью. А `pop_gframe` в этот момент держал кадр 185 +убитого стража с прошлого уровня: после смерти соперника `pop_guard_tick` +выходит по `charid == 0` РАНЬШЕ `pop_load_fram_det_col`, и кэш не трогается +вообще, сколько бы комнат Кид ни прошёл. + +Само по себе это моргнуло бы один кадр. Смертельным его делает **пропуск +неизменившегося кадра** (DRAW-COST): снимок `cd_sig` пишется по +`Guard.frame`, то есть страница запоминает «нарисован кадр 15», хотя на ней +чужой спрайт. Тень дальше стоит, снимок совпадает — страница не +перерисовывается НИКОГДА. + +**Фикс.** `pop_char_draw` зовёт `pop_load_frame()` сразу после +`pop_loadkid`/`pop_loadshad` — там же, где это делает оригинал. После этой +строки `image` однозначно определяется парой `(charid, frame)`, то есть +снимок `cd_sig` снова описывает ровно то, что нарисовано, и расхождение +«пиксели против снимка» становится невозможным независимо от того, кто и как +испортил кэш. Цена: +3 байта в банке 4, кадровый бюджет не трогает +(отрисовка и так пропускается у «тихого» слота). + +Заодно приведена к оригиналу ветка «кадра нет в таблице»: `load_frame` +(`pop_kid.c`) ставила `cur_frame.image = 255` и выходила, НЕ обновив кэш +владельца, — то есть вместо «не рисовать» рисовалась прошлая картинка. +Оригинал кладёт туда `blank_frame {255,0,0,0,0}` (`get_frame_internal`, +seg006:507). Выход из функции теперь один на обе ветки: +46 байт резидента. + +**Проверка (MAME, уровень 6 комната 1).** Брейкпоинт на трамплине +`___sdcc_bcall_ehl` с условием `hl == _pop_char_draw` и действием +«записать в `pop_gframe` кадр 185 и продолжить» — то есть кэш травится +ровно перед КАЖДОЙ отрисовкой персонажа. Слот при принудительной +перерисовке (`valid[0] = valid[1] = 0`) остаётся `12×41` на обеих +страницах, `pop_gframe` после кадра — снова кадр 15. До фикса та же +подмена дала бы `34×38 @ (25,85)` — ровно числа из улики. + +**Что осталось невыясненным.** Не удалось воспроизвести САМ МОМЕНТ порчи: +вход в комнату с заведомо испорченным кэшем (подмена `pop_gframe` в соседней +комнате + возврат читом обхода) самолечится — метка «фон трогали» от +перерисовки комнаты стоит на обеих страницах, и слот честно перерисовывается +следующим кадром. Значит порча случилась в кадре, где фон НЕ трогали, и +конкретный путь остался неизвестен. На вывод это не влияет: фикс закрывает +класс целиком, а не найденный путь. Ловушка на будущее, если симптом +всплывёт снова: `wp <адрес pop_cd[OPP].w>,4,w` — сработает на первой же +записи «чужой» ширины. diff --git a/applications/PoP/roomtest/pop_cdraw.c b/applications/PoP/roomtest/pop_cdraw.c index 2dcc269..343f514 100644 --- a/applications/PoP/roomtest/pop_cdraw.c +++ b/applications/PoP/roomtest/pop_cdraw.c @@ -317,10 +317,12 @@ void pop_char_heal(uint8_t who) __banked { cd_heal(who); } /* ---- Пропуск неизменившегося кадра (DRAW-COST; контракт — pop_cdraw.h) -- */ -/* Снимок ВХОДОВ отрисовки слота. Кадр анимации (kid_frame/pop_gframe) - * однозначно определяется полем frame, поэтому его самого в снимке нет; - * тайлы вокруг персонажа (clip_char, fore-проход) в снимок тоже не входят — - * их изменение ловит pop_cd_dirty. */ +/* Снимок ВХОДОВ отрисовки слота. Картинки (image) в снимке нет: её + * однозначно определяет пара (charid, frame), и это ГАРАНТИРУЕТ pop_load_frame + * в самой отрисовке — без него кадр приходил из кэша, который мог отстать, и + * снимок описывал не то, что нарисовано (BUG-SHADOW-STALE-FRAME, разбор в + * pop_char_draw). Тайлы вокруг персонажа (clip_char, fore-проход) в снимок + * тоже не входят — их изменение ловит pop_cd_dirty. */ typedef struct { uint8_t frame, x, y, action, sword, charid, room, hurt; int8_t dir, ccol, crow; @@ -528,6 +530,33 @@ void pop_char_draw(uint8_t who) __banked pages = kidp; npages = kid_npages; } } + /* load_fram_det_col (seg008:22F5 / 2329) — кадр грузит САМА отрисовка, + * сразу после loadkid/loadshad. У нас его не было: kid_frame/pop_gframe + * считались кэшем, который наполняет тик (play_seq -> load_frame), а + * отрисовка берёт готовое. Кэш лагает — тот, кто пишет Char.frame МИМО + * play_seq, кадр не перезагружает, и рисуется прошлая картинка: + * + * do_init_shad (guards.c) кладёт тени frame = 15 (стойка) прямой + * записью, а pop_gframe в этот момент держит кадр 185 УБИТОГО СТРАЖА + * с прошлого уровня (после его смерти pop_guard_tick выходит по + * `charid == 0` РАНЬШЕ pop_load_fram_det_col, и кэш не трогается + * вообще). В кадре входа в комнату тень рисуется картинкой 33 + * (34x38, dx 3, dy 4) вместо 12x41 — да ещё из атласа КИДА, потому что + * набор выбирается по Guard.frame, а image приходит из кэша. + * + * Само по себе это моргнуло бы один кадр. Смертельным его делает + * пропуск неизменившегося кадра: снимок пишется по Guard.frame, то есть + * страница запоминает «нарисован кадр 15», хотя на ней чужой спрайт. + * Тень дальше стоит, снимок совпадает — страница НИКОГДА не + * перерисовывается, и дабл-буфер вечно мигает двумя разными позами + * (BUG-SHADOW-STALE-FRAME, уровень 6 комната 1). + * + * Лечим корень, а не симптом: после этой строки image ОДНОЗНАЧНО + * определяется парой (charid, frame), и снимок cd_sig снова описывает + * ровно то, что нарисовано. determine_col из связки НЕ берём: он пишет + * Char.curr_col, которую тут же читает clip_char, а физика колонку ведёт + * сама (расхождение с оригиналом — ../docs/impl_diff.md). */ + pop_load_frame(); if (fr->image == 255) return; /* пустой кадр */ /* load_frame_to_obj (seg008:1728): НИЗ спрайта на obj_y, левый край на diff --git a/applications/PoP/roomtest/pop_kid.c b/applications/PoP/roomtest/pop_kid.c index 1e5932b..f0af4c1 100644 --- a/applications/PoP/roomtest/pop_kid.c +++ b/applications/PoP/roomtest/pop_kid.c @@ -198,20 +198,32 @@ static void load_frame(void) * в остальных она ходит кадрами Кида (seg006:532). */ const uint8_t *f; uint8_t use_guard_tbl = pop_frame_tbl_is_guard(Char.charid, Char.frame); + f = 0; if (use_guard_tbl) { int16_t idx = (int16_t)Char.frame; if (idx >= 102 && idx < 107) idx += 70; idx -= 149; - if (idx < 0 || idx >= KID_NGFRAMES) { cur_frame.image = 255; return; } - f = kd_gframe_ptr((uint8_t)idx); + if (idx >= 0 && idx < KID_NGFRAMES) f = kd_gframe_ptr((uint8_t)idx); } else { f = kd_frame_ptr(Char.frame); } - cur_frame.image = f[0]; - cur_frame.dx = (int8_t)f[1]; - cur_frame.dy = (int8_t)f[2]; - cur_frame.flags = f[3]; - cur_frame.sword = f[4]; + if (f) { + cur_frame.image = f[0]; + cur_frame.dx = (int8_t)f[1]; + cur_frame.dy = (int8_t)f[2]; + cur_frame.flags = f[3]; + cur_frame.sword = f[4]; + } else { + /* Кадра нет в таблице — оригинал кладёт blank_frame {255,0,0,0,0} + * (get_frame_internal, seg006:507) и этим ГАСИТ отрисовку. Выход + * ОДИН на обе ветки: «пусто» обязано доехать и до кэша владельца + * (ниже), иначе kid_frame/pop_gframe держат ПРОШЛУЮ картинку и + * вместо «не рисовать» рисуется она — тот же класс, что + * BUG-SHADOW-STALE-FRAME (разбор в pop_char_draw). */ + cur_frame.image = 255; + cur_frame.dx = cur_frame.dy = 0; + cur_frame.flags = cur_frame.sword = 0; + } /* Кэш кадра для ОТРИСОВКИ раскладываем ЗДЕСЬ, по charid активного * персонажа, а не в save*-функциях. Раньше кэш писали pop_savekid/ * pop_saveshad, и любое окно Char БЕЗ play_seq (окна боёвки