Боевая стойка без меча: чит бессмертия ставил seq_74 не глядя на меч

Кида сталкивают с ряда 1 на ряд 2, страж падает за спину — Кид встаёт в
боевую стойку без меча и перестаёт реагировать на клавиши.

Оба симптома — один отказ: control() уходит в control_with_sword только при
sword == 2, а для кадров стойки (158/170/171) среди обычных веток нет ни
одной.  Разворот к сопернику за спиной (SEQ_60 при char_opp_dist() < -4)
живёт как раз внутри control_with_sword.

Корень — наш чит, а не механика.  hurt_by_sword перехватывался бессмертием
в самом начале и безусловно ставил SEQ_74_HIT_BY_SWORD, а это анимация
«получил удар В БОЕВОЙ СТОЙКЕ», её кадры 150..179 все с мечом.  В оригинале
(seg002) туда попадают только из ветки sword == 2; безоружного там убивают:
«Being hurt when not in fighting pose means death», take_hp(100).

Спецветка чита удалена целиком — она избыточна: под бессмертием
pop_take_hp(1) и так возвращает 0, и обычный путь сам даёт seq_74 с
сохранённым мечом.  Осталось запретить читу действовать в безоружной ветке.
Глушится вызов, а не pop_take_hp: тот общий с физикой (падения/пики/
чомперы), там бессмертие обязано работать при любом положении меча.

Проверено в MAME: в бою удары урона не приносят, без меча — смерть с одного
удара.  Разбор и грабли метода — BUGS_CLOSED.md#bug-cheat-imm-1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:34:46 +03:00
parent aa845c0362
commit 18f58439a3
2 changed files with 106 additions and 10 deletions
+87
View File
@@ -2653,3 +2653,90 @@ x = 0x51 — то есть персонаж В ПОКОЕ. А слот отри
Фикс убирает залипание, но кадр, для которого атлас отдал 0x0, всё равно Фикс убирает залипание, но кадр, для которого атлас отдал 0x0, всё равно
не нарисуется. Подозрение — конкуренция за окно W0 между `atlas_image` и не нарисуется. Подозрение — конкуренция за окно W0 между `atlas_image` и
чтением данных уровня; проверять трассировкой маппинга. чтением данных уровня; проверять трассировкой маппинга.
---
<a id="bug-cheat-imm-1"></a>
## 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` ловушка слепа ровно в тот тик, когда меч теряется, и ложно срабатывает
на каждом законном доставании.
+19 -10
View File
@@ -964,18 +964,27 @@ static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] =
static void hurt_by_sword(void) static void hurt_by_sword(void)
{ {
if (Char.alive >= 0) return; 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) { 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_take_hp(100);
pop_immortal = imm;
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
} else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) { } else if (Char.charid != CHARID_4_SKELETON && pop_take_hp(1)) {
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */ pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */