diff --git a/applications/PoP/roomtest/Makefile b/applications/PoP/roomtest/Makefile
index eb86225..d8b2841 100644
--- a/applications/PoP/roomtest/Makefile
+++ b/applications/PoP/roomtest/Makefile
@@ -35,7 +35,7 @@ MEMORY ?= huge # small-раскладка + банки кода в W
# ВАЖНО: любое сравнение занятости банков имеет смысл только при ОДНОМ и том
# же ALLOCS — иначе сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 3000
-EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c $(PROF_FLAGS)
+EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c --bank 8=roomtest_cold.c $(PROF_FLAGS)
# Профилирование полосами бордюра (см. PROF() в roomtest.c) — ВКЛЮЧЕНО по
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
@@ -112,4 +112,4 @@ $(KID_DATA): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py
kid_data.h $(KID_BIN): $(TC)/pop_extract_kid_data.py
cd $(TC) && python3 pop_extract_kid_data.py
-$(EXAMPLE).exe: guards.c pop_guard.c pop_guard.h pop_cdraw.c pop_cdraw.h pop_draw.c _pop_draw.h pop_tile.c pop_tile.h pop_state.c pop_state.h pop_cheat.h pop_bg.c pop_bg.h pop_room.c _pop_bg.h pop_geom.c pop_geom.h pop_redraw.c pop_redraw.h pop_kid.c pop_kid.h pop_ctrl.c pop_ctrl.h pop_map.c pop_map.h pop_level.c pop_level.h pop_trob.c pop_trob.h kid_data.h $(BG_DATA) $(KID_DATA) $(KID_BIN) $(LVL_DATA)
+$(EXAMPLE).exe: roomtest_cold.c roomtest_cold.h guards.c pop_guard.c pop_guard.h pop_cdraw.c pop_cdraw.h pop_draw.c _pop_draw.h pop_tile.c pop_tile.h pop_state.c pop_state.h pop_cheat.h pop_bg.c pop_bg.h pop_room.c _pop_bg.h pop_geom.c pop_geom.h pop_redraw.c pop_redraw.h pop_kid.c pop_kid.h pop_ctrl.c pop_ctrl.h pop_map.c pop_map.h pop_level.c pop_level.h pop_trob.c pop_trob.h kid_data.h $(BG_DATA) $(KID_DATA) $(KID_BIN) $(LVL_DATA)
diff --git a/applications/PoP/roomtest/NEXT_SESSION.md b/applications/PoP/roomtest/NEXT_SESSION.md
index be622bf..1ac6e1e 100644
--- a/applications/PoP/roomtest/NEXT_SESSION.md
+++ b/applications/PoP/roomtest/NEXT_SESSION.md
@@ -1,4 +1,4 @@
-# Точка входа для следующей сессии (обновлено 2026-08-12)
+# Точка входа для следующей сессии (обновлено 2026-08-12, вечер)
Файл для старта с чистого контекста: где всё стоит, что делать первым, какие
грабли уже собраны. Читать целиком — он короткий. Дальше по ссылкам:
@@ -6,39 +6,37 @@
---
-## 1. СЛЕДУЮЩАЯ ЗАДАЧА: живая проверка зелий в MAME
+## 1. СЛЕДУЮЩАЯ ЗАДАЧА: smoke-прогон уровня 8
-Код зелья медленного падения НАПИСАН (все шесть шагов плана
-[`TASKS_OPEN.md#l7-feather`](TASKS_OPEN.md#l7-feather)) и закрыт хост-тестом,
-но в MAME ещё не смотрелся. Проверить две сцены:
+Зелья приняты пользователем (уровень 2 — «−HP», уровень 7 — перо), уровень 7
+считается прошедшим smoke. Спецсобытие уровня 8 —
+[L8-MOUSE](TASKS_CLOSED.md#l8-mouse) — **закрыто 2026-08-12**: мышь
+портирована, покрыта хост-набором `t_mouse` (17 проверок) и проверена живьём в
+MAME (появилась, нажала кнопку (0,7) комнаты 16, решётка (0,3) поехала вверх,
+ушла и погасла). Новых тайлов уровень 8 не приносит вовсе.
-- **уровень 7, комната 1** — зелье пера: зелёные пузырьки, зелёная вспышка
- при питье, спуск в шахту заметно медленнее обычного и без урона;
-- **уровень 2, комната 13, тайл (1,3)** — зелье «−HP»: пузырьки СИНИЕ (были
- красные — цвет по типу зелья не был портирован вовсе), HP убавляется на
- единицу, экран краснеет ровно один раз.
+Осталось пройти сам уровень: он длинный, с возвратом через два чомпера
+комнаты 4, и стражи там сильнее прежних — комната 5 держит **skill 7**
+(максимум из встречавшихся), комнаты 12/22/24 — skill 2/3/3.
-Заодно попадают под проверку `pop_flash_color` (жёлтый/красный/зелёный) и
-атлас `pop_pot` (23 → 37 спрайтов: добавлены зелёный и синий наборы кадров
-пузырька).
+Правило появления мыши (сверено с оригиналом, детали в
+[TASKS_CLOSED](TASKS_CLOSED.md#l8-mouse)): счётчик — сам `leveldoor_open`,
+растёт только пока Кид в комнате 16, срабатывание ровно на 150, **второго
+раза нет**; сбрасывает только рестарт уровня. Тупика это не создаёт — низом
+через провал есть путь к двери уровня ценой 1 HP.
-**Зацеп в падении — закрыт 2026-08-12**
-([GRAB-BELOW-ROOM](BUGS_CLOSED.md#grab-below-room)). Оказалось, что зацеп
-из падения был жив, а сломан был спуск через НИЖНЮЮ кромку комнаты (уровень 7,
-комната 14): `do_fall` не пускал ряд персонажа до 3, и `check_grab` на этом
-отрезке не звался ни разу. Плюс восстановлена ветка `ACT_MIDAIR` (кадры
-102..105 — начало падения). Проверено хост-тестом `t_grab` и живьём в MAME.
+Резидент разгружен ([MEM-COLD1](TASKS_CLOSED.md#mem-cold1)): холодная половина
+главного цикла уехала в **банк 8** (`roomtest_cold.c`), куча 879 → **1707 Б**.
+Следующие кандидаты — [MEM-COLD2](TASKS_OPEN.md#mem-cold2).
-Осталась ощущенческая часть — [GRAB-KBD-TIMING](BUGS_OPEN.md#grab-kbd-timing):
-зацеп при входе на уровень 7 срабатывает менее стабильно, чем в оригинале
-(подозрение на клавиатурный модуль и на общий темп игры, `L1-SPEED`).
-**Решение пользователя: разбирать после того, как будут готовы все уровни.**
+Ощущенческий хвост прежний — [GRAB-KBD-TIMING](BUGS_OPEN.md#grab-kbd-timing),
+разбирается после готовности всех уровней (решение пользователя).
## 2. Состояние репозитория
-Всё закоммичено, `main` чист. `tests-host`: 5 наборов проходят
-(`[char] 65`, `[geom] 3144`, `[grab] 55`, `[phys] 1727`, `[wall] 1`).
-`make size-check`: OK (65 программ, роста нет).
+Всё закоммичено, `main` чист. `tests-host`: **6 наборов** проходят
+(`[char] 65`, `[geom] 3144`, `[grab] 55`, `[mouse] 17`, `[phys] 1727`,
+`[wall] 1`). `make size-check`: OK (65 программ, роста нет).
Коммиты 2026-08-12:
@@ -55,10 +53,17 @@
## 3. Состояние окружения
- **MAME запущена с мостом**, на HDD — roomtest, собранный с
- `-DFIRST_LEVEL=7`. Пересобрать образ: `make hdd` (или
- `make PROF_FLAGS="-DPROF_BORDER=1 -DFIRST_LEVEL=N" hdd` — при смене
- `FIRST_LEVEL` нужен `touch roomtest.c`, иначе не переедет), после чего
- MAME **обязана** пройти полный рестарт (memory `mame_hdd_rebuild_restart`).
+ `-DFIRST_LEVEL=8`. **ПРАВИЛО ПОЛЬЗОВАТЕЛЯ: перед сборкой образа —
+ `make clean`, потом `make`, потом `make hdd`** (memory
+ `mame_build_clean_rule`). Без этого `make hdd` кладёт на диск ПРОШЛУЮ
+ сборку: `FIRST_LEVEL` живёт внутри `roomtest.c`, и без изменения исходника
+ make считает `.exe` актуальным — 2026-08-12 на этом уехал уровень 1 вместо
+ 8, заметил пользователь. После пересборки образа MAME **обязана** пройти
+ полный рестарт (memory `mame_hdd_rebuild_restart`).
+- Адреса состояния сместились после MEM-COLD1: брать из
+ `.sprinter-cc-roomtest/roomtest.map` заново (`cur_room` 0xA33D,
+ `pop_leveldoor_open` 0xA34C, `Kid` 0xA3B1, `Guard` 0xA759 — на сборку
+ 2026-08-12). Мост: `setmem ADDR HEXBYTES` (не `write`), `mem ADDR N`.
- **Остановка MAME — `mame/v306/bridge_cmd.sh 'cmd exit'`**, НЕ `kill`
(правило пользователя). `pkill -f` бывает не срабатывает, и остаются ДВА
инстанса, оба отвечающих на файловый IPC — тогда скриншоты приходят от
diff --git a/applications/PoP/roomtest/TASKS_CLOSED.md b/applications/PoP/roomtest/TASKS_CLOSED.md
index cd4cb0d..6ab3cb7 100644
--- a/applications/PoP/roomtest/TASKS_CLOSED.md
+++ b/applications/PoP/roomtest/TASKS_CLOSED.md
@@ -11,6 +11,103 @@
---
+## Уровни: спецсобытия
+
+
+### L8-MOUSE. Мышь уровня 8 — ЗАКРЫТА 2026-08-12
+
+Спецсобытие комнаты 16: кнопка (0,7) открывает решётку (0,3), но с левой
+площадки верхнего ряда до кнопки не дойти — между ними провал в три колонки.
+Когда дверь уровня уже открыта, а Кид всё ещё в комнате 16, оригинал через
+150 кадров присылает мышь: она пробегает справа налево, наступает на кнопку и
+убегает. Порт по SDLPoP: `do_timers` seg003:0545, `do_mouse` seg003:0ABA,
+`autocontrol_mouse` seg002:07EB.
+
+**Ассетов и отрисовки не потребовалось вовсе.** Кадры 186..188 лежат в
+таблице КИДА (`load_frame` seg006:0293 — общий case), их images 130..132 уже
+упакованы в `kid16.atl` (idx 2/3/4: 17×4, 19×4, 12×10) — `pop_pack_kid.py`
+берёт все `res(401+N).png`, что есть в `data/KID`. Отрисовка подхватывает
+мышь сама: `pop_frame_tbl_is_guard(24, …)` отдаёт 0, значит и таблица кадров,
+и набор атласов берутся кидовские, и палитра (0x70) тоже.
+
+Что написано:
+
+| где | что |
+|---|---|
+| `guards.c` | `pop_check_mouse` (условие + рождение), `autocontrol_mouse`, ветки мыши в `autocontrol_opponent` / `play_guard` / `pop_check_can_guard_see_kid` |
+| `pop_state.c/h` | `pop_leveldoor_open` стал **word**, как в оригинале (data.h:361): в него же считается задержка, байт переполнился бы через 255 кадров и обнулил признак «дверь открыта» |
+| `pop_guard.c` | `leave_guard` не записывает в данные уровня тень и мышь (seg002:02F5) — раньше эта проверка отсутствовала вовсе |
+| `pop_cdraw.c` | полосы HP у мыши нет (`draw_guard_hp`, seg000:1159) |
+| `pop_tune.h` | числа события (уровень/комната/задержка/пороги X) |
+
+**Правило появления — мышь ОДНОРАЗОВАЯ (сверено с оригиналом).** Счётчик
+живёт в `leveldoor_open`: 0 на старте уровня (seg003:97), 1 — когда створка
+двери уровня доехала доверху (seg007:456, то есть Кид нажал кнопку в комнате
+3), дальше `++` каждый кадр, пока Кид в комнате 16, и вызов ровно при
+`== 150`. Отсюда три следствия:
+
+- второго раза НЕТ: счётчик растёт дальше, а сравнение строгое. Не успел
+ пройти решётку, пока она открыта — мышь больше не придёт;
+- выход из комнаты 16 счётчик не сбрасывает, а ЗАМОРАЖИВАЕТ: ушёл на сотом
+ кадре, вернулся — ждать осталось пятьдесят;
+- обнуляет только `start_level`, то есть смерть/рестарт уровня — но он же
+ возвращает тайлы и модификаторы, так что дверь придётся открывать заново.
+
+Значения `0x4D` (музыка уровней 4 и 6) и `2` (победа над Джаффаром, уровень
+13) в ту же переменную к уровню 8 отношения не имеют — там счёт идёт
+монотонно 1 → 150.
+
+**Чего в условии НЕТ (проверено грепом по всем упоминаниям `mouse_level` /
+`mouse_room` / `do_mouse` — единственная точка запуска это seg003:545):**
+оригинал не смотрит ни на состояние решётки (0,3), ни на то, с какой стороны
+провала стоит Кид, ни на то, прошёл ли он её уже. «Кид заперт за закрытой
+решёткой» получается САМО: находиться в комнате 16 двенадцать с половиной
+секунд можно только топчась там, а стоит уйти в комнату 4 — счётчик
+замирает. Обратная сторона той же простоты: вернувшись в комнату 16 с ЛЮБОЙ
+стороны, игрок доигрывает накопленное ожидание и может получить мышь, даже
+если решётка в этот момент открыта.
+
+Тупика при этом нет: провал в колонках 4..6 ведёт на нижний ряд комнаты
+(`debris` между колоннами, а колонна — проходимый пол, seg006:0628), оттуда
+низом через комнату 4 с шипами есть путь к двери уровня в комнате 3. Цена —
+падение на два ряда, то есть −1 HP. Мышь экономит эту единицу, а не спасает
+от тупика.
+
+**Тонкость, на которой легко сломать уход:** `seq_107` прыгает на метку
+`Mscurry1`, стоящую ПОСЛЕ опкода `act(actions_1_run_jump)` в `seq_105` — на
+обратном пути кадры бега проигрываются, а `action` остаётся стоечным. Именно
+этим оригинал отличает «бежит к кнопке» от «убегает»: условие исчезновения
+висит на `action == 0`.
+
+**Проверено.** Хост-набор `t_mouse` (17 проверок, единственный, куда
+линкуется `guards.c`) — рождение по таймеру, замороженный счётчик вне комнаты,
+требование открытой двери, нажатие кнопки на пробеге, разворот и исчезновение,
+невидимость для Кида, необращение в «стража комнаты». Регресс проверен
+откатом: без ветки в `autocontrol_opponent` падают две проверки ухода.
+Живьём в MAME (комната 16 уровня 8, `leveldoor_open` взведён через мост):
+мышь появилась (`charid = 0x18`), добежала до колонки 7 (кадр 188, x = 164),
+решётка (0,3) поехала вверх, мышь ушла вправо и погасла (`direction = 0x56`).
+
+
+### MEM-COLD1. Холодная половина главного цикла — в банк 8 (2026-08-12)
+
+К уровню 8 куча в режиме huge упала до **879 Б**. Убирать из `roomtest.c`
+оказалось нечего — отладочное там всё живое (обход комнат, стоп-кадр, читы,
+профиль), — поэтому вынесли в банк то, что отрабатывает раз на уровень или
+только по клавише: `pop_start_level` + `find_start_level_door`, чит-навигацию
+по комнатам и лейбл номера комнаты (`roomtest_cold.c`, `--bank 8`, `n_banks`
+поднят до 8).
+
+Результат: `_CODE` 25653 → 24825, **куча 879 → 1707 Б**, банк 8 занят на 5 %.
+Кадровый путь не тронут: за обычный кадр в банк 8 уходит ноль вызовов, при
+смене комнаты — один трамплин.
+
+Что осталось в резиденте намеренно: `enter_room_side` и рабочие массивы
+комнаты (их читает и главный цикл, и холодная половина), `guard_over_kid` с
+хелперами (зовётся каждый кадр). Следующий шаг — [MEM-COLD2](TASKS_OPEN.md#mem-cold2).
+
+---
+
## Приёмка уровней
diff --git a/applications/PoP/roomtest/TASKS_OPEN.md b/applications/PoP/roomtest/TASKS_OPEN.md
index 3f8c6de..b08eef1 100644
--- a/applications/PoP/roomtest/TASKS_OPEN.md
+++ b/applications/PoP/roomtest/TASKS_OPEN.md
@@ -35,7 +35,38 @@
---
-## ТЕКУЩАЯ ЦЕЛЬ: уровень 5
+## ТЕКУЩАЯ ЦЕЛЬ: уровень 8
+
+Уровни 1-7 играются (smoke; зелья уровней 2 и 7 приняты пользователем
+2026-08-12). Уровень 8 **новых тайлов не приносит вовсе** (инвентарь
+`res2008.bin` целиком покрыт уровнями 1-7), тайлсет — подземелье.
+Спецсобытие уровня — мышь, [L8-MOUSE](TASKS_CLOSED.md#l8-mouse) закрыта
+2026-08-12 (хост-набор `t_mouse` + живая проверка в MAME).
+
+Остаётся smoke-прогон самого уровня: он длинный и с возвратом через два
+чомпера комнаты 4, а стражи там сильнее прежних (комната 5 — skill **7**,
+это максимум из встречавшихся; 12/22/24 — skill 2/3/3).
+
+
+### MEM-COLD2. Второй шаг разгрузки резидента
+
+После [MEM-COLD1](TASKS_CLOSED.md#mem-cold1) куча 1707 Б. Когда упрётся
+снова, в банк 8 переезжают следующие кандидаты (в порядке выгоды):
+
+1. `enter_room_side` — самая большая функция `roomtest.c`, зовётся раз на
+ комнату. Требует вынести наружу рабочие массивы комнаты
+ (`room_bg`/`lcol_*`/`rcol_*`/`below_fg`/`above_*`) — сами данные останутся
+ в `_DATA`, уедет только код;
+2. `guard_over_kid` + `objtile_at_char`/`char_x_left_of`/`tile_div_mod` —
+ зовётся КАЖДЫЙ кадр, но ровно один раз: цена выноса — один трамплин;
+3. блок редроя левого шва в `main` (`seam_row_sig` + разбор сигнатуры).
+
+Если и этого не хватит — следующим уходит сам кадровый порядок отрисовки,
+но тогда уже стоит мерить, а не выносить наугад.
+
+---
+
+## ПРЕДЫДУЩАЯ ЦЕЛЬ: уровень 5
Уровни 1-4 играются (smoke). Дальше идём по порядку уровней; уровень 5 —
следующий.
diff --git a/applications/PoP/roomtest/guards.c b/applications/PoP/roomtest/guards.c
index d2dd3e5..ee35b36 100644
--- a/applications/PoP/roomtest/guards.c
+++ b/applications/PoP/roomtest/guards.c
@@ -98,6 +98,10 @@ void pop_check_can_guard_see_kid(void) __banked
int16_t left_pos, right_pos;
uint8_t tile;
+ /* МЫШЬ Кида «не видит» (seg003:0965): иначе он выхватит меч на зверька,
+ * пробегающего мимо. */
+ if (Guard.charid == CHARID_24_MOUSE) { can_guard_see_kid = 0; return; }
+
/* Первый множитель — порт seg003:702. ТЕНЬ боевая только на 12-м
* уровне; на 4/5/6 она Кида «не видит», и это не косметика: увидев её,
* Кид достаёт меч (control_standing, seg005:352), а сама тень идёт той
@@ -198,6 +202,50 @@ void pop_check_skel(void) __banked
is_guard_notice = guard_refrac = 0;
}
+/* ---- Мышь: спецсобытие уровня 8 (do_timers seg003:0545 + do_mouse) --- *
+ *
+ * Кнопка (0,7) комнаты 16 открывает решётку (0,3), но с левой площадки
+ * верхнего ряда до кнопки не дойти — между ними провал в три колонки.
+ * Поэтому, когда дверь уровня уже открыта, а Кид всё ещё в комнате 16,
+ * оригинал через POP_MOUSE_DELAY кадров присылает мышь: она пробегает
+ * справа налево, наступает на кнопку (кнопку жмёт ОБЩИЙ check_press из
+ * физики соперника, как и любой персонаж) и убегает обратно.
+ *
+ * Счётчик — сам pop_leveldoor_open, как в оригинале: он растёт ТОЛЬКО пока
+ * Кид в комнате 16, поэтому «12,5 секунды» отсчитываются с момента, когда
+ * игрок туда вернулся, а не с открытия двери. Ради этого он и word
+ * (см. pop_state.c).
+ *
+ * Слот соперника занимаем БЕЗ проверки «свободен ли» — так в оригинале, и
+ * на уровне 8 конфликта нет: в данных комнаты 16 стража нет
+ * (guards_tile[15] = 30 = «пусто»). */
+#define SEQ_105_MOUSE_FORWARD 105
+#define SEQ_107_MOUSE_STAND_UP_GO 107
+
+void pop_check_mouse(void) __banked
+{
+ if (pop_current_level != POP_MOUSE_LEVEL) return;
+ if (Kid.room != POP_MOUSE_ROOM) return;
+ if (!pop_leveldoor_open) return;
+ if (++pop_leveldoor_open != POP_MOUSE_DELAY) return;
+
+ /* do_mouse (seg003:0ABA) поднимает мышь В ОКНЕ Char, начиная с КИДА
+ * (loadkid): поля, которых нет в списке ниже — frame/action/room/
+ * fall_x/fall_y — наследуются от него. Комната как раз оттуда и
+ * берётся, отдельного присваивания в оригинале нет. */
+ pop_loadkid();
+ Char.charid = CHARID_24_MOUSE;
+ Char.x = POP_MOUSE_START_X;
+ Char.curr_row = 0;
+ Char.y = (uint8_t)pop_y_land[1];
+ Char.alive = -1;
+ Char.direction = -1; /* dir_FF_left */
+ guardhp_curr = 1;
+ pop_char_set_seq(SEQ_105_MOUSE_FORWARD);
+ play_seq();
+ pop_saveshad();
+}
+
/* ---- Тень: спецсобытие входа в комнату (check_shadow, seg002:0064) -- *
*
* На «шадоу»-уровнях слот соперника занимает ТЕНЬ, и занимает его ВМЕСТО
@@ -638,6 +686,26 @@ static void autocontrol_shadow_level6(void)
move_1_forward();
}
+/* autocontrol_mouse (seg002:07EB). Весь «ИИ» мыши — два порога по X:
+ * добежала влево до POP_MOUSE_TURN_X → seq_107 (встать, поднять голову,
+ * развернуться и уйти), вернулась вправо до POP_MOUSE_LEAVE_X → исчезнуть.
+ *
+ * Почему проверка на исчезновение висит на action == 0 (стойка), хотя мышь
+ * при этом БЕЖИТ: seq_107 заканчивается прыжком на метку `Mscurry1`, которая
+ * стоит ПОСЛЕ опкода act(actions_1_run_jump) в seq_105 — то есть на обратном
+ * пути кадры бега проигрываются, а action остаётся стоечным. Именно этим
+ * оригинал и различает «бежит туда» и «бежит обратно». */
+static void autocontrol_mouse(void)
+{
+ if (Char.direction == DIR_56_NONE) return;
+ if (Char.action == 0) {
+ if (Char.x >= POP_MOUSE_LEAVE_X) clear_char();
+ } else if (Char.x < POP_MOUSE_TURN_X) {
+ pop_char_set_seq(SEQ_107_MOUSE_STAND_UP_GO);
+ play_seq();
+ }
+}
+
/* autocontrol_shadow (seg002:081D) — по уровням. Ветка 12 появится со своим
* уровнем. */
static void autocontrol_shadow(void)
@@ -658,6 +726,8 @@ static void autocontrol_opponent(void)
/* autocontrol_skeleton (seg002:685): у скелета меч вынут ВСЕГДА — он не
* бывает «неактивным», поэтому ветка inactive ему недоступна. */
if (Char.charid == CHARID_4_SKELETON) Char.sword = SWORD_2_DRAWN;
+ /* МЫШЬ — своей веткой (seg002:0643), первой в цепочке charid'ов. */
+ if (Char.charid == CHARID_24_MOUSE) { autocontrol_mouse(); return; }
/* ТЕНЬ идёт своей веткой ЦЕЛИКОМ (seg002:0647): к стражьему ИИ она не
* сводится — на уровне 4 не дерётся вовсе. */
if (Char.charid == CHARID_1_SHADOW) { autocontrol_shadow(); return; }
@@ -780,6 +850,11 @@ void pop_check_sword_hurt(void) __banked
* доигрывает последовательность. */
static void play_guard(void)
{
+ /* МЫШЬ (seg006:0D85, первая ветка): ей положен ТОЛЬКО автоконтроль.
+ * Диспетчер control() ей не нужен и вреден — он бы гонял её по
+ * стоечным веткам управления, а вся её «жизнь» уже описана
+ * последовательностями seq_105/107. */
+ if (Char.charid == CHARID_24_MOUSE) { autocontrol_opponent(); return; }
if (Char.alive < 0 && guardhp_curr == 0) {
Char.alive = 0;
/* on_guard_killed (seg006:1927) — у нас пока только звук победы,
diff --git a/applications/PoP/roomtest/pop_cdraw.c b/applications/PoP/roomtest/pop_cdraw.c
index 98a2186..a4c8af6 100644
--- a/applications/PoP/roomtest/pop_cdraw.c
+++ b/applications/PoP/roomtest/pop_cdraw.c
@@ -807,8 +807,10 @@ void pop_hp_draw(void) __banked
for (i = 0; i < hitp_curr && i < HP_MAXDRAW; i++)
pop_kid_img_blit(HP_KID_FULL, (int)i * HP_STEP, HP_Y + POP_YOFF);
- /* Страж: только полные деления, справа налево. Скелет полосы не имеет. */
- if (g_ok && Guard.charid != 0 && Guard.charid != CHARID_4_SKELETON && gd) {
+ /* Страж: только полные деления, справа налево. Скелет полосы не имеет,
+ * мышь (спецсобытие уровня 8) — тоже: draw_guard_hp, seg000:1159. */
+ if (g_ok && Guard.charid != 0 && Guard.charid != CHARID_4_SKELETON &&
+ Guard.charid != CHARID_24_MOUSE && gd) {
const uint8_t *img = (const uint8_t *)atlas_image(&gp[0], 0);
uint16_t w, h;
gfx_w0_map(gp[0].page);
diff --git a/applications/PoP/roomtest/pop_guard.c b/applications/PoP/roomtest/pop_guard.c
index d638594..1af736f 100644
--- a/applications/PoP/roomtest/pop_guard.c
+++ b/applications/PoP/roomtest/pop_guard.c
@@ -185,6 +185,10 @@ void pop_guard_load_frame(void)
void pop_guard_leave(void)
{
if (Guard.charid == 0) return; /* стража не было */
+ /* Тень и МЫШЬ в данные уровня не записываются (seg002:02F5): они не
+ * «стражи комнаты», а спецсобытия — вернувшись, их надо получить заново
+ * от check_shadow / check_mouse, а не поднять из guards_tile. */
+ if (Guard.charid == CHARID_1_SHADOW || Guard.charid == CHARID_24_MOUSE) return;
pop_guard_state_save(Guard.room,
(uint8_t)(Guard.curr_row * 10), /* get_tilepos(0,row) */
Guard.direction, Guard.x, guard_skill,
diff --git a/applications/PoP/roomtest/pop_guard.h b/applications/PoP/roomtest/pop_guard.h
index e3c3fe0..0ac1d57 100644
--- a/applications/PoP/roomtest/pop_guard.h
+++ b/applications/PoP/roomtest/pop_guard.h
@@ -68,6 +68,12 @@
#define CHARID_2_GUARD 2
#define CHARID_4_SKELETON 4
#define CHARID_5_PRINCESS 5
+/* МЫШЬ (спецсобытие уровня 8) — тоже персонаж слота Guard: своей таблицы
+ * кадров у неё нет, кадры 186..188 лежат в таблице КИДА (load_frame,
+ * seg006:0293 — общий case с charid_0_kid), а спрайты (images 130..132) —
+ * в атласах Кида, страница kid16.atl. Поэтому отрисовка её подхватывает
+ * без единой правки: pop_frame_tbl_is_guard(24,…) отдаёт 0. */
+#define CHARID_24_MOUSE 24
/* dir_56_none (types.h:402): «направления нет» — им clear_char убирает
* персонажа со сцены. Значение 0x56 в int8_t = 86, то есть заведомо не
@@ -191,6 +197,12 @@ void pop_check_can_guard_see_kid(void) __banked;
* в кадр, ДО логики персонажей (порядок play_frame, seg000:868). */
void pop_check_skel(void) __banked;
+/* Спецсобытие уровня 8 — МЫШЬ (do_timers seg003:0545 + do_mouse seg003:0ABA).
+ * Условие запуска: дверь уровня открыта И Кид в комнате 16; тогда счётчик
+ * pop_leveldoor_open растёт кадрами, и на 150-м мышь появляется у правого
+ * края верхнего ряда. Звать раз в кадр рядом с pop_check_skel. */
+void pop_check_mouse(void) __banked;
+
/* check_shadow (seg002:0064) — СПЕЦСОБЫТИЕ входа в комнату: на «шадоу»-
* уровнях слот соперника занимает ТЕНЬ, а не страж из данных. Звать ВМЕСТО
* pop_guard_enter при входе в комнату; вернула 1 — событие обработано и
diff --git a/applications/PoP/roomtest/pop_state.c b/applications/PoP/roomtest/pop_state.c
index 75f1680..615f83b 100644
--- a/applications/PoP/roomtest/pop_state.c
+++ b/applications/PoP/roomtest/pop_state.c
@@ -27,8 +27,13 @@ uint8_t pop_droppedout;
/* leveldoor_open (seg007:456): дверь уровня доехала до верха. Ставит
* анимация двери (pop_trob, банк 6), читает спецсобытие подъёма скелета
* (guards.c, банк 1) — снова случай «данные через границу банков».
- * Сбрасывается стартом уровня (seg003:97). */
-uint8_t pop_leveldoor_open;
+ * Сбрасывается стартом уровня (seg003:97).
+ *
+ * ШИРИНА — word, как в оригинале (data.h:361), а не byte: на уровне 8
+ * do_timers СЧИТАЕТ этой же переменной время до появления мыши
+ * (`++leveldoor_open` каждый кадр, пока Кид в комнате 16), и байт
+ * переполнился бы через 255 кадров, обнулив признак «дверь открыта». */
+uint16_t pop_leveldoor_open;
/* ---- Шина управления (seg006 globals) ------------------------------ *
* Объявлены в pop_ctrl.h, а ЛЕЖАТ здесь: к ним обращаются ТРИ банка —
diff --git a/applications/PoP/roomtest/pop_state.h b/applications/PoP/roomtest/pop_state.h
index 6347e4e..29e3ff1 100644
--- a/applications/PoP/roomtest/pop_state.h
+++ b/applications/PoP/roomtest/pop_state.h
@@ -43,8 +43,10 @@ extern int pop_leveldoor_ybottom;
extern uint8_t pop_droppedout;
/* leveldoor_open (seg007:456): 1 — дверь уровня открыта полностью. Пишет
- * анимация двери (банк 6), читает подъём скелета (банк 1). */
-extern uint8_t pop_leveldoor_open;
+ * анимация двери (банк 6), читает подъём скелета (банк 1). Word, как в
+ * оригинале: на уровне 8 в него же СЧИТАЕТСЯ задержка до мыши (см.
+ * pop_state.c и pop_check_mouse в guards.c). */
+extern uint16_t pop_leveldoor_open;
/* Отладочная «пустышка» для брейкпоинтов из банков — см. pop_state.c.
* Звать из отлаживаемого места под нужным `if`, брейкпоинт ставить на
diff --git a/applications/PoP/roomtest/pop_tune.h b/applications/PoP/roomtest/pop_tune.h
index 57e9a5d..a454396 100644
--- a/applications/PoP/roomtest/pop_tune.h
+++ b/applications/PoP/roomtest/pop_tune.h
@@ -90,6 +90,25 @@
* (TASKS_OPEN.md, L1-SPEED), а не свойство зелья. */
#define POP_FEATHER_FRAMES 225
+/* ---- Спецсобытие «МЫШЬ» (уровень 8, комната 16) ---------------------- *
+ * Кнопка (0,7) комнаты 16 открывает решётку (0,3) — ту, за которой лежит
+ * путь через комнату 4 (два чомпера) к кнопке выхода в комнате 3. Сам Кид
+ * до кнопки с левой площадки не дотягивается (провал в колонках 4..6),
+ * поэтому, когда дверь уровня уже открыта, а он всё ещё в комнате 16,
+ * оригинал через 150 тиков присылает мышь: она пробегает справа налево по
+ * верхнему ряду, наступает на кнопку и убегает обратно (do_timers
+ * seg003:0545, do_mouse seg003:0ABA, autocontrol_mouse seg002:07EB).
+ *
+ * Числа — custom-константы SDLPoP (data.h:821): уровень/комната/задержка/
+ * стартовая X. Пороги разворота (166) и исчезновения (200) — литералы
+ * autocontrol_mouse. Задержка в ИГРОВЫХ КАДРАХ, как POP_FEATHER_FRAMES. */
+#define POP_MOUSE_LEVEL 8
+#define POP_MOUSE_ROOM 16
+#define POP_MOUSE_DELAY 150
+#define POP_MOUSE_START_X 200
+#define POP_MOUSE_TURN_X 166 /* x < этого — развернуться и уйти */
+#define POP_MOUSE_LEAVE_X 200 /* x >= этого в стойке — исчезнуть */
+
/* ---- Уровень зелий (SDLPoP: «potions level», seg006:1894) ------------ *
* На нём синие склянки «вреда» забирают ПОЛОВИНУ запаса HP, а не единицу.
* В оригинале это условие `current_level == 15` прямо в proc_get_object. */
diff --git a/applications/PoP/roomtest/roomtest.c b/applications/PoP/roomtest/roomtest.c
index c9672fb..89186d8 100644
--- a/applications/PoP/roomtest/roomtest.c
+++ b/applications/PoP/roomtest/roomtest.c
@@ -21,6 +21,7 @@
#include "pop_cdraw.h" /* общая отрисовка персонажей (слоты Кид/соперник) */
#include "pop_cheat.h"
#include "pop_tune.h"
+#include "roomtest_cold.h" /* холодная половина цикла — в банке */
__sfr __at (0xFE) io_border;
@@ -54,7 +55,9 @@ __sfr __at (0xFE) io_border;
* оверлеи, кладка), 3 = pop_map (коллизия/физика), 4 = pop_cdraw
* (отрисовка персонажей + полоса HP), 5 = pop_ctrl (диспетчер управления),
* 6 = pop_trob (ловушки/анимация тайлов), 7 = pop_room (ХОЛОДНАЯ половина
- * фона: полная отрисовка комнаты, точечные перерисовки, падающие плиты).
+ * фона: полная отрисовка комнаты, точечные перерисовки, падающие плиты),
+ * 8 = roomtest_cold (ХОЛОДНАЯ половина главного цикла: старт уровня и
+ * отладочная навигация — см. roomtest_cold.h).
* Отрисовка персонажей и холодная половина фона вынесены из банка 2
* отдельно: тот дважды подходил к 16 КБ вплотную, а расти ему ещё есть
* куда — тайлы поздних уровней, чомперы, зеркало.
@@ -64,7 +67,7 @@ __sfr __at (0xFE) io_border;
* останется нулём, и трамплин прыгнет по 0xC000 в мусор (см. шапку
* runtime/bank.s). Проверено 2026-08-05: с n_banks=4 и пятым банком игра
* доходила до конца загрузки ресурсов и вставала намертво в DSS. */
-const uint8_t n_banks = 7;
+const uint8_t n_banks = 8;
/* Режим читов (pop_cheat.h): на время разработки включаем в main. */
uint8_t pop_cheats;
@@ -119,13 +122,14 @@ static void kbd_idle(void)
/* Рабочая копия ТЕКУЩЕЙ комнаты (W2, мутабельная: loose→empty) + срезы
* соседей для кромок. Заполняются pop_room_load из уровня. File-scope,
* чтобы enter_room переключал комнату без длинного списка параметров. */
-static uint8_t room_fg[30], room_bg[30];
+uint8_t room_fg[30];
+static uint8_t room_bg[30];
/* lcol_fg/rcol_fg — ШЕСТЬ записей: [0..2] кромочная колонка соседа (col9/col0),
* [3..5] следующая за ней (col8/col1) для коллизии, когда Kid стоит В шве и
* смотрит наружу (колонка −2 / 11). См. pop_level.h. */
static uint8_t lcol_fg[6], lcol_bg[3], rcol_fg[6], rcol_bg[3], below_fg[11];
static uint8_t above_fg[10], above_mod[10]; /* ряд 2 верхнего соседа (полоса кладки у потолка) */
-static uint8_t cur_room; /* номер ОТРИСОВАННОЙ комнаты (drawn_room) */
+uint8_t cur_room; /* номер ОТРИСОВАННОЙ комнаты (drawn_room) */
static uint8_t kid_room; /* РЕАЛЬНАЯ комната Kid (S1: == cur_room; расходятся в S3 straddle) */
/* Рендер-смещение Kid по логической X (порт xpos_in_drawn_room): комната Kid
@@ -258,10 +262,11 @@ static uint8_t seam_sig[3]; /* последняя нарисованная ope
static uint8_t seam_rows; /* маска рядов, ждущих редроя (переживает 2 кадра) */
static uint8_t seam_redraw; /* оставшиеся кадры редроя (обе страницы) */
-/* Отладочный лейбл номера комнаты — рисуем только при смене (outtextxy тяжёлая):
- * sig = последняя показанная комната, redraw = остаток кадров (2 = обе страницы). */
-static uint8_t room_label_sig = 0xFF;
-static uint8_t room_label_redraw;
+/* checkpoint (seg000:270): взводится, когда Кид уходит ВЛЕВО в комнату 7
+ * уровня 3 (level3_set_chkp, seg002:0665); по нему do_startpos подменяет
+ * стартовую позицию. Сбрасывается началом игры и прохождением уровня —
+ * ровно как в оригинале (seg003:37 и seg003:413). */
+uint8_t pop_checkpoint;
/* Изменения тайлов (упавшая плита → пусто, щебень на месте приземления
* куска, поднятый предмет → пол) пишутся ПРЯМО В УРОВЕНЬ —
@@ -387,7 +392,7 @@ static void enter_room_side(uint8_t room, uint8_t side)
/* Вход в комнату БЕЗ перехода за Кидом: старт уровня, респавн, чит-обход.
* Страж такой смены не «видит» — он остаётся в своей комнате. */
-static void enter_room(uint8_t room) { enter_room_side(room, 0xFF); }
+void enter_room(uint8_t room) { enter_room_side(room, 0xFF); }
/* start_level (seg003:0055) + do_startpos (seg003:01A3) + set_start_pos
* (seg003:028A) — поставить Кида в стартовую позицию УРОВНЯ. Один вход и
@@ -414,109 +419,6 @@ static void enter_room(uint8_t room) { enter_room_side(room, 0xFF); }
#define ENTRY_POSE_RUN 2 /* tbl_entry_pose[13] */
#define TILE_LEVELDOOR_LEFT 16 /* tiles_16_level_door_left */
-/* find_start_level_door (seg003:02E6): дверь уровня В СТАРТОВОЙ КОМНАТЕ —
- * это дверь, через которую Кид ВОШЁЛ, поэтому она открыта полностью
- * (modif = 43) и заводится trob'ом типа 3 (start_level_door, seg007:0D72).
- * Без этого на уровне 2 Кид материализуется внутри глухой закрытой створки:
- * его стартовый тайл (комната 5, ряд 1, колонка 3) — правая половина
- * той самой двери. */
-static void find_start_level_door(uint8_t room)
-{
- uint8_t *m = pop_trob_modif(room);
- uint8_t tp;
- if (!m) return;
- /* Батч-доступ к странице уровня тут не нужен: функция отрабатывает раз
- * за уровень, а pop_add_trob сам лезет в W0. */
- for (tp = 0; tp < 30; tp++) {
- if (pop_level_tile(room, tp) != TILE_LEVELDOOR_LEFT) continue;
- m[tp] = 43; /* створка поднята полностью */
- pop_add_trob(room, tp, 3); /* ...и тут же поедет ВНИЗ (type>=3) */
- }
-}
-
-/* checkpoint (seg000:270): взводится, когда Кид уходит ВЛЕВО в комнату 7
- * уровня 3 (level3_set_chkp, seg002:0665); по нему do_startpos подменяет
- * стартовую позицию. Сбрасывается началом игры и прохождением уровня —
- * ровно как в оригинале (seg003:37 и seg003:413). */
-static uint8_t pop_checkpoint;
-
-static void pop_start_level(void)
-{
- pop_leveldoor_open = 0; /* seg003:97 — дверь прошлого уровня не в счёт */
- pop_feather = 0; /* seg003:189 — перо не переживает старт уровня */
- uint8_t entry_pose = pop_level_entry_pose(pop_current_level);
- uint8_t start_room = pop_level_start_room();
- uint8_t pos = pop_level_start_pos();
- int8_t dir = (int8_t)~pop_level_start_dir();
- uint8_t seq = SEQ_TURN;
- /* Особое событие: старт с чекпойнта (do_startpos, seg003:0141).
- * Числа — в pop_tune.h. Плиту снимаем ПОСЛЕ pop_level_reset_tiles()
- * ниже, иначе рестарт её вернёт. */
- uint8_t chkp = (uint8_t)(pop_current_level == POP_CHKP_LEVEL && pop_checkpoint);
- if (chkp) {
- start_room = POP_CHKP_RESPAWN_ROOM;
- pos = POP_CHKP_RESPAWN_TILEPOS;
- dir = (int8_t)~POP_CHKP_RESPAWN_DIR; /* стартует в ПРОТИВОположную
- * и разворачивается seq_5 */
- }
-
- /* Рестарт уровня = load_level() заново (play_level, seg003:57): в
- * исходное возвращаются И модификаторы (пики/ворота), И сами тайлы —
- * разбитые плиты целы, выпитые зелья на месте (BUG-RESPAWN-1), и
- * убитые стражи снова живы на своих тайлах (BUG-RESPAWN-2). */
- pop_trob_reset();
- pop_level_reset_tiles();
- pop_level_reset_guards();
- pop_guard_reset(); /* start_level: Guard.direction = none */
- pop_loose_mob_reset(); /* start_level: mobs_count = 0 (seg003:88) */
- /* «Особое событие: убрать loose-плиту» (do_startpos): без этого путь в
- * комнату-чекпойнт после респавна остался бы непройденным. */
- if (chkp)
- pop_level_set_tile(POP_CHKP_CLEAR_ROOM,
- (uint8_t)(POP_CHKP_CLEAR_ROW * 10 + POP_CHKP_CLEAR_COL),
- TILE_EMPTY);
-
- /* Кнопку жмём ДО отрисовки комнаты — как в оригинале (do_startpos
- * отрабатывает раньше первого draw_room), иначе шов кадр показывал бы
- * ещё не тронутое состояние решётки. */
- if (entry_pose == ENTRY_POSE_FALL) {
- /* get_tile(5,2,0) + trigger_button(0,0,-1) — «нажать» кнопку room5,
- * col2, row0 её же типом и модификатором (в оригинале и то и другое
- * берётся из curr_* после get_tile). */
- uint8_t tp = 2; /* row0*10 + col2 */
- pop_trigger_button(5, tp, pop_level_tile(5, tp), pop_trob_modif(5)[tp]);
- seq = SEQ_FALL;
- } else if (entry_pose == ENTRY_POSE_RUN) {
- seq = SEQ_RUN;
- }
-
- /* Тоже ДО отрисовки комнаты: дверь, через которую вошли, обязана быть
- * открыта уже в первом кадре (в оригинале find_start_level_door стоит
- * между do_startpos и draw_level_first). */
- find_start_level_door(start_room);
-
- enter_room(start_room);
- kid_init(seq, (int8_t)(pos % 10), (int8_t)(pos / 10), dir);
- /* Спецсобытие «вход падением» (set_start_pos, seg003:0196): на 7-м
- * уровне Кид ставится в комнату 17, а экран тут же переводится на
- * комнату ПОД ней (`goto_other_room(3)`: y −= 189, ряд пересчитать) —
- * он влетает в неё сверху, продолжая падение с шестого уровня. */
- if (pop_current_level == FALLING_ENTRY_LEVEL &&
- start_room == FALLING_ENTRY_ROOM) {
- uint8_t below = pop_room_link(start_room, LINK_DOWN);
- if (below) {
- Kid.y = (uint8_t)(Kid.y - 189);
- Kid.curr_row = pop_y_to_row((int16_t)Kid.y);
- enter_room(below);
- }
- }
- Kid.sword = 0; /* set_start_pos: sword_0_sheathed */
- /* play_level (seg003:106): have_sword = level >= have_sword_from_level
- * (=2) — начиная со второго уровня меч у Кида уже есть. */
- pop_have_sword = (uint8_t)(pop_current_level >= 2);
- pop_kid_hp_reset();
-}
-
int main(void)
{
/* Дабл-буфер: front — видимая страница, back — куда рисуем. dbuf=1
@@ -663,89 +565,13 @@ int main(void)
#ifdef ROOMNAV
/* Обход комнат по номеру: '+' (0x79 numpad / 0x55 '=') — вперёд,
* '-' (0x7B / 0x4E) — назад. Edge по nav_prev: одно нажатие = одна
- * комната.
- *
- * Здесь БЫЛ pop_trob_reset() перед enter_room — «чтобы каждая комната
- * смотрелась в исходном состоянии». Убран: reset обнуляет room_seen,
- * и модификаторы перечитываются из уровня заново, то есть чит
- * ОТМАТЫВАЕТ МИР — открытые/закрытые ворота, выдвинутые пики, нажатые
- * кнопки. Пока ворота с bg=1 ошибочно стартовали закрытыми, откат был
- * не виден; после порта load_alter_mod он выглядит как баг «ворота
- * снова открылись» (уровень 1, решётка комнаты 5 (0,9), которую
- * закрывает стартовый триггер do_startpos). Навигация обязана только
- * телепортировать; исходное состояние даёт перезапуск уровня. */
+ * комната. Сама навигация — в банке (roomtest_cold.c): она нужна
+ * только по клавише, а место в резиденте дефицитно. */
{
uint8_t nav = (uint8_t)((kbd_raw_down(0x79) || kbd_raw_down(0x55)) |
((kbd_raw_down(0x7B) || kbd_raw_down(0x4E)) << 1));
- if (nav && !nav_prev) {
- uint8_t r = cur_room, i, c, pos = 20, any = 0xFF;
- /* Кид МЁРТВ: сначала честный рестарт уровня, потом телепорт.
- * Навигация только переставляет Кида (kid_init + сброс HP),
- * то есть «оживляет» его мимо start_level — а тот обязан
- * вернуть тайлы, модификаторы и ОЧИСТИТЬ слот соперника
- * (seg003:100: Guard.direction = dir_56_none). Без этого
- * после смерти оставался живой скелет рядом с вернувшейся
- * кучей костей. */
- if (pop_kid_dead) { pop_start_level(); dead_frames = 0; }
- r = (nav & 1) ? (uint8_t)(r == 24 ? 1 : r + 1)
- : (uint8_t)(r == 1 ? 24 : r - 1);
- enter_room(r);
- /* Куда поставить Кида в новой комнате: пол С МАКСИМАЛЬНЫМ Y —
- * сначала НИЖНИЙ ряд (2), потом 1, и только потом 0
- * (пользователь 2026-08-11). Раньше брался первый пол
- * подряд, то есть почти всегда (0,0) — верхний левый угол:
- * это и «прилёт» в проём/ворота, и, на уровне 5, посадка
- * вплотную к тени спецсобытия, которая ждёт в колонке −1
- * ряда 0. Вместо сцены кражи зелья начиналась схватка,
- * которой в этой комнате быть не должно. Снизу вверх нужно
- * и самой навигации: в верхнем ряду пола чаще нет, и Кид
- * проваливается из комнаты сразу после телепорта.
- *
- * ИСКЛЮЧЕНИЕ — комната 14 уровня 7 (пользователь 2026-08-12):
- * её проходят СВЕРХУ ВНИЗ (с ряда 0 на ряд 2 через зацеп за
- * кромки), и с нижнего ряда этот спуск не проверить вовсе.
- * Пол в ряду 0 там есть, так что Кид не проваливается.
- *
- * В каждом ряду сначала ищем ОБЫЧНЫЙ пол, и только если его
- * там нет — любой проходимый tile_is_floor (seg001: всё,
- * кроме пустоты, верха колонны, верха двери, стены и
- * «половинок» 26..29). */
- {
- int8_t row, step = -1, first = 2;
- if (pop_current_level == GRAB_DOWN_LEVEL &&
- r == GRAB_DOWN_ROOM) { first = 0; step = 1; }
- for (row = first; row >= 0 && row <= 2 && any == 0xFF;
- row = (int8_t)(row + step)) {
- uint8_t fallback = 0xFF;
- for (i = (uint8_t)(row * 10); i < (uint8_t)(row * 10 + 10); i++) {
- c = (uint8_t)(room_fg[i] & 0x1F);
- if (c == 1) { any = i; break; }
- if (fallback == 0xFF && c && c != 9 && c != 12 &&
- c != 20 && !(c >= 26 && c <= 29)) fallback = i;
- }
- if (any == 0xFF) any = fallback;
- }
- }
- if (any != 0xFF) pos = any;
- kid_init(SEQ_STAND, (int8_t)(pos % 10), (int8_t)(pos / 10), 0);
- pop_kid_hp_reset();
+ if (nav && !nav_prev && pop_dbg_roomnav((uint8_t)(nav & 1 ? 1 : 2)))
dead_frames = 0;
- /* ВЫХОД ИЗ БОЯ (BUG-CHEAT-FIGHT-1). Телепорт с вынутым мечом
- * заклинивал управление намертво: pop_control при
- * Kid.sword == SWORD_2_DRAWN уходит в control_with_sword, а
- * там единственный выход — ветка «убрать меч» на кадре
- * FRAME_171_STAND_WITH_SWORD. После kid_init(SEQ_STAND) кадр
- * обычный, стража в новой комнате нет, swordfight не
- * запускается — и диспетчер каждый кадр не делает НИЧЕГО.
- * Это болезнь ЧИТА: в оригинале телепорта между комнатами
- * нет, попасть в режим боя без соперника нечем. Меч в
- * инвентаре (pop_have_sword) НЕ теряем — только состояние
- * схватки. */
- Kid.sword = SWORD_0_SHEATHED;
- holding_sword = 0;
- offguard = 0;
- guard_refrac = 0;
- }
nav_prev = nav;
}
#endif
@@ -755,6 +581,7 @@ int main(void)
gfx_set_draw_page(back);
pop_check_skel(); /* спецсобытие ур.3: скелет встаёт */
+ pop_check_mouse(); /* спецсобытие ур.8: мышь жмёт кнопку */
pop_check_can_guard_see_kid(); /* луч видимости — ДО логики персонажей */
pop_ctrl_tick(); /* ввод -> control(): смена seq */
/* Кто в этом кадре не изменился с прошлой отрисовки ЭТОЙ страницы —
@@ -1045,26 +872,7 @@ int main(void)
* при её смене (change-driven, счётчик 2 = обе страницы дабл-буфера),
* а не каждый кадр (обход sdcc_z80_cmp_store_a_bug: сначала сохранить). */
#ifdef ROOMNAV
- {
- uint8_t r = cur_room, prev = room_label_sig;
- room_label_sig = r; /* СНАЧАЛА сохранить (sdcc_z80_cmp_store_a_bug) */
- if (r != prev) room_label_redraw = 2;
- if (room_label_redraw) {
- /* Номер ПАЛОЧКАМИ, а не outtextxy: текст тянет системный
- * знакогенератор (_gfx_font_buf, 2 КБ статики в W2) и сажает
- * кучу с 3867 до 576 Б. Слева десятки, справа единицы:
- * || |||| = 24. bar/setfillstyle уже используются. */
- uint8_t i;
- setfillstyle(SOLID_FILL, BLACK);
- bar(100, 0, 175, 5);
- /* 0x57, а НЕ WHITE: палитра игровая, запись 15 в ней чёрная
- * (kid.pal: 0x0F = 0,0,0; чистый белый = 0x57). */
- setfillstyle(SOLID_FILL, 0x57);
- for (i = 0; i < (uint8_t)(r / 10); i++) bar(100 + i * 5, 0, 101 + i * 5, 5);
- for (i = 0; i < (uint8_t)(r % 10); i++) bar(130 + i * 5, 0, 131 + i * 5, 5);
- room_label_redraw--;
- }
- }
+ pop_dbg_room_label(cur_room); /* номер комнаты палочками — в банке */
#endif
gfx_wait_vsync();
diff --git a/applications/PoP/roomtest/roomtest_cold.c b/applications/PoP/roomtest/roomtest_cold.c
new file mode 100644
index 0000000..7f77ae6
--- /dev/null
+++ b/applications/PoP/roomtest/roomtest_cold.c
@@ -0,0 +1,249 @@
+/*
+ * roomtest_cold.c — ХОЛОДНАЯ половина главного цикла, собирается в БАНК
+ * (--bank 8=roomtest_cold.c, окно W3). Здесь всё, что отрабатывает раз на
+ * уровень или только по отладочной клавише; кадровый путь остался в
+ * roomtest.c. Зачем разделили и что осталось снаружи — см. roomtest_cold.h.
+ *
+ * Правила банка те же, что у guards.c: состояние — в W1/W2 (данные этого
+ * модуля тоже, --bank-data не включён), звать можно резидент W1/W2 и другие
+ * __banked-модули через трамплины.
+ */
+#include
+#include
+#include "roomtest_cold.h"
+#include "pop_bg.h" /* pop_loose_mob_reset — банк 7, зовём через трамплин */
+#include "pop_kid.h"
+#include "pop_map.h"
+#include "pop_level.h"
+#include "pop_trob.h"
+#include "pop_guard.h"
+#include "pop_state.h"
+#include "pop_geom.h"
+#include "pop_tune.h"
+
+#define SEQ_STAND 2
+#define SEQ_TURN 5
+#define SEQ_FALL 7
+#define SEQ_RUN 84
+#define ENTRY_POSE_FALL 1 /* tbl_entry_pose[1] */
+#define ENTRY_POSE_RUN 2 /* tbl_entry_pose[13] */
+#define TILE_LEVELDOOR_LEFT 16 /* tiles_16_level_door_left */
+#define TILE_EMPTY 0
+#define LINK_DOWN 3 /* сторона links: 0=L,1=R,2=U,3=D */
+
+/* find_start_level_door (seg003:02E6): дверь уровня В СТАРТОВОЙ КОМНАТЕ —
+ * это дверь, через которую Кид ВОШЁЛ, поэтому она открыта полностью
+ * (modif = 43) и заводится trob'ом типа 3 (start_level_door, seg007:0D72).
+ * Без этого на уровне 2 Кид материализуется внутри глухой закрытой створки:
+ * его стартовый тайл (комната 5, ряд 1, колонка 3) — правая половина
+ * той самой двери. */
+static void find_start_level_door(uint8_t room)
+{
+ uint8_t *m = pop_trob_modif(room);
+ uint8_t tp;
+ if (!m) return;
+ /* Батч-доступ к странице уровня тут не нужен: функция отрабатывает раз
+ * за уровень, а pop_add_trob сам лезет в W0. */
+ for (tp = 0; tp < 30; tp++) {
+ if (pop_level_tile(room, tp) != TILE_LEVELDOOR_LEFT) continue;
+ m[tp] = 43; /* створка поднята полностью */
+ pop_add_trob(room, tp, 3); /* ...и тут же поедет ВНИЗ (type>=3) */
+ }
+}
+
+/* start_level (seg003:0055) + do_startpos (seg003:01A3) + set_start_pos
+ * (seg003:028A).
+ *
+ * Позиция берётся ИЗ ДАННЫХ уровня (pop_level_start_*), а не из #define:
+ * start_pos — тайл 0..29 (row*10+col), start_dir — направление, в котором
+ * персонаж должен ОКАЗАТЬСЯ.
+ *
+ * Направление ИНВЕРТИРУЕТСЯ (`Char.direction = ~level.start_dir`): персонаж
+ * ставится спиной и доворачивается кадрами позы входа. Сама поза — из
+ * tbl_entry_pose (data.h:848 = {0,1,0,…,2,…}):
+ * 1 (уровень 1) — падение внутрь (seq_7_fall) + нажатие кнопки room5(0,2);
+ * это оно захлопывает решётку за спиной в первой комнате;
+ * 2 (уровень 13) — вбегание (seq_84_run);
+ * иначе — разворот на месте (seq_5_turn).
+ * Таблица индексируется НОМЕРОМ УРОВНЯ — pop_level_entry_pose(). */
+void pop_start_level(void) __banked
+{
+ uint8_t entry_pose, start_room, pos, seq = SEQ_TURN, chkp;
+ int8_t dir;
+
+ pop_leveldoor_open = 0; /* seg003:97 — дверь прошлого уровня не в счёт */
+ pop_feather = 0; /* seg003:189 — перо не переживает старт уровня */
+ entry_pose = pop_level_entry_pose(pop_current_level);
+ start_room = pop_level_start_room();
+ pos = pop_level_start_pos();
+ dir = (int8_t)~pop_level_start_dir();
+ /* Особое событие: старт с чекпойнта (do_startpos, seg003:0141).
+ * Числа — в pop_tune.h. Плиту снимаем ПОСЛЕ pop_level_reset_tiles()
+ * ниже, иначе рестарт её вернёт. */
+ chkp = (uint8_t)(pop_current_level == POP_CHKP_LEVEL && pop_checkpoint);
+ if (chkp) {
+ start_room = POP_CHKP_RESPAWN_ROOM;
+ pos = POP_CHKP_RESPAWN_TILEPOS;
+ dir = (int8_t)~POP_CHKP_RESPAWN_DIR; /* стартует в ПРОТИВОположную
+ * и разворачивается seq_5 */
+ }
+
+ /* Рестарт уровня = load_level() заново (play_level, seg003:57): в
+ * исходное возвращаются И модификаторы (пики/ворота), И сами тайлы —
+ * разбитые плиты целы, выпитые зелья на месте (BUG-RESPAWN-1), и
+ * убитые стражи снова живы на своих тайлах (BUG-RESPAWN-2). */
+ pop_trob_reset();
+ pop_level_reset_tiles();
+ pop_level_reset_guards();
+ pop_guard_reset(); /* start_level: Guard.direction = none */
+ pop_loose_mob_reset(); /* start_level: mobs_count = 0 (seg003:88) */
+ /* «Особое событие: убрать loose-плиту» (do_startpos): без этого путь в
+ * комнату-чекпойнт после респавна остался бы непройденным. */
+ if (chkp)
+ pop_level_set_tile(POP_CHKP_CLEAR_ROOM,
+ (uint8_t)(POP_CHKP_CLEAR_ROW * 10 + POP_CHKP_CLEAR_COL),
+ TILE_EMPTY);
+
+ /* Кнопку жмём ДО отрисовки комнаты — как в оригинале (do_startpos
+ * отрабатывает раньше первого draw_room), иначе шов кадр показывал бы
+ * ещё не тронутое состояние решётки. */
+ if (entry_pose == ENTRY_POSE_FALL) {
+ /* get_tile(5,2,0) + trigger_button(0,0,-1) — «нажать» кнопку room5,
+ * col2, row0 её же типом и модификатором (в оригинале и то и другое
+ * берётся из curr_* после get_tile). */
+ uint8_t tp = 2; /* row0*10 + col2 */
+ pop_trigger_button(5, tp, pop_level_tile(5, tp), pop_trob_modif(5)[tp]);
+ seq = SEQ_FALL;
+ } else if (entry_pose == ENTRY_POSE_RUN) {
+ seq = SEQ_RUN;
+ }
+
+ /* Тоже ДО отрисовки комнаты: дверь, через которую вошли, обязана быть
+ * открыта уже в первом кадре (в оригинале find_start_level_door стоит
+ * между do_startpos и draw_level_first). */
+ find_start_level_door(start_room);
+
+ enter_room(start_room);
+ kid_init(seq, (int8_t)(pos % 10), (int8_t)(pos / 10), dir);
+ /* Спецсобытие «вход падением» (set_start_pos, seg003:0196): на 7-м
+ * уровне Кид ставится в комнату 17, а экран тут же переводится на
+ * комнату ПОД ней (`goto_other_room(3)`: y −= 189, ряд пересчитать) —
+ * он влетает в неё сверху, продолжая падение с шестого уровня. */
+ if (pop_current_level == FALLING_ENTRY_LEVEL &&
+ start_room == FALLING_ENTRY_ROOM) {
+ uint8_t below = pop_room_link(start_room, LINK_DOWN);
+ if (below) {
+ Kid.y = (uint8_t)(Kid.y - 189);
+ Kid.curr_row = pop_y_to_row((int16_t)Kid.y);
+ enter_room(below);
+ }
+ }
+ Kid.sword = 0; /* set_start_pos: sword_0_sheathed */
+ /* play_level (seg003:106): have_sword = level >= have_sword_from_level
+ * (=2) — начиная со второго уровня меч у Кида уже есть. */
+ pop_have_sword = (uint8_t)(pop_current_level >= 2);
+ pop_kid_hp_reset();
+}
+
+/* ---- ОТЛАДКА: обход комнат по номеру -------------------------------- *
+ *
+ * Нужен, чтобы пройти все комнаты уровня и собрать список багов отрисовки,
+ * включая недостижимые обычным путём.
+ *
+ * Здесь БЫЛ pop_trob_reset() перед enter_room — «чтобы каждая комната
+ * смотрелась в исходном состоянии». Убран: reset обнуляет room_seen, и
+ * модификаторы перечитываются из уровня заново, то есть чит ОТМАТЫВАЕТ МИР
+ * — открытые/закрытые ворота, выдвинутые пики, нажатые кнопки. Навигация
+ * обязана только телепортировать; исходное состояние даёт перезапуск. */
+uint8_t pop_dbg_roomnav(uint8_t dir) __banked
+{
+ uint8_t r, i, c, pos = 20, any = 0xFF;
+
+ /* Кид МЁРТВ: сначала честный рестарт уровня, потом телепорт. Навигация
+ * только переставляет Кида (kid_init + сброс HP), то есть «оживляет» его
+ * мимо start_level — а тот обязан вернуть тайлы, модификаторы и ОЧИСТИТЬ
+ * слот соперника (seg003:100: Guard.direction = dir_56_none). Без этого
+ * после смерти оставался живой скелет рядом с вернувшейся кучей костей. */
+ if (pop_kid_dead) pop_start_level();
+
+ r = cur_room;
+ r = (dir == 1) ? (uint8_t)(r == 24 ? 1 : r + 1)
+ : (uint8_t)(r == 1 ? 24 : r - 1);
+ enter_room(r);
+
+ /* Куда поставить Кида в новой комнате: пол С МАКСИМАЛЬНЫМ Y — сначала
+ * НИЖНИЙ ряд (2), потом 1, и только потом 0 (пользователь 2026-08-11).
+ * Раньше брался первый пол подряд, то есть почти всегда (0,0) — верхний
+ * левый угол: это и «прилёт» в проём/ворота, и, на уровне 5, посадка
+ * вплотную к тени спецсобытия, которая ждёт в колонке −1 ряда 0. Вместо
+ * сцены кражи зелья начиналась схватка, которой в этой комнате быть не
+ * должно. Снизу вверх нужно и самой навигации: в верхнем ряду пола чаще
+ * нет, и Кид проваливается из комнаты сразу после телепорта.
+ *
+ * ИСКЛЮЧЕНИЕ — комната 14 уровня 7 (пользователь 2026-08-12): её проходят
+ * СВЕРХУ ВНИЗ (с ряда 0 на ряд 2 через зацеп за кромки), и с нижнего ряда
+ * этот спуск не проверить вовсе. Пол в ряду 0 там есть, так что Кид не
+ * проваливается.
+ *
+ * В каждом ряду сначала ищем ОБЫЧНЫЙ пол, и только если его там нет —
+ * любой проходимый tile_is_floor (seg001: всё, кроме пустоты, верха
+ * колонны, верха двери, стены и «половинок» 26..29). */
+ {
+ int8_t row, step = -1, first = 2;
+ if (pop_current_level == GRAB_DOWN_LEVEL &&
+ r == GRAB_DOWN_ROOM) { first = 0; step = 1; }
+ for (row = first; row >= 0 && row <= 2 && any == 0xFF;
+ row = (int8_t)(row + step)) {
+ uint8_t fallback = 0xFF;
+ for (i = (uint8_t)(row * 10); i < (uint8_t)(row * 10 + 10); i++) {
+ c = (uint8_t)(room_fg[i] & 0x1F);
+ if (c == 1) { any = i; break; }
+ if (fallback == 0xFF && c && c != 9 && c != 12 &&
+ c != 20 && !(c >= 26 && c <= 29)) fallback = i;
+ }
+ if (any == 0xFF) any = fallback;
+ }
+ }
+ if (any != 0xFF) pos = any;
+ kid_init(SEQ_STAND, (int8_t)(pos % 10), (int8_t)(pos / 10), 0);
+ pop_kid_hp_reset();
+
+ /* ВЫХОД ИЗ БОЯ (BUG-CHEAT-FIGHT-1). Телепорт с вынутым мечом заклинивал
+ * управление намертво: pop_control при Kid.sword == SWORD_2_DRAWN уходит
+ * в control_with_sword, а там единственный выход — ветка «убрать меч» на
+ * кадре FRAME_171_STAND_WITH_SWORD. После kid_init(SEQ_STAND) кадр
+ * обычный, стража в новой комнате нет, swordfight не запускается — и
+ * диспетчер каждый кадр не делает НИЧЕГО. Это болезнь ЧИТА: в оригинале
+ * телепорта между комнатами нет, попасть в режим боя без соперника
+ * нечем. Меч в инвентаре (pop_have_sword) НЕ теряем — только состояние
+ * схватки. */
+ Kid.sword = SWORD_0_SHEATHED;
+ holding_sword = 0;
+ offguard = 0;
+ guard_refrac = 0;
+ return 1;
+}
+
+/* ---- ОТЛАДКА: номер отрисованной комнаты в верхнем борте ------------- */
+
+static uint8_t room_label_sig = 0xFF; /* последняя показанная комната */
+static uint8_t room_label_redraw; /* остаток кадров (2 = обе страницы) */
+
+void pop_dbg_room_label(uint8_t room) __banked
+{
+ uint8_t prev = room_label_sig, i;
+ room_label_sig = room; /* СНАЧАЛА сохранить (sdcc_z80_cmp_store_a_bug) */
+ if (room != prev) room_label_redraw = 2;
+ if (!room_label_redraw) return;
+ /* Номер ПАЛОЧКАМИ, а не outtextxy: текст тянет системный знакогенератор
+ * (_gfx_font_buf, 2 КБ статики в W2) и сажает кучу. Слева десятки,
+ * справа единицы: || |||| = 24. */
+ setfillstyle(SOLID_FILL, BLACK);
+ bar(100, 0, 175, 5);
+ /* 0x57, а НЕ WHITE: палитра игровая, запись 15 в ней чёрная
+ * (kid.pal: 0x0F = 0,0,0; чистый белый = 0x57). */
+ setfillstyle(SOLID_FILL, 0x57);
+ for (i = 0; i < (uint8_t)(room / 10); i++) bar(100 + i * 5, 0, 101 + i * 5, 5);
+ for (i = 0; i < (uint8_t)(room % 10); i++) bar(130 + i * 5, 0, 131 + i * 5, 5);
+ room_label_redraw--;
+}
diff --git a/applications/PoP/roomtest/roomtest_cold.h b/applications/PoP/roomtest/roomtest_cold.h
new file mode 100644
index 0000000..b175993
--- /dev/null
+++ b/applications/PoP/roomtest/roomtest_cold.h
@@ -0,0 +1,53 @@
+/*
+ * roomtest_cold.h — контракт между главным циклом (резидент W1/W2) и его
+ * ХОЛОДНОЙ половиной, вынесенной в банк (roomtest_cold.c).
+ *
+ * Зачем. В режиме huge под кучу остаётся то, что не занял _CODE+_DATA, и к
+ * уровню 8 запас упал ниже килобайта. Главный цикл при этом наполовину
+ * состоит из кода, который отрабатывает РАЗ НА УРОВЕНЬ (старт/рестарт) или
+ * вообще только по отладочной клавише (обход комнат, лейбл номера) — держать
+ * его в дефицитном окне незачем. Кадровый путь (tick/draw/флип) остался в
+ * резиденте целиком: цена выноса — один трамплин на вызов, а он тут ноль раз
+ * за обычный кадр.
+ *
+ * Что ОСТАЛОСЬ в roomtest.c и почему: enter_room_side (её зовут и холодная
+ * половина, и переходы между комнатами в кадре падения) и рабочие массивы
+ * комнаты — они же и есть то состояние, ради которого функция вообще
+ * существует; банковый модуль читает их как обычные глобалы (данные банков
+ * линкуются в общий _DATA, --bank-data мы не включаем).
+ */
+#ifndef ROOMTEST_COLD_H
+#define ROOMTEST_COLD_H
+
+#include
+
+/* ---- то, что холодная половина берёт у главного цикла ---------------- */
+
+extern uint8_t room_fg[30]; /* тайлы ОТРИСОВАННОЙ комнаты (fg & 0x1F) */
+extern uint8_t cur_room; /* её номер (drawn_room) */
+extern uint8_t pop_checkpoint; /* checkpoint (seg000:270) — ставит уход */
+ /* влево из комнаты 7 уровня 3 */
+
+/* Вход в комнату БЕЗ перехода за Кидом (старт уровня, респавн, чит-обход):
+ * страж такой смены не «видит» и остаётся в своей комнате. */
+void enter_room(uint8_t room);
+
+/* ---- холодная половина ----------------------------------------------- */
+
+/* start_level (seg003:0055) + do_startpos + set_start_pos: поставить Кида в
+ * стартовую позицию уровня. Один вход и для запуска, и для рестарта после
+ * смерти / выпадения из уровня — в оригинале это буквально один путь. */
+void pop_start_level(void) __banked;
+
+/* ОТЛАДОЧНЫЙ обход комнат по номеру ('+' / '−'). dir: 1 — следующая,
+ * 2 — предыдущая. Ставит Кида на подходящий пол новой комнаты и выводит
+ * его из боя. Возвращает 1, если комната сменилась (вызывающий обязан
+ * вернуть себе draw-страницу и обнулить счётчик кадров смерти). */
+uint8_t pop_dbg_roomnav(uint8_t dir) __banked;
+
+/* ОТЛАДОЧНЫЙ лейбл номера комнаты палочками в верхнем борте. Рисует ТОЛЬКО
+ * при смене комнаты (и по разу на каждую страницу дабл-буфера), поэтому
+ * звать можно каждый кадр. */
+void pop_dbg_room_label(uint8_t room) __banked;
+
+#endif /* ROOMTEST_COLD_H */
diff --git a/applications/PoP/roomtest/tests-host/Makefile b/applications/PoP/roomtest/tests-host/Makefile
index 11c4728..a6b968b 100644
--- a/applications/PoP/roomtest/tests-host/Makefile
+++ b/applications/PoP/roomtest/tests-host/Makefile
@@ -39,6 +39,11 @@ OBJS_grab := $(OBJS_phys)
OBJS_wall := $(OBJS_phys)
# t_char — физика над Char для РАЗНЫХ персонажей (Кид / страж / тень).
OBJS_char := $(OBJS_phys)
+# t_mouse — спецсобытие уровня 8 (мышь). ЕДИНСТВЕННЫЙ набор, куда линкуется
+# guards.c: здесь проверяется сам ИИ (autocontrol_mouse) вместе с рождением
+# (pop_check_mouse) и общей физикой соперника — то есть ровно то, что в
+# остальных наборах заменено ручным прогоном play_guard_frame.
+OBJS_mouse := $(OBJS_phys) build/eng_guards.rel
include $(TESTKIT)/host-tests.mk
diff --git a/applications/PoP/roomtest/tests-host/stubs.c b/applications/PoP/roomtest/tests-host/stubs.c
index baca337..16da24f 100644
--- a/applications/PoP/roomtest/tests-host/stubs.c
+++ b/applications/PoP/roomtest/tests-host/stubs.c
@@ -232,6 +232,14 @@ uint8_t pop_level_tile_raw(uint8_t room, uint8_t tilepos)
return tk_room_fg[room][tilepos];
}
+/* pop_level_tile (то же самое, но с маппингом окна W0 внутри) — её зовёт
+ * спецсобытие подъёма скелета из guards.c; тому единственному набору, где
+ * guards.c линкуется (t_mouse), достаточно чтения из tk_room_fg. */
+uint8_t pop_level_tile(uint8_t room, uint8_t tilepos)
+{
+ return pop_level_tile_raw(room, tilepos);
+}
+
/* Запись в «данные уровня» — у теста это тот же tk_room_fg. Нужна с тех
* пор, как do_pickup правит страницу уровня сразу (BUG-POTION-STRIPE). */
void pop_level_set_tile(uint8_t room, uint8_t tilepos, uint8_t tile)
diff --git a/applications/PoP/roomtest/tests-host/t_mouse.c b/applications/PoP/roomtest/tests-host/t_mouse.c
new file mode 100644
index 0000000..f3deb63
--- /dev/null
+++ b/applications/PoP/roomtest/tests-host/t_mouse.c
@@ -0,0 +1,178 @@
+/*
+ * t_mouse — спецсобытие уровня 8: МЫШЬ (do_timers seg003:0545,
+ * do_mouse seg003:0ABA, autocontrol_mouse seg002:07EB).
+ *
+ * Единственный набор, куда линкуется guards.c: проверяется не физика (её
+ * держат t_phys/t_char), а связка «условие запуска → рождение → ИИ →
+ * нажатие кнопки → уход». Всё это ветки по charid внутри общего кода
+ * соперника, и сломать их можно, ничего не трогая в самой мыши.
+ *
+ * Кадр соперника здесь полный, с ИИ — pop_guard_tick() + pop_guard_phys_tick(),
+ * как в главном цикле; sc_guard_run для этого не годится (он намеренно без ИИ).
+ */
+#include
+
+#include "tcheck.h"
+#include "pop_kid.h"
+#include "pop_map.h"
+#include "pop_guard.h"
+#include "pop_geom.h"
+#include "pop_state.h"
+#include "pop_level.h" /* pop_current_level (под тестом — из stubs.c) */
+#include "pop_tune.h"
+#include "scene.h"
+#include "stubs.h"
+
+#define E 0 /* empty */
+#define F 1 /* floor */
+#define O 15 /* opener */
+#define W 20 /* wall */
+
+/* Верхний ряд комнаты 16 в упрощённом виде: сплошной пол и кнопка на
+ * колонке 7 — там же, где она стоит в данных уровня 8. Провал колонок
+ * 4..6 намеренно засыпан: он мешает Киду, а не мыши, и в тест на ИИ
+ * притащил бы падение соперника. */
+static const uint8_t room_mouse[30] = {
+ F, F, F, F, F, F, F, O, F, F,
+ W, W, W, W, W, W, W, W, W, W,
+ W, W, W, W, W, W, W, W, W, W,
+};
+
+/* Поставить сцену уровня 8: Кид в комнате 16, дверь уровня открыта. */
+static void scene_level8(void)
+{
+ sc_room(room_mouse, POP_MOUSE_ROOM);
+ sc_kid_at(1, 0, 1);
+ Kid.room = POP_MOUSE_ROOM;
+ pop_guard_reset();
+ pop_current_level = POP_MOUSE_LEVEL;
+ pop_leveldoor_open = 1;
+ tk_log_clear();
+}
+
+/* Один полный кадр соперника, как в главном цикле roomtest.c. */
+static void mouse_frame(void)
+{
+ pop_guard_tick();
+ pop_guard_phys_tick();
+}
+
+/* ---- рождение -------------------------------------------------------- */
+
+TC_TEST(mouse_appears_after_delay)
+{
+ uint16_t i;
+ scene_level8();
+
+ /* Счётчик стартует с 1 («дверь открыта») и растёт по кадру, срабатывая
+ * на значении POP_MOUSE_DELAY — то есть кадров до мыши на два меньше.
+ * Прогоняем всё, кроме последнего: слот обязан быть ещё пуст. */
+ for (i = 0; i < POP_MOUSE_DELAY - 2; i++) pop_check_mouse();
+ TC_EQ(Guard.charid, 0);
+
+ pop_check_mouse(); /* тот самый кадр */
+ TC_EQ(Guard.charid, CHARID_24_MOUSE);
+ TC_EQ(Guard.x, POP_MOUSE_START_X);
+ TC_EQ(Guard.curr_row, 0);
+ TC_EQ(Guard.direction, -1); /* dir_FF_left */
+ TC_EQ(guardhp_curr, 1);
+ TC_EQ(Guard.room, POP_MOUSE_ROOM); /* унаследована от Кида (loadkid) */
+}
+
+/* Счётчик тикает ТОЛЬКО пока Кид в комнате мыши: в оригинале это то же
+ * условие, что и запуск (Char.room == mouse_room), поэтому 12,5 секунды
+ * отсчитываются с возвращения игрока, а не с открытия двери. */
+TC_TEST(mouse_timer_frozen_outside_room)
+{
+ uint16_t i;
+ scene_level8();
+ Kid.room = POP_MOUSE_ROOM + 1;
+ for (i = 0; i < POP_MOUSE_DELAY * 2; i++) pop_check_mouse();
+ TC_EQ(Guard.charid, 0);
+ TC_EQ(pop_leveldoor_open, 1); /* не сдвинулся ни на кадр */
+}
+
+/* Закрытая дверь уровня — мыши нет вовсе (она подсказка на обратном пути). */
+TC_TEST(mouse_needs_open_leveldoor)
+{
+ uint16_t i;
+ scene_level8();
+ pop_leveldoor_open = 0;
+ for (i = 0; i < POP_MOUSE_DELAY * 2; i++) pop_check_mouse();
+ TC_EQ(Guard.charid, 0);
+}
+
+/* ---- ИИ: пробежала, нажала, ушла ------------------------------------- */
+
+/* Родить мышь, не гоняя POP_MOUSE_DELAY пустых кадров. */
+static void spawn_mouse(void)
+{
+ scene_level8();
+ pop_leveldoor_open = POP_MOUSE_DELAY - 1;
+ pop_check_mouse();
+}
+
+TC_TEST(mouse_runs_left_and_presses_button)
+{
+ uint8_t i;
+ spawn_mouse();
+ TC_EQ(Guard.charid, CHARID_24_MOUSE);
+
+ /* Бежит влево: до порога разворота ей ~35 пикселей по 3-5 за кадр. */
+ for (i = 0; i < 24; i++) mouse_frame();
+ TC_TRUE(Guard.x < POP_MOUSE_START_X);
+
+ /* По дороге наступила на кнопку (0,7) — общий check_press физики
+ * соперника, тот же, которым кнопки жмут страж и тень. */
+ TC_TRUE(tk_log_count(TK_BUTTON) >= 1);
+}
+
+TC_TEST(mouse_turns_back_and_disappears)
+{
+ uint8_t i;
+ spawn_mouse();
+
+ /* 120 кадров с запасом покрывают путь туда (200→166), стойку seq_107
+ * (11 кадров) и путь обратно (166→200). */
+ for (i = 0; i < 120; i++) {
+ mouse_frame();
+ if (Guard.direction == DIR_56_NONE) break;
+ }
+ TC_EQ(Guard.direction, DIR_56_NONE); /* clear_char: ушла со сцены */
+ TC_EQ(guardhp_curr, 0);
+}
+
+/* ---- ветки по charid у соседнего кода -------------------------------- */
+
+/* Мышь Кида «не видит»: иначе он выхватит на неё меч (seg003:0965). */
+TC_TEST(mouse_is_not_seen_by_kid)
+{
+ spawn_mouse();
+ Guard.curr_row = Kid.curr_row; /* один ряд — условие луча */
+ pop_check_can_guard_see_kid();
+ TC_EQ(can_guard_see_kid, 0);
+}
+
+/* leave_guard мышь не записывает в данные уровня (seg002:02F5): вернувшись
+ * в комнату, игрок не должен встретить «стража» с её параметрами.
+ * Комнату берём из диапазона таблицы заглушки (TK_ROOMS = 8). */
+TC_TEST(mouse_is_not_saved_as_room_guard)
+{
+ spawn_mouse();
+ Guard.room = 1;
+ tk_gs_tile[0] = 0xEE; /* метка «никто сюда не писал» */
+ pop_guard_leave();
+ TC_EQ(tk_gs_tile[0], 0xEE);
+}
+
+void main(void)
+{
+ sc_init();
+ TC_RUN(mouse_appears_after_delay);
+ TC_RUN(mouse_timer_frozen_outside_room);
+ TC_RUN(mouse_needs_open_leveldoor);
+ TC_RUN(mouse_runs_left_and_presses_button);
+ TC_RUN(mouse_turns_back_and_disappears);
+ TC_RUN(mouse_is_not_seen_by_kid);
+ TC_RUN(mouse_is_not_saved_as_room_guard);
+}