SprPoP: финал больше не убивает программу — прямой вызов в чужой банк

Пройденная игра доходила до таблицы рекордов и умирала: программа
исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила
в reset.  Одинаково из Flex Navigator и из голого DSS.

КОРЕНЬ.  pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked.  Пока
pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после
переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в
пустой хвост чужого банка.  Процессор полз по 0xFF до 0x0000, где ловушка
DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3,
которую было видно на трупе, оказалась уборкой, а не причиной.

Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка
MAME на узком участке: trace включалась брейкпоинтом на входе в
pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56).

ЧТО СДЕЛАНО

* pop_ui.h/.c — группа text_*_mapped помечена __banked.
* toolchain/check_bank_calls.py — две проверки банкового кода:
  1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено
     намеренной поломкой);
  2) указатель на данные своего банка, отданный в чужой (эвристика по
     форме кода, только предупреждает).
  Встроена в app.mk, запускается сразу после линковки.
* pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10
  и после пометки __banked уезжал из-под ног чужому банку, заливая экран
  знаками вопроса.
* libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды
  нумпада в EXT-половину карты.  Лечит залипание стрелок (потерянный
  префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем),
  заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз.
* pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и
  gfx_wait_vsync: без этого F10 в геймплее не работал вовсе.
* pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с
  оригиналом записано в docs/impl_diff.md).
* Экран версии показывается только через Menu/Settings/About: стартовый
  показ и Ctrl+V убраны, мёртвый код снят.
* sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с
  выросшим за уровень максимумом полоса HP моргала между страницами.

Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md
(FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY),
правило про банки — в applications/SprPoP/CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:11:01 +03:00
parent 25b2db8b0b
commit 4e43890fce
22 changed files with 645 additions and 89 deletions
+112
View File
@@ -3630,3 +3630,115 @@ public `_pop_mus_page` по смещениям из `.sym`):
брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`.
---
<a id="final-bankcall"></a>
## FINAL-BANKCALL. Финал убивал программу: прямой вызов в чужой банк
**Симптом (пользователь, 2026-08-27).** Пройденная игра доходила до
таблицы рекордов и умирала: программа исчезала, а машина следом вставала
намертво (`di; halt` по адресу 0x0000) либо уходила в reset. Из Flex
Navigator и из голого DSS — одинаково.
**Что оказалось не при чём** (проверено, чтобы не искать заново): Flex
Navigator, звук и CBL (с выключенным звуком падало так же), потоковое
чтение победной темы, второй `open` поверх открытого файла, фейды с
клавиатурным диспетчером, чтение архива PV, межбанковый вызов
банк 10 → банк 11, переполнение банка (самый полный — BANK8, 15376 из
16384), утечка манипуляторов (`_fd_open_count` = 1) и утечка ссылок на
IM2-таблицу (`_irq_refs` = 2, как задумано).
**Как искали.** Бисекция ключом `HOF=0/2/4/5` (пропустить таблицу целиком
/ только `hof_load` / без фона / без фейдов) сузила место до
`hof_draw_rows`, а точную инструкцию дала ТРАССИРОВКА MAME на узком
участке: `trace` включалась брейкпоинтом на входе в `pop_hof_show`
(0x44AB) и выключалась на процедуре завершения процесса DSS (0x1E56).
`history` для этого не годится — её 250 записей забиваются обработчиком
прерываний, пока процессор ползёт по мусору.
**Корень.**
```
CD42: ld hl,$B8AB ← _gfx_bank
CD45: ld (hl),$58 ← gfx_set_bank(GFX_BANK_TRANSPARENT)
CD47: call $E503 ← ПРЯМОЙ вызов; по карте 0009E503 = _pop_text_map, банк 9
E503: rst $38 ← а в W3 стоит банк 10, там пустой хвост (0xFF)
```
`pop_ui.h` объявлял группу `pop_text_*_mapped` БЕЗ `__banked`. Пока
`pop_hof.c` лежал в банке 9 рядом с `pop_ui.c`, прямой `call` был верен.
Когда `pop_hof`/`pop_config`/`pop_pal` перенесли в банк 10 ради разгрузки
банка 9, тот же `call` стал уходить в пустоту. Процессор полз по 0xFF до
0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс: закрывает его
файлы, возвращает контекст (байт 0x1E48: 2 = шелл, 3 = наша программа) и
восстанавливает страницы родителя. Именно это выглядело как «W2 подменили
у нас под ногами» — на самом деле подмена была уже уборкой трупа.
**Фикс.** Группа помечена `__banked`; появилась
`toolchain/check_bank_calls.py`, встроенная в `app.mk` и валящая сборку
(проверено намеренной поломкой). Правило записано в `CLAUDE.md`.
---
<a id="final-hof-garbage"></a>
## FINAL-HOF-GARBAGE. Таблица рекордов заливалась знаками вопроса
**Симптом.** После фикса FINAL-BANKCALL экран HOF рисовался правильно, но
с началом ввода имени строку заливало `?` во всю ширину.
**Корень — вторая мина того же класса, созданная первым фиксом.** Курсор
рисовался литералом:
```c
pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); /* ___str_3 в _BANK10 */
```
Пока `pop_text_draw_mapped` был в том же банке, литерал был виден. После
пометки `__banked` трамплин на время вызова переключает W3 на банк 9 —
указатель показывает в чужой банк, функция читает оттуда байты до первого
нуля и рисует их знаками вопроса. Сходится и с наблюдением пользователя
«сначала рисуется правильно, потом мусор»: строка времени берётся из
локального массива (стек, W2 — виден всем), а мусор начинается с мигания
курсора.
**Фикс.** Курсор строится на стеке. Вторая проверка
`check_bank_calls.py` предупреждает о таких случаях (проверено: на сборке
с возвращённым литералом даёт ровно одно срабатывание).
---
<a id="kbd-arrow-phantom"></a>
## KBD-ARROW-PHANTOM. Залипшая стрелка вешала ожидания насмерть
**Симптом.** После игры (особенно 14-й уровень — он почти весь проходится
удержанием Left) текстовые экраны переставали прерываться, а «новая игра»
вставала намертво: `pop_new_game_load` начинается с
`while (kbd_raw_any_down())`. При этом ввод имени в HOF работал —
он опрашивает конкретные коды, а не всю карту.
**Корень (подтверждён дважды: 0x72 24.08, 0x6B 27.08).** Стрелка идёт по
проводу как `E0 6B`. При переполнении 3-байтового FIFO SIO теряется
префикс, make садится в PLAIN-половину карты как код нумпада (0x6B = KP4),
а break приходит уже с префиксом и снимает бит в EXT-половине. PLAIN-бит
остаётся зажатым навсегда, `kbd_raw_any_down()` отвечает «да» вечно.
Доказано вмешательством: запись 0 в этот байт отладчиком мгновенно
перевела состояние 5 (LEVEL_LOAD) → 6 (PLAYING).
**Фикс.** `kbd_raw_keypad_as_ext()` в libc: `kbd_raw_sync()` переносит
биты голых кодов нумпада в EXT-половину и гасит в PLAIN — make и break
начинают работать с одним битом. В PS/2 это и есть одни и те же
физические клавиши, поэтому нумпад заодно стал управлением (7/8/9, 4/6,
2 и 5 — вниз). НЕ лечит обратный случай (префикс потерян у break) — там
бит стоит уже в EXT и выглядит как реально зажатая клавиша.
---
<a id="f10-gameplay"></a>
## F10-GAMEPLAY. Выход по F10 не работал в игре
F10 проверяется в idle-хуке, а хук висит на `gfx_wait_vsync`. Когда
игровой цикл перевели на пейсинг по лучу, он стал ждать через
`pop_wait_edge`, который звал `kbd_raw_poll` напрямую — хук не вызывался,
и F10 в геймплее умер (в заставках и меню работал). Фикс: `pop_wait_edge`
зовёт тот же `pop_idle`.