Files
Sprinter-SDCC/applications/PoP/docs/impl_diff.md
T
snark13 5756101d29 Статус-строка: LEVEL N и лейбл QS/QL до дисковой операции
* pop_status_level() — порт show_level (seg008:25A8): «LEVEL N» на старте
  уровня.  Демо-уровень 0 и номера от 14 молчат, тринадцатый показывается
  двенадцатым, бесшовный переход 12->13 пропускается и гасит флаг за собой.
  Первое сообщение с ПАРАМЕТРОМ: строка собирается вручную (dec2, без
  printf и без деления), номер хранится снимком st_arg — иначе вторая
  страница дабл-буфера нарисовала бы другое число.
* pop_status_show_now() — печать немедленно, в ВИДИМУЮ страницу.
  QuickSave/QuickLoad заявляют лейбл ПЕРВЫМ действием, до mem_alloc/ESTEX:
  запись снимка занимает доли секунды, и раньше игрок видел сначала
  необъяснённый фриз, а надпись — уже после него.  Отказ переписывает
  строку на NO QUICKSAVE/NO QUICKLOAD обычной заявкой.
  Расхождение с оригиналом (он печатает по результату) — impl_diff.md.

Проверено в MAME: watchpoint ловит заявку внутри pop_status_show_now,
step_out возвращает в pop_qsave_process — и QUICKSAVE уже на экране, диск
ещё не тронут.  Бюджет прежний: _CODE 23981, куча 266 Б.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:13:48 +03:00

