diff --git a/applications/PoP/roomtest/BUGS_CLOSED.md b/applications/PoP/roomtest/BUGS_CLOSED.md index 887daf4..4f6e23c 100644 --- a/applications/PoP/roomtest/BUGS_CLOSED.md +++ b/applications/PoP/roomtest/BUGS_CLOSED.md @@ -2653,3 +2653,90 @@ x = 0x51 — то есть персонаж В ПОКОЕ. А слот отри Фикс убирает залипание, но кадр, для которого атлас отдал 0x0, всё равно не нарисуется. Подозрение — конкуренция за окно W0 между `atlas_image` и чтением данных уровня; проверять трассировкой маппинга. + +--- + + +## BUG-CHEAT-IMM-1. Кид залипал в боевой стойке без меча — ЗАКРЫТ 2026-08-17 + +**Наблюдение (пользователь, уровень 2, комната 4).** Кида с мечом сталкивают +ударами с ряда 1 на ряд 2; страж падает следом и оказывается у него за +спиной. Кид встаёт в боевую стойку, но: **меча в руке нет** (не +отрисовывается), и **к сопернику он не разворачивается** — на клавиши не +реагирует вовсе. «Иногда Кид успевает развернуться, а вот сейчас не успел». + +**Два симптома — один отказ.** Диспетчер `control()` (`pop_ctrl.c`, +порт seg005:252) уходит в `control_with_sword` только при +`Char.sword == SWORD_2_DRAWN`. При `sword == 0` управление проваливается в +обычные ветки, а среди них для кадров стойки с мечом (158/170/171) нет ни +одной — каждый кадр не делается НИЧЕГО. Отсюда и «не разворачивается»: +разворот к сопернику за спиной (`SEQ_60_TURN_WITH_SWORD` при +`char_opp_dist() < -4`) живёт внутри `control_with_sword`. + +**Корень — наш чит бессмертия, а не механика боя.** `hurt_by_sword` +(`guards.c`, порт seg002) перехватывался читом в самом начале и +**безусловно**, не глядя на меч, ставил `SEQ_74_HIT_BY_SWORD`: + +```c +if (Char.charid == CHARID_0_KID && pop_immortal) { + pop_char_set_seq(SEQ_74_HIT_BY_SWORD); /* ← без проверки sword */ +``` + +А `seq_74` — это анимация «получил удар **В БОЕВОЙ СТОЙКЕ**», её кадры +(150..179) все с мечом. В оригинале в неё попадают ТОЛЬКО из ветки +`sword == 2`; безоружного там убивают (seg002, дословный комментарий: +*«Being hurt when not in fighting pose means death»*, `take_hp(100)`). +Чит подменял эту смерть выживанием и оставлял Кида в кадрах с мечом при +`sword == 0` — состояние, которого в оригинале не существует и для которого +у диспетчера нет ветки. + +**Как доказано** (важно для метода — гипотез было три, и две неверные): + +1. Кодоген `draw_sword` проверен по `bank5_pop_ctrl.asm`: `ld hl,#_Char+12 / + ld (hl),#0x02` — store меча на месте, версия «SDCC потерял запись» + (ср. [[sdcc_z80_cmp_store_a_bug]]) отпала. +2. Watchpoint на `Kid.sword` в MAME: `0 → 2` при доставании и `2 → 0` в + `start_fall` проходят штатно (`start_fall` меч гасит и в оригинале, + seg006:1102). +3. **Решающее:** с ВЫКЛЮЧЕННЫМ читом (`pop_immortal = 0`) тот же сценарий + даёт корректную смерть — то есть порт совпадает с оригиналом, а баг + живёт только под читом. + +Грабли метода, стоившие времени: ловушки прошлой сессии печатали +`b@0x992A` **без** префикса `0x10000`, то есть мимо логического вида Z80 — +`charid/frame/dir` в той трассе были мусором, отсюда ложный вывод «записей +в `sword` нет вовсе». И ловушка на `Char.sword` бесполезна: `Char` — +переключаемое окно, у стража меч вынут, поэтому `2 → 0` там происходит на +каждом переключении окна. Ещё одно: `pop_char_set_seq` пишет +`ld ($992D),hl`, запись 16-битная — байтовое условие `wpdata==0xDC` не +совпало бы никогда. + +**Фикс** (решение пользователя: «повторяем поведение оригинала, наш иммортал +работает только во время боя, с мечом»). Спецветка чита удалена целиком — +она оказалась **избыточной**: под бессмертием `pop_take_hp(1)` и так +возвращает 0, и обычный путь сам приводит к `seq_74` с сохранённым мечом. +Осталось запретить читу действовать в безоружной ветке: + +```c +if (Char.sword != SWORD_2_DRAWN) { + uint8_t imm = pop_immortal; + pop_immortal = 0; /* чит не действует вне боевой стойки */ + pop_take_hp(100); + pop_immortal = imm; + pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); +} +``` + +Глушится именно вызов, а не `pop_take_hp`: тот общий с физикой (падения, +пики, чомперы), и там бессмертие обязано работать при любом положении меча. + +**Подтверждение.** Пользователь, 2026-08-17: «всё сработало как надо», +проверено обоими концами — «во время боя удары, пропускаемые Кидом, урон не +приносят; если Кид без меча — смерть с одного удара». То есть чит в бою +работает как работал, а вне боевой стойки поведение снова совпадает с +оригиналом. +Взведена регресс-ловушка на сам инвариант: Киду пишется кадр стойки +(158/170/171), а `Char.sword != 2`. Условие смотрит на `Char`, а НЕ на +`Kid`: `ldir` копирует байт кадра (0) раньше байта меча (12), поэтому по +`Kid` ловушка слепа ровно в тот тик, когда меч теряется, и ложно срабатывает +на каждом законном доставании. diff --git a/applications/PoP/roomtest/guards.c b/applications/PoP/roomtest/guards.c index c766fe7..b2cd844 100644 --- a/applications/PoP/roomtest/guards.c +++ b/applications/PoP/roomtest/guards.c @@ -964,18 +964,27 @@ static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] = static void hurt_by_sword(void) { if (Char.alive >= 0) return; - /* Чит бессмертия перекрывает и «безоружного закалывают насмерть»: - * без этого Кид гибнет с одного удара мимо стойки, и чит бесполезен - * ровно там, где нужен. Анимацию удара оставляем. */ - if (Char.charid == CHARID_0_KID && pop_immortal) { - pop_char_set_seq(SEQ_74_HIT_BY_SWORD); - Char.y = (uint8_t)pop_y_land[Char.curr_row + 1]; - Char.fall_y = 0; - play_seq(); - return; - } if (Char.sword != SWORD_2_DRAWN) { + /* Удар мимо боевой стойки в оригинале СМЕРТЕЛЕН (seg002: «Being + * hurt when not in fighting pose means death», take_hp(100)), и + * чит бессмертия сюда НЕ распространяется — он работает только в + * бою, то есть с мечом в руке. + * + * Раньше чит перехватывал hurt_by_sword целиком и безусловно ставил + * SEQ_74_HIT_BY_SWORD. Но это анимация «получил удар В БОЕВОЙ + * СТОЙКЕ»: её кадры (150..179) все с мечом, и Кид оказывался в них + * при sword == 0. Диспетчер control() при sword != 2 в + * control_with_sword не идёт, а среди обычных веток кадра 158/170/171 + * нет ни одной — управление зависало намертво: Кид стоял в боевой + * стойке без меча и не разворачивался к сопернику (BUG-CHEAT-IMM-1). + * + * Ветку глушим на время вызова, а не правим pop_take_hp: тот общий + * с физикой (падения/пики/чомперы), и там бессмертие обязано + * работать при любом положении меча. */ + uint8_t imm = pop_immortal; + pop_immortal = 0; pop_take_hp(100); + pop_immortal = imm; pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); } else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) { pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */