SprPoP: мёртвое тело больше не приземляется в присед, надпись смерти не залипает

Два независимых фикса, оба проверены в MAME.

1. ПРИЗЕМЛЕНИЕ МЁРТВОГО (симптом «убили Кида, а он сел этажом ниже»).
   Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
   развязка «мягко / средне / разбиться» отведена ЖИВОМУ, телу — своя
   ветка (добить HP, звук падения насмерть, seq_22).  У нас развилки не
   было, и в глаза это не бросалось только из-за высоты: тело, сброшенное
   ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет, «последнее HP»
   не тратится, ветка «разбился» не выбирается никогда.  Труп уходил в
   мягкое приземление и садился (кадр 109).

   Цена — один тест байта на вызов land(), то есть на событие касания
   земли, а не на кадр.  Живой путь не изменился ни на операцию.

2. ЗАЛИПАВШАЯ НАДПИСЬ «Press Button to Continue».  Счётчик кадров смерти
   живёт снаружи главного витка и потому переживает возврат на заставку.
   Ответ игрока кнопкой его обнулял, а выход по таймауту (24 с молчания
   -> title) уходил мимо сброса.  Дальше счётчик оставался израсходованным
   на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила в
   title, не показав надписи; лечилось только перезапуском программы.
   Сброс поставлен на входе в игровой маршрут — закрывает и остальные
   боковые дороги (выпадение за нижнюю границу, смена уровня).

Здесь же довезена связка находок 12/13 аудита: смерть безоружного у
обрыва уходит в свою последовательность (seq_81), а прижатие к полу
осталось страховкой для прочих веток — снять его целиком не вышло дважды,
подробности в комментарии guards.c.

Тесты: t_death дополнен обеими сторонами развилки (мёртвый обязан
разбиться, живой с той же высоты — сесть без урона), 18 проверок; все 16
наборов host-тестов зелёные.