606 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Осознанные расхождения с SDLPoP
Правило подпроекта (`../CLAUDE.md`): расхождение нашей реализации с
`SDLPoP/src/` — по умолчанию **баг у нас**. Этот файл — список исключений:
мест, где мы сознательно сделали иначе, потому что платформа/ABI/бюджет
кадра требуют другого, а НАБЛЮДАЕМОЕ поведение обязано совпадать.
Формат записи: что делает оригинал → что делаем мы → почему → чем платим и
что проверять при регрессе. Если запись перестала быть верной (портировали
дословно, отказались от обхода) — удалять, а не оставлять «для истории»:
история в git.
---
## D-1. История флагов перекрытия у бокового шва: сдвиг вместо тега комнаты
**Файлы:** `roomtest/pop_map.c` (`pop_coll_shift`, `pop_coll_invalidate`,
`check_collisions`), `roomtest/roomtest.c` (`enter_room_side`).
**Связанный баг:** BUG-GATE-PASS-1 (`roomtest/BUGS_CLOSED.md`).
**Дата:** 2026-08-09.
### Как в оригинале
`check_collisions` (seg004:0004) вместе с `get_row_collision_data`
(seg004:0185) держит **10 слотов** флагов перекрытия и рядом —
**параллельный массив номера комнаты**:
```c
row_coll_flags_ptr[tile_col] = curr_flags; /* tile_col — колонка ВНУТРИ разрешённой комнаты (0..9) */
row_coll_room_ptr [tile_col] = curr_room; /* и номер этой комнаты */
...
for (short column = 9; column >= 0; --column) {
if (curr_row_coll_room[column] >= 0 &&
prev_coll_room[column] == curr_row_coll_room[column]) {
if ((prev_coll_flags[column] & 0x0F) == 0 &&
(curr_row_coll_flags[column] & 0x0F) != 0)
bump_col_left_of_wall = column;
...
```
Ключ слота — пара **(колонка в своей комнате, номер комнаты)**. Решётка
комнаты 8 и до перехода 8→6, и после лежит в слоте 9 с `room = 8`: история
переживает смену комнаты, переход флага 0→1 виден, `bumped()` срабатывает.
Комнату оригинал резолвит на лету через `find_room_of_tile` (seg006:005D),
никакого кэша всех комнат у него нет.
### Что делаем мы
Индекс — **колонка ОТРИСОВАННОЙ комнаты**, диапазон −2…11 (14 слотов,
`COLL_C0`/`COLL_N`/`COLL_IDX`), номер комнаты рядом не хранится. При смене
комнаты тот же физический тайл менял бы слот на ±10, поэтому раньше история
просто выбрасывалась (`pop_coll_invalidate``prev = 3` = «уже
перекрывал» → бампа нет). Именно это и был BUG-GATE-PASS-1.
Теперь при **боковом** переходе история не выбрасывается, а
**перенумеровывается**: `pop_coll_shift(∓10)` сдвигает `coll_curr`,
`coll_above`, `coll_below` на 10 слотов и заполняет освободившиеся
тройками. `enter_room_side` зовёт её сразу после `pop_map_set_edges`.
Корректность держится на том, что `check_leave` двигает `Char.x` ровно на
∓140 = 10 тайлов по 14 px, и координата грани (`pop_x_bump[col + …]`)
сдвигается на те же 140 вместе с габаритом Кида, — **сами флаги
инвариантны**, меняется только номер слота. Сдвигаются `curr/above/below`,
а не `prev`: `prev` на следующем кадре всё равно перезапишет
`move_coll_to_prev`, выбирая источник как раз из этих трёх.
Переходы **вверх/вниз** и все прочие входы в комнату (старт уровня,
респавн, чит-навигация) остаются на полной инвалидации: там колонки не
сдвигаются, но тайлы под ними принадлежат другой комнате — история
действительно недействительна.
### Почему не дословно (вариант A)
Дословный порт — 10 слотов + параллельный массив номера комнаты, индекс по
колонке разрешённой комнаты, бамп только при совпадении номеров; тогда
`pop_coll_invalidate` не нужен вовсе, история сама «не совпадает» там, где
колонка сменила комнату.
Не взяли по одной причине: **десяти слотов нам не хватит**. Оригинал
перебирает узкое окно вокруг Кида (от `col(char_x_left_coll) 1` до
`col(char_x_right_coll) + 2`), поэтому коллизии слотов у него практически
не случаются. Мы держим четырнадцать колонок (−2…11) — при узком окне это
не мешает, а вот в десять слотов колонки −2/−1 и 8/9 сядут поверх 8/9.
**Окно перебора с 2026-08-09 у нас такое же, как в оригинале** (было: все
четырнадцать колонок каждый кадр). Признак годности слота при этом не
массив номеров комнат, как у оригинала, а ГРАНИЦЫ окна — четыре байта,
которые `move_coll_to_prev` переносит в `prev` вместе с флагами; сравнение
идёт по пересечению двух окон. Очистки массивов нет вовсе, то есть это
дешевле оригинала, а смысл тот же (у него слот вне окна помечен
`row_coll_room = 1` и в цикл бампа не попадает). `check_chomped_flags`
тоже ограничен окном — иначе протухшие слоты дали бы фантомный перемол.
### Чем платим
- Расхождение структур: если в будущем понадобится знать, из какой комнаты
пришёл тайл конкретного слота, этого у нас нет — придётся идти в вариант A.
- Границы окна надо переносить везде, где переносятся флаги: `pop_coll_shift`
двигает и их, `move_coll_to_prev` снимает их в `prev`. Забыть один из
переносов = молча потерять или, наоборот, разрешить лишний бамп.
- Сдвиг работает только для чисто горизонтальных переходов на ровно 10
колонок. Любая будущая диагональ/иная ширина комнаты его сломает молча.
- `coll_last_row`: `pop_coll_invalidate` прячет прошлый ряд, чтобы
`pop_coll_shift` мог отменить инвалидацию. Порядок вызовов в
`enter_room_side` (сначала `pop_map_set_edges`, потом `pop_coll_shift`)
стал значимым.
### Что проверять при регрессе
Это сердце коллизии, вокруг которого разбирался BUG-SEAM-PINGPONG. После
любой правки здесь — прогон швов:
1. Уровень 1, комнаты 6 ↔ 8, закрытая решётка, **обе** стороны.
2. Оба режима подхода: мелким шагом (упереться) и с разбега (не пройти
насквозь).
3. Проверить, что пинг-понг у шва не вернулся (экран не перескакивает
туда-сюда на кадре бампа о ворота).
4. `make -C roomtest/tests-host` — наборы `t_wall`/`t_char` ходят по этой же
геометрии.
---
## D-2. Кнопка в шве: перерисовываем, хотя оригинал не перерисовывает
### Что делает оригинал
Тайл-«трансформер» (кнопка, ворота, пика) перерисовывается только если он в
ОТРИСОВАННОЙ комнате: `redraw_11h``redraw_tile_height`
`get_trob_pos_in_drawn_room` (seg007:0258), а та для `trob.room != drawn_room`
возвращает 30 — заведомо несуществующий tilepos, то есть «не рисовать».
Исключение сделано ровно одно — факелы (`animate_torch`, seg007:03CF, ветка
`trob.room == room_L && tilepos % 10 == 9`).
Кнопка соседа слева при этом ВЛИЯЕТ на картинку: `get_tile_to_draw`
(seg008:253) подменяет нажатый `tiles_15_opener` на `tiles_1_floor`, а
`load_leftroom` (seg008:360) кладёт результат в `leftroom_[row]`, откуда он
приходит в `draw_tile` как `tile_left`. У пола правая грань есть, у кнопки
нет — значит в оригинале нажатие кнопки, видимой через левый шов, меняет
картинку только при следующей ПОЛНОЙ отрисовке комнаты.
### Что делаем мы
Перерисовываем шов сразу: `seam_row_sig` (roomtest.c) подмешивает в сигнатуру
ряда бит «кнопка нажата» (`pop_doorlink2(mod) & 0x1F > 1`) для тайлов
`0x0F`/`0x06`, и change-driven редрой `pop_room_redraw_seam_left` срабатывает
на нём так же, как на openness ворот.
### Почему
Голый порт давал видимый залип (SEAM-BUTTON-STALE, roomtest/BUGS_CLOSED.md): кнопка
(1,9) комнаты 11 — она же (1,−1) комнаты 24 — оставалась нарисованной в том
состоянии, в каком была на входе в комнату, хотя связь срабатывала. Сигнатура
шва следила только за `room_modif`, а у кнопки `modif` — это ИНДЕКС LINKLOC,
константа уровня: нажатие живёт в `doorlinks2` и в сигнатуру не приходило
никогда. Добавить кнопку в сигнатуру — те же три сравнения на кадр, что уже
делались для ворот; воспроизводить артефакт оригинала смысла нет.
### Чем платим
- Резидент +200 Б (`_CODE` 24 739 → 24 939), куча W2 1795 → 1595 Б. Если
станет тесно — `seam_row_sig` переносится в банк 7 к
`pop_room_redraw_seam_left`, ценой одного трамплина на кадр.
- Сигнатура ряда стала разнотипной: для кнопки это булев бит, для остальных
тайлов — modif. Значения между собой не сравниваются (сравнивается только
ряд сам с собой), но при добавлении нового типа тайла в шов про это надо
помнить.
### Что проверять при регрессе
Уровень 5, кнопка нижних ворот комнаты 24 (она же (1,9) комнаты 11), оба
направления: нажать её из комнаты 11 и войти в 24; и наоборот — войти в 24
поверху и наступить на неё, стоя в шве. Картинка кнопки обязана совпадать
со статусом ворот в обоих случаях.
---
## Перо (медленное падение) ловит ТОЛЬКО Кида
**Оригинал** (`fall_accel`, seg006:057C): `is_feather_fall` — глобальный флаг,
и медленное падение достаётся ЛЮБОМУ персонажу, который окажется в `Char`,
пока эффект жив. То есть страж, сошедший с уступа в те же секунды, парит
вместе с Кидом, хотя зелье пил не он. SDLPoP считает это багом и чинит
опцией `fix_feather_fall_affects_guards`.
**Мы** берём поведение С ФИКСОМ: `pop_feather` проверяется вместе с
`Char.charid == CHARID_0_KID` — и в `fall_accel` (`pop_map.c`), и в опкоде
`JMP_IF_FEATHER` интерпретатора seqtbl (`pop_kid.c`), чтобы физика и анимация
не разъехались.
**Чем платим.** Сцена, где страж падает при живом пере, будет выглядеть иначе,
чем в DOS-оригинале (у нас он падает нормально, там — парит). На уровне 7,
единственном с этим зельем, такой сцены нет: зелье в комнате 1, стражи — в
других комнатах.
**Что проверять при регрессе.** Уровень 7: выпить зелье в комнате 1, тут же
столкнуть стража в провал — он обязан падать БЫСТРО, а Кид рядом — медленно.
---
## Синее зелье («−HP») не ставит свою вспышку
**Оригинал** (`proc_get_object`, seg006:1892): ветка `case 5` только глушит
звуки, играет `sound_13_kid_hurt` и ставит `hitp_delta`. Экран краснеет не
здесь, а общим механизмом «Кид ранен» (`flash_if_hurt`, seg003:0AFC).
**Мы** раньше ставили в этой ветке ещё и `pop_flash_*` (красную вспышку на 2
кадра) — то есть красили экран дважды: своей вспышкой и кадром урона.
Приведено к оригиналу: ветка правит только `hitp_delta`, краснеет `pop_kid_hurt`.
**Что проверять при регрессе.** Уровень 2, комната 13, зелье `(1,3)`: выпить —
HP убавляется на единицу, экран краснеет РОВНО один раз (без двойного строба).
---
## Переворот (зелье инверсии) применяется НА ГРАНИЦЕ КАДРА, а не мгновенно
**Оригинал** (`toggle_upside`, seg000:15E9): `upside_down = ~upside_down` и
`need_redraw_because_flipped = 1` — флаг переключается прямо в момент глотка,
то есть в середине кадра. Оригиналу это ничего не стоит: он ВСЕГДА рисует в
offscreen неперевёрнутым, а зеркалит только при выводе на экран
(`flip_screen` вокруг `copy_screen_rect`, seg000:939/946). Внутренние
координаты у него от переворота не зависят вообще.
**Мы** offscreen-буфера не имеем (две видеостраницы + теневая ОЗУ-копия на
каждую), поэтому рисуем зеркально сразу — переворот «зашит» в координаты
каждого слоя. Из-за этого момент переключения важен: зелье выпивается из
`play_seq`, то есть в СЕРЕДИНЕ кадра, и остаток кадра рисовался бы уже
зеркально поверх ещё неперевёрнутого фона. Хуже всего пламя факела — оно
ЗАПЕКАЕТСЯ в ОЗУ-копию (`pop_torch_draw`, у него нет heal: каждый следующий
кадр непрозрачно накрывает предыдущий). Кадр пламени, положенный в
зеркальную позицию на старом фоне, оставался там навсегда — по комнате
рассыпались лишние языки огня.
Поэтому у нас два флага: `pop_upside_want` (пишут зелье, смерть Кида, чит U)
и `pop_upside` (читают все слои отрисовки). Переключение — ровно одно место,
начало кадра, вместе с перерисовкой: главный цикл делает
`pop_upside = pop_upside_want` и зовёт `pop_flip_screen`.
Сама перерисовка при этом СОВПАДАЕТ с оригиналом: там на
`need_redraw_because_flipped` вызывается `redraw_screen(0)` — полная
отрисовка, а не отражение уже нарисованного. У нас то же самое —
`pop_flip_screen` рисует комнату заново (и получает чистый фон по
построению), а вторую страницу дабл-буфера отдаёт копией акселератора.
**Что проверять при регрессе.** Уровень 9: выпить зелёное зелье — картинка
переворачивается ровно один раз, лишних языков пламени по комнате нет. Чит
U даёт тот же результат (он идёт тем же путём).
---
## Окклюзия воротами: спрашиваем про рисуемого персонажа, а не жёстко про Кида
**Оригинал** (`draw_tile_fore`, seg008:0D15) первой строкой:
```c
if (tile_left == tiles_4_gate && Kid.curr_row == drawn_row &&
Kid.curr_col == drawn_col - 1 && Kid.room != room_R)
draw_gate_fore();
```
То есть бары ворот попадают в foretable — поверх всего нарисованного — когда
на тайле ворот стоит **именно Кид**. Это следствие устройства оригинала:
foretable ОДНА на весь проход тайлов, персонажи в неё уже добавлены, и
отдельного «переднего слоя на персонажа» там нет.
**Мы** ради скорости не рисуем foretable целиком, а возвращаем куски тайлов
поверх ТОЛЬКО в прямоугольнике персонажа (`pop_fore_over_char`, см. memory
`pop_fore_layer_cost`: полный проход стоил 78 % кадра). Проход идёт по
персонажу, значит и вопрос естественно задавать про него —
`pop_gate_over_char(ch)`, а не про глобального `Kid`.
**Чем платим.** Наш вариант — надмножество оригинального: страж (или тень),
стоящий в проёме ворот, у нас уходит ЗА решётку, а в оригинале остался бы
нарисованным поверх неё, пока на том же тайле нет Кида. Визуально это
правильнее, но формально расхождение. Обратной разницы нет: во всех случаях,
где оригинал рисует бары поверх, рисуем и мы.
**Что проверять при регрессе.** Уровень 10, комната 7, тайл (2,6): Кид,
стоящий в проёме ворот, виден ЗА прутьями. Решение покрыто хост-тестами
(`tests-host/t_char.c`, набор `char_gate_*`) — отрисовка в харнесс не
линкуется, поэтому проверяется предикат.
---
## Тень рисуется спрайтами КИДА, а не XOR-силуэтом
**Файлы:** `roomtest/pop_cdraw.c` (выбор набора атласов по `charid`/`frame`).
**Дата:** 2026-08-18 (решение принималось раньше, записано здесь).
**Оригинал** рисует тень тем же кадром Кида, но ДВАЖДЫ — вторым проходом со
сдвигом на один пиксель и через XOR. Получается тёмный силуэт с контуром,
а не «второй Кид».
**Мы** рисуем тень обычными спрайтами Кида, обычным блиттером — она выглядит
как Кид.
**Почему.** Приём оригинала — read-modify-write по уже нарисованному, а
читать данные из ВИДЕО-ОЗУ (там, где спрайты) на Sprinter нельзя: читается
только ОЗУ-копия. Блочный XOR у акселератора есть и работает как раз по
ОЗУ-копии, но с нашей прозрачностью он несовместим: прозрачность сделана
подавлением записи 0xFF, а XOR прозрачные пиксели тоже смешает — под него
нужен набор с прозрачным 0x00 (замер: memory `accel_block_ops`,
`sprinter_vram_transparency`). То есть «сделать как в оригинале» всё равно
упирается в отдельный набор спрайтов.
**Чем платим.** Тень визуально неотличима от Кида (уровни 4/5/6/12). На
механику не влияет: слот, окна `Char`, ИИ и коллизия у тени свои и от
картинки не зависят.
**План.** Отдельный АТЛАС ТЕНИ (готовый силуэт), а не воспроизведение
XOR-прохода: один набор спрайтов вместо второго пути блита. До тех пор
расхождение сознательное — багом не заводить.
**Что проверять при регрессе.** Уровень 6 комната 1: тень стоит слева
через провал, поза совпадает с позой Кида-в-стойке. Родственная запись —
«Слияние с тенью» ниже.
---
## Слияние с тенью: мигания Кида спрайтами тени нет
**Файлы:** `roomtest/guards.c` (`autocontrol_shadow_level12`),
`roomtest/pop_cdraw.c`.
**Дата:** 2026-08-13.
**Оригинал** (`draw_objtable_item`, seg008:20CA) во время вспышки слияния
(`united_with_shadow` считает 42 → 0) рисует КИДА как тень на чётных
значениях счётчика: тот же кадр уходит не обычным прозрачным блиттером, а
парой OR+XOR со сдвигом на пиксель. Получается мерцание «Кид/тень»
примерно полторы секунды.
**Мы** рисуем всё это время обычного Кида, а само событие обозначаем белой
вспышкой фона (`pop_flash_color = POP_FLASH_WHITE`, 18 кадров) — она в
оригинале тоже есть и ставится тем же кодом.
**Почему.** У нас Кид и соперник рисуются из РАЗНЫХ атласов своими
палитрами (`pop_cdraw.c`), а «тень» — это персонаж слота Guard с палитрой
комнаты; блиттеров OR/XOR в libbgi нет вовсе, прозрачность сделана
0xFF-подавлением записи. Воспроизвести эффект — значит завести Киду второй
набор спрайтов и второй путь блита ради 42 кадров за всю игру.
**Чем платим.** Момент слияния читается только по вспышке и по тому, что
тень исчезла, — без «двоящегося» силуэта. На механику не влияет: счётчик
`pop_united_shadow` тикает и уходит в −1 независимо от отрисовки, а от него
зависят и повторный подъём тени, и появление плит в комнатах 2/13.
**Что проверять при регрессе.** Уровень 12: после слияния экран белеет,
соперник пропал, HP-потолок вырос на единицу, тень в комнате 15 больше не
появляется. Логика покрыта `tests-host/t_shadow.c`.
---
## Отложенный старт падающих плит (уровень 13): фаза 0 у нас «не анимируется»
**Файлы:** `roomtest/pop_map.c` (`pop_check_fall_flo`, `pop_loose_tick`),
`roomtest/pop_trob.c` (`animate_loose`).
**Дата:** 2026-08-13.
**Оригинал** (`check_fall_flo`, seg000:1317) раздаёт шести плитам ряда 2
верхней комнаты модификатор `(prandom(0xFF) & 0x0F)`, то есть 0..−15, и
заводит на каждую trob. Фаза считает вверх, проходит ноль и дальше идёт
обычным отсчётом до провала — плита падает через `n + 11` кадров. Ноль там
безопасен: плиту держит в игре СПИСОК trob, а не значение модификатора.
**Мы** списка trob для loose текущей комнаты не держим — плита анимируется
ровно тогда, когда её фаза не ноль (`pop_loose_modif[pos] != 0`). Значит
счёт, дойдя до нуля, оборвался бы навсегда. Компенсируем двумя правками,
которые работают только в паре:
* тик перескакивает ноль (`if (m == 0) m = 1`);
* стартовое значение берётся на единицу «отрицательнее» (`n1`).
**Чем платим.** Ничем в наблюдаемом поведении: суммарная задержка остаётся
`n + 11` кадров, проверено арифметикой на обоих концах диапазона (n = 0 и
n = 15). Платим связностью — две правки в разных функциях, и убрать любую
одну нельзя.
**Что проверять при регрессе.** Уровень 13, вход в комнату 23 (она же
стартовая): плиты сверху сыплются ВРАЗНОБОЙ, а не разом и не «никогда».
Логика покрыта `tests-host/t_jaffar.c`
(`jaffar_negative_phase_counts_through_to_fall` и парный контроль на другом
уровне).
---
## Чит навигации по комнатам не запускает бесшовный переход уровня
**Файлы:** `roomtest/roomtest_cold.c` (`pop_dbg_roomnav`), `roomtest/roomtest.c`,
`roomtest/pop_state.c` (`pop_nav_hold`).
**Дата:** 2026-08-13.
**Оригинал** (`play_level_2`, seg000:0900) проверяет `Kid.room == 23` КАЖДЫЙ
кадр: уровень 12 кончается самим фактом присутствия Кида в комнате 23, двери
у него нет. Никакого «как он туда попал» там нет и быть не может —
телепорта между комнатами в игре 1989 года не существует.
**Мы** держим этот триггер, пока Кид попал в комнату ЧИТОМ навигации
(`+`/``), и отпускаем на первой же смене комнаты обычным ходом.
**Почему.** Чит перебирает комнаты ПО НОМЕРУ (1..24 с обёрткой), то есть
любой обход уровня 12 неизбежно наступает на 23-ю — и уровень молча
становится 13-м. Поймано на первом же прогоне 2026-08-13: проверяющий час
смотрел «комнату 20 уровня 12», которая на самом деле была комнатой 20
уровня 13, и сравнивал её с картой не того уровня. Комнату 23 уровня 12
читом не посмотреть в принципе. Это ровно та же болезнь чит-телепорта, что
BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
**Чем платим.** Ничем в игре: в обычном прохождении Кид входит в комнату 23
ногами, флаг снят, переход срабатывает как в оригинале. Расхождение видно
ТОЛЬКО при включённых читах.
**Что проверять при регрессе.** Уровень 12: пройти в комнату 23 ногами —
уровень меняется на 13-й без заставки и без сброса HP. Обойти уровень
читом `+` через 23-ю — уровень НЕ меняется.
---
## Чит «убить стража» (K) идёт через штатный путь смерти
**Файлы:** `roomtest/pop_guard.c` (`pop_guard_kill`).
**Дата:** 2026-08-13. Решение пользователя.
**Оригинал** (seg000:786) ставит `guardhp_delta = -guardhp_curr` И
`Guard.alive = 0`. А гейт события смерти в `play_guard` (seg006:1490)
требует `Char.alive < 0` — то есть у оригинала чит убивает стража В ОБХОД
`on_guard_killed`. На 13-м уровне это заметно: победа над Джафаром по читу
не ставит `leveldoor_open = 2`, и выход на 14-й не открывается.
**Мы** `Guard.alive` в чите не трогаем: применённая дельта обнуляет HP, и
`play_guard` сам переводит стража в «умирает», вызвав `on_guard_killed`
брызги, вспышка, флаг выхода. То есть чит даёт ровно «как будто убил Кид».
**Почему.** Отладочный прогон 13-го уровня иначе требует каждый раз честно
выигрывать бой с Джафаром (skill 9, 6 HP) — это дорого по времени, а
проверять надо совсем другое.
**Чем платим.** Ничем в игре: читы включаются флагом `pop_cheats`, в
релизной сборке они выключены. Расхождение наблюдаемо только с читами.
**Что проверять при регрессе.** Уровень 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 этот сценарий не воспроизводил —
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
край и добить.
## ГСЧ разведён по доменам (у оригинала он ОДИН)
**Оригинал.** `random_seed` один на всё: кладка стены, анимация тайлов,
броски боя, модификаторы падающих плит — всё тянет из одной
последовательности (`seg009` PRNG, 32-битный LCG). Поэтому в оригинале
бой воспроизводим вместе со всем остальным: тот же сид — тот же бой.
**У нас.** Сидов несколько: `pop_t_seed` (кладка, `pop_tile.h`),
`pop_fight_seed` (броски боя, `pop_guard.h`), отдельные у trob и loose.
Сам генератор тот же (`pop_prandom`), таблицы вероятностей —
побайтно те же, что в `data.h`.
**Чем платим.** Конкретный бой у нас и в SDLPoP разойдётся: порядок
бросков другой, значит блоки/удары лягут иначе. Статистически поведение
то же (те же вероятности, тот же генератор), но «сверить бой кадр в кадр
с SDLPoP» нельзя, и QuickSave обязан сохранять ВСЕ сиды, а не один.
**Что проверять при регрессе.** Если страж кажется сильнее/слабее
оригинала — сначала проверить не таблицы (они сверены), а **режим
скорости**: `fight_speed` у оригинала 100 мс, а в нашем FASTEST бой идёт
61,4 мс, то есть в реальном времени на 63 % быстрее, и на глаз это ровно
«страж давит сильнее». Режим NORMAL (дефолт) даёт 102,4 мс — см.
`frame_pacing_plan.md`.
## PV intro: единые 12,5 FPS вместо переменных 10/7,5/8,57 FPS
**Оригинал.** `proc_cutscene_frame()` двигает последовательности через
`cutscene_frame_time`: 6 тиков 60 Гц в начале, 8 после первой речи и 7 во
время заклинания. Это соответственно 10, 7,5 и примерно 8,57 FPS.
**У нас (осознанное временное отличие).** Один логический кадр PV держится
четыре физических кадра Sprinter: номинально 50/4 = 12,5 FPS. Молния живёт
на отдельной физической шкале и не растягивается этим делителем. Если полная
отрисовка пересечёт дополнительный фронт, реальная частота может упасть до
10 FPS — это допустимо на текущем этапе, но должно быть измерено.
**TODO.** Перевести PV-сцену на тот же anchor-based механизм точного темпа,
который gameplay использует через `pop_beam_sample/pop_pace_end`: измерять
число реально прошедших фронтов во время сборки кадра, держать период ровно
четыре фронта при укладывании в бюджет и явно учитывать overrun. После замера
можно вернуть точные переменные интервалы SDLPoP без накопления фазы.
## Межуровневые PV-сцены: сохранён реальный период 100 мс
Это правило не относится к временному темпу основного Princess/Jaffar intro
выше. `reset_cutscene()` SDLPoP задаёт для сцен перед уровнями период
6 кадров при 60 Гц, то есть 100 мс. На Sprinter тот же период получается
ровно как 5 кадров при 50 Гц.
Суммы вызовов `proc_cutscene_frame()` перенесены без изменения реального
времени: сцены 2/4/6 и обе ветки 12 содержат 26 логических кадров (130
физических, 2,6 с), сцена 8 — 60 (300, 6,0 с), сцена 9 — 72 (360, 7,2 с).
Fade in/out в эти числа не входят, как и в оригинале.
## Gameplay: загрузка уровней через чёрный cut, без fade
**Оригинал.** На границах игровых уровней использует fade out/in.
**У нас (решение пользователя 2026-08-24).** Вход в первый уровень и
переход между уровнями выполняются как `старый кадр -> чёрная палитра ->
подготовка -> новый кадр с новой палитрой`. Fade на этих двух маршрутах
отсутствует. Сюжетные title/story/PV переходы сохраняют собственные fade и
left-to-right эффекты.
Чёрная палитра устанавливается до любого HDD I/O. Загрузчики guard и
tileset сами физически правят отдельные цветовые слоты, поэтому после них
чёрный экран подтверждается повторно. Зеркальные атласы уровня 9 готовятся
до финального источника палитры. CBL открывается последним: старый порядок
`level_switch -> CBL open -> BIOS fade` давал скрежет повторяющейся половины
аппаратного буфера на входе в Level 1; после перестановки баг исчез в MAME.
## Тень: кайма силуэта не подкрашивается фоном
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
прозрачным блитом в x и «блиттером XOR» в x+1 (`draw_objtable_item`,
seg008.c:1600). XOR идёт по 24-битному RGB того, что УЖЕ на экране
(`blit_xor`, seg009.c:3190), поэтому там, где спрайт прозрачен в x, но
непрозрачен в x−1, цвет получается как `фон XOR цвет спрайта`.
**У нас.** Пакетный блит наложения на себя не умеет, поэтому результат
запечён в отдельный атлас (`toolchain/pop_pack_shadow.py`,
`docs/shadow_atlas_plan.md`). Запекать пришлось для КОНКРЕТНОГО фона, и
выбран чёрный: на нём `фон XOR цвет == цвет`, то есть запечка точна.
**Чем платим.** Ровно одним: **кайма в один пиксель по ЛЕВЫМ кромкам
силуэта** на НЕчёрном фоне. У оригинала она принимает оттенок фона, у нас
всегда «свой» цвет. Внутренность силуэта и правые кромки совпадают точно —
там первый проход уже закрасил пиксель, и от фона результат не зависит.
**Почему это приемлемо.** Тень бывает на четырёх уровнях, и почти всегда
на чёрном: у зеркала (ур. 4), в проёме (5), над пропастью (6), в бою (12).
**Что проверять при регрессе.** Если Тень окажется на светлом фоне и
кайма станет резать глаз — вариантов два: запечь второй набор под светлый
фон (ещё 32 страницы EMM) или считать эту кайму прозрачной (силуэт станет
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
## QuickSave/QuickLoad: лейбл печатается ДО дисковой операции, а не после
**Как в оригинале.** SDLPoP печатает `QUICKSAVE` / `NO QUICKSAVE` (и пару
для загрузки) уже ПО РЕЗУЛЬТАТУ операции — `process_quicksave` (seg000:497)
сначала делает save/load, потом зовёт `display_text_bottom` и ставит
`text_time_total = 24`. На PC это незаметно: файл пишется мгновенно.
**У нас.** `pop_qsave_process` заявляет строку ПЕРВЫМ действием, ещё до
`mem_alloc_pages`/ESTEX, через `pop_status_show_now()` — та печатает её
немедленно в ВИДИМУЮ страницу, не дожидаясь конца кадра. Отказ уже потом
переписывает строку на `NO QUICKSAVE`/`NO QUICKLOAD` обычной заявкой.
**Зачем.** Запись снимка на диск занимает доли секунды, и всё это время
игра стоит. При порядке оригинала игрок видел сначала необъяснённый фриз,
и только по его окончании — надпись, объясняющую то, что уже прошло.
Решение пользователя, 2026-08-25.
**Чем платим.** Строка успевает мигнуть даже там, где операция потом не
удалась: сначала `QUICKSAVE`, следом `NO QUICKSAVE`. На практике отказ —
редкость (нет места/диска), и «заявка → отказ» читается не хуже.
**Что проверять при регрессе.** Что после неудачной операции на экране
остаётся именно `NO QUICKSAVE`/`NO QUICKLOAD`, а не первая строка: отказ
идёт обычной заявкой и печатается кадровым проходом, то есть на кадр позже.