В доску записан CLIMB-VS-GUARD: Кид подтягивается к стражу этажом выше —
у нас удар порой смертелен, в оригинале Кид срывается без урона.  Цепочка
засчитывания удара сверена с оригиналом и совпадает дословно, расходятся
входные данные.  Лучшая зацепка — ЗВУК: оригинал играет взмах клинка (11)
при любом уколе, до всякой проверки попадания, а у нас слышен упор в
стену (8) — значит страж не атакует, а сталкивается.  Набор звуков
проверен и не виноват.  Отложено по решению пользователя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
This commit is contained in:
2026-08-31 22:17:38 +03:00
parent 4e12aa50d1
commit b0e7130d0b
6 changed files with 264 additions and 7 deletions
+81
View File
@@ -38,6 +38,7 @@
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
---
@@ -1313,3 +1314,83 @@ uint16_t pop_music_left(void) { uint16_t a, b;
раз в кадр, задержка прерывания ничтожна против 11,7 мс периода насоса),
но двойное чтение не трогает состояние прерываний вовсе — на фоне
[cbl_w0_bios_conflict](../../PoP/roomtest/BUGS_CLOSED.md) это плюс.
## CLIMB-VS-GUARD
Кид стоит рядом 2,6, страж — этажом выше на 1,6. Кид тянется подтянуться
на 1,6.
**Оригинал (SDLPoP).** Страж машет мечом, Кид срывается обратно на 2,6
**без потери HP**.
**У нас.** Тот же замах убивает Кида на месте. Если Кид безоружен, это
мгновенная смерть (ветка «заколот без меча», HP разом в ноль) — и дальше
тело падает, что до 2026-08-31 давало отдельный симптом «мёртвый
вприсядку» (закрыт развилкой в `land()`, см. коммит того же дня).
**Наблюдение сверх того (пользователь, 2026-08-31).** В этой же связке
поведение расходится сильнее: страж провалился с ряда 1 на ряд 2 ВМЕСТЕ с
Кидом — причём Кид ушёл в провал в колонке 7 (там дыра), а страж между
колонками 5 и 6, где на ряду 1 пол ЕСТЬ. То есть страж проваливается
сквозь целый пол. Это может быть тем же корнем, что и удар: обоим нужен
корректный ряд/колонка персонажа в момент подтягивания.
**Что уже проверено статически (2026-08-31).** Вся цепочка засчитывания
удара сверена с оригиналом и совпадает ДОСЛОВНО:
- гейт по кадру Кида (нулевой кадр и кадры выхода по лестнице пропускают
разбор целиком) — `pop_check_sword_hurting`;
- приоритет стража при встречном попадании (отметка «ранен» у Кида
снимается) — `pop_check_sword_hurt`;
- требование ОДНОГО ряда у бьющего и жертвы — `check_hurting`;
- кадры удара (укол/третий удар), диапазон дистанции (8 для безоружной
жертвы, 12 для вооружённой, верхняя граница 29);
- формула расстояния `pop_char_opp_dist` — совпадает с `char_opp_dist`.
Значит расходятся не правила, а ВХОДНЫЕ данные: ряд, колонка или X
персонажа во время виса/подтягивания. Наиболее вероятный кандидат —
дистанция: висящий вплотную под кромкой в оригинале не дотягивает до
нижнего порога 8, а у нас пара пикселей переводит его через порог.
**Как чинить.** Не гадать — замерить на живой сцене: в момент, когда
`Opp.action` становится «ранен», снять у обоих `curr_row`, `curr_col`, `x`,
`direction` и сравнить с теми же величинами в SDLPoP на том же кадре
(метод — lldb-трасса живого SDLPoP, как в
[[sdlpop_odd_pixel_char_x]]). Отдельно снять, почему страж теряет опору:
`get_tile_at_char` под ним в кадре провала.
**Уточнение пользователя (2026-08-31, важное).** Оригинальное поведение —
Кид сорвался без потери HP — ВОСПРОИЗВОДИТСЯ и у нас: исход сильно зависит
от того, где именно стоит страж. То есть расхождение не абсолютное, а
пороговое, и это прямо подкрепляет версию про ДИСТАНЦИЮ: пара пикселей
переводит расстояние через нижний порог засчитывания удара (8 для
безоружной жертвы), и удар из «мимо» становится смертельным. Значит
искать надо не потерянную ветку, а сдвиг координаты/порога — сравнивать
`pop_char_opp_dist` в момент замаха при ОДИНАКОВОЙ расстановке.
**ЗВУК КАК ИНДИКАТОР ПУТИ (пользователь, 2026-08-31) — лучшая зацепка.**
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
А звучащий вместо него упор в стену говорит, что у нас сработало
СТОЛКНОВЕНИЕ, а не атака.
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
«атака с промахом», а мы — по ветке «столкновение с персонажем»
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
сквозь пол стража: столкновение двигает его координату, а не атака.
Проверять надо ветку выбора действия стража, а не только дистанцию.
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному
симптому.
+41 -7
View File
@@ -999,6 +999,7 @@ uint8_t pop_demo_kid_ai(void) __banked
#define FRAME_154_POKING 154
#define SEQ_69_ATTACK_WAS_PARRIED 69
#define SEQ_74_HIT_BY_SWORD 74
#define SEQ_81_PUSHED_OFF_LEDGE 81 /* заколот у обрыва — падает замертво */
#define SEQ_85_STABBED_TO_DEATH 85
/* refractimer (seg002:36) — «отдышка» стража после того, как его ранили. */
@@ -1008,10 +1009,40 @@ static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] =
/* hurt_by_sword (seg002): применить попадание к АКТИВНОМУ персонажу.
* Без меча в руке любое попадание смертельно; с мечом — минус 1 HP и кадр
* «получил удар».
* УПРОЩЕНИЕ: ветку «сбит с уступа» (seq_81, когда сзади пусто и до кромки
* меньше 4) не портируем — ей нужны тайловые запросы ОТ Char, а pop_map
* пока умеет только от Kid. На ровном полу (тайл сзади не пустой) оригинал
* идёт ровно нашей веткой. */
*
* СМЕРТЬ БЕЗОРУЖНОГО БЫВАЕТ ДВУХ ВИДОВ, и выбор между ними делается по
* обстановке ПОЗАДИ (seg002): если сзади есть опора или до кромки меньше
* четырёх — «заколот на месте» (seq_85); если сзади пусто и от кромки
* далеко — «сброшен с уступа» (seq_81), отдельная последовательность,
* которая сама отыгрывает падение замертво.
*
* Вторая ветка появилась 2026-08-31 (docs/sdlpop_audit.md, находка 12):
* раньше её не было, потому что считалось, будто тайловые запросы от Char
* недоступны — на деле они давно работают, не хватало объявлений.
*
* Замечание к сцене: падение первым делом убирает меч (start_fall), так
* что персонаж, сбитый в пропасть во время боя, к следующему удару уже
* безоружен — и попадает сюда же. */
/* ПРИЖАТЬ К ПОЛУ СВОЕГО РЯДА — только для ПЕРЕЖИВШЕГО удар.
*
* В оригинале (seg002, ветка seq_74_hit_by_sword) эти две строки стоят
* ВНУТРИ ветки выжившего; смертельные ветки координату не трогают.
*
* У нас они долго выполнялись безусловно и работали СТРАХОВКОЙ за
* отсутствующую ветку «сброшен с уступа»: убитого в воздухе прижимали к
* полу, иначе он продолжал падать и выпадал за нижнюю границу, а игра
* уходила на рестарт, не показав тела (проверено на живой машине
* 2026-08-31 — правка в одиночку ломала смерть).
*
* Снято ПОСЛЕ появления ветки seq_81: теперь смерть у обрыва отыгрывает
* своя последовательность, и страховка больше не нужна. Порядок именно
* такой и важен — сперва ветка, потом снятие. */
static void hurt_stand_on_floor(void)
{
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
}
static void hurt_by_sword(void)
{
if (Char.alive >= 0) return;
@@ -1032,19 +1063,22 @@ static void hurt_by_sword(void)
* Костыля «снять бессмертие на время вызова» здесь БОЛЬШЕ НЕТ:
* pop_take_hp гасит только урон меньше 100, а тут ровно 100. */
pop_take_hp(100);
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
if (pop_tile_behind_char() != 0 || pop_dist_to_edge_weight() < 4)
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* есть опора сзади */
else
pop_char_set_seq(SEQ_81_PUSHED_OFF_LEDGE); /* сзади обрыв */
} else if (Char.charid == CHARID_0_KID && pop_immortal) {
/* ЧИТ, уровень 1 и выше: в боевой стойке удары не отнимают HP.
* Кадр «получил удар» оставляем — иначе бой перестаёт читаться,
* да и оригинал на выживший удар ставит ровно его. */
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
hurt_stand_on_floor();
} else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) {
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */
} else {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
hurt_stand_on_floor();
}
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
/* seg002:0C1F: у Кида свой звук боли, у соперника свой. */
pop_sfx_play((uint8_t)(Char.charid == CHARID_0_KID ? 13 : 12));
play_seq();
+29
View File
@@ -549,6 +549,18 @@ static void determine_col(void)
* control(). См. разбор там. */
void pop_determine_col(void) __banked { determine_col(); }
/* Тайл ПОЗАДИ персонажа и расстояние до кромки — наружу, для боевой
* половины (guards.c): по ним оригинал выбирает, какой смертью умирает
* заколотый — на месте или сброшенным с уступа (seg002, hurt_by_sword).
* Обе давно работают от Char, а не от Kid; не хватало только объявлений
* (docs/sdlpop_audit.md, находка 12). */
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
{
@@ -682,6 +694,19 @@ static void land(void)
* control_with_sword и до него не доходит — Кид садится в присед
* НАВСЕГДА (BUG-LAND-SWORD-1). */
is_screaming = 0; /* seg005:116 */
/* seg005:0173 — ВСЯ развязка приземления (мягко/средне/разбиться) у
* оригинала заперта за `if (Char.alive < 0)`, то есть выполняется
* только для ЖИВОГО. Мёртвому телу отведена своя дорога (seg005 ветка
* else, loc_5F6C): добить HP, звук падения насмерть и seq_22.
*
* Развилки у нас не было, и труп шёл по живой дороге. Смертельно это
* не выглядело только из-за высоты: тело, сброшенное ударом с ОДНОГО
* ряда, набирает fall_y < 22 — урона нет, «последнее HP» не тратится,
* ветка «разбился» не выбирается никогда, и мёртвый Кид приземлялся
* в ПРИСЕД (кадр 109, seq_17). Отсюда симптом «убитого Кида уронили,
* а он сел этажом ниже, и только потом обнаружилось, что он мёртв».
* Цена проверки — один тест байта на приземление. */
if (Char.alive >= 0) goto crushed;
if (Char.fall_y < 22) {
soft_land:
if (Char.charid >= CHARID_2_GUARD || Char.sword == SWORD_2_DRAWN) {
@@ -698,6 +723,10 @@ static void land(void)
if (!deadly && Char.charid == CHARID_2_GUARD) deadly = 1; /* seg005:190 */
Char.fall_x = Char.fall_y = 0;
if (deadly || pop_take_hp(1)) { /* 3+ этажа или последнее HP */
crushed:
/* Сюда же приходит УЖЕ мёртвое тело (см. развилку выше); для
* него обнуление ниже — первое, живой путь его уже сделал. */
Char.fall_x = Char.fall_y = 0;
pop_take_hp(100);
if (Char.charid == CHARID_0_KID) pop_sfx_play(0); /* разбился */
pop_char_set_seq(SEQ_22_CRUSHED);
+5
View File
@@ -67,6 +67,11 @@ void pop_row_tiles(int8_t row, int8_t c0, int8_t c1, uint8_t *out) __banked;
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
void pop_determine_col(void) __banked;
/* Тайл позади АКТИВНОГО персонажа и расстояние до кромки его тайла.
* Нужны боевой половине (guards.c) для выбора смерти у обрыва. */
uint8_t pop_tile_behind_char(void) __banked;
int8_t pop_dist_to_edge_weight(void) __banked;
/* HP/смерть. pop_kid_dead=1 когда Kid убит (пики); hitp_curr — текущее HP.
* pop_kid_hp_reset() ставит старт HP и снимает смерть (звать в kid_init/
* респавн). */
+9
View File
@@ -391,6 +391,15 @@ int main(void)
if (!is_demo && pop_app_state != POP_APP_PLAYING) break;
demo_new_game = 0;
demo_finished = 0;
/* Счётчик кадров смерти живёт СНАРУЖИ витка, поэтому переживает возврат
* на заставку. Ответ игрока кнопкой его обнуляет, а вот выход по
* таймауту (24 с молчания -> title, pop_dead_prompt вернул 2) уходит
* мимо этого сброса. Без строки ниже счётчик оставался израсходованным
* на всю сессию, и в следующей игре ПЕРВАЯ же смерть мгновенно уводила
* в title, не показав «Press Button to Continue». Сбрасываем на входе
* в игровой маршрут — это закрывает и любую другую боковую дорогу
* (выпадение за нижнюю границу, смена уровня), а не только таймаут. */
dead_frames = 0;
while (!quit && !pop_quit_req && (pop_app_state == POP_APP_PLAYING ||
pop_app_state == POP_APP_DEMO)) {
+99
View File
@@ -113,6 +113,101 @@ TC_TEST(death_at_ledge_keeps_body_in_place)
TC_EQ(Kid.fall_y, 0);
}
/* --- ДВЕ СМЕРТИ БЕЗОРУЖНОГО: на месте и сброшенным с уступа ---------- *
*
* Оригинал выбирает между ними по обстановке ПОЗАДИ: есть опора или до
* кромки меньше четырёх — «заколот на месте»; сзади обрыв — отдельная
* последовательность падения замертво (docs/sdlpop_audit.md, находка 12).
*
* Проверяем сам ВЫБОР: последовательности должны различаться. Сравнение
* с конкретным номером было бы хрупким — смещения приходят из таблицы,
* извлечённой из данных оригинала. */
static uint16_t seq_after_unarmed_death(const uint8_t *room, uint8_t col, int8_t dir)
{
sc_room(room, 1);
sc_kid_at(col, 2, dir);
hitp_curr = 3;
Kid.sword = 0; /* безоружен: падение уже спрятало меч */
Kid.alive = -1;
pop_kid_dead = 0;
Kid.action = 99;
pop_check_sword_hurt();
pop_do_delta_hp();
return Kid.curr_seq;
}
TC_TEST(unarmed_death_with_floor_behind_differs_from_ledge)
{
uint16_t on_floor, at_ledge;
/* Лицом ВПРАВО в середине сплошного пола: сзади (слева) опора. */
on_floor = seq_after_unarmed_death(room_flat, 4, 1);
/* Лицом ВЛЕВО у кромки: сзади (справа) обрыв — колонки 5..9 пусты. */
at_ledge = seq_after_unarmed_death(room_ledge, 4, -1);
TC_TRUE(on_floor != at_ledge);
}
TC_TEST(unarmed_death_at_ledge_is_lethal_too)
{
(void)seq_after_unarmed_death(room_ledge, 4, -1);
TC_EQ(hitp_curr, 0);
}
/* --- ПРИЗЕМЛЕНИЕ МЁРТВОГО ТЕЛА ---------------------------------------
*
* Оригинал спрашивает у приземляющегося, жив ли он (seg005:0173): вся
* развязка «мягко / средне / разбиться» отведена ЖИВОМУ, а телу — своя
* ветка (добить HP, звук падения насмерть, seq_22).
*
* У нас развилки не было, и это не бросалось в глаза только из-за высоты:
* тело, сброшенное ударом с ОДНОГО ряда, набирает fall_y < 22 — урона нет,
* «последнее HP» не тратится, ветка «разбился» не выбирается никогда, и
* труп приземлялся в ПРИСЕД (кадр 109). Отсюда симптом «убитого Кида
* уронили, а он сел этажом ниже».
*
* Пара тестов держит обе стороны развилки: мёртвый обязан разбиться,
* живой на той же высоте — сесть, как и раньше. */
#define FRAME_109_CROUCH 109
/* Уронить персонажа с ряда 1 на пол ряда 2 и дать физике доиграть. */
static void drop_from_row1(const uint8_t *room, uint8_t col, uint8_t alive, uint8_t hp)
{
uint8_t i;
sc_room(room, 1);
sc_kid_at(col, 1, 1 /* лицом вправо */);
hitp_curr = hp;
Kid.alive = (int8_t)alive; /* -1 жив, 0 мёртв (соглашение оригинала) */
Kid.sword = 0;
pop_kid_dead = 0;
Kid.action = 4; /* свободное падение */
for (i = 0; i < 40; i++) pop_phys_tick();
}
TC_TEST(dead_body_falls_crushed_not_crouched)
{
/* Мёртвое тело падает на ОДИН ряд: высоты не хватает ни на урон, ни на
* «последнее HP», поэтому до фикса оно уходило в мягкое приземление. */
drop_from_row1(room_flat, 4, 0 /* мёртв */, 0);
TC_EQ(Kid.curr_row, 2); /* долетело до пола */
TC_EQ(Kid.fall_y, 0); /* скорость погашена */
TC_FALSE(Kid.frame == FRAME_109_CROUCH); /* НЕ присед */
TC_EQ(pop_kid_dead, 1); /* пошли дорогой «разбился» */
}
TC_TEST(living_soft_land_from_row1_still_crouches)
{
/* Обратная сторона развилки: живой с той же высоты обязан сесть, как и
* до фикса. Если этот тест покраснеет — развилка съела живую ветку. */
drop_from_row1(room_flat, 4, (uint8_t)-1 /* жив */, 3);
TC_EQ(Kid.curr_row, 2);
/* Кадр не проверяем: присед — это ПОСЛЕДОВАТЕЛЬНОСТЬ, и к сороковому
* тику она уже ушла с начального 109 (первый прогон теста поймал это
* на кадре 107). Держим то, что действительно различает ветки. */
TC_EQ(pop_kid_dead, 0); /* живого не разбило */
TC_EQ(hitp_curr, 3); /* один ряд — без урона */
}
int main(void)
{
sc_init();
@@ -122,5 +217,9 @@ int main(void)
TC_RUN(survivor_is_placed_on_floor);
TC_RUN(death_on_flat_floor_keeps_body_in_place);
TC_RUN(death_at_ledge_keeps_body_in_place);
TC_RUN(unarmed_death_with_floor_behind_differs_from_ledge);
TC_RUN(unarmed_death_at_ledge_is_lethal_too);
TC_RUN(dead_body_falls_crushed_not_crouched);
TC_RUN(living_soft_land_from_row1_still_crouches);
return 0;
}