47 Commits

Author SHA1 Message Date
Александр Петров 18d177407b TASKS: зацеп в прыжке — в список на финальную приёмку уровней 1-3
Точки вызова стоят и в обеих ветках check_bumped, поэтому регрессия
проявится не в зацепе, а в обычном ударе о стену с зажатым Shift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:59:59 +03:00
Александр Петров 0cd6b2d737 L3-CHKP, зацеп в прыжке опцией, pop_tune.h; сборка 10 мин -> 1:48
L3-CHKP (чекпойнт уровня 3).  Флаг взводится, когда Кид уходит ВЛЕВО ИЗ
комнаты 7, а do_startpos по нему подменяет старт на комнату 2, тайл (0,6),
лицом влево и снимает loose-плиту (7,0,4).  Тонкость, на которой я сначала
ошибся: level3_set_chkp (seg002:0665) вызван из leave_room ДО
goto_other_room, поэтому `Char.room == 7` — это комната, ИЗ которой уходят,
а не в которую входят.  Поймал пользователь прогоном в SDLPoP: смерть В
комнате 7 вернула его в стартовую 9, а плита осталась цела.  Проверено в
MAME: вход в 7 флаг не ставит, уход влево — ставит; респавн в комнате 2;
после обычной смерти (без чекпойнта) плиты восстанавливаются как раньше.

Зацеп ПРЯМО В ПРЫЖКЕ (check_grab_run_jump, seg006:1228) — портирован за
выключателем POP_ENABLE_JUMP_GRAB.  Это НЕ ваниль: в оригинале зацепиться
можно только в начале падения (кадры 102..105), то есть Shift приходится
жать уже в полёте; у SDLPoP это enable_jump_grab, и работает он лишь при
включённых fixes-and-enhancements.  Три точки вызова как у оригинала:
check_action и обе ветки check_bumped (зацеп за верх стены вместо удара).

pop_tune.h — настраиваемые константы в одном месте (аналог
custom_options_type SDLPoP): чекпойнт, выключатель зацепа и отладочная
крутилка POP_DBG_GATE_HOLD (сколько кадров решётка держится поднятой;
оригинал 5, потолок 30 — таймер связи пятибитный, 31 = «заклинено»).
Задача TUNE-1 в TASKS.md: читать это из ini рядом с exe.

СБОРКА.  --max-allocs-per-node снижен со 100000 (дефолт sprinter-cc) до
3000 (дефолт SDCC) через `make ALLOCS=...`, а pop_trob.c уехал в БАНК 6 —
на 3000 резидент иначе не влезает (замер: конец _HOME 0xBC69 при стеке с
0xBB00).  Итог: сборка с нуля 1:48 вместо >10 минут, куча 2751 Б вместо
2298.  Релизная сборка — make ALLOCS=100000; сравнивать занятость банков
можно только при одинаковом ALLOCS.

Отдельно (вне git, SDLPoP в .gitignore): из референса вычищена вся наша
отладка DBG-GRAB — трасса JMP, GRAB try/probe/fail/OK/skip, автоскриншоты
seg003, печати DBG mob/mid/overlay/kidobj и счётчик dbg_shots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:57:51 +03:00
Александр Петров c6cadd0140 Закрыты BUG-GRAB-1 и BUG-GATEMOD-1; BUG-SPIKE-1 — в низкоприоритетные
Оба фикса подтверждены игрой, записи с разбором корней переехали в
bug_closed.md.  BUG-GATE-PASS-1 остался ждать сценария, но его оговорка
про BUG-GATEMOD-1 обновлена (тот закрыт).  BUG-SPIKE-1 понижен до низкого
приоритета: маловоспроизводим, смертельность пик подтверждена замером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:50:26 +03:00
Александр Петров 3078886306 BUG-GUARD-DEAF-1 закрыт: страж оборачивается на вернувшегося Кида
Проверено в игре: комната 11 уровня 2, страж выталкивает Кида в 22, Кид
возвращается бегом — страж оборачивается и достаёт меч.  Разбор корня
(is_guard_notice не взводился ни в одном из пяти мест оригинала) переехал
в bug_closed.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:41:20 +03:00
Александр Петров 4424ea20a8 BUG-SPIKE-1: попиксельная подгонка X тоже не воспроизводит — состояние не позиционное
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:55:38 +03:00
Александр Петров eecb00f911 BUG-SPIKE-1: пользователь не воспроизвёл; смертельность подтверждена замером
Запись переведена в «ждёт сценария»: чистый пробег по убранным пикам
убивает (трасса по кадрам в записи), а наблюдавшееся «нет урона» — это
уже выдвинутые пики, безвредные для бегущего и в оригинале.  Открытым
остаётся только визуальное расхождение со скриншотом SDLPoP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:51:15 +03:00
Александр Петров 2b17408609 BUG-SPIKE-1: замер опроверг гипотезу о раннем триггере
Watchpoint на модификатор пики (ур.2 к.6, тайл 13) на чистом пробеге:
  modif=1 — кадр 11 (беговой), x=112, col=2
  modif=2 — кадр 12 (беговой), x=117, col=3   (Кид на тайле, h=2)
  modif=3 — кадр 177 (frame_177_spiked)       (напоролся)

То есть check_spike_below/check_spiked/is_spike_harmful и тайминг
выдвижения верны, «раннего» триггера нет.  Настоящий корень: пики
ЗАЛИПАЮТ выдвинутыми — пока габарит Кида накрывает колонку, каждый кадр
start_anim_spike переставляет отрицательный модификатор обратно в 0x8F.
Выдвинутые пики (h=1) для бегущего безвредны по правилам оригинала, отсюда
обе жалобы: пробег не убивает и острия остаются на экране.

Открытый вопрос сузился до 2–3 пикселей: код start_anim_spike совпадает с
оригиналом дословно, значит на скриншоте SDLPoP Кид стоит чуть левее и
колонку пики не задевает.  План закрытия — в записи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:41:42 +03:00
Александр Петров dc8b2b7115 Приёмка ур. 2–3: сквозь стену, клин в шве, бар под плитой, глухой страж
Четыре разбора с прогонов пользователя; три бага закрыты, четвёртый
(BUG-SPIKE-1, пики) заведён с замером и гипотезой.

BUG-JUMPWALL-1 (Critical) — недолетевший прыжок проходил СКВОЗЬ стену.
check_collisions держал флаги перекрытия ОДНОГО ряда, а оригинал
(seg004:0004) — трёх, и move_coll_to_prev (seg004:00DF) берёт «прошлые»
флаги из нужного.  Наш prev=3 («уже перекрывал») подавлял бамп ровно на
кадре смены ряда, а в падении ряд меняется почти каждый кадр — переход 0→1
на стене приходился как раз на него.  Порт трёх рядов дословно.
Воспроизведено и закрыто на харнессе (новый набор t_wall: свип по 20
стартовым X, 4 давали проход сквозь кладку); 1723 трассы t_phys НЕ
изменились — правка поведение-сохраняющая.  Живьём подтвердил пользователь.

BUG-GUARD-DEAF-1 (Major) — is_guard_notice не взводился НИГДЕ, поэтому
неактивный страж не оборачивался на Кида за спиной никогда.  Портированы
все пять мест оригинала: опкод SOUND в play_seq (звуки 0..2), bumped_sound,
мягкое/среднее приземление, обрушенная плита, щелчок кнопки.  Ждёт
игровой проверки боем в комнате 11 уровня 2.

BUG-SEAM-WEDGE-1 — клин кладки в пустом (2,0).  Сосед угла снизу-слева
лежит в комнате по диагонали (room_BL); мы безусловно считали его стеной,
оригинал (load_rowbelow, seg008:368) — только когда такой комнаты нет.
Ряд «снизу» стал 11-байтным: [10] = тайл (0,9) диагональной комнаты.

BUG-LOOSE-3 — чёрный бар под упавшей плитой-потолком.  pop_ceil_bake_empty
стирал полосу и восстанавливал только два тайла ряда −1, а в полосу лезет
графика соседа слева и верхушки ряда 0.  Теперь перерисовываются ряды −1 и
0, колонки col−1..col+1.  Проверено попиксельной сверкой с эталонной
перерисовкой: 0 различий.

Плюс карта связности комнат уровней 1–3 (TASKS.md): на ур. 2 недостижимых
нет, на ур. 3 это 23 и 24 — те же односторонние ссылки, что дали 13/18/24
на уровне 1, только комнаты полностью пустые.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:29:37 +03:00
Александр Петров 4737ec323c MEM-BANK5: pop_ctrl.c → банк 5; чит [/] подгонки Кида по X
Три правки едут вместе намеренно: n_banks и --bank обязаны меняться
атомарно, иначе промежуточный коммит — зависание (см. ниже).

MEM-BANK5.  CODE и DATA делят одно 32-КБ пространство W1+W2, поэтому
килобайт кода, уехавший в банк, — это килобайт, доступный данным.
Кандидат выбран не по размеру, а по частоте вызова: диспетчер управления
дёргается раз в кадр на персонажа и горячих банк→банк переходов не
создаёт (в отличие от pop_level, чей pop_level_tile зовётся из банка 2 на
КАЖДЫЙ тайл).

  _CODE   26 780 -> 24 662 Б   (−2 118)
  куча       180 -> 2 298 Б
  банк 5            2 211 / 16 384 (13.5 %)

Шина control_* (8 глобалов) переехала в pop_state.c.  Сегодня она уцелела
бы и в pop_ctrl.c — банки собираются без --bank-data, их писучие данные
остаются в общем _DATA, — но это флаг сборки, а не свойство кода, а шину
трогают уже три банка: 5 пишет с клавиатуры, 1 подаёт синтетический ввод
ИИ (autocontrol_*, seg002), 3 читает через pop_ctrl_shift_held.
Заодно pop_ctrl.c наконец включает собственный заголовок — раньше
объявления жили прямо в нём.

ГРАБЛИ, на которые наступили (стоили дольше самой задачи): n_banks в
roomtest.c захардкожен, и его надо править вместе с числом --bank.  С
n_banks=4 и пятым банком crt0 выделил четыре страницы, _bank_pages[5]
остался нулём, и первый же вызов pop_ctrl_init() через трамплин
отобразил в W3 страницу 0 и прыгнул на 0xC874 в мусор — исполнение
забрело в дисковый код DSS и осталось крутить чтение секторов.  Симптом:
загрузка ресурсов проходит целиком (open=45 — все атласы), комната и Кид
успевают нарисоваться из enter_room, а HP и номер комнаты уже нет, и
kid_tick не вызывается ни разу.  Ровно предупреждение из шапки
runtime/bank.s.  Сверку n_banks с реальным максимальным индексом банка
записал в docs/TODO.md (Auto-banking) — это должно быть ошибкой сборки.

DBG-CHEATS: [ (0x54) и ] (0x5B) двигают Кида на пиксель (seg000:1828),
по фронту нажатия, под pop_cheats.  Нужны потому, что мост MAME теряет
нажатия при быстрой отправке и подогнать Кида в позу скриптом нельзя —
на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.

ROOMNAV больше не зовёт pop_trob_reset: reset обнуляет room_seen, то есть
чит ОТМАТЫВАЛ МИР (открытые/закрытые ворота, нажатые кнопки).  Навигация
обязана только телепортировать.

Проверено в MAME: старт уровня 1 рисуется полностью (комната, Кид, HP,
номер), бег вправо и падение на второй ряд отрабатывают, ] даёт x+1 и
[ даёт x−1 по одному нажатию, Shift+→ — осторожный шаг (x 131 -> 142,
колонка 4 -> 5) и при удержании 90 кадров не срывается в бег (x 142 ->
151), то есть pop_ctrl_shift_held работает через границу банк 3 -> банк 5.
Наборы под ucsim: geom 39, grab 53, phys 1723 — все зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:45:15 +03:00
Александр Петров cfc3602375 L2: падение с мечом, разбег-прыжок через 3 тайла, стартовое состояние ворот
BUG-FALL-SWORD-1 — start_fall (seg006:1044) был портирован не целиком:
не хватало трёх веток выбора последовательности и уборки меча в ножны.
Из-за seq_7 с set_fall(1,15) (дрейф 1 px/кадр) Кид с мечом уезжал примерно
на тайл вбок; в оригинале это seq_81_fightfall — падение строго вниз.
Сверено по логу SDLPoP: кадры 102..105 дают x = 155,157,159,160.

BUG-RJUMP-1 — run_jump (seg005:0AA8) не выравнивал Кида по кромке пола
перед толчком, а был заглушкой «полировка K3».  Суммарный dx seq_4 —
62 px при тайле 14, то есть провал ровно в три тайла берётся ТОЛЬКО с
кромки: без выравнивания Кид не перепрыгивал его никогда.  Порт —
pop_run_jump_align() в pop_map (беззнаковое сравнение оригинала = «сдвиг
не попал в [-8,-1]»).  На харнессе: было — толчок с x=165, кадр 44 в
колонке 3 (провал); стало — 5 кадров добега, толчок с x=149, кадр 44 даёт
x=87 col=1 row=1.  Остальные 8 сценариев не изменились.

BUG-GATEMOD-1 — load_alter_mod (seg008:198E) был портирован только для
зелий, поэтому ворота с bg=1 («Open» по спецификации DAT, табл. 8)
стартовали закрытыми.  Добавлены ветки gate (1 -> 188) и loose.  Ветка
wall намеренно НЕ портируется: связи стен наш pop_bg считает по типам
соседей в момент отрисовки.  На уровнях 1-3 таких ворот всего двое
(ур. 1 комн. 5 (0,9); ур. 2 комн. 13 (1,5)) — у остальных bg=2, а 2 и 0
ведут себя одинаково.

Убран временный трассировщик pop_dbg_trace/pop_dbg_draw; pop_dbg_trap()
оставлен как многоразовый инструмент.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:34:26 +03:00
Александр Петров 3d8b81c9f6 build: examples вне регрессной сборки; эталон размеров пересобран
`make all` собирал и examples/, из-за чего цикл «правка libc -> проверка»
упирался в mdview (компилируется минутами) и ничего нового про libc не
показывал.  Регресс ловит разжирение библиотеки, а для этого хватает
мелких tests/ — каждый тянет свой кусок libc и пересобирается за секунды.

- `make all` = tools lib tests; examples собираются явно (`make examples`,
  и как зависимость `make floppy`);
- size_check.py смотрит только tests/*.

Эталон принят заново.  Разбор расхождения, чтобы оно не выглядело
необъяснённым: эталон стоял с 30 июля (0280b05), а libc менялась 1 и 3
августа (b56f2b4 kbd_raw_poll, 1f16e8f fake shift) — _irq_tramp вырос
267 -> 336 Б и не был перебазирован, отсюда +33 Б у всех, кто линкует
трамплин (cbl*, irqtest, rt_test), и +22 у gfx_dbuf (gfx_set_idle_hook в
libbgi из того же KBD-1).  Текущий фикс BUG-KBD-5 вернул 36 Б из этих 69.
Заодно выкинуты мёртвые строки эталона (rpgprof/rpgwalk/scroll/space —
их давно нет в APPS, mdview/mdview2 — теперь вне регресса).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:33:34 +03:00
Александр Петров 030af74631 BUG-KBD-5: зажатый Shift снимался автоповтором стрелки
Симптом: Shift работал в одиночку и НЕ работал вместе со стрелками —
прыжок с зацепом не выходил (BUG-GRAB-1), а осторожный шаг срывался в бег.

Декодеры делали из «fake shift» два вывода, и второй был неверен:
  обёртка E0 F0 12 / E0 12 есть  -> Shift зажат -> взвести бит   — верно;
  расширенный make БЕЗ обёртки   -> Shift отпущен -> снять бит   — НЕТ.

Замер потока байт (MAME, breakpoint на выходе из in a,($18)): клавиатура
pc_kbd ms_naturl обёртку не шлёт вовсе — при зажатом Shift поток на ↑ ровно
`E0 75 E0 75 …`, ни одного F0/12.  А typematic-повторы идут непрерывно,
пока стрелка зажата, значит каждый повтор снимал реально зажатый Shift.
Короткий тап это маскировал: после отпускания стрелки Shift снова
становился последней клавишей, и его собственный автоповтор `12` взводил
бит обратно за ~30 мс.

Фикс: обратный вывод убран, расширенная клавиша о Shift не судит.
Состояние Shift ведут его собственные make/break 12 / F0 12 — они приходят
всегда.  Прямой вывод оставлен (дёшев и верен там, где обёртка есть).
Ушла ставшая ненужной _kbdraw_fakesh; трамплин короче на 36 Б (0x150→0x12C),
что важно — его клавиатурный блок упирается в диапазон jr.

Плата: потерянный при overrun break Shift снять нечем, модификатор может
залипнуть до перенажатия (BUG-KBD-3).  Размен решён как и раньше в
kbd_raw_sync: лучше залипание, чем отвал — сорванный посреди игры Shift
в PoP стоит жизни.

Проверено в MAME чтением _kbdraw_down: Shift+↑+→ зажаты 5 с (автоповтор
идёт) -> LSh остаётся 04; отпускание Shift -> 00.  Зацеп в игре
подтверждён пользователем.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:33:15 +03:00
Александр Петров 3fe083331f tests-host: покадровый харнесс сценариев Кида (физика + зацеп)
Проверять физику Кида глазами в MAME дорого и ненадёжно: ошибка почти
всегда не в одной функции, а в РАСХОЖДЕНИИ ТРАЕКТОРИИ через несколько
кадров.  Харнесс гоняет тот же кадр, что и главный цикл
(pop_ctrl_tick -> kid_tick -> pop_phys_tick -> pop_loose_tick), и
сравнивает трассу состояния с эталоном.

- scene.c/.h — раннер: комната + стартовая поза + скрипт ввода -> трасса;
  sc_kid_at_x задаёт точный X (исход часто зависит от фазы внутри тайла).
- stubs.c/.h — libc/libbgi/соседние модули; read() реально отдаёт
  kid_data.bin (иначе kdat_ok=0 и play_seq молчит — трасса замирает).
- t_phys.c — 9 характеризующих сценариев, 1723 сверки (golden/).
- t_grab.c — окно зацепа: существует, достижимо коротким шагом, не
  зависит от рисунка нажатий.
- record_golden.py — снятие эталона по одному сценарию за прогон.
- testkit/host-tests.mk — CODE_LOC настраиваемый, EXTRA_INC/EXTRA_CFLAGS.

Именно харнесс дал доказательство, что физика зацепа у нас верна, и тем
самым перевёл поиск BUG-GRAB-1 на клавиатуру.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:32:55 +03:00
Александр Петров 29d60665e0 docs: уточнение — при падении с мечом рендер не «кривой», а корректный
Отличие от остальных записей раздела «НЕ БАГИ»: там картинка кривая и
совпадает с оригиналом лишь потому, что оригинал сам так рисует (порядок
midtable).  Здесь же спрайт перекрывает кромку ровно настолько, насколько
персонаж за неё зашёл — геометрия и рендер согласованы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:20:27 +03:00
Александр Петров 969f3f1b9a docs: падение при отходе с мечом — НЕ БАГ (сверено с SDLPoP)
Пользователь проверил в SDLPoP v1.24: оригинал падает с той же позиции,
по той же траектории и с тем же видом кадра падения (голова/руки поверх
кромки пола).  Кадры совпадают один в один.

Механика записана с числами: у стоек с мечом weight_x = 13-14 против 3 у
обычной стойки, поэтому при взгляде влево точка веса уезжает на 13 px
вправо от Char.x, и кромку персонаж переступает раньше, чем выглядит.
Замер: x=151 -> dx_weight=164 -> колонка 7 (дыра) вместо 6 (пол).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:19:24 +03:00
Александр Петров 3164065243 Клинок расширяет футпринт перерисовки на колонку (redraw_at_char)
Порт seg003:0430 «If char is holding sword, it makes redraw-area bigger»:
при Char.sword >= sword_2_drawn футпринт расширяется на одну колонку в
сторону взгляда (вправо при dir>=0, влево иначе).  У нас char_footprint
этого не делал, и клинок торчал на колонку дальше области, где
перерисовываются передние грани: меч оставался поверх столба, а его след
— на фоне.

char_footprint получил параметр sword; pop_fore_over_char — тоже (страж
машет мечом ровно так же).  Ветка Кида берёт Kid.sword, ветка стража —
Guard.sword.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:15:34 +03:00
Александр Петров e67117219f BUG-CTRL-FRAME-1: геометрия Кида считалась по кадру стража
cur_frame — один глобал на всех персонажей (как в оригинале), владелец —
тот, кто последним прошёл load_frame.  Последним в кадре тикает страж,
поэтому к моменту control() Кида там лежал кадр СТРАЖА, а через
kid_cur_dx/dy/flags по нему считается вся геометрия управления:
dx_weight -> determine_col -> distance_to_edge_weight -> get_edge_distance
-> выбор ветки в check_jump_up.

Оригинал зовёт load_fram_det_col() (seg006:0144) сразу после
loadkid/loadshad и ДО control() — play_kid_frame (seg000:1211) и
play_guard_frame (seg000:1248).  У нас этого не было.

Замерено брейкпоинтом на pop_jump_up_seq: Kid x=156 col=6 кадр 15
(dx=0 weight_x=3) при кадре стража image17 (dx=-1 weight_x=8) дал
curr_col=7 и distance=2 вместо 6 и 10 — то есть jump_up_plain (вернулось
A=28, пустой прыжок) вместо «шаг назад на x=160 + зацеп».  Предсказание
по кадру стража совпало с намеренным до единицы.

Отсюда же плавающее поведение: кадр стража меняется каждый тик, distance
Кида скакал через порог 6 — то прыжок, то попытка зацепа с неверной X.
И «голова Кида поверх плиты (1,6)» — не баг отрисовки, а следствие позы,
которой в оригинале в этом месте не бывает.

Фикс: pop_load_fram_det_col() (pop_kid.c) + вызовы в pop_ctrl_tick и
pop_guard_tick.  determine_col — только на ветке Кида: у нас он
существует лишь для него (pop_map работает с Kid, а не с Char).

Разбор с числами — bug_closed.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:55:43 +03:00
Александр Петров 5353bdaaec docs: приёмка уровня 2 — первым приоритетом, карта содержимого уровня
Порядок работ по решению пользователя: L2-PASS -> L3-CHOMP/SKEL/CHKP.
Причина техническая — чомперы лягут в банк 2, где живёт отрисовка, и
чинить баги фона поверх свежей механики дороже.

TASKS: запись L2-PASS с картой уровня 2, снятой с res2002.bin —
стражи (5, комнаты 4/7/11/15/24), ловушки и зелья по комнатам, и
декодированные из LINKLOC/LINKMAP цепочки «кнопка -> что открывает»
(в т.ч. кнопка к.9 @1,1, открывающая дверь выхода в к.23).  Плюс
отдельный список того, что сделано именно в L2 и на уровне 1 не
проверялось: большая склянка, меч с начала уровня, выход через дверь,
респавн на своём уровне.

bug_list: заведён раздел «Уровень 2» под список багов отрисовки,
который пользователь подаст отдельно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:46:12 +03:00
Александр Петров 6aefee0cc7 docs: TASKS — цель «уровни 1-3 (подземелье)», разбор что нужно уровню 3
Palace (уровни 4+) отложен решением 2026-08-04.  На доску вынесены три
задачи уровня 3, снятые с данных и SDLPoP, а не с общих соображений:

- L3-CHOMP: 5 чомперов (комнаты 5, 16×3, 22); шаблон как у пик/ворот,
  риск — банк 2 занят на 86.5%.
- L3-SKEL: в данных уровня 3 стражей НЕТ ВООБЩЕ; единственный враг —
  скелет, и он спецсобытие check_skel (seg002:1044), а не страж из
  данных.  Нужен новый атлас (data/SKEL, 29 файлов) — pop_pack_guard.py
  прибит к GUARD/.
- L3-CHKP: чекпойнт (seg002:519 + seg003:141); hitp_beg_lev уже есть.

Плюс инвентарь тайлов по уровням: ур. 3 вводит только chomper(18),
palace-набор (lattice*) начинается с уровня 4 — отсюда и граница скоупа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:43 +03:00
Александр Петров e36828ae6e L2: переход между уровнями и игра уровня 2
Порт levels_plan.md §2 (шаг 1) — машинерия смены уровня целиком.

- Номер уровня стал состоянием: pop_current_level (порт current_level),
  pop_next_level больше не флаг, а НОМЕР; главный цикл срабатывает по
  расхождению next != current (порт play_level_2, seg003:0386).
- pop_level_load_num(n): LEVELS\res20NN.bin (fallback a:\), старая
  EMM-страница отпускается только после успешной загрузки новой.
  На диск кладутся все 15 уровней (34 КБ).
- Потабличные различия (data.h:840..848) — таблицы по 16 в pop_level.c:
  tbl_entry_pose, tbl_guard_hp (HP стража ушло из хардкода 3),
  tbl_guard_type (−1 = стражей на уровне нет), tbl_level_type.
- find_start_level_door (seg003:02E6): на уровне 2 стартовый тайл —
  правая половина двери уровня, без modif=43 + add_trob(...,3) Кид
  материализуется внутри глухой створки.  Тип 3 = дверь захлопывается
  за спиной, как в оригинале.
- HP через уровень: hitp_beg_lev (seg003) — рестарт уровня откатывает
  HP к нему, пройденный уровень подтягивает его к hitp_max.  Заодно
  большая склянка add_life (тип зелья 2: +1 к потолку HP до 10) —
  на уровне 2 она есть, комната 20.
- have_sword = level >= 2 (play_level, seg003:106).
- Чит Shift+L — следующий уровень (seg000:698).

Проверено в MAME: уровень 1 → Shift+L → уровень 2 (комната 5, новые для
нас тайлы 8/9 «большая колонна» на месте, дверь захлопнута, HP 3) →
влево в комнату 4 со стражем → Shift+L → уровень 3.  Вход в стартовую
дверь уровня 2 по Up не срабатывает — как и должно (start_room-гард).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:35:56 +03:00
Александр Петров 7ef007757b docs: BUG-SEAM-DRAW-1 закрыт (не воспроизводится), заведён BUG-GATE-PASS-1
BUG-SEAM-DRAW-1: Кид упирается в решётку и встаёт на x=61, curr_col=-1,
room=1 — и в комнате 1 РИСУЕТСЯ, за решёткой.  При x=61 он уже внутри
системы координат комнаты 1 (obj_x = 6), straddle-смещение не требуется.
Остаток теоретический: при x <= 60 спрайт ушёл бы за левую кромку; полное
лекарство — довести S3, но поводов нет, заводить обратно только по живому
наблюдению.

BUG-GATE-PASS-1: однократное наблюдение — Кид стоял НА тайле решётки (0,9)
комнаты 5, дождался закрытия, пошёл вправо и прошёл в комнату 1.  Повторить
не удалось: в том же месте при x=196/col=9 решётка держит штатно.
Плоскость блокировки для колонки 9 — x=205, а «колонка 9» по m7 это
x ∈ [191,205), то есть при curr_col==9 проход невозможен по построению;
значит наблюдался x >= 205, и поведение может оказаться штатным (решётка
закрылась за спиной).  Гипотеза НЕ подтверждена, поэтому баг оставлен
открытым со списком того, что снять в следующий раз, и с двумя запасными
кандидатами на корень.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:20:01 +03:00
Александр Петров f63751edce docs: BUG-LOOSE-2 закрыт — проверено вручную
Падающий кусок теперь привязан к своей комнате и долетает после ухода Кида;
подтверждено живым прогоном.  Автоматикой гонка не воспроизводилась — мост
MAME шлёт нажатия рывками, и «уйти раньше, чем долетит плита» через него не
набиралось; это отмечено в записи вместе с указанием, что кейс стоит первым
в плане host-тестов.

Вторая волна прогона уровня 1 закрыта целиком: BUG-KBD-4, BUG-RESPAWN-2,
BUG-DRAWORDER-1, BUG-LOOSE-2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:04:13 +03:00
Александр Петров a89b8b30c6 docs: BUG-DRAWORDER-1 закрыт; остаток «ноги поверх головы» — в НЕ БАГИ
Сверено в SDLPoP: Кид, стоящий колонкой правее лежащего трупа, и там
рисуется поверх его головы.  Значит порт верен, а артефакт врождённый:
set_objtile_at_char приписывает персонажа РОВНО ОДНОМУ тайлу, ширина
спрайта на выбор тайла не влияет, и всё, что стоит правее, перекрывает
выступающую часть тела.  Механизма «широкий объект в нескольких тайлах» в
оригинале нет — сверено с draw_objtable_items_at_tile, sort_curr_objs и
веткой tile_object_redraw == 0xFF (та про оверлеи пола).

Закрытая запись перечисляет все четыре корня, которые пришлось снять по
очереди: порядок по роли вместо тайла; своя регрессия с общим окном
fore-клипа; колонка трупа из тайла вместо X; непортированная ветка
actions_1_run_jump.  Записано и то, как отличать остаток от этих багов,
и цена «починки» остатка (тайл по центру габарита = отход от эталона).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:02:02 +03:00
Александр Петров 61d4255091 testkit: модульные тесты под ucsim_z80 + планы покрытия
Обвязка для быстрых тестов plain-C логики: секунды вместо прогона в MAME,
без образа диска.  ucsim_z80 идёт в комплекте нашего SDCC — новых
зависимостей нет.

ПОЧЕМУ ПОД Z80, А НЕ ХОСТОВЫМ GCC.  У SDCC z80 int 16 бит, у хоста 32, и
расходится это НЕ в объявлениях, а в выражениях: integer promotion
повышает операнды до int независимо от того, объявлены они как uint8_t
или uint16_t.  Перевод кода на фиксированные типы разницу не убирает —
убирает только исполнение с z80-семантикой.  Побочно проверяется
кодогенерация SDCC и модули с inline-asm, которых хостовая сборка не
видит в принципе.

Устройство: crt0_ucsim.s (SP, зануление, main, halt), tcheck.* (итог в
структуру в ОЗУ), run_ucsim.py (гоняет ucsim, дампит tc_result, печатает
отчёт), host-tests.mk (общие правила).  Вывода через printf нет: тестовый
бинарь линкуется без Sprinter-libc.  Через ucsim-simif не идём — номера
его команд плавают между версиями, halt + dump работают везде.

Наборы лежат РЯДОМ с проверяемым кодом, обвязка общая:
  testkit/t_selftest.c                     — самопроверка (sizeof(int)==2)
  applications/PoP/roomtest/tests-host/    — движок PoP

Первый содержательный набор — t_geom: сверяет рукописный asm-LCG из
pop_geom.c с наивной 32-битной формулой на 128 шагах.  Заявка «бит-в-бит
как в SDLPoP» до сих пор держалась на комментарии.  Тест проверен
мутацией: порча эталонной константы даёт красный.

Планы дальнейшего покрытия:
  docs/host-tests-plan.md                    — libc и libbgi (не начато)
  applications/PoP/docs/host_tests_plan.md   — движок PoP

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:16 +03:00
Александр Петров b5d2a81ee3 L1: фиксы прогона уровня 1 — уровень мутабелен, стражи, loose-плиты, порядок
Одиннадцать наблюдений первого прогона свелись к шести корням, четыре
наблюдения второго — ещё к четырём.  Разбор каждого — bug_closed.md.

Первая волна:
- BUG-LVLSTATE-1: уровень стал мутабельным (эталонная копия foretable для
  рестарта, pop_level_set_tile вместо таблицы оверрайдов);
- BUG-RESPAWN-1: рестарт = load_level, тайлы возвращаются из эталона;
- BUG-DEATH-1: смерть от меча доигрывается (порт control_kid, seg006:0CD1);
- BUG-GATE-ANIM-1: ворота в отрисованной комнате перерисовываются
  (POP_RD_GATE, порт draw_trob seg007:01E6);
- BUG-COLL-1: полный порт check_collisions/bumped (seg004) вместо поиска
  стены только в колонке переднего края;
- BUG-STANDUP-1: убран лишний guard в bumped_floor — вставание у стены
  роняло Кида сквозь пол.

Вторая волна:
- BUG-RESPAWN-2: рестарт возвращает и СТРАЖЕЙ (в оригинале play_level на
  каждой итерации делает load_level + pos_guards);
- BUG-LOOSE-2: падающий кусок привязан к своей комнате и долетает после
  ухода Кида (do_mobs крутит mobs[] независимо от drawn_room);
- BUG-DRAWORDER-1: порядок «Кид / страж» задаётся обходом тайлов
  (redraw_needed_tiles: ряды 2,1,0, колонки 0..9), а не ролью персонажа.

По BUG-DRAWORDER-1 понадобилось три захода, и два первых были неполны:
  1) сам порядок — но общее окно fore-клипа осталось стражьим, и Кид
     нарисовался поверх передних столбов (kid_fore_clip_restore);
  2) enter_guard брал curr_col из тайла, а leave_guard пишет туда
     get_tilepos(0,row) — у запомненного ТРУПА колонка была 0 при
     настоящей X.  Теперь колонка выводится из X, как в оригинале;
  3) ветка actions_1_run_jump в set_objtile_at_char оказалась не
     «упрощаемой»: в беге тайл берётся из нижнего ряда и ЛЕВОЙ колонки
     габарита, поэтому бегущий Кид уходит за объекты справа.  Считается
     для обоих персонажей — enter_guard ставит action=1 и стражу.

Проверено в MAME: зелья/меч/плиты переживают выход из комнаты и
восстанавливаются после смерти; кнопка room5 поднимает решётку; падение с
кнопки больше не роняет в комнату 6; убитый страж жив после respawn;
Кид проходит за телом стража.  make size-check — роста нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:35:41 +03:00
Александр Петров 1f16e8fa70 KBD: состояние Shift по «fake shift» — ни залипания, ни отвала
Клавиатура PS/2 обёртывает КАЖДЫЙ расширенный код парой E0 F0 12 / E0 12,
пока реально зажат Shift (замер на железе/MAME: правый шифт обёртывается
своим кодом 0x59).  Это прямое и непрерывное свидетельство состояния
Shift — единственное доступное, потому что опросить PS/2 нельзя, а
typematic повторяет последнюю нажатую клавишу, то есть стрелку.

Оба декодера (_irq_tramp.c, kbd_raw_poll.c) читают обёртку в обе стороны:
обёртка есть -> Shift взвести; расширенный make без обёртки -> Shift
снять.  Бит взводит общий писатель — достаточно обнулить префиксы, и код
уходит в plain-половину карты как make.

Это снимает размен, между крайностями которого мы метались:
  - исключать модификаторы из сброса по overrun -> Shift залипал навсегда
    (BUG-KBD-3);
  - сбрасывать всю карту, как DSS -> Shift сносился каждым overrun'ом, а
    при зажатом Shift тап стрелки это 10 байт в 3-байтовый FIFO, то есть
    overrun почти гарантирован (BUG-KBD-4).
Теперь kbd_raw_sync снова не трогает модификаторы, и это безопасно:
залипание снимается первым же нажатием стрелки.

Раскладка трамплина: клавиатурный блок перевалил за 127 байт, а jp внутри
запрещён (копия в W2).  Префиксные обработчики переехали вплотную к своим
cp, посередине тела стоят ретрансляторы tr_kbd_hub/tr_hub_notkbd/
tr_hub_dss.  В kbd_raw_poll такого ограничения нет — там три jp.

Проверено в MAME: Shift переживает пять тапов подряд; штатное отпускание
снимает; искусственно залипший бит снимается первым тапом.
make size-check — роста нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:16:27 +03:00
Александр Петров ba37bd1133 BUG-DOOR-CLIP: обрезка силуэта правым косяком двери уровня
Симптом (нашёл пользователь сразу после L1-EXIT): при подъёме по лестнице за
дверью уровня силуэт Кида вылезал ПРАВЕЕ правого косяка проёма; по высоте
обрезка была корректна.

Причина — недопортированная половина clip_char (seg006:1231).  Для кадров
двери оригинал ставит ДВА клипа, у нас был только первый:
    obj_clip_top   = leveldoor_ybottom + 1;   // было
    obj_clip_right = leveldoor_right;          // не было
Отдельная ловушка: комментарий в SDLPoP говорит «frames 217..228», а КОД
проверяет >= frame_224_exit_stairs_8, то есть 224..228 — портировано по коду.

Fore-слоем это не лечится: створка и косяк уходят в оригинале целиком в
backtable (draw_leveldoor, все add_backtable), рисуются ПОД персонажем и
перекрыть его не могут.  Единственный способ — срезать сам спрайт.

libbgi: gfx_blit_cols_part_w(..., uint8_t maxw) — обрезка СПРАВА у
колоночного блита.  Для column-major это ровно уменьшение числа колонок, то
есть внутри ядра механизм уже был (так же клипается край экрана,
w = _bgi_maxx + 1 - x), наружу не выводился.  Тело блита переехало туда,
gfx_blit_cols_part стал тонкой обёрткой (maxw=0) — тем же приёмом, каким
gfx_blit_cols уже обёрнут вокруг gfx_blit_cols_part.  Работает и при flip:
первые maxw нарисованных колонок всегда ложатся в левую часть футпринта.
make size-check: роста нет.

PoP: pop_leveldoor_right / pop_leveldoor_ybottom (порт одноимённых глобалов)
пишет draw_leveldoor в pop_state — их читает clip_char из другого банка;
pop_clip_char_right() отдаёт границу, kid_draw превращает её в maxw и уводит
эти кадры с noclip-пути на общий.  Прямоугольник heal (kid_lw) сужается тоже
— стираем ровно нарисованное.

Проверено в MAME: pop_leveldoor_right = 176, что есть ровно (draw_xh<<3)+48
для двери комнаты 9; pop_leveldoor_ybottom = 112 у закрытой створки и 69 у
поднятой — сходится с формулой оригинала.  Отрисовку подтвердил пользователь
на живом подъёме.

Заодно: ROOMNAV остаётся включённым осознанно — это наш чит, которого в
оригинале не было, как и S/K/I; позже сведём в общий блок читов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:43:01 +03:00
Александр Петров e86f254b87 L1-START + L1-EXIT: старт по данным уровня и выход через дверь уровня
L1-START.  Старт и оба рестарта (смерть, выпадение из уровня) сведены в
pop_start_level() — порт start_level + do_startpos + set_start_pos (seg003).
Комната/тайл/направление берутся из pop_level_start_*, направление
инвертируется (~start_dir), поза входа — из tbl_entry_pose: у уровня 1 это
падение внутрь (seq_7_fall) плюс нажатие кнопки room5(0,2), то самое, что
захлопывает решётку за спиной.  Жёсткие START_ROOM/COL/ROW убраны.
Проверено в MAME: старт даёт room 1, col 0, падение на row 1 — как по данным.

L1-EXIT.  Ветка двери уровня из up_pressed + go_up_leveldoor (seg005:0482/
0574): тайлы и геометрия — pop_leveldoor_enter() в pop_map, последовательность
seq_70 — в pop_ctrl.  Опкод 0xF1 END_LEVEL в play_seq инкрементит
pop_next_level (порт next_level), главный цикл по нему перезапускает уровень
— ровно та точка, куда levels_plan §2.2 подключит загрузку уровня 2.
Открытость двери проверяется по modifier >= 42 (ветка fix_exit_door), а не по
ванильному leveldoor_open: иначе можно войти в ещё ползущую створку.

Отдельно стоило разбора: go_up_leveldoor сначала писал Char.x/Char.direction,
и оба присваивания молча терялись — окно Char вокруг диспетчера возвращает
назад только curr_seq и sword (pop_savekid_state).  Направление оставалось
«вправо», а все DX в seq_70 отрицательные, поэтому Кид уходил ИЗ проёма
влево (поймано стоп-кадром).  Геометрию персонажа в этом порте меняет
pop_map, пишет в Kid — как pop_down_action и pop_jump_up_seq.

Известный остаток — BUG-DOOR-CLIP в bug_list.md: нет обрезки силуэта правым
косяком проёма (недопортирован obj_clip_right в clip_char); нужен вариант
колоночного блита с ограничением ширины.  ROOMNAV пока оставлен включённым —
он нужен, чтобы попадать в комнату 9 для этой работы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:47:47 +03:00
Александр Петров 1146c57544 CLIP-1: heal Кида и стража — линейным ядром, когда клип не нужен
kid_heal/pop_guard_heal звали gfx_heal всегда, хотя рисуют ровно тот
прямоугольник, который блит в большинстве кадров кладёт noclip-ядром.
Все три места heal (+ heal_off фона) сведены к общему pop_heal_fast.

Замер в MAME, счётчики totalcycles на входах kid_heal и kid_tick (вся
группа heal за кадр), комната 1, Кид стоит:
  клипающее ядро  26 200 тактов/кадр (149 кадров)
  noclip          15 848 тактов/кадр (239 кадров)
−10 352 такта, −39.5 %.  A/B в одном прогоне: вторая половина снята с
пропатченным в памяти условием (jr nz → jr), то есть на той же геометрии.

Размер СУММАРНО −362 Б: _CODE +17, BANK2 −116 (свободно 2708 — это тесный
банк из рисков levels_plan §5), BANK3 −263, BANK4 без изменений.

Грабли по дороге: первым заходом хелпер был static inline в pop_bg.h —
SDCC 4.5 И встраивает тело (181 Б) в каждый вызов, И оставляет копию в
каждом TU, который видит заголовок.  pop_guard_heal раздулся с ~60 до
663 Б, итого +1091 Б в _CODE и +636 Б в банке стража.  Отсюда pop_draw.c:
обычная функция в резиденте W1, из банков это прямой call без трамплина.

pop_room_clip_borders оставлен клипающим осознанно (320 не лезет в 8 бит,
гейт border_dirty редкий) — причина записана в коде.

Проверено визуально: ходьба, прыжок, спуск, позиция за решёткой шва
(straddle — там работает клипающий фолбэк) — артефактов нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:53:30 +03:00
Александр Петров d552cbaca9 docs: bug_list — только открытые баги; закрытые → bug_closed.md
Три Critical'а (BUG-1 провал на row 1 при боковом переходе, BUG-2 ping-pong
при возврате, BUG-3 окклюзия climb-up на кнопке) висели непроверенными с
2026-07-21.  Прогнал в MAME:

- BUG-1 не воспроизводится: room6 → кнопка (0,2) → открытая решётка →
  переход влево даёт room8, y=55, curr_row=0.  Заодно снят и сам диагноз
  записи — репроекция Y при БОКОВОМ переходе не нужна: goto_other_room
  (seg002.c:390) меняет только x, наш check_leave делает то же.
- BUG-2 не воспроизводится: шов room2↔room3, четыре пересечения с
  разворотом сразу после входа — комната меняется ровно раз на пересечение.
- BUG-3 закрыт фиксом tile_code_drawn от 2026-07-28 (это дубль уже
  записанного «спуск с кнопки»); оговорка про непереснятый подъём — в
  bug_closed.md.

bug_list.md теперь только открытое (BUG-CEIL-1/2/3, BUG-OCCL-1, T-1, T-2,
таблица обхода 24 комнат) + индекс с якорями.  bug_closed.md — закрытое
вместе с разбором корней (odd-pixel char_x, подстановка тайла кнопки, баг
кодогенератора SDCC), он и есть главная ценность архива.

TASKS.md: кросслинки на открытые баги в шапке, в L1-TRIAGE, L1-PASS и
«Отложено».  Указатели в CLAUDE.md/README/room_model_plan/layout_plan_v2
переведены на нужный из двух файлов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:57:33 +03:00
Александр Петров 6b4a3b6b41 docs: итог KBD-1 — что лечит плотный опрос и что осталось
Ручная проверка пользователем: стало значительно лучше, но редкие пропуски
стрелок при зажатом Shift всё же ощущаются.  Счётчики на 35 нажатиях подряд
потерь не показали, то есть остаточная частота заметно ниже прежних ~15 %.
Задача отложена до финальной полировки программы (решение пользователя) —
для работы клавиатура пригодна.

Записано, где именно осталась дыра, чтобы не начинать с нуля: idle-хук
покрывает простой (~2/3 кадра), а в занятой трети DI-окно одного
accel-прохода доходит до ~650 мкс при допуске FIFO ~300 мкс — пачка байт,
целиком попавшая в такое окно, ещё может потерять байт.  Порядок действий
на возврат: вызовы между блитами занятой фазы, замер тем же счётным методом
от 50 нажатий, и только потом рычаги вне нашего кода (Scan Code Set 3 через
BIOS $EA — в MAME непроверяемо; общий m_irq_off_timer в драйвере).

Заодно сняты оговорки «плотный опрос ещё не подтверждён замером» в
kbd_raw.h и libc-reference.md — теперь там штатный рецепт через
gfx_set_idle_hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:15:21 +03:00
Александр Петров 4b498d171b libbgi: idle-хук в ожидании кадра; им лечится потеря нажатий с Shift
Причина потерь (замеры — applications/PoP/roomtest/TASKS.md, KBD-1): при
зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», нажатие
стрелки становится 5 байтами вместо 2, а импульс запроса прерывания здесь
теряется примерно в 44 % случаев — трёхбайтовый FIFO SIO переполняется, и
байт пропадает ДО чтения порта.  Лечится только плотным вычерпыванием: раз
в ~0.5 мс.  Столько времени есть даром — при пейсинге «3 растровых кадра на
логический тик» процессор проводит ~42 мс из 60 в gfx_wait_vsync, крутя
опрос луча и больше ничего не делая.

- gfx_set_idle_hook(fn) — что вызывать, пока gfx_wait_vsync ждёт луч.
  Состояние в отдельном data-модуле (_gfx_idle_state.c), чтобы не тянуть
  сеттер в программы, которые хук не ставят.
- Лучевой цикл зовёт хук в обеих фазах.  BC (счётчик таймаута)
  сохраняется, косвенный вызов — push адреса возврата + jp (hl), так как
  `call (hl)` в Z80 нет; без хука это ret по нулевому указателю, порядка
  двух десятков тактов в цикле, который и так сжигает время.
- Путь FPS-делителя не затронут: там ожидание через HALT.
- roomtest вешает на хук kbd_raw_poll.

Проверка в MAME счётчиками (брейкпоинты с { b@ADDR = b@ADDR+1 ; g } на
чтении порта 0x18 и на установке make-бита): 35 нажатий Shift+Home → 35
make, ноль потерь; до фикса было 9 из 10.  Боевой сценарий: четыре Shift+→
подряд дали четыре осторожных шага (Kid.x 114 -> 147).  _CODE +170 Б,
кадровый бюджет не затронут.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:05:24 +03:00
Александр Петров b56f2b4582 libc/kbd: kbd_raw_poll + замер потери нажатий при зажатом Shift (KBD-1)
Симптом: при удерживаемом Shift часть нажатий стрелок не отрабатывает
(~15 % по наблюдению пользователя), без Shift потерь нет.

Переведено в числа: нажимается Home — тоже расширенная клавиша (тот же
E0-префикс и тот же «fake shift»), но игрой игнорируется, поэтому рельеф
комнаты на результат не влияет.  Счётчики — брейкпоинты MAME с действием
{ b@ADDR = b@ADDR+1 ; g } на входе клавиатурной ветки трамплина, на чтении
порта 0x18 и на установке make-бита.

Что измерено (10 нажатий Shift+Home, дошло make):
  игра идёт, опрос ВКЛ   9/10     игра идёт, опрос ВЫКЛ  9/10
  игра ЗАМОРОЖЕНА (блитов нет вообще, длинных DI нет)  8/10

Обе исходные гипотезы отпали:
- длина наших DI-окон ни при чём (в замороженном кадре потерь больше);
- снятие di в accel-ядрах libbgi УРОНИЛО машину — режим «акселератор при
  EI» из docs/new/06-accel.md §6.6 в этой прошивке недоступен.

Байт теряется НИЖЕ нашего кода: на 49 прочитанных байт пришлось только 28
входов в клавиатурную ветку, то есть ~44 % импульсов запроса прерывания не
обслуживается и трёхбайтовый FIFO SIO переполняется.

Потолок приёма измерен отдельной программой tests/kbdpoll (ничего, кроме
kbd_raw_poll в цикле): 25 нажатий -> 25 make, 250 байт из 250, ноль потерь.
Значит опрос лечит полностью, вопрос только в плотности: нужно раз в
~0.5 мс, а шесть вызовов за 60-мс кадр давали раз в 10 мс.

Поэтому вызовы из roomtest.c УБРАНЫ (они стояли там, где прерывания и так
разрешены, и дублировали трамплин — 9/10 с ними и без).  Сама функция
kbd_raw_poll оставлена в libc: она корректна и нужна как основа плотного
опроса.  В заголовке и в libc-reference — честная оговорка, чтобы её не
ставили в игровой цикл «на всякий случай» без замера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:33:16 +03:00
Александр Петров 774b1cc7c4 docs(PoP): документация к актуальному статусу + план следующих уровней
Документы отстали от кода: PORT_PLAN писал «PoC не начат», хотя играется
весь уровень 1, а четыре плана были исполнены целиком.

- PORT_PLAN: таблица статусов по разделам; фазы 0-3 сделаны, 4-6 нет;
  риски §8 п.1/п.3 закрыты, п.2 переформулирован под реальный движок
  (спрайтовый движок для персонажей не используется, лимит «21 спрайт»
  неприменим), п.4 — найдено расхождение таймингов: оригинал считает
  логический кадр за 5 тиков при BASE_FPS=60 (83.3 мс, в бою 100 мс), а мы
  ждём три vsync (60 мс) — игра идёт примерно на 39 % быстрее эталона.
- levels_plan.md — новый: машинерия перехода между уровнями, второй
  тайлсет (palace), потабличные различия и читы SDLPoP, которые окупаются
  сразу.  Инвентарь тайлов снят прямо с res200N.bin: уровень 2 не требует
  ни одного нового ассета и ни одной новой механики.
- roomtest/TASKS.md — новый: доска текущих задач с критериями готовности.
- Удалены как исполненные и перекрытые кодом: clip_char_plan,
  double_buffer_plan, loose_floors_plan, size_optimization_plan.  Его §8
  (замеры скорости отрисовки) не был перекрыт — перенесён в
  layout_plan_v2 §9, чтобы не потерять цифры.
- KID_PLAN / gates_spikes_plan — шапки «реализовано, оставлено
  справочником»; room_model_plan — «S1 сделан, остальное не срочно».
- docs/README.md стал индексом с отметками актуальности.
- ideas_backlog: зелье переворота экрана — оригинал переворачивает готовый
  буфер построчно, спрайты не трогает; по данным уровней тип 4 встречается
  только на уровне 9, до него механика не нужна.
- examples/scroll: ссылка на удалённый план вела к неверному факту
  «теневая копия одна — общая»; заменено на подтверждённое «у каждой
  страницы своя».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:32:48 +03:00
snark13 2f3e854854 PoP roomtest: отрисовка стража — в собственный банк (банк 2 упёрся в потолок)
Банк 2 (pop_bg + pop_gdraw) подошёл к границе страницы вплотную: 16 021 из
16 384, свободно 363 байта.  А расти ему ещё есть куда — тайлы поздних
уровней, чомперы, зеркало, анимации смерти стража.

pop_gdraw.c уехал в банк 4 (n_banks 3 -> 4):
  банк 2  16 021 -> 13 792  (84.2 %, свободно 2 592)
  банк 4              2 236  (13.6 %, свободно 14 148)

Цена: pop_fore_over_char стал кроссбанковым, поэтому помечен __banked —
один трамплин (~654 такта) за кадр, других вызывающих у него нет.
pop_fore_set_clip уже был __banked, так что там ничего не изменилось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:22:27 +03:00
snark13 75a51fb1db PoP roomtest: выпивание зелья (уровень 1 — склянка здоровья)
Каркас предметов уже был (check_get_item/do_pickup/proc_get_object), пустой
оставалась только ветка зелий.  Порт seg005 get_item + seg006
proc_get_object:

- pop_get_item_action теперь отдаёт 3 = «пить» и делает do_pickup с ТИПОМ
  зелья, который лежит в старших битах модификатора тайла (modif >> 3);
- pop_ctrl на код 3 запускает seq_78_drink;
- эффекты: тип 1 (здоровье) — +1 HP через hitp_delta и красная вспышка,
  причём как в оригинале только если HP не полные; тип 5 («злое») — −1 HP.
  Типы 2/3/4/6 (жизнь, перо, переворот, открыть ворота) — свойства поздних
  уровней, портируем вместе с ними.

Вспышка фона получила цвет: меч даёт ярко-жёлтую (было), зелье — красную
(flash_color оригинала; двух значений достаточно, других в игре нет).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:09:36 +03:00
snark13 a32b66700f toolchain: make_hdd.sh — закрывать mtools ОБА канала запроса, не один
Прошлая правка убрала только управляющий терминал (os.setsid), и mmd
переключился на stdin: lsof показал fd 0 = /dev/ttys002, процесс снова спал,
теперь уже после отметки «копирование файлов».

Каналов, откуда mtools может ждать ответ, два — /dev/tty и stdin — и
закрывать надо оба.  Обёртка mt() теперь и создаёт новую сессию, и подаёт
stdin из /dev/null.

Проверено: с stdin=/dev/null mmd на свежем образе отрабатывает с кодом 0, а
на уже существующем каталоге честно возвращает 1 и ничего не спрашивает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:01:39 +03:00
snark13 59e51f7e83 toolchain: make_hdd.sh не виснет при запуске из терминала
Симптом: сборка образа молча вставала навсегда сразу после эхо-строки
команды.  Появилось не «само» — ровно тогда, когда образ разложили по
подкаталогам (BG/KID/GUARD/LEVELS) и в скрипте появился mmd.

Диагноз по артефакту, а не по догадке: зависший процесс — `mmd z:/BG`,
и lsof показал fd 0 = /dev/null, fd 4 = /dev/tty.  То есть mtools (собран
с enable-raw-term) для интерактивного вопроса открывает УПРАВЛЯЮЩИЙ
ТЕРМИНАЛ напрямую, в обход stdin — поэтому ни `< /dev/null`, ни
перенаправления stdio не помогают.  А `2>/dev/null` на mmd прятал сам
вопрос, из-за чего это выглядело как зависание на пустом месте.
Из НЕинтерактивного запуска (CI, фоновая задача) терминала нет, вопрос не
задаётся, и баг не воспроизводится — потому и жил незамеченным.

Лечение: все вызовы mtools идут через обёртку mt(), которая запускает их в
НОВОЙ СЕССИИ (os.setsid + exec питоном; setsid(1) в macOS нет).  Без
управляющего терминала открывать /dev/tty нечего, и mtools выбирает
неинтерактивный путь.

Заодно добавлены отметки этапов («разметка и формат», «копирование
файлов», «конвертация RAW -> CHD»): если что-то встанет снова, будет сразу
видно где, а не после последнего аргумента команды.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:57:39 +03:00
snark13 b22cee3456 PoP roomtest: убитый страж остаётся мёртвым; меч только по подбору или читу S
Смерть стража теперь персистентна между входами в комнату — по механизму
оригинала, а не отдельной таблицей «убит/не убит».  В SDLPoP массивы
level.guards_* лежат в ОЗУ и движок их ПЕРЕПИСЫВАЕТ: leave_guard (seg002:02F5)
кладёт туда позицию/направление/мастерство, а у МЁРТВОГО ещё и curr_seq;
enter_guard, увидев непустой seq_hi, поднимает стража прямо в этой
последовательности и по кадру смерти (185/177/178) ставит alive = 1.

У нас уровень лежит в EMM-странице только на чтение, поэтому в W2 добавлена
живая копия — 6 байт на комнату (tile/dir/x/skill/seq_lo/seq_hi):
- pop_guard_leave() в начале enter_room запоминает уходящего стража;
- pop_guard_enter поднимает труп сохранённой последовательностью И
  сохранённой X (pos_guards пересчитывает её из колонки только при загрузке
  уровня, дальше ею владеет leave_guard — иначе тело прыгает в центр тайла).

ГРАБЛИ: guards_seq_lo/hi в ФАЙЛЕ уровня не используются, там 0xFF во всех
комнатах (оригинал чистит их в reset_level_unused_fields).  Прочитав их как
есть, я скормил интерпретатору curr_seq = 0xFFFF, и приложение зависало —
бордюр оставался синим, цикл не доходил до vsync.  Живая копия стартует
нулями: 0 = «поднимать стандартной стойкой».

Меч Киду больше не выдаётся автоматически: DEBUG_SWORD_ROOM убран, вместо
него чит S (выдать меч).  Штатный путь — подобрать с пола.

Проверено в MAME: чит K убивает стража, уход из комнаты 3 и возврат —
тело на месте, страж не воскресает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:33:21 +03:00
snark13 2e90eaf7d7 PoP roomtest: окно Char больше не затирает правки pop_map (спуск с уступа)
Регрессия от окна Char вокруг control() (cf06896): диспетчер работает с
копией Char, а часть его действий у нас исполняет pop_map (pop_down_action,
pop_jump_up_seq, safe_step, зацеп) — и пишет ПРЯМО в Kid, потому что на Char
он ещё не переведён.  Завершающее `Kid = Char` затирало эти правки:
выравнивание x и ряд терялись, и спуск с уступа через вис не срабатывал —
Кид просто приседал.

pop_savekid_state теперь копирует только то, что диспетчер реально меняет
у персонажа: curr_seq и sword.  Когда pop_map переведём на Char, вернётся
полное копирование — в комментарии это зафиксировано.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:51:49 +03:00
snark13 3983fa4513 PoP roomtest: HP-учёт, индикаторы HP, чит бессмертия; фикс кэша кадра
Боёвка (порт seg002/seg006):
- check_sword_hurting / check_hurting / check_sword_hurt / hurt_by_sword /
  take_hp через дельты; do_delta_hp сводит их раз в кадр;
- парирование (justblocked), refractimer после ранения стража;
- смерть по seq_71_dying — через неё же теперь работает чит K: страж
  действительно погибает, а не замирает на месте;
- парные окна Char/Opp: loadkid_and_opp / savekid_and_opp /
  saveshad_and_opp.

Индикаторы HP (порт draw_kid_hp / draw_guard_hp): Кид слева, страж справа.
Перерисовка ТОЛЬКО при изменении числа и тогда на ОБЕИХ страницах
дабл-буфера (счётчик hp_todo, иначе на второй странице осталось бы старое
значение и мерцало через кадр); pop_hp_invalidate при входе в комнату, где
фон перерисован целиком.

Чит I — бессмертие Кида (нашего изобретения, в оригинале его нет).
Перекрывает и путь «безоружного закалывают насмерть»: тот идёт мимо HP, и
без этого чит бесполезен ровно там, где нужен.

ДВА НАЙДЕННЫХ БАГА:
1. Полосу HP блитил row-major примитивом, а атласы Кида и стража хранятся
   COLUMN-major (ради бесплатного флипа) — марки выходили транспонированными.
   Теперь колоночный блит, стрелки как в оригинале.
2. Кэш кадра для ОТРИСОВКИ заполняли pop_savekid/pop_saveshad.  Любое окно
   Char БЕЗ play_seq — а это оба окна боёвки — записывало Киду кадр, который
   принадлежал СТРАЖУ, и kid_draw искал этот image в атласе Кида, рисуя
   произвольную позу.  Владельцем кэша стал load_frame: он один знает, чей
   кадр загружен (по Char.charid).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:43:39 +03:00
snark13 d014a3f577 PoP roomtest: ИИ стража — подход к Киду и боевые ветки диспетчера
Пункт 2 плана закрыт: страж не только замечает Кида, но и идёт к нему и
дерётся.  Порт по SDLPoP, диспетчер общий — ИИ выставляет те же control_*,
что и клавиатура игрока.

guards.c (банк 1), порт seg002:
- autocontrol_guard_active (737) + kid_in_sight (0A93) + kid_armed (0AC1)
  + kid_far (09CB);
- guard_advance / guard_block / guard_strike с таблицами вероятностей по
  12 градациям мастерства (seg002:26..38), бросок prob > prandom(255);
- move_2_backward / move_3_up / move_6_shift / move_down_back;
- таймеры justblocked / kid_sword_strike / guard_refrac убывают раз в кадр
  в autocontrol_opponent, как в оригинале.

pop_ctrl.c, порт seg005: control_with_sword (964), swordfight (0CDB),
sword_strike, parry, forward_with_sword, back_with_sword.  Ветвление у
Кида и у соперника разное — соперник блокирует только на кадре 152, Кид
ещё и по 153 (и тогда последовательность прокручивается сразу).

pop_guard.c: guard_skill из данных уровня (12 градаций, вне диапазона -> 3),
HP по get_guard_hp (extrastrength[skill] + tbl_guard_hp[уровень]),
собственный сид бросков pop_fight_seed — иначе перерисовка стены сбивала бы
решения стража.

char_opp_dist переехал из банка в pop_kid.c: он нужен по ОБЕ стороны
банковой границы — и ИИ, и диспетчеру боёвки.

Проверено в MAME (комната 3): страж проходит комнату, встаёт в дистанцию и
машет мечом, позы меняются.  Урона пока нет — HP-учёт и check_hurt
следующим шагом, без них бой не заканчивается.

Бюджет В БОЮ (175 кадров): 412 224 – 421 068 = 0.958–0.979 кадра.
В покое было 400 800.  Запас в худшем кадре ~9 000 — тесно, но в один
кадр укладываемся; оптимизация отложена сознательно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:24:53 +03:00
snark13 4f7d9c0596 PoP docs: запасные PRNG (LFSR/LCG, xorshift(7,9,8)) + оценка потолка выигрыша
Тексты обеих Z80-процедур, разбор их устройства и качества, почему НЕ берём
8-битный RND Apple II (вырожденные младшие биты — раскладка кладки читает
prandom(1), вышла бы шахматка), и главное — сколько это реально даст.

Потолок выигрыша 2 814 тактов за кадр (0.65 %): тело генератора уже не
основной расход, остаются обёртка pop_prandom, pop_rnd_fit и ABI вызова.
Поэтому первый шаг, если упрёмся, — слить приведение к диапазону в ту же
asm-процедуру (один call вместо трёх), и только потом менять генератор.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:10:35 +03:00
snark13 37fc572cc3 PoP roomtest: LCG оригинала на ассемблере — точность без потери скорости
Возврат к БИТ-В-БИТ генератору оригинала по умолчанию (POP_PRANDOM_EXACT=1):
по нему проще отлаживать и сверять картинку с эталоном.  Чтобы это не
стоило процента бюджета, сам шаг LCG переписан на Z80-ассемблере —
единственное место в порте, где это сделано, с явного разрешения.

Приём: 214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3 — схема Горнера по
РАЗРЕЖЕННОЙ записи константы.  Вместо 12 сложений (по числу единиц в
0x343FD) — 17 удвоений, три сложения и одно вычитание; величина 3*s,
нужная в конце, попадается по дороге на втором шаге.

Проверка в ДВА этапа:
- схема на хосте: horner(s) == s*214013+2531011 на 3 000 000 сидов;
- сама asm-транскрипция на живой машине: breakpoint на pop_prandom,
  11 последовательных состояний сида из MAME — каждый переход совпал с
  s*214013+2531011 бит-в-бит.

Замер, комната 3, 175 кадров (медиана кадра / prandom->torch_draw):
  C, бит-в-бит (16-бит половины)   403 632 / 10 933
  C, xorshift16 + шаг Вейля        397 986 /  7 927
  asm, бит-в-бит                   400 800 /  9 331
То есть asm вернул половину разрыва (2 832 такта за кадр), сохранив
совместимость с эталоном.  Ветка xorshift оставлена под
-DPOP_PRANDOM_EXACT=0 как запасной ход — брать её имеет смысл, только
если не хватит последних 2 800 тактов.

Итог оптимизационного круга: 416 154 -> 400 800 (0.968 -> 0.932 кадра).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:05:13 +03:00
snark13 99b430f2ed PoP/libbgi: убрана 32-бит арифметика, noclip для column-major, быстрый PRNG
Ревью на 32-бит сделан ПО ASM, а не по коду (искали и безымянные
временные): во всём приложении был ровно ОДИН 32-битный вызов —
__mullong в pop_prandom.  Замер в MAME: 8 430 тактов на вызов, два
вызова за кадр.  Прочие библиотечные вызовы 16-битные (__divsint 16,
__modsint 12, __moduchar 8, __divuchar 5).

1. pop_prandom.  Состояние 32-бит -> две 16-битные половины.  Два
   генератора, выбор через POP_PRANDOM_EXACT:
   - 0 (по умолчанию) — xorshift16 + шаг Вейля, без единого умножения;
   - 1 — LCG оригинала бит-в-бит, посчитанный половинами (для сверки
     картинки с эталоном).
   8-битный RND Apple II (5*x+23 mod 256) НЕ взят: у LCG по модулю 256
   вырождены младшие биты (бит 0 просто чередуется), а раскладка кладки
   берёт как раз prandom(1) и prandom(4) — вместо шума вышла бы
   правильная шахматка.  Шаг Вейля ещё и убирает ноль как неподвижную
   точку xorshift (сид кладки вполне может быть нулём).
   Бит-в-бит эквивалентность half-word версии проверена на хосте:
   70 000 сидов x 8 шагов + 7 крайних сидов x 2000 шагов.
   Остаток 0..maxv: делитель степень двойки — маска вместо __moduint.

2. libbgi: gfx_blit_cols_part_noclip — column-major блит без клипа
   (пара к gfx_blit_cols_part, как gfx_blit_part_noclip к
   gfx_blit_part).  Клипающий вариант платит ~5 622 такта подготовки на
   КАЖДЫЙ вызов независимо от того, вылезает край (замер: подготовка
   5 622 против 13 596 на сам accel-проход).  Kid, страж и клинок
   выбирают путь по pop_onscreen_cols.  size-check: роста нет.

Бюджет (175 кадров, комната 3, медиана):
  было (после клинка)   416 154   0.968 кадра
  стало                 397 986   0.926 кадра
Разница между генераторами, замер на одинаковой сборке:
  xorshift16 + Вейль    397 986   prandom->torch_draw  7 927
  бит-в-бит LCG         403 632   prandom->torch_draw 10 933
то есть точность обходится в 5 646 тактов за кадр (1.3 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:48:37 +03:00
snark13 d438a1d3da PoP roomtest: клинок в атласе целиком + раздельный heal накладных спрайтов
Меч в оригинале ОДИН на всех: и Кид, и страж рисуют клинок из chtab_0
(add_sword_to_objtable, seg006:1798).  Раньше sword.atl содержал только
кадры подъёма/ножен (sword_tbl 35..42), поэтому у стража меча не было
видно вовсе.

- pop_pack_kid.py пакует chtab_0 целиком (id 0..33, 5168 Б), индекс в
  атласе = id;
- pop_extract_kid_data.py вытаскивает sword_tbl (53 строки) в kid_data.h
  макро-инициализаторами — таблица ложится в один TU, а не в каждый;
- pop_sword_draw (pop_kid.c) — общая точка отрисовки клинка с полным
  условием оригинала (кадры 229..237 ИЛИ меч обнажён ИЛИ живой страж);
  зовут и kid_draw, и pop_guard_draw.

Раздельный heal накладных спрайтов.  Клинок и брызги урона раньше
объединялись в один прямоугольник с персонажем, а объединение почти вдвое
больше суммы двух (клинок уходит вперёд-вверх) — heal же стоит ровно по
площади.  Теперь у накладных свой прямоугольник и свой gfx_heal, а
объединение осталось ТОЛЬКО для окна fore-клипа: там это 4 сравнения без
рисования, но покрыть клинок обязано, иначе он полезет поверх столба.

Отладка: DEBUG_SWORD_ROOM — Киду выдаётся меч при входе в комнату 3
(там страж), чтобы не бегать за ним в комнату 15.

Бюджет (225 кадров, комната 3): 415 284 – 416 370 = 0.966–0.968 кадра.
Против 384 168 – 384 636 до этого шага, то есть +31 700.  Разложение по
фазам (медианы): process_trobs 83 190, pop_guard_draw 92 483 (спрайт
28 763 + клинок 23 820 + fore_over_char 39 906), kid_draw 59 089, fore
поверх Кида + борта 47 483, heal 40 068, логика 76 338, ввод 13 050.
Видно, что клинок 21x8 стоит почти как спрайт стража — это фиксированные
накладные расходы клипающего блита, а не пиксели; лечится noclip-путём
для column-major (см. gfx_blit_noclip_fast).  Отдельным шагом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:40:08 +03:00
snark13 79ea473910 PoP roomtest: страж замечает Кида и достаёт меч (ИИ, шаг 1)
Первый самостоятельный кусок ИИ стража (план, пункт 2).  В оригинале у
соперника нет своего диспетчера: autocontrol_* (seg002) выставляет те же
глобалы control_*, что и ввод игрока, а дальше исполняется общий control().
Инфраструктура под это встала прошлым коммитом, здесь — сама логика.

Порт:
- check_can_guard_see_kid (seg003:688) — луч видимости по ряду: стены и
  верхи дверей рвут его совсем, loose/чомпер/дыра/неподнятые ворота дают
  «вижу, но не пойду».  В guards.c (банк 1);
- Opp + loadshad_and_opp (seg006:841) и char_opp_dist (seg006:2135);
- autocontrol_guard_inactive (seg002:710) + move_* (seg002:0706..);
- ветки control(): control_guard_inactive (seg006:2123) и draw_sword
  (seg005:945) — соперник уходит сразу в seq_90 en garde;
- pop_guard_tick перестроен по play_guard_frame (seg000:1246): окно
  Char/Opp вокруг ИИ, диспетчера и play_seq.

По дороге:
- Kid.alive не выставлялся (= 0 = «мёртв» в семантике оригинала), из-за
  чего луч видимости не мог сработать в принципе — ставим -1 в kid_init;
- Kid.room не выставлялась вовсе; условие Kid.room == Guard.room всегда
  было ложным.  Ставим в enter_room (полная модель Kid.room != drawn_room
  у шва по-прежнему впереди);
- pop_tile_at — тайл текущей комнаты наружу из pop_map (луч видимости);
- control_x/y/shift открыты в шину: ИИ заполняет оси как есть, без
  flip_control_x (его «вперёд» уже в системе персонажа).

Проверено в MAME (комната 3, страж на tile 17): страж переходит из
стойки 166 в 171 stand_with_sword, sword=2, can_guard_see_kid=2; ввод
игрока не пострадал.  Клинок отдельным спрайтом пока не рисуется —
sword.atl содержит только кадры 229..237 (подъём меча Кидом), остальные
строки sword_tbl приедут с боёвкой.

Бюджет (225 кадров, комната 3): 384 168 – 384 636 тактов, 0.893–0.895
кадра, запас 45 364.  Прошлый замер 382 584 – 383 064 — шаг стоил ~1 570.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:09:12 +03:00
119 changed files with 10390 additions and 1616 deletions
+16 -4
View File
@@ -1,10 +1,14 @@
# Sprinter C Compiler — top-level Makefile
#
# make build host tools, libc archive, all tests, all apps
# make build host tools, libc archive, all tests
# make tools build only host tools (mkexe)
# make lib build lib/sprinter.lib (libc) + lib/bgi256.lib (libbgi)
# make tests build all libc feature tests under tests/
# make examples build all real applications under examples/
# make examples build all real applications under examples/ (НЕ входит
# в `make all`: это регрессная сборка, а examples/ —
# крупные приложения, которые её только замедляют
# (mdview компилируется минутами) и ничего нового про
# libc не показывают. Собирать явно перед `make floppy`.)
# make floppy package every .exe + test fixtures into mame/v306/IMG/mc.img
# make check run mkexe unit tests
# make clean remove all build artefacts
@@ -39,9 +43,9 @@ DATA_FILES := \
examples/mdview/SAMPLE.MD
.PHONY: all tools lib tests examples check clean sdcc floppy \
size-check size-baseline $(TESTS) $(APPS)
size-check size-baseline host-tests $(TESTS) $(APPS)
all: tools lib tests examples
all: tools lib tests
tools:
$(MAKE) -C toolchain/mkexe
@@ -73,6 +77,14 @@ floppy: tests examples tests/seek/big.txt
@echo "Floppy ready: $(FLOPPY_IMG)"
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
# ucsim идёт в комплекте нашего SDCC.
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
host-tests:
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
size-check:
python3 toolchain/size_check.py
+9 -5
View File
@@ -59,11 +59,15 @@ Princed для DAT v1.0 (контейнер/индекс/чек-сумма, ко
## Структура папки
- `docs/` — планы и форматы: `PORT_PLAN.md` (общий план фаз),
`KID_PLAN.md`, `double_buffer_plan.md`, `loose_floors_plan.md`, форматы
ресурсов. Начинать чтение отсюда.
- `roomtest/`**активная разработка**: комната 1 + Kid (анимация, ввод,
коллизия, падение, зацеп, fore-окклюзия, loose-полы). Свой `CLAUDE.md`.
- `docs/` — планы и форматы; **индекс с отметками актуальности —
`docs/README.md`**, начинать чтение оттуда. Ключевое:
`levels_plan.md` (следующий этап), `layout_plan_v2.md` (раскладка кода по
окнам/банкам + скорость отрисовки), `PORT_PLAN.md` (карта фаз со
статусами).
- `roomtest/`**активная разработка**: уровень 1 целиком (Kid, стражи,
ловушки, ворота, loose-полы). Свой `CLAUDE.md`; текущие задачи —
`roomtest/TASKS.md`, открытые баги — `roomtest/bug_list.md`, закрытые с
разбором корней — `roomtest/bug_closed.md`.
- `poc/` — ранний proof-of-concept (снег/атлас/kbd_raw); ассеты в `poc/res/`.
- `bgtest/`, `coltest/` — отдельные проверки фона/коллизии.
- `toolchain/` — python-упаковщики ассетов + эталонные PNG (`1.1-2.png`).
+11 -4
View File
@@ -4,15 +4,22 @@
target-слоя SDCC (sprinter-cc / libc / libbgi). Цель — 320×256×256 (режим
0x81), VGA-256 ассеты оригинала переносятся почти впрямую.
Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md).
Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md).
**Состояние (2026-08-01): играется весь уровень 1** — комнаты и переходы,
Kid со всем набором действий, ловушки, ворота, дверь уровня, меч и бой,
стражи с ИИ, HP и зелья. Нет: перехода на следующий уровень, звука,
таймера/HUD, сохранений.
- Что в работе прямо сейчас — [`roomtest/TASKS.md`](roomtest/TASKS.md).
- Следующий этап (уровни 2+) — [`docs/levels_plan.md`](docs/levels_plan.md).
- Общий план и статус фаз — [`docs/PORT_PLAN.md`](docs/PORT_PLAN.md).
- Правила работы для ИИ-сессий — [`CLAUDE.md`](CLAUDE.md).
## Что где
| Папка | Назначение |
|-------|-----------|
| `roomtest/` | **Активная разработка.** Комната 1 уровня 1 живой композицией тайлов + Kid: анимация (seqtbl), управление с клавиатуры, коллизия, падение, зацеп/подтягивание, fore-окклюзия, проваливающиеся полы. Свой README/CLAUDE. |
| `docs/` | Планы (`PORT_PLAN`, `KID_PLAN`, `double_buffer_plan`, `loose_floors_plan`) и разбор форматов ресурсов Apple II / DOS. |
| `roomtest/` | **Активная разработка.** Уровень 1 целиком: фон композицией тайлов, Kid (seqtbl-анимация, ввод, коллизия, падение, зацеп, окклюзия), ловушки, ворота, стражи, бой. Свой README/CLAUDE/TASKS. |
| `docs/` | Планы и разбор форматов ресурсов Apple II / DOS — см. индекс в [`docs/README.md`](docs/README.md). |
| `toolchain/` | Python-упаковщики ассетов под Sprinter (рендер комнат, атласы тайлов/Kid, извлечение данных анимации) + эталонные скриншоты. |
| `poc/` | Ранний proof-of-concept (снег, атлас, raw-клавиатура). Ассеты в `poc/res/`. |
| `bgtest/`, `coltest/` | Точечные проверки фона и коллизии. |
+16 -1
View File
@@ -1,6 +1,21 @@
# Prince of Persia — Kid (персонаж): анализ и план
Статус: план (2026-07-16). Опирается на разбор `SDLPoP/src/seg006.c`
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Kid играется целиком: интерпретатор
> `seqtbl` + `frame_table` (`roomtest/pop_kid.c`), диспетчер `control()`
> (`pop_ctrl.c`), коллизия/физика/зацеп (`pop_map.c`), бой и HP. Модель
> персонажа стала общей: `Char`-окно (`pop_state.c`) обслуживает и Кида, и
> стражей. Таблицы кадров и `seqtbl` уехали из `_CODE` в EMM-страницу
> (`kid_data.bin`, см. `layout_plan_v2.md` шаг 1).
>
> Отступление от §2.1 плана: выбран ПАДДИНГ кадров (общий канвас), а не
> per-frame offset — компромисс зафиксирован в `PORT_PLAN.md §6.1`.
>
> **Документ оставлен как СПРАВОЧНИК по модели персонажа** (`char_type`,
> категории `actions_*`, устройство `play_seq`, объём спрайтов) — он нужен
> при портировании остальных акторов (скелет, тень, визирь). Текущие
> задачи — `../roomtest/TASKS.md`.
Составлен 2026-07-16. Опирается на разбор `SDLPoP/src/seg006.c`
(ядро физики/управления Kid), `seqtbl.c` (таблицы последовательностей),
`types.h` (char_type, seq_*, SEQ_*, actions_*), `SDLPoP/data/KID` (спрайты).
Фон уже готов и проверен на MAME (`applications/PoP/roomtest`, см.
+98 -52
View File
@@ -1,8 +1,29 @@
# Prince of Persia на ZX Sprinter — план порта
Статус: план (2026-07-15). §2 (A: kbd_mod_state / B: kbd_raw) —
РЕАЛИЗОВАНО и частично проверено в MAME (tests/kbdraw, 2026-07-15,
подробности в §2.2); PoC (§5) и остальные фазы — не начаты. Опирается на
## СТАТУС (обновлено 2026-08-01)
Документ составлен 2026-07-15 как план «с нуля» и с тех пор во многом
исполнен. Читать его надо так:
| Раздел | Что с ним сейчас |
|--------|------------------|
| §1 возможности библиотек | актуально как обзор, но **спрайтовый движок `sprite.h` для персонажей НЕ используется**: Kid/страж рисуются прямыми блитами атласов (`gfx_blit_cols_part*`) с ручным heal — так требует модель оригинала (§6) |
| §2 held-state клавиатуры | **сделано** (`kbd_mod_state`, `<kbd_raw.h>`). Открытая проблема — потеря байт при аккордах Shift+стрелка; диагноз и план в `../roomtest/TASKS.md` (KBD-1) |
| §3 форматы данных | актуально; уровень читается живьём (`roomtest/pop_level.c`) |
| §4 стратегия фона | **сделано** — тайловый рендерер в рантайме (`roomtest/pop_bg.c`) |
| §5 PoC | **закрыт и превзойдён.** `poc/` (плейсхолдер-персонаж) — история; активная разработка ушла в `roomtest/` с настоящей графикой |
| §6 модель движения | **сделано**: `play_seq` + `frame_table` оригинала, не физика с нуля |
| §7 фазы | см. отметки статуса прямо в разделе |
| §8 риски | п.1 закрыт, п.3 закрыт (28 страниц-атласов Кида), п.2/п.4 — см. отметки в разделе |
| §10 режим памяти | **сделано и переросло план**: `huge` + четыре банка кода; актуальная раскладка — `layout_plan_v2.md` |
**Где смотреть текущее состояние, а не план:** `../roomtest/README.md`
(что играется), `../roomtest/TASKS.md` (что в работе), `levels_plan.md`
(следующие уровни), `layout_plan_v2.md` (раскладка кода по окнам и банкам).
---
Опирается на
`APPLEII_RESOURCE_FORMAT.md` / `MSDOS_RESOURCE_FORMAT.md` / `README.md` в
этой папке, на текущий sprinter-cc/libc/libbgi (см. §1) и на локальные копии
`applications/PoP/SDLPoP` (github.com/NagyD/SDLPoP, GPLv3) и
@@ -227,6 +248,13 @@ dirty-биты, heal против фона через ОЗУ-копию); `room_
## 5. Proof-of-Concept — цель: доказать, что порт вообще ощущается как PoP
> **Закрыт (исторический раздел).** PoC в `poc/` свою задачу выполнил и
> дальше не развивается: управление ощущается как PoP, held-state работает.
> Всё, что ниже про плейсхолдер-персонажа и приблизительную дугу прыжка,
> — уже неправда для активной ветки: в `roomtest/` стоит настоящая графика
> Кида и авторские таблицы кадров (§6). Раздел оставлен ради истории
> решений (в частности §5.1 — почему сначала был плейсхолдер).
**Объём**: одна комната (например Level 1, экран старта Кида), без
переходов между экранами, без стражников (стретч-цель, не обязательна).
@@ -364,45 +392,53 @@ memory/png_strip_padding_tradeoff.
## 7. Полноценное приложение — фазы (после PoC)
Порядок — по риску и зависимостям, не по геймплейной важности.
**Отметки статуса — на 2026-08-01.**
**Фаза 0 — инфраструктура порта** (расширяет PoC, не переписывает):
- Хелд-стейт клавиатуры — финальное решение и реализация по §2 (после
подтверждения пользователем).
- Полный конвертер уровней (все 15 файлов `levels.dat`/`LEVELn`) → бинарный
формат приложения (можно 1-в-1 raw dump, читать по офсетам в рантайме —
не обязательно разворачивать в C-struct с указателями).
- Полный конвертер фона (24 экрана × N уровней) в растры + конвертер
спрайт-лент Кид/стражник/скелет/тень/Джаффар в атласы `.atl` (расширение
`conv_sprites.py`/формата `.atl`, если частот кадров/атласов на актора не
хватит текущего лимита — см. риск в §8).
**Фаза 0 — инфраструктура порта** — **СДЕЛАНА**, но иначе, чем задумано:
- Хелд-стейт клавиатуры по §2 — сделан.
- Конвертер уровней не понадобился: `res200N.bin` из `SDLPoP/data/LEVELS`
кладётся на образ как есть и читается по офсетам в рантайме
(`roomtest/pop_level.c`), уровень живёт в EMM-странице.
- Конвертер фона в растры **отменён осознанно** (§4): фон собирается
тайлами в рантайме. Спрайты — `toolchain/pop_pack_bg.py` /
`pop_pack_kid.py` / `pop_pack_guard.py` → атласы `.atl` (Kid — 28
страниц, риск §8 п.3 закрыт).
**Фаза 1 — Кид, полный набор действий**: стоять/идти/бежать/тормозить/
разворот/прыжок (на месте, вперёд, «прыжок с разбега»)/повисание на
краю/подтягивание/спуск по свисанию/приседание/питьё зелья/смерть от
провала. Переходы между экранами (`MAP`-граф, `INFO.KidStartScrn`).
**Фаза 1 — Кид, полный набор действий** — **СДЕЛАНА**: стоять/идти/бежать/
тормозить/разворот/прыжки/повисание/подтягивание/спуск/приседание/
осторожный шаг/питьё зелья/смерть от провала и от пик; переходы между
комнатами во все четыре стороны. Осталось: **старт по данным уровня**
(`pop_level_start_*` реализованы, но не подключены) — задача L1-START в
`../roomtest/TASKS.md`.
**Фаза 2 — мир и ловушки**: нажимные плиты/двери через граф
`LINKLOC`/`LINKMAP` (см. `APPLEII_RESOURCE_FORMAT.md` §1.2), шипы
(выдвижение/втягивание/заклинивание), шаткие плиты (loose, обрушение),
зелья (эффект по `BLUESPEC×32`), стартовые позиции по `INFO`.
**Фаза 2 — мир и ловушки** — **СДЕЛАНА**: кнопки/ворота через
`LINKLOC`/`LINKMAP`, шипы, loose-полы (тряска, обрушение, щебень, пробой
потолка), зелья, дверь уровня (открывается), факелы. Подробности и
справочник — `gates_spikes_plan.md`.
**Фаза 3 — бой**: подбор/выхватывание меча, состояние стойки, парирование/
удар, коллизия клинков — по логике `AUTO.S`/`seg003-006.c` (референс, не
копия). Стражник: базовое AI-поведение по `GdStartProg` (несколько
шаблонов программ), Y-сортировка слоями уже есть в движке для «кто
спереди/сзади».
**Фаза 3 — бой** — **СДЕЛАНА в объёме обычного стражника**: подбор и
выхватывание меча, стойка, удар/парирование, коллизия клинков, HP обеих
сторон, смерть; ИИ стража (замечает Кида, подходит, боевые ветки),
персистентность трупа между комнатами.
**Фаза 4 — разнообразие противников**: скелет, тень (копия анимации Кида —
подтверждено побайтовым совпадением данных, см. `MSDOS_RESOURCE_FORMAT.md`
§3), толстый стражник/визирь (общая база анимации с визирем).
**Фаза 4 — разнообразие противников** — **НЕ НАЧАТА**. Скелет нужен на
уровне 3, толстый — на 6, тень — на 12, визирь — на 13; привязка
«уровень → тип стража» (`tbl_guard_type`) описана в `levels_plan.md` §1.
**Фаза 5 — звук**: CBL-эффекты (шаги, удары, двери, падение) из
`digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как опциональный
дешёвый бипер без CBL, если формат подтвердится простым парсингом.
**Фаза 5 — звук** — **НЕ НАЧАТА**: CBL-эффекты (шаги, удары, двери,
падение) из `digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как
опциональный дешёвый бипер без CBL, если формат подтвердится простым
парсингом. Опкод `SOUND` в `play_seq` пока просто съедает свой аргумент —
точки вызова уже на месте.
**Фаза 6 — оболочка**: титры, меню/выбор уровня, HUD (таймер/жизни),
сохранение прогресса (FILE*), финальные катсцены — по минимуму,
геймплейно не критично.
**Фаза 6 — оболочка** — **НЕ НАЧАТА**: титры, меню/выбор уровня, HUD
(таймер/жизни), сохранение прогресса (FILE*), финальные катсцены — по
минимуму, геймплейно не критично. Полоса HP — единственное, что уже есть.
**Между Фазами 4 и 5 вклинивается то, чего в этом плане не было:
переход между УРОВНЯМИ** (загрузка следующего уровня, второй тайлсет
palace, потабличные различия уровней). Отдельный документ —
`levels_plan.md`.
**Фаза 7 — стабилизация**: полный прогон всех 14 уровней в MAME
(`mame_interactive.py`), затем на реальном железе; профилирование бюджета
@@ -418,23 +454,33 @@ tiny/small.
(по правилу `defer_unexplained_quirks` — не гадать, проверять)
1. **Held-state клавиатуры** (§2) — блокирует даже PoC, если решать
«правильно»; иначе PoC на компромиссном варианте 2 (таймаут-эвристика).
2. **Бюджет спрайтов на насыщенный экран** — сцена с 2+ стражниками +
несколько анимированных ловушек может приблизиться к лимиту
~21 спрайт/кадр (`sprite_engine_perf`) — нужна прикидка по реальным
уровням (сколько объектов одновременно активно в худшем экране).
3. **Ёмкость одного атласа/страницы EMM на актора** — у Кида ~220 кадров
(все действия) против 4×12 у `rpgwalk` — потребуется либо несколько
атласов на актора с переключением по фазе действия (стоять/идти отдельно
от боя), либо расширение формата `.atl`/загрузчика на мульти-страничные
атласы — оценить фактический байтовый вес конвертированных кадров Кида
прежде чем проектировать.
4. **Тайминг оригинала** — сверить логическую частоту кадров анимации
оригинала (Apple II ~60 Гц NTSC / DOS — фиксированный таймер) с 50 Гц
Sprinter; если оригинал считался на другой частоте — потребуется
коэффициент пересчёта смещений кадров (§6), иначе прыжки/бег будут
визуально быстрее/медленнее эталона.
1. ~~**Held-state клавиатуры** (§2)~~**закрыт** (`<kbd_raw.h>`). Открытый
остаток — не «есть ли held-state», а потеря байт при аккордах
Shift+стрелка: `../roomtest/TASKS.md`, KBD-1.
2. **Бюджет кадра** — риск подтвердился, но не в том виде, в каком ожидался:
спрайтовый движок для персонажей не используется, поэтому лимит
«~21 спрайт/кадр» неприменим. Реальный бюджет упирается в heal+блиты и
перерисовку тайлов; замер 2026-07-30 — типичный кадр ~371 К тактов
(~86 % периода). Инструмент замера уже в коде: полосы бордюра `PROF()`
в `roomtest.c`. План выжимания — `../roomtest/TASKS.md` (CLIP-1) и
`../roomtest/bug_list.md` (T-1/T-2).
3. ~~**Ёмкость атласа на актора**~~ — **закрыт**: Kid разложен на 28
атласов-страниц по 8 спрайтов (`pop_pack_kid.py`), страж — на 5;
мульти-страничного формата `.atl` не потребовалось. Побочно
подтвердился компромисс паддинга (§6.1).
4. **Тайминг оригинала** — **ОТКРЫТ, и сверка 2026-08-01 показывает
расхождение.** Цифры оригинала (SDLPoP): базовый таймер `BASE_FPS = 60`
(`types.h:1373`), логический кадр игры — `base_speed = 5` тиков
(`data.h:869`), то есть **83.3 мс (12 лог. кадров/с)**; в бою
`fight_speed = 6` → **100 мс (10/с)**. У нас (`roomtest.c`) — три
ожидания `gfx_wait_vsync()` на итерацию, то есть **60 мс (16.7/с)** и
без отдельной скорости боя. Значит **игра идёт примерно на 39 %
быстрее эталона**. Точное соответствие даёт 4 ожидания vsync (80 мс
против 83.3) и 5 в бою (100 мс — совпадает точно).
Проверять не «на глаз», а секундомером по одинаковому отрезку
(SDLPoP рядом на том же экране), и только после того, как кадр
перестанет иногда вылезать за период (см. п.2) — иначе замедление
спрячет проблему бюджета вместо того, чтобы её показать.
---
+42 -2
View File
@@ -1,4 +1,40 @@
# Форматы ресурсов Prince of Persia — сводка
# `applications/PoP/docs` — индекс + сводка по форматам ресурсов
## Индекс документов (актуальность на 2026-08-01)
**Живые планы — читать перед работой:**
| Документ | О чём |
|----------|-------|
| [`../roomtest/TASKS.md`](../roomtest/TASKS.md) | **Что берётся в работу сейчас** (не в этой папке, но входная точка) |
| [`../roomtest/bug_list.md`](../roomtest/bug_list.md) | Открытые баги roomtest (закрытые — в `bug_closed.md` рядом) |
| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP |
| [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки |
| [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди |
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии из `bug_closed.md`, дифф против SDLPoP |
| [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) |
| [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра |
**Исполненные планы, оставленные как справочники:**
| Документ | Чем ещё полезен |
|----------|-----------------|
| [`PORT_PLAN.md`](PORT_PLAN.md) | Общая карта фаз со статусами; §6 (модель движения), §10 (режим памяти) |
| [`KID_PLAN.md`](KID_PLAN.md) | Модель персонажа: `char_type`, `actions_*`, устройство `play_seq` — нужна для скелета/тени/визиря |
| [`gates_spikes_plan.md`](gates_spikes_plan.md) | Раскладка объектов уровня 1 по комнатам, декод `LINKLOC`/`LINKMAP`, точные ссылки на seg-код |
**Форматы ресурсов** (ниже по этому файлу): `POP-DAT-FormatSpecifications.pdf`
/ `.txt` (первоисточник), `APPLEII_RESOURCE_FORMAT.md`,
`MSDOS_RESOURCE_FORMAT.md`.
Удалены 2026-08-01 как полностью исполненные и перекрытые кодом:
`clip_char_plan.md`, `double_buffer_plan.md`, `loose_floors_plan.md`,
`size_optimization_plan.md` (его §8 про скорость отрисовки перенесён в
`layout_plan_v2.md` §9). Ищутся в истории git, если понадобятся.
---
## Форматы ресурсов — сводка
**Каноническая спецификация форматов**`POP-DAT-FormatSpecifications.pdf`
(+ текстовая конверсия `POP-DAT-FormatSpecifications.txt` для grep/цитирования):
@@ -70,7 +106,11 @@ DOS-упаковщика). Это значит: раскладку `BLUETYPE`/`B
дворца/подземелий) практичнее взять их оттуда напрямую, чем писать свой
декодер сжатия пикселей DOS `.DAT`.
## Что дальше (не сделано в этом заходе)
## Что дальше по форматам (не сделано и пока не нужно)
Порт читает уровень напрямую из `res200N.bin` (`roomtest/pop_level.c`), а
графику берёт из распакованных PNG `SDLPoP/data/` — поэтому ни один пункт
ниже сейчас не блокирует работу.
1. Точный кодек сжатия пикселей спрайтов в сыром DOS `.DAT` (нужен только
если понадобится читать именно нашу локальную копию `MSDOS/*.dat`
-112
View File
@@ -1,112 +0,0 @@
# План: порт `clip_char()` — обрезка спрайта персонажа
Статус: в работе с 2026-07-28. Контекст: баг «спуск Кида с кнопки в комнате 8»
(Kid просвечивает в щель между кнопкой и ближним столбом, мусор на кромке).
## Симптом
room 8, Kid спускается (climbdown) с тайла-кнопки у ближней колонны. Спрайт
Кида нарисован ЦЕЛИКОМ, включая часть, которая в оригинале обрезана по линии
пола: видно «просвет» Кида в щели между кнопкой и колонной и мусор на кромке.
## Корень
Не портирован `clip_char()` (SDLPoP `seg006.c:1749`, вызывается из
`add_kid_to_objtable`/`add_guard_to_objtable`, `seg008.c:1671/1690`, ПОСЛЕ
`set_char_collision`/`set_objtile_at_char`/`redraw_at_char*` и ПЕРЕД
`add_objtable`). Оригинал кладёт в objtable не только позицию спрайта, но и
прямоугольник клипа `obj_clip_{top,bottom,left,right}`; блиттер рисует только
внутри него. Мы рисуем без клипа вообще.
## Что делает оригинал (дословно)
`reset_obj_clip()``left=0, top=0, right=320, bottom=192`.
Дальше (упрощая C-трюк с глобалью `curr_tile2`, которую ставит каждый
`get_tile`: `X == wall || tile_is_floor(curr_tile2)` — это «тайл X = стена
ИЛИ пол»):
```
T_L = get_tile(room, char_col_left, char_top_row)
T_R = get_tile(room, char_col_right, char_top_row)
if (T_L — стена или пол) &&
( (action == stand && (frame == 79 || frame == 81)) /* прыжок вверх / зацеп */
|| (T_R — стена или пол) )
{
clip_row = Char.curr_row + 1;
clip_y = y_clip[clip_row]; /* y_clip[] = {-60, 3, 66, 129, 192} */
if (clip_row == 1 || (clip_y < obj_y && clip_y - 15 < char_top_y))
obj_clip_top = char_top_y = clip_y;
}
```
Смысл: `y_clip[row+1]` — верхняя граница СВОЕЙ полосы ряда. Если над головой
пол/стена — всё, что выше этой линии, не рисуется (персонаж «уходит под пол»).
Для ряда 0 (`clip_row == 1`) клип применяется БЕЗУСЛОВНО.
Метрики из `set_char_collision` (seg006:0723):
- `char_x_left = obj_x/2 + 58``-= char_width_half`, если смотрит вправо)
- `char_x_right = char_x_left + char_width_half`, `char_width_half = (w+1)/2`
- `char_top_y = obj_y - h + 1`; если `>= 192``0`
- `char_top_row = y_to_row_mod4(char_top_y)`
- `char_col_left = MAX(get_tile_div_mod(char_x_left), 0)`,
`char_col_right = MIN(get_tile_div_mod(char_x_right), 9)`
Второй блок `clip_char``obj_clip_right` (doortop / стена / зеркало для
виса-полёта-подъёма при взгляде влево, кадры 137..139) и ветка кадров 224..228
(выход в дверь уровня). **Фаза 2**, не в этом заходе.
## Наши точки касания
- `roomtest/pop_kid.c` `kid_draw()` — сам считает `obj_x/obj_y/w/h` и блитит
через `gfx_blit_cols(bx, top + POP_YOFF, img, flip)`; запоминает
прямоугольник в `kid_l{x,y,w,h}[page]` для `kid_heal()`.
- `roomtest/pop_map.c` — там `get_tile()`, `tile_is_floor()`, `y_to_row()`,
`get_tile_div_mod_m7()`, Kid-структура. Аналогичный расчёт габарита уже
есть в `check_spike_below()` (строка ~1198) — брать за образец.
- `libbgi/common/gfx_blit_cols.c` — клип по экрану есть (при `y < 0`
пропускает `sy` верхних строк колонки), но произвольной верхней границы нет.
## Шаги
1. **libbgi**: `gfx_blit_cols_part(int x, int y, const void *img, uint8_t flip,
int sy, int h)` — «пропустить `sy` верхних строк спрайта, нарисовать `h`».
Реализация = тело `gfx_blit_cols` с предустановленными `sy`/`h` (источник
`+= sy`, экранный `y += sy`). Прототип в `libbgi/include/gfx.h`.
Стоимость: 0 в общем пути (обычный `gfx_blit_cols` вызывает то же ядро).
2. **pop_map.c**: `int pop_clip_char_top(int obj_x, int obj_y, uint16_t w,
uint16_t h)` — порт первого блока `clip_char`; возвращает КОМНАТНЫЙ y
(0 = клипа нет). Экспорт в `pop_map.h`.
3. **pop_kid.c**: в `kid_draw()` после расчёта `top` —
`ct = pop_clip_char_top(...)`; если `ct > top` → `sy = ct - top`,
рисовать `gfx_blit_cols_part(bx, ct + POP_YOFF, img, flip, sy, h - sy)`;
в `kid_l*[page]` класть ОБРЕЗАННЫЙ прямоугольник (иначе heal чистит лишнее
и стирает кромку пола). То же для `kid_draw_splash` — там оригинал делает
`reset_obj_clip()`, т.е. splash НЕ клипится (ничего не менять).
4. **Сборка**: `make -C libbgi`, `make -C applications/PoP/roomtest`,
`make size-check`; вывести свободное место в W2/W3 (порог 512 Б).
5. **Проверка в MAME** (`mame_hdd_test_disk`, полный рестарт после
пересборки образа): комната 6 → переход в 8 → влезть на кнопку → спуск.
Сверять с эталоном: живой SDLPoP той же позой (см. `pop_check_sdlpop_first`).
## Риски / что проверить отдельно
- `char_top_row` считается `y_to_row_mod4` — у нас `y_to_row()` даёт 1 для
полосы у потолка; `get_tile(row 1)` уже умеет ряд 2 верхнего соседа.
- Kid ниже комнаты (`char_top_y >= 192` → `0`) — обязателен ресет, иначе клип
прыгнет.
- `clip_row == 1` (ряд 0) — клип БЕЗУСЛОВНЫЙ: проверить, что не режет Кида в
обычной стойке на ряду 0 (условие внешнего `if` про пол/стену над головой
должно отсекать).
- Клип меняет прямоугольник heal → возможен «хвост» на второй странице
дабл-буфера: проверять оба кадра (SPACE — выключить дабл-буфер).
## Дальше (Фаза 2, отдельно)
`obj_clip_right` (doortop/стена/зеркало) — нужен для виса и подъёма при
взгляде влево; и `obj_clip_left` для зеркала (уровень 4). Требует клипа по
колонкам в `gfx_blit_cols` (обрезка справа = уменьшить `w`) — дёшево, но
без тестовой сцены проверять нечем.
См. memory: `pop_clip_char_todo`, `pop_backtable_vs_midtable`,
`pop_check_sdlpop_first`, `gfx_blit_noclip_fast`.
@@ -1,69 +0,0 @@
# Double buffer (два экрана + флип) — план
Цель: убрать мерцание/тиринг при перерисовке слоёв (Kid ↔ fore/пол-оверлей)
и гарантировать, что на экране ВСЕГДА готовый кадр с правильным порядком
слоёв. Нужно для отладки fore-слоя (видно момент композиции, а не
промежуточные состояния heal-рендера). **Тумблер обязателен**
однобуферный режим удобнее для отладки багов рисования.
## Что уже готово (libbgi — трогать НЕ нужно)
- Две графических страницы 0/1: `gfx_set_draw_page(p)` (двигает
`_gfx_addr_base` 0xC000/0xC140 — рисуют все примитивы),
`gfx_set_visible_page(p)` (ESTEX $54 SELPAGE — display-учёт DSS).
- `gfx_wait_vsync()` — луч (bit5 порта 0xFE), момент без разрывов.
- Паттерн из gfx.h: `set_draw_page(hidden); draw(); wait_vsync();
set_visible_page(hidden);`
- Замечание gfx.h: у каждой страницы СВОЯ плоскость палитры
(page0→pal0, page1→pal1) — для seamless грузить одну палитру в ОБЕ.
## Текущая модель рендера roomtest (однобуфер)
`roomtest.c`: `draw_page=0`, `visible_page=0` фиксированы. Фон комнаты
рисуется ОДИН раз в видео-ОЗУ + теневую копию (GFX_BANK_TRANSPARENT).
Цикл: 3× `gfx_wait_vsync` (пейсинг) → `pop_ctrl_tick` → `kid_heal()`
(восстановить прямоугольник Кида из тени) → `kid_tick`/`pop_phys_tick`
→ `kid_draw` → `pop_fore_over_kid`. Мерцание = heal+draw+fore длиннее
бланка, луч ловит промежуток.
## Работа на стороне PoP
1. **Инициализация обеих страниц**: `pop_room_draw` в page 0 И page 1
(теневая копия одна — общая, из неё heal'ит любая страница).
2. **Палитра в обе плоскости**: сейчас `gfx_pal_fload(0,...)` + sync.
Продублировать в plane 1 (проверить сигнатуру gfx_pal_fload/sync —
plane-параметр).
3. **Per-page heal-история** (ядро): вынести `kid_lx/ly/lw/lh` в
массивы `[2]`, индекс = страница, в которую рисуем. `kid_heal(page)`
восстанавливает СВОЙ прошлый прямоугольник (кадр -2, т.к. рисуем
через страницу). То же для fore/пол-оверлея, если они рисуют вне
футпринта Кида.
4. **Ping-pong в цикле**:
```
uint8_t back = dbuf ? (front ^ 1) : 0;
gfx_set_draw_page(back);
kid_heal(back); kid_tick; phys; kid_draw; fore;
gfx_wait_vsync();
if (dbuf) { gfx_set_visible_page(back); front = back; }
```
5. **Тумблер** `dbuf`: off → draw==visible==0, без флипа, heal[0] —
бит-в-бит текущее поведение. Управление — клавишей (напр. F2) или
compile-флагом.
6. **Пейсинг**: сейчас 3× vsync/лог.кадр. При флипе — один vsync перед
свопом; недостающий пейсинг добрать `gfx_set_fps_div(3)` или ручным
счётом кадров, чтобы скорость игры не изменилась.
## Порядок
- D1: обе страницы + палитра в обе плоскости; ping-pong без per-page
heal (проверить, что флип работает, фон корректен на обеих).
- D2: per-page heal-история (kid_l*[2]) — убрать «хвост» Кида.
- D3: тумблер dbuf + сверка однобуферного пути с текущим (регресс-нет).
- D4: пейсинг (fps_div) — вернуть исходную скорость.
## Связанные
Рендер-модель — [[pop_fore_layer]], [[pop_background_strategy]];
heal — kid_heal/gfx_heal. Будущие динамические слои (loose-floors
[[двойной буфер требует их per-page перерисовки]]) должны рисоваться в
обе страницы по той же дисциплине.
+18 -3
View File
@@ -1,8 +1,23 @@
# Интерактивные объекты (кнопки/гейты/пики) + HP/смерть — ПОДРОБНЫЙ план
Статус: **план** (2026-07-20). Реализация — отдельной сессией. Документ
самодостаточный: рассчитан на старт «с чистого листа» (пустой контекст).
Всё сверено с `applications/PoP/SDLPoP/src/` и данными `res2001.bin`.
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Все фазы плана (P0 персистентный
> per-room `room_modif`, S пики, B кнопки+ворота) сделаны и играются:
> `roomtest/pop_trob.c` (trob-диспетчер, `LINKLOC`/`LINKMAP`, ворота, дверь
> уровня, факелы, зелья), `pop_map.c` (HP, смерть на пиках, урон падения),
> `pop_redraw.c` (пометки перерисовки вместо прямых блитов). Ограничения
> из §0 закрыты: тайлы персистентны, HP/смерть есть, loose обобщён в trob;
> L3-вверх (climb-up в комнату сверху) тоже сделан (`pop_leave_dir = 3`).
> Из §5 остаётся открытым только **переход на следующий уровень через дверь
> уровня** — он вынесен в `levels_plan.md`.
>
> **Документ оставлен как СПРАВОЧНИК**, а не как план: §1 (раскладка
> объектов уровня 1 по комнатам, декод связей кнопка→цель) и §2 (точные
> ссылки на механику SDLPoP) продолжают экономить время при отладке.
> Текущие задачи — `../roomtest/TASKS.md`.
Составлен 2026-07-20. Документ самодостаточный: рассчитан на старт
«с чистого листа» (пустой контекст). Всё сверено с
`applications/PoP/SDLPoP/src/` и данными `res2001.bin`.
Правило проекта (см. `applications/PoP/CLAUDE.md`): **SDLPoP — источник истины**,
перед кодингом читать соответствующий код seg*.c, не гадать.
+142
View File
@@ -0,0 +1,142 @@
# План: модульные тесты движка roomtest под ucsim_z80
Обвязка общая — `testkit/` в корне репозитория (там же объяснение, почему
прогон именно под z80, а не хостовым gcc). Наборы лежат в
`../roomtest/tests-host/`.
Задача плана: **перестать чинить одно и то же дважды**. За два прогона
уровня 1 (2026-08-03) закрыто восемь корней, и часть из них — регрессии
соседней механики, внесённые предыдущим фиксом. Такие вещи ловятся тестом
за миллисекунды, а в MAME — часами ручного вождения Кида.
## Что уже есть
| набор | модуль | статус |
|-------|--------|--------|
| `t_geom` | `pop_geom.c` | 39 проверок, включая побитовую сверку asm-LCG с 32-битной формулой на 128 шагах |
`pop_geom.c` выбран первым, потому что не тянет ничего за собой. Дальше
начинаются швы.
## Фаза 1. Два шва (блокирует всё остальное)
### 1.1 Доступ к странице уровня
`pop_level.c` ходит по абсолютным адресам: `gfx_w0_map(lvl_page)`, затем
разыменование `(uint8_t *)(LVL_DATA_OFF + …)`. В тестовом бинаре это
обращение в никуда.
Нужен макрос `W0PTR(off)`:
- на таргете — `((uint8_t *)(off))`, то есть ровно как сейчас;
- в тестах — смещение в обычном массиве-подложке.
Правка механическая и компайл-таймовая, на размер продукта не влияет.
Заодно снимает магию абсолютных констант из тела функций.
Тестовая подложка должна уметь: загрузить синтетическую комнату (10×3
байта fg + mod) и целый синтетический уровень на 24 комнаты, чтобы
проверять межкомнатные вещи.
### 1.2 Журналирующий рендерер
Вместо `pop_bg.c`/`pop_gdraw.c` в тестовый бинарь линкуется модуль с теми
же прототипами, который **не рисует, а записывает вызовы**: какой тайл
помечен к перерисовке, каким кодом, с каким счётчиком страниц.
Это не обход проблемы, а самостоятельная ценность: `BUG-GATE-ANIM-1` был
ровно такой формы — ворота меняли состояние, но пометка на перерисовку не
ставилась. Проверяется утверждением, а не глазами.
Минимум, который надо перехватывать: `pop_set_redraw`,
`pop_set_redraw_above`, `pop_loose_mob_spawn`, `pop_gate_redraw`.
## Фаза 2. Регрессионные кейсы из `bug_closed.md`
После швов `bug_closed.md` превращается в готовую спецификацию: у каждой
записи есть симптом и ожидаемое поведение. Кандидаты, которые ловятся
логикой (без отрисовки и без железа):
| баг | что закрепить тестом |
|-----|----------------------|
| `BUG-LVLSTATE-1` | запись тайла переживает выход из комнаты |
| `BUG-RESPAWN-1` | рестарт уровня возвращает ВСЕ тайлы из эталонной копии |
| `BUG-RESPAWN-2` | рестарт возвращает таблицу стражей; убитый снова жив |
| `BUG-GATE-ANIM-1` | смена состояния ворот ставит пометку `POP_RD_GATE`; закрывающиеся — на обе страницы, открывающиеся — на одну |
| `BUG-COLL-1` | `check_collisions` сканирует ряд справа налево и выбирает НАИМЕНЬШУЮ занятую колонку |
| `BUG-STANDUP-1` | `bumped_floor` у трупа (`alive >= 0`) только выравнивает и не трогает последовательность |
| `BUG-DEATH-1` | `hitp_curr == 0` при живом Киде переводит его в «умирает» ровно один раз |
| `BUG-LOOSE-2` | кусок, начавший падать, долетает и кладёт щебень ПОСЛЕ смены комнаты |
| `BUG-CEIL-2` | loose-плита ряда 2 верхнего соседа живёт как «ряд −1» |
`BUG-LOOSE-2` стоит взять первым: он до сих пор помечен в `bug_list.md`
как непроверенный именно потому, что гонку «уйти из комнаты раньше, чем
долетит плита» через мост MAME воспроизвести не удалось. На уровне логики
это несколько строк — заспавнить кусок, сменить комнату, тикать до
приземления, проверить щебень в данных уровня.
Не берутся (нужна картинка либо железо): `BUG-DOOR-CLIP`, `BUG-CEIL-1`,
`BUG-CEIL-3`, `BUG-OCCL-1`, `BUG-KBD-4`, `BUG-3`.
## Фаза 3. Сценарные тесты
Сейчас шаг кадра размазан по `main()` в `roomtest.c`. Вынести его в
`pop_frame_tick()` — тогда появляются тесты вида «поставить Кида в
известное состояние, скормить N тиков ввода, проверить итог»:
```
дано: комната 5, Кид на кнопке (0,6)
когда: 40 тиков без ввода
тогда: комната по-прежнему 5, Кид на полу ряда 2
```
Это тот самый BUG-STANDUP-1, который ловили потиковой трассой в MAME.
Ввод подаётся не через `kbd_raw_down()`, а через подменяемый источник —
это же даст возможность проигрывать записанные сценарии.
## Фаза 4. Дифф против SDLPoP
`SDLPoP/src/` лежит в дереве, собирается на хосте, и там **уже стоят
отладочные трассы** (`DBG kidobj tilepos=…` в seg008, `DBG make_loose_fall`
в seg007). Значит эталон можно заставить печатать потиковую трассу
автоматически.
Схема: общий формат скрипта ввода и общий формат трассы (тик, frame, x, y,
room, col, row, action, alive, hp). Гоняем обе реализации, диффим, первое
расхождение — номер тика и есть баг. Это ровно то, что делалось руками
через MAME, только бесплатно и повторяемо: `BUG-COLL-1` и `BUG-STANDUP-1`
такой дифф нашёл бы за секунды.
**Лицензия.** SDLPoP — GPLv3, правило подпроекта — читать и переписывать,
не линковать. Оракул обязан быть **отдельным исполняемым файлом**,
общающимся через файлы трасс, а не слинкованным с нашим кодом в один
бинарь.
Требование к детерминизму: сиды PRNG должны совпадать. У нас
`POP_PRANDOM_EXACT` даёт ту же последовательность, что в оригинале, и это
уже закреплено тестом `geom_lcg_matches_reference`.
## Чего эти тесты не поймают
Отрисовку, банки и W-окна, тайминги, клавиатуру — за этим остаётся MAME.
И отдельный класс: **баги порядка вызовов**. Свежий пример — окно
fore-клипа (`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто
рисует персонажа; когда порядок «Кид/страж» стал переменным, окно осталось
стражьим, и Кид нарисовался поверх передних столбов. Это не «функция
вернула не то», unit-тест такое не видит. Ловится инвариантом,
вкомпилированным в safe-сборку: «в момент `pop_fore_over_kid` окно клипа
принадлежит Киду». Отдельный инструмент, дополняющий тесты.
## Порядок работ
1. Шов `W0PTR` + подложка уровня.
2. Журналирующий рендерер.
3. `BUG-LOOSE-2` — закрыть висящий вопрос.
4. Остальные кейсы из таблицы фазы 2.
5. `pop_frame_tick()` + сценарные тесты.
6. Дифф против SDLPoP.
Правило приёмки: тест не считается написанным, пока не проверен мутацией —
сломать проверяемое место и убедиться, что набор краснеет.
+57
View File
@@ -4,6 +4,63 @@
гипотеза с причиной, по которой её стоит проверить, и с тем, что мешает
сделать это прямо сейчас.
## Зелье «переворот экрана» (upside-down)
**Вопрос пользователя (2026-08-01).** Тайлы фона у нас лежат строками, а
кадры Кида/стражей — КОЛОНКАМИ (`transpose_cols` в `pop_pack_kid.py`, ради
бесплатного горизонтального зеркала). Значит вертикальный переворот для
персонажей заметно сложнее, чем для фона. Верно; но прежде чем это чинить,
надо знать три факта.
**Факт 1 — когда оно вообще нужно.** Зелье переворота — тип 4
(`proc_get_object`, `seg006.c:1885``toggle_upside()`). Скан всех уровней
по данным (`res200N.bin`, тайл 10 = зелье, тип в backtable): тип 4
встречается **впервые на уровне 9** (две склянки), и больше нигде. Тип 3
(перо, медленное падение) — уровень 7. То есть **до уровня 9 механика не
нужна вообще**, и «на первом этапе просто не реализовывать» — не компромисс,
а точное соответствие данным уровней 1..8.
**Факт 2 — что именно делает оригинал.** НЕ переворачивает спрайты.
`flip_screen` (`seg009.c:1042`) → `flip_not_ega` (`seg009.c:1023`) меняет
местами СТРОКИ готового offscreen-буфера (top↔bottom, порядок пикселей
внутри строки не трогает — это вертикальное зеркало, не поворот на 180°).
Вызывается вокруг отрисовки кадра целиком (`seg003.c:296..301`): перевернул
буфер → дорисовал → перевернул обратно. Так что в оригинале это
post-process всего экрана, и вопрос «как перевернуть колоночный спрайт»
там просто не возникает.
**Факт 3 — почему нам этот приём не подходит как есть.** У нас нет шага
«готовый offscreen → экран»: рисуем прямо в видеостраницу, а heal берёт фон
из ОЗУ-копии этой же страницы. Переворот всей страницы построчно — это
320×192 Б копирования КАЖДЫЙ кадр, что мимо бюджета на порядок.
**Варианты, которые надо будет взвесить (не сейчас):**
1. **Предпечённые перевёрнутые атласы.** Второй набор кадров
Кида/стража, перевёрнутый по вертикали ещё в `pop_pack_kid.py` (там уже
есть транспонирование — добавляется одной строкой). Рантайм: выбор
набора + зеркальная арифметика Y. Память: ещё ~28 страниц EMM при
бюджете ~3.3 МБ — не проблема. Похоже, самый дешёвый по тактам путь.
2. **Фон рисовать с обратным Y** — для row-major тайлов строка остаётся
непрерывным accel-прогоном, меняется только адрес назначения; цена —
вызов на строку вместо вызова на тайл. Померить, прежде чем закладывать.
3. **Аппаратная помощь** — до проектирования проверить, есть ли у
акселератора направление копирования «вниз» (обратный инкремент адреса);
если есть, вариант 1 может и не понадобиться. Смотреть
`docs/new/06-accel.md` и `docs/reference/accel_r.txt`.
**Почему не сейчас.** Уровни 1..8 этого не требуют, а к уровню 9 у нас уже
будет ответ на вопрос «сколько стоит кадр» (задачи CLIP-1/T-2) — без него
выбирать между вариантами выше бессмысленно.
## Заменить генератор псевдослучайных чисел
Сейчас стоит LCG оригинала, шаг на ассемблере (~1 020 тактов), бит-в-бит
совместимый с SDLPoP. Есть более дешёвые Z80-генераторы (86–148 тактов),
но потолок выигрыша — 2 814 тактов за кадр, 0.65 %, и он растворяется в
обёртках вызова. Тексты процедур, разбор качества и порядок действий —
`prng_alternatives.md`. Первый шаг там не про генератор: слить приведение
к диапазону в ту же asm-процедуру, чтобы на вызов был один `call`, а не три.
## Отключать мышь на время игры
**Гипотеза.** Мышь на Sprinter — источник прерываний (обёртки RST 30h,
+71 -4
View File
@@ -1,8 +1,26 @@
# roomtest — план v2: размер кода и раскладка по окнам/банкам/страницам
> **Замер 2026-08-01 (актуальная сборка).** `_CODE` 25 119 Б, `_DATA` 3 709,
> куча ~2.4 КБ. Банки: 1 (`guards.c`) 1 896, 2 (`pop_bg.c`) 13 792,
> 3 (`pop_map.c`) 6 331, 4 (`pop_gdraw.c`) 2 236 — все из 16 384.
> **Резидента `--w3` больше нет**: отрисовка уехала в банк 2, и это сняло
> главное ограничение резидента (из банка его было не достать) — банк→банк
> работает, трамплин сохраняет страницу окна на стеке. Отрисовка стража
> вынесена из банка 2 в собственный банк 4, потому что банк 2 подошёл к
> потолку (16 021 из 16 384) — коммит `2f3e854`.
>
> **Свободного места в банке 2 осталось ~2.6 КБ**, а туда же просятся
> чомперы, зеркало и второй тайлсет (palace). Прежде чем начинать
> `levels_plan.md` §3 — посчитать, куда это ляжет. Свободные номера банков
> есть, гранулярность — файл.
>
> Из плана ниже **не сделаны шаги 5 (данные: `room_modif`, `dl1/dl2`,
> `_kbdraw_down`; потенциал ~1.5 КБ) и 7 (дедуп `draw_tile`, отложен по
> решению пользователя)**.
Статус: **план для отдельной сессии**, составлен 2026-07-29 по свежему замеру.
Заменяет `size_optimization_plan.md` (v1, 2026-07-21): часть его пунктов уже
сделана, часть опиралась на неверную модель банкинга. Документ самодостаточный
Заменял `size_optimization_plan.md` (v1, 2026-07-21) — тот удалён 2026-08-01
как полностью перекрытый этим документом. Документ самодостаточный
— рассчитан на старт с пустого контекста.
Повод: перед стражами и боёвкой (новый код ~5–8 КБ) надо понять, куда он
@@ -442,5 +460,54 @@ dir_behind`, `y_to_row`, `char_dx_forward`, `get_tile_div_mod(_m7)`,
`bank_local_data_pattern`, `sprinter_memory_modes`, `memory_modes_implemented`,
`sprinter_emm_budget`, `mame_mcp_bridge`, `avoid_32bit_arith_z80`,
`libc_one_function_per_module`.
- `applications/PoP/docs/size_optimization_plan.md` — v1 (замер 2026-07-21,
раздел §8 про скорость отрисовки актуален и не дублируется здесь).
- `applications/PoP/roomtest/TASKS.md` — что из этого берётся в работу сейчас.
---
## 9. Скорость отрисовки: замеры и запас
Перенесено из удалённого `size_optimization_plan.md` §8 (замер 2026-07-27) —
единственная его часть, которая не была перекрыта этим документом.
Профилирование в MAME: маркеры в порт 0xFE + `wpiset … totalcycles` (приём из
memory `mame_mcp_bridge`); в самом `roomtest.c` для этого уже стоят полосы
бордюра `PROF()`. Кадр Sprinter = **430 080 тактов**.
**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке
`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип,
пересчёт src, нарезка полос >256), а не за пиксели:
| путь (спрайт 32×3) | тактов |
|---|---|
| `gfx_blit` (общее ядро, с клипом) | 13 288 |
| линейное ядро без клипа | 4 617 |
Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 %
кадра**; сам `bar` — только 13 308.
**Сделано:** `gfx_blit_noclip()` в libbgi, фоновые блиты `pop_bg` уходят на
него, когда спрайт целиком на экране (~2.9×, подтверждено в MAME). Позже
тем же приёмом закрыты спрайты персонажей (`gfx_blit_cols_part_noclip`).
**Не закрыт heal** — задача CLIP-1 в `../roomtest/TASKS.md`.
**ВАЖНО:** W3-скобку (`_bgi_begin`/`_bgi_end`) ставит САМА libbgi — вызывать
её из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято
видеобанком и код вызывающего исчезает из адресного пространства (проверено:
белый экран).
**Запас, когда перестанет хватать бюджета кадра:**
1. **Батчинг W3-скобки** — одна `_bgi_begin`/`_bgi_end` на весь `draw_tile`
вместо скобки на блит; нужен публичный batch-API в libbgi.
**Осторожно, и это стало важнее, чем было:** между begin/end стоит `DI`,
длинная серия задержит кадровое прерывание — а по разбору KBD-1
(`../roomtest/TASKS.md`) длинные DI-окна и есть причина потери байт
клавиатуры. Батчинг эту проблему УХУДШИТ, если делать его вслепую.
2. **Решётка ворот одним спрайтом**`draw_gate_back` рисует бары по одному
(до 7 блитов). Сгенерировать в атласе «столб решётки» и выводить одним
`gfx_blit_part` с обрезкой по фазе `gate_bot_y & 7`: 7 блитов → 1.
3. **Не перерисовывать статичные части шва** — грань ворот, пол и кромка при
анимации решётки не меняются (см. OPT-1 в `../roomtest/bug_closed.md`
решено не делать, стоимость транзиентная).
4. **T-1 / T-2** (`../roomtest/bug_list.md`) — перерисовка пик по причине и
idle-skip Кида: самый большой оставшийся резерв, потому что убирает работу
целиком, а не удешевляет её.
+197
View File
@@ -0,0 +1,197 @@
# План: от одного уровня к нескольким (загрузка, переходы, тайлсеты)
Статус: план, 2026-08-01. Продолжает `PORT_PLAN.md` §7 (Фаза 1: «переходы
между экранами» → теперь между УРОВНЯМИ). Текущая точка: `roomtest` играет
уровень 1 целиком в одной комнате-за-комнатой модели, но уровень нельзя
ни выбрать, ни закончить.
Источник истины — `../SDLPoP/src/` (правило `../CLAUDE.md`). Ключевые
места: `seg000.c: load_lev_spr/play_level_2/init_game`, `seg005.c:
up_pressed/go_up_leveldoor`, `seg006.c: play_seq → SEQ_END_LEVEL`,
`seg002.c` (спецсобытия уровней), `data.h:835..850` (потабличные различия
уровней).
---
## 0. Что уже готово (не проектировать заново)
- **Формат и загрузчик уровня.** `pop_level.c/.h` читает сырой
`res200N.bin` (2305 Б) в отдельную EMM-страницу; путь — параметр
`pop_level_load(const char *)`. Мультиуровневость здесь стоит одной
функции формирования имени.
- **Стартовая позиция уровня** уже разобрана: `pop_level_start_room()`,
`pop_level_start_pos()`, `pop_level_start_dir()` — реализованы и пока
НЕ вызываются (см. `../roomtest/TASKS.md` L1-START).
- **Страж по данным уровня**: `pop_level_guard()` (порт `enter_guard`),
сохранение состояния между комнатами (`pop_guard_state_save`).
- **Палитра разложена по слотам ровно как в оригинале** (`pop_pack_kid.py`
`build_palette`): env 0x50, wall 0x60, pot 0x40, kid 0x70, меч 0x80,
страж 0x90. Это тот же раскрой, что `set_pal_arr(0x50/0x60)` в
`seg000.c:1140..1148`, — значит смена тайлсета не требует переиндексации
спрайтов Кида (см. §3).
- **Все 16 файлов уровней распакованы**: `../SDLPoP/data/LEVELS/res2000..
res2015.bin` (0 — демо-уровень).
---
## 1. Что реально различается между уровнями (замер по данным, не по памяти)
Таблицы из `../SDLPoP/src/data.h:840..847` + инвентарь тайлов, снятый прямо
с `res200N.bin` (маска `fg & 0x1F`):
| Ур. | Тайлсет | Страж | Новое против предыдущих |
|-----|---------|-------|--------------------------|
| 1 | dungeon | обычный | — (текущая база) |
| **2** | **dungeon** | **обычный** | **ничего нового: тот же набор объектов минус меч** |
| 3 | dungeon | СКЕЛЕТ | чомперы |
| 4 | palace | обычный | **тайлсет palace**, зеркало (спецсобытие `mirror_level`) |
| 5 | palace | обычный | — |
| 6 | palace | ТОЛСТЫЙ | падение на входе (спецсобытие) |
| 7 | dungeon | обычный | — |
| 8, 9 | dungeon | обычный | — |
| 10, 11 | palace | обычный | — |
| 12 | dungeon | ТЕНЬ | seamless-выход (комната 23), исчезающий меч |
| 13 | dungeon | ВИЗИРЬ | мышь, особый выход |
| 14 | palace | нет | — |
| 15 | dungeon | нет | финал |
Прямое следствие для порядка работ: **уровень 2 не требует ни одного нового
ассета и ни одной новой механики** — он проверяет ровно машинерию перехода.
Это и есть первый шаг.
Прочие потабличные различия, которые придётся завести массивами по 16:
`tbl_level_type` (тайлсет), `tbl_guard_type` (−1 = стражей нет),
`tbl_guard_hp`, `tbl_level_color` (вариантные палитры, 1.3), `tbl_entry_pose`.
---
## 2. Шаг 1 — машинерия перехода (цель: уровни 1 → 2 → 3) — **СДЕЛАН 2026-08-04**
> **Итог.** Всё в этом разделе портировано и проверено в MAME: уровень 1 →
> Shift+L → уровень 2 (комната 5, дверь захлопывается за спиной, большие
> колонны рисуются) → уровень 3. Разбор что именно сделано и что по
> уровню 2 осталось — `../roomtest/TASKS.md`, запись **L2**.
>
> Сверх плана пришлось доделать две вещи, без которых уровень 2 не играется:
> **`find_start_level_door`** (стартовый тайл уровня 2 — это правая половина
> двери уровня) и **большую склянку** `add_life` (тип зелья 2, комната 20).
Порядок именно такой; каждый пункт проверяем в MAME отдельно.
**2.1 Выход с уровня.** Портировать `up_pressed()` ветку двери
(`seg005.c:410..423`) + `go_up_leveldoor()` (`seg005.c:497`): дверь рядом
(при/за/перед персонажем) И `drawn_room != level.start_room` И створка
открыта полностью (`curr_room_modif >= 42` — вариант `fix_exit_door`) →
`Char.x = x_bump[...] + 10`, направление влево, `seq_70_go_up_on_level_door`.
Затем оживить опкод `0xF1 END_LEVEL` в `play_seq` (`../roomtest/pop_kid.c:418`
— сейчас пустой `break`): `++pop_next_level`, как `seg006.c:662`.
**2.2 Цикл уровня.** В `main()` после тика: `if (pop_next_level !=
pop_current_level) → load_level(pop_next_level)`. Порядок сноса/подъёма
состояния (порт `load_lev_spr` + `play_level_2`):
`pop_level_free` → `pop_level_load("LEVELS\\res200%d.bin")` →
`pop_trob_reset` → `pop_guard_reset` → сброс tile-override'ов
(`ovr_*` в `roomtest.c`) → `enter_room(pop_level_start_room())` →
`kid_init(поза/позиция/направление из данных уровня)`.
**HP через уровень переносится** (в оригинале `hitp_beg_lev`), не сбрасывать
в максимум — сверить с `seg000.c` `init_game`/`play_level_2`.
**2.3 Стражи по уровню.** Завести `tbl_guard_type[16]`/`tbl_guard_hp[16]`;
`-1` = стражей на уровне нет (уровни 14, 15) — `pop_guard_enter` обязан это
понимать, иначе на 14-м полезут стражи из мусора. Для шага 1 (уровни 2, 3)
достаточно обычного стража, но проверку `-1` заложить сразу.
**2.4 Чит «следующий уровень» (Shift+L).** Реализуется ровно тем же
`pop_next_level` — и без него отладка уровней превращается в прохождение
игры руками. Делать в этом же шаге, не позже (см. §4).
**Приёмка шага 1:** дверь уровня 1 → уровень 2 играется целиком → его дверь
→ уровень 3 стартует (чомперы могут быть ещё не портированы — тогда
фиксируем как известное ограничение, а не «баг»).
---
## 3. Шаг 2 — второй тайлсет (palace, уровни 4+)
**Ассеты.** `toolchain/pop_pack_bg.py` уже читает PNG каскадом
VDUNGEON→VPALACE (та же логика, что в игре), но печёт ОДИН набор атласов
(`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl` ≈ 75 КБ). Нужен второй
набор из VPALACE (`pal_env*.atl` / `pal_wall.atl`), плюс `torch_debris` —
тайл, который встречается только на palace-уровнях. По EMM это ещё ~6
страниц при бюджете ~3.3 МБ — не проблема.
**Палитра — главный технический вопрос, и он уже решён раскроем.**
Тайлсет живёт в слотах `0x50..0x5F` (env) и `0x60..0x6F` (wall); Кид, меч,
страж, склянки — в других слотах. Значит смена тайлсета = перезапись 32
записей палитры (`gfx_pal_set` на обе страницы, как `flash_bg` в
`roomtest.c`), а НЕ перезагрузка `kid.pal` и не переиндексация спрайтов.
Сделать `pal_dungeon.bin` / `pal_palace.bin` (по 32 записи) и грузить при
смене типа уровня. Проверить артефактом: скриншот palace-комнаты против
рендера `render_room.py` для того же уровня.
**Вариантные цвета уровней** (`tbl_level_color`, `level_var_palettes` — это
уже 1.3, в 1.0 их нет): по той же механике, тот же диапазон слотов. Решение
на будущее — сначала базовые два тайлсета, потом при желании цвета.
**Выбор набора в коде.** `pop_bg_load()` сейчас грузит фиксированные имена;
превратить в `pop_bg_load(type)` с двумя таблицами имён + выгрузка старых
атласов при смене типа (`atlas_free`). Переключение — только на границе
уровня, не в кадре.
---
## 4. Читы SDLPoP: что взять на следующем этапе
Из `../SDLPoP/README.md` (раздел Cheats). У нас уже есть: **K** — убить
стража, **I** — бессмертие (наш, в оригинале нет), **S** — выдать меч (наш),
**+/−** — обход комнат (`ROOMNAV`, наш).
**Брать сразу вместе с переходами уровней** (без них отладка дороже самой
работы):
| Чит | Что даёт | Цена |
|-----|----------|------|
| **Shift+L — следующий уровень** | единственный вменяемый способ тестировать уровни 2..15 | тривиально: `++pop_next_level` из §2.2 |
| **R — воскресить Кида** | у нас респавн по ↑ + таймаут; порт `resurrect` ближе к оригиналу и не мешает управлению | низкая |
| **Shift+S / Shift+T — +1 HP / +максимум** | отладка боёвки без «ровно трёх попыток»; честная замена нашему читу бессмертия | низкая, HP-машинерия уже есть |
| **[ и ] — сдвинуть Кида на пиксель** (debug-чит SDLPoP) | прямо бьёт в наш класс багов «окклюзия/шов на один пиксель» — воспроизведение позы без ловли момента | тривиально |
**Брать во вторую очередь:**
| Чит | Почему позже |
|-----|--------------|
| **H / J / U / N + Ctrl+B — смотреть соседние комнаты** | требует честной модели `drawn_room ≠ Kid.room` (наш S3-straddle, каркас есть: `update_kid_render_dx`). Зато потом заменяет самодельный `ROOMNAV` и попутно закрывает straddle-задачу |
| **Shift+W — медленное падение (feather)** | ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` уже есть, но не проверена ничем — чит станет её единственным тестом |
| **C / Shift+C — номера комнат** | у нас номер рисуется палочками именно потому, что текст тянет 2 КБ знакогенератора в W2 (`roomtest.c`). Ждёт своего шрифта |
**Не брать:** `Shift+I` (переворот экрана), `Shift+B` (blind mode) —
развлекательные, к отладке порта отношения не имеют. `/+` (время) — нужен
таймер уровня, которого у нас нет (Фаза 6).
**Отдельно, дорого, но очень ценно — `F6`/`F9` (quicksave/quickload точного
состояния).** Это сериализация `Char` + `room_modif` всех комнат + trob'ов +
состояния стражей. Даёт то, чего нам сейчас сильно не хватает:
воспроизводимый регресс в MAME («вот кадр, где баг») вместо ручного подхода
к позиции. Кандидат сразу после того, как заработают уровни.
---
## 5. Риски и что проверить артефактом до кодинга
1. **Размер кода.** Замер сборки 2026-08-01: `_CODE` 25 119 Б,
куча ~2.4 КБ, банк 2 (`pop_bg`) 13 792 / 16 384, банк 3 (`pop_map`)
6 331, банк 1 (`guards`) 1 896, банк 4 (`pop_gdraw`) 2 236. Чомперы,
зеркало, скелет и второй тайлсет пойдут в банк 2 — там осталось 2.6 КБ.
**Прежде чем начинать §3, посчитать, куда лягут новые тайлы**, иначе
повторится история «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные
номера банков есть (5+), гранулярность — файл.
2. **Спецсобытия уровней** (`seg002.c`: `level3_set_chkp`, `sword_disappears`,
`Jaffar_exit`, зеркало, мышь) — их НЕ надо портировать заранее. Для
уровней 2 и 3 нужен только чекпойнт уровня 3. Остальное — по мере
подхода к уровню.
3. **Чомперы** (уровень 3 и почти все дальше) — отдельная механика
(`animate_chomper` + коллизия + смерть); шаблон работы тот же, что у
пик/ворот, см. `gates_spikes_plan.md`.
4. **`tbl_guard_type = -1`** на уровнях 14/15: без проверки страж
«появится» из неинициализированных данных.
5. **Уровень 0 (демо)** существует в данных, но в скоуп не входит.
-143
View File
@@ -1,143 +0,0 @@
# Loose floors (проваливающиеся полы) — план порта
Разбор SDLPoP (seg007 loose/trob/mob, seg008 draw_loose). ПЛАН, ещё не
реализовано. Тайл в комнате 1: `[2,6] = 0x0B = tiles_11_loose`.
## 1. Хранение состояния
- **Тип тайла**: `curr_room_tiles[tilepos] & 0x1F == 11` (tiles_11_loose).
Бит `0x20` = «solid» loose (авто-падающий вариант, ур.13 — от шага НЕ
падает). После падения тайл → `0` (tiles_0_empty).
- **Модификатор** `curr_room_modif[tilepos]` = состояние анимации:
- `0` — покой (обычный loose-пол);
- `0x80..0x83`**трясётся** (бит7); за ~4 кадра затухает обратно в 0;
- `1..11`**обратный отсчёт до падения** (на нём что-то стоит);
достигает `loose_floor_delay = 11` → падает.
## 2. Анимация тряски (shake) — когда включается
- **Триггер = do_knock** (seg007:0FE0): на ЖЁСТКОМ приземлении в кадрах
посадки играет `SEQ_KNOCK_DOWN` → взводит `knock``check_knock()`
`do_knock(room, curr_row (knock>0))`.
- `do_knock(room, row)`: по всем колонкам ряда — если тайл loose →
`loose_make_shake()`.
- `loose_make_shake()` (seg007:0FB4): если `modif==0` (и не ур.13) →
`modif = 0x80`, `add_trob(type 1)`.
- **Отсюда кейс пользователя**: Kid падает/приземляется на `[2,4]`
do_knock трясёт ВСЕ loose-тайлы ряда 2 → `[2,6]` трясётся. (Через
knock-смещение ряда может задеть и соседний ряд.)
- `animate_loose` (кадрово): `++modif`; при бите7 трясёт до `>=0x84`
сброс в 0, `trob.type=-1`. `loose_shake()` играет звук
(sound 20/21/22) по таблице `loose_sound[]`.
## 3. Анимация падения (fall) — когда включается
- **Триггер = make_loose_fall(1)** (seg007:0EF6), вызывается когда:
- Kid СТОИТ на loose-тайле — `check_press()` (seg006): кадр с
FRAME_NEEDS_FLOOR над loose → make_loose_fall(1);
- зацеп/подтягивание на loose (`check_grab`, `check_jump_up`);
- пробой сверху: кадр 79 (jumphang) над loose → make_loose_fall(1);
- авто-падающие (ур.13) — `make_loose_fall(-(prandom&0x0F))`.
- `make_loose_fall(modifier)`: если НЕ solid (`tiles & 0x20 == 0`) и
`(sbyte)modif <= 0``modif = modifier`, `add_trob(type 0)`.
- `animate_loose`: `++modif`; когда `modif >= 11` (loose_floor_delay) →
`remove_loose()` (тайл → empty) + `add_mob()` (спавн падающего куска).
## 4. Падающий кусок (mob)
- `add_mob()` кладёт `curmob` в `mobs[]` (до 14). `do_mobs()` каждый
кадр: `move_mob()` (гравитация, y растёт) + `check_loose_fall_on_kid()`
(урон Киду/страже, если попал).
- Приземление куска → тайл под ним `curr_room_tiles[...] = tiles_14_debris`
(seg007 move_mob:1053). Т.е. **loose(11) упал → сверху empty(0), снизу
debris(14)**.
## 5. Отрисовка по статусу
- Куски тайла: `loose_fram_left[]={41,69,41,70,70,41,41,41,70,70,70,0}`,
`loose_fram_right[]={42,71,...}`, `loose_fram_bottom[]={43,73,...}`
(env-спрайты, seg008:518/596/608).
- Индекс кадра = `get_loose_frame(modifier)` (seg008): `0` = ровный
(41/42/43); `1..10` = дрожащие варианты (69–74); при бите7/большой
задержке — низкие индексы.
- **До падения**: рисуем loose с `get_loose_frame(modif)` (0 = ровно,
иначе колеблется). **После**: сверху empty, снизу debris(14) — обычная
статическая отрисовка (у нас уже есть tile 0x0E/14 debris в tile_table).
## 6. Что нужно в нашем движке (сейчас НЕТ)
Наш `pop_bg` рисует комнату СТАТИЧЕСКИ один раз. Loose-полы требуют
**динамического тайлового слоя**:
1. **Массив модификаторов** `room_modif[30]` (у нас есть `bg[30]` — можно
переиспользовать/рядом) — состояние каждого тайла.
2. **Очередь trob** (список анимируемых тайлов) + `animate_loose` пер-кадр
→ перерисовка ТОЛЬКО изменившихся тайлов (как heal-прямоугольник Kid).
3. **make_loose_fall / do_knock / loose_make_shake** — триггеры (из
физики Kid: приземление→knock, стойка на loose→fall).
4. **mob-система** (падающий кусок): минимум 1–2 mob'а, гравитация,
приземление → debris. Урон Киду (`check_loose_fall_on_kid`) — можно
Фазой 2.
5. **Перерисовка тайла**: `draw_tile(row,col)` у нас уже умеет loose
(`code==11`, `loose_fram_*` в env) — нужно вызывать его выборочно с
текущим модификатором (сейчас draw_tile берёт статический bg).
**Порядок реализации (предложение):**
- L1: room_modif[] + выборочная перерисовка тайла по модификатору
(draw_loose с get_loose_frame) — статика→динамика одного тайла.
- L2: trob-очередь + animate_loose (тряска по do_knock на приземлении).
- L3: make_loose_fall (стойка на loose) + отсчёт + remove → empty.
- L4: mob (падающий кусок → debris снизу).
- L5: урон Киду от падающего куска.
## Конкретика из SDLPoP (сверено 2026-07-18, готово к реализации)
Таблицы (seg008.c), индекс = `get_loose_frame(modif)`:
- `loose_fram_left[] = {41,69,41,70,70,41,41,41,70,70,70,0}`
- `loose_fram_right[] = {42,71,42,72,72,42,42,42,72,72,72,0}`
- `loose_fram_bottom[]= {43,73,43,74,74,43,43,43,74,74,74,0}`
- `get_loose_frame(m)`: если `(m&0x80)` (или delay>11) → `m&=0x7F; if(m>10) return 1;``return m;`
- `y_loose_land[] = {2,65,128,191,254}` (mob), `loose_floor_delay = 11`.
Триггеры (call-sites):
- **make_loose_fall(modifier=1)** — из `check_press()` (seg006): когда Kid
СТОИТ на тайле (FRAME_NEEDS_FLOOR, action < hang_climb / turn / bumped) и
`get_tile_at_char()==11`; ИЛИ `frame==79` (прыжок вверх) и
`get_tile_above_char()==11` (пробой сверху). `tile_is_floor(11)==1`
Kid стоит на loose (start_fall НЕ зовётся). Тело:
`if(!(tile&0x20) && (sbyte)modif<=0){ modif=modifier; add_trob(type0); }`
- **do_knock(row)** — на ЖЁСТКОМ приземлении (SEQ_KNOCK_DOWN→check_knock,
seg003): по всем колонкам ряда `if(tile==11) loose_make_shake()`
(`if(modif==0){ modif=0x80; add_trob(type1); }`).
- **animate_loose** (каждый кадр, seg007:816): `++modif`; если `&0x80`
(тряска): `if(modif>=0x84){modif=0; trob=-1;}`; иначе (отсчёт):
`if(modif>=11){ remove_loose(tile→0); trob=-1; add_mob(); } else shake`.
## Интеграция в наш движок (roomtest) — подход
Наш движок ПЕКЁТ комнату один раз (двойной буфер: своя ОЗУ-копия на
страницу). Loose требует динамики + общего состояния pop_map↔pop_bg:
1. **Общая МУТАБЕЛЬНАЯ копия комнаты**: roomtest.c держит `uint8_t
room_fg[30]` (копия room1_fg) и передаёт ОДИН указатель и в
`pop_room_draw`, и в `pop_map_set` → мутации loose видны обоим.
(Сейчас g_fg — `const`; сделать неконстантным.)
2. **Состояние**: `uint8_t pop_loose_modif[30]` (0 / 0x80.. / 1..11).
3. **Модель** (pop_map): `check_press()` в `pop_phys_tick` (make_loose_fall
при стоянии на 11), `animate` каждый кадр.
4. **Перерисовка** (pop_bg, на back-странице каждый кадр):
- тряска/отсчёт (код всё ещё 11): `gfx_heal(tile rect)` (вернуть
печёный фон) + нарисовать loose-кадр (left/right/bottom по
get_loose_frame) банком SPRITE;
- падение (11→0): mutate `g_fg[pos]=0` + «запечь пустоту» на ОБЕИХ
страницах (bake-счётчик 2: чёрный bar + draw_tile empty банком NORMAL
на текущей странице 2 кадра подряд) → дальше heal показывает пусто.
5. **L4 mob**: падающий кусок → debris(14) снизу (y_loose_land); пока
отложено — упавший loose = пусто. **L5** урон — позже.
Риск: перерисовка динамического тайла в двойном буфере (per-page heal +
bake) — единственное тонкое место; остальное — прямой порт логики выше.
## Связанные
Триггеры завязаны на физику Kid ([[pop_hang_state]] check_press/check_grab,
приземление land/SEQ_KNOCK_DOWN). Отрисовка — [[pop_fore_layer]] /
[[pop_background_strategy]] (draw_tile уже знает loose_fram_*).
+148
View File
@@ -0,0 +1,148 @@
# Генераторы псевдослучайных чисел: запасные варианты
Что сейчас стоит в порте, какие есть альтернативы и сколько на них реально
можно выиграть. Заготовка на случай, если упрёмся в бюджет кадра —
**сейчас менять ничего не нужно**.
## Что стоит сейчас
`pop_geom.c`, ветка `POP_PRANDOM_EXACT=1` (по умолчанию) — LCG оригинала
`s = s*214013 + 2531011`, шаг написан на Z80-ассемблере (единственное такое
место в порте). Схема Горнера по разреженной записи константы:
```
214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3
```
17 удвоений, три сложения, одно вычитание; величина `3*s`, нужная в конце,
попадается по дороге на втором шаге. Тело — **≈1 020 тактов** по статическому
подсчёту. Бит-в-бит совместим с SDLPoP, поэтому по картинке можно сверяться
с эталоном.
Вторая ветка, `POP_PRANDOM_EXACT=0` — xorshift16 + шаг Вейля на C.
Совместимость теряется.
Замер в MAME, комната 3, 175 кадров (медиана кадра):
| вариант | кадр | prandom → torch_draw |
|---|---|---|
| C, бит-в-бит (16-битные половины) | 403 632 | 10 933 |
| C, xorshift16 + Вейль | 397 986 | 7 927 |
| **asm, бит-в-бит (сейчас)** | **400 800** | **9 331** |
## Вариант A — комбинированный LFSR + LCG, ~148 тактов
Период > 4 млрд (lcm(65536, 65535) ≈ 4.29e9), младшие биты не вырождены.
```z80
prng16:
seed1=$+1
ld hl, 9999
ld b, h
ld c, l
add hl, hl
add hl, hl
inc l
add hl, bc
ld (seed1), hl
seed2=$+1
ld hl, 987
add hl, hl
sbc a, a
and 101101b
xor l
ld l, a
ld (seed2), hl
add hl, bc
ret
```
Устройство: `seed1` — LCG `x = 5x + 1` (по модулю 2^16; `inc l` вместо
`inc hl` — экономия байта, на период не влияет). `seed2` — 16-битный
LFSR Галуа: сдвиг влево, и если выехала единица, XOR младшего байта с маской
`0x2D` (примитивный многочлен `x^16 + x^5 + x^3 + x^2 + 1`). На выходе
сумма обоих состояний — она и разрушает регулярность младших бит LCG.
**Что мешает взять как есть:** сиды зашиты в код (SMC), а нам нужны ДВЕ
независимые последовательности — раскладка кладки и анимация тайлов.
Пришлось бы передавать состояние через указатель, как сейчас у `pop_prandom`
(это +20…40 тактов, не принципиально).
## Вариант B — xorshift(7,9,8), ~86 тактов
Самый быстрый, период 65535.
```z80
xrnd:
ld hl, 1 ; seed must not be 0
ld a, h
rra
ld a, l
rra
xor h
ld h, a
ld a, l
rra
ld a, h
rra
xor l
ld l, a
xor h
ld h, a
ld (xrnd+1), hl
ret
```
**Две оговорки.** Ноль — неподвижная точка, а сид раскладки кладки у нас
считается как `номер комнаты + смещение ряда + колонка` и вполне может
оказаться нулём: нужен либо guard, либо шаг Вейля поверх. И тот же SMC-сид,
что в варианте A.
## Чего НЕ брать: RND из Apple II
Оригинальный `Prince-of-Persia-Apple-II`:
```
RNDseed := (5 * RNDseed + 23) mod 256
```
```asm
RND
lda RNDseed
asl
asl
clc
adc RNDseed
clc
adc #23
sta RNDseed
rts
```
Полный период 256 (`a ≡ 1 mod 4`, `c` нечётное), и для своего движка он
работал. Нам не годится: у LCG по модулю 256 младшие биты вырождены — бит 0
просто чередуется. Наши вызовы это увидят: раскладка кладки берёт
`prandom(1)` (ОДИН бит) и `prandom(4)`, то есть вместо шума получилась бы
аккуратная шахматка.
## Сколько реально можно выиграть
Меньше, чем кажется по числам 86/148 против 1 020. Тело генератора — уже не
весь расход: остаются обёртка `pop_prandom`, приведение к диапазону
`pop_rnd_fit` и ABI вызова. Верхняя граница выигрыша видна из замера выше:
между нынешним asm-LCG и самым дешёвым из проверенных вариантов разница
**2 814 тактов за кадр (0.65 %)** при двух вызовах за кадр, и это ПОТОЛОК —
любой из вариантов A/B ниже него не опустится.
Порядок действий, если понадобится:
1. Сначала убрать обёртки: слить `pop_rnd_fit` в ту же asm-процедуру, чтобы
на вызов приходился один `call`, а не три. Это ничего не ломает и не
трогает совместимость с эталоном.
2. И только если этого мало — менять генератор, начиная с варианта A
(качество последовательности у него не хуже LCG, в отличие от B).
Важно помнить: число вызовов вырастет с боёвкой. Сейчас их два за кадр
(факелы), а `guard_advance` / `guard_block` / `guard_strike` дёргают
`prandom(255)` каждый по разу за кадр боя — то есть при драке станет 5–6, и
цена вопроса вырастет во столько же раз.
+20 -2
View File
@@ -1,5 +1,21 @@
# PoP roomtest — модель `kid_room ≠ drawn_room` (баг #4)
> **Статус: ЖИВОЙ ПЛАН, сделан частично (сверено 2026-08-01).**
> - **S1 — сделан:** `kid_room` заведён отдельно от `cur_room`,
> `update_kid_render_dx()` (`roomtest.c`) даёт рендер-смещение ∓140, а
> `pop_kid_set_render_dx` применяет его в отрисовке. Фактически это пока
> каркас: `enter_room` держит `kid_room == cur_room`, так что смещение
> всегда 0.
> - **S2/S3/S4 — не сделаны и не срочны.** Исходный повод (баг #4,
> пинг-понг у шва) закрыт иначе — поправкой odd-pixel в
> `char_x_forward_edge` + `pop_leave_timer` (разбор корня —
> `../roomtest/bug_closed.md`, BUG-SEAM-PINGPONG).
>
> **Зачем документ остаётся.** Полная straddle-модель понадобится для:
> (а) читов осмотра соседних комнат `H/J/U/N` (`levels_plan.md` §4),
> (б) сцен, где Кид и страж в разных комнатах кадра, (в) остатков окклюзии у
> шва (S4). Брать из `../roomtest/TASKS.md`, когда дойдёт очередь.
Порт straddle-модели SDLPoP: персонаж может находиться в СОСЕДНЕЙ комнате,
пока на экране ещё ТЕКУЩАЯ (drawn_room). Источник истины — SDLPoP.
@@ -62,7 +78,9 @@ drawn_room с `curr_col=-1/10` + снапшоты соседей `g_lcol/g_rcol`
### S4. Полировка
- Окклюзия/ceiling у шва при straddle, BUG-OCCL-1 (глубина), правый край.
## Связанные баги (bug_list.md)
## Связанные баги — все ЗАКРЫТЫ (`../roomtest/bug_closed.md`)
BUG-CEIL-1 (руки при прыжке вверх), BUG-CEIL-2 (loose в потолке),
BUG-CEIL-3 (потолок над анимируемыми воротами), BUG-OCCL-1 (тень дальней
колонны). Memory: `pop_seam_room_model`.
колонны) — починены без полной straddle-модели. То есть S4 «полировка
окклюзии» осталась актуальной только для окклюзии У ШВА при straddle.
Memory: `pop_seam_room_model`.
@@ -1,335 +0,0 @@
# roomtest — план оптимизации по размеру + переход на huge/banking
Статус: **план для отдельной сессии** (2026-07-21). Документ самодостаточный
(рассчитан на старт с пустого контекста). Цель — освободить место: сейчас
`applications/PoP/roomtest` в режиме `small` почти упёрся в потолок 32 КБ.
Правило проекта (`applications/PoP/CLAUDE.md`): механику/раскладку памяти
сверять с исходником и с memory (`sprinter_memory_modes`, `sdcc_banking`,
`bank_local_data_pattern`, `pop_banking_architecture`). Перед оптимизацией —
`make size-check`-подобный замер до/после (здесь — руками по `.map`).
---
## 0. Как мерить
- Сборка: `cd applications/PoP/roomtest && make roomtest.exe` (режим `small`,
`--gfx 256`). Карта символов — `.sprinter-cc-roomtest/roomtest.map`
(адреса сдвигаются при каждой пересборке!).
- Размеры областей — из `.map` (`_CODE`, `_DATA`, `_BSS`).
- Вклад модулей в `_CODE` — атрибуция диапазонов между символами по модулю
(скрипт-однострочник на python в истории; группировать символы `.map` по
3-й колонке-модулю и суммировать `addr[i+1]-addr[i]`).
- MAME-проверка после изменений раскладки ОБЯЗАТЕЛЬНА (режимы памяти —
типовой источник «молча ломается», см. `sprinter_memory_modes`).
## 1. ТЕКУЩЕЕ СОСТОЯНИЕ (замер 2026-07-21)
Режим `small` = единое пространство **W1+W2 = 0x4000..0xBFFF (32 КБ)**; CODE с
0x4100, DATA/BSS/heap цепляются ЗА CODE автоматически (`--data-loc 0` =
linker chains), стек — вверху W2.
| Область | Размер | Диапазон |
|---------|--------|----------|
| `_CODE` | ~27 250 Б (0x6A6F) | 0x41000xAB6F |
| `_HOME` | 227 Б | 0xAB6F |
| `_DATA` | 3 449 Б (0x0D79) | 0xAC780xB9F1 |
| `_BSS` | 290 Б | |
**Образ ≈ 31.2 КБ; до верха W2 (0xBFFF) остаётся ≈ 1.3 КБ на кучу+стек.**
Куча в roomtest почти не используется (атласы/уровень — в EMM-страницах),
но запас критично мал.
### Вклад модулей в _CODE (по .map, приблизительно)
```
7003 pop_bg (вся отрисовка тайлов/слоёв/wall_pattern)
6326 pop_kid (из них ~3745 Б — СТАТ. ТАБЛИЦЫ kid_data.h, см. ниже)
3762 pop_map (коллизия/физика/пики)
1455 pop_trob (кнопки/ворота/пики-каркас)
1224 pop_level (загрузка уровня, doorlink)
914 roomtest (главный цикл)
~7000 libc/libbgi (gfx_blit*, atlas_load, kbd_raw, open/read, irq, div/mul…)
```
### Крупные СТАТИЧЕСКИЕ данные (сейчас в _CODE как `const`)
- **`kid_data.h` — самый большой кусок, ~3.7 КБ**, живёт в _CODE (атрибутируется
pop_kid):
- `kid_seqtbl[2310]` — байткод последовательностей (play_seq).
- `kid_frames[241]` × 5 Б = 1205 Б — таблица кадров (image,dx,dy,flags,sword).
- `kid_seq_off[115]` × 2 Б = 230 Б — смещения seq.
- `pop_bg`: `tile_table[31]`×12 = 372 Б + ~20 мелких const-таблиц (COL_XH,
WALL_FRAM_*, SPIKES_FRAM_{RIGHT,LEFT,FORE}, LOOSE_FRAM_*, DOOR_FRAM_SLICE,
BLUELINE_*, LPOS/RPOS, FLOOR_LEFT_OVERLAY) — суммарно ~0.50.7 КБ.
- `pop_map`: `x_bump[20]`, `y_land[5]`, `wall_dl/dr`, `dir_front/behind` — ~100 Б.
- В `_DATA` (W2, не CODE): `room_modif[24][30]`=720 Б + копии LINKLOC/LINKMAP=512 Б
(pop_trob/pop_level) + рабочие массивы roomtest.
---
## 2. ПУТЬ A — оптимизация КОДА (без смены модели)
1. **Компиляторные флаги** (`bin/sprinter-cc`): попробовать `--opt-code-size`
у SDCC и подобрать `--max-allocs` (сейчас дефолт 100000; меньше = мельче код,
но медленнее компиляция; см. `mdview2_size_budget` — там `--max-allocs`
давал −1.4 КБ). Замерить каждый модуль отдельно.
2. **Дедуп подстановки нажатой кнопки**: логика `opener→floor / closer→stuck`
по таймеру связи ПРОДУБЛИРОВАНА в `draw_tile` и `fore_tile` (pop_bg.c).
Вынести в `static inline`/helper `subst_pressed_button(code,mod)`.
3. **wall_pattern / prandom** (pop_bg): 32-битный LCG (`unsigned long`) —
пользователь не любит 32-бит (см. `avoid_32bit_arith_z80`); но это PRNG
оригинала (нужен для совпадения раскладки стен) — трогать осторожно, только
если найдётся 16-битный эквивалент, дающий ТУ ЖЕ последовательность.
4. **Ревизия дублей**: `y_to_row` определён в pop_bg И pop_map; мелкие
геометрические хелперы дублируются — свести в один internal-модуль.
5. `/simplify`-проход по последним правкам Фазы B (pop_trob/pop_bg).
Ожидаемый выигрыш пути A: единицы–первые сотни байт на пункт; в сумме,
оптимистично, ~1–2 КБ. Недостаточно как единственная мера.
---
## 3. ПУТЬ B — вынос СТАТ. ДАННЫХ в EMM-страницы (с атласами / с level)
**Идея (по замечанию пользователя):** EMM-страницы атласов и уровня
использованы лишь частично (страница 16 КБ, данных меньше), в «хвосте» —
свободное место. Часть `const`-таблиц можно хранить ТАМ, а не в _CODE/_DATA,
если таблица читается ИМЕННО ТОГДА, когда нужная страница уже в W0.
**Механика W0:** атласы блитятся из W0 (`_gfx_w0_state`: `_gfx_w0_cur`
спрайт-страница в W0; ISR-стаб `_gfx_w0_isr` возвращает её после прерывания).
Уровень (pop_level) маппит свою страницу в W0 на время извлечения
(`gfx_w0_map`/`gfx_w0_unmap`). → пока страница в W0, CPU может читать и
данные из неё по адресам 0x0000..0x3FFF.
**Категоризация таблиц по W0-контексту (задача сессии — уточнить по каждой):**
- **(a) Читается, когда в W0 АТЛАС** → хранить в свободном хвосте атлас-страницы.
Кандидаты — таблицы, которые нужны В МОМЕНТ блита конкретного атласа.
ГРАБЛИ: `draw_tile` читает `tile_table`/`COL_XH` ДО блита (чтобы решить, какой
спрайт/куда) — в этот момент в W0 может быть ДРУГАЯ страница (DSS/предыдущий
атлас). Т.е. большинство draw-таблиц читаются ВНЕ W0-атлас-контекста →
«в лоб» не переносятся. Нужен аудит КАЖДОГО чтения: гарантирована ли нужная
страница в W0 в этот тик.
- **(b) Читается, когда в W0 LEVEL** → хранить с уровнем (в его странице; там
~13.7 КБ свободно из 16). Кандидаты: константы декода doorlink, разбор
комнат — всё, что pop_level делает под `gfx_w0_map(lvl_page)`.
- **(c) Нужна и там, и там** → дублировать в обеих страницах ЛИБО оставить
резидентной (если дубли дороже экономии).
- **(d) Читается в чистой ЛОГИКЕ (W0 не важен)** → перенос требует ЯВНОГО
`gfx_w0_map` на каждое чтение (дорого, особенно в горячих циклах) → как
правило оставить резидентной.
**Отдельно `kid_data.h` (3.7 КБ — самый жирный кандидат):**
- `kid_frames`/`kid_seqtbl` читаются в `play_seq` (ЧИСТАЯ логика, каждый тик) И
в `kid_draw` (блит из kid-атласа, kid-страница в W0). Т.е. частично (a),
частично (d). Перенос всей таблицы в kid-атлас-страницу заставит `play_seq`
делать `gfx_w0_map` на каждый шаг байткода → замерить стоимость (может убить
бюджет спрайтов, см. `sprite_engine_perf`). Вариант: держать в EMM отдельной
страницей данных Kid и маппить один раз на кадр вокруг kid_tick+kid_draw.
- Это самый большой одиночный выигрыш (−3.7 КБ из _CODE), но и самый рискованный
по скорости — приоритетный к ПРОТОТИПИРОВАНИЮ и замеру.
**Паттерн переноса writable/const данных в банк/страницу:** см. memory
`bank_local_data_pattern` (--codeseg/--constseg/--dataseg BANKn + trampoline-fix
+ mkexe -p 0) и `sdcc_static_storage_gotcha`.
### 3.1 Свободное место в страницах (замер 2026-07-21, страница = 16384 Б)
```
BG-атласы: размер свободно
pop_env0.atl 10578 5806
pop_env1.atl 12449 3935 <- САМАЯ ТЕСНАЯ из bg
pop_env2.atl 10798 5586
pop_env3.atl 5032 11352 <- много места
pop_env4.atl 8498 7886
pop_wall.atl 11543 4841
pop_fore.atl 7763 8621
Kid-атласы (28 стр): free min=6161 max=15452 avg=9722
Level (res2001.bin): данные 2305, свободно ~13823 (16384 0x100 стаб 2305)
```
**Выводы по вместимости:**
- **Макс. данных в ОДНОМ атлас-банке = свободный хвост ЭТОЙ страницы** (см.
таблицу). Связывающее ограничение — самая тесная нужная страница (env1 =
3935 Б; не перегружать её).
- Если страница будет маппиться в **W0** — минус ~0x100 Б на ISR-стаб (как
level). Атлас-страницы стаб УЖЕ содержат (atlas_load патчит) → данные класть
в хвост ПОСЛЕ атласа.
- **`kid_data.h` (3.7 КБ) влезает в kid-страницу** (min free 6161) или в
отдельную выделенную страницу данных Kid — предпочтительно отдельную (маппить
раз на кадр, не конфликтуя с kid-атласами блита).
- **Level-таблицы** — вагон места в level-странице (~13.8 КБ).
- **BG draw-таблицы** (~0.7 КБ) влезут в env3/fore/env4 (много free), НО см.
граблю W0-контекста в §3(a) — читаются ли они, когда нужная страница в W0.
- **Выделенная страница ТОЛЬКО под данные** (не делить с атласом) = до ~16 КБ
(−0x100 стаб при W0-маппинге). EMM-бюджет это позволяет (см.
`sprinter_emm_budget`: 215/3440 КБ free на старте).
- **Принудительно уменьшать макс. атлас (репак мельче) — КРАЙНИЙ случай:** это
резко поднимет число атлас-банков (сейчас 5 env-страниц адресуются как id>>5;
дробление ломает эту адресацию и множит страницы). Сначала использовать
СУЩЕСТВУЮЩИЙ свободный хвост и отдельные data-страницы.
---
## 4. ПУТЬ C — переход на huge (banked code)
### 4.1 Что такое huge сейчас (`bin/sprinter-cc`, `runtime/crt0_banked`)
- `--memory huge`: `MODE_CODE_LOC=0x4100`, **`MODE_DATA_LOC=0x8000` (ФИКС.)**,
banked code в W3. crt0_banked, как crt0_small, авто-детектит W2.
Помечено `[TODO]` — не обкатано.
- Отличие от small: small цепляет DATA сразу за CODE (`--data-loc 0`); huge
ФИКСИРУЕТ DATA на 0x8000.
### 4.2 ТРЕБОВАНИЕ (по пользователю): huge должен переносить DATA динамически
Сейчас huge жёстко кладёт DATA на 0x8000. Если РЕЗИДЕНТНЫЙ CODE вылезет за
0x8000 (W1 = только 0x4000..0x7FFF ≈ 16 КБ; резидент > 16 КБ лезет в W2) →
коллизия с DATA. **Надо научить huge класть DATA динамически ЗА резидентным
CODE (как small: `--data-loc 0` + crt0 считает старт), а не на фикс 0x8000.**
Тогда huge = «small-раскладка резидента (W1+W2, DATA за CODE) + ДОП. код в
банках W3». Это первый пункт работ по huge.
### 4.3 КОНФЛИКТ: графика тоже хочет W3 (ключевой риск)
`pop_banking_architecture` прямо говорит: **графику нельзя в W3** (блиты/атласы
используют окна; см. §4.5). Поэтому в банки W3 можно выносить ТОЛЬКО
НЕ-графические блоки, и такой банк НЕ должен во время своего исполнения держать
графику в W3. Если W3-банкованная функция ЗОВЁТ графику (которой нужен W3),
трамплин обязан сохранить/восстановить банк вокруг вызова (проверить, что
banking-ABI это делает — `sdcc_banking`). Альтернатива без этого риска —
**big + BANK_W1** (банк кода в W1, не W3), рекомендованная в
`pop_banking_architecture` именно из-за W3-графики. Сессия должна выбрать:
huge(W3) с аккуратным save/restore ИЛИ big(BANK_W1).
### 4.4 Какие блоки МОЖНО вынести (не работают с графикой напрямую)
Замер graphics-ref по модулям (grep `gfx_|blit|env_b|wall_b|fore_b|setfillstyle|
bar(|GFX_BANK|initgraph`):
```
pop_bg.c : 83 — РЕЗИДЕНТ (вся отрисовка)
roomtest.c : 23 — РЕЗИДЕНТ (главный цикл + флип страниц)
pop_level.c : 17 — использует gfx_w0_map (W0, не W3-блиты) — ПОГРАНИЧНЫЙ
pop_kid.c : 12 — kid_draw = графика; НО play_seq — чистая логика (можно split)
pop_ctrl.c : 0 — КАНДИДАТ В БАНК (ввод/диспетчер control)
pop_map.c : 0 — КАНДИДАТ В БАНК (коллизия/физика, ~3.8 КБ) — лучший по объёму
pop_trob.c : 0 — КАНДИДАТ В БАНК (кнопки/ворота/пики-логика)
```
- **Лучшие кандидаты в W3-банк(и): pop_map + pop_trob + pop_ctrl** (нет прямой
графики; вместе ~5.3 КБ CODE). Освобождают резидент → он влезает в W1.
- **Осторожно с межбанковыми вызовами:** pop_map/pop_trob ЗОВУТ pop_bg
(перерисовка loose/пик/кнопок/шва) и pop_kid (play_seq/kid_set_seq). Это
кросс-банк вызовы через трамплин (`sdcc_banking`: стек +3 байта, виртуальный
24-битный адрес). Правило `pop_banking_architecture`: «один файл = один банк
= прямые вызовы», main резидентен. Проверить, что трамплин сохраняет W3
вокруг вызова в графический pop_bg (см. §4.3).
- **pop_kid split** (по желанию): вынести play_seq/seqtbl-интерпретатор
(логика + таблицы kid_data.h) в банк, оставить kid_draw/kid_heal резидентными.
Даёт и −код, и −данные из резидента, но требует аккуратного разделения TU
(1 функция = 1 модуль, см. `libc_one_function_per_module`).
- **pop_level: пограничный** — не блитит, но маппит уровень в W0; банковать
можно, если W0-логика совместима с трамплином (проверить ISR-стаб взаимодействие).
### 4.5 Почему графику нельзя в W3 (контекст)
Блиттер держит спрайт-страницу атласа в **W0** (`_gfx_w0_state`,
`_gfx_w0_isr`). Ускоритель/адресация видео — отдельная тема (см.
`sprinter_accelerator`, `sprinter_graphics`). W3 в banked-раскладке — окно
кода-банка; смешивать с окном, которое графика перемапливает, нельзя без
save/restore. Детально — `pop_banking_architecture`, `graphics_constraints`.
---
## 5. РЕКОМЕНДУЕМЫЙ ПОРЯДОК РАБОТ (для след. сессии)
1. **Замер-базлайн** (CODE/DATA/BSS + per-module) — зафиксировать до.
2. **Путь A** дешёвые пункты (флаги, дедуп кнопки, дедуп y_to_row) — быстрый 1..2 КБ.
3. **huge §4.2**: научить huge класть DATA динамически (как small) — инфраструктурный
пререквизит, без него банкинг не даст гибкости. Обкатать в MAME на текущем
резиденте (пока без выноса — просто huge-раскладка = small + пустой W3).
4. **huge §4.4**: вынести pop_map (+pop_trob, +pop_ctrl) в W3-банк(и); проверить
кросс-банк вызовы в pop_bg (§4.3) в MAME. ЛИБО выбрать big+BANK_W1.
5. **Путь B** (по остатку нужды): прототип выноса `kid_data.h` в EMM-страницу
Kid с маппингом раз на кадр; замерить скорость (`sprite_engine_perf`).
Затем аудит draw-таблиц по W0-контексту (§3 a/b/c/d).
## 6. Ссылки
- `bin/sprinter-cc` (§162+ — резолв memory-mode → CODE_LOC/DATA_LOC).
- `runtime/crt0_small.*`, `runtime/crt0_banked.*`, `runtime/bank.s`.
- memory: `sprinter_memory_modes`, `memory_modes_implemented`,
`setwin2_for_w2_alloc`, `sdcc_banking`, `bank_local_data_pattern`,
`pop_banking_architecture`, `avoid_32bit_arith_z80`,
`libc_one_function_per_module`, `sprite_engine_perf`, `mdview2_size_budget`.
- `applications/PoP/roomtest/bug_list.md` — открытые баги Фазы B (не блокируют
оптимизацию, но держать в уме при рефакторе pop_map/pop_bg).
---
## 7. Лишние блиты в горячем пути (добавлено 2026-07-27)
Найдено при разборе окклюзии по эталону SDLPoP: **наш «передний слой» рисовал
спрайты, которых в оригинале там нет** — это и артефакты, и лишняя работа
каждый кадр. Исправлено: `fore_tile` (вызывается для КАЖДОГО тайла футпринта
Kid, обычно 2–4 за кадр) рисовал ещё и `bottom_id` — переднюю кромку пола; в
оригинале `draw_tile_fore` (seg008:690) добавляет только `add_foretable`-часть,
а `bottom` идёт через `draw_tile_bottom` в backtable (ПОД персонажем).
Итог: −2..4 блита за кадр, `_CODE` −388 Б, ушла «тень» у основания колонны.
**Что проверить тем же методом (по одному вопросу к каждому месту: а есть ли
этот спрайт в оригинале в ЭТОЙ таблице?):**
1. `pop_room_draw`/`draw_tile` — вызовы на входе в комнату не критичны по
скорости, но по ним стоит сверить состав слоёв (backtable vs foretable).
2. `overlay_mid_tile` — сейчас точный порт midtable-части `draw_tile2`;
проверить, не рисуем ли `base_id` там, где оригинал его не рисует
(loose: base=0, потому что кадр плиты идёт через `draw_loose` в backtable).
3. `pop_loose_mob_tick` — перерисовка соседнего тайла (`draw_tile(mob_row,
mob_col+1)`) КАЖДЫЙ кадр падения: в оригинале это `set_redraw_full` на
один кадр; можно ограничить только тайлом, который реально пересекается
с куском.
4. `pop_ceil_shake_draw` — heal 64×8 + два `draw_tile(-1,·)` на кадр тряски;
проверить, нужен ли второй тайл (правую грань loose в полосе потолка
оригинал не рисует вовсе — `draw_tile_aboveroom` без `draw_tile_anim_right`).
5. `fore_only_tile` для полосы потолка: вызывается для всех колонок габарита,
а оригинал (`redraw_needed_above`) — только для колонок с флагом
`redraw_frames_above`; сузить до колонок, реально задетых спрайтом.
6. `wall_pattern` внутри fore/overlay — тяжёлая (PRNG + до 4 блитов); проверить,
не зовём ли её там, где оригинал ограничивается `wall_fram_main`.
---
## 8. Скорость отрисовки: замеры и запас (2026-07-27)
Профилирование в MAME (маркеры в порт 0xFE + `wpiset … totalcycles`, приём из
memory `mame_mcp_bridge`). Кадр Sprinter = **430 080 тактов**.
**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке
`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип,
пересчёт src, нарезка полос >256), а не за пиксели:
| путь (спрайт 32×3) | тактов |
|---|---|
| `gfx_blit` (общее ядро, с клипом) | 13 288 |
| линейное спрайтовое ядро без клипа (`putsprite` при `gfx_sprite_clip(0)`) | 4 617 |
Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 %
кадра**; сам `bar` — только 13 308.
**СДЕЛАНО (шаг 1):** в libbgi добавлен `gfx_blit_noclip()`
(`common/gfx_blit_noclip.c`, прототип в `include/gfx.h`) — блит без клипа в
ТЕКУЩЕМ банке через линейное ядро; `pop_bg.blit_b` уходит на него, когда
спрайт целиком на экране и не нужен `g_clip_top`. Выигрыш ~2.9× на каждом
фоновом блите (подтверждено в MAME).
**ВАЖНО:** W3-скобку (`_bgi_begin/_bgi_end`) ставит САМА libbgi — вызывать её
из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято
видеобанком и код вызывающего исчезает из адресного пространства (проверено:
белый экран).
**ЗАПАС (шаг 2), когда перестанет хватать бюджета кадра:**
1. **Батчинг W3-скобки** — одна `_bgi_begin/_bgi_end` на весь `draw_tile`
вместо скобки на блит; нужен публичный batch-API в libbgi (как у
спрайтового движка). Осторожно: между begin/end стоит `DI` — длинная
серия задержит кадровое прерывание.
2. **Решётка ворот одним спрайтом** — `draw_gate_back` рисует бары по одному
(`env 52`, до 7 блитов). Сгенерировать в атласе «столб решётки» (повтор
бара на высоту тайла) и выводить одним `gfx_blit_part` с обрезкой по фазе
`gate_bot_y & 7`: 7 блитов → 1.
3. **Не перерисовывать статичные части шва** — грань ворот (env 47, 26×62),
пол (41) и кромка (43) при анимации решётки не меняются; если стирать
только полосу баров, уйдут ещё 3 блита из 9.
4. См. также §7 (лишние блиты, которых нет в оригинале).
+1 -1
View File
@@ -37,5 +37,5 @@ static const char *const kid_atl[KID_PAGES] = {
};
#define KID_PAL_FILE "kid.pal"
#define KID_SWORD_ATL "sword.atl" /* chtab_0: меч в руке */
#define KID_SWORD_ID0 20 /* индекс в атласе = sword_tbl.id - ID0 */
#define KID_SWORD_ID0 0 /* индекс в атласе = sword_tbl.id - ID0 */
#endif
Binary file not shown.
+14 -1
View File
@@ -11,13 +11,23 @@
по памяти и не угадывай константы/порядок слоёв. Расхождение с SDLPoP =
по умолчанию баг у нас.
**Что в работе сейчас — `TASKS.md`** (доска задач: приоритеты, критерии
готовности). Баги — `bug_list.md` (только ОТКРЫТЫЕ) и `bug_closed.md`
(закрытые + разбор корней: перед заведением нового бага грепни там по
симптому). План следующих уровней — `../docs/levels_plan.md`.
## Сборка и запуск
```
make # собрать roomtest.exe (упаковав ассеты через toolchain/)
make run # exe + EXTRA_DATA на дискету + запуск MAME (см. корневой док)
make -C tests-host # модульные тесты движка под ucsim_z80 (секунды, без MAME)
```
Логику, которую можно проверить без железа, покрывать в `tests-host/`
(обвязка — `testkit/`, там же почему прогон именно под z80). MAME остаётся
для отрисовки, банков, таймингов и клавиатуры.
`MEMORY=small`, `--gfx 256`. Ассеты (`pop_env0..4.atl`, `pop_wall.atl`,
`pop_fore.atl`, `kid0..27.atl`, `kid.pal`) генерируются python-скриптами
`../toolchain/` — Makefile дёргает их сам при изменении. Данные комнаты —
@@ -34,7 +44,10 @@ Kid — `kid_data.h` (генерится `pop_extract_kid_data.py`).
Отладочные тумблеры в живой сессии (`roomtest.c`): **SPACE** — вкл/выкл
дабл-буфер (в однобуфере видно баги рисования без мерцания-через-кадр);
**1** — заморозить кадр, **2** — продолжить (разбор позы/окклюзии);
**ESC** — выход.
**ESC** — выход. Читы (`pop_cheat.h`): **K** — убить стража, **I**
бессмертие, **S** — выдать меч, **Shift+L** — следующий уровень,
**+/−** — обход комнат (`ROOMNAV`). Уровень грузится по номеру
(`pop_level_load_num`), на диске лежат все 15.
## Модули
+30 -5
View File
@@ -14,19 +14,44 @@ MEMORY ?= huge # small-раскладка + банки кода в W
# банк1→банк2→банк1 в MAME 2026-07-30). Теперь в банки можно уводить и
# логику, которая рисует. См. docs/layout_plan_v2.md §2, memory
# sdcc_banked_call_rules.
EXTRA_FLAGS ?= --gfx 256 --bank 2=pop_bg.c --bank 2=pop_gdraw.c --bank 3=pop_map.c --bank 1=guards.c $(PROF_FLAGS)
# Банк 5 = pop_ctrl.c (MEM-BANK5). CODE и DATA делят одно 32-КБ
# пространство W1+W2, поэтому килобайт кода, уехавший в банк, — это
# килобайт, доступный данным. Кандидат выбран не по размеру, а по частоте
# вызова: диспетчер управления дёргается один раз за кадр на персонажа и
# горячих банк→банк переходов не создаёт (в отличие от pop_level, чей
# pop_level_tile зовётся из банка 2 на КАЖДЫЙ тайл). Шина control_*
# переехала в pop_state.c — см. там.
# --max-allocs-per-node: во сколько вариантов размещения регистров SDCC
# упирается на узел. У sprinter-cc дефолт 100000 (агрессивно, как fast-сборки
# библиотек) — на roomtest это МИНУТЫ на банк, потому что модули крупные.
# Дефолт SDCC — 3000, и для разработки его достаточно: разница в размере
# единицы процента, а сборка кратно быстрее. Замер (2026-08-05) — в шапке
# TASKS.md, запись BUILD-FAST.
#
# make — быстрая сборка (ALLOCS=3000)
# make ALLOCS=100000 — как раньше: минимальный код, для замеров размера
# и для «релизного» образа
#
# ВАЖНО: любое сравнение занятости банков имеет смысл только при ОДНОМ и том
# же ALLOCS — иначе сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 3000
EXTRA_FLAGS ?= --gfx 256 --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 4=pop_gdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c $(PROF_FLAGS)
# Профилирование полосами бордюра (см. PROF() в roomtest.c) — ВКЛЮЧЕНО по
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
# MAME = время фазы в растровых строках.
PROF ?= 1
PROF_FLAGS := -DPROF_BORDER=$(PROF)
EXTRA_SRCS := pop_state.c pop_kid.c pop_ctrl.c pop_level.c pop_trob.c pop_redraw.c pop_geom.c pop_guard.c
EXTRA_SRCS := pop_state.c pop_draw.c pop_kid.c pop_level.c pop_redraw.c pop_geom.c pop_guard.c
BG_DIR := $(CURDIR)/../poc/res/bg
KID_DIR := $(CURDIR)/../poc/res/kid
LVL_DIR := $(CURDIR)/../SDLPoP/data/LEVELS
LVL_DATA := $(LVL_DIR)/res2001.bin
# ВСЕ уровни игры (res2001..res2015, по 2305 Б = 34 КБ на всех): переход
# уровень→уровень и чит Shift+L требуют, чтобы файл следующего лежал на
# диске. res2000 — демо-уровень, в скоуп не входит.
LVL_NUMS := 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15
LVL_DATA := $(foreach n,$(LVL_NUMS),$(LVL_DIR)/res20$(n).bin)
BG_DATA := $(BG_DIR)/pop_env0.atl $(BG_DIR)/pop_env1.atl $(BG_DIR)/pop_env2.atl \
$(BG_DIR)/pop_env3.atl $(BG_DIR)/pop_env4.atl \
$(BG_DIR)/pop_wall.atl $(BG_DIR)/pop_fore.atl \
@@ -58,7 +83,7 @@ hdd: $(EXAMPLE).exe
$(foreach f,$(BG_DATA),BG:$(f)) \
$(foreach f,$(KID_DATA) $(KID_BIN),KID:$(f)) \
$(foreach f,$(GUARD_DATA),GUARD:$(f)) \
LEVELS:$(LVL_DATA)
$(foreach f,$(LVL_DATA),LEVELS:$(f))
TC := $(PROJ_ROOT)/applications/PoP/toolchain
$(BG_DATA): $(TC)/pop_pack_bg.py $(TC)/render_room.py
@@ -71,4 +96,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_gdraw.c pop_state.c pop_state.h pop_cheat.h pop_bg.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: guards.c pop_guard.c pop_guard.h pop_gdraw.c pop_draw.c _pop_draw.h pop_state.c pop_state.h pop_cheat.h pop_bg.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)
+10 -5
View File
@@ -39,9 +39,14 @@ ESC выход.
- `room1_data.h` — карта комнаты 1; `kid_data.h` — данные анимации Kid
(оба генерируются скриптами `../toolchain/`).
## Статус
## Статус (2026-08-01)
Готово и проверено в MAME: статический фон комнаты 1; Kid анимация,
управление, коллизия/падение, зацеп/подтягивание/спуск, fore-окклюзия
пола/стены над Kid. В работе: проваливающиеся полы (loose floors) — см.
`../docs/loose_floors_plan.md`.
Играется весь уровень 1: комнаты и переходы между ними, Kid (анимация,
управление, коллизия/падение, зацеп/подтягивание/спуск, окклюзия), кнопки и
ворота, пики, проваливающиеся полы, дверь уровня, меч и бой, стражи с ИИ,
HP и зелья.
Не сделано: выход в следующий уровень (дверь открывается, но войти в неё
нельзя), звук, таймер/HUD времени, сохранения. **Что берём в работу
сейчас — [`TASKS.md`](TASKS.md)**; план следующих уровней —
[`../docs/levels_plan.md`](../docs/levels_plan.md).
+822
View File
@@ -0,0 +1,822 @@
# roomtest — доска текущих задач (обновлено 2026-08-04)
Не список багов (открытые — [`bug_list.md`](bug_list.md), закрытые с разбором
корней — [`bug_closed.md`](bug_closed.md)) и не план фаз
(`../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`, `../docs/levels_plan.md`),
а то, **что берём в работу сейчас и в каком порядке**. Каждая запись: что
сделать, почему именно сейчас, чем подтверждать результат.
**Открытых багов нет** ([`bug_list.md`](bug_list.md)): остались незакрытые
оптимизации [T-1](bug_list.md#t-1), [T-2](bug_list.md#t-2) и незаконченная
[таблица обхода 24 комнат](bug_list.md#обход-всех-24-комнат-уровня-1).
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
гипотезой (memory `defer_unexplained_quirks`).
---
## ТЕКУЩАЯ ЦЕЛЬ: уровни 1–3 (подземелье) отлажены целиком
Решение 2026-08-04: **palace (уровни 4+) откладываем**, доводим до
играбельности три dungeon-уровня. Основание — они не требуют ни одного
нового ассета фона: инвентарь тайлов, снятый с `res200N.bin`, показывает,
что новое появляется только так —
```
ур. 1 empty, floor, spike, pillar, gate, closer, potion, loose, debris,
opener, level_door L/R, torch, wall, skeleton, sword ← всё есть
ур. 2 bigpillar_bottom(8), bigpillar_top(9), doortop(12) ← есть (2026-08-04)
ур. 3 chomper(18) ← НЕТ механики
ур. 4 lattice_pillar(25)…lattice_right(29) + тайлсет palace ← отложено
```
**Порядок (решение пользователя 2026-08-04): СНАЧАЛА приёмка уровня 2,
потом уровень 3.** Причина техническая, а не вкусовая: чомперы лягут в
тот же банк 2, где живёт отрисовка, и чинить баги фона поверх свежей
механики дороже, чем до неё.
| # | Задача | Что | Блокирует |
|---|--------|-----|-----------|
| 1 | [L2-PASS](#l2-pass) | приёмка уровня 2 + баги отрисовки | ← **сейчас** |
| 2 | [L3-CHOMP](#l3-chomp) | чомперы (5 шт) | прохождение ур. 3 |
| 3 | [L3-SKEL](#l3-skel) | скелет (единственный противник ур. 3) | прохождение ур. 3 |
| 4 | [L3-CHKP](#l3-chkp) | чекпойнт ур. 3 | корректный респавн ур. 3 |
| — | [L1-SPEED](#l1-speed) | игра на ~39 % быстрее оригинала | ощущение от ВСЕХ уровней; берётся в любой момент |
| — | [L1-PASS](#l1-pass) | сквозной прогон ур. 1 + таблица 24 комнат | приёмка ур. 1 |
| — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности |
| ✔ | [DBG-CHEATS](#dbg-cheats) | `[`/`]` — подгонка Кида по X | **СДЕЛАНО 2026-08-05** |
| ✔ | [MEM-BANK5](#mem-bank5) | `pop_ctrl.c` → банк 5 | **СДЕЛАНО 2026-08-05**: куча 180 Б → 2298 Б |
---
## Ждёт ФИНАЛЬНОЙ приёмки уровней 1–3
Сюда попадает то, что уже работает в проверочном прогоне, но должно быть
подтверждено на сквозных прогонах уровней — потому что задевает механику
шире, чем собственный сценарий.
- **Зацеп ПРЯМО В ПРЫЖКЕ** (`POP_ENABLE_JUMP_GRAB`, `pop_tune.h`, сделан
2026-08-06, предварительно проверен пользователем). Почему нужен именно
финальный прогон: точки вызова стоят не только в `check_action`, но и в
ОБЕИХ ветках `check_bumped` — то есть код вклинивается перед обычным
ударом о стену. Регрессия проявится не в самом зацепе, а рядом: удар о
стену с зажатым Shift, осторожный шаг у стены, отскок в прыжке. На
уровнях 1–3 это надо специально потрогать в паре мест каждого уровня.
Напоминание: в ВАНИЛИ этого зацепа нет (у SDLPoP — `enable_jump_grab`),
так что сверять его с оригиналом «как есть» нельзя — только с SDLPoP при
включённых enhancements.
## P0 — делаем сейчас
### <a id="l2-pass"></a>L2-PASS. Приёмка уровня 2 — **SMOKE ПРОЙДЕН 2026-08-05**
> **Прогон 2026-08-05 (пользователь): уровень 2 пройден.** Полная приёмка по
> всем комнатам — позже; из smoke пришли [BUG-LOOSE-3](bug_list.md#bug-loose-3)
> и [BUG-GUARD-DEAF-1](bug_list.md#bug-guard-deaf-1). Следом прогнан smoke
> уровня 3 — его наблюдения в [разделе «Уровень 3»](bug_list.md#уровень-3).
Баги отрисовки уровня 2 пользователь подаёт списком отдельно — они идут в
[`bug_list.md`](bug_list.md), раздел «Уровень 2». Ниже — **карта
содержимого уровня, снятая прямо с `res2002.bin`**, чтобы приёмка шла по
списку, а не «на глаз»: если механика в таблице есть, а в игре не
сработала — это баг, а не «так задумано».
**Стражи — 5, в комнатах 4, 7, 11, 15, 24** (skill 1/2/1/1/3, цвета
1/3/1/1/6 — цвет мы пока игнорируем, атлас один). Заметить: страж
комнаты 24 со skill 3 — первый по-настоящему опасный.
**Кнопки и что они открывают** (декодировано из LINKLOC/LINKMAP):
| Кнопка | Тип | Цель |
|--------|-----|------|
| к.9 @ряд1,кол1 | RAISE | **дверь уровня** к.23 @1,3 — то есть выход |
| к.11 @1,1 | RAISE | ворота к.18 @0,9 |
| к.18 @0,7 | RAISE | ворота к.7 @0,9 **и** к.18 @0,9 (две сразу) |
| к.18 @0,2 | DROP | закрывает ворота к.7 @0,9 |
| к.13 @1,4 | DROP | закрывает ворота к.13 @1,5 |
**Ловушки и предметы по комнатам:**
```
к. 3 loose@2,2 зелье@2,5 (здоровье)
к. 4 loose@1,7 loose@1,8 + СТРАЖ
к. 5 дверь уровня @1,2-3 — ВХОД (захлопывается за спиной)
к. 6 пики@1,3 loose@1,5 зелье@2,7 (здоровье)
к. 7 ворота@0,9 пики@2,7 + СТРАЖ
к. 8 зелье@2,2 (здоровье)
к. 9 кнопка RAISE@1,1 loose@2,6
к.10 пики@2,2
к.11 кнопка RAISE@1,1 + СТРАЖ
к.12 зелье@2,6 (здоровье) loose@2,8
к.13 зелье@1,3 (ВРЕДНОЕ, −1 HP) кнопка DROP@1,4 ворота@1,5 зелье@1,8
к.15 + СТРАЖ
к.18 кнопка DROP@0,2 loose@0,4 кнопка RAISE@0,7 ворота@0,9 зелье@2,6
к.19 пики@1,4
к.20 ЗЕЛЬЕ@1,2 — БОЛЬШАЯ СКЛЯНКА (+1 к потолку HP) пики@2,6 пики@2,7
к.23 дверь уровня @1,3-4 — ВЫХОД
к.24 + СТРАЖ (skill 3)
```
**Отдельно проверить то, что сделано именно сегодня и на уровне 1 не
проверялось:**
- **большая склянка** (к.20) — потолок HP становится 4, индикатор рисует
четыре деления, и это HP **переносится на уровень 3**;
- **меч уже в руках** с самого старта (`have_sword = level >= 2`) — на
уровне 1 его надо было подбирать;
- **выход через дверь к.23** вживую (кнопка в к.9) — путь тот же, что
закрыт на уровне 1 (L1-EXIT), но на этом уровне не прогонялся;
- **смерть/респавн** возвращают на уровень 2, а не на 1.
### <a id="rooms-graph"></a>Связность комнат уровней 1–3 (снято 2026-08-05)
Обход графа `roomlinks` (@1952, по 4 байта на комнату: L, R, U, D; 0 = нет
соседа) от стартовой комнаты — тем же методом, которым на уровне 1 нашлись
13/18/24 (см. «НЕ БАГИ» в [`bug_closed.md`](bug_closed.md)). Скрипт разовый,
в репозиторий не клался: чтение трёх массивов, BFS и проверка симметрии.
| уровень | старт | недостижимы | признак |
|---------|-------|-------------|---------|
| 1 | к.1 (0,0) | **13, 18, 24** | ссылки наружу есть, обратных нет |
| 2 | к.5 (1,3) | **нет** | граф полностью симметричен, все 24 достижимы |
| 3 | к.9 (2,4) | **23, 24** | то же, что на 1: односторонние ссылки, обе комнаты **полностью пустые** |
```
ур.3: 23 L→4, у 4 R=22 | 24 L→2, у 2 R=7
23 R→22, у 22 L=4 | 24 R→7, у 7 L=2
| 24 U→16, у 16 D=0
на 23 ссылается только 24, на 24 — только 23; тайлы обеих = все empty
```
То есть на уровне 3 это даже более чистый случай, чем на уровне 1: там в
брошенных комнатах была геометрия, здесь — пустота. Практический вывод тот
же: **в 23/24 возможен «мусор в шве»** (наш рендер кромки читает крайнюю
колонку соседа ПО ССЫЛКЕ, а сосед соседом себя не считает), приоритет багов
там низкий, в игре их не видно.
**Уровень 2 — недостижимых нет, но есть три КОЛОДЦА без выхода** (единственная
связь — вверх, откуда Кид падает):
```
к.10 U→4 пики(2,2) + пол — падение из комнаты 4
к.14 U→21 шахта 2 тайла шириной, дно = обломки
к.17 U→15 то же
```
Это не баги данных: в 14/17 попадают только падением насмерть, а из 10
(если выжил) выхода нет вовсе — так в оригинале. При приёмке не считать
«застрял» багом.
### <a id="l3-chomp"></a>L3-CHOMP. Чомперы — механика уровня 3
**Где:** 5 штук, комнаты 5(тайл 4), 16(23,24,25), 22(26). Дальше они почти
на каждом уровне, так что это вложение не только в ур. 3.
**Что портировать:** `animate_chomper` (seg007) — состояние в `room_modif`,
как у пик/ворот, значит шаблон уже отработан (`gates_spikes_plan.md`);
коллизия и смерть Кида в сомкнутых челюстях (`seg004`/`seg006`); отрисовка
кадра по модификатору (`draw_tile_anim`, ветка `tiles_18_chomper`).
**Риск:** банк 2 (`pop_bg`) занят на **86.5 %, свободно 2213 Б** — считать
место ДО кодинга (`levels_plan.md` §5.1), иначе повторится «банк 2 упёрся
в потолок» (коммит 2f3e854). Свободные номера банков есть (5+).
### <a id="l3-skel"></a>L3-SKEL. Скелет — единственный противник уровня 3
**Важно:** в данных уровня 3 **стражей нет вообще** (`guards_tile` пуст во
всех 24 комнатах). Единственный враг — скелет, и он не «страж из данных»,
а **спецсобытие** `check_skel` (seg002:1044):
> в комнате 1, когда `Kid.curr_col` == 2 или 3 и дверь уровня открыта,
> тайл `tiles_21_skeleton` (комната 1, тайлпос 15) стирается в пол, а на
> его месте поднимается персонаж: `charid_4_skeleton`, меч сразу вынут,
> `seq_88_skel_wake_up`, skill 2, HP 3.
Ещё два тайла скелета (комнаты 17 и 19) — декорация, они не оживают.
**Что нужно:**
- `charid_4_skeleton` в `enter_guard`/`pop_guard_enter` (seg002:196): при
`tbl_guard_type[level] == 2` персонаж поднимается **с вынутым мечом** и
последовательностью `seq_63_guard_active_after_fall`, а не
`seq_77_guard_stand_inactive`;
- скелет **бессмертен** — чит `K` его уже не берёт (`pop_guard_kill`
проверяет `CHARID_4_SKELETON`), но и боёвка должна возвращать его к
жизни (seg002:252);
- **новый атлас**: `../SDLPoP/data/SKEL/` (29 файлов) — `pop_pack_guard.py`
сейчас прибит к `GUARD/`, нужен параметр набора (`tbl_guard_dat` =
GUARD/FAT/SKEL/VIZIER/SHADOW).
### <a id="l3-chkp"></a>L3-CHKP. Чекпойнт уровня 3
`level3_set_chkp` (seg002:519): вход в комнату 7 ставит `checkpoint = 1` и
`hitp_beg_lev = hitp_max`. `do_startpos` (seg003:141) при `checkpoint`
подменяет старт: комната 2, тайлпос 6, направление влево, и убирает
loose-плиту (комната 7, колонка 4, ряд 0). Механика `hitp_beg_lev` уже
есть (сделана в L2), остаётся сам флаг и подмена старта.
### <a id="tune-1"></a>TUNE-1. Параметры движка — в конфиг, а не в код
**Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном
заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки
(`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`).
Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а
не константой по месту.
**Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без
пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под
моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа,
комментарии `;`, неизвестные ключи игнорировать), файл необязателен —
нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`,
`[debug]`, `[enhancements]`.
**Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) +
`SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его
(`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и
опций — часть задачи: у него уже разложены по группам стартовые
HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало,
чекпойнт), тайминги ворот и пик, скорости, а отдельной группой —
`fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже
портировали). Брать всё подряд не надо: переносим по мере того, как
константа реально понадобилась в игре.
**Оговорка по памяти.** Парсер и таблица параметров — холодный код,
исполняется один раз при старте: кандидат в банк, а не в резидент W1.
### KBD-1. Shift + стрелки: нажатия теряются — **ПРИЧИНА НАЙДЕНА, ФИКС ПРОВЕРЕН**
> **ПОПРАВКА К ПОСЫЛКЕ (2026-08-05).** Ниже «fake shift» подан как
> установленный факт («при зажатом Shift PS/2 удваивает трафик»). Прямой
> замер потока байт это не подтвердил: клавиатура MAME-Sprinter
> (`pc_kbd ms_naturl`) обёртку `E0 F0 12` / `E0 12` не шлёт вовсе — при
> зажатом Shift поток на стрелку ровно `E0 75 E0 75 …`. Значит удвоения
> трафика в связке Shift+стрелка нет, и мотивировка «поэтому FIFO
> переполняется» отпадает; сам ФИКС (плотный опрос `kbd_raw_poll`) остаётся
> верным и нужным — переполнение вызывает не Shift, а короткая жизнь
> импульса IRQ (пункт 3 гипотезы) плюс DI-окна графики. Разбор и следствия
> — [BUG-KBD-5](bug_closed.md#bug-kbd-5).
>
> **Итог (2026-08-01).** Причина — не наш код и не DI-окна графики: при
> зажатом Shift PS/2 удваивает трафик («fake shift»), а импульс запроса
> прерывания здесь теряется примерно в 44 % случаев, и трёхбайтовый FIFO
> SIO переполняется. Лечится ПЛОТНЫМ опросом: `kbd_raw_poll` повешен
> idle-хуком на ожидание кадра (`gfx_set_idle_hook`, новый API libbgi) —
> процессор всё равно проводит там ~42 мс из 60, крутя опрос луча.
>
> **Проверка в roomtest тем же счётным методом: 35 нажатий Shift+Home →
> 35 make, ноль потерь** (до фикса — 9 из 10). Боевой сценарий тоже:
> четыре Shift+→ подряд дали четыре осторожных шага, `Kid.x` 114 → 147.
> Цена: `_CODE` +170 Б, кадровый бюджет не затронут (опрос стоит в
> простое). Ниже — полный протокол, как к этому пришли.
>
> **ОСТАТОК (ручная проверка пользователем, 2026-08-01): «стало значительно
> лучше, но иногда при зажатом Shift стрелка всё-таки пропускается».**
> Ощущение, не замер — счётчики на 35 нажатиях подряд потерь не показали,
> значит остаточная частота заметно ниже прежних ~15 %. **Задача осознанно
> ОТЛОЖЕНА до финальной полировки всей программы** (решение пользователя);
> сейчас клавиатура пригодна для работы.
>
> **Где именно осталась дыра — чтобы на полировке не начинать с нуля.**
> Idle-хук покрывает простой, то есть ~2/3 кадра. Оставшаяся треть — это
> занятая фаза, и там DI-окно одного accel-прохода доходит до ~650 мкс при
> допуске FIFO ~300 мкс. Пачка байт, целиком попавшая в такое окно, всё
> ещё может потерять байт — ровно «иногда». Порядок действий, если
> вернёмся:
> 1. Вернуть вызовы `kbd_raw_poll()` между блитами занятой фазы (они
> бесплатны; сами по себе не помогали, но вместе с хуком закрывают
> именно этот зазор) и при необходимости внутрь тайловых циклов
> `pop_bg` — тогда слепым остаётся только тело одного блита.
> 2. Мерить тем же счётным методом (см. ниже), а не на ощупь: скриптовые
> нажатия ровнее человеческих, поэтому набирать выборку от 50 нажатий.
> 3. Если и это не добьёт — остаются два рычага вне нашего кода: Scan Code
> Set 3 через BIOS `$EA` (убирает «fake shift» в корне, но в MAME
> непроверяемо — обратный путь к клавиатуре не разведён) и общий
> `m_irq_off_timer` в драйвере MAME.
**Симптом (пользователь, 2026-08-01).** Залипаний почти нет, но при
УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше
нажатия ← не отрабатываются, пока Shift не отпустишь.
**Рабочая гипотеза (механизм, а не догадка «что-то с клавиатурой»).**
Три известных факта складываются в одну картину:
1. **PS/2 Set 2, «fake shift».** При зажатом Shift нажатие РАСШИРЕННОЙ
клавиши (стрелки — `E0`-коды) обрамляется фиктивным отпусканием/нажатием
шифта: нажатие ← шлёт `E0 F0 12` + `E0 6B` = **5 байт** (без шифта было
бы 2), отпускание — `E0 F0 6B` + `E0 12` = **5 байт** (было 3). То есть
ровно в связке Shift+стрелка трафик удваивается.
2. **Приёмный FIFO SIO — 3 байта.** Пачка в 5 байт переживает только то,
что мы успеваем вычерпывать её по ходу. Потерянный make стрелки =
«нажатие не отработало»; потерянный break = залипание (его лечит
`kbd_raw_sync`, но ценой сброса всех немодификаторных клавиш).
3. **Импульс IRQ клавиатуры в MAME живёт 32 такта CPU.**
`mame/sources/MAME/src/mame/sinclair/sprinter.cpp`: `on_kbd_data()`
выставляет `m_irqs->in_set<1>()` НА КАЖДЫЙ принятый байт (то есть старая
запись в `docs/TODO.md` «MAME не даёт per-byte INT» — неверна), но тут же
заводит `m_irq_off_timer` на 32 такта, а `irq_off()` снимает линию.
**Если в эти 32 такта мы под `DI` — прерывание пропало насовсем**, байт
остаётся в FIFO до следующего IRQ (следующий байт или кадровый 50 Гц).
4. **Наши DI-окна длинные.** Ядра акселератора держат `di` на ВЕСЬ блит
(`libbgi/bgi256/_bgi_blit_cols_raw.c:47` — «один DI на весь блит»);
порядок цены прохода — 13.6 К тактов (`libbgi/include/gfx.h`), это
сотни микросекунд, на порядки больше 32-тактового импульса.
Отсюда: **обе версии пользователя — про одно и то же.** Логика ввода
(`pop_ctrl.c`, порт `read_user_control`/`safe_step`) сверена с SDLPoP и
выглядит корректной: `safe_step()` ставит `control_forward = CONTROL_IGNORE`,
и это снимается в `read_user_control()` при ОТПУСКАНИИ стрелки — то есть
повторные тапы ← при зажатом Shift обязаны работать. Не работают они
потому, что до нас не доезжает либо make, либо break стрелки.
**План проверки — по шагам, каждый даёт артефакт:**
1. Счётчики в MAME: брейк на `_kbdraw_overrun` (запись) и на ветке
`tr_kbd_drain` — сколько overrun'ов за 10 с при «Shift зажат, тапаю ←»
против «тапаю ← без Shift». Ожидание по гипотезе: с Shift кратно больше.
2. Замер максимального DI-окна кадра: брейкпоинты на `di`/`ei` в
`_bgi_blit_cols_raw` + `{printf totalcycles; g}` — получить реальную длину
в тактах и в микросекундах.
3. **Спайк «блит без DI».** `docs/new/06-accel.md §6.6`: новая прошивка
допускает работу акселератора при EI (по приходу прерывания он
отключается, по `RETI` включается обратно); старая — нет. Собрать libbgi
с убранным `di` в блит/heal-ядрах, прогнать roomtest в MAME: (а) не
рушится ли картинка, (б) падает ли счётчик overrun из п.1. Если да —
причина подтверждена, и дальше это вопрос «какая прошивка на живом
железе» (по умолчанию оставить DI, режим без DI — опцией libbgi).
4. **Независимо от п.3 — `kbd_raw_poll()`.** Вычерпывание FIFO ОПРОСОМ
(порт `0x19` бит 0 → читать `0x18`, тот же декодер make/break, что в
трамплине) из главного цикла 2–4 раза за кадр между фазами `PROF()`.
Снимает зависимость от «поймали ли мы импульс IRQ» вообще, стоит сотни
тактов, графику не трогает. Реализация: вынести drain-цикл из
`libc/irq/_irq_tramp.c` в общий кусок либо продублировать в
`libc/kbd/kbd_raw_poll.c`; тело обязано идти под `DI` (гонка с ISR за
деструктивное чтение порта 0x18).
5. Побочно сюда же играет **[T-2](bug_list.md#t-2) (idle-skip)**: не
перерисовывать Кида, пока поза/координаты не менялись, — это минус
heal+blit (то есть минус DI-окна) в самых спокойных кадрах, где как раз
и тапают Shift+стрелку.
6. Только если после 3–4 симптом жив — копать логику
`control_shift2`/`CONTROL_IGNORE` против `seg005.c:374..390`.
**Критерий готовности:** при зажатом Shift десять тапов ← дают десять
осторожных шагов (проверка в MAME через `:kbd:ms_naturl:*` напрямую, НЕ
через `press_key` — тот дёргает обе клавиатуры, см. `docs/libc-reference.md`
`<kbd_raw.h>`).
---
### KBD-1: ЧТО ИЗМЕРЕНО (сессия 2026-08-01) — гипотеза про DI НЕ подтвердилась
**Методика.** Симптом «нажатие не отработало» переведён в счётчики, чтобы не
спорить с глазами. Нажимается **Home** — тоже расширенная клавиша (тот же
`E0`-префикс и тот же «fake shift», что у стрелок), но игрой игнорируется,
поэтому Кид стоит на месте и рельеф комнаты на результат не влияет.
Брейкпоинты с действием `{ b@ADDR = b@ADDR + 1 ; g }` (счёт без остановки
машины) в трёх точках: вход клавиатурной ветки трамплина, чтение порта 0x18
внутри drain-цикла, запись make-бита для кода `0x6C`. Скратч-байты — хвост
`ovr_tile[]` (в этом сценарии не используется).
**Симптом воспроизведён скриптом:** при зажатом Shift 10 нажатий → до
декодера дошло 9 make-байт. Без Shift потерь нет — ровно как сообщил
пользователь.
| Прогон | make дошло / нажато | overrun |
|--------|---------------------|---------|
| игра идёт, `kbd_raw_poll` ВКЛ | 9 / 10 | 3 |
| игра идёт, `kbd_raw_poll` ВЫКЛ (патч `ret` в точке входа) | 9 / 10 | 4 |
| игра ЗАМОРОЖЕНА клавишей «1» (блитов нет вообще, значит и длинных DI нет) | **8 / 10** | 6 |
**Вывод 1: наши DI-окна ни при чём.** В замороженном кадре, где блитов нет
и прерывания разрешены практически всё время, потерь НЕ меньше, а больше.
**Вывод 2: `kbd_raw_poll()` в текущей расстановке бесполезен** — 9/10 и с
ним, и без. Причина понятна задним числом: шесть вызовов стоят В ТЕХ ЖЕ
точках, где прерывания и так разрешены, то есть добавляют ровно то, что
трамплин сделал бы сам. Вызовы из `roomtest.c` убраны; сама функция в libc
оставлена — она корректна и нужна как заготовка под «плотный опрос» (см.
ниже), но в горячем цикле её держать не за что.
**Вывод 3 (главный): байт теряется НИЖЕ нашего кода.** Счётчик чтений порта
0x18: 5 нажатий Shift+Home должны дать ровно 50 байт (нажатие `E0 F0 12` +
`E0 6C`, отпускание `E0 F0 6C` + `E0 12` = по 10 на цикл). Насчитано **49**
— и ровно один make потерян. То есть до процессора байт не доехал вообще,
декодер тут ни при чём.
**Вывод 4: прерывание на байт теряется примерно в 44 % случаев.** На тех же
49 прочитанных байтах — только **28 входов** в клавиатурную ветку трамплина
(1.75 байта за вход). То есть больше сорока процентов импульсов запроса
не были обслужены, и байты копятся в трёхбайтовом FIFO вплотную к его
потолку; одна неудачная пауза — и байт потерян.
### KBD-1: ПОТОЛОК ПЛОТНОГО ОПРОСА ИЗМЕРЕН — приём лечит полностью
`tests/kbdpoll` — программа, которая не делает НИЧЕГО, кроме
`kbd_raw_poll()` в бесконечном цикле (ни графики, ни vsync, ни вывода:
любая работа разредила бы опрос и испортила замер). Это физический
максимум плотности. Тот же счётный метод, те же брейкпоинты-счётчики.
| Прогон | нажатий | make дошло | байт прочитано / ожидалось |
|--------|---------|-----------|-----------------------------|
| контроль: Shift зажат 4 с, нажатий нет | 0 | 0 | 0 (Shift сам ничего не шлёт — автоповтора у модификатора нет) |
| Shift + Home | **25** | **25** | **250 / 250** |
**Ни одного потерянного байта.** Для сравнения: в игре при шести вызовах
за кадр терялся 1 байт из 50. При такой частоте потерь вероятность
случайно получить ноль потерь на 250 байтах ≈ 0.6 %, так что результат не
совпадение.
**Вывод: опрос — рабочее решение, вопрос только в ПЛОТНОСТИ.** Нужно
опрашивать примерно раз в 0.5 мс (≈10 000 тактов), а шесть вызовов за
60-мс кадр давали один раз в 10 мс — в двадцать раз реже необходимого.
**Где взять частоту:** логический тик = 60 мс, из них ~18 мс занято
работой и **~42 мс процессор простаивает внутри `gfx_wait_vsync`**, опрашивая
луч. Опрос там стоит ноль и покрывает две трети периода с запасом по
плотности. Остаётся слепым только тело одного accel-блита под DI (до
~650 мкс) — разорвать его нельзя (см. «что НЕ делать»).
**Почему нужна именно такая частота (вопрос «PS/2 же не даёт больше 30
нажатий в секунду»).** Частота опроса определяется НЕ темпом нажатий, а
темпом байт ВНУТРИ одного нажатия и глубиной FIFO. Одно нажатие при
зажатом Shift — это 5 байт подряд (`E0 F0 12`, `E0 6C`), отпускание — ещё 5,
и клавиатура выдаёт их со скоростью провода: 11 бит на байт при ~10–16 кГц
= ~0.7–1.1 мс на байт. Воронка — 3 байта. Значит между двумя вычерпываниями
имеют право прийти максимум два байта, то есть вычерпывать надо не реже чем
раз в ~1.5–2 мс (0.5 мс взято с запасом). **Даже ОДНО нажатие в секунду
переполнит FIFO**, если в эти несколько миллисекунд его никто не разгребает.
Замер это подтверждает: 1.75 байта за одно вычерпывание — уже 58 % ёмкости.
В норме разгребает прерывание; опрос понадобился только потому, что ~44 %
импульсов здесь теряется.
**Альтернатива, которая убирает опрос совсем — уменьшить трафик, а не
ускорять разгребание.** BIOS `$EA` (`FN_KBD_OUT`, `docs/new/09-input.md`
§9.2) шлёт байт НА клавиатуру, то есть ей можно скомандовать:
- **Scan Code Set 3** — нет ни «fake shift», ни `E0`-префиксов: make = 1 байт,
break = 2. Нажатие с шифтом перестаёт превышать FIFO в принципе.
- либо хотя бы отключить typematic (`0xF5`/`0xF7`).
**Но проверить это в MAME НЕЛЬЗЯ:** в `sprinter.cpp` подключено только
направление клавиатура→SIO (`m_kbd->out_data_cb() → rxa_w`); обратный путь
(SIO→клавиатура) не разведён вовсе, так что команда просто уйдёт в никуда.
Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это
кандидат на «когда дойдём до реального железа», а не на сейчас.
**Что сделано по этому плану (2026-08-01):**
1. ✅ Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания
луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`.
Полезен не только нам — любой программе даёт «качать» что-то в ожидании
кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова
нужен push/pop, а сам таймаут в итерациях станет длиннее по времени.
**Важно про цену: это НЕ новая нагрузка.** Опрос ставится ровно туда,
где процессор и так впустую крутит `in a,(#0xFE)` — 42 мс из 60. Полезной
работы не отнимается нисколько.
**Ограничитель области, если «постоянный опрос» всё равно не нравится:**
потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift
потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt —
тогда опрос работает исключительно в той ситуации, ради которой заведён.
2. ✅ Перемерено в roomtest тем же счётным методом: **35/35**, потерь нет.
3. ⏳ Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило.
Держать в уме, если на реальном железе или на более тяжёлых сценах
(несколько стражей) потери появятся снова — накрыть блиты дешевле, чем
изобретать что-то новое.
4. ⏳ Ручная проверка пользователем — без неё этап не закрыт: скриптовые
нажатия ровнее человеческих, и «залипания до отпускания Shift» они не
воспроизводили с самого начала.
**Куда смотреть дальше, если плотного опроса не хватит.**
1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на
его машине занимает часы). Для протокола, подозрение осталось:
`sinclair/sprinter.cpp` держит запрос от клавиатуры ровно **32 такта
CPU**, и `m_irq_off_timer`**один на два источника** (`irq_on()` экрана
заводит его же, `irq_off()` гасит разом обе линии). То есть кадровое
прерывание способно обрезать клавиатурный импульс — правдоподобное
объяснение «44 % пропущенных импульсов».
2. **Реальное железо.** Если п.1 — чисто эмуляционный артефакт, на железе
проблемы может не быть вовсе. Проверять при первом прогоне на живом
Sprinter.
**Про совпадение кадрового и клавиатурного прерываний** (вопрос
пользователя, 2026-08-01). Документация Sprinter: оба приходят с вектором
`0FFh`, различать по биту приёма байта в порту клавиатуры — «не пришёл,
значит экран»; совпадение возможно, но «исключительно редкий случай»
(в новой версии обещают развести жёстче через ПЛМ). То есть наш трамплин
делает ровно предписанное. Известный побочный эффект: при совпадении мы
обслуживаем клавиатуру и `reti`, пропуская кадровую цепочку и DSS — на
потерю байт это не влияет (линия кадрового остаётся взведённой и вызывает
повторный вход), но кадровый тик может пропасть. Отдельная мелкая правка,
в KBD-1 не входит.
**Что НЕ делать (проверено, стоило времени):**
- **Снимать `di` в accel-ядрах libbgi нельзя.** Патч `di``nop` в
`_bgi_blit_cols_raw`/`_bgi_heal_rows_raw`/`_bgi_blit_rows_raw` прямо в
памяти **уронил машину в перезагрузку**. То есть режим «акселератор
работает при EI» из `docs/new/06-accel.md §6.6` в этой прошивке/эмуляции
недоступен — вопрос закрыт артефактом, а не рассуждением.
- Дробить DI-окна по колонкам смысла тоже нет: см. вывод 1.
---
### CLIP-1. Аудит блитов: где клип не нужен — **СДЕЛАНО 2026-08-01**
> **Итог.** Heal Кида и стража переведены на выбор ядра по тому же тесту,
> что давно стоит у блитов. Замер в MAME (счётчики `totalcycles` на входах
> `kid_heal` и `kid_tick`, то есть вся группа heal за кадр; комната 1, Кид
> стоит, стража нет):
>
> | путь | тактов на кадр | кадров в выборке |
> |------|----------------|------------------|
> | клипающее ядро (как было) | **26 200** | 149 |
> | noclip (стало) | **15 848** | 239 |
>
> **−10 352 такта на кадр, то есть −39.5 % с группы heal** (≈0.49 мс при
> ~21 МГц). A/B честный: оба замера сняты в ОДНОМ прогоне, вторая половина —
> с пропатченным в памяти условием (`jr nz` → `jr` в `pop_heal_fast`), то
> есть на той же геометрии и в той же сцене.
>
> **Размер: −362 Б суммарно** (не плюс!): `_CODE` 25 289 → 25 306 (+17),
> BANK2 13 792 → **13 676** (116, свободно стало 2708 Б — это тот самый
> тесный банк из рисков `levels_plan.md` §5), BANK3 6512 → 6249 (263),
> BANK4 без изменений.
>
> **Грабли, стоившие двух пересборок** (вынесено в память
> `sdcc-static-inline-double-cost`): первым заходом хелпер был `static
> inline` в `pop_bg.h` — и SDCC 4.5 И встроил его тело (181 Б) в каждое
> место вызова, И оставил отдельную копию в КАЖДОМ TU, который видит
> заголовок. `pop_guard_heal` раздулся с ~60 до 663 Б, итого +1091 Б в
> `_CODE` и +636 Б в банке стража. Лечится обычной функцией в одном
> резидентном модуле (`pop_draw.c`, W1 — из банков это прямой `call` без
> трамплина, как у `pop_sword_draw`).
>
> **Проверено визуально:** обычная ходьба, прыжок, спуск и позиция «за
> решёткой шва» (straddle — там как раз работает клипающий фолбэк) —
> артефактов и следов нет.
**Ниже — исходная постановка задачи (что и почему смотрели).**
**Зачем сейчас.** Кадр занят на ~86 %; подготовка клипающего варианта
стоит ~5.6 К тактов на вызов, а общее ядро против линейного — 13 288 против
4 617 тактов на спрайт 32×3 (`libbgi/include/gfx.h`). Это самая дешёвая
оставшаяся оптимизация: не переписывание логики, а выбор ядра.
**Что уже правильно** (шаблон, который надо распространить): блиты Кида,
стража, клинка и брызг спрашивают `pop_onscreen_cols()` и уходят в
`gfx_blit_cols_part_noclip`, иначе в клипающий вариант
(`pop_kid.c:86,591,676`, `pop_gdraw.c:105`).
**Что чинили:**
-`kid_heal()` и `pop_guard_heal()` звали `gfx_heal` — **всегда с
клипом**, хотя `gfx_heal_noclip` существует и `heal_off` в `pop_bg.c` им
уже пользовался. Это heal 2–4 прямоугольников КАЖДЫЙ кадр; замер общего
ядра — 11 658 тактов на heal 22×22. Теперь все три идут через общий
`pop_heal_fast` (`pop_draw.c` + `_pop_draw.h`).
-`pop_room_clip_borders()` (`pop_bg.c`) — `gfx_heal(0,0,320,…)`:
**оставлен клипающим осознанно**. Полоса шириной 320 не лезет в 8-битный
параметр noclip-ядра, а бить её на два куска по 160 нет смысла: гейт
`border_dirty` пускает туда только в кадрах падения, и выигрыш подготовки
тонет в цене самих 320×28 пикселей. Причина записана прямо в коде, чтобы
не «оптимизировать» повторно.
**Что обязано остаться с клипом** (зафиксировано комментариями в коде):
- кромочные тайлы фона (`blit_b` в `pop_bg.c`) — тайл у края экрана режется
по построению;
- спрайты при straddle (`kid_render_dx = ∓140`, комната Кида ≠ отрисованной)
и при падении ниже поля — фолбэки в `kid_draw`/`pop_guard_draw`;
- борта поля (`pop_room_clip_borders`) — см. выше.
**Что уже было правильно** (шаблон, который и распространили): блиты Кида,
стража, клинка и брызг спрашивают `pop_onscreen_cols()` и уходят в
`gfx_blit_cols_part_noclip`. Отдельный случай — `pop_kid_img_blit`: noclip
БЕЗ проверки, потому что единственный вызывающий (полоса HP) рисует по
фиксированным координатам; это тоже помечено в коде.
---
## P1 — сразу после P0 (закрываем уровень 1 как ИГРУ, а не стенд)
### L1-START. Старт по данным уровня — **СДЕЛАНО 2026-08-01**
Старт и оба рестарта (смерть, выпадение из уровня) сведены в один
`pop_start_level()` — порт `start_level` + `do_startpos` + `set_start_pos`
(seg003): комната/тайл/направление берутся из `pop_level_start_*`, направление
инвертируется (`~start_dir`), поза входа — из `tbl_entry_pose`. У уровня 1
это «падение внутрь» плюс нажатие кнопки room5(0,2) — то самое, что
захлопывает решётку за спиной. Проверено: старт даёт room 1, col 0, падение
на row 1 — как по данным уровня.
`#define ROOMNAV` оставлен ВКЛЮЧЁННЫМ осознанно: это наш чит, которого в
оригинале не было, — как и `S` (выдать меч), `K`, `I`. Все они со временем
съедутся в общий блок читов, разрешаемый в настройках (решение 2026-08-01).
### L1-EXIT. Выход с уровня (дверь уровня) — **СДЕЛАНО 2026-08-01**
Портированы: ветка двери уровня из `up_pressed` + `go_up_leveldoor`
(seg005:0482/0574) — в `pop_leveldoor_enter()` (`pop_map.c`, тайлы и
геометрия) и `up_pressed()` (`pop_ctrl.c`, только последовательность);
опкод `0xF1 END_LEVEL` в `play_seq` теперь инкрементит `pop_next_level`
(порт `next_level`), а главный цикл по нему перезапускает уровень — ровно
та точка, куда `levels_plan.md` §2.2 подключит загрузку уровня 2.
Открытость двери проверяем по `modifier >= 42` (ветка `fix_exit_door`), а не
по ванильному `leveldoor_open`: с ванильным условием можно войти в ещё
ползущую створку.
**Грабли, которые стоили отдельного разбора:** `go_up_leveldoor` сначала
писал `Char.x`/`Char.direction`, и оба присваивания молча терялись — окно
`Char` вокруг диспетчера возвращает назад ТОЛЬКО `curr_seq` и `sword`
(`pop_savekid_state`). Направление оставалось «вправо», а все `DX`
последовательности seq_70 отрицательные, поэтому Кид уходил ИЗ проёма влево.
Вывод на будущее: геометрию персонажа в этом порте меняет `pop_map` (пишет в
`Kid`), а не диспетчер.
✅ Косметика тоже закрыта: BUG-DOOR-CLIP (обрезка силуэта правым косяком
проёма) — недоставало второй половины `clip_char` (`obj_clip_right`) и
обрезки СПРАВА у колоночного блита. В libbgi добавлен
`gfx_blit_cols_part_w(..., maxw)`; разбор — в [`bug_closed.md`](bug_closed.md).
### L1-TRIAGE. Ревизия багов — **ЧАСТЬ 1 СДЕЛАНА 2026-08-01**
**Три Critical'а прогнаны в MAME и закрыты** (протокол с числами — в
[`bug_closed.md`](bug_closed.md), раздел «Проверено в MAME 2026-08-01»):
BUG-1 (провал на row 1 при переходе через открытые ворота) и BUG-2
(ping-pong при возврате) **не воспроизводятся**, BUG-3 (окклюзия climb-up на
кнопке) закрыт фиксом `tile_code_drawn` от 2026-07-28. Заодно снят неверный
диагноз BUG-1: репроекция Y при БОКОВОМ переходе — не наш пробел, а точное
поведение `goto_other_room` (`SDLPoP/src/seg002.c:390` меняет только `x`).
Список разделён на [`bug_list.md`](bug_list.md) (открытое) и
[`bug_closed.md`](bug_closed.md) (закрытое + разбор корней).
**Косметика окклюзии тоже закрыта** — BUG-CEIL-1/2/3 и BUG-OCCL-1 были
починены кодом ещё в июле, а записи никто не снял: `ceil_over_kid_tile`
(`pop_bg.c:1275`), `pop_ceil_modif` + `pop_ceil_shake_draw`/`_bake_empty`
(`:547,815,827`), `bar` с `POP_YOFF+3` в `pop_room_redraw_seam_left` (`:793`),
разделение слоёв по `add_backtable` vs `ptr_add_table` в `overlay_mid_tile`
(`:1367`). Разбор — в [`bug_closed.md`](bug_closed.md).
**Осталось:** закрыть таблицу обхода 24 комнат
([`bug_list.md`](bug_list.md#обход-всех-24-комнат-уровня-1)) — заполнена на
5 строк из 24, инструмент (`ROOMNAV`) готов. Делать вместе с L1-PASS: это
единственный оставшийся источник новых багов уровня 1.
### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01)
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
`fight_speed = 6` = **100 мс**. У нас `roomtest.c` ждёт **три** `gfx_wait_vsync()`
= 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости
эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою.
Условие «делать ПОСЛЕ CLIP-1» **снято** — CLIP-1 закрыт (см. P0). Проверка —
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
### <a id="l1-pass"></a>L1-PASS. Сквозное прохождение уровня 1 — **SMOKE ПРОЙДЕН 2026-08-05**
> **Прогон 2026-08-05 (пользователь): успешный.** От старта до выхода с
> уровня одним заходом — меч подобран, **оба стража побеждены в честном бою**
> (без читов), выход отработал корректно. Это smoke: пройдены не все
> комнаты, поэтому таблица обхода 24 комнат остаётся открытой. Ценность
> прогона в том, что он снял главные риски этапа 1 разом — боёвка,
> предметы, переход с уровня — и стал регресс-базой для уровня 2.
От старта до двери уровня одним заходом: подбор меча, страж, кнопки/ворота,
пики, loose-полы, зелье, падения. Это приёмка этапа 1 и одновременно
регресс-база для уровня 2.
Совмещать с [таблицей обхода 24 комнат](bug_list.md#обход-всех-24-комнат-уровня-1):
известные баги закрыты, значит новые придут только отсюда. Точки, где стоит
смотреть внимательно, — уже закрытая косметика окклюзии (потолок при прыжке
вверх, шов при анимации решётки, грани дальней колонны) и подъём на
тайл-кнопку: см. оговорку к BUG-3 в [`bug_closed.md`](bug_closed.md).
### L2. Переход на уровень 2 и его игра — **МАШИНЕРИЯ СДЕЛАНА 2026-08-04**
Порт `levels_plan.md` §2 (шаг 1). Что появилось:
- **Номер уровня стал состоянием.** `pop_current_level` (порт
`current_level`) в `pop_level.c`; `pop_next_level` больше не флаг, а
НОМЕР — `END_LEVEL` его инкрементит (как `++next_level`, seg006:662), а
главный цикл срабатывает по расхождению `pop_next_level !=
pop_current_level` (порт `play_level_2`, seg003:0386).
- **Загрузка по номеру** — `pop_level_load_num(n)`: `LEVELS\res20NN.bin`
(fallback `a:\`), старая EMM-страница отпускается ТОЛЬКО после успешной
загрузки новой (нет файла — играем дальше на текущем). На диск кладутся
все 15 уровней (34 КБ).
- **Потабличные различия** (`data.h:840..848`) — таблицы по 16 в
`pop_level.c`: `tbl_entry_pose` (поза входа), `tbl_guard_hp` (HP стража,
ушло из хардкода `3`), `tbl_guard_type` (**−1 = стражей нет**, иначе на
14/15 они полезли бы из данных комнат), `tbl_level_type` (тайлсет —
пока только читается).
- **`find_start_level_door`** (seg003:02E6) — на уровне 2 это НЕ косметика:
стартовый тайл (комната 5, ряд 1, колонка 3) — правая половина двери
уровня, и без `modif = 43` + `add_trob(...,3)` Кид материализуется внутри
глухой створки. Тип 3 = «быстро закрыть»: дверь захлопывается за спиной
за три кадра, как в оригинале.
- **HP через уровень** — `hitp_beg_lev` (seg003): рестарт уровня
откатывает HP к нему, пройденный уровень подтягивает его к `hitp_max`.
Заодно реализована **большая склянка** (`add_life`, тип зелья 2: +1 к
ПОТОЛКУ HP до 10) — на уровне 2 она есть, комната 20.
- **Меч** — `have_sword = level >= 2` (play_level, seg003:106), а не
жёсткий ноль.
- **Чит Shift+L** (seg000:698) — `pop_next_level = pop_current_level + 1`.
NB: `L` без Shift занят отладочным «осторожным шагом вправо»
(`pop_ctrl` `KBD_DBG_STEPR`); с Shift шаг тоже пройдёт, но уровень тут же
сменится — на практике не мешает.
**Проверено в MAME (2026-08-04):** старт уровня 1 → Shift+L → уровень 2
рисуется правильно (комната 5, большие колонны — новые для нас тайлы 8/9 —
на месте, дверь захлопнута, HP 3) → влево в комнату 4, страж на месте →
Shift+L → уровень 3 (комната 9). То есть цепочка загрузок работает
повторно, а не только один раз.
**Что осталось по уровню 2 (не машинерия, а контент):**
- сквозное прохождение уровня 2 руками — приёмка, как L1-PASS;
- выход через дверь уровня 2 (комната 23) вживую: путь тот же, что уже
закрыт на уровне 1 (L1-EXIT), но не прогнан на этом уровне;
- цвет стража из данных (`guards_color`, на уровне 2 их три разных) — у нас
один атлас; отдельная задача, к переходам отношения не имеет.
### <a id="dbg-cheats"></a>DBG-CHEATS. Отладочные читы SDLPoP — разведка сделана, код не написан
Записано 2026-08-03 (по ходу охоты за BUG-GATE-PASS-1, отложено на потом).
Мотив прямой: мост MAME **теряет нажатия при быстрой отправке**, поэтому
подогнать Кида в нужную позу для отладки автоматикой сейчас нельзя — именно
на это упёрлись BUG-LOOSE-2 и BUG-GATE-PASS-1.
**Берём (дёшево и бьёт в наш класс багов):**
- **`[` / `]` — сдвинуть Кида на пиксель влево/вправо.** Оригинал —
`../SDLPoP/src/seg000.c:1828`:
```c
if (key_states[SDL_SCANCODE_RIGHTBRACKET] & key_state) ++Char.x;
else if (key_states[SDL_SCANCODE_LEFTBRACKET] & key_state) --Char.x;
```
У нас: коды PS/2 set 2 `[` = **0x54**, `]` = **0x5B** в `pop_cheat.h`
рядом с `KBD_CHEAT_KILL/IMMO/SWORD`; обработка — в том же блоке читов
`roomtest.c` (~строка 479), **по фронту** (`*_prev`, как у остальных),
иначе одно нажатие уедет на десяток пикселей. Работает по `Kid.x`
напрямую: геометрию персонажа в этом порте меняет не диспетчер (см.
грабли L1-EXIT).
Первый же потребитель — BUG-GATE-PASS-1: там надо снять `Kid.x` и
`Kid.curr_col` в момент, когда решётка уже закрылась, а Кид ещё стоит
на её тайле.
- **Shift+L — следующий уровень.** Уже входит в L2-машинерию
(`../docs/levels_plan.md` §2.4): `++pop_next_level` и всё.
**Остальные — оценка, а не обязательство:**
| Чит | Вердикт |
|-----|---------|
| **T — таймер** | **не сейчас**: таймера уровня у нас нет вообще (Фаза 6), чит пришлось бы делать вместе с механикой |
| **F — остаток feather-fall** | **вместе с Shift+W**: ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` есть, но не проверена ничем; индикатор без самого зелья бесполезен, а пара «включить + видеть остаток» закрывает ветку целиком. Перо — уровень 7, так что не срочно |
| **Shift+F9 — quickload с тем же уровнем** | **самое ценное и самое дорогое**: это сериализация `Char` + `room_modif` всех комнат + trob'ов + стражей (`levels_plan.md` §4). Даёт воспроизводимый регресс «вот кадр, где баг» вместо ручной подгонки. Кандидат сразу после того, как заработают уровни |
---
### <a id="mem-bank5"></a>MEM-BANK5. Разгрузка W1/W2 новым банком кода
Вопрос 2026-08-04: «надо делать новый банк?». **Да, и он лечит именно то,
что жмёт.** В нашей раскладке (`MEMORY=huge`, small-вариант) CODE и DATA
живут в ОДНОМ 32-КБ пространстве W1+W2 — карта текущей сборки:
```
_CODE 0x4100..0xAA00 26880 Б
_DATA 0xAAD0..0xB9B0 3808 Б
_BSS 0xB9B8..0xBADA 290 Б
куча 0xBADA..0xBB00 38 Б ← упёрлись сюда, добавляя отладку
стек 0xBB00..0xC000 1280 Б
```
Поэтому **каждый килобайт кода, уехавший в банк, становится килобайтом,
доступным данным**. Отдельного «дефицита W2» у нас нет — дефицит один.
(38 байт кучи не опасны сами по себе: malloc'ом мы не пользуемся, страницы
берутся через `mem_alloc_block`. Опасно то, что следующая структура
данных упрётся в стек молча.)
Резидентный код по модулям (из `.sprinter-cc-roomtest/*.rel`):
| модуль | _CODE | как часто зовётся | в банк? |
|--------|------:|-------------------|---------|
| `pop_kid.c` | 6287 | `load_frame`/`play_seq` — 2×/кадр (Кид + страж) | частично: холодная половина (загрузка страниц спрайтов, `pop_kid_load`) — да; движок кадров — нет |
| `roomtest.c` | 3893 | main-loop | нет (точка входа, зовёт всех) |
| `pop_trob.c` | 2526 | `do_trobs` 1×/кадр, но `pop_trob_modif` — горячий аксессор | **кандидат №1**, если вынести аксессор в резидент |
| `pop_level.c`| 2292 | `pop_level_tile` — из `pop_bg` (банк 2) на каждый тайл | **нет**: банк→банк на каждый тайл убьёт отрисовку |
| `pop_ctrl.c` | 2189 | `user_control` 1×/кадр | **кандидат №2** — дёшево и безопасно |
| `pop_guard.c`| 923 | 1×/кадр | нет смысла |
Порядок действий, когда упрёмся: `pop_ctrl.c` → банк 5 (2.2 КБ, один
banked-вызов за кадр), затем расщепление `pop_kid.c` на горячее ядро и
холодную загрузку. Критерий кандидата — **не размер, а частота вызова и
отсутствие горячих банк→банк переходов**; `pop_level` показывает, что
большой холодный на вид модуль может быть горячим аксессором.
Брать по факту нехватки места, не заранее.
---
## Отложено осознанно (не брать, пока не появится причина)
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
- **[T-1](bug_list.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
бесплатно после [T-2](bug_list.md#t-2), отдельно не окупается.
- **Отключение мыши на время игры** и **замена PRNG** —
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
транзиентная; разбор в [`bug_closed.md`](bug_closed.md).
+47
View File
@@ -0,0 +1,47 @@
/*
* _pop_draw.h — внутренний заголовок слоя отрисовки: выбор ядра libbgi
* (клипающее / линейное «noclip») по одному и тому же тесту для блита и
* для heal.
*
* ПОЧЕМУ ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ pop_bg.h. Обе функции — `static inline`, а
* SDCC 4.5 оставляет тело такой функции в КАЖДОМ TU, который видит
* объявление, даже если тот её не зовёт (memory
* `sdcc_inline_codegen_findings`). pop_bg.h включают почти все модули PoP,
* то есть из общего заголовка эти ~100 Б размножились бы десятком мёртвых
* копий (замер CLIP-1, 2026-08-01: +1091 Б в _CODE и +636 Б в банке
* стража — за код, который там никто не вызывает). Здесь их видят ровно
* три файла, которые реально рисуют: pop_bg.c, pop_kid.c, pop_gdraw.c.
*/
#ifndef POP_DRAW_H
#define POP_DRAW_H
#include <stdint.h>
#include <gfx.h>
/* Спрайт целиком на экране И укладывается в 8-битные параметры noclip-
* примитивов libbgi? Условие входа в gfx_blit_cols_part_noclip: клипающий
* вариант платит ~5.6 К тактов подготовки на КАЖДЫЙ вызов, независимо от
* того, вылезает край или нет. */
static inline uint8_t pop_onscreen_cols(int x, int y, uint16_t w, uint16_t h)
{
return (uint8_t)(x >= 0 && y >= 0 && w < 256 && h < 256 &&
x + (int)w <= 320 && y + (int)h <= 256);
}
/* Стереть прямоугольник (heal из ОЗУ-копии) тем же приёмом, что и блит:
* целиком на экране → линейное ядро без клипа. Условие входа у
* gfx_heal_noclip ровно то же, что у gfx_blit_cols_part_noclip, поэтому
* тест один — общее ядро gfx_heal платит за клип и 16-бит, а не за пиксели
* (замер: 11 658 тактов на heal 22×22).
*
* НЕ inline (тело — pop_draw.c, резидент W1): SDCC 4.5 встраивал бы его в
* каждое место вызова по 181 Б И оставлял мёртвую копию в каждом TU — см.
* шапку pop_draw.c с замером. Зовётся 5 раз за кадр, цена вызова тонет в
* стоимости самого heal.
*
* w/h тут int, а не uint16_t: вызывающие считают их вычитанием (h − skip), и
* отрицательный результат обязан быть no-op, а не превратиться в огромный
* unsigned. */
void pop_heal_fast(int x, int y, int w, int h);
#endif
File diff suppressed because it is too large Load Diff
+293 -343
View File
@@ -1,363 +1,281 @@
# roomtest — список известных багов (Фаза B и смежное)
# roomtest — ОТКРЫТЫЕ баги и незакрытые оптимизации
Найдено при тестировании Фазы B (кнопки/ворота) в MAME. **НЕ решаем сейчас**
— задача следующего этапа. Правило проекта: механику сверять с
`applications/PoP/SDLPoP/src/` ДО кодинга.
Только то, что **не закрыто**. Всё закрытое (и, что важнее, разбор корней)
переехало в [`bug_closed.md`](bug_closed.md): прежде чем заводить новый баг,
грепни там по симптому.
Статус на момент записи: 2026-07-21. Тестовый старт — комната 6 (`START_ROOM`
в `roomtest.c`). Кнопка room6(0,2, opener) → ворота room8(0,9), видны в левом
шве room6.
Приоритеты работ — в [`TASKS.md`](TASKS.md), а не здесь. Правило проекта:
механику сверять с `../SDLPoP/src/` ДО кодинга.
Ревизия списка: **2026-08-03** (прогон всех комнат уровня 1). Одиннадцать
наблюдений с прогона свелись к шести корням; **все шесть закрыты и проверены
в MAME** — разбор в [`bug_closed.md`](bug_closed.md): BUG-LVLSTATE-1 (уровень
стал мутабельным), BUG-RESPAWN-1 (респавн перезагружает уровень),
BUG-DEATH-1 (смерть от меча), BUG-GATE-ANIM-1 (анимация ворот), BUG-COLL-1
(полный порт `check_collisions`), BUG-STANDUP-1 (лишний guard в
`bumped_floor`). Клавиатурный BUG-KBD-3 доведён до конца во второй волне
(см. ниже); из первой волны открытым остался косметический BUG-SEAM-DRAW-1.
Ревизия **2026-08-03, вечер** (вторая волна с прогона): все четыре наблюдения
закрыты — BUG-KBD-4 (фантомный отжим Shift), BUG-RESPAWN-2 (стражи не оживают),
BUG-DRAWORDER-1 (Кид поверх тела стража) и BUG-LOOSE-2 (осколки только от одной
из двух плит). У BUG-DRAWORDER-1 остался артефакт «ноги поверх головы»,
сверенный с SDLPoP и записанный в «НЕ БАГИ». Тогда же закрыт как
невоспроизводящийся BUG-SEAM-DRAW-1 (Кид в шве рисуется) и заведён
BUG-GATE-PASS-1 — однократное наблюдение прохода сквозь закрывшуюся решётку,
которое ЖДЁТ надёжного сценария.
| ID | что | тип | статус |
|----|-----|-----|--------|
| [Уровень 2](#уровень-2) | остальные баги с приёмки | — | принимаются по ходу |
| [BUG-GATE-PASS-1](#bug-gate-pass-1) | проход сквозь закрывшуюся решётку (0,9) комнаты 5 | Major | **ждёт сценария** (BUG-GATEMOD-1, из-за которого решётка стартовала не в том состоянии, закрыт — перепроверить) |
| [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт |
| [T-2](#t-2) | Кид перерисовывается в покое | оптимизация | открыт |
| [обход 24 комнат](#обход-всех-24-комнат-уровня-1) | таблица заполнена на 5 строк из 24 | ревизия | открыт |
| [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводимый: ни сценарием, ни попиксельной подгонкой X не поднимается |
---
## BUG-1. Боковой переход через ворота: Kid проваливается на row 1 — **Critical**
<a id="уровень-2"></a>
# Уровень 2 — баги с приёмки (заведено 2026-08-04)
**Симптом:** при проходе через ОТКРЫТЫЕ ворота в соседнюю комнату (через шов)
Kid оказывается на ряду **row 1** вместо row 0 — «проваливается» на этаж ниже
(стоит в дверном проёме на неправильной высоте).
Уровень 2 заиграл 2026-08-04 (см. [`TASKS.md`](TASKS.md) запись **L2**).
Пользователь при первом прогоне увидел **несколько багов отрисовки** и
подаст их списком отдельно — раздел заведён под них заранее, чтобы
наблюдения не растеклись по переписке.
**Где:** `pop_map.c` `check_leave()` (порт seg002 leave_room) — боковой уход
`pop_leave_dir`; репроекция координат `Kid.x ∓= 140` есть, но Y/`curr_row`
при боковом переходе НЕ репроецируются (в отличие от `check_leave_below`,
где `Kid.y -= 189; curr_row = y_to_row`). При входе в соседнюю комнату у
шва Kid, видимо, попадает в позицию, где `determine_col`/физика следующего
кадра сбрасывают его на row 1 (нет пола на row 0 у входа, либо неверный y).
**Закрыто с этой волны:** [BUG-LOOSE-3](bug_closed.md#bug-loose-3) — чёрный
бар под упавшей плитой-потолком (запечка восстанавливала не всё, что стёрла).
**Сверить с SDLPoP:** `goto_other_room` (seg002) — как именно
пересчитываются `Char.curr_row`/`Char.y` при side-переходе (left/right).
Для вертикального перехода мы это сделали, для бокового — нет.
**Порядок работы по уровню 2 (2026-08-05):** наблюдения СНАЧАЛА копятся
здесь целиком — фиксы берём, когда уровень пройден до конца. Причина:
косметика отрисовки почти всегда сводится к общему корню (ср. волну
BUG-CEIL-1/2/3 + BUG-OCCL-1), и чинить по одному наблюдению — значит
чинить один и тот же корень трижды.
## BUG-2. Возврат из комнаты назад: Kid отбрасывается обратно (ping-pong) — **Critical**
Карта содержимого уровня (что где стоит по данным `res2002.bin`, какие
кнопки какие ворота открывают) — в [`TASKS.md`](TASKS.md#l2-pass),
запись **L2-PASS**: по ней видно, «механика не сработала» это или
«так и задумано».
**Симптом:** после перехода в соседнюю комнату попытка сразу вернуться
обратно приводит к тому, что Kid снова закидывается в ту же комнату —
переход «пинг-понгует», выйти нельзя.
Перед заведением записи — грепнуть [`bug_closed.md`](bug_closed.md) по
симптому: почти вся косметика окклюзии уровня 1 уже разбиралась
(BUG-CEIL-1/2/3, BUG-OCCL-1, BUG-DOOR-CLIP, BUG-DRAWORDER-1), и уровень 2
вводит ровно три новых фоновых тайла — **большая колонна (низ 8 / верх 9)
и верх двери (12)**; если артефакт рядом с ними, это первый подозреваемый.
**Где:** `pop_map.c` `check_leave()`. После перехода Kid репроецирован к
ПРОТИВОПОЛОЖНОЙ кромке новой комнаты (`x ∓ 140`). На СЛЕДУЮЩЕМ же кадре
передний край снова за порогом ухода в обратную сторону → `pop_leave_dir`
взводится опять → мгновенный обратный переход. Нужен guard «только что
перешёл» (подавить повторный leave на N кадров / пока Kid не отойдёт от
кромки), как в оригинале (`next_room`/leave обрабатываются раз за кадр +
Kid входит достаточно внутрь).
<a id="уровень-3"></a>
# Уровень 3 — баги с приёмки (заведено 2026-08-05)
**Сверить с SDLPoP:** порядок `leave_room`/`goto_other_room` + на какой
X ставится персонаж при входе (должен быть НЕ на самой кромке, иначе
мгновенный обратный триггер).
Smoke-прогон уровня 3 (пользователь, 2026-08-05) дал четыре наблюдения. Два
из них — **не баги, а неначатые задачи**, и записаны как таковые:
## BUG-3. Climb-up на тайл-кнопку: неправильная окклюзия (Kid поверх кнопки) — **Critical**
| наблюдение | что это на самом деле |
|------------|------------------------|
| к.22: чомпер (2,6) не анимируется и **вообще не рисуется** | [L3-CHOMP](TASKS.md#l3-chomp) — механики чомперов НЕТ. В таблице тайлов `pop_bg.c:211` строка `12 chomper` рисует только основание, правую грань и низ; самих челюстей (спрайт из `chtab`, `draw_tile_anim` seg008) нет вовсе. Так и должно выглядеть до порта |
| к.10: скелет не оживает | [L3-SKEL](TASKS.md#l3-skel) — `check_skel` (seg002:0E1F) не портирован. **И комната другая:** тайл `skeleton(21)`, который оживает, лежит в **к.1 (1,5)** — это `skeleton_room=1, skeleton_column=5, skeleton_row=1`. Ещё два скелета уровня (к.17 (2,7), к.19 (2,2)) — просто декорация, они не оживают никогда. Плюс условие: скелет встаёт, только когда **дверь уровня уже открыта** и Кид стоит в колонке 2 или 3 |
**Симптом:** когда Kid подтягивается (climb-up) на тайл, верх которого —
КНОПКА (opener/closer), а не обычный пол, отрисовка неверная: Kid рисуется
ПОВЕРХ кнопки, хотя передняя грань пола/кнопки должна перекрывать нижнюю
часть Kid во время подъёма.
Остальные два были настоящими багами и **закрыты в тот же день** — разбор
корней в [`bug_closed.md`](bug_closed.md): **BUG-JUMPWALL-1** (флаги
коллизий считались для одного ряда вместо трёх — бамп терялся на кадре
смены ряда) и **BUG-SEAM-WEDGE-1** (сосед угла (2,0) безусловно считался
стеной).
**Где:** `pop_bg.c` `climb_overlay_tile()` — floor-overlay при подъёме
рисуется только для floor-подобных тайлов `code ∈ {1,3,5,19}`. Кнопка
(opener 0x0F / closer 0x06) в этот набор НЕ входит → пол-оверлей кнопки не
рисуется поверх Kid. Нужно: для нажатой кнопки (draw как floor/stuck)
трактовать её как floor-тайл в climb-overlay (учесть подстановку из
`get_tile_to_draw`), и/или добавить opener/closer в набор.
<a id="bug-spike-1"></a>
## BUG-SPIKE-1. Пики залипают выдвинутыми — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ
**Сверить с SDLPoP:** `draw_floor_overlay` (seg008) + `get_tile_to_draw`
(нажатая кнопка → tiles_1_floor / tiles_5_stuck) — оверлей должен идти по
ПОДСТАВЛЕННОМУ типу тайла.
> **Статус 2026-08-05, вечер.** Пользователь **повторить не смог**, а замер
> (ниже) показал, что чистый пробег по убранным пикам убивает штатно. То
> есть в смертельности бага, похоже, нет вовсе: наблюдался частный случай —
> пики, УЖЕ выдвинутые полностью (h = 1), для бегущего безвредны и в
> оригинале. Запись оставлена открытой ровно из-за визуального расхождения
> со скриншотом SDLPoP (у нас острия торчат, у него убраны) — см. конец.
>
> **Как получить состояние нарочно:** подойти к пикам вплотную (выдвинутся),
> отступить на полшага, НЕ выходя из зоны срабатывания, и пробежать по ним.
> Пользователь пробовал и это, и попиксельную подгонку X читом `[`/`]` —
> не поднялось. Вывод для будущего разбора: состояние **не чисто
> позиционное**, одной шириной габарита его не объяснить; следующий
> подозреваемый — момент, в который `process_trobs` застаёт модификатор
> относительно кадра Кида.
**Наблюдение (пользователь, 2026-08-05).** Уровень 2, комната 6, пики (1,3):
| действие | что происходит |
|----------|----------------|
| длинный прыжок с ряда 0 на пики | **смерть — правильно** (путь `fell_on_spikes`) |
| пробег по ряду 1 прямо по пикам | **урона нет** |
| после уборки пик | **на экране остаются белые остатки остриёв** (в оригинале чисто) |
| прыжок на месте, стоя на пиках | урона нет |
| просто стоять на выдвинутых пиках | можно сколько угодно |
**Замер (MAME, чтение `room_modif` комнаты 6).** Пока Кид стоит на тайле,
модификатор пики (индекс 13) = **0x8E**, то есть «пики ПОЛНОСТЬЮ вышли и
идёт обратный отсчёт». Дальше вся арифметика сходится с оригиналом:
```
is_spike_harmful (seg007:1178): 0/-1 → 0; <0 → 1; 1..4 → 2; >=5 → 0
check_spiked (seg006:0658): убивает при h>=2 на кадрах бега 7..14
и при h!=0 на кадрах приземления 43/26
```
То есть **при h = 1 (пики уже вышли) бегущий не гибнет и в оригинале**
смертельно только окно ВЫДВИЖЕНИЯ (модификатор 1..4, h = 2). Наши
`animate_spike`, `start_anim_spike`, `is_spike_harmful`, `check_spiked`
сверены с seg006/seg007 построчно и совпадают дословно.
**Значит расходится ТАЙМИНГ, а не логика:** у нас пики успевают полностью
выйти ДО того, как Кид на них вбежит, и он проскакивает мимо смертельного
окна 1..4. Триггер — `check_spike_below` (seg006:1199), и у нас он берёт
габарит спрайта из `kid_fp` — метрик ПОСЛЕДНЕГО отрисованного кадра, а
оригинал берёт `char_x_left/right` ТЕКУЩЕГО кадра (`set_char_collision`).
Это первый подозреваемый; второй — момент вызова `process_trobs`
относительно `play_frame` (сколько кадров модификатор успевает пройти,
пока Кид добегает).
**ВТОРОЕ НАБЛЮДЕНИЕ (то же место, сравнение с оригиналом) — оно и уточняет
гипотезу.** Кид уронил плиту-потолок и спрыгнул вниз; пики выдвинулись и
«спрятались не все — часть артефактов осталась». Скриншоты рядом:
[наш](bugscreens/l2-r6-spikes-ours.png) и
[SDLPoP](bugscreens/l2-r6-spikes-sdlpop.png) в той же позе. У нас из-под
щебня торчат белые острия, у оригинала пик не видно ВООБЩЕ.
Это стыкуется с замером: пока Кид стоит рядом, `check_spike_below` каждый
кадр зовёт `start_anim_spike`, а тот при отрицательном модификаторе
переставляет его обратно в 0x8F — то есть пики у нас «залипают выдвинутыми»
и отсчёт до уборки не доходит. В оригинале в той же позе они убраны,
значит его `check_spike_below` эту колонку УЖЕ не задевает.
**ГИПОТЕЗА «ТРИГГЕР СРАБАТЫВАЕТ РАНО» ПРОВЕРЕНА И ОПРОВЕРГНУТА (замер
2026-08-05, MAME, watchpoint на `room_modif[13]` комнаты 6).** Чистый
пробег по УБРАННЫМ пикам убивает штатно:
```
запись modif=1 : кадр 11 (беговой), x=112, curr_col=2 ← пики пошли вверх
запись modif=2 : кадр 12 (беговой), x=117, curr_col=3 ← Кид уже НА тайле, h=2
запись modif=3 : кадр 177 (frame_177_spiked) ← напоролся
```
То есть `check_spike_below`, `check_spiked`, `is_spike_harmful` и тайминг
выдвижения работают правильно, и «раннего» триггера нет.
**Настоящий корень — пики ЗАЛИПАЮТ выдвинутыми.** Пока габарит Кида
накрывает колонку пики, `check_spike_below` каждый кадр зовёт
`start_anim_spike`, а тот при отрицательном модификаторе переставляет его
обратно в 0x8F — отсчёт до уборки не доходит. А выдвинутые пики (h = 1)
для бегущего БЕЗВРЕДНЫ по правилам оригинала. Отсюда обе жалобы: пробег
по уже вышедшим пикам не убивает, и они же остаются торчать на экране.
**Что осталось выяснить (и это единственный открытый вопрос).** Код
`start_anim_spike` у нас с оригиналом совпадает дословно, значит оригинал
тоже удерживал бы пики, стой Кид там же. На скриншоте SDLPoP в похожей
позе пики УБРАНЫ — то есть его Кид стоит чуть левее и его габарит колонку
пики уже не задевает. Разница в 2–3 пикселя посадки, а у нас такие
расхождения по X уже ловились (см. заметку в BUG-GRAB-1: после касания
площадки SDLPoP уводит Кида на 134, мы — на 141).
**Как закрывать:** инструментировать SDLPoP (печать `char_x_left/right`,
`left/right_checked_col` и модификатора пики каждый кадр), проиграть ту же
сцену — падение плиты-потолка в комнате 6 и остановку на щебне — и сверить
с нашей трассой ПОЗИЦИЮ КИДА после приземления. Если позиции совпадут, а
диапазоны колонок разойдутся — виноват габарит (`kid_fp` против
`set_char_collision` текущего кадра); если разойдутся позиции — это отдельный
баг посадки, а пики — его следствие.
---
## Оптимизации (отложено)
### OPT-1. Хирургический редрой левого шва (ворота соседа) — 2026-07-22
<a id="ручная-перепроверка-2026-08-03"></a>
# Ручная перепроверка фиксов (2026-08-03)
**Возможность:** `pop_room_redraw_seam_left()` (pop_bg.c) на каждое изменение
openness рисует `bar(BLACK)` по всему col0 + **полный `draw_tile(0,0)`**
(стены, `topright`, `wall_pattern` с `prandom()` — десятки блитов). Реально
анимируются только бары решётки — `draw_gate_back` (~9 `env_b`).
Шесть корней закрыты автоматической проверкой в MAME (мост `mame-z80`:
чтение `_Kid`, потиковые трассы, скриншоты) — этого достаточно, чтобы
показать, что конкретный сценарий больше не воспроизводится, но НЕ
достаточно, чтобы поймать регрессии в соседней механике. Ниже — сценарии
ровно в тех формулировках, в которых баги были заведены.
**Стоимость:** seam-блок (синий io_border) занимает ~30-50% кадрового периода,
**но только пока openness меняется** — т.е. во время открытия и медленного
авто-закрытия (~5 сек после схода с кнопки). В покое (ворота открыты-держим
кнопку / закрыты / FF) — 0%. Замерено в MAME: брейк на
`_pop_room_redraw_seam_left` (0x5A54) срабатывает ⟺ сегмент дорогой.
Собрано и лежит в образе (`make` + `make hdd` уже прогнаны; libc пересобран,
`make size-check` без роста).
**Приём** (как у пик, но сложнее — heal НЕ годится: печёные бары устаревшие,
поэтому и стоит `bar(BLACK)`+redraw): `bar(BLACK)` только по полосе баров
(x=0, gate_top..gate_bot) + `draw_gate_back(lmod,...)` + дорисовать статику
тайла (0,0), задетую полосой. Ожидаемо срезает бóльшую часть 30-50%.
**Пиксель-чувствительно** — полоса баров пересекается со статикой col0,
обязательна выверка в MAME по кромкам.
| # | что проверить | ожидаемо |
|---|---------------|----------|
| 1 | комната 22: выпить зелье (0,6), выйти в 16/23 и вернуться | кувшина нет, пузырька над пустым местом нет |
| 2 | то же для комнат 14 (0,5) и 17 (2,3) | так же |
| 3 | комната 15: подобрать меч (2,2), выйти и вернуться | меча нет; кладка на дальней стене НЕ мигает |
| 4 | комната 12: разбить плиты (0,1)/(0,2) и потолок в 16, выйти-вернуться | остаются разбитыми, проём не закрывается |
| 5 | комната 17 из 23: разбить (1,5)/(1,6), выпить зелье (2,3), вернуться | всё остаётся |
| 6 | **после смерти** зайти в те же комнаты | ВСЁ восстановлено (в оригинале смерть = `load_level`) |
| 7 | комната 12: разбег в закрытую решётку (0,9) с полушага | не проходит насквозь; перелистывание экрана штатно (см. BUG-SEAM-DRAW-1 в `bug_closed.md`) |
| 8 | комната 6: бег справа налево от (0,9), длинный прыжок (0,6)→(0,7) | не влетает внутрь стены |
| 9 | комната 5: с кнопки (0,6) падение на (2,7), присед, вставание | остаётся в комнате 5 (проверено трассой: `fr=111 x=177``seq_47``fr=15 x=173`) |
| 10 | комната 5: нажать кнопку (0,4) | поднимаются ОБЕ решётки — (0,5) видно на экране, (0,9) проверять из комнаты 1 |
| 11 | страж (комнаты 3, 21) убивает Кида | смерть доигрывается, респавн в стартовой позиции уровня; цикла «убил-воскрес» нет |
| 12 | клавиатура: долгая игра с Shift+стрелка | ↑ и Shift не залипают; осторожный шаг не превращается в бег (доработано во второй волне — п.17) |
**Решение (2026-07-22):** оставляем как есть — стоимость транзиентная, в бюджет
помещаемся. Делать, если упрёмся в кадровый бюджет на сценах с воротами.
## Вторая волна (2026-08-03, вечер)
| # | что проверить | ожидаемо |
|---|---------------|----------|
| 17 | **[BUG-KBD-4]** долго играть Shift+стрелка, много тапов подряд | Shift не «отваливается»; если однажды залипнет — снимается первым же нажатием стрелки |
| 18 | **[BUG-RESPAWN-2]** убить стража (комнаты 3/21), умереть, вернуться в ту же комнату | страж снова жив, стоит на исходном тайле, HP полные |
Отдельно стоит посмотреть **на регрессии от порта `check_collisions`** — он
трогает всю горизонтальную коллизию: бамп в стену на бегу и в прыжке,
осторожный шаг у стены, проход через ОТКРЫТЫЕ ворота, разворот в проёме
решётки, переходы через швы (старый BUG-SEAM-PINGPONG).
---
## Исправлено
# Открытые баги уровня 1
- **Спуск с кнопки (room8, кромка (0,6)): Кид просвечивал в щель, ближняя рука
срезана до одного пикселя** — ИСПРАВЛЕНО 2026-07-28. Две причины:
(а) не был портирован `clip_char()` (seg006:1749) — верхняя обрезка спрайта по
`y_clip[curr_row+1]`, когда тайл над головой стена/пол; сделано
(`pop_clip_char_top` в pop_map.c + `gfx_blit_cols_part` в libbgi, heal чистит
уже обрезанный прямоугольник);
(б) `climb_overlay_tile` выбирал ветку `draw_floor_overlay` (seg008:1E3A) по
СЫРОМУ коду тайла — а нажатая кнопка в `get_tile_to_draw` (seg008:240)
подменяется на floor/stuck. Тайл-кнопка не проходил тест floor, уходил в
`draw_other_overlay` и закрашивал Kid ЦЕЛЫМ тайлом вместо узкой кромки
`floor_left_overlay[frame-137]`. Фикс — `tile_code_drawn()` (одна подстановка
на все слои). Проверено покадрово в MAME (кадры 148..138).
<a id="bug-gate-pass-1"></a>
## BUG-GATE-PASS-1. Проход сквозь закрывшуюся решётку — ЖДЁТ СЦЕНАРИЯ
- **Шов ворот жёг 50% кадра в покое (закрытая решётка)** — ИСПРАВЛЕНО
2026-07-22. Причина — **баг кодогенератора SDCC z80** (memory
`sdcc_z80_cmp_store_a_bug`): `if (m[9] != seam_sig) seam_sig = m[9];`
компилировался в `sub (seam_sig)` (A ← разность) + `ld (seam_sig),a`
сохранял РАЗНОСТЬ `m[9]-seam_sig`, не `m[9]`. `seam_sig` осциллировала
(напр. 25↔231), `m[9]!=seam_sig` истинно каждый кадр → `draw_tile(0,0)`
каждый кадр даже у неподвижной решётки. Фикс: store-до-сравнения
(`seam_sig = g;` из чистого `g` ДО `sub`), подтверждён в .asm. Прочёс всех
модулей PoP: других случайных compare-then-store нет.
**Статус: наблюдался один раз, воспроизвести повторно НЕ удалось.** Заведён,
чтобы наблюдение не потерялось; закрывать нельзя — ни как исправленный, ни как
«не баг», пока нет надёжного сценария.
- **Пики: 2×полный `draw_tile` на кадр анимации** → хирургический редрой,
2026-07-22. `pop_spike_redraw`: `heal_off` уже возвращает всю печёную
статику (база 127, пол, грани); поверх анимируются только два острия
(`SPIKES_FRAM_LEFT` в своей ячейке + `SPIKES_FRAM_RIGHT` в соседней).
Заменили 2×`draw_tile` на 2×`env_b` — пиксель-в-пиксель тот же результат,
в разы дешевле (шахта из нескольких пик больше не съедает полный кадр).
**Что наблюдалось (2026-08-03).** Комната 5: Кид стоял НА тайле решётки (0,9)
и ждал, пока она опустится. После закрытия пошёл вправо — прошёл в комнату 1
и упал на (1,1).
- **Отрисовка нажатой кнопки (0,2)/(0,3)** — ИСПРАВЛЕНО. Причина: `fore_tile`
(pop_bg.c, fore-слой поверх Kid) рисовал переднюю грань КНОПКИ (bottom_id
149) поверх уже нарисованной грани пола (43) — «остаток нажатой кнопки».
Фикс: `fore_tile` применяет ту же подстановку нажатой кнопки, что и
`draw_tile` (opener→floor / closer→stuck при таймере связи >1). Плюс
`pop_button_redraw` — wipe своей ячейки + правой грани (дальний угол в
0,3), низ строго yb+64 (не залезать в стену ряда 1).
**Что уже измерено и в чём загвоздка.** Сразу после наблюдения повторить не
получилось: в том же месте Кид стоит на `x = 196`, `col = 9`, и решётка его
ДЕРЖИТ — то есть штатно.
## НЕ БАГИ (кривая картинка, но совпадает с оригиналом — НЕ чинить)
Арифметика оригинала объясняет разницу. `is_obstacle` (seg004) ставит
плоскость блокировки в `x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX`, для
колонки 9 это **x = 205**. При этом «колонка 9» по `get_tile_div_mod_m7`
это `x ∈ [191, 205)`. Пока `curr_col == 9`, Кид гарантированно левее
плоскости и обязан блокироваться; чтобы пройти, он должен оказаться **правее
205**, то есть уже на дальней стороне решётки, — и тогда уход вправо законен:
решётка закрылась у него за спиной, в оригинале она блокирует плоскость, а не
весь тайл.
- **Голова стоящего Кида поверх падающей на него loose-плиты.** Комната 12:
зацеп не удался, Кид остался стоять, сбитая плита падает прямо на него —
голова рисуется ПОВЕРХ плиты. Сверено покадрово с SDLPoP v1.24 (2026-07-29):
там ровно то же самое. Артефакт оригинального движка (порядок midtable), а
не наш баг; «починка» увела бы от эталона. Отличать от соседних случаев,
которые БЫЛИ нашими багами и исправлены: вис/подтягивание на кромке плиты и
падение вместе с плитой — там плита обязана быть поверх Кида.
Отсюда рабочая гипотеза: в момент наблюдения Кид стоял правее 205 (успел зайти
по тайлу дальше, пока решётка была поднята), и поведение штатное. Но повторить
эту позу и снять `x` пока не удалось, поэтому гипотеза НЕ подтверждена.
## Заметки
**Что снять в следующий раз** (без этих чисел вопрос не закрыть):
- Тестовые клавиши осторожного шага: **J** = шаг влево, **L** = шаг вправо
(эмуляция Shift+стрелка), см. `pop_ctrl.c` `KBD_DBG_STEP*`. Первый шаг в
сторону = разворот (как в оригинале safe_step), движение со второго.
- Респавн после смерти — по **↑** (или авто через `RESPAWN_DELAY`).
- `START_ROOM=6` — временный тестовый старт; вернуть на
`pop_level_start_room()` после отладки объектов.
1. `Kid.x` и `Kid.curr_col` в момент, когда решётка уже закрылась, а Кид ещё
стоит на её тайле — до шага вправо;
2. модификатор решётки (openness) комнаты 5, тайл 9 — `can_bump_into_gate()`
считает её препятствием только пока `(modif >> 2) + 6 < char_height`, то
есть пока она опустилась достаточно низко относительно РОСТА кадра;
3. `Kid.x` покадрово на самом шаге вправо — где именно перестал блокировать.
---
Быстрый способ снять первое: отладочный стоп-кадр (**1** заморозить, **2**
продолжить), затем чтение `_Kid` из отладчика MAME.
## BUG-CEIL-1. Прыжок вверх: руки Kid рисуются ПОВЕРХ потолка — **Medium**
**Возможный корень, если гипотеза не подтвердится.** Проверка идёт по колонке,
которая на шве уже принадлежит СОСЕДНЕЙ комнате (`curr_row_coll_room[]` в
оригинале); у нас межкомнатная коллизия на шве — исторически проблемное место
(ср. закрытый BUG-SEAM-PINGPONG). Второй кандидат — `char_height` в
`can_bump_into_gate()`: если он берётся не от того кадра, решётка может
перестать считаться препятствием раньше времени.
**Симптом (2026-07-22):** при прыжке вверх (SEQ up, кадры 67..79) руки/голова
Kid заходят в полосу кладки у потолка (row -1) и рисуются ПОВЕРХ неё — потолок
должен окклюдить руки (как передняя грань).
**Где:** `pop_bg.c` — полоса потолка (`draw_tile(-1)`) сейчас только фон
(запечена в pop_room_draw), не окклюдер поверх Kid. Нужен ceiling-overlay в
`pop_fore_over_kid` (перерисовать срез полосы над Kid поверх спрайта), по
аналогии с fore-тайлами. Инфраструктура `g_clip_top`/`gfx_blit_part` уже есть.
## BUG-CEIL-2. Тряска/разбитие loose-плиты в потолке (row -1) не работает — **Medium**
**Симптом (2026-07-22):** loose-плита в ряду 2 верхнего соседа (room5 (2,5) →
потолок room6 над (0,5)) при прыжках Kid на (0,5) НЕ трясётся и не разбивается.
**Где:** верхний сосед не «живой» — `above_fg` берётся из статики уровня,
loose-состояние соседней комнаты не тянем (per-room loose/modif не персистятся,
P0). check_press/knock работают только по текущей комнате. Нужно: (а) пробой
loose сверху через границу комнаты, (б) персистентное loose-состояние соседа +
его отрисовка в полосе. Пока полоса рисует loose статически (кадр покоя).
## BUG-OCCL-1. Тень дальней колонны перекрывает Kid в (0,4)-(0,5) — **Medium**
**Симптом (2026-07-22):** Kid, стоя/прыгая у (0,4)-(0,5), частично перекрыт
тёмной штриховкой — это ТЕНЬ/боковая грань ДАЛЬНЕЙ колонны (сзади по глубине),
которая НЕ должна окклюдить персонажа. Over-occlusion fore-слоя.
**Где:** `pop_bg.c` `pop_fore_over_kid`/`fore_tile` — рисует fore футпринт-тайлов
без учёта глубины (какая грань РЕАЛЬНО перед Kid). Порт seg003 redraw_at_char
окклюдит только передние грани; дальняя колонна (за Kid) не должна. Сверить с
SDLPoP redraw_at_char (какие тайлы/грани идут в fore vs back по y-сортировке).
## BUG-CEIL-3. Анимация ворот стирает потолок над ними — **Medium**
**Симптом (2026-07-22):** при опускании/поднятии решётки шва (анимация openness)
полоса кладки у потолка НАД воротами пропадает (см. потолок над левым швом).
**Где:** `pop_bg.c` `pop_room_redraw_seam_left``pop_button_redraw`/
`pop_spike_redraw` — общий класс): чёрный `bar` по col0 row0 стирает потолочную
полосу (row-1), а redraw рисует только сам гейт, не восстанавливая полосу.
Нужно: при перерисовке шва/тайла у потолка перерисовать и срез потолочной полосы
(draw_tile(-1,col) с g_clip_top) над затронутыми колонками.
---
# BUG-SEAM-PINGPONG (#4). Пинг-понг drawn_room у шва с закрытыми воротами — **РЕШЁН 2026-07-22**
Статус: 2026-07-22, вечер. НАСТОЯЩИЙ корень найден потиковой трассой ЖИВОГО
SDLPoP 1.23 (lldb-брейкпоинты на leave_room/bumped/safe_step с логом Char +
char_x_left/right; fixes выключены = vanilla). Все прежние гипотезы ниже
оставлены для истории — они НЕ были причиной.
## КОРЕНЬ (подтверждён трассой + исходником)
`set_char_collision` (seg006:0723): `char_x_right = obj_x/2 + 58`, где
`load_frame_to_obj` (seg008:1728) считает `obj_x = 2*char_dx_forward(dx) - 116`
и **добавляет +1** для кадров «чётного пикселя»:
`if ((sbyte)(cur_frame.flags ^ obj_direction) >= 0) ++obj_x;`
(бит 0x80 флагов кадра XOR направление; вправо: +1 если бит НЕ стоит).
Деление `obj_x/2` — C-усечение К НУЛЮ, поэтому при `e = x+dx <= 57`
(obj_x < 0, зона левого шва) поправка +1 даёт `char_x_right = e+1`, а при
e >= 58 формула сокращается к чистому `e`.
Итог: Kid, осевший после отскока от ворот шва на x=57 (frame15, флаги 0x43 —
бит 0x80 не стоит), имеет **char_x_right = 58** и порога leave-left (<=57)
НЕ достигает. Наш движок считал передний край как `Kid.x + dx` без поправки
→ 57 → ложный leave → пинг-понг.
Эталонный цикл SDLPoP (сн normalized по трассе): стойка x=61 → тап вправо →
safe_step(d=0) → step, на первом dx(1) x=62 → bump (edge-триггер) → align 61 →
seq47 dx(-4) → **x=57** (скрыт за кромкой) → кадры 50/51/52 (cxr 61/60/58, у
всех бит 0x80 снят, e>57 — без сдвига) → стойка cxr=58 → leave НЕ срабатывает;
тап → safe_step d=3 → x=60 (1/3 видно); тап → step1 → x=61 (2/3 видно); тап →
bump → 57 … по кругу. Char.room и drawn_room НЕ меняются вообще.
## Фикс (pop_map.c)
`char_x_forward_edge()`: `e = char_dx_forward(dx); if (((flags ^ (dir<0 ?
0x80 : 0)) & 0x80) == 0 && e <= 57) e++;` — используется в `char_front_coll`
(коллизия/bump/edge_distance) и в `check_leave` (порог ухода). Плюс порт
doortop-гарда leave-right из leave_room (тайл (9,row) = doortop → правого
выхода нет). `pop_leave_timer` (exit_room_timer) оставлен — он реален в
seg002/seg003.
## Гипотезы, ОТПАВШИЕ после трассы (история диагноза)
## Симптом (подтверждён пользователем + инструментально)
Kid стоит за решёткой закрытых ворот шва (левый сосед room8 виден в кромке
room6). При удержании/нажатии ВПРАВО экран пинг-понгует между двумя
состояниями:
- **A**: показывается room6, Kid у левой кромки за решёткой (спрайт виден на 2/3);
- **B**: показывается room8, Kid у его правой кромки.
Эталон SDLPoP (описан пользователем): drawn_room **всегда остаётся room6**,
Kid осциллирует у кромки (1/3→2/3→отступил→по кругу), в room8 экран НЕ
переключается. То есть у нас лишний свитч A→B.
## Тестовый контекст
START_ROOM=6. Ворота room8(0,9) закрыты, видны в col0 room6. Kid-структура
kframe: см. pop_kid.h (frame,x,y,direction,curr_col,curr_row,action,...).
Адрес _Kid и статиков — в .sprinter-cc-roomtest/roomtest.map (сдвигается от
сборки к сборке). MAME: memory `mame_mcp_bridge`/`mame_hdd_test_disk`.
Отладочные: J/L — короткий шаг влево/вправо (эмуляция Shift+стрелка).
## Инструментальный диагноз (watchpoint на pop_leave_dir)
В момент лишнего свитча A→B: `pop_leave_dir=1 (LEFT)`, Kid **frame=15 (СТОЯ,
не transient!), Kid.x=57** (до репроекции +140). То есть:
- `char_x_right = char_dx_forward(kid_cur_dx()) = Kid.x + frame15.dx = 57+0 = 57`.
- Порог leave-left (взгляд вправо): `char_x_right <= 57` → срабатывает РОВНО на 57.
- Грань ворот (где их держит коллизия) = **61** (`wall_dist_from_left[1]=10 +
coll_tile_left_xpos=51`). Между 57 и 61 — **зазор 4px**: Kid НЕ удержан
воротами (d=6157=4≥0 → check_bumped не бампит), но уже на пороге ухода.
- Kid оседает на 57 из-за recoil отскока: seq_47 = `act(bumped), dx(-4),
frame_50, 51, 52`; SEQ_DX(4) двигает Char.x на −4 суммарно; frame_50.dx=4
компенсирует ТОЛЬКО точку коллизии НА кадре 50, но при возврате в стойку
(frame15, dx=0) `char_x_right = Char.x = aligned4 = 57`.
## Что ИСКЛЮЧЕНО (сверено с исходниками SDLPoP, НЕ причина)
- Формула char_x: `char_x_right = obj_x/2+58 = Char.x+frame.dx` (seg006
set_char_collision) — совпадает с нашим char_dx_forward.
- Позиция грани ворот: `get_left_wall_xpos = wall_dist_from_left[1](10) +
xpos_in_drawn_room(x_bump[9+5])+7 = 10+(184140)+7 = 61` — совпадает с нашим
(`x_bump[1+5]=44`, +7, +10 = 61).
- Порог leave: SDLPoP leave_room looking-right `char_x_right<=57` — совпадает.
- Данные кадров: frame_50 (image=49,dx=4,flags=0x67) и frame_15
(image=14,dx=0,flags=0x43,sword=9) — БАЙТ-В-БАЙТ как в SDLPoP frame_table_kid.
- seq_47 (act bumped, dx(-4), frame 50/51/52) — совпадает.
## Почему точечные фиксы НЕ работают
- Гард в check_leave (подавить leave на закрытых воротах) — это ОТСЕБЯТИНА,
не SDLPoP (в leave_room такого нет); откачено.
- `exit_room_timer=2` (порт seg002 exit_room — РЕАЛЬНЫЙ механизм, оставлен как
pop_leave_timer): блокирует leave 2 кадра после входа в комнату. НЕ спасает:
положение Char.x=57 **устойчивое** (Kid стоит), а не transient — после
истечения таймера leave срабатывает.
- По коду SDLPoP raw-рассуждением раз за разом выходит, что leave-left ДОЛЖЕН
сработать и там (Char.x=57), но эталон показывает, что нет. Значит различие
не в этих кусках, а в ДИНАМИКЕ: где именно оседает Char.x у шва И как
вычисляется drawn_room.
## КОРЕНЬ (гипотеза)
Наш движок держит `drawn_room == комната_Kid` ВСЕГДА, а шов подделан:
- коллизия — самодельные снапшоты кромок соседа (g_lcol/g_rcol, 1 колонка) +
`x_bump[tcol=-1]`, а НЕ порт `curr_row_coll_room[]`/`get_all_coll_row`/
`xpos_in_drawn_room` из seg004;
- уход — мгновенный `enter_room(сосед)` при пороге char_x, а НЕ отложенный
drawn_room через `next_room`/`check_the_end`.
Из-за упрощённой коллизии Char.x у шва оседает иначе, чем в SDLPoP (у нас ровно
57 = порог), и/или drawn_room переключается там, где в оригинале нет.
## План S2/S3 (порт coll_room + отложенный drawn_room) — НЕ потребовался для
## этого бага (трасса показала: в эталоне Char.room/drawn_room вообще не
## меняются). Остаётся как задел под стражей/двух персонажей в кадре.
Правило проекта: SDLPoP — источник истины, переводить 1:1, НЕ рассуждать «как
должно быть». Нужно портировать связку целиком:
1. **Раздельные комнаты.** `Char.room` (реальная комната персонажа) ≠
`drawn_room` (отрисованная). У нас уже есть `kid_room` (S1) + рендер-смещение
`pop_kid_set_render_dx(∓140)` (порт xpos_in_drawn_room для СПРАЙТА).
2. **Коллизия по Char.room через coll_room (seg004, S2).** Портировать
`check_collisions` → `get_row_collision_data` (строки curr/below/above) →
`get_left_wall_xpos`/`get_right_wall_xpos` (по `xpos_in_drawn_room(x_bump[
col+FIRST_ONSCREEN_COLUMN])`, ±140 когда колонка принадлежит соседу) →
массивы `curr_row_coll_room[]`/`_flags[]`, `bump_col_left_of_wall`/
`bump_col_right_of_wall` → `check_bumped_look_left/right` → `bumped()`.
Тайлы берутся из РЕАЛЬНОЙ комнаты колонки (get_tile(room,col,row) для любой
комнаты), НЕ из снапшота. Именно это определяет, где Char.x оседает у шва.
3. **Отрисовка по drawn_room, персонаж со сдвигом.** Рисуем drawn_room; Kid
(и будущие стражи) — со смещением ±140, если их комната ≠ drawn_room.
4. **Отложенная смена drawn_room (seg002/seg000, S3).** Уход =
`leave_room` (seg002:0504, ПОРТ ЦЕЛИКОМ, включая up/down/side, guard'ы
climb/standup/turn, doortop-check для right) → `goto_other_room`
(seg002:0486: Char.room=сосед, x±140/y±189). `exit_room` (seg002:03C7):
`exit_room_timer` + `next_room = Char.room`. `check_the_end` (seg000:0FBD):
`if (next_room != 0 && next_room != drawn_room) drawn_room = next_room;
redraw`. drawn_room меняется ТОЛЬКО так, а не по нашему прямому enter_room.
Ключевой момент, который надо переводить БУКВАЛЬНО (а не выводить): порядок
вызовов в play_frame (seg000:863 `play_frame`: … play_kid_frame → exit_room →
check_the_end) + set_char_collision перед leave_room в exit_room.
Карта сегментов: seg002 (leave/goto/exit_room), seg004 (коллизия/coll_room/
bumped), seg006 (set_char_collision/play_seq/frame_table_kid), seg000
(play_frame/check_the_end/draw). Наши файлы: pop_map.c (коллизия/физика/leave),
roomtest.c (enter_room/drawn_room/главный цикл), pop_kid.c (кадры/рендер-сдвиг).
Реализовано на 2026-07-22: S1 (kid_room + render_dx) + pop_leave_timer.
S2/S3 отложены — баг закрыт odd-pixel поправкой (см. КОРЕНЬ выше).
Память: [[pop_seam_room_model]], [[sdlpop_odd_pixel_char_x]].
---
# TODO: оптимизация отрисовки (записано 2026-07-29)
# Оптимизация отрисовки (записано 2026-07-29)
Не баги — план работ. Оба пункта про одно: у оригинала пометка тайла к
перерисовке стоит копейки (бит в таблице, которая всё равно чистится каждый
@@ -365,6 +283,7 @@ S2/S3 отложены — баг закрыт odd-pixel поправкой (с
ОЗУ-копии акселератора) плюс блиты. Поэтому буквальный порт «перерисовываем
безусловно» корректен, но дорог.
<a id="t-1"></a>
## T-1. Пики: перерисовывать по причине, а не безусловно
**Сейчас:** `pop_process_trobs` зовёт `pop_spike_redraw` каждый кадр для
@@ -388,6 +307,9 @@ S2/S3 отложены — баг закрыт odd-pixel поправкой (с
сравнивает `tilepos` с диапазоном целочисленно. Никаких пересечений
прямоугольников (см. память `manual_hints_over_auto_detect`).
**Приоритет:** отдаётся почти бесплатно ПОСЛЕ T-2, отдельно не окупается.
<a id="t-2"></a>
## T-2. Idle-skip: не перерисовывать Кида, когда ничего не происходит
**Сейчас:** `kid_heal``kid_draw``pop_fore_over_kid` идут каждый кадр,
@@ -402,9 +324,11 @@ S2/S3 отложены — баг закрыт odd-pixel поправкой (с
неизменного кадра — иначе одна из двух страниц останется со старым
содержимым. Условие «обе страницы уже получили это состояние».
**Связь с T-1:** после T-1 idle-skip не требует ничего дополнительного для
пик: раз Кида не перерисовываем — heal'а нет стирать пики нечем редрой
не нужен, он отпадает сам.
**Связь с T-1:** после T-2 пики отпадают сами — раз Кида не перерисовываем,
heal'а нет, стирать пики нечем, редрой не нужен.
**Связь с KBD-1:** это ещё и минус DI-окна в самых спокойных кадрах — ровно
там, где тапают Shift+стрелку (см. остаток KBD-1 в [`TASKS.md`](TASKS.md)).
---
@@ -416,24 +340,12 @@ Kid ставится на первый пол, `pop_trob_reset()` возвращ
исходное. Номер комнаты — полосками в верхнем борте: слева десятки, справа
единицы (`||` `||||` = 24). Убирается закомментированием одной строки.
Комнаты **13, 18, 24 недостижимы** в обычной игре — это свойство данных
уровня, разбор в [`bug_closed.md`](bug_closed.md) («НЕ БАГИ»). Приоритет
багов в них низкий.
**Связность уровня 1 (проверено по `res2001.bin`, links @1952).** Обход графа
от стартовой комнаты 1 показывает: комнаты **13, 18, 24 НЕДОСТИЖИМЫ** в
обычной игре. У всех трёх ссылки наружу есть, а на них не ссылается никто
(24: `L→9`, но у 9 `R=0`; 13 и 18 связаны только друг с другом). Признак
«комнату выкинули из компоновки, связи не почистили» — несимметричные ссылки
ровно у этих трёх, у остальных 21 симметрия полная:
```
13 L→22, у 22 R=16 | 18 L→15, у 15 R=12 | 24 L→9, у 9 R=0
13 R→16, у 16 L=22 | 18 R→12, у 12 L=15
| 18 D→19, у 19 U=12
```
Следствие для обхода: в 13/18/24 возможен «мусор в шве» — наш рендер кромки
читает крайнюю колонку соседа ПО ССЫЛКЕ, а сосед этих комнат соседом себя не
считает. Это свойство данных, а не наш баг. Приоритет багов в этих трёх
комнатах — низкий (в игре их не видно).
Таблица заполнена на 5 строк из 24 — доделать в рамках L1-TRIAGE
([`TASKS.md`](TASKS.md)).
| комната | статус | что не так |
|---------|--------|------------|
@@ -448,7 +360,7 @@ Kid ставится на первый пол, `pop_trob_reset()` возвращ
| 9 | сделано | дверь уровня (1,3)-(1,4) рисовалась чёрным проёмом: не был портирован `draw_leveldoor` (seg008:1D29) — створка (слайсы 33 + верх 34), лестница за ней (99/144) и анимация подъёма по кнопке (`animate_leveldoor`, seg007:05F1, modif 0→43). Спрайты 33/34/99/144 добавлены в атлас явным набором (render_room.py дверь не рисует) |
| 10 | | |
| 11 | | |
| 12 | пофикшено | вис/подтягивание на кромке loose-плиты: плита рисовалась ПОД Кидом. Не хватало двух кусков `draw_tile`: (а) `draw_loose` кладёт нижнюю грань плиты И в foretable (поверх персонажа), (б) `draw_tile_base` подставляет верх плиты из `loose_fram_left`, а в нашем midtable-оверлее стоял голый `base_id` (у loose он 0). Голова Кида поверх падающей на него плиты — см. «НЕ БАГИ» |
| 12 | пофикшено | вис/подтягивание на кромке loose-плиты: плита рисовалась ПОД Кидом. Не хватало двух кусков `draw_tile`: (а) `draw_loose` кладёт нижнюю грань плиты И в foretable (поверх персонажа), (б) `draw_tile_base` подставляет верх плиты из `loose_fram_left`, а в нашем midtable-оверлее стоял голый `base_id` (у loose он 0). Голова Кида поверх падающей на него плиты — см. «НЕ БАГИ» в `bug_closed.md` |
| 13 | недостижима в игре | |
| 14 | | |
| 15 | сделано | меч (2,2) не рисовался: тайл 22 в draw_tile_anim не был портирован. Добавлены отрисовка предмета (chtab_1 id 10/11 на draw_main_y3), подъём по Shift (check_get_item/get_item/do_pickup: присед → seq_91 pickupsword → меч исчезает с пола) и статус `pop_have_sword` |
@@ -461,3 +373,41 @@ Kid ставится на первый пол, `pop_trob_reset()` возвращ
| 22 | | |
| 23 | | |
| 24 | недостижима в игре | |
---
## Заметки (отладка)
- Тестовые клавиши осторожного шага: **J** = шаг влево, **L** = шаг вправо
(эмуляция Shift+стрелка), см. `pop_ctrl.c` `KBD_DBG_STEP*`. Первый шаг в
сторону = разворот (как в оригинале safe_step), движение со второго.
- Читы (`pop_cheat.h`): **K** — убить стража, **I** — бессмертие (toggle),
**S** — выдать меч.
- Респавн после смерти — по **↑** (или авто через `RESPAWN_DELAY`); ставит
Kid в стартовую позицию УРОВНЯ (`pop_start_level`, порт do_startpos).
- **ROOMNAV (`=`/`-`) — тоже наш чит**, которого в оригинале не было, как и
`S`. Все они со временем съедутся в общий блок читов, разрешаемый в
настройках; пока просто включены (`pop_cheats = 1` в `roomtest.c`).
---
<a id="сырые-наблюдения-прогон-2026-08-03"></a>
## Сырые наблюдения (прогон 2026-08-03) → корень
Формулировки с прогона, как записаны. Закрытые корни — в
[`bug_closed.md`](bug_closed.md).
| # | наблюдение (кратко) | корень | статус |
|---|---------------------|--------|--------|
| 1 | кувшин выпит, а пузырёк рисуется / кувшин возвращается (14, 22, 17) | BUG-LVLSTATE-1 | закрыт |
| 2 | меч возвращается в 15 / мигает контур кладки | BUG-LVLSTATE-1 | закрыт |
| 3 | комната 12: пробегает сквозь закрытую решётку (0,9) | BUG-COLL-1 | закрыт |
| 4 | после respawn плиты остаются разбитыми, зелья выпитыми | BUG-RESPAWN-1 | закрыт |
| 5 | плита/зелье возвращаются и БЕЗ respawn (12→16, 17, 22) | BUG-LVLSTATE-1 | закрыт |
| 6 | комната 6: длинный прыжок (0,6)→(0,7) — влёт в стену, респавн | BUG-COLL-1 | закрыт |
| 7 | залипает ↑ | BUG-KBD-3 | закрыт (см. `bug_closed.md`) |
| 8 | залипает Shift | BUG-KBD-3 / BUG-KBD-4 | закрыт (см. `bug_closed.md`) |
| 9 | комната 5: кнопка (0,4) не открывает решётку (0,5) | BUG-GATE-ANIM-1 | закрыт |
| 10 | комната 5: с кнопки (0,6) на (2,7), присед — провал в комнату 6 | BUG-STANDUP-1 | закрыт |
| 11 | страж убил Кида → Кид воскресает на месте и его убивают снова | BUG-DEATH-1 | закрыт |
+80
View File
@@ -0,0 +1,80 @@
Активные баги на уровне 1 -
- После того как кувшин со здоровьем выпит и Кид попадает в комнату во второй раз
то иногда кувшин мы не показываем (это правильно) но показываем анимацию пузырьков над
кувшином (чего показывать больше не должны - нет кувшина, нет пузырьков),
а в комнате 14 пузырек вообще восстанавливается полностью после того как Кид
выйдет из комнаты и вернется в нее обратно (есть ощущение что даже в одной комнате
поведение пузырька - будет ли он восстановлен или нет зависит от чего-то непонятного.
(есть еще ощущение что первый выпитый кувшин не восстанавливается а остальные
восстанавливаются)
- Меч - один раз подобранный он больше не должен появляться в своей (15-ой) комнате
иногда он появляется снова, иногда он больше не появляется но на дальней стене начинают
моргать контуры кирпичной кладки (странно что она не впечатана в heal уровень потому
что она должна быть отрисована всегда). причем мерцает по странному - не как double-buff
а как буд-то вместо искры на мече - изредка на несколько (один логический?) кадров.
- комната 12 - опять проблема с телепортацией через решетку - решетка в 0,9 - Кид стоит
за шаг (пол-шага) от решетки - бежит и пробегает через закрытую решетку.
- после respawn - остаются разбитыми плиты что были разбиты, выпитыми пузырьки и прочее -
это надо сравнить с оригиналом (SDLPoP) - но главное другое - если до respawn какая-то
плита не была разбита то теперь если ее разбить, выйти из комнаты и вернуться - то
плита восстановится (еще интереснее вариант когда плита-потолок в комнате 12 разбивается,
Кид через проем поднимается в верхнюю комнату и поднявшись плита в новой комнате опять
закрывает проем.
- проблема с плитой из комнаты 12 в верхнюю и пузырек в комнате 22 - они возникают даже
без respawn - когда Кид заходит в комнату 22 из комнаты 16, выпивает пузырек, выходит опять
в комнату 16 и опять возвращается в комнату 22 - пузырек возникает снова. Когда Кид разбивает
плиту из комнаты 12 в комнату 16 то после подъема в комнату 16 плита опять возникает.
аналогично комната 17 - если войти в нее из комнаты 23, разбить плиты 1,5 и 1,6, опуститься на
2,3 и выпить пузырек. вернуться в комнату 23 и опять в 17 - то и плиты вернутся и пузырек.
но могут плита и пузырек не вернуться - такое ощущение что если это первая разбитая плита
(Кида мы перенесли в комнату 12 через cheats) - то разбитая плита остается разбитой.
- комната 6 - Кид бежит по уровню ноль справа налево (начало бега 0,9), делает длинный
прыжок между 0,6 и 0,7 - он пролетает во внутрь стены и респавнится - скриншоты в bugscreens
- иногда залипают все-таки клавиши (пока замечено на кнопке вверх - отлипает только после
следующего нажатия). надо проверить - есть ли возможность не только отслеживать события
нажатие/отжатие но и текущий статус нажатых кнопок. Может вообще отказаться от прямой работы
с клавиатурой а работать с нею через BIOS ?
- иногда таки залипает и shift =((((
- комната 5 - кнопка на 0,4 не открывает решетку/ворота на 0,5 - это двойная кнопка - она
должна сразу открывать ворота на 0,5 и на 0,9 (возможно у нас она работает только на 0,9)
возможно что логически решетка на 0,5 открывается но анимации ее открытия нету =(
- комната 5 - Кид падает с кнопки 0,6 на 2,7 - получает урон, присяд в 2,8 - когда встает
он проваливается еще ниже в комнату 6 на между 2,7-2,8.
- если стражник убивает Кида то Кид респавнится на месте смерти и стражник тут же его убивает
еще раз и так по циклу
--------
- проверить как отрисовывается Кид вместе с мертвым стражником - в оригинале стражник поверх
Кида (Кид проходит за телом стража), у нас Кид поверх (перед) стражем;
- сейчас у нас шифт не залипает но теперь он теряет статус нажатости - то есть при нажатом шифте
второе-треть-четвертое нажатие стрелки отрабатывает не как осторожный шаг а переводит Кида на бег
по идее с новым алгоритмом должно было бы корректно отрабатывать нажатие шифта ? теперь же залипаний
нету но есть фантомный отжим ? такое поведение работы с клавиатурой хуже чем залипание шифта (потому
что игрок надеется что шифт нажат и будет короткий шаг а Кид начинает бежать и может провалиться в
ловушку/яму или набежать на пики и погибнуть). то есть надо как-то более точно проверять статус нажатости
шифта.
то есть - когда нажат шифт и нажимаются стрелки то вариант редких пропусков стрелок предпочтительнее чем
вариант когда мы перестаем понимать что шифт нажат.
- после respawn - должны ли оживать стражники ? сейчас они остаются мертвые.
- небольшой баг отображения (такой баг у нас уже был и ты его фиксил) - комната 12 - две падающие плиты
рядом (0,1 и 0,2) - после падения осколки только от одной на 1,2 а от 0,1 на 1,1 осколков нету.
при том что в комнате 7 две плиты 0,5 и 0,6 падая на 2,5 и 2,6 дают двое осколков.
возможная причина - когда Кид пробегает в комнате 12 по падающим плитам то он убегает сразу в комнату 15
и плита 0,2 успевает упасть и поменять статус пола 1,2 на пол с осколками а плита 0,1 еще остается в падении
когда Кид уже уходит в комнату 15 и потому статус пола на 1,1 на 'пол с осколками' измениться не успевает
--------
Подумай вот над чем -
интересует возможность покрытия уже написаного кода автотестами (тесткейсами).
автотесты внутри MAME я думаю будут достаточно затруднительными, но поскольку это у нас plain-C
то можно наверное делать автотесты и на host OS.
возможно это нам поможет не создавать ситуации когда нам каждый раз приходится фиксить баги которые
мы уже фиксили (по крайней мере постараемся избегать регресса при реализации новых фич)
я понимаю что это скорее всего будет достаточно сложно совместить с тем что финальный продукт идет
для Sprinter. но подумать над этим надо.
Пока ничего не предпринимай, только подумай что и как можно сделать.
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

+480 -4
View File
@@ -10,17 +10,493 @@
* пока исполняется банк, резидентной страницы в адресном пространстве
* нет. Поэтому фон/окклюзию за стража рисует главный цикл.
*
* Сейчас каркас: тик есть, ИИ и боёвка (порт seg005 control_guard*,
* seg004 fight/collision) добавляются сюда.
* Порт: seg003 check_can_guard_see_kid, seg002 autocontrol_*, seg000
* play_guard_frame, seg006 char_opp_dist. ДИСПЕТЧЕР у соперника не свой:
* ИИ выставляет те же глобалы control_* (pop_ctrl.h), что и ввод игрока, а
* дальше работает общий pop_control().
*/
#include "pop_guard.h"
#include "pop_kid.h"
#include "pop_ctrl.h"
#include "pop_map.h"
#include "pop_geom.h"
#include "pop_trob.h"
#include "pop_state.h" /* pop_immortal — чит бессмертия */
#define TILE_MIDX 7 /* середина тайла в координатах персонажа */
#define SCREENSPACE_X 58 /* = pop_x_bump[FIRST_ONSCREEN_COLUMN] */
#define TILE_GATE 4
#define TILE_DOORTOP_FLOOR 7
#define TILE_LOOSE 11
#define TILE_DOORTOP 12
#define TILE_CHOMPER 18
#define TILE_WALL 20
/* tile_is_floor (seg004): не-пол — пустота, ковёр, верх двери, стена и
* «половинки» 26..29. Дубликат крошечного switch'а из pop_map: тянуть его
* через границу банка дороже, чем повторить. */
static uint8_t tile_is_floor(uint8_t t)
{
switch (t) {
case 0: case 9: case 12: case 20:
case 26: case 27: case 28: case 29:
return 0;
default:
return 1;
}
}
/* Тайл в ряду Кида по X-координате персонажа (порт get_tile_at_kid,
* seg003:761): xpos 7 делится на ширину тайла с округлением вниз. */
static int8_t tile_col; /* колонка последнего tile_at_kid */
static uint8_t tile_at_kid(int16_t xpos)
{
/* get_tile_div_mod_m7 (seg006:697): колонка = floor((xpos 7 58)/14),
* СРАЗУ в координатах комнаты (58 = x_bump[FIRST_ONSCREEN_COLUMN]).
* Оригинал берёт её из tile_div_tbl, у нас делением с округлением
* вниз, как в pop_map. */
int16_t x = xpos - 7 - SCREENSPACE_X;
int16_t col = x / TILE_SIZEX;
if (x % TILE_SIZEX < 0) --col;
tile_col = (int8_t)col;
return pop_tile_at(tile_col, Kid.curr_row);
}
/* check_can_guard_see_kid (seg003:688) — луч видимости по ряду.
* Результат в can_guard_see_kid: 0 не видит / 1 видит, но не пойдёт /
* 2 видит и пойдёт. */
void pop_check_can_guard_see_kid(void) __banked
{
uint8_t kid_frame = Kid.frame;
int16_t left_pos, right_pos;
uint8_t tile;
if (!(Guard.charid != 0 &&
/* кадры 219..228 — подъём по лестнице: там страж Кида не видит */
kid_frame != 0 && (kid_frame < 219 || kid_frame >= 229) &&
Kid.alive < 0 && Guard.alive < 0 &&
Kid.room == Guard.room && Kid.curr_row == Guard.curr_row)) {
can_guard_see_kid = 0;
return;
}
can_guard_see_kid = 2;
left_pos = pop_x_bump[Kid.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
right_pos = pop_x_bump[Guard.curr_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
if (left_pos > right_pos) {
int16_t t = left_pos; left_pos = right_pos; right_pos = t;
}
/* Чомпер стоит у ЛЕВОГО края тайла — свой тайл он не перекрывает. */
if (tile_at_kid(left_pos) == TILE_CHOMPER)
left_pos += TILE_SIZEX;
/* Ворота — у ПРАВОГО края, поэтому крайний правый тайл не считается. */
if (tile_at_kid(right_pos) == TILE_GATE)
right_pos -= TILE_SIZEX;
while (left_pos <= right_pos) {
tile = tile_at_kid(left_pos);
/* Сквозь это не видно вовсе. */
if (tile == TILE_WALL || tile == TILE_DOORTOP_FLOOR || tile == TILE_DOORTOP) {
can_guard_see_kid = 0;
return;
}
/* Видно, но страж не пойдёт: провалится / попадёт под чомпер /
* упрётся в неоткрытые ворота / шагнёт в дыру. */
if (tile == TILE_LOOSE || tile == TILE_CHOMPER || !tile_is_floor(tile)) {
can_guard_see_kid = 1;
} else if (tile == TILE_GATE &&
pop_trob_modif(Kid.room)[Kid.curr_row * 10 + tile_col] < 112) {
can_guard_see_kid = 1; /* ворота подняты не до конца */
}
left_pos += TILE_SIZEX;
}
}
/* ---- Синтетический ввод (seg002:0706..) --------------------------- */
static void move_0_nothing(void)
{
control_shift = control_y = control_x = control_shift2 = 0;
control_down = control_up = control_backward = control_forward = 0;
}
static void move_1_forward(void)
{
control_x = POP_CONTROL_HELD_FORWARD;
control_forward = POP_CONTROL_HELD;
}
static void move_2_backward(void)
{
control_backward = POP_CONTROL_HELD;
control_x = POP_CONTROL_HELD_BACKWARD;
}
static void move_3_up(void)
{
control_y = POP_CONTROL_HELD_UP;
control_up = POP_CONTROL_HELD;
}
static void move_4_down(void)
{
control_down = POP_CONTROL_HELD;
control_y = POP_CONTROL_HELD_DOWN;
}
static void move_6_shift(void)
{
control_shift = POP_CONTROL_HELD;
control_shift2 = POP_CONTROL_HELD;
}
static void move_down_back(void)
{
control_down = POP_CONTROL_HELD;
move_2_backward();
}
static void move_down_forw(void)
{
control_down = POP_CONTROL_HELD;
move_1_forward();
}
/* autocontrol_guard_inactive (seg002:710): страж с убранным мечом.
* Пока Кид за спиной страж не реагирует; исключение «шум» Кида
* (is_guard_notice), от которого страж оборачивается. */
static void autocontrol_guard_inactive(void)
{
int16_t distance;
if (Kid.alive >= 0) return;
distance = pop_char_opp_dist();
/* В оригинале условие записано как (word)distance < (word)-8 —
* беззнаковое сравнение, истинное для ВСЕГО, кроме диапазона 8..1.
* То есть: Кид не «вплотную за спиной». */
if (Opp.curr_row != Char.curr_row || distance >= 0 || distance < -8) {
if (is_guard_notice) {
is_guard_notice = 0;
if (distance < 0) {
if (distance < -4) move_4_down(); /* обернуться на шум */
return;
}
} else if (distance < 0) {
return;
}
}
if (can_guard_see_kid) {
/* Увидел — переходит в боевую стойку (вниз+вперёд = достать меч). */
move_down_forw();
}
}
/* ---- Активный страж (меч обнажён), seg002:737.. -------------------- *
* Таблицы вероятностей по 12 градациям мастерства (seg002:26..38). Бросок
* prandom(255) с СОБСТВЕННЫМ сидом: у нас последовательности разведены
* по доменам (кладка / анимация тайлов / бой), см. pop_geom.h. */
static const uint8_t STRIKEPROB[NUM_GUARD_SKILLS] = { 61,100, 61, 61, 61, 40,100,220, 0, 48, 32, 48};
static const uint8_t RESTRIKEPROB[NUM_GUARD_SKILLS] = { 0, 0, 0, 5, 5,175, 16, 8, 0,255,255,150};
static const uint8_t BLOCKPROB[NUM_GUARD_SKILLS] = { 0,150,150,200,200,255,200,250, 0,255,255,255};
static const uint8_t IMPBLOCKPROB[NUM_GUARD_SKILLS] = { 0, 61, 61,100,100,145,100,250, 0,145,255,175};
static const uint8_t ADVPROB[NUM_GUARD_SKILLS] = {255,200,200,200,255,255,200, 0, 0,255,100,100};
/* Бросок «сработало ли»: в оригинале prob > prandom(255). */
static uint8_t roll(uint8_t prob)
{
return (uint8_t)(prob > (uint8_t)pop_prandom(&pop_fight_seed, 255));
}
/* guard_advance (seg002:0AF5): наступать. Мастерство 0 наступает всегда,
* остальные только пока Кид не замахнулся. */
static void guard_advance(void)
{
if (guard_skill == 0 || kid_sword_strike == 0) {
if (roll(ADVPROB[guard_skill])) move_1_forward();
}
}
/* guard_block (seg002:0B1D): блокировать замах Кида. Если Кид только что
* пробил блок (justblocked) своя, «импровизационная» вероятность. */
static void guard_block(void)
{
uint8_t of = Opp.frame;
if (of == FRAME_152_STRIKE_2 || of == FRAME_153_STRIKE_3 ||
of == FRAME_162_BLOCK_TO_STRIKE) {
if (roll(justblocked ? IMPBLOCKPROB[guard_skill]
: BLOCKPROB[guard_skill]))
move_3_up();
}
}
/* guard_strike (seg002:0B73): бить. По замаху Кида не бьём (кадры 169/151);
* из своего парирования по отдельной вероятности «добить». */
static void guard_strike(void)
{
uint8_t of = Opp.frame, cf = Char.frame;
if (of == FRAME_169_BEGIN_BLOCK || of == FRAME_151_STRIKE_1) return;
if (cf == FRAME_161_PARRY || cf == FRAME_150_PARRY) {
if (roll(RESTRIKEPROB[guard_skill])) move_6_shift();
} else {
if (roll(STRIKEPROB[guard_skill])) move_6_shift();
}
}
/* autocontrol_guard_kid_armed (seg002:0AC1): Кид с мечом. Вплотную и
* далеко наступаем; в «рабочей» дистанции 10..28 сначала пробуем блок, и
* только отдышавшись бьём. */
static void autocontrol_guard_kid_armed(int16_t distance)
{
if (distance < 10 || distance >= 29) {
guard_advance();
} else {
guard_block();
if (guard_refrac == 0) {
if (distance < 12 || distance >= 29) guard_advance();
else guard_strike();
}
}
}
/* autocontrol_guard_kid_in_sight (seg002:0A93). */
static void autocontrol_guard_kid_in_sight(int16_t distance)
{
if (Opp.sword == SWORD_2_DRAWN) {
autocontrol_guard_kid_armed(distance);
} else if (guard_refrac == 0) {
/* Кид безоружен: подойти и рубить. */
if (distance < 29) move_6_shift();
else move_1_forward();
}
}
/* Тайл перед стражем / через один (порт get_tile_infrontof_char и
* get_tile_infrontof2_char, seg004) но для Char, а не для Kid. */
static uint8_t tile_infront(int8_t step)
{
int8_t d = (int8_t)(Char.direction < 0 ? -step : step);
return pop_tile_at((int8_t)(Char.curr_col + d), Char.curr_row);
}
/* autocontrol_guard_kid_far (seg002:09CB): идём, если впереди есть пол хотя
* бы через тайл; иначе пятимся (не шагать в пропасть). */
static void autocontrol_guard_kid_far(void)
{
if (tile_is_floor(tile_infront(1)) || tile_is_floor(tile_infront(2)))
move_1_forward();
else
move_2_backward();
}
/* autocontrol_guard_active (seg002:737). */
static void autocontrol_guard_active(void)
{
uint8_t cf = Char.frame;
uint8_t of;
int16_t distance;
/* Кадр 166 (неактивная стойка) и всё, что ниже 150, — не боевые позы. */
if (cf == FRAME_166_STAND_INACTIVE || cf < 150 || can_guard_see_kid == 1)
return;
if (can_guard_see_kid == 0) {
/* TODO: droppedout -> guard_follows_kid_down (seg002:09F8) — вместе
* с физикой стража; скелета (charid 4) ветка тоже не берёт. */
if (Char.charid != CHARID_4_SKELETON) move_down_back();
return;
}
/* can_guard_see_kid == 2 */
of = Opp.frame;
distance = pop_char_opp_dist();
/* Кид падает/приземляется в отскоке — не мешаем ему, ждём. */
if (distance >= 12 && of >= 102 && of < 118 && Opp.action == 5)
return;
if (distance < 35) {
if ((Char.sword < SWORD_2_DRAWN && distance < 8) || distance < 12) {
/* Слишком близко: если смотрим в одну сторону — развернуться. */
if (Char.direction == Opp.direction) move_2_backward();
else move_1_forward();
} else {
autocontrol_guard_kid_in_sight(distance);
}
return;
}
if (guard_refrac != 0) return;
if (Char.direction != Opp.direction) {
/* Кид разбежался — встречаем ударом, а не шагом. */
if (of >= 7 && of < 15) { /* бег */
if (distance < 40) move_6_shift();
return;
} else if (of >= 34 && of < 44) { /* разбег в прыжок */
if (distance < 50) move_6_shift();
return;
}
}
autocontrol_guard_kid_far();
}
/* autocontrol_guard (seg002:701) + autocontrol_opponent (seg002:628). */
static void autocontrol_opponent(void)
{
move_0_nothing();
/* Таймеры боя убывают раз в кадр — здесь, как в оригинале. */
if (justblocked) --justblocked;
if (kid_sword_strike) --kid_sword_strike;
if (guard_refrac) --guard_refrac;
if (Char.sword < SWORD_2_DRAWN) autocontrol_guard_inactive();
else autocontrol_guard_active();
}
/* ---- Урон мечом (seg002:0CD4..0E1F) -------------------------------- */
#define ACTION_99_HURT 99
#define ACTION_1_RUN 1
#define FRAME_154_POKING 154
#define SEQ_69_ATTACK_WAS_PARRIED 69
#define SEQ_74_HIT_BY_SWORD 74
#define SEQ_85_STABBED_TO_DEATH 85
/* refractimer (seg002:36) — «отдышка» стража после того, как его ранили. */
static const uint8_t REFRACTIMER[NUM_GUARD_SKILLS] =
{ 16, 16, 16, 16, 8, 8, 8, 8, 0, 8, 0, 0};
/* take_hp (seg006): отнять count HP у АКТИВНОГО персонажа через дельту.
* Возврат 1 HP кончились. Чит бессмертия ловим здесь: анимацию удара
* Кид получает, а HP не теряет. */
static uint8_t take_hp(uint8_t count)
{
if (Char.charid == CHARID_0_KID) {
if (pop_immortal) return 0;
if (count >= hitp_curr) { hitp_delta = (int8_t)-hitp_curr; return 1; }
hitp_delta = (int8_t)-(int8_t)count;
} else {
if (count >= guardhp_curr) { guardhp_delta = (int8_t)-guardhp_curr; return 1; }
guardhp_delta = (int8_t)-(int8_t)count;
}
return 0;
}
/* hurt_by_sword (seg002): применить попадание к АКТИВНОМУ персонажу.
* Без меча в руке любое попадание смертельно; с мечом минус 1 HP и кадр
* «получил удар».
* УПРОЩЕНИЕ: ветку «сбит с уступа» (seq_81, когда сзади пусто и до кромки
* меньше 4) не портируем ей нужны тайловые запросы ОТ Char, а pop_map
* пока умеет только от Kid. На ровном полу (тайл сзади не пустой) оригинал
* идёт ровно нашей веткой. */
static void hurt_by_sword(void)
{
if (Char.alive >= 0) return;
/* Чит бессмертия перекрывает и «безоружного закалывают насмерть»:
* без этого Кид гибнет с одного удара мимо стойки, и чит бесполезен
* ровно там, где нужен. Анимацию удара оставляем. */
if (Char.charid == CHARID_0_KID && pop_immortal) {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
play_seq();
return;
}
if (Char.sword != SWORD_2_DRAWN) {
take_hp(100);
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH);
} else if (Char.charid != CHARID_4_SKELETON && take_hp(1)) {
pop_char_set_seq(SEQ_85_STABBED_TO_DEATH); /* HP кончились */
} else {
pop_char_set_seq(SEQ_74_HIT_BY_SWORD);
}
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
play_seq(); /* TODO: звук 12/13 */
}
/* check_hurting (seg002:0D56): достал ли АКТИВНЫЙ персонаж соперника.
* Бьют кадры 153/154; если соперник в этот момент парирует удар отбит. */
static void check_hurting(void)
{
int16_t distance;
uint8_t cf, of, min_range;
if (Char.sword != SWORD_2_DRAWN) return;
if (Char.curr_row != Opp.curr_row) return;
cf = Char.frame;
if (cf != FRAME_153_STRIKE_3 && cf != FRAME_154_POKING) return;
distance = pop_char_opp_dist();
of = Opp.frame;
if (distance < 0 || distance >= 29 ||
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
/* Соперник НЕ парирует. */
if (cf == FRAME_154_POKING) {
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
distance = pop_char_opp_dist();
if (distance >= (int16_t)min_range && distance < 29)
Opp.action = ACTION_99_HURT;
}
} else {
/* Парировано. */
Opp.frame = FRAME_161_PARRY;
if (Char.charid != CHARID_0_KID) justblocked = 4;
pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
play_seq();
}
}
/* check_sword_hurting (seg002:0D1A): прогнать проверку с ОБЕИХ сторон. */
void pop_check_sword_hurting(void) __banked
{
uint8_t kf = Kid.frame;
if (kf == 0 || (kf >= 219 && kf < 229)) return; /* подъём по лестнице */
pop_loadshad_and_opp();
check_hurting();
pop_saveshad_and_opp();
pop_loadkid_and_opp();
check_hurting();
pop_savekid_and_opp();
}
/* check_sword_hurt (seg002:0CD4): применить помеченные попадания. Если
* ранен страж Киду в этом кадре урон уже не проходит (в оригинале так же:
* его action сбрасывается ДО проверки). */
void pop_check_sword_hurt(void) __banked
{
if (Guard.action == ACTION_99_HURT) {
if (Kid.action == ACTION_99_HURT) Kid.action = ACTION_1_RUN;
pop_loadshad();
hurt_by_sword();
pop_saveshad();
guard_refrac = (int8_t)REFRACTIMER[guard_skill];
} else if (Kid.action == ACTION_99_HURT) {
pop_loadkid();
hurt_by_sword();
pop_savekid();
}
}
/* play_guard (seg006): ИИ + общий диспетчер. Отдельная ветка «HP кончились»:
* пока страж жив (alive < 0) и HP есть обычный ход; как только HP дошли до
* нуля, он переводится в «умирает» (alive = 0), и дальше тот же control()
* ставит ему seq_71_dying. Уже умирающий сюда попадает с alive >= 0 и просто
* доигрывает последовательность. */
static void play_guard(void)
{
if (Char.alive < 0 && guardhp_curr == 0) {
Char.alive = 0;
/* on_guard_killed (seg006:1927) — у нас пока только звук победы,
* которого нет; для уровня 1 других эффектов там нет. */
}
autocontrol_opponent();
pop_control();
}
/* pop_guard_tick — порт play_guard_frame (seg000:1246) в объёме, который у
* нас уже есть: окно Char/Opp вокруг ИИ, диспетчера и интерпретатора
* последовательностей. Физика стража (fall_accel/коллизия/бамп/пики)
* отдельным шагом. */
void pop_guard_tick(void) __banked
{
if (Guard.charid == 0) return; /* стража в комнате нет */
pop_loadshad_and_opp(); /* Char = Guard, Opp = Kid */
/* Симметрично Киду (play_guard_frame, seg000:1248): cur_frame один на
* всех, и до этой строки в нём лежит кадр КИДА он тикал раньше. */
pop_load_fram_det_col();
play_guard();
/* Анимация: та же seqtbl, что у Кида, но своя таблица кадров —
* выбирается по Char.charid внутри load_frame (seg006:0293). */
pop_guard_play();
/* TODO: порт control_guard/fight (seg005/seg004) — состояние в Guard. */
play_seq();
pop_saveshad();
}
+27
View File
@@ -42,4 +42,31 @@ static const uint16_t kid_seq_off[KID_NSEQ] = {
0x21FC,0x2240,0x2257,
};
/* sword_tbl (seg006:2047): клинок отдельным спрайтом chtab_0.
* Индекс sword_frame (кадр.sword & 0x3F), id 255 = клинка нет.
* Смещения x/y ЭКРАННЫЕ (применяются после calc_screen_x_coord),
* x «вперёд» по направлению персонажа. */
#define KID_NSWORD 51
#define KID_SWORD_ID_INIT { \
0xFF,0x00,0x05,0x01,0x02,0x06,0x07,0x03,0x04,0x1E,0x08,0x09, \
0x0A,0x0B,0x0C,0x0D,0x0E,0x0F,0x10,0x11,0x12,0x13,0x13,0x11, \
0x11,0x07,0x01,0x1C,0x08,0x04,0x04,0x0A,0x0D,0x0D,0x0D,0x14, \
0x15,0x16,0x17,0x18,0x19,0x1A,0x1B,0xFF,0xFF,0x1D,0x08,0x1F, \
0x20,0x21,0x08, \
}
#define KID_SWORD_DX_INIT { \
0,0,-9,7,17,7,0,17,16,12,13,7, \
10,10,22,28,13,0,0,21,14,21,22,7, \
15,0,7,14,7,6,9,11,24,19,21,7, \
14,14,14,28,28,21,14,14,21,0,8,14, \
14,7,8, \
}
#define KID_SWORD_DY_INIT { \
0,-9,-29,-25,-26,-14,-5,-16,-19,-9,-34,-25, \
-16,-11,-21,-23,-35,-38,-29,-19,-23,-22,-23,-13, \
-18,-8,-27,-28,-27,-23,-21,-18,-23,-23,-23,-32, \
-32,-31,-29,-28,-28,-25,-22,-25,-25,-16,-37,-24, \
-24,-14,-37, \
}
#endif
+132 -24
View File
@@ -14,10 +14,12 @@
#include <fcntl.h>
#include <unistd.h>
#include "pop_bg.h"
#include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */
#include "pop_state.h"
#include "pop_geom.h"
#include "pop_level.h" /* pop_doorlink2 — состояние нажатия кнопки */
#include "pop_kid.h" /* Kid.curr_col/curr_row — порядок оверлеев (см. ниже) */
#include "pop_guard.h" /* SWORD_2_DRAWN — расширение футпринта под клинок */
/* ---- Атласы (прямая адресация) ------------------------------------- */
static atlas_t env[5];
@@ -135,18 +137,12 @@ static void blit_b(atlas_t *a, uint8_t idx, int x, int ybottom)
gfx_w0_unmap();
}
/* heal в room-space (со сдвигом POP_YOFF) — все heal слоя фона/loose. */
/* heal в room-space (со сдвигом POP_YOFF) — все heal слоя фона/loose.
* Выбор ядра (noclip / клипающее) общий с Кидом и стражем, pop_heal_fast
* в pop_bg.h. */
static void heal_off(int x, int y, int w, int h)
{
int sy = y + POP_YOFF;
/* Без клипа — линейным ядром (~2.9×, как gfx_blit_noclip): общий
* gfx_heal платит за клип/16-бит/split, а не за пиксели (замер:
* 11 658 тактов на heal 22×22). */
if (x >= 0 && sy >= 0 && w > 0 && h > 0 && w < 256 && h < 256 &&
x + w <= 320 && sy + h <= 256)
gfx_heal_noclip(x, sy, (uint8_t)w, (uint8_t)h);
else
gfx_heal(x, sy, w, h);
pop_heal_fast(x, y + POP_YOFF, w, h);
}
/* Область, куда в этом кадре рисовался ОВЕРЛЕЙ (не футпринт fore, а
@@ -354,7 +350,7 @@ static uint8_t wall_modifier(int row, int col)
}
/* ---- PRNG (seg009.c:321, 32-бит LCG) — только на ВХОД в комнату ---- */
static unsigned long rnd_seed;
static pop_rnd_t rnd_seed;
#define prandom(maxv) pop_prandom(&rnd_seed, (maxv))
@@ -417,7 +413,7 @@ static void wall_rnd(int row, int col)
* соседнего РЯДА и чужую раскладку (тихий баг вместо промаха). */
int idx = (col >= 0 && col < 10) ? (row + 1) * 10 + col : -1;
uint8_t a, b, bg;
unsigned long saved;
pop_rnd_t saved;
if (idx >= 0 && idx < WP_N && wp_ok[idx]) {
a = wp_a[idx]; b = wp_b[idx];
wr_mid_div = (uint8_t)(a & 1); wr_mid_off = (uint8_t)((a >> 1) & 7);
@@ -430,7 +426,7 @@ static void wall_rnd(int row, int col)
saved = rnd_seed;
/* Верхняя полоса (row<0): SDLPoP рисует её как ряд 2 комнаты сверху
* (drawn_row=2 для seed, draw_main_y=-1 для позиции) seed по row 2. */
rnd_seed = (unsigned long)(g_room + TBL_LINE[row < 0 ? 2 : row] + col);
pop_prandom_set(rnd_seed, g_room + TBL_LINE[row < 0 ? 2 : row] + col);
prandom(1); /* сброс */
wr_mid_div = (uint8_t)prandom(1);
wr_mid_off = (uint8_t)prandom(4);
@@ -590,6 +586,11 @@ static void draw_leveldoor(uint8_t modif, int xh, int dmy)
int ybottom = dmy - 13;
int ylo = ybottom - (int)(modif & 3) - 48; /* leveldoor_ybottom */
int y = ybottom - (int)modif;
/* leveldoor_right (seg008:1D29): правая кромка проёма — от xh ТЕКУЩЕГО
* тайла, то есть ПРАВОЙ половины двери (сюда мы попадаем из
* draw_tile_anim_right, разбирая левого соседа). +8 у palace-уровней
* (tbl_level_type) на уровне 1 не применяется. */
pop_leveldoor_right = xh * 8 + 48;
env_b(99, x, ybottom);
if (modif)
env_b(144, x, ybottom - 4); /* лестница (комната ≠ стартовой) */
@@ -598,6 +599,9 @@ static void draw_leveldoor(uint8_t modif, int xh, int dmy)
if (y > ylo) ylo += 4;
else break;
}
/* ИМЕННО после цикла: в оригинале clip_char читает уже доведённое
* значение (низ поднятой створки), а не стартовое. */
pop_leveldoor_ybottom = ylo;
env_b(34, x, dmy - 64);
}
@@ -629,7 +633,11 @@ static void draw_tile(int row, int col)
* room_B(0, col-1); col-1<0 левая комната снизу = стена уровня. */
if (row + 1 > 2) {
int lc = col - 1;
if (lc < 0) rbl_code = 20; /* кромка уровня = стена */
/* lc < 0 — угол (2,0): сосед снизу-слева лежит в ЧУЖОЙ комнате
* (room_BL). Её тайл (0,9) приходит одиннадцатым байтом below-ряда;
* «стена» там стоит, только если такой комнаты нет как в
* load_rowbelow (seg008:368). См. BUG-SEAM-WEDGE-1. */
if (lc < 0) rbl_code = g_below ? (uint8_t)(g_below[10] & 0x1F) : 20;
else rbl_code = g_below ? (uint8_t)(g_below[lc] & 0x1F) : 1;
} else rbl_code = tile_code(row + 1, col - 1);
@@ -839,8 +847,22 @@ void pop_ceil_bake_empty(int col) __banked
gfx_set_bank(GFX_BANK_TRANSPARENT);
g_clip_top = POP_YOFF;
bake_rest = 1; /* фон = покой (см. bake_rest) */
draw_tile(-1, col); /* тайл уже EMPTY → колодец наверх */
if (col + 1 < 10) draw_tile(-1, col + 1);
/* Восстанавливаем ВСЁ, чья графика попадает в стёртую полосу, а не только
* два тайла ряда 1 (BUG-LOOSE-3). Куски тайла рисуются ВВЕРХ от своей
* нижней грани и свисают ВПРАВО, поэтому в полосу над тайлом col лезут:
* ряд 1, колонки col1..col+1 сама полоса кладки у потолка;
* ряд 0, колонки col1..col+1 их верхушки заходят в те же 8 px.
* Раньше перерисовывались только (1,col) и (1,col+1), и в полосе
* оставался чёрный прямоугольник ~13×3 px (замер: экранные y 47..52 при
* полосе 44..52), который heal возвращал каждый кадр. Признак, что дело
* именно в запечке: после выхода из комнаты и возврата бара нет. */
{
int c0 = (col > 0) ? col - 1 : 0;
int c1 = (col + 1 < 9) ? col + 1 : 9;
int c;
for (c = c0; c <= c1; c++) draw_tile(-1, c);
for (c = c0; c <= c1; c++) draw_tile(0, c);
}
bake_rest = 0;
g_clip_top = 0;
gfx_set_bank(GFX_BANK_NORMAL);
@@ -919,6 +941,35 @@ void pop_leveldoor_redraw(int row, int col) __banked
gfx_set_bank(GFX_BANK_NORMAL);
}
/* Ворота ВНУТРИ комнаты: перерисовать бары по живому modif (порт draw_trob,
* seg007:01E6 им заканчивается animate_door). (row,col) сами ворота;
* решётку рисует ячейка ПРАВОГО соседа (draw_tile, ветка lcode==4), поэтому
* wipe и redraw идут по ней. Логика ровно как у левого шва
* (pop_room_redraw_seam_left), только колонка произвольная.
*
* Bar-в-чёрный обязателен: бары висят в видео-ОЗУ и ОЗУ-копии, и при
* ПОДЪЁМЕ решётки heal вернул бы запечённые нижние бары. Полоса ровно
* зона решётки: от gate_top_y (dby62 = 63*row+3) до низа (63*row+64);
* выше начинается ряд 1, ниже стена ряда ниже.
*
* Ворота в col9: их бары рисуются уже в СОСЕДНЕЙ комнате, в отрисованной их
* не видно оригинал такой trob тоже пропускает
* (get_trob_right_pos_in_drawn_room 30). Для взгляда из комнаты справа
* работает pop_room_redraw_seam_left. */
void pop_gate_redraw(int row, int col) __banked
{
int x, yt;
if (col + 1 > 9 || row < 0 || row > 2) return;
x = COL_XH[col + 1] * 8;
yt = POP_YOFF + 63 * row;
gfx_set_bank(GFX_BANK_NORMAL);
setfillstyle(SOLID_FILL, BLACK);
bar(x, yt + 3, x + 31, yt + 64);
gfx_set_bank(GFX_BANK_TRANSPARENT);
draw_tile(row, col + 1);
gfx_set_bank(GFX_BANK_NORMAL);
}
/* Запечь ПУСТОТУ на месте упавшего loose (текущая страница). Порт
* redraw_at_cur_mob (seg007:132C): перерисовать сам тайл (теперь empty) И
* тайл СПРАВА (его левая грань зависит от нашего кода: пока был loose
@@ -983,6 +1034,7 @@ typedef struct {
int prev_y[2]; /* последняя нарисованная y на КАЖДОЙ странице (heal) */
int8_t speed;
int8_t row, col; /* тайл-позиция куска (для окклюзии соседа) */
uint8_t room; /* curmob.room: кусок падает В СВОЕЙ комнате, не в текущей */
} mob_t;
static mob_t mobs[MOB_MAX];
@@ -1011,6 +1063,7 @@ void pop_loose_mob_spawn(int row, int col) __banked
m->y = MOB_Y_LAND[row + 1]; /* y_loose_land: низ куска в момент отрыва */
m->row = (int8_t)row;
m->col = (int8_t)col;
m->room = g_room; /* curmob.room (add_mob, seg007:1010) */
m->speed = 0;
m->active = 1;
m->clean = 0;
@@ -1044,14 +1097,42 @@ void pop_loose_mob_reset(void) __banked
}
}
/* Смена комнаты. Куски НЕ останавливаем: в оригинале mobs[] чистится только
* в start_level (seg003:88), а do_mobs (seg007:1063) крутит их все независимо
* от drawn_room move_loose работает по curmob.room, и плита, за которой Кид
* не досмотрел, всё равно долетает и кладёт щебень (BUG-LOOSE-2: в комнате 12
* плита 0,1 не успевала упасть до ухода в комнату 15). Сбросить надо только
* heal-историю: новая комната перерисована целиком, старых пикселей нет. */
void pop_loose_mob_room_changed(void) __banked
{
uint8_t i;
for (i = 0; i < MOB_MAX; i++) {
mobs[i].defer = 0;
mobs[i].prev_y[0] = mobs[i].prev_y[1] = MOB_Y_NONE;
/* дочистка хвоста относилась к покинутой комнате — она уже не нужна */
if (!mobs[i].active) mobs[i].clean = 0;
}
}
/* Тайл под куском. В своей комнате — обычный tile_code (он знает про кромки
* и соседей), в чужой прямо из данных уровня. */
static uint8_t mob_tile_at(mob_t *m)
{
if (m->room == g_room) return (uint8_t)(tile_code(m->row, m->col) & 0x1F);
if (m->row < 0 || m->row > 2 || m->col < 0 || m->col > 9) return 1; /* кромка = пол */
return (uint8_t)(pop_level_tile(m->room, (uint8_t)(m->row * 10 + m->col)) & 0x1F);
}
static void mob_tick_one(mob_t *m, uint8_t pg)
{
uint8_t here;
if (!m->active && !m->clean) return;
here = (uint8_t)(m->room == g_room);
/* Стереть кусок, нарисованный на ЭТОЙ странице в прошлый раз (у каждой
* страницы своя копия фона). Раньше коридор был фиксирован под ряд 2;
* плита-потолок падает через весь ряд 0, поэтому чистим по фактической
* прошлой позиции. */
if (m->prev_y[pg] != MOB_Y_NONE) {
if (here && m->prev_y[pg] != MOB_Y_NONE) {
heal_off(MOB_X0(m->x), m->prev_y[pg] - 27, MOB_W, 32);
m->prev_y[pg] = MOB_Y_NONE;
}
@@ -1064,9 +1145,17 @@ static void mob_tick_one(mob_t *m, uint8_t pg)
* loose_fall «плита ломает плиту» не нужен: в ряду ниже её нет);
* пол приземление (loose_land: debris + do_knock). */
if (m->row < 3 && m->y < 226 && MOB_Y_BOUND[m->row + 1] <= m->y) {
uint8_t t = (uint8_t)(tile_code(m->row, m->col) & 0x1F);
uint8_t t = mob_tile_at(m);
if (t == 0 || t == 11) {
m->row++; /* mob_down_a_row */
} else if (!here) {
/* Приземление в ПОКИНУТОЙ комнате: рисовать нечего, сигнальная
* пара pop_loose_landed/pop_debris_at обслуживает только текущую
* комнату щебень кладём прямо в данные уровня (loose_land пишет
* в curr_room_tiles комнаты curmob.room, а не отрисованной). */
pop_level_set_tile(m->room, (uint8_t)(m->row * 10 + m->col), 0x0E);
m->active = 0; m->clean = 0;
return;
} else if (pop_loose_landed) {
/* сигнал прошлого приземления ещё не разобран (один за кадр) —
* подержим кусок на границе и приземлим следующим кадром */
@@ -1079,9 +1168,10 @@ static void mob_tick_one(mob_t *m, uint8_t pg)
}
}
if (m->y - 27 >= POP_PLAYFIELD_H) { /* весь ниже поля (улетел из комнаты) */
m->active = 0; m->clean = 2;
m->active = 0; m->clean = (uint8_t)(here ? 2 : 0);
return;
}
if (!here) return; /* чужая комната — считаем, но не рисуем */
/* Порядок «плита vs Kid». В оригинале объекты рисуются при обходе СВОИХ
* тайлов (redraw_needed_tiles: ряды 2,1,0; колонки 0..9), и только внутри
* ОДНОГО тайла сортируются по obj_y (sort_curr_objs, seg008:1561: кто
@@ -1162,8 +1252,13 @@ void pop_loose_mob_draw_over(void) __banked
uint8_t pop_loose_mob_pos(int *col, int *y) __banked
{
uint8_t i;
/* check_loose_fall_on_kid (seg007:1192) начинается с Char.room ==
* curmob.room: кусок, падающий в покинутой комнате, Киду по голове не
* бьёт. */
for (i = 0; i < MOB_MAX; i++)
if (mobs[i].active) { *col = mobs[i].col; *y = mobs[i].y; return 1; }
if (mobs[i].active && mobs[i].room == g_room) {
*col = mobs[i].col; *y = mobs[i].y; return 1;
}
return 0;
}
@@ -1190,6 +1285,11 @@ void pop_room_clip_borders(void) __banked
{
if (!border_dirty) return;
border_dirty--;
/* ОСТАЁТСЯ клипающим осознанно (CLIP-1, 2026-08-01): полоса шириной 320
* не лезет в 8-битный параметр noclip-ядра, а разбивать её на два куска
* по 160 нет смысла гейт border_dirty пускает сюда только в кадрах
* падения/выхода за поле, и выигрыш (~2×5.6 К тактов подготовки) там
* тонет в цене самих 320×28 пикселей. Не «оптимизировать» повторно. */
gfx_heal(0, 0, 320, POP_YOFF); /* верхний борт */
gfx_heal(0, POP_CLIP_BOTTOM, 320, 256 - POP_CLIP_BOTTOM);/* статус-полоса + нижний борт */
}
@@ -1466,7 +1566,7 @@ static void other_overlay_tile(int row, int col)
static int8_t fp_cL, fp_cR, fp_cLraw, fp_rT, fp_rB, fp_rTraw;
static void char_footprint(int obj_x, int obj_y, uint16_t w, uint16_t h,
int8_t dir)
int8_t dir, uint8_t sword)
{
int wh = (int)((w + 1) >> 1); /* char_width_half */
int xl = obj_x / 2 + 58; /* char_x_left (58-база) */
@@ -1477,6 +1577,14 @@ static void char_footprint(int obj_x, int obj_y, uint16_t w, uint16_t h,
cLraw = col_from_x(xl); /* до клампа: <0 = футпринт за левым швом */
cL = cLraw; if (cL < 0) cL = 0; else if (cL > 9) cL = 9;
cR = col_from_x(xr); if (cR < 0) cR = 0; else if (cR > 9) cR = 9;
/* redraw_at_char (seg003:0430): «If char is holding sword, it makes
* redraw-area bigger» КЛИНОК торчит на колонку дальше габарита кадра,
* и без этого расширения передние грани в той колонке не
* перерисовываются: меч остаётся поверх столба, а его след на фоне. */
if (sword >= SWORD_2_DRAWN) {
if (dir >= 0) { if (++cR > 9) cR = 9; }
else { if (--cL < 0) cL = 0; }
}
/* Порт set_char_collision (seg006:0723): нижний ряд = y_to_row_mod4(obj_y),
* причём обёртка -1 (Kid НИЖЕ комнаты climbdown/провал, obj_y в полосе
* «ряд 3») трактуется как ряд 3, НЕ ряд 0. Верхний ряд считается от
@@ -1508,7 +1616,7 @@ void pop_fore_over_kid(int obj_x, int obj_y, uint16_t w, uint16_t h,
{
int8_t cL, cR, cLraw, rT, rB, rTraw, r, c;
fclip_on = 1; /* окно = прямоугольник спрайта */
char_footprint(obj_x, obj_y, w, h, dir);
char_footprint(obj_x, obj_y, w, h, dir, Kid.sword);
cL = fp_cL; cR = fp_cR; cLraw = fp_cLraw;
rT = fp_rT; rB = fp_rB; rTraw = fp_rTraw;
/* set_objtile_at_char (seg006:1833): тайл, при обработке которого спрайт
@@ -1592,11 +1700,11 @@ void pop_fore_over_kid(int obj_x, int obj_y, uint16_t w, uint16_t h,
* (seq_77..seq_84). Появятся падающие/висящие стражи тогда и общий
* порт redraw_at_char2, а не догадки сейчас. */
void pop_fore_over_char(int obj_x, int obj_y, uint16_t w, uint16_t h,
int8_t dir)
int8_t dir, uint8_t sword) __banked
{
int8_t r, c;
fclip_on = 1; /* окно = прямоугольник спрайта */
char_footprint(obj_x, obj_y, w, h, dir);
char_footprint(obj_x, obj_y, w, h, dir, sword);
gfx_set_bank(GFX_BANK_SPRITE);
for (r = fp_rT; r <= fp_rB; r++)
for (c = fp_cL; c <= fp_cR; c++)
+25 -5
View File
@@ -25,6 +25,11 @@
/* Экранный y нижней границы поля (ниже — статус-полоса + борт → клип). */
#define POP_CLIP_BOTTOM (POP_YOFF + POP_PLAYFIELD_H)
/* Выбор ядра libbgi (pop_onscreen_cols / pop_heal_fast) переехал в
* `_pop_draw.h` его включают только три рисующих модуля. Из общего
* заголовка SDCC размножал мёртвые копии этих inline'ов по всем TU: см.
* шапку `_pop_draw.h`. */
/* Загрузить 7 атласов фона. Звать ДО initgraph (как poc.c — atlas_load
* трогает W3; проверенный порядок). Палитру pop_bg.pal грузит ПРИЛОЖЕНИЕ
* после initgraph (gfx_pal_fload). 0 OK, -1 ошибка. */
@@ -41,9 +46,11 @@ void pop_room_draw(uint8_t room_num,
const uint8_t *fg, const uint8_t *bg,
const uint8_t *lcol_fg, const uint8_t *lcol_bg) __banked;
/* Задать ряд 0 комнаты СНИЗУ (fg-коды, 10 шт.) — для draw_tile_topright,
* когда тайл нижнего ряда стал empty (после падения loose): «стена вниз»
* рисуется по коду room_B(0, col-1). 0 нет комнаты снизу. */
/* Задать ряд 0 комнаты СНИЗУ — для draw_tile_topright, когда тайл нижнего
* ряда стал empty (после падения loose): «стена вниз» рисуется по коду
* room_B(0, col-1). ОДИННАДЦАТЬ байт: [0..9] колонки комнаты снизу,
* [10] тайл (0,9) комнаты СНИЗУ-СЛЕВА (сосед угла (2,0); см.
* load_rowbelow, seg008:368). 0 нет комнаты снизу. */
void pop_room_set_below(const uint8_t *below_row0_fg) __banked;
/* Задать ряд 2 (fg-коды + модификаторы, 10 шт.) комнаты СВЕРХУ — для полосы
@@ -69,8 +76,11 @@ void pop_fore_set_clip(int x, int y, int w, int h) __banked;
/* То же для НЕ-Kid персонажа (страж): только слой fore + полоса потолка,
* без оверлеев поз виса/полёта/подъёма. obj_x ЛОГИЧЕСКАЯ координата
* (до ×8/7), как у kid_fp_obj_x(). */
/* __banked: отрисовка стража уехала в СВОЙ банк (pop_gdraw.c), поэтому
* вызов стал кроссбанковым и обязан идти через трамплин. Зовётся раз в
* кадр, других вызывающих нет цена одного трамплина здесь не видна. */
void pop_fore_over_char(int obj_x, int obj_y, uint16_t w, uint16_t h,
int8_t dir);
int8_t dir, uint8_t sword) __banked;
/* Восстановить фон там, где в прошлых кадрах рисовался ОВЕРЛЕЙ
* (draw_other_overlay/floor_overlay/полоса потолка): он выходит за
@@ -120,12 +130,22 @@ void pop_button_redraw(int row, int col) __banked; /* = pop_floor_bake (кно
* запечкой створка анимируется (pop_trob animate_leveldoor). */
void pop_leveldoor_redraw(int row, int col) __banked;
/* Ворота ВНУТРИ отрисованной комнаты: перерисовать по живому modif.
* (row,col) координаты САМИХ ворот; бары рисует ячейка правого соседа
* (draw_tile, ветка lcode==4), туда и идёт wipe+redraw. Порт draw_trob
* (seg007:01E6), которым заканчивается animate_door. */
void pop_gate_redraw(int row, int col) __banked;
/* Падающий кусок loose (mob, seg007 move_loose): спавн при падении тайла
* (row,col) и покадровое продвижение+отрисовка на back-странице. tick
* зовётся каждый кадр (внутри pop_loose_tick); клип низа делает clip. */
void pop_loose_mob_spawn(int row, int col) __banked;
void pop_loose_mob_tick(void) __banked;
void pop_loose_mob_reset(void) __banked; /* остановить кусок (смена комнаты) */
void pop_loose_mob_reset(void) __banked; /* убрать все куски (СТАРТ УРОВНЯ, mobs_count=0) */
/* Смена комнаты: куски продолжают падать в СВОИХ комнатах (do_mobs крутит
* mobs[] независимо от drawn_room), сбрасывается только heal-история. */
void pop_loose_mob_room_changed(void) __banked;
/* Кусок НИЖЕ Kid по obj_y → рисуется ПОВЕРХ него (порт sort_curr_objs,
* seg008:1561). Звать после kid_draw, перед fore-слоем. */
+10 -1
View File
@@ -6,7 +6,8 @@
* просто глобальный флаг: main ставит его при старте (на время разработки
* ВКЛЮЧЁН). Клавиши читов обрабатывает главный цикл.
*
* Реализовано: K убить стража (seg000:786). Остальные читы оригинала
* Реализовано: K убить стража (seg000:786), I/S/Shift+L (наши отладочные),
* [ / ] подгонка Кида по X (seg000:1828). Остальные читы оригинала
* (время, перо, воскрешение, переход по комнатам) пока не портированы.
*/
#ifndef POP_CHEAT_H
@@ -17,5 +18,13 @@
extern uint8_t pop_cheats; /* 0 = выключены, иначе включены */
#define KBD_CHEAT_KILL 0x42 /* K (PS/2 set 2) — kill guard */
#define KBD_CHEAT_IMMO 0x43 /* I (PS/2 set 2) — бессмертие Кида (toggle) */
#define KBD_CHEAT_SWORD 0x1B /* S (PS/2 set 2) — выдать Киду меч */
#define KBD_CHEAT_NEXTLVL 0x4B /* Shift+L (PS/2 set 2) — следующий уровень */
#define KBD_CHEAT_XDEC 0x54 /* [ (PS/2 set 2) — сдвинуть Кида на 1 px влево */
#define KBD_CHEAT_XINC 0x5B /* ] (PS/2 set 2) — сдвинуть Кида на 1 px вправо */
/* Последний уровень игры (tbl_* в pop_level рассчитаны на 0..15). */
#define POP_LAST_LEVEL 15
#endif
+282 -23
View File
@@ -17,8 +17,10 @@
*/
#include <stdint.h>
#include <kbd_raw.h>
#include "pop_ctrl.h" /* свой API (__banked) + объявления шины control_* */
#include "pop_kid.h"
#include "pop_map.h"
#include "pop_guard.h" /* charid, состояние меча, seq стража */
/* Отладочные клавиши осторожного шага (эмуляция Shift+стрелка) для тестов в
* MAME, где Shift трудно подать: J = шаг влево, L = шаг вправо. PS/2 set2
@@ -51,6 +53,7 @@
#define SEQ_49_STAND_UP_FROM_CROUCH 49
#define SEQ_50_CROUCH 50
#define SEQ_79_CROUCH_HOP 79
#define SEQ_78_DRINK 78 /* выпить зелье */
#define SEQ_91_PICKUP_SWORD 91 /* поднять меч (pickupsword) */
#define SEQ_STEP_BASE 28 /* +distance(1..14) = seq 29..42 шаг-N */
#define SEQ_39_UNSAFE_STEP 39 /* шаг за кромку (distance 11) */
@@ -58,6 +61,7 @@
#define SEQ_14_JUMP_INTO_CEILING 14 /* прыжок вверх: сверху стена/пол */
#define SEQ_28_JUMP_NOTHING_ABOVE 28 /* прыжок вверх: сверху пусто */
#define SEQ_25_HANG_AGAINST_WALL 25 /* вис у стены (Shift в висе) */
#define SEQ_GO_UP_LEVELDOOR 70 /* seq_70_go_up_on_level_door */
#define FRAME_7_RUN 7
#define FRAME_11_RUN 11
@@ -70,15 +74,15 @@
#define FRAME_109_CROUCH 109
/* ---- Состояние управления (seg006 globals) ------------------------ *
* control_x/y/shift «сырое» состояние осей ЭТОГО кадра (абсолютно:
* X LEFT/-1..RIGHT/+1); *_forward/backward/up/down/shift2 конечный
* автомат held/released/ignore (авто-повтор); ctrl1_* их память между
* кадрами (rest/save). */
static int8_t control_x, control_y, control_shift;
/* НЕ static: в оригинале это глобалы, через которые ИИ стража подаёт
* СИНТЕТИЧЕСКИЙ ввод (autocontrol_*, seg002), а дальше работает тот же
* control(), что и у игрока. Поэтому шина общая. */
int8_t control_forward, control_backward, control_up, control_down, control_shift2;
* Сами control_* ЖИВУТ В pop_state.c, а не здесь. Модуль уехал в БАНК 5,
* а шину пишут с трёх сторон (этот банк, guards.c из банка 1) и читают из
* банка 3 (pop_map) это ровно тот случай, для которого pop_state и
* заведён. Сегодня оно работало бы и отсюда: банки собираются БЕЗ
* --bank-data, поэтому их писучие данные остаются в общем _DATA. Но это
* флаг сборки, а не свойство кода: включат --bank-data ради экономии
* W1/W2 и шина молча уедет в страницу банка.
*
* ctrl1_* остаются здесь: их трогает только этот модуль. */
static int8_t ctrl1_forward, ctrl1_backward, ctrl1_up, ctrl1_down, ctrl1_shift2;
/* ---- seqtbl_offset_char: сменить последовательность Kid ------------ *
@@ -125,20 +129,30 @@ static uint8_t get_item_action(void)
seqtbl_offset_char(SEQ_91_PICKUP_SWORD);
return 1;
}
if (a == 3) { /* зелье — выпить (get_item) */
control_shift2 = CONTROL_IGNORE;
seqtbl_offset_char(SEQ_78_DRINK);
return 1;
}
return 0;
}
/* run_jump (seg005:0AA8). Выравнивание по кромке пола живёт в pop_map
* (там тайловые запросы) pop_run_jump_align; здесь только диспетчерская
* часть. Отказ выравнивателя = прыжка в этом кадре НЕТ, и control_up
* гасить нельзя: Кид бежит дальше с зажатой «вверх» и попробует снова на
* следующем кадре. Ровно так игрок и «ловит» фазу перед провалом. */
static void run_jump(void)
{
/* Оригинал выравнивает Kid по краю пола (нужны tile-запросы) — это
* полировка K3; K2b просто запускает run-jump. */
if (Char.frame >= FRAME_7_RUN) {
if (!pop_run_jump_align()) return;
control_up = release_arrows();
seqtbl_offset_char(SEQ_4_RUN_JUMP);
}
}
static void safe_step(void); /* fwd */
static void draw_sword(void); /* fwd: нужен в control_standing */
static void forward_pressed(void)
{
@@ -179,8 +193,15 @@ static void jump_up(void)
static void up_pressed(void)
{
/* верь уровня/телепорт — нет в тесте.) ↑+forward = прыжок с места;
* иначе вертикальный прыжок вверх. */
/* Дверь уровня рядом и открыта → войти в неё: постановку в проём
* (go_up_leveldoor) делает pop_leveldoor_enter, здесь остаётся только
* последовательность подъёма по лестнице. Телепортов у нас нет это
* модификация SDLPoP, а не оригинал.
* Иначе: +forward = прыжок с места, иначе вертикальный прыжок вверх. */
if (pop_leveldoor_enter()) {
seqtbl_offset_char(SEQ_GO_UP_LEVELDOOR);
return;
}
if (control_x == CONTROL_HELD_FORWARD)
standing_jump();
else
@@ -232,7 +253,32 @@ static void control_standing(void)
if (control_shift2 == CONTROL_HELD && control_shift == CONTROL_HELD &&
get_item_action())
return;
/* Kid charid_0, без меча: остаётся нижняя часть control_standing. */
/* Ветку «charid != kid && вниз+вперёд -> draw_sword» не портируем: у нас
* соперник до control_standing не доходит, его перехватывает
* control_guard_inactive в самом диспетчере (порядок как в seg005). */
/* seg005:352 — Кид с мечом ВИДИТ активного стража: достаёт клинок сам.
* Без этого игрок стоит с мечом в ножнах, пока его рубят. */
if (pop_have_sword) {
if (offguard != 0 && control_shift >= CONTROL_RELEASED) {
/* Меч убран намеренно (down в бою) — не доставать, пока не
* отпустят Shift; в оригинале это переход на loc_6213. */
} else if (can_guard_see_kid >= 2) {
int16_t d = pop_char_opp_dist();
if (d >= -10 && d < 90) {
holding_sword = 1;
/* (word)d < (word)-6 — беззнаковое: истинно для ВСЕГО, кроме
* 6..1. То есть «страж вплотную за спиной» -> отступить,
* иначе достать меч. */
if (d >= -6 && d < 0) { back_pressed(); return; }
draw_sword();
return;
}
} else {
offguard = 0;
}
}
if (control_shift == CONTROL_HELD) {
if (control_backward == CONTROL_HELD) back_pressed();
else if (control_up == CONTROL_HELD) up_pressed();
@@ -324,10 +370,199 @@ static void control_jumpup(void)
standing_jump();
}
/* control() — диспетчер по кадру (seg005.c:252), Kid жив, без меча. */
void pop_control(void)
/* draw_sword (seg005:945): достать меч. Кид переходит в seq_55 (подъём
* клинка со звуком), соперник сразу в стойку en garde (seq_90). Стрелки
* гасятся, чтобы «вперёд», которым меч достали, не сработало второй раз
* уже как выпад. */
static void draw_sword(void)
{
uint8_t seq_id = SEQ_55_DRAW_SWORD;
control_forward = control_shift2 = release_arrows();
if (Char.charid == CHARID_0_KID) {
offguard = 0; /* TODO: play_sound(19) — звука пока нет */
} else if (Char.charid != CHARID_1_SHADOW) {
seq_id = SEQ_90_EN_GARDE; /* соперник встаёт сразу в стойку */
}
Char.sword = SWORD_2_DRAWN;
seqtbl_offset_char(seq_id);
}
/* control_guard_inactive (seg006:2123): неактивный страж реагирует ТОЛЬКО в
* кадре 166 (стойка) и только на «вниз»: вниз+вперёд достать меч,
* вниз без «вперёд» развернуться (stand flipped). */
static void control_guard_inactive(void)
{
if (Char.frame == FRAME_166_STAND_INACTIVE && control_down == CONTROL_HELD) {
if (control_forward == CONTROL_HELD) {
draw_sword();
} else {
control_down = CONTROL_IGNORE;
seqtbl_offset_char(SEQ_80_STAND_FLIPPED);
}
}
}
/* ---- Боёвка (seg005:964..) ---------------------------------------- *
* Всё это ветки ОБЩЕГО диспетчера: у стража тот же control(), просто
* control_* ему выставляет ИИ (autocontrol_*, guards.c), а не клавиатура. */
/* back_with_sword (seg005): шаг назад — только из стоек. */
static void back_with_sword(void)
{
uint8_t f = Char.frame;
if (f == FRAME_158_STAND_WITH_SWORD || f == FRAME_170_STAND_WITH_SWORD ||
f == FRAME_171_STAND_WITH_SWORD) {
control_backward = CONTROL_IGNORE; /* без авто-повтора */
seqtbl_offset_char(SEQ_57_BACK_WITH_SWORD);
}
}
/* forward_with_sword (seg005): шаг вперёд; у стража своя последовательность. */
static void forward_with_sword(void)
{
uint8_t f = Char.frame;
if (f == FRAME_158_STAND_WITH_SWORD || f == FRAME_170_STAND_WITH_SWORD ||
f == FRAME_171_STAND_WITH_SWORD) {
control_forward = CONTROL_IGNORE;
seqtbl_offset_char(Char.charid != CHARID_0_KID ? SEQ_56_GUARD_FORWARD
: SEQ_86_FORWARD_WITH_SWORD);
}
}
/* sword_strike (seg005): выпад из стойки/шага либо добивание после парирования. */
static void sword_strike(void)
{
uint8_t f = Char.frame;
uint8_t seq_id;
if (f == FRAME_157_WALK_WITH_SWORD || f == FRAME_158_STAND_WITH_SWORD ||
f == FRAME_170_STAND_WITH_SWORD || f == FRAME_171_STAND_WITH_SWORD ||
f == FRAME_165_WALK_WITH_SWORD) {
seq_id = (Char.charid == CHARID_0_KID) ? SEQ_75_STRIKE : SEQ_58_GUARD_STRIKE;
} else if (f == FRAME_150_PARRY || f == FRAME_161_PARRY) {
seq_id = SEQ_66_STRIKE_AFTER_PARRY;
} else {
return;
}
control_shift2 = CONTROL_IGNORE;
seqtbl_offset_char(seq_id);
}
/* parry (seg005): блок. Условие сильно разное у Кида и у соперника —
* соперник блокирует ТОЛЬКО на кадре 152 (второй кадр удара), а Кид ещё и
* по 153 (и тогда последовательность прокручивается сразу). */
static void parry(void)
{
uint8_t cf = Char.frame, of = Opp.frame;
uint8_t seq_id = SEQ_62_PARRY;
uint8_t do_play_seq = 0;
if (cf == FRAME_158_STAND_WITH_SWORD || cf == FRAME_170_STAND_WITH_SWORD ||
cf == FRAME_171_STAND_WITH_SWORD || cf == FRAME_168_BACK ||
cf == FRAME_165_WALK_WITH_SWORD) {
if (pop_char_opp_dist() >= 32 && Char.charid != CHARID_0_KID) {
back_with_sword();
return;
} else if (Char.charid == CHARID_0_KID) {
if (of == FRAME_168_BACK) return;
if (of != FRAME_151_STRIKE_1 && of != FRAME_152_STRIKE_2 &&
of != FRAME_162_BLOCK_TO_STRIKE) {
if (of == FRAME_153_STRIKE_3) do_play_seq = 1;
}
} else {
if (of != FRAME_152_STRIKE_2) return;
}
} else {
if (cf != FRAME_167_BLOCKED) return;
seq_id = SEQ_61_PARRY_AFTER_STRIKE;
}
control_up = CONTROL_IGNORE;
seqtbl_offset_char(seq_id);
if (do_play_seq) play_seq();
}
/* swordfight (seg005:0CDB) — разбор ввода в бою. Порядок веток важен:
* удар старше «убрать меч», тот старше блока, блок старше шагов. */
static void swordfight(void)
{
uint8_t f = Char.frame;
uint8_t seq_id;
if (f == FRAME_161_PARRY && control_shift2 >= CONTROL_RELEASED) {
seqtbl_offset_char(SEQ_57_BACK_WITH_SWORD); /* отскок после парирования */
return;
} else if (control_shift2 == CONTROL_HELD) {
if (Char.charid == CHARID_0_KID) kid_sword_strike = 15;
sword_strike();
if (control_shift2 == CONTROL_IGNORE) return;
}
if (control_down == CONTROL_HELD) {
if (f == FRAME_158_STAND_WITH_SWORD || f == FRAME_170_STAND_WITH_SWORD ||
f == FRAME_171_STAND_WITH_SWORD) {
control_down = CONTROL_IGNORE;
Char.sword = SWORD_0_SHEATHED;
if (Char.charid == CHARID_0_KID) {
offguard = 1; /* убрал намеренно — сразу не доставать */
guard_refrac = 9; /* и страж на это время придержан */
holding_sword = 0;
seq_id = SEQ_93_PUT_SWORD_AWAY_FAST;
} else if (Char.charid == CHARID_1_SHADOW) {
seq_id = SEQ_92_PUT_SWORD_AWAY;
} else {
seq_id = SEQ_87_GUARD_BECOME_INACT; /* Кид ушёл из виду */
}
seqtbl_offset_char(seq_id);
}
} else if (control_up == CONTROL_HELD) {
parry();
} else if (control_forward == CONTROL_HELD) {
forward_with_sword();
} else if (control_backward == CONTROL_HELD) {
back_with_sword();
}
}
/* control_with_sword (seg005:964): пока соперник виден и близко — бой;
* иначе страж убирает меч, а Кид просто стоит. */
static void control_with_sword(void)
{
if (Char.action >= 2) return; /* actions_2_hang_climb и выше */
if (pop_tile_at(Char.curr_col, Char.curr_row) == 11 /* loose */ ||
can_guard_see_kid >= 2) {
int16_t d = pop_char_opp_dist();
if (d < 90) {
if (d >= 0) { swordfight(); return; }
/* Соперник за спиной: далеко — развернуться, вплотную — драться. */
if (d < -4) { seqtbl_offset_char(SEQ_60_TURN_WITH_SWORD); return; }
swordfight();
return;
}
}
/* Не видит / далеко. */
if (Char.charid < CHARID_2_GUARD) {
if (Char.frame == FRAME_171_STAND_WITH_SWORD) {
Char.sword = SWORD_0_SHEATHED;
seqtbl_offset_char(SEQ_92_PUT_SWORD_AWAY); /* страж убит */
}
} else {
swordfight();
}
}
/* control() — диспетчер по кадру (seg005.c:252) для АКТИВНОГО персонажа. */
void pop_control(void) __banked
{
uint8_t f = Char.frame;
/* seg005 control(), самая первая ветка: персонаж УМИРАЕТ (alive >= 0).
* Из любой стойки он валится в seq_71_dying; остальное управление в
* этом состоянии не обрабатывается. Через это же место проходит чит K
* (pop_guard_kill ставит Guard.alive = 0). */
if (Char.alive >= 0) {
if (f == FRAME_15_STAND || f == FRAME_166_STAND_INACTIVE ||
f == FRAME_158_STAND_WITH_SWORD || f == FRAME_171_STAND_WITH_SWORD)
seqtbl_offset_char(SEQ_71_DYING);
return;
}
/* seg005 control(): в отскоке (bumped) и в свободном падении управление
* НЕ обрабатывается вовсе только гасятся стрелки. Без этого игрок
* прерывает присед после удара (medland/плита по голове) нажатием , и
@@ -338,7 +573,13 @@ void pop_control(void)
release_arrows();
return;
}
if (f == FRAME_15_STAND || (f >= FRAME_50_TURN && f < 53))
/* Порядок веток — как в seg005: меч старше charid, charid старше кадров.
* Пока Kid безоружен, обе ветки про стража. */
if (Char.sword == SWORD_2_DRAWN)
control_with_sword();
else if (Char.charid >= CHARID_2_GUARD)
control_guard_inactive();
else if (f == FRAME_15_STAND || (f >= FRAME_50_TURN && f < 53))
control_standing();
else if (f == FRAME_48_TURN)
control_turning();
@@ -434,7 +675,7 @@ static void save_ctrl_1(void)
}
/* ---- Публичный API ------------------------------------------------ */
int pop_ctrl_init(void)
int pop_ctrl_init(void) __banked
{
control_forward = control_backward = control_up = control_down = CONTROL_RELEASED;
control_shift2 = CONTROL_RELEASED;
@@ -442,27 +683,45 @@ int pop_ctrl_init(void)
return kbd_raw_open();
}
void pop_ctrl_close(void)
void pop_ctrl_close(void) __banked
{
kbd_raw_close();
}
int8_t pop_ctrl_shift_held(void)
int8_t pop_ctrl_shift_held(void) __banked
{
return (int8_t)(control_shift == CONTROL_HELD);
}
void pop_ctrl_tick(void)
void pop_ctrl_tick(void) __banked
{
kbd_raw_sync(); /* снять залипшее после Rx-overrun SIO */
/* control_kid (seg006:0CD1), ПЕРВАЯ строка: HP кончились — персонаж
* мёртв. Без этой ветки смерть от меча стража не доводилась до конца:
* hurt_by_sword играл seq_85, hitp_curr падал в 0, но Kid.alive
* оставался 1 и pop_kid_dead не взводился Кид досматривал сиквенс,
* возвращался в стойку с нулём HP, и страж убивал его снова по кругу
* (BUG-DEATH-1). pop_kid_dead наш сигнал главному циклу на респавн
* (в оригинале это счётчик Char.alive в play_kid, seg006:0BEE). */
if (Kid.alive < 0 && hitp_curr == 0) {
Kid.alive = 0;
pop_kid_dead = 1;
}
rest_ctrl_1();
read_input();
read_user_control();
/* Окно Char вокруг диспетчера: control() работает с АКТИВНЫМ
* персонажем (как в оригинале), поэтому ввод игрока применяется к
* Киду только внутри loadkid/savekid. Кадр не перезагружаем
* play_seq в kid_tick сделает это следующим шагом. */
* Киду только внутри loadkid/savekid.
*
* load_fram_det_col ОБЯЗАТЕЛЕН здесь порт play_kid_frame
* (seg000:1209), где он стоит ровно между loadkid_and_opp() и
* play_kid(). cur_frame один на всех персонажей, и в кадре последним
* его перезаписывает страж; без перезагрузки диспетчер считает
* геометрию Кида (dx_weight -> determine_col -> distance_to_edge_weight
* -> get_edge_distance) по кадру СТРАЖА. Замерено: BUG-CTRL-FRAME-1. */
pop_loadkid();
pop_load_fram_det_col();
user_control(); /* -> pop_control(): может сменить Char.curr_seq */
pop_savekid_state();
save_ctrl_1();
+23 -5
View File
@@ -18,19 +18,26 @@
#include <stdint.h>
/* МОДУЛЬ ЖИВЁТ В БАНКЕ 5 (--bank 5=pop_ctrl.c), поэтому всё, что зовут
* снаружи, помечено __banked. Зовут с трёх сторон: главный цикл
* (резидент), guards.c (банк 1) и pop_map.c (банк 3) банкбанк
* работает, трамплин сохраняет страницу окна на стеке. Диспетчер
* дёргается один раз за кадр на персонажа, так что цена перехода
* незаметна. Сама шина control_* лежит в pop_state.c (см. ниже). */
/* Открыть raw-канал клавиатуры и обнулить память авто-повтора.
* 0 OK, -1 ошибка (EBUSY и пр., см. kbd_raw_open). */
int pop_ctrl_init(void);
void pop_ctrl_close(void);
int pop_ctrl_init(void) __banked;
void pop_ctrl_close(void) __banked;
/* Один кадр ввода: прочитать клавиши -> обновить control_* -> прогнать
* диспетчер control() (может сменить Kid.curr_seq). Звать ПЕРЕД
* kid_tick() (play_seq продолжит с новой последовательности). */
void pop_ctrl_tick(void);
void pop_ctrl_tick(void) __banked;
/* control_shift зажат ПРЯМО СЕЙЧАС? — для check_grab в физике (pop_map):
* зацеп за уступ в падении по Shift. Валидно после pop_ctrl_tick. */
int8_t pop_ctrl_shift_held(void);
int8_t pop_ctrl_shift_held(void) __banked;
/* ---- Шина синтетического ввода (порт глобалов control_* из seg005) ----
* В оригинале ИИ соперника (autocontrol_*, seg002) не имеет своего
@@ -39,12 +46,23 @@ int8_t pop_ctrl_shift_held(void);
#define POP_CONTROL_RELEASED 0
#define POP_CONTROL_IGNORE 1
#define POP_CONTROL_HELD (-1)
/* Оси ЭТОГО кадра (абсолютные): X = -1 влево / +1 вправо, Y = -1 вверх /
* +1 вниз. Для ИИ «вперёд/назад» уже в системе персонажа, поэтому оси он
* заполняет как есть, без flip_control_x. */
/* Определены в pop_state.c (резидент W1/W2), а не в pop_ctrl.c: шину
* трогают три банка сразу, а данные банкового модуля живут в _DATA лишь
* пока сборка идёт без --bank-data. */
extern int8_t control_x, control_y, control_shift;
#define POP_CONTROL_HELD_FORWARD (-1)
#define POP_CONTROL_HELD_BACKWARD 1
#define POP_CONTROL_HELD_UP (-1)
#define POP_CONTROL_HELD_DOWN 1
extern int8_t control_forward, control_backward, control_up, control_down,
control_shift2;
/* Диспетчер (seg005:252) для АКТИВНОГО персонажа Char. Игрок идёт через
* pop_ctrl_tick (тот делает user_control с зеркалом «вперёд»), страж из
* своего тика напрямую. */
void pop_control(void);
void pop_control(void) __banked;
#endif
+29
View File
@@ -0,0 +1,29 @@
/*
* pop_draw.c выбор ядра libbgi для heal: линейное «noclip», если
* прямоугольник целиком на экране, иначе общее клипающее. Объявление и
* мотивация `_pop_draw.h`.
*
* ПОЧЕМУ ОТДЕЛЬНЫЙ РЕЗИДЕНТНЫЙ МОДУЛЬ, а не inline в заголовке. Замер
* CLIP-1 (2026-08-01), SDCC 4.5: `static inline` тут обходится втридорога
* компилятор И встраивает тело в каждый вызов (181 Б на место), И оставляет
* отдельную мёртвую копию в каждом TU. У стража это раздуло
* `pop_guard_heal` с ~60 до 663 Б. Пяти местам вызова это стоило бы ~1.4 КБ
* ради экономии ~50 тактов на вызов при том что BANK2 занят на 86 %.
*
* Резидент W1 (_CODE), а не банк: сюда зовут из ДВУХ разных банков
* (pop_bg.c BANK2, pop_gdraw.c BANK4) и из резидента (pop_kid.c). W1
* замаплено всегда, поэтому из банка это обычный `call` без трамплина
* тот же приём, что у pop_sword_draw.
*/
#include <stdint.h>
#include <gfx.h>
#include "_pop_draw.h"
void pop_heal_fast(int x, int y, int w, int h)
{
if (w <= 0 || h <= 0) return;
if (pop_onscreen_cols(x, y, (uint16_t)w, (uint16_t)h))
gfx_heal_noclip(x, y, (uint8_t)w, (uint8_t)h);
else
gfx_heal(x, y, w, h);
}
+139 -8
View File
@@ -15,7 +15,10 @@
#include <gfx.h>
#include <sprite.h>
#include "pop_guard.h"
#include "pop_kid.h" /* pop_sword_draw — клинок общий с Кидом */
#include "pop_bg.h" /* POP_YOFF, pop_fore_over_char */
#include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */
#include "pop_map.h" /* hitp_curr/hitp_max — HP Кида */
#include "kid_data.h"
/* ---- Атласы стража (chtab_5, база 750): g[id>>3], idx id&7 ---------- */
@@ -29,6 +32,21 @@ static int gx_l[2], gy_l[2];
static uint16_t gw_l[2], gh_l[2];
static uint8_t gvalid[2];
/* Клинок — ОТДЕЛЬНЫМ прямоугольником, не объединением со стражем: объединение
* почти вдвое больше суммы двух (клинок уходит вперёд-вверх), а heal стоит
* ровно по площади. Объединение считаем только для окна fore-клипа. */
static int sx_l[2], sy_l[2];
static uint16_t sw_l[2], sh_l[2];
static uint8_t svalid[2];
/* Ширина спрайта последнего кадра стража — нужна set_char_collision
* (char_width_half) при вычислении его ТАЙЛА ОБЪЕКТА. Не по страницам:
* это свойство кадра, а не буфера. Отставание на кадр допустимо так же
* работает kid_fp_width() у Кида. */
static uint16_t g_last_w;
uint16_t pop_guard_fp_width(void) __banked { return g_last_w; }
int pop_guard_load(void) __banked
{
static const char *const names[GUARD_PAGES] = {
@@ -50,19 +68,28 @@ void pop_guard_free(void) __banked
g_ok = 0;
}
/* Стирание прошлого кадра стража и его клинка. pop_heal_fast — по той же
* причине, что в kid_heal: стираем ровно то, что рисовали, а рисовали в
* большинстве кадров noclip-ядром. Два прямоугольника, а не объединение
* (см. комментарий к sx_l выше). */
void pop_guard_heal(void) __banked
{
uint8_t p = gfx_get_draw_page() & 1;
if (gvalid[p]) gfx_heal(gx_l[p], gy_l[p] + POP_YOFF, gw_l[p], gh_l[p]);
gvalid[p] = 0;
if (gvalid[p]) pop_heal_fast(gx_l[p], gy_l[p] + POP_YOFF, gw_l[p], gh_l[p]);
if (svalid[p]) pop_heal_fast(sx_l[p], sy_l[p] + POP_YOFF, sw_l[p], sh_l[p]);
gvalid[p] = svalid[p] = 0;
}
void pop_guard_draw(void) __banked
{
int obj_x, obj_y, top, fwd, fp_x, bx0 = 0, top0 = 0;
int obj_x, obj_y, top, fwd, fp_x;
uint8_t page, idx, p, flip;
const uint8_t *img;
uint16_t w, h;
/* Окно fore-клипа — по ОБЪЕДИНЁННОМУ габариту «страж + клинок»; сам
* футпринт (pop_fore_over_char) считается по спрайту стража, поэтому
* w/h ниже НЕ трогаем. */
int clip_x = 0, clip_y = 0; uint16_t clip_w = 0, clip_h = 0;
if (!g_ok || Guard.charid == 0 || pop_gframe.image == 255) return;
/* load_frame_to_obj (seg008:1728) — та же математика, что у Kid. */
@@ -85,22 +112,126 @@ void pop_guard_draw(void) __banked
flip = (uint8_t)(Guard.direction >= 0); /* спрайты нарисованы влево */
if (flip) bx -= (int)w;
top = obj_y - (int)h + 1;
bx0 = bx; top0 = top;
p = gfx_get_draw_page() & 1;
gfx_set_bank(GFX_BANK_SPRITE);
if (pop_onscreen_cols(bx, top + POP_YOFF, w, h))
gfx_blit_cols_part_noclip(bx, top + POP_YOFF, img, flip, 0, 0);
else
gfx_blit_cols_part(bx, top + POP_YOFF, img, flip, 0, 0);
gfx_set_bank(GFX_BANK_NORMAL);
gx_l[p] = bx; gy_l[p] = top; gw_l[p] = w; gh_l[p] = h; gvalid[p] = 1;
g_last_w = w; /* для char_width_half, см. выше */
clip_x = bx; clip_y = top; clip_w = w; clip_h = h;
}
/* Клинок стража — тот же chtab_0, что у Кида (add_sword_to_objtable,
* seg006:1798): у ЖИВОГО стража меч в руке всегда. Свой прямоугольник
* heal; окно fore-клипа объединение (без него клинок полез бы поверх
* переднего столба). */
{
int sx, sy; uint16_t sw, sh;
if (pop_sword_draw(&Guard, &pop_gframe, obj_x, obj_y,
&sx, &sy, &sw, &sh)) {
p = gfx_get_draw_page() & 1;
sx_l[p] = sx; sy_l[p] = sy; sw_l[p] = sw; sh_l[p] = sh;
svalid[p] = 1;
if (!clip_w) {
clip_x = sx; clip_y = sy; clip_w = sw; clip_h = sh;
} else {
int x1 = clip_x + (int)clip_w, y1 = clip_y + (int)clip_h;
if (sx < clip_x) clip_x = sx;
if (sy < clip_y) clip_y = sy;
if (sx + (int)sw > x1) x1 = sx + (int)sw;
if (sy + (int)sh > y1) y1 = sy + (int)sh;
clip_w = (uint16_t)(x1 - clip_x);
clip_h = (uint16_t)(y1 - clip_y);
}
}
}
gfx_w0_unmap();
/* Окно fore-слоя = прямоугольник стража (см. pop_bg.c, fclip_on):
* без него проход возвращал ЦЕЛЫЕ тайлы 432 К тактов на кадр. */
pop_fore_set_clip(bx0, top0 + POP_YOFF, (int)w, (int)h);
/* Окно fore-слоя = прямоугольник стража с клинком (см. pop_bg.c,
* fclip_on): без него проход возвращал ЦЕЛЫЕ тайлы 432 К тактов. */
pop_fore_set_clip(clip_x, clip_y + POP_YOFF, (int)clip_w, (int)clip_h);
/* Передние грани тайлов ПОВЕРХ стража: в оригинале персонаж — запись
* midtable, а foretable рисуется после всех midtable-записей, поэтому
* колонна/кромка пола перекрывает ЛЮБОГО персонажа, не только Kid
* (seg008:690 draw_tile_fore). Без этого страж виден сквозь передний
* столб. */
if (w && h)
pop_fore_over_char(fp_x, obj_y, w, h, Guard.direction);
pop_fore_over_char(fp_x, obj_y, w, h, Guard.direction, Guard.sword);
}
/* ---- Индикаторы HP (порт draw_kid_hp / draw_guard_hp, seg000:1416) --- *
* Оригинал рисует их в статус-полосе на y = 194 (ниже поля тайлов, внутри
* тех же 200 строк экрана PoP): HP Кида слева направо шагом 7, HP стража
* справа налево от x = 314. Пустые деления у Кида свой спрайт (chtab_2
* id 217); у стража оригинал печатает тот же спрайт ЧЁРНЫМ силуэтом, а мы
* просто не печатаем полоса и так на чёрном фоне.
*
* Спрайты Кида (216/217) лежат в его же атласах, поэтому блитим через
* pop_kid_img_blit; спрайт стража image 0 его chtab (наша страница 0). */
#define HP_Y 194
#define HP_STEP 7
#define HP_KID_FULL 216
#define HP_KID_EMPTY 217
#define HP_MAXDRAW 10 /* больше в полосу всё равно не влезает */
/* Полоса перерисовывается ТОЛЬКО когда числа изменились — и тогда на ОБЕИХ
* страницах дабл-буфера (иначе на второй останется старое значение и его
* будет видно как мерцание через кадр). Отсюда счётчик страниц, а не флаг.
*
* Снимки прошлых значений пишем ДО сравнения: `if (n != s) s = n;` SDCC z80
* собирает неверно SUB затирает A, и в s уезжает разность (memory
* sdcc_z80_cmp_store_a_bug). */
static uint8_t hp_todo; /* сколько страниц ещё обновить */
static uint8_t hp_kid_prev, hp_kidmax_prev, hp_gd_prev, hp_gdmax_prev;
/* Заставить перерисовать полосу на обеих страницах: звать при входе в
* комнату (фон перерисован целиком и стёр её) и при старте. */
void pop_hp_invalidate(void) __banked { hp_todo = 2; }
void pop_hp_draw(void) __banked
{
uint8_t i, n;
uint8_t gd = (uint8_t)(Guard.charid ? guardhp_curr : 0);
uint8_t gm = (uint8_t)(Guard.charid ? guardhp_max : 0);
{ /* Изменилось хоть одно число — обновить обе страницы. */
uint8_t k = hitp_curr, km = hitp_max;
uint8_t changed = (uint8_t)(k != hp_kid_prev || km != hp_kidmax_prev ||
gd != hp_gd_prev || gm != hp_gdmax_prev);
hp_kid_prev = k; hp_kidmax_prev = km;
hp_gd_prev = gd; hp_gdmax_prev = gm;
if (changed) hp_todo = 2;
}
if (hp_todo == 0) return;
hp_todo--;
/* Стереть прошлую полосу: фон под ней — чёрный борт. */
gfx_set_bank(GFX_BANK_NORMAL);
setfillstyle(SOLID_FILL, BLACK);
bar(0, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
/* Кид: сначала пустые деления, поверх — полные (порядок оригинала). */
n = (uint8_t)(hitp_max > HP_MAXDRAW ? HP_MAXDRAW : hitp_max);
for (i = hitp_curr; i < n; i++)
pop_kid_img_blit(HP_KID_EMPTY, (int)i * HP_STEP, HP_Y + POP_YOFF);
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) {
const uint8_t *img = (const uint8_t *)atlas_image(&gp[0], 0);
uint16_t w, h;
gfx_w0_map(gp[0].page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
if (w && h) {
/* Атлас стража тоже column-major — блит колоночный. */
n = (uint8_t)(gd > HP_MAXDRAW ? HP_MAXDRAW : gd);
for (i = 0; i < n; i++)
gfx_blit_cols_part_noclip(314 - (int)i * HP_STEP,
HP_Y + POP_YOFF, img, 0, 0, 0);
}
gfx_w0_unmap();
}
}
+217 -4
View File
@@ -15,9 +15,222 @@ int8_t pop_y_to_row(int16_t y)
return (int8_t)((y + 60) / TILE_SIZEY % 4 - 1);
}
uint16_t pop_prandom(unsigned long *seed, uint16_t maxv)
/* Уложить значение в диапазон 0..maxv. Вызовы оригинала — prandom(1),
* prandom(255), prandom(0xFF): делитель степень двойки, то есть маска, а не
* деление. __moduint на Z80 стоит заметно дороже проверки n & (n-1). */
static uint16_t pop_rnd_fit(uint16_t v, uint16_t maxv)
{
unsigned long s = *seed * 214013UL + 2531011UL;
*seed = s;
return (uint16_t)((uint16_t)(s >> 16) % (uint16_t)(maxv + 1));
uint16_t n = (uint16_t)(maxv + 1);
if ((uint16_t)(n & (uint16_t)(n - 1)) == 0)
return (uint16_t)(v & (uint16_t)(n - 1));
return (uint16_t)(v % n);
}
#if POP_PRANDOM_EXACT
/* ---- LCG оригинала: s = s*214013 + 2531011 (seg009:321) ------------ *
*
* ЕДИНСТВЕННОЕ место в порте на ассемблере, и вот почему. На Z80 нет
* умножения, а SDCC для 32-битного `s * 214013` зовёт __mullong 8 430
* тактов на вызов (замер в MAME). Написанное на C через 16-битные
* половины это ~3 500. Здесь ~1 000, при полностью той же
* последовательности, что в SDLPoP. Альтернатива «взять генератор
* попроще» (8-битный RND Apple II, xorshift) экономит меньше и ломает
* совместимость с эталоном, по которому сверяем картинку.
*
* Приём: 214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3 схема Горнера по
* РАЗРЕЖЕННОЙ записи константы. Вместо 12 сложений (по числу единиц в
* 0x343FD) 17 удвоений, ТРИ сложения и одно вычитание. Величина 3*s,
* нужная в конце, попадается по дороге на втором шаге её и сохраняем.
*
* ABI __sdcccall(1): arg1 (указатель на сид) -> HL, возврат -> DE. IX не
* используется, сохранять нечего. Клоббер AF/BC/DE/HL. Аккумулятор
* DE:HL (DE старшее слово); s.lo живёт в BC, s.hi и 3*s в статиках
* (регистров на всё не хватает).
*/
uint16_t pop_pr_ptr; /* адрес сида */
uint16_t pop_pr_sh; /* старшее слово исходного сида */
uint16_t pop_pr_3s_lo, pop_pr_3s_hi; /* 3*s, нужное в конце */
static uint16_t pop_lcg_step(pop_rnd_t *seed) __naked
{
(void)seed;
__asm
ld (_pop_pr_ptr), hl
ld c, (hl)
inc hl
ld b, (hl) ; BC = s.lo
inc hl
ld a, (hl)
ld (_pop_pr_sh), a
inc hl
ld a, (hl)
ld (_pop_pr_sh + 1), a ; (pop_pr_sh) = s.hi
ld h, b
ld l, c
ld de, (_pop_pr_sh) ; акк = s
;; акк = акк*2 + s => 3s
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh) ; LD A,(nn) флаги НЕ трогает
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
ld (_pop_pr_3s_lo), hl
ld (_pop_pr_3s_hi), de
;; акк = акк*4 + s => 13s
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh)
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
;; акк = акк*16 + s => 209s
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, bc
ex de, hl
ld a, (_pop_pr_sh)
adc a, l
ld l, a
ld a, (_pop_pr_sh + 1)
adc a, h
ld h, a
ex de, hl
;; акк *= 1024 => 214016s (10 удвоений)
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
add hl, hl
rl e
rl d
;; акк -= 3s => 214013s
ld bc, (_pop_pr_3s_lo)
or a ; CY = 0
sbc hl, bc
ld bc, (_pop_pr_3s_hi) ; LD BC,(nn) флаги НЕ трогает
ex de, hl
sbc hl, bc
ex de, hl
;; акк += 2531011 = 0x0026_9EC3
ld bc, #0x9EC3
add hl, bc
ld bc, #0x0026
ex de, hl
adc hl, bc
ex de, hl
;; сохранить сид, вернуть СТАРШЕЕ слово в DE
push de
ex de, hl ; DE = младшее слово
ld hl, (_pop_pr_ptr)
ld (hl), e
inc hl
ld (hl), d
inc hl
pop de ; DE = старшее слово
ld (hl), e
inc hl
ld (hl), d
ret
__endasm;
}
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
{
return pop_rnd_fit(pop_lcg_step(seed), maxv);
}
#else /* !POP_PRANDOM_EXACT — генератор попроще, НЕ совместимый с эталоном */
/* xorshift16 + шаг Вейля. Зачем не LCG оригинала: тот 32-битный, и даже
* без __mullong (см. ветку выше) стоит ~7 000 тактов на вызов при двух
* вызовах за кадр это 3 % бюджета на генератор случайных чисел. Здесь
* умножений нет вовсе, только сдвиги и xor.
*
* Почему НЕ 8-битный RND Apple II (RNDseed := 5*RNDseed + 23 mod 256,
* Prince-of-Persia-Apple-II): он ещё быстрее, но у LCG по модулю 256
* младшие биты вырождены бит 0 просто чередуется. Наши вызовы это
* видят: раскладка кладки берёт prandom(1) (один бит) и prandom(4), и
* вместо шума получилась бы правильная шахматка. У xorshift такой
* структуры нет, а стоит он примерно столько же.
*
* Шаг Вейля (+0x9E37, нечётный) нужен ещё и потому, что у чистого
* xorshift ноль неподвижная точка, а сид кладки (номер комнаты + ряд +
* колонка) вполне может оказаться нулём.
*
* ПОСЛЕДСТВИЕ: последовательность отличается от SDLPoP другая (но
* статистически такая же) раскладка кладки и другие броски в боёвке.
* Поэтому по умолчанию собирается НЕ эта ветка: после переписывания LCG
* на ассемблер выигрыш от смены генератора почти исчез, а сверять
* картинку с эталоном стало важнее. Ветка оставлена как запасной ход. */
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv)
{
uint16_t s = seed->lo;
s ^= (uint16_t)(s << 7);
s ^= (uint16_t)(s >> 9);
s ^= (uint16_t)(s << 8);
s = (uint16_t)(s + 0x9E37u);
seed->lo = s;
seed->hi = 0; /* старшая половина не используется */
/* Отдаём СТАРШИЙ байт: у него нет короткопериодической структуры
* младших бит, а вызывающие берут от результата именно младшие. */
return pop_rnd_fit((uint16_t)(s >> 8), maxv);
}
#endif
+31 -4
View File
@@ -26,9 +26,36 @@ extern const int16_t pop_y_land[5];
* над комнатой, 3 = под комнатой). */
int8_t pop_y_to_row(int16_t y);
/* prandom (seg009:321) — 32-битный LCG оригинала. Сид ВНЕШНИЙ: у стен
* (раскладка кладки) и у анимаций тайлов (фаза факелов) свои
* последовательности, смешивать их нельзя. */
uint16_t pop_prandom(unsigned long *seed, uint16_t maxv);
/* prandom (порт seg009:321). Сид ВНЕШНИЙ: у стен (раскладка кладки) и у
* анимаций тайлов (фаза факелов) свои последовательности, смешивать их
* нельзя.
*
* ДВА генератора, выбор через POP_PRANDOM_EXACT (см. pop_geom.c):
* 1 (по умолчанию) LCG оригинала (s*214013+2531011) БИТ-В-БИТ: та же
* раскладка кладки и те же броски, что в SDLPoP, поэтому по картинке
* можно сверяться с эталоном;
* 0 xorshift16 + шаг Вейля, без единого умножения. Дешевле ровно на
* 5 646 тактов за кадр, 1.3 % бюджета (замер A/B в MAME, комната 3,
* 175 кадров: 403 632 против 397 986). Последовательность другая
* статистически эквивалентная, но не та же.
*
* Обе версии считают 16-битными половинами, а не unsigned long: на 32-бит
* умножении SDCC зовёт __mullong, и это 8 430 тактов на вызов (замер в
* MAME, breakpoint на __mullong).
*
* ЗАДЕЛ: разрыв между версиями определяется не алгоритмом, а кодогенерацией
* SDCC те же генераторы на Z80-ассемблере укладываются в 86..148 тактов.
* Если процент понадобится, сначала переписать в asm ОРИГИНАЛЬНЫЙ LCG (там
* умножение на константу), а менять генератор только если и этого мало. */
typedef struct { uint16_t lo, hi; } pop_rnd_t;
#ifndef POP_PRANDOM_EXACT
#define POP_PRANDOM_EXACT 1
#endif
uint16_t pop_prandom(pop_rnd_t *seed, uint16_t maxv);
/* Сид из 16-битного числа (старшая половина = 0). */
#define pop_prandom_set(s, v) do { (s).lo = (uint16_t)(v); (s).hi = 0; } while (0)
#endif
+104 -6
View File
@@ -10,11 +10,22 @@
#include "pop_geom.h"
#include "pop_level.h"
#include "pop_kid.h" /* Char/play_seq — интерпретатор общий */
#include "pop_map.h" /* hitp_* — HP Кида */
#include "kid_data.h"
pop_char_t Guard;
uint8_t guardhp_curr, guardhp_max;
int8_t guardhp_delta;
int8_t can_guard_see_kid;
int8_t is_guard_notice;
uint8_t guard_skill;
int8_t guard_refrac, justblocked, kid_sword_strike;
int8_t offguard, holding_sword;
/* Сид бросков боёвки. В оригинале генератор один на всё, у нас
* последовательности разведены по доменам (кладка / анимация тайлов / бой),
* см. pop_geom.h иначе перерисовка стены сбивала бы броски стража. */
pop_rnd_t pop_fight_seed;
/* Кадр стража (порт cur_frame для Char). Глобальный, а не статик: его
* заполняет логика (W1/W2), а читает отрисовка из резидента W3. */
@@ -32,6 +43,10 @@ void pop_guard_reset(void)
Guard.direction = 0;
guardhp_curr = 0;
guardhp_delta = 0;
can_guard_see_kid = 0;
is_guard_notice = 0;
guard_refrac = justblocked = kid_sword_strike = 0;
offguard = holding_sword = 0;
}
/* kill guard cheat (seg000:786):
@@ -44,11 +59,35 @@ void pop_guard_kill(void)
{
if (Guard.charid == CHARID_4_SKELETON) return;
if (!pop_guard_present()) return;
/* Как в оригинале: HP НЕ обнуляем здесь — ставим дельту, её применит
* pop_do_delta_hp. alive = 0 переводит стража в «умирает», а дальше
* общий control() ставит ему seq_71_dying. */
guardhp_delta = (int8_t)-guardhp_curr;
guardhp_curr = 0;
Guard.alive = 0;
}
/* do_delta_hp (seg003): применить накопленную за кадр дельту HP и обнулить
* её. Звать раз в кадр ПОСЛЕ тиков персонажей, как в play_frame. */
void pop_do_delta_hp(void)
{
int16_t hp;
if (hitp_delta) {
hp = (int16_t)hitp_curr + hitp_delta;
if (hp < 0) hp = 0;
if (hp > (int16_t)hitp_max) hp = (int16_t)hitp_max;
hitp_curr = (uint8_t)hp;
pop_kid_hurt = 1; /* -> «брызги» поверх Кида */
hitp_delta = 0;
}
if (guardhp_delta) {
hp = (int16_t)guardhp_curr + guardhp_delta;
if (hp < 0) hp = 0;
if (hp > (int16_t)guardhp_max) hp = (int16_t)guardhp_max;
guardhp_curr = (uint8_t)hp;
guardhp_delta = 0;
}
}
/* load_frame для стража (seg006:0293 ветка charid_2_guard): таблица у
* стражей СВОЯ (frame_tbl_guard), индекс = frame + add_frame 149, где
* add_frame = 70 для кадров 102..106 (seg006:535). */
@@ -61,11 +100,39 @@ void pop_guard_load_frame(void)
pop_kid_data_frame(KID_BIN_GFRAMES_OFF, (uint8_t)idx, (uint8_t *)&pop_gframe);
}
/* leave_guard (seg002:02F5): вызывать ПЕРЕД сменой комнаты, пока Guard ещё
* держит стража уходящей комнаты. Мёртвого запоминаем вместе с
* последовательностью тогда при возврате он лежит там же, а не встаёт
* живым. */
void pop_guard_leave(void)
{
if (Guard.charid == 0) return; /* стража не было */
pop_guard_state_save(Guard.room,
(uint8_t)(Guard.curr_row * 10), /* get_tilepos(0,row) */
Guard.direction, Guard.x, guard_skill,
Guard.curr_seq,
(uint8_t)(Guard.alive >= 0));
}
/* get_tile_div_mod_m7 (seg006:697): колонка комнаты по X персонажа —
* floor((xpos 7 58) / 14), где 58 = pop_x_bump[FIRST_ONSCREEN_COLUMN]. */
static int8_t guard_col_from_x(uint8_t xpos)
{
int16_t x = (int16_t)xpos - 7 - pop_x_bump[FIRST_ONSCREEN_COLUMN];
int16_t col = x / TILE_SIZEX;
if (x % TILE_SIZEX < 0) --col;
return (int8_t)col;
}
void pop_guard_enter(uint8_t room)
{
uint8_t tile, color, skill;
int8_t dir;
pop_guard_reset();
/* load_lev_spr (seg000:1112): tbl_guard_type == 1 — стражей на уровне
* НЕТ вообще (14/15), их графика даже не грузится. Без этой проверки
* на таком уровне полезли бы стражи из данных комнат. */
if (pop_level_guard_type(pop_current_level) < 0) return;
if (!pop_level_guard(room, &tile, &dir, &color, &skill)) return;
/* enter_guard (seg002:0112) + pos_guards (seg003): X считается из
* колонки тайла, а не берётся из файла. */
@@ -80,13 +147,44 @@ void pop_guard_enter(uint8_t room)
Guard.alive = -1;
Guard.action = 1; /* actions_1_run_jump */
Guard.fall_x = Guard.fall_y = 0;
guardhp_max = guardhp_curr = 3; /* get_guard_hp: skill 0, уровень 1 */
/* enter_guard (seg002:214): мастерство из данных уровня, 12 градаций;
* всё, что вне диапазона, оригинал сводит к 3. */
guard_skill = (uint8_t)(skill < NUM_GUARD_SKILLS ? skill : 3);
/* get_guard_hp (seg002:54): extrastrength[skill] + tbl_guard_hp[уровень].
* extrastrength (seg002:38) ненулевой только у skill 4 (=1). */
guardhp_max = guardhp_curr =
(uint8_t)(pop_level_guard_hp(pop_current_level) + (guard_skill == 4 ? 1 : 0));
guardhp_delta = 0;
/* enter_guard (seg002:0208): seqtbl_offset_char(seq_77_guard_stand_inactive).
* Последовательность крутит общий интерпретатор (pop_guard_play), а
* таблицу кадров он выбирает по Char.charid у стража своя. */
/* enter_guard (seg002:0208). Живого поднимаем стандартной стойкой; у
* ЗАПОМНЕННОГО МЁРТВОГО берём сохранённую последовательность он лежит
* ровно там, где лёг (порт ветки guards_seq_hi != 0). */
{
uint16_t saved_seq = pop_guard_state_seq(room);
/* У ЗАПОМНЕННОГО трупа X берём сохранённую: pos_guards (seg003:620)
* пересчитывает её из колонки только при загрузке уровня, а дальше
* ею владеет leave_guard. Иначе тело при возврате прыгает в центр
* тайла. */
if (saved_seq) Guard.x = pop_guard_state_x(room);
/* ...и КОЛОНКУ пересчитываем из X (enter_guard, seg002:180:
* `Char.curr_col = get_tile_div_mod_m7(Char.x)`). Брать её из
* тайла нельзя: leave_guard сохраняет get_tilepos(0, row), то
* есть колонку 0 всегда. У живого стража это незаметно (X там
* сам считается из колонки), а у ЗАПОМНЕННОГО ТРУПА X
* настоящая, и curr_col оставался нулём при теле, лежащем в
* колонке 2. Тайл трупа шёл в обходе раньше тайла Кида, и Кид
* рисовался ПОВЕРХ тела вместо того, чтобы пройти за ним
* (BUG-DRAWORDER-1). Колонка нужна не только отрисовке на
* неё смотрят коллизия и check_can_guard_see_kid. */
Guard.curr_col = guard_col_from_x(Guard.x);
pop_loadshad();
pop_char_set_seq(SEQ_77_GUARD_STAND_INACTIVE);
if (saved_seq) Char.curr_seq = saved_seq;
else pop_char_set_seq(SEQ_77_GUARD_STAND_INACTIVE);
pop_saveshad();
pop_guard_play(); /* до первого кадра */
/* enter_guard (seg002:218): кадр смерти -> страж поднят ТРУПОМ. */
if (Guard.frame == 185 || Guard.frame == 177 || Guard.frame == 178) {
Guard.alive = 1;
guardhp_curr = 0;
}
}
}
+97 -1
View File
@@ -20,9 +20,45 @@
#include <stdint.h>
#include "kid_data.h" /* kframe */
#include "pop_char.h"
#include "pop_geom.h" /* pop_rnd_t — сид бросков боёвки */
/* seqids (types.h:1070) — последовательности стража. */
/* seqids (types.h:1052+) — последовательности боя и стойки. */
#define SEQ_55_DRAW_SWORD 55
#define SEQ_56_GUARD_FORWARD 56 /* шаг вперёд с мечом (страж) */
#define SEQ_57_BACK_WITH_SWORD 57
#define SEQ_58_GUARD_STRIKE 58
#define SEQ_60_TURN_WITH_SWORD 60
#define SEQ_61_PARRY_AFTER_STRIKE 61
#define SEQ_62_PARRY 62
#define SEQ_66_STRIKE_AFTER_PARRY 66
#define SEQ_71_DYING 71
#define SEQ_75_STRIKE 75 /* удар (Кид) */
#define SEQ_77_GUARD_STAND_INACTIVE 77
#define SEQ_80_STAND_FLIPPED 80
#define SEQ_86_FORWARD_WITH_SWORD 86 /* шаг вперёд с мечом (Кид) */
#define SEQ_87_GUARD_BECOME_INACT 87
#define SEQ_90_EN_GARDE 90
#define SEQ_92_PUT_SWORD_AWAY 92
#define SEQ_93_PUT_SWORD_AWAY_FAST 93
/* frameids (types.h:911+) и состояния меча (types.h:332). */
#define FRAME_150_PARRY 150
#define FRAME_151_STRIKE_1 151
#define FRAME_152_STRIKE_2 152
#define FRAME_153_STRIKE_3 153
#define FRAME_157_WALK_WITH_SWORD 157
#define FRAME_158_STAND_WITH_SWORD 158
#define FRAME_161_PARRY 161
#define FRAME_162_BLOCK_TO_STRIKE 162
#define FRAME_165_WALK_WITH_SWORD 165
#define FRAME_166_STAND_INACTIVE 166
#define FRAME_167_BLOCKED 167
#define FRAME_168_BACK 168
#define FRAME_169_BEGIN_BLOCK 169
#define FRAME_170_STAND_WITH_SWORD 170
#define FRAME_171_STAND_WITH_SWORD 171
#define SWORD_0_SHEATHED 0
#define SWORD_2_DRAWN 2
/* charids (types.h:320) — нужны хотя бы для «скелет не убивается читом». */
#define CHARID_0_KID 0
@@ -31,12 +67,53 @@
#define CHARID_4_SKELETON 4
#define CHARID_5_PRINCESS 5
/* Градаций мастерства стража (seg002: таблицы вероятностей по 12 записей). */
#define NUM_GUARD_SKILLS 12
/* Тип персонажа — в pop_char.h (общий с Кидом и Char). */
extern pop_char_t Guard; /* страж ТЕКУЩЕЙ комнаты (как в оригинале) */
extern uint8_t guardhp_curr, guardhp_max;
extern int8_t guardhp_delta; /* <0 = в этом кадре отняли HP */
/* Видит ли страж Кида (порт can_guard_see_kid, seg003:688):
* 0 не видит, 1 видит, но не пойдёт (между ними loose/чомпер/дыра),
* 2 видит и пойдёт. Считает pop_check_can_guard_see_kid (банк) раз в
* кадр ДО логики персонажей; читают ИИ (autocontrol_*) и боёвка. */
extern int8_t can_guard_see_kid;
/* Кид «нашумел» (порт is_guard_notice, seg000:364): заставляет неактивного
* стража обернуться, даже если Кид у него за спиной. Ставит источник шума,
* снимает сам ИИ. */
extern int8_t is_guard_notice;
/* Боевые таймеры/уровень мастерства (seg000:295..311). Все убывают раз в
* кадр в autocontrol_opponent как в оригинале.
* guard_skill 0..11, из данных уровня; индекс в таблицы вероятностей;
* guard_refrac «отдышка» стража: пока >0, он не атакует и не наступает;
* justblocked Кид только что парировал (влияет на вероятность блока);
* kid_sword_strike Кид только что бил (стража это придерживает). */
extern uint8_t guard_skill;
extern int8_t guard_refrac, justblocked, kid_sword_strike;
/* Состояние меча КИДА (seg005/seg006):
* offguard меч убран НАМЕРЕННО (вниз в бою): пока стоит, Кид не
* достаёт клинок автоматически, даже видя стража;
* holding_sword клинок в руке (для индикатора HP стража). */
extern int8_t offguard, holding_sword;
/* Сид бросков боёвки (свой домен последовательности, см. pop_geom.h). */
extern pop_rnd_t pop_fight_seed;
/* Луч видимости стража — раз в кадр, ДО тиков персонажей (в оригинале
* check_can_guard_see_kid стоит в play_frame перед play_kid_frame). */
void pop_check_can_guard_see_kid(void) __banked;
/* Боёвка: пометить попадания (обе стороны) и применить их. Порядок в
* кадре как в play_frame: hurting -> hurt -> do_delta_hp. */
void pop_check_sword_hurting(void) __banked;
void pop_check_sword_hurt(void) __banked;
/* Текущий кадр стража (порт cur_frame для Char): заполняет логика в W1/W2,
* читает отрисовка в резиденте W3. image == 255 рисовать нечего. */
extern kframe pop_gframe;
@@ -54,12 +131,27 @@ void pop_guard_free(void) __banked;
* seg002:0112) либо убрать его, если в комнате стража нет. */
void pop_guard_enter(uint8_t room);
/* Запомнить стража УХОДЯЩЕЙ комнаты (порт leave_guard). Звать в начале
* enter_room, пока Guard ещё держит прежнего. */
void pop_guard_leave(void);
/* Нарисовать текущий кадр стража на back-странице (банк GFX_BANK_SPRITE,
* как kid_draw) и стереть прошлый (heal). Реализация pop_gdraw.c
* (резидент W3), поэтому звать ТОЛЬКО из главного цикла, не из банка. */
/* Полоса HP (Кид слева, страж справа) в статус-полосе. Рисует главный цикл
* ПОСЛЕ персонажей она ничего не перекрывает. */
void pop_hp_draw(void) __banked;
/* Полоса стёрта перерисовкой фона (вход в комнату) — обновить обе страницы. */
void pop_hp_invalidate(void) __banked;
void pop_guard_heal(void) __banked;
void pop_guard_draw(void) __banked;
/* Ширина спрайта последнего нарисованного кадра стража (char_width_half
* в set_char_collision). Нужна для его ТАЙЛА ОБЪЕКТА от него зависит
* порядок отрисовки относительно Кида. */
uint16_t pop_guard_fp_width(void) __banked;
/* Убрать стража со сцены (смена комнаты / инициализация). */
void pop_guard_reset(void);
@@ -67,6 +159,10 @@ void pop_guard_reset(void);
* при включённых читах проверку делает вызывающий. */
void pop_guard_kill(void);
/* do_delta_hp (seg003): применить дельту HP, накопленную за кадр. Звать
* раз в кадр ПОСЛЕ тиков персонажей (в оригинале в play_frame). */
void pop_do_delta_hp(void);
/* Тик логики стража — В БАНКЕ (guards.c). Состояние берёт отсюда. */
void pop_guard_tick(void) __banked;
+326 -92
View File
@@ -12,6 +12,7 @@
#include "pop_kid.h"
#include "pop_guard.h" /* Guard/pop_gframe/CHARID_* — интерпретатор общий */
#include "pop_bg.h" /* POP_YOFF — вертикальное центрирование */
#include "_pop_draw.h" /* pop_onscreen_cols / pop_heal_fast */
#include "pop_map.h" /* pop_clip_char_top (clip_char) */
#include "pop_geom.h"
#include "kid_data.h"
@@ -22,21 +23,28 @@ pop_char_t Kid;
* в play_seq, потребляется pop_check_knock (pop_map) трясёт loose-полы. */
int8_t knock;
/* Порт глобала next_level: seqtbl-команда END_LEVEL инкрементит его в конце
* seq_70 (подъём по лестнице за дверью уровня), а главный цикл сравнивает с
* текущим уровнем и грузит следующий. Счётчик, а не флаг ровно как в
* оригинале, чтобы читы «следующий/предыдущий уровень» (levels_plan.md §4)
* легли сюда же без переделки. */
uint8_t pop_next_level;
/* ---- Атласы Kid (прямая адресация image>>3) ------------------------ */
#define KID_MAXPAGES 28
static atlas_t kidp[KID_MAXPAGES];
static uint8_t kid_npages;
/* chtab_0 «меч в руке» (add_sword_to_objtable, seg006:1798): отдельный
* спрайт клинка поверх Kid в кадрах «нашёл меч»/убирание в ножны (229..237).
* Из sword_tbl взяты только строки 35..42 те, что адресуют эти кадры (бой
* не портирован); индекс в атласе = sword_frame 35. Смещения таблицы
* в ЭКРАННЫХ пикселях: оригинал применяет их ПОСЛЕ calc_screen_x_coord. */
#define SWORD_SF_LO 35
#define SWORD_SF_HI 42
static const int8_t SWORD_DX[8] = { 7, 14, 14, 14, 28, 28, 21, 14 };
static const int8_t SWORD_DY[8] = {-32,-32,-31,-29,-28,-28,-25,-22 };
/* chtab_0 «меч в руке» (add_sword_to_objtable, seg006:1798): клинок —
* ОТДЕЛЬНЫЙ спрайт поверх персонажа, один и тот же атлас у Кида и у стража.
* Таблица sword_tbl (kid_data.h, генерится из seg006) индексируется
* sword_frame = кадр.sword & 0x3F; id 255 = клинка в этом кадре нет.
* Смещения в ЭКРАННЫХ пикселях: оригинал применяет их ПОСЛЕ
* calc_screen_x_coord, x «вперёд» по направлению персонажа. */
static const uint8_t SWORD_ID[KID_NSWORD] = KID_SWORD_ID_INIT;
static const int8_t SWORD_DX[KID_NSWORD] = KID_SWORD_DX_INIT;
static const int8_t SWORD_DY[KID_NSWORD] = KID_SWORD_DY_INIT;
static atlas_t swordp;
static uint8_t sword_ok;
@@ -47,6 +55,74 @@ int pop_sword_load(void)
return 0;
}
/* add_sword_to_objtable (seg006:1798). Клинок рисуется, если персонаж в
* кадрах подъёма/ножен, ИЛИ держит меч, ИЛИ это ЖИВОЙ страж (у стража меч
* в руке всегда, даже пока sword ещё sheathed). Страницу W0 оставляем
* замапленной вызывающий делает gfx_w0_unmap сам. */
uint8_t pop_sword_draw(const pop_char_t *ch, const kframe *fr,
int obj_x, int obj_y,
int *rx, int *ry, uint16_t *rw, uint16_t *rh)
{
uint8_t sf, id, flip;
const uint8_t *simg;
uint16_t w, h;
int bx, top;
if (!sword_ok) return 0;
if (!((ch->frame >= 229 && ch->frame < 238) ||
ch->sword != 0 ||
(ch->charid == CHARID_2_GUARD && ch->alive < 0)))
return 0;
sf = (uint8_t)(fr->sword & 0x3F);
if (sf == 0 || sf >= KID_NSWORD) return 0;
id = SWORD_ID[sf];
if (id == 0xFF) return 0;
simg = (const uint8_t *)atlas_image(&swordp, id);
gfx_w0_map(swordp.page);
w = (uint16_t)(simg[0] | ((uint16_t)simg[1] << 8));
h = (uint16_t)(simg[2] | ((uint16_t)simg[3] << 8));
if (!w || !h) return 0;
flip = (uint8_t)(ch->direction >= 0);
bx = obj_x + ((ch->direction < 0) ? -(int)SWORD_DX[sf] : (int)SWORD_DX[sf]);
top = obj_y + (int)SWORD_DY[sf] - (int)h + 1;
if (flip) bx -= (int)w;
gfx_set_bank(GFX_BANK_SPRITE);
/* Клинок почти всегда целиком на экране — noclip-путь (см. gfx.h:
* подготовка клипающего варианта стоит ~5.6 К тактов на вызов). */
if (pop_onscreen_cols(bx, top + POP_YOFF, w, h))
gfx_blit_cols_part_noclip(bx, top + POP_YOFF, simg, flip, 0, 0);
else
gfx_blit_cols_part(bx, top + POP_YOFF, simg, flip, 0, 0);
gfx_set_bank(GFX_BANK_NORMAL);
*rx = bx; *ry = top; *rw = w; *rh = h;
return 1;
}
/* Блит спрайта chtab_2 (атласы Kid) по ЕГО id, левым верхним углом в (x,y).
* Нужен полосе HP: она рисует id 216/217 из тех же атласов, что и кадры
* Кида, а держать вторую копию раскладки в pop_gdraw ни к чему. */
void pop_kid_img_blit(uint8_t image, int x, int y)
{
uint8_t pg = (uint8_t)(image >> 3); /* раскладка атласов: 8 спрайтов/стр. */
uint8_t idx = (uint8_t)(image & 7);
const uint8_t *img;
uint16_t w, h;
if (pg >= kid_npages) return;
img = (const uint8_t *)atlas_image(&kidp[pg], idx);
gfx_w0_map(kidp[pg].page);
w = (uint16_t)(img[0] | ((uint16_t)img[1] << 8));
h = (uint16_t)(img[2] | ((uint16_t)img[3] << 8));
/* Атласы Кида хранятся COLUMN-MAJOR (transpose_cols в pop_pack_kid —
* ради бесплатного горизонтального флипа), поэтому блит обязан быть
* колоночным. Row-major gfx_blit_noclip выдал бы марку транспонированной.
* noclip БЕЗ проверки (CLIP-1): единственный вызывающий полоса HP
* (pop_gdraw.c), у неё координаты фиксированные и заведомо на экране. */
if (w && h) gfx_blit_cols_part_noclip(x, y, img, 0, 0, 0);
gfx_w0_unmap();
}
int pop_kid_load(uint8_t npages)
{
static const char *const names[KID_MAXPAGES] = {
@@ -132,13 +208,61 @@ static kframe kid_frame; /* кадр Kid для kid_draw */
* копирование платится один раз на переключение персонажа. */
pop_char_t Char;
void pop_loadkid(void) { Char = Kid; }
void pop_savekid(void) { Kid = Char; kid_frame = cur_frame; }
/* Выгрузка БЕЗ кадра: control() кадр не трогает, а cur_frame в этот
* момент принадлежит тому, кто последним крутил play_seq. */
void pop_savekid_state(void) { Kid = Char; }
/* Opp (seg006): СОПЕРНИК активного персонажа — вторая копия, которую ставят
* рядом с Char (loadkid_and_opp / loadshad_and_opp, seg006:809). Через неё
* ИИ и боёвка меряют дистанцию (char_opp_dist), не зная, кто именно перед
* ними: для стража Opp = Kid, для Кида Opp = Guard. */
pop_char_t Opp;
/* Снимок последовательности на момент входа в окно Char: по нему
* pop_savekid_state отличает «последовательность сменил ДИСПЕТЧЕР (в Char)»
* от «её сменил pop_map (прямо в Kid)». */
static uint16_t kid_seq_at_load;
void pop_loadkid(void) { Char = Kid; kid_seq_at_load = Kid.curr_seq; }
void pop_savekid(void) { Kid = Char; }
/* Выгрузка после control(): копируем ТОЛЬКО то, что диспетчер реально
* меняет у активного персонажа последовательность и состояние меча.
*
* Почему не `Kid = Char`, как делает savekid оригинала: у нас часть
* действий control() исполняет pop_map (pop_down_action, pop_jump_up_seq,
* safe_step...), и он пишет ПРЯМО в Kid там ещё не портирован переход на
* Char. Полное копирование затирало эти правки: Kid терял выравнивание x
* и ряд, и спуск с уступа через вис не срабатывал персонаж просто
* приседал. Когда pop_map переведём на Char, здесь вернётся Kid = Char. */
void pop_savekid_state(void)
{
/* Последовательность мог сменить КАЖДЫЙ из двоих: диспетчер — в Char
* (pop_char_set_seq), pop_map прямо в Kid (kid_set_seq: спуск с
* уступа SEQ_68, зацеп, осторожный шаг). Решение pop_map старше:
* диспетчер про эти переходы не знает и, копируя Char вслепую, затирал
* их спуск в проём не срабатывал, а персонаж даже не приседал, потому
* что pop_down_action уже вернул «обработано». */
uint8_t map_changed = (uint8_t)(Kid.curr_seq != kid_seq_at_load);
if (!map_changed) Kid.curr_seq = Char.curr_seq;
Kid.sword = Char.sword;
}
void pop_loadshad(void) { Char = Guard; }
void pop_saveshad(void) { Guard = Char; pop_gframe = cur_frame; }
void pop_saveshad(void) { Guard = Char; }
void pop_loadshad_and_opp(void) { Char = Guard; Opp = Kid; }
void pop_saveshad_and_opp(void) { Guard = Char; Kid = Opp; }
void pop_loadkid_and_opp(void) { Char = Kid; Opp = Guard; }
void pop_savekid_and_opp(void) { Kid = Char; Guard = Opp; }
/* char_opp_dist (seg006:2135): >0 — соперник ПЕРЕД Char, <0 — за спиной.
* +13 при встречных направлениях: у стоящих лицом друг к другу «ноль»
* приходится на ширину тайла между ними. Живёт здесь, а не в guards.c,
* потому что нужен по обе стороны банковой границы и ИИ, и диспетчеру
* боёвки (pop_ctrl). */
int16_t pop_char_opp_dist(void)
{
int16_t distance;
if (Char.room != Opp.room) return 999;
distance = (int16_t)Opp.x - (int16_t)Char.x;
if (Char.direction < 0) distance = -distance;
if (distance >= 0 && Char.direction != Opp.direction) distance += 13;
return distance;
}
int pop_kid_data_load(const char *path)
{
@@ -215,6 +339,15 @@ static void load_frame(void)
cur_frame.dy = (int8_t)f[2];
cur_frame.flags = f[3];
cur_frame.sword = f[4];
/* Кэш кадра для ОТРИСОВКИ раскладываем ЗДЕСЬ, по charid активного
* персонажа, а не в save*-функциях. Раньше кэш писали pop_savekid/
* pop_saveshad, и любое окно Char БЕЗ play_seq (окна боёвки
* check_sword_hurting / check_sword_hurt) записывало Киду кадр, который
* на самом деле принадлежал СТРАЖУ: kid_draw брал его image из атласа
* Кида и рисовал произвольную позу. Теперь владелец один тот, для
* кого кадр реально загружен. */
if (Char.charid == CHARID_0_KID) kid_frame = cur_frame;
else pop_gframe = cur_frame;
}
static uint8_t char_dx_forward(int8_t dx)
@@ -226,6 +359,7 @@ static uint8_t char_dx_forward(int8_t dx)
/* Аксессоры к текущему кадру (для pop_map): читают cur_frame, НЕ таблицу —
* их дёргают несколько раз за кадр, маппинг страницы тут недопустим. */
int8_t kid_cur_dx(void) { return cur_frame.dx; }
int8_t kid_cur_dy(void) { return cur_frame.dy; }
uint8_t kid_cur_flags(void) { return cur_frame.flags; }
/* seqtbl_offset_char: сменить последовательность Kid (единая точка —
@@ -289,10 +423,21 @@ void play_seq(void)
case 0xF4: /* KNOCK_DOWN: приземление трясёт loose В СВОЁМ РЯДУ */
knock = -1;
break;
case 0xF2: /* SOUND — потребить 1 байт */
case 0xF2: /* SOUND (seg006:0611): звука у нас нет, но опкод несёт
* ВТОРОЙ смысл «Кид нашумел», и его терять нельзя.
* Шумом считаются id 0..2 (SILENT/FOOTSTEP/BUMP); id 0
* назван silent потому, что НЕ звучит, а стражи его всё
* равно замечают (стоит, например, в seq доставания меча).
* DRINK(3)/LEVEL(4) не шум. См. BUG-GUARD-DEAF-1. */
if (SEQ(Char.curr_seq) < 3) is_guard_notice = 1;
Char.curr_seq++;
break;
case 0xF1: /* END_LEVEL */
case 0xF1: /* END_LEVEL: seq_70 доиграл — уровень пройден.
* В оригинале это `++next_level`, а главный цикл
* (play_level_2, seg003:0386) выходит, когда next_level
* разошёлся с current_level. У нас так же: счётчик, а
* решение снаружи (roomtest.c). */
pop_next_level++;
break;
case 0xF3: /* GET_ITEM: аргумент 1 — применить эффект предмета
* (порт play_seq -> proc_get_object, seg006) */
@@ -311,6 +456,40 @@ void play_seq(void)
}
}
/* load_fram_det_col (seg006:0144) = load_frame() + determine_col().
*
* Зачем это отдельной публичной точкой. cur_frame ОДИН глобал на всех
* персонажей (так и в оригинале), и его владелец тот, кто последним
* прошёл load_frame. В кадре последним тикает страж, поэтому к моменту
* управления Кидом в cur_frame лежит кадр СТРАЖА. А через cur_frame
* считается вся геометрия: dx_weight -> determine_col ->
* distance_to_edge_weight -> get_edge_distance, то есть буквально выбор
* ветки в check_jump_up.
*
* Оригинал от этого страхуется явно: play_kid_frame (seg000:1209) и
* play_guard_frame (seg000:1246) сразу после loadkid/loadshad зовут
* load_fram_det_col() ДО control(). У нас этого не было, и Кид считал
* свой прыжок по кадру стража (BUG-CTRL-FRAME-1): при distance 2 вместо 10
* check_jump_up уходил в «пустой прыжок вверх» вместо «шаг назад и
* зацеп». Результат менялся от кадра к кадру вместе с анимацией стража.
*
* determine_col у нас существует только для Кида: pop_map работает с Kid
* напрямую, а не с абстрактным Char. Поэтому вторую половину связки делаем
* только на его ветке стражу пока хватает перезагрузки самого кадра (его
* колонку ведёт pop_guard_enter/leave). Char.curr_col синхронизируем
* руками: окно Char копия Kid. */
void pop_load_fram_det_col(void)
{
if (!kdat_ok) return;
gfx_w0_map(kdat_page);
load_frame();
gfx_w0_unmap();
if (Char.charid == CHARID_0_KID) {
pop_determine_col();
Char.curr_col = Kid.curr_col;
}
}
/* Прокрутить последовательность КОНКРЕТНОГО персонажа (порт связки
* loadkid/play_seq/savekid, seg006). Звать вместо голого play_seq: тот
* работает с Char и сам по себе не знает, чей это персонаж. */
@@ -327,6 +506,7 @@ void kid_init(uint8_t seq_id, int8_t col, int8_t row, int8_t dir)
Kid.fall_x = Kid.fall_y = 0;
Kid.repeat = 0;
Kid.charid = 0; /* charid_0_kid: своя таблица кадров */
Kid.alive = -1; /* в оригинале ЖИВОЙ = alive < 0 */
Kid.curr_seq = kid_seq_off[seq_id];
pop_kid_play(); /* до первого кадра */
}
@@ -363,6 +543,64 @@ static int kid_lx[2], kid_ly[2];
static uint16_t kid_lw[2], kid_lh[2];
static uint8_t kid_lvalid[2];
/* ОТДЕЛЬНЫЙ прямоугольник накладных спрайтов (клинок chtab_0, брызги урона).
* Раньше он объединялся с прямоугольником персонажа но объединение сильно
* больше суммы двух: клинок уходит вперёд-вверх, и общий габарит выходил
* почти вдвое шире и выше самого Kid, а heal стоит ровно по площади.
* Поэтому heal делаем двумя вызовами по своим прямоугольникам, а объединение
* оставляем ТОЛЬКО для окна fore-клипа (там это 4 сравнения, без рисования
* зато клинок/брызги честно уходят под передние грани тайлов). */
static int kid_ox[2], kid_oy[2];
static uint16_t kid_ow[2], kid_oh[2];
static uint8_t kid_ovalid[2];
/* Добавить накладной спрайт в overlay-прямоугольник страницы dp. Клинок и
* брызги в одном кадре встречаются редко и оба маленькие их объединение
* дешевле третьего слота. */
static void kid_overlay_add(uint8_t dp, int x, int y, uint16_t w, uint16_t h)
{
if (!kid_ovalid[dp]) {
kid_ox[dp] = x; kid_oy[dp] = y; kid_ow[dp] = w; kid_oh[dp] = h;
kid_ovalid[dp] = 1;
return;
}
{
int x0 = kid_ox[dp], y0 = kid_oy[dp];
int x1 = x0 + (int)kid_ow[dp], y1 = y0 + (int)kid_oh[dp];
if (x < x0) x0 = x;
if (y < y0) y0 = y;
if (x + (int)w > x1) x1 = x + (int)w;
if (y + (int)h > y1) y1 = y + (int)h;
kid_ox[dp] = x0; kid_oy[dp] = y0;
kid_ow[dp] = (uint16_t)(x1 - x0); kid_oh[dp] = (uint16_t)(y1 - y0);
}
}
/* Окно fore-клипа = объединение «персонаж + накладные». Считается по
* прямоугольникам, ничего не рисует. */
static void kid_fore_clip(uint8_t dp)
{
int x0, y0, x1, y1;
if (!kid_lvalid[dp] && !kid_ovalid[dp]) { pop_fore_set_clip(0, 0, 0, 0); return; }
if (!kid_ovalid[dp]) {
pop_fore_set_clip(kid_lx[dp], kid_ly[dp] + POP_YOFF,
(int)kid_lw[dp], (int)kid_lh[dp]);
return;
}
if (!kid_lvalid[dp]) {
pop_fore_set_clip(kid_ox[dp], kid_oy[dp] + POP_YOFF,
(int)kid_ow[dp], (int)kid_oh[dp]);
return;
}
x0 = kid_lx[dp] < kid_ox[dp] ? kid_lx[dp] : kid_ox[dp];
y0 = kid_ly[dp] < kid_oy[dp] ? kid_ly[dp] : kid_oy[dp];
x1 = kid_lx[dp] + (int)kid_lw[dp];
y1 = kid_ly[dp] + (int)kid_lh[dp];
if (kid_ox[dp] + (int)kid_ow[dp] > x1) x1 = kid_ox[dp] + (int)kid_ow[dp];
if (kid_oy[dp] + (int)kid_oh[dp] > y1) y1 = kid_oy[dp] + (int)kid_oh[dp];
pop_fore_set_clip(x0, y0 + POP_YOFF, x1 - x0, y1 - y0);
}
/* Метрики последнего kid_draw для футпринта fore-over-Kid (pop_bg):
* obj_x ЛОГИЧЕСКАЯ X кадра (ДО ×8/7), obj_y низ, w/h размер. */
static int kid_fp_x, kid_fp_y;
@@ -406,38 +644,38 @@ void kid_draw_splash(void)
int top = obj_y - (int)h + 1;
if (flip) bx -= (int)w;
gfx_set_bank(GFX_BANK_SPRITE);
if (pop_onscreen_cols(bx, top + POP_YOFF, w, h))
gfx_blit_cols_part_noclip(bx, top + POP_YOFF, img, flip, 0, 0);
else
gfx_blit_cols(bx, top + POP_YOFF, img, flip);
gfx_set_bank(GFX_BANK_NORMAL);
pop_clip_sprite(bx, w, top + POP_YOFF, (int)h);
/* Прямоугольник splash расширяет область heal ЭТОЙ страницы: иначе
* брызги останутся на экране после кадра. */
/* Брызги — накладной спрайт: свой прямоугольник heal (см.
* kid_overlay_add), окно fore пересчитываем объединением. */
{
uint8_t dp = gfx_get_draw_page();
if (kid_lvalid[dp]) {
int x0 = kid_lx[dp] < bx ? kid_lx[dp] : bx;
int y0 = kid_ly[dp] < top ? kid_ly[dp] : top;
int x1 = kid_lx[dp] + (int)kid_lw[dp];
int y1 = kid_ly[dp] + (int)kid_lh[dp];
if (bx + (int)w > x1) x1 = bx + (int)w;
if (top + (int)h > y1) y1 = top + (int)h;
kid_lx[dp] = x0; kid_ly[dp] = y0;
kid_lw[dp] = (uint16_t)(x1 - x0); kid_lh[dp] = (uint16_t)(y1 - y0);
/* Окно fore-слоя расширяем тем же объединением: брызги
* тоже должны уходить под передние грани тайлов. */
pop_fore_set_clip(x0, y0 + POP_YOFF, x1 - x0, y1 - y0);
}
kid_overlay_add(dp, bx, top, w, h);
kid_fore_clip(dp);
}
}
gfx_w0_unmap();
}
/* Восстановить фон из теневой копии на месте ПРЕДЫДУЩЕГО кадра Kid. */
/* Восстановить фон из теневой копии на месте ПРЕДЫДУЩЕГО кадра Kid.
*
* pop_heal_fast, а не голый gfx_heal: прямоугольник тут это ровно тот
* прямоугольник, который kid_draw нарисовал noclip-ядром, то есть в
* подавляющем большинстве кадров он заведомо на экране. Клипающий путь
* остаётся для straddle (kid_render_dx = 140) и падения ниже поля
* там координата реально уходит за экран. */
void kid_heal(void)
{
uint8_t p = gfx_get_draw_page();
if (kid_lvalid[p])
gfx_heal(kid_lx[p], kid_ly[p] + POP_YOFF, kid_lw[p], kid_lh[p]); /* +YOFF: центрирование */
kid_lvalid[p] = 0;
if (kid_lvalid[p]) /* +YOFF: центрирование */
pop_heal_fast(kid_lx[p], kid_ly[p] + POP_YOFF, kid_lw[p], kid_lh[p]);
if (kid_ovalid[p])
pop_heal_fast(kid_ox[p], kid_oy[p] + POP_YOFF, kid_ow[p], kid_oh[p]);
kid_lvalid[p] = kid_ovalid[p] = 0;
}
void kid_draw(void)
@@ -482,7 +720,8 @@ void kid_draw(void)
uint8_t flip = (Kid.direction >= 0); /* dir_0_right -> зеркало */
int bx = obj_x + KID_XOFF;
int skip = 0; /* строк спрайта срезано сверху */
int ct;
int ct, cr;
uint16_t vis_w = w; /* видимая ширина (клип справа) */
if (flip)
bx -= (int)w; /* seg008: xpos -= image->w */
top = obj_y - (int)h + 1 + KID_YOFF;
@@ -495,9 +734,28 @@ void kid_draw(void)
skip = ct - top;
if (skip >= (int)h) skip = (int)h; /* обрезан целиком */
}
/* clip_char, вторая половина: правый косяк двери уровня. Дверь
* рисуется ПОД персонажем (вся в backtable), поэтому спрятать его
* можно только срезав спрайт см. pop_clip_char_right. */
cr = pop_clip_char_right();
if (cr) {
int avail = cr - bx;
if (avail <= 0) vis_w = 0; /* весь спрайт за косяком */
else if (avail < (int)w) vis_w = (uint16_t)avail;
}
gfx_set_bank(GFX_BANK_SPRITE); /* видео-ОЗУ, без теневой копии */
if (skip < (int)h) /* +YOFF: центрирование */
gfx_blit_cols_part(bx, top + skip + POP_YOFF, img, flip, skip, 0);
if (skip < (int)h && vis_w) { /* +YOFF: центрирование */
/* noclip-ядро ширину не ограничивает, поэтому при обрезке справа
* идём общим путём: это редкие кадры (подъём в двери уровня раз
* за уровень), цена подготовки тут не видна. */
if (vis_w == w && pop_onscreen_cols(bx, top + skip + POP_YOFF, w,
(uint16_t)((int)h - skip)))
gfx_blit_cols_part_noclip(bx, top + skip + POP_YOFF, img, flip,
(uint8_t)skip, 0);
else
gfx_blit_cols_part_w(bx, top + skip + POP_YOFF, img, flip,
skip, 0, (uint8_t)(vis_w == w ? 0 : vis_w));
}
gfx_set_bank(GFX_BANK_NORMAL);
/* клип СВОЕГО выхода за поле (падение ниже поля / верхний борт) —
* узко, только по прямоугольнику Кида и только при выходе. */
@@ -505,62 +763,38 @@ void kid_draw(void)
/* heal чистит ТОЛЬКО нарисованное: иначе стирается кромка пола над
* срезом (мусор/дыра в кладке на второй странице дабл-буфера). */
kid_lx[dp] = bx; kid_ly[dp] = top + skip;
kid_lw[dp] = w; kid_lh[dp] = (uint16_t)((int)h - skip);
kid_lvalid[dp] = (skip < (int)h);
kid_lw[dp] = vis_w; kid_lh[dp] = (uint16_t)((int)h - skip);
kid_lvalid[dp] = (skip < (int)h && vis_w != 0);
kid_fp_w = w; kid_fp_h = h; /* размер кадра для футпринта fore */
}
/* add_sword_to_objtable (seg006:1798): клинок отдельным спрайтом поверх
* Kid. Прямоугольник heal расширяем объединением иначе клинок, выйдя
* за габарит Kid, оставит след. */
if (sword_ok && Kid.frame >= 229 && Kid.frame < 238) {
uint8_t sf = (uint8_t)(fr.sword & 0x3F);
if (sf >= SWORD_SF_LO && sf <= SWORD_SF_HI) {
uint8_t si = (uint8_t)(sf - SWORD_SF_LO);
const uint8_t *simg = (const uint8_t *)atlas_image(&swordp, si);
uint16_t sw, sh;
gfx_w0_map(swordp.page);
sw = (uint16_t)(simg[0] | ((uint16_t)simg[1] << 8));
sh = (uint16_t)(simg[2] | ((uint16_t)simg[3] << 8));
if (sw && sh) {
uint8_t sflip = (Kid.direction >= 0);
int sbx = obj_x + ((Kid.direction < 0) ? -(int)SWORD_DX[si]
: (int)SWORD_DX[si]);
int stop = obj_y + (int)SWORD_DY[si] - (int)sh + 1;
uint8_t dp = gfx_get_draw_page() & 1;
if (sflip) sbx -= (int)sw;
gfx_set_bank(GFX_BANK_SPRITE);
gfx_blit_cols_part(sbx, stop + POP_YOFF, simg, sflip, 0, 0);
gfx_set_bank(GFX_BANK_NORMAL);
if (!kid_lvalid[dp]) {
kid_lx[dp] = sbx; kid_ly[dp] = stop;
kid_lw[dp] = sw; kid_lh[dp] = sh;
kid_lvalid[dp] = 1;
} else {
int x0 = kid_lx[dp], y0 = kid_ly[dp];
int x1 = x0 + (int)kid_lw[dp], y1 = y0 + (int)kid_lh[dp];
if (sbx < x0) x0 = sbx;
if (stop < y0) y0 = stop;
if (sbx + (int)sw > x1) x1 = sbx + (int)sw;
if (stop + (int)sh > y1) y1 = stop + (int)sh;
kid_lx[dp] = x0; kid_ly[dp] = y0;
kid_lw[dp] = (uint16_t)(x1 - x0);
kid_lh[dp] = (uint16_t)(y1 - y0);
}
}
}
* Kid со СВОИМ прямоугольником heal (kid_overlay_add). */
{
int sx, sy; uint16_t sw, sh;
if (pop_sword_draw(&Kid, &fr, obj_x, obj_y, &sx, &sy, &sw, &sh))
kid_overlay_add(gfx_get_draw_page() & 1, sx, sy, sw, sh);
}
gfx_w0_unmap();
/* Окно fore-слоя = итоговый прямоугольник спрайта (с клинком и
* обрезкой clip_char): fore-проход вернёт куски тайлов ТОЛЬКО поверх
* него, а не целыми тайлами см. pop_bg.c, fclip_on. Ставим и когда
* рисовать нечего (kid_lvalid=0): нулевое окно = fore-проход не
* трогает экран, что и правильно перекрывать нечего. */
{
uint8_t dp = gfx_get_draw_page() & 1;
if (kid_lvalid[dp])
pop_fore_set_clip(kid_lx[dp], kid_ly[dp] + POP_YOFF,
(int)kid_lw[dp], (int)kid_lh[dp]);
else
pop_fore_set_clip(0, 0, 0, 0);
}
/* Окно fore-слоя = объединение «Kid + клинок» (с обрезкой clip_char):
* fore-проход вернёт куски тайлов ТОЛЬКО поверх него, а не целыми
* тайлами см. pop_bg.c, fclip_on. Ставим и когда рисовать нечего:
* нулевое окно = fore-проход не трогает экран, что и правильно. */
kid_fore_clip(gfx_get_draw_page() & 1);
}
/* Вернуть окно fore-клипа на прямоугольник ПОСЛЕДНЕГО кадра Kid.
*
* Окно (pop_fore_set_clip) одно на всех, и его ставит КАЖДЫЙ, кто рисует
* персонажа: kid_draw в конце своё, pop_guard_draw в конце своё. Пока
* страж рисовался строго ДО Кида, к моменту pop_fore_over_kid в окне
* оказывался Kid, и всё сходилось само. Как только порядок стал
* переменным (guard_over_kid в roomtest.c порядок задаёт обход тайлов),
* при «страж поверх» окно оставалось СТРАЖЬИМ и fore-проход Кида отсекался
* целиком: Кид рисовался поверх передних столбов.
*
* Поэтому окно восстанавливается явно перед pop_fore_over_kid, а не
* держится «по счастливому порядку вызовов». */
void kid_fore_clip_restore(void)
{
kid_fore_clip(gfx_get_draw_page() & 1);
}
+43
View File
@@ -12,6 +12,7 @@
#include <stdint.h>
#include "pop_char.h"
#include "kid_data.h" /* kframe — тип кадра для pop_sword_draw */
/* Состояние персонажа — общий тип для Кида, стража и Char (pop_char.h). */
extern pop_char_t Kid;
@@ -20,6 +21,11 @@ extern pop_char_t Kid;
* (свой ряд). Ставит play_seq, читает/сбрасывает pop_check_knock. */
extern int8_t knock;
/* Порт next_level (seg000): END_LEVEL в play_seq инкрементит — «уровень
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
* там рестарт того же уровня; загрузка следующего levels_plan.md. */
extern uint8_t pop_next_level;
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 0 OK, -1 ошибка. */
int pop_kid_load(uint8_t npages);
@@ -43,6 +49,15 @@ void pop_kid_data_free(void);
int pop_sword_load(void);
void pop_kid_free(void);
/* Нарисовать клинок chtab_0 поверх персонажа (порт add_sword_to_objtable,
* seg006:1798) общая точка для Кида и стража: меч в оригинале ОДИН на
* всех. obj_x/obj_y та же опорная точка, что у спрайта персонажа.
* Возвращает 1 и прямоугольник клинка (для heal), 0 клинка нет.
* Страницу W0 оставляет замапленной: вызывающий делает gfx_w0_unmap. */
uint8_t pop_sword_draw(const pop_char_t *ch, const kframe *fr,
int obj_x, int obj_y,
int *rx, int *ry, uint16_t *rw, uint16_t *rh);
/* Инициализировать Kid: последовательность seq_id, клетка col/row,
* направление dir (-1/0). Ставит x/y по x_bump/y_land. */
void kid_init(uint8_t seq_id, int8_t col, int8_t row, int8_t dir);
@@ -59,11 +74,22 @@ void play_seq(void); /* работает с Char — см. pop_kid_play
* Интерпретатор последовательностей общий для Kid и стража: у них общая
* seqtbl, но РАЗНЫЕ таблицы кадров (load_frame выбирает по Char.charid). */
extern pop_char_t Char;
/* Соперник активного персонажа (порт Opp): ставится вместе с Char через
* pop_loadshad_and_opp. Читают ИИ стража и боёвка (char_opp_dist). */
extern pop_char_t Opp;
void pop_loadkid(void);
void pop_savekid(void);
void pop_savekid_state(void); /* Kid = Char, кадр не трогаем */
void pop_loadshad(void);
void pop_saveshad(void);
void pop_loadshad_and_opp(void); /* Char = Guard, Opp = Kid (seg006:841) */
void pop_saveshad_and_opp(void); /* Guard = Char, Kid = Opp */
void pop_loadkid_and_opp(void); /* Char = Kid, Opp = Guard */
void pop_savekid_and_opp(void); /* Kid = Char, Guard = Opp */
/* Дистанция до соперника в системе Char: >0 впереди, <0 за спиной,
* 999 разные комнаты (порт char_opp_dist, seg006:2135). */
int16_t pop_char_opp_dist(void);
/* Прокрутить последовательность персонажа целиком (load + play_seq +
* save). Звать вместо голого play_seq. */
@@ -71,9 +97,17 @@ void pop_char_set_seq(uint8_t seq_id); /* seq активного персон
void pop_kid_play(void);
void pop_guard_play(void);
/* load_fram_det_col (seg006:0144) — перезагрузить cur_frame под АКТИВНОГО
* персонажа (Char) и пересчитать его колонку. Звать сразу после pop_loadkid/
* pop_loadshad и ДО диспетчера control(), как это делают play_kid_frame /
* play_guard_frame (seg000:1209/1246): cur_frame один на всех, и без этого
* геометрия одного персонажа считается по кадру другого. */
void pop_load_fram_det_col(void);
/* Поля текущего кадра kid_frames[Kid.frame] (pop_map, без дублирования
* таблицы во втором TU). */
int8_t kid_cur_dx(void);
int8_t kid_cur_dy(void);
uint8_t kid_cur_flags(void);
/* Сменить последовательность (Kid.curr_seq = kid_seq_off[seq_id]) — единая
@@ -92,10 +126,19 @@ void pop_kid_set_render_dx(int dx);
* теневой копии; запоминает прямоугольник для kid_heal). */
void kid_draw(void);
/* Вернуть окно fore-клипа на прямоугольник последнего кадра Kid. Звать
* ПЕРЕД pop_fore_over_kid, если между ним и kid_draw кто-то ещё рисовал
* персонажа (pop_guard_draw ставит окно по стражу). */
void kid_fore_clip_restore(void);
/* draw_hurt_splash (seg006:2003): «брызги» урона (image 218) поверх Kid —
* звать СРАЗУ после kid_draw в кадре, когда HP убавилось (pop_kid_hurt). */
void kid_draw_splash(void);
/* Блит спрайта chtab_2 по id, левым верхним углом в (x,y) — для полосы HP
* (id 216 полное деление / 217 пустое). */
void pop_kid_img_blit(uint8_t image, int x, int y);
/* Восстановить фон (из теневой копии) на месте ПРЕДЫДУЩЕГО кадра Kid —
* вместо перерисовки всего экрана. Звать ПЕРЕД kid_tick/kid_draw. */
void kid_heal(void);
+198 -10
View File
@@ -16,6 +16,12 @@
extern void _gfx_w0_isr(void);
#define LVL_DATA_OFF 0x100u /* данные с 0x100; 0x00..0xFF — ISR-стаб */
/* Эталонная копия foretable В ТОЙ ЖЕ странице: страница 16 КБ, данных
* уровня 2.3 КБ место даром. Нужна, потому что foretable теперь
* МУТАБЕЛЬНАЯ (pop_level_set_tile), а рестарт уровня в оригинале
* это load_level() заново (play_level, seg003:57), то есть все плиты целы
* и зелья на месте. pop_level_reset_tiles восстанавливает из неё. */
#define LVL_PRISTINE_OFF 0x1000u
/* Смещения блоков blueprnt относительно начала данных (Table 6). */
#define BP_FG 0u
@@ -27,12 +33,18 @@ extern void _gfx_w0_isr(void);
#define BP_GTILE 2119u
#define BP_GDIR 2143u
#define BP_GX 2167u
#define BP_GSEQLO 2191u
#define BP_GSKILL 2215u
#define BP_GSEQHI 2239u
#define BP_GCOLOR 2263u
#define BP_START 2112u
#define ROOM_TILES 30u
#define MIN_SIZE (BP_START + 3u) /* минимум, чтобы прочитать старт */
/* fwd: инициализация живой копии стражей (тело ниже). */
static void gstate_init(void);
static uint8_t lvl_blk, lvl_page, lvl_ok;
/* Копии таблиц дверных связей в W2 (LINKMAP мутабельна — таймеры кнопок). */
static uint8_t dl1[256], dl2[256];
@@ -68,12 +80,16 @@ int pop_level_load(const char *path)
dl1[i] = pg[LVL_DATA_OFF + BP_LINKLOC + i];
dl2[i] = pg[LVL_DATA_OFF + BP_LINKMAP + i];
}
/* Эталон foretable — для рестарта уровня (см. LVL_PRISTINE_OFF). */
for (i = 0; i < 24u * ROOM_TILES; i++)
pg[LVL_PRISTINE_OFF + i] = pg[LVL_DATA_OFF + BP_FG + i];
}
sprinter_page_w3(saved_w3);
close(fd);
if (n < (int)MIN_SIZE) { mem_free_block(blk); return -1; }
lvl_ok = 1;
gstate_init(); /* живая копия состояния стражей — из уровня */
return 0;
}
@@ -82,6 +98,52 @@ void pop_level_free(void)
if (lvl_ok) { mem_free_block(lvl_blk); lvl_ok = 0; }
}
/* ---- Номер уровня и потабличные различия ---------------------------- */
uint8_t pop_current_level;
/* Таблицы из SDLPoP data.h:840..848, индекс = номер уровня (0 = демо).
* Держим целиком по 16 байт: короче и дешевле любых «оптимизаций», а
* расхождение с оригиналом сразу видно глазами. */
static const uint8_t tbl_entry_pose[16] =
{ 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0 };
static const uint8_t tbl_guard_hp[16] =
{ 4, 3, 3, 3, 3, 4, 5, 4, 4, 5, 5, 5, 4, 6, 0, 0 };
static const int8_t tbl_guard_type[16] =
{ 0, 0, 0, 2, 0, 0, 1, 0, 0, 0, 0, 0, 4, 3, -1, -1 };
static const uint8_t tbl_level_type[16] =
{ 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0 };
uint8_t pop_level_entry_pose(uint8_t n) { return n < 16 ? tbl_entry_pose[n] : 0; }
uint8_t pop_level_guard_hp(uint8_t n) { return n < 16 ? tbl_guard_hp[n] : 3; }
int8_t pop_level_guard_type(uint8_t n) { return n < 16 ? tbl_guard_type[n] : -1; }
uint8_t pop_level_tileset(uint8_t n) { return n < 16 ? tbl_level_type[n] : 0; }
int pop_level_load_num(uint8_t n)
{
/* Имена патчим на месте, а не через sprintf: printf-семейство тянет в
* резидент W1 форматтер, которого здесь больше негде применить. */
static char pri[] = "LEVELS\\res2000.bin"; /* цифры — [12],[13] */
static char alt[] = "a:\\res2000.bin"; /* цифры — [8],[9] */
uint8_t saved_blk = lvl_blk, saved_page = lvl_page, saved_ok = lvl_ok;
if (n > 15) return -1;
pri[12] = alt[8] = (char)('0' + n / 10);
pri[13] = alt[9] = (char)('0' + n % 10);
/* Старую страницу отпускаем ТОЛЬКО после успешной загрузки новой:
* pop_level_load сам аллоцирует страницу, и если файла нет остаёмся
* на текущем уровне вместо падения в пустой уровень. */
lvl_ok = 0; /* чтобы pop_level_load не мешался */
if (pop_level_load(pri) != 0 && pop_level_load(alt) != 0) {
lvl_blk = saved_blk; lvl_page = saved_page; lvl_ok = saved_ok;
return -1;
}
if (saved_ok) mem_free_block(saved_blk);
pop_current_level = n;
return 0;
}
/* Указатель на fg-тайлы комнаты r (1..24) в W0-пространстве (страница уже
* замаплена вызывающим). */
static const uint8_t *room_fg_ptr(uint8_t r)
@@ -148,12 +210,23 @@ int pop_room_load(uint8_t room, uint8_t *fg, uint8_t *bg,
for (i = 0; i < 3; i++) { rcol_fg[i] = 20; rcol_fg[3 + i] = 20; rcol_bg[i] = 0; }
}
/* belowrow: верхний ряд (row0) комнаты снизу */
/* belowrow: верхний ряд (row0) комнаты снизу — ОДИННАДЦАТЬ байт.
* [0..9] колонки 0..9 комнаты снизу; [10] тайл (0,9) комнаты
* ПО ДИАГОНАЛИ СНИЗУ-СЛЕВА (room_BL у оригинала). Одиннадцатый нужен
* тайлу (2,0): его «сосед снизу-слева» это колонка 1 ряда 3, то есть
* чужая комната, и load_rowbelow (seg008:368) резолвит её именно так,
* с дефолтом «стена» только когда такой комнаты НЕТ. Раньше мы клали
* туда стену БЕЗУСЛОВНО, и в пустом углу рисовался клин кладки
* (BUG-SEAM-WEDGE-1, ур. 3 комн. 18). */
if (down >= 1 && down <= 24) {
const uint8_t *df = room_fg_ptr(down);
uint8_t dl = ((const uint8_t *)(LVL_DATA_OFF + BP_LINKS))[(down - 1) * 4];
for (i = 0; i < 10; i++) below_fg[i] = (uint8_t)(df[i] & 0x1F);
below_fg[10] = (dl >= 1 && dl <= 24)
? (uint8_t)(room_fg_ptr(dl)[9] & 0x1F)
: 20; /* кромка уровня */
} else {
for (i = 0; i < 10; i++) below_fg[i] = 20; /* нет комнаты снизу = стена */
for (i = 0; i < 11; i++) below_fg[i] = 20; /* нет комнаты снизу = стена */
}
gfx_w0_unmap();
@@ -177,6 +250,48 @@ int8_t pop_room_col_landing(uint8_t room, uint8_t col)
return res;
}
/* set_tile-запись в ЖИВУЮ foretable уровня (порт curr_room_tiles[…] = …).
* В оригинале уровень обычная структура в ОЗУ, и do_pickup (seg006:1671),
* remove_loose (seg007:0EB8), loose_land (seg007:11E8) правят её насовсем.
* У нас уровень в EMM-странице пишем прямо туда, поэтому изменение
* переживает выход из комнаты (BUG-LVLSTATE-1). Пишем БАЙТ ЦЕЛИКОМ:
* старшие биты старого тайла (напр. 0x20 «solid loose») к новому типу
* отношения не имеют. */
void pop_level_set_tile(uint8_t room, uint8_t tilepos, uint8_t tile)
{
if (!lvl_ok || room < 1 || room > 24 || tilepos >= ROOM_TILES) return;
gfx_w0_map(lvl_page);
((uint8_t *)(LVL_DATA_OFF + BP_FG))[(uint16_t)(room - 1) * ROOM_TILES + tilepos]
= tile;
gfx_w0_unmap();
}
/* Рестарт уровня (порт load_level из цикла play_level, seg003:57): вернуть
* foretable в исходное состояние из эталонной копии. */
void pop_level_reset_tiles(void)
{
uint16_t i;
uint8_t *dst;
const uint8_t *src;
if (!lvl_ok) return;
gfx_w0_map(lvl_page);
dst = (uint8_t *)(LVL_DATA_OFF + BP_FG);
src = (const uint8_t *)LVL_PRISTINE_OFF;
for (i = 0; i < 24u * ROOM_TILES; i++) dst[i] = src[i];
gfx_w0_unmap();
}
/* Рестарт уровня, часть вторая (тот же load_level + pos_guards, seg003:83):
* стражи такая же часть данных уровня, как тайлы, и перезагрузка файла
* возвращает их всех. Убитый до смерти Кида страж после respawn снова жив
* и стоит на исходном тайле; забывается и запомненная поза трупа
* (guards_seq_hi = 0 -> «поднять стандартной стойкой»). */
void pop_level_reset_guards(void)
{
if (!lvl_ok) return;
gstate_init();
}
uint8_t pop_doorlink1(uint8_t i) { return dl1[i]; }
uint8_t pop_doorlink2(uint8_t i) { return dl2[i]; }
void pop_doorlink2_set(uint8_t i, uint8_t v) { dl2[i] = v; }
@@ -243,22 +358,95 @@ static uint8_t start_byte(uint16_t off)
/* Страж комнаты (enter_guard, seg002:0112 + pos_guards, seg003). Возврат
* 0 = стража в комнате нет (tile >= 30). X оригинал пересчитывает из
* колонки (pos_guards), поэтому guards_x из файла не читаем. */
/* ---- ЖИВОЕ состояние стражей (порт level.guards_*) ------------------ *
* В оригинале массивы стражей часть структуры уровня в ОЗУ, и движок их
* ПЕРЕПИСЫВАЕТ: leave_guard при выходе из комнаты кладёт туда текущие
* позицию/направление/мастерство, а у МЁРТВОГО ещё и curr_seq. Благодаря
* этому, вернувшись, находишь труп там же, а не воскресшего стража.
* У нас уровень лежит в EMM-странице только на чтение, поэтому держим
* мутабельную копию в W2: 6 байт на комнату. */
#define GS_TILE 0
#define GS_DIR 1
#define GS_X 2
#define GS_SKILL 3
#define GS_SEQL 4
#define GS_SEQH 5
static uint8_t gstate[24 * 6];
static void gstate_init(void)
{
const uint8_t *p = (const uint8_t *)LVL_DATA_OFF;
uint8_t r;
gfx_w0_map(lvl_page);
for (r = 0; r < 24; r++) {
uint8_t *g = gstate + (uint16_t)r * 6;
g[GS_TILE] = p[BP_GTILE + r];
g[GS_DIR] = p[BP_GDIR + r];
g[GS_X] = p[BP_GX + r];
g[GS_SKILL] = p[BP_GSKILL + r];
/* guards_seq_lo/hi в ФАЙЛЕ не используются — там 0xFF во всех
* комнатах (оригинал чистит их в reset_level_unused_fields).
* Осмысленное значение туда кладёт только leave_guard, поэтому
* стартуем нулями: 0 = «поднимать стандартной стойкой».
* Без этого в интерпретатор уходил curr_seq = 0xFFFF и он вис. */
g[GS_SEQL] = 0;
g[GS_SEQH] = 0;
}
gfx_w0_unmap();
}
/* leave_guard (seg002:02F5): запомнить состояние стража комнаты. seq
* сохраняем ТОЛЬКО у мёртвого у живого seq_hi = 0 означает «поднять
* стандартной стойкой», как в оригинале. */
void pop_guard_state_save(uint8_t room, uint8_t tile, int8_t dir, uint8_t x,
uint8_t skill, uint16_t seq, uint8_t dead)
{
uint8_t *g;
if (room < 1 || room > 24) return;
g = gstate + (uint16_t)(room - 1) * 6;
g[GS_TILE] = tile;
g[GS_DIR] = (uint8_t)dir;
g[GS_X] = x;
g[GS_SKILL] = skill;
if (dead) { g[GS_SEQL] = (uint8_t)seq; g[GS_SEQH] = (uint8_t)(seq >> 8); }
else { g[GS_SEQH] = 0; }
}
uint16_t pop_guard_state_seq(uint8_t room)
{
const uint8_t *g;
if (room < 1 || room > 24) return 0;
g = gstate + (uint16_t)(room - 1) * 6;
if (g[GS_SEQH] == 0) return 0; /* живой — стандартная стойка */
return (uint16_t)(g[GS_SEQL] | ((uint16_t)g[GS_SEQH] << 8));
}
/* Запомненная X стража комнаты (для трупа: он лежит там, где лёг, а не в
* центре тайла). */
uint8_t pop_guard_state_x(uint8_t room)
{
if (room < 1 || room > 24) return 0;
return gstate[(uint16_t)(room - 1) * 6 + GS_X];
}
uint8_t pop_level_guard(uint8_t room, uint8_t *tile, int8_t *dir,
uint8_t *color, uint8_t *skill)
{
const uint8_t *p;
const uint8_t *g;
uint8_t t;
if (!lvl_ok || room < 1 || room > 24) return 0;
gfx_w0_map(lvl_page);
p = (const uint8_t *)LVL_DATA_OFF;
t = p[BP_GTILE + room - 1];
/* Тайл/направление/мастерство — из ЖИВОЙ копии (её правит
* pop_guard_state_save); цвет статичен, его читаем со страницы. */
g = gstate + (uint16_t)(room - 1) * 6;
t = g[GS_TILE];
if (t < 30) {
*tile = t;
*dir = (int8_t)p[BP_GDIR + room - 1];
*color = (uint8_t)(p[BP_GCOLOR + room - 1] & 0x0F);
*skill = p[BP_GSKILL + room - 1];
}
*dir = (int8_t)g[GS_DIR];
*skill = g[GS_SKILL];
gfx_w0_map(lvl_page);
*color = (uint8_t)(((const uint8_t *)LVL_DATA_OFF)[BP_GCOLOR + room - 1] & 0x0F);
gfx_w0_unmap();
}
return (uint8_t)(t < 30);
}
+58 -1
View File
@@ -26,6 +26,36 @@
int pop_level_load(const char *path);
void pop_level_free(void);
/* ---- Номер уровня и потабличные различия (levels_plan.md §1) -------- */
/* Порт current_level (seg000 load_lev_spr: current_level = next_level =
* level). Уровень 1..15; 0 демо-уровень, в скоуп не входит. Читают
* потабличные функции ниже, стражи (HP/тип) и стартовая логика. */
extern uint8_t pop_current_level;
/* Загрузить уровень НОМЕРОМ: освободить страницу прошлого, взять
* `LEVELS\res20NN.bin` (fallback `a:\res20NN.bin`) и выставить
* pop_current_level. 0 OK, -1 файла нет / не влез (тогда
* pop_current_level и страница НЕ тронуты играем дальше на старом).
* Порт связки load_lev_spr + load_level (seg000:1098/1169). */
int pop_level_load_num(uint8_t n);
/* tbl_entry_pose (data.h:848): 1 — падение внутрь (ур. 1), 2 — вбегание
* (ур. 13), 0 разворот на месте. Индекс номер уровня. */
uint8_t pop_level_entry_pose(uint8_t n);
/* tbl_guard_hp (data.h:846) — база HP стража на уровне (get_guard_hp,
* seg002:0044: extrastrength[skill] + tbl_guard_hp[level]). */
uint8_t pop_level_guard_hp(uint8_t n);
/* tbl_guard_type (data.h:844): 0 обычный, 1 толстый, 2 скелет, 3 визирь,
* 4 тень, 1 стражей на уровне НЕТ (уровни 14/15). */
int8_t pop_level_guard_type(uint8_t n);
/* tbl_level_type (data.h:840): 0 = dungeon, 1 = palace. Пока читается
* только для контроля второй тайлсет это levels_plan.md §3. */
uint8_t pop_level_tileset(uint8_t n);
/* Извлечь комнату room (1..24) в массивы приложения (W2):
* fg[30], bg[30] тайлы комнаты (fg маскирован &0x1F, bg raw);
* lcol_fg[6], lcol_bg[3] [0..2] правый столбец (col9) комнаты СЛЕВА
@@ -33,7 +63,8 @@ void pop_level_free(void);
* для коллизии на колонке 2 (Kid стоит В шве);
* rcol_fg[6] симметрично (col0 / col1);
* нет комнаты стена/0;
* below_fg[10] верхний ряд (row0) комнаты СНИЗУ («стена
* below_fg[11] [0..9] верхний ряд (row0) комнаты СНИЗУ,
* [10] тайл (0,9) комнаты СНИЗУ-СЛЕВА («стена
* вниз»); нет комнаты стена.
* 0 OK, -1 уровень не загружен / room вне диапазона. */
int pop_room_load(uint8_t room, uint8_t *fg, uint8_t *bg,
@@ -49,6 +80,20 @@ uint8_t pop_room_link(uint8_t room, uint8_t side);
* тип анимируемого тайла в ЛЮБОЙ комнате, не только текущей. */
uint8_t pop_level_tile(uint8_t room, uint8_t tilepos);
/* Записать тайл в ЖИВУЮ foretable уровня (порт curr_room_tiles[tp] = tile:
* do_pickup / remove_loose / loose_land). Изменение персистентно уровень
* лежит в ОЗУ-странице, а не в ПЗУ, то есть переживает выход из комнаты,
* как в оригинале. Отменяется только pop_level_reset_tiles. */
void pop_level_set_tile(uint8_t room, uint8_t tilepos, uint8_t tile);
/* Рестарт уровня: вернуть ВСЕ тайлы в исходное (порт load_level, который
* play_level зовёт на каждой итерации в т.ч. после смерти Кида). */
void pop_level_reset_tiles(void);
/* Рестарт уровня: вернуть ВСЕХ стражей (тот же load_level + pos_guards).
* Убитый страж после respawn снова жив как в оригинале. */
void pop_level_reset_guards(void);
/* Батч-доступ к странице уровня (один map/unmap окна 0 на много чтений).
* Между begin/end только pop_level_tile_raw, без рисования. */
void pop_level_access_begin(void);
@@ -75,6 +120,18 @@ int8_t pop_room_col_landing(uint8_t room, uint8_t col);
/* Стартовая позиция уровня (блок @2112): комната / поза / направление. */
/* Страж комнаты: 1 = есть (заполнит tile/dir/color/skill), 0 = нет.
* Порт enter_guard (seg002:0112). */
/* leave_guard (seg002:02F5): запомнить состояние стража комнаты в ЖИВОЙ
* копии массивов уровня. dead != 0 сохранить и последовательность, тогда
* при возврате в комнату страж поднимется трупом там же, где лёг.
* tile == 30 (или больше) «стража в комнате больше нет». */
void pop_guard_state_save(uint8_t room, uint8_t tile, int8_t dir, uint8_t x,
uint8_t skill, uint16_t seq, uint8_t dead);
/* Последовательность запомненного стража (0 — поднимать стандартной
* стойкой, как у живого). */
uint16_t pop_guard_state_seq(uint8_t room);
uint8_t pop_guard_state_x(uint8_t room);
uint8_t pop_level_guard(uint8_t room, uint8_t *tile, int8_t *dir,
uint8_t *color, uint8_t *skill);
+618 -111
View File
@@ -30,6 +30,9 @@
#include "pop_bg.h"
#include "pop_state.h"
#include "pop_trob.h" /* room_modif + trob-список (пики/ворота) */
#include "pop_level.h" /* pop_level_start_room — вход в дверь уровня */
#include "pop_guard.h" /* SWORD_2_DRAWN — ветка приземления с мечом */
#include "pop_tune.h" /* POP_ENABLE_JUMP_GRAB и прочие настройки */
/* ---- Тайлы / действия / seq id (подмножество PoP) ------------------ */
#define TILE_EMPTY 0
@@ -46,6 +49,7 @@
#define TILE_POTION 10 /* зелье */
#define TILE_SWORD 22 /* меч на полу */
#define TILE_OPENER 15 /* кнопка-открывалка (RAISE, 0x0F) */
#define TILE_LEVELDOOR_LEFT 16 /* ЛЕВАЯ половина двери уровня (на ней modif) */
#define ACT_STAND 0
#define ACT_RUNJUMP 1
#define ACT_HANGCLIMB 2
@@ -66,6 +70,7 @@
#define SEQ_47_BUMP 47 /* удар в стену: dx(-4) отскок + 50-52 + stand */
/* K4 — зацеп/вис/подтягивание/спуск */
#define SEQ_8_GRAB_STRAIGHT 8 /* прыжок вверх и зацеп (прямо) */
#define SEQ_9_GRAB_WHILE_JUMPING 9 /* зацеп ПРЯМО В ПРЫЖКЕ (улучшение SDLPoP) */
#define SEQ_14_JUMP_INTO_CEILING 14 /* прыжок вверх: сверху стена/пол */
#define SEQ_16_JUMP_UP_AND_GRAB 16 /* прыжок вверх и зацеп (нет пола сзади)*/
#define SEQ_24_GRAB_FORWARD 24 /* прыжок вверх и зацеп (пол сзади) */
@@ -78,6 +83,8 @@
#define SEQ_73_CLIMB_UP_TO_GATE 73 /* подъём к закрытой калитке */
#define SEQ_51_SPIKED 51 /* напороться на пики (смерть) */
#define SEQ_22_CRUSHED 22 /* разбиться при падении (смерть) */
#define SEQ_63_ACTIVE_AFTER_FALL 63 /* стойка С МЕЧОМ после приземления */
#define SEQ_81_FIGHTFALL 81 /* fightfall: падение из боевой стойки */
#define TILE_FLOOR 1
#define FRAME_109_CROUCH 109
@@ -114,17 +121,29 @@ uint8_t pop_loose_fell; /* 0=нет; иначе tilepos+1 упавше
uint8_t pop_kid_dead; /* 1 = Kid мёртв (напоролся на пики и т.п.) */
uint8_t pop_kid_hurt; /* 1 = в этом кадре убавилось HP → «брызги» */
uint8_t hitp_curr; /* текущее HP (0 = мёртв); pop_kid_hp_reset ставит старт */
uint8_t hitp_max; /* стартовое HP Кида (для индикатора) */
/* hitp_beg_lev (seg003 init_game/play_level_2): HP, с которым НАЧАТ уровень.
* Смерть/рестарт уровня возвращают ровно его (do_startpos: hitp_max =
* hitp_curr = hitp_beg_lev), а по ПРОХОЖДЕНИИ уровня он подтягивается к
* достигнутому максимуму (hitp_beg_lev = hitp_max) так «жизнь» из большой
* склянки переносится на следующий уровень, а из мёртвых прогонов нет.
* Стартовое значение (custom->start_hitp = 3) ставит main через
* pop_kid_hp_newgame инициализатором нельзя: модуль банкованный. */
uint8_t hitp_beg_lev;
/* Дельта HP Кида за кадр (порт hitp_delta). Её ставит СВОРД-путь боёвки
* (guards.c), а применяет pop_do_delta_hp раз в кадр как в оригинале.
* Путь пик/падения (take_hp ниже) правит hitp_curr сразу: он был написан и
* проверен до боёвки, и переводить его на дельту без нужды рискованно. */
int8_t hitp_delta;
static uint8_t g_room; /* номер ТЕКУЩЕЙ комнаты (для room_modif пик/ворот) */
static uint8_t *g_fg; /* fg[30], row*10+col (МУТАБЕЛЬНАЯ — loose→empty) */
static uint8_t g_curr_tile; /* = curr_tile2: тайл последнего get_tile */
static uint8_t g_obj_xl; /* позиция внутри тайла (get_tile_div_mod) */
static uint8_t grab_timer; /* после зацепа (12): блокирует climb-up */
/* Состояние edge-триггера бампа (порт prev_coll_flags, seg004): колонка, для
* которой сохранены флаги перекрытия, и сами флаги (бит0 = левая грань стены,
* бит1 = правая). BUMP_COL_NONE данных нет (смена комнаты/старт). */
#define BUMP_COL_NONE ((int8_t)-128)
static int8_t bump_prev_col;
static uint8_t bump_prev_flags;
/* Состояние edge-триггера бампа — массивы флагов перекрытия по колонкам
* (порт prev_coll_flags/curr_row_coll_flags, seg004); объявлены ниже, рядом
* с check_collisions. Сброс при смене комнаты coll_prev_row. */
static void pop_coll_invalidate(void);
/* Связи комнаты (0 = нет соседа) + кромки швов: g_lcol[0..2] = col9 левого
* соседа (за col0), g_lcol[3..5] = его col8 (колонка 2); g_rcol симметрично
* (col0 / col1 правого соседа, колонки 10 и 11). Для коллизии на швах
@@ -156,7 +175,27 @@ void pop_map_set(uint8_t *fg) __banked { g_fg = fg; }
void pop_map_set_room(uint8_t room) __banked { g_room = room; }
void pop_kid_hp_reset(void) __banked { hitp_curr = 3; pop_kid_dead = 0; }
/* do_startpos (seg003:0160): HP уровня = hitp_beg_lev, а не константа —
* иначе большая склянка теряется на границе уровня. */
void pop_kid_hp_reset(void) __banked
{
hitp_curr = hitp_max = hitp_beg_lev;
hitp_delta = 0;
pop_kid_dead = 0;
}
/* init_game (seg003:0043): начало ИГРЫ, а не уровня — стартовое HP. */
void pop_kid_hp_newgame(void) __banked { hitp_beg_lev = POP_START_HITP; }
/* add_life (seg000:11EC) + set_health_life (seg000:1200): большая склянка
* поднимает ПОТОЛОК HP (до max_hitp_allowed = 10) и доливает до него. */
static void pop_kid_add_life(void)
{
uint8_t hpmax = (uint8_t)(hitp_max + 1);
if (hpmax > POP_MAX_HITP) hpmax = POP_MAX_HITP;
hitp_max = hpmax;
hitp_delta = (int8_t)(hitp_max - hitp_curr);
}
/* take_hp (seg006:06BD, только Kid): count>=hitp → смерть (return 1). */
static uint8_t take_hp(uint8_t count)
@@ -175,7 +214,7 @@ void pop_map_set_edges(uint8_t left, uint8_t right, uint8_t up, uint8_t down,
/* смена комнаты — колонки перепроецированы, прошлые флаги перекрытия
* недействительны (в оригинале prev_coll_room != curr_row_coll_room и
* бампа на кадре входа нет). */
bump_prev_col = BUMP_COL_NONE;
pop_coll_invalidate();
}
static uint8_t get_tile(int8_t col, int8_t row)
@@ -202,6 +241,16 @@ static uint8_t get_tile(int8_t col, int8_t row)
return g_curr_tile;
}
/* Тайл ТЕКУЩЕЙ комнаты по клетке — наружу (порт get_tile(room,col,row) для
* случая room == текущая). Нужен лучу видимости стража
* (check_can_guard_see_kid, seg003:688): тот идёт по ряду между Кидом и
* стражем, а оба по условию в одной (= отрисованной) комнате. Колонки за
* пределами 0..9 get_tile разрешает через швы здесь не используются. */
uint8_t pop_tile_at(int8_t col, int8_t row) __banked
{
return get_tile(col, row);
}
/* can_bump_into_gate (seg004:373): ОТКРЫТЫЕ ворота проходимы — Kid не бампит и
* идёт сквозь них (на шве уходит в соседнюю комнату; внутри комнаты просто
* проходит по полу под поднятыми барами). ВАЖНО: ворота остаются тайлом 4 (в
@@ -321,6 +370,11 @@ static void determine_col(void)
Kid.curr_col = get_tile_div_mod_m7(dx_weight());
}
/* determine_col наружу — для pop_load_fram_det_col (pop_kid): порт связки
* load_frame + determine_col (seg006:0144), которую оригинал зовёт перед
* control(). См. разбор там. */
void pop_determine_col(void) __banked { determine_col(); }
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
{
@@ -434,7 +488,22 @@ static void land(void)
* приложение рисует «брызги» И даёт КРАСНУЮ вспышку фона (flash_if_hurt,
* seg003:0AFC). */
if (Kid.fall_y < 22) {
/* seg005:176 — ВЕТКА С МЕЧОМ, и она не косметика. seq_17_soft_land
* заканчивается ВЕЧНЫМ циклом на кадре 109 (`jmp softland_crouch` в
* seqtbl), из которого выводит только control_crouched(). А
* control() при `Char.sword == sword_2_drawn` до него не доходит:
* управление уходит в control_with_sword, который знает лишь кадр
* 171 (стойка с мечом). То есть Кид, упавший на этаж С ВЫНУТЫМ
* МЕЧОМ, в этот присед садится НАВСЕГДА ровно то, что поймали на
* приёмке уровня 2 (BUG-LAND-SWORD-1). Оригинал такой позы не
* допускает: с мечом приземление даёт боевую стойку. */
if (Kid.sword == SWORD_2_DRAWN) {
seq = SEQ_63_ACTIVE_AFTER_FALL;
} else {
seq = SEQ_17_SOFT_LAND;
}
/* seg005:185 — мягкое приземление слышно (звука нет, флаг есть). */
is_guard_notice = 1;
} else {
uint8_t deadly = (uint8_t)(Kid.fall_y >= 33); /* СНЯТЬ до обнуления */
pop_kid_hurt = 1;
@@ -447,6 +516,7 @@ static void land(void)
pop_kid_dead = 1;
return;
}
is_guard_notice = 1; /* seg005:195 */
seq = SEQ_20_MEDIUM_LAND;
}
Kid.fall_x = Kid.fall_y = 0;
@@ -458,11 +528,34 @@ static void land(void)
static void start_fall(void)
{
uint8_t frame = Kid.frame, seq, tile;
/* seg006:1044 первым делом убирает меч в ножны: дальше падением рулит
* не боевой диспетчер, а обычный. Без этого Kid летит «с клинком», и
* control_with_sword разбирает кадры падения как боевые. */
Kid.sword = SWORD_0_SHEATHED;
inc_curr_row();
/* start_chompers() — чомперов ещё нет (L3-CHOMP). */
/* seg006:1044 start_fall: frame 9 -> seq_7, 13 -> seq_19 (лишний dx(1)) */
if (frame == 13) seq = SEQ_19_FALL;
else if (frame == 26) seq = SEQ_18_FALL_STANDJUMP;
else if (frame == 44) seq = SEQ_21_FALL_RUNJUMP;
else if (frame >= 81 && frame < 86) {
/* сорвался, приземляясь после прыжка вверх: сдвиг ВПЕРЁД на 5 */
seq = SEQ_19_FALL;
Kid.x = (uint8_t)char_dx_forward(5);
pop_load_fram_det_col();
}
else if (frame >= 150 && frame < 180) {
/* Кадры С МЕЧОМ (150..179) — отступил в провал. У оригинала это
* ОТДЕЛЬНАЯ последовательность seq_81 (fightfall): падение строго
* вниз (set_fall(0,15)), а не seq_7 с дрейфом fall_x=1 из-за
* дрейфа наш Kid уезжал на ~тайл в сторону за время падения.
* Плюс сдвиг на 5 назад, если стоит у самой кромки лицом влево.
* Ветка стража (seq_82/83) не нужна: start_fall только Kid.
* TODO: droppedout=1 (guard_follows_kid_down, guards.c:296). */
if (Kid.direction < 0 && distance_to_edge_weight() <= 7)
Kid.x = (uint8_t)char_dx_forward(-5);
seq = SEQ_81_FIGHTFALL;
}
else seq = SEQ_7_FALL; /* frame 9 + stand/step/crouch */
kid_set_seq(seq);
pop_kid_play();
@@ -537,6 +630,70 @@ static void check_grab(void)
}
}
static void make_loose_fall(int pos, uint8_t modifier);
/* check_grab_run_jump — ЗАЦЕП ПРЯМО В ПРЫЖКЕ, при Shift, зажатом ЗАРАНЕЕ.
*
* ВАЖНО: в оригинальной игре этого НЕТ. Это улучшение SDLPoP
* (`USE_JUMP_GRAB` + `fixes->enable_jump_grab`, seg006:1228), и там оно
* выключено, пока не включены fixes-and-enhancements. Ванильное поведение:
* зацепиться можно только в НАЧАЛЕ ПАДЕНИЯ (check_grab, кадры 102..105)
* то есть Shift надо успеть нажать уже в полёте. Пользователь ловил ровно
* это и просил «чтобы работало и при заранее зажатом Shift», поэтому
* улучшение портировано, но за выключателем POP_ENABLE_JUMP_GRAB
* (pop_tune.h): игровая сборка сможет вернуться к оригиналу.
*
* Условия оригинала: действие прыжок, кадр 22..23 (прыжок с места) или
* 39..41 (разбег-прыжок), «вперёд» ОТПУЩЕНО, «вверх» зажато. Дальше та
* же проба, что у check_grab, но персонаж не падает, а сразу ставится на
* кромку и играет seq_9.
*
* Упрощение против SDLPoP: там колонка захвата правится, когда тайл
* оказался в ЧУЖОЙ комнате (curr_room != Char.room) у нас модель
* однокомнатная, get_tile сам резолвит колонки за швом в координатах
* ТЕКУЩЕЙ комнаты, поэтому правка не нужна. Зато кнопка/плита за швом
* (grab_col вне 0..9) не сработают тайл принадлежит соседу, а
* pop_trob_modif/g_fg у нас про свою комнату; такие случаи пропускаем. */
static uint8_t check_grab_run_jump(void)
{
uint8_t frame = Kid.frame;
uint8_t grab_tile;
int8_t grab_col, grab_row;
/* Сам выключатель проверяют ТОЧКИ ВЫЗОВА (иначе SDCC ругается на
* недостижимый код при POP_ENABLE_JUMP_GRAB = 1). */
if (Kid.action != ACT_RUNJUMP) return 0;
if (!((frame >= 22 && frame <= 23) || (frame >= 39 && frame <= 41))) return 0;
if (control_x != POP_CONTROL_RELEASED || control_y != POP_CONTROL_HELD_UP) return 0;
if (!can_grab_front_above()) return 0;
grab_tile = g_curr_tile; /* тайл, за который взялись */
grab_col = g_infrontx;
grab_row = (int8_t)(Kid.curr_row - 1); /* СНЯТЬ до play_seq: он двигает ряд */
Kid.x = (uint8_t)(pop_x_bump[grab_col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX);
Kid.x = (uint8_t)char_dx_forward(Kid.direction < 0 ? -12 : 2);
Kid.y = (uint8_t)pop_y_land[Kid.curr_row + 1];
kid_set_seq(SEQ_9_GRAB_WHILE_JUMPING);
pop_kid_play();
determine_col();
grab_timer = 12;
/* Хвост оригинала: за что схватились — то и сработает. Нужен потому,
* что check_press на следующем кадре уже не поможет: кадр сменится на
* 81 (hangdrop), если Shift отпустят сразу. */
if (grab_col >= 0 && grab_col <= 9 && grab_row >= 0 && grab_row <= 2) {
uint8_t tp = (uint8_t)(grab_row * 10 + grab_col);
if (grab_tile == TILE_OPENER || grab_tile == TILE_CLOSER)
pop_trigger_button(g_room, tp, grab_tile, pop_trob_modif(g_room)[tp]);
else if (grab_tile == TILE_LOOSE) {
is_guard_notice = 1;
make_loose_fall(tp, 1);
}
}
return 1;
}
static void do_fall(void)
{
uint8_t nrow = (uint8_t)(Kid.curr_row + 1);
@@ -696,6 +853,47 @@ uint8_t pop_jump_up_seq(void) __banked
return jump_up_plain();
}
/* run_jump (seg005:0AA8), часть «выровнять по кромке» ------------------ *
* Оригинал НЕ отталкивается откуда попало: перед разбег-прыжком он смотрит
* на 12 тайла вперёд и, если там провал (или пика), подгоняет X так, чтобы
* толчок пришёлся ровно на кромку пола. Иначе прыжок стартует в случайной
* фазе бегового цикла а запаса у seq_4 почти нет: суммарный dx кадров
* 34..44 равен 62 px при ширине тайла 14, то есть ровно 4 колонки и меньше
* половины тайла сверху. Поэтому без выравнивания провал в ТРИ пустых
* тайла (перелёт с колонки 5 на колонку 1) становится непроходимым
* BUG-RJUMP-1, уровень 2, комнаты 1 и 9.
*
* Возврат: 1 прыгать (Kid.x уже подогнан), 0 прыжок ОТМЕНИТЬ. Отмена
* в оригинале НЕ гасит control_up: Кид бежит дальше с зажатой «вверх», и на
* следующем кадре попытка повторяется так игрок ловит нужную фазу, просто
* удерживая клавишу. */
uint8_t pop_run_jump_align(void) __banked
{
int xpos = char_dx_forward(4);
int8_t col = get_tile_div_mod_m7(xpos);
uint8_t tf;
for (tf = 0; tf < 2; tf++) {
uint8_t t;
col = (int8_t)(col + dir_front[Kid.direction + 1]);
t = get_tile(col, Kid.curr_row);
if (t != TILE_SPIKE && tile_is_floor(t)) continue; /* тут пол — дальше */
{
int adj = distance_to_edge(xpos) + TILE_SIZEX * (int)tf - TILE_SIZEX;
/* Оригинал сравнивает БЕЗЗНАКОВО:
* if ((word)adj < (word)-8 || adj >= 2) { if (adj < 128) return; adj = -3; }
* что означает ровно «adj НЕ попал в [-8,-1] прыжка нет».
* Ветка с adj = -3 недостижима: distance_to_edge [0,13] и
* tf {0,1} дают adj [-14,13], а туда нужно adj >= 128. */
if (adj < -8 || adj > -1) return 0;
Kid.x = (uint8_t)char_dx_forward((int8_t)(adj + 4));
}
break;
}
return 1;
}
/* Стена впереди достаточно близко для стопа бега? Детект (без сдвига —
* позицию держит check_bumped=in_wall). d<=2: передний край почти у грани. */
int pop_wall_ahead(void) __banked
@@ -761,6 +959,58 @@ uint8_t pop_hang_fall_seq(void) __banked
* либо спуск seq_68 через зацеп, либо отступ от передней кромки), 0 =
* приседать. Меняет Kid.x. Kid развёрнут от края (край сзади) у кромки
* пола + под соседним тайлом есть за что зацепиться спуск на этаж вниз. */
/* Ветка «войти в дверь уровня» из up_pressed (seg005:0482) вместе с
* go_up_leveldoor (seg005:0574). Дверь рядом и открыта поставить Кида в
* проём, вернуть 1 (последовательность seq_70 ставит вызывающий это его
* слой); иначе 0 и Кид не тронут.
*
* Геометрию персонажа меняем в Kid, а НЕ в Char: окно Char вокруг
* диспетчера возвращает назад ТОЛЬКО curr_seq и sword
* (pop_savekid_state), поэтому во всём порте позицию правит pop_map как
* в pop_down_action и pop_jump_up_seq. Запись в Char.x/Char.direction
* отсюда молча теряется (проверено: Кид уходил из проёма влево).
*
* Порядок проб как в оригинале и с той же семантикой `else if`: берём
* ПЕРВУЮ клетку, в которой стоит дверь (под персонажем за спиной перед
* лицом), и уже её проверяем на открытость а не ищем «первую открытую».
*
* Открытость: `modifier >= 42`, то есть створка доехала до верха. Это
* ветка fix_exit_door, а не ванильный глобал leveldoor_open: с ванильным
* условием в дверь можно войти, пока она ещё ползёт вверх, и персонаж
* проходит сквозь опущенную створку.
*
* Стартовая комната исключена (`level.start_room != drawn_room`): в первой
* комнате уровня дверь та, через которую вошли, и обратно в неё нельзя. */
uint8_t pop_leveldoor_enter(void) __banked
{
int8_t cols[3];
uint8_t i;
if (pop_level_start_room() == g_room) return 0;
cols[0] = Kid.curr_col;
cols[1] = (int8_t)(dir_behind[Kid.direction + 1] + Kid.curr_col);
cols[2] = (int8_t)(dir_front[Kid.direction + 1] + Kid.curr_col);
for (i = 0; i < 3; i++) {
if (get_tile(cols[i], Kid.curr_row) != TILE_LEVELDOOR_LEFT)
continue;
/* дверь найдена — дальше решает только её modif (см. выше) */
if (cols[i] < 0 || cols[i] > 9) return 0; /* дверь в шве: не наша */
if (pop_trob_modif(g_room)[Kid.curr_row * 10 + cols[i]] < 42)
return 0;
/* go_up_leveldoor (seg005:0574): встать РОВНО в проём и развернуться
* ВЛЕВО. Направление тут не косметика: все DX последовательности
* seq_70 ОТРИЦАТЕЛЬНЫЕ (5,4,4,2,3,1,3,1,2), а
* char_dx_forward при взгляде влево меняет им знак то есть именно
* dir_FF_left и гонит персонажа ВПРАВО-ВВЕРХ по лестнице. При
* взгляде вправо тот же seq уводит его влево, ИЗ проёма. */
Kid.x = (uint8_t)(pop_x_bump[cols[i] + FIRST_ONSCREEN_COLUMN] + 10);
Kid.direction = -1;
return 1;
}
return 0;
}
uint8_t pop_down_action(void) __banked
{
if (!tile_is_floor(get_tile_infrontof_char()) && distance_to_edge_weight() < 3) {
@@ -793,6 +1043,9 @@ uint8_t pop_down_action(void) __banked
uint8_t pop_have_sword;
uint8_t pop_item_taken; /* 0 = нет; иначе tilepos+1 поднятого */
uint8_t pop_flash_time; /* кадров вспышки фона (flash_time) */
/* Цвет вспышки: 0 = ярко-жёлтая (меч), 1 = красная (зелье). В оригинале это
* flash_color; нам хватает двух значений, которые реально встречаются. */
uint8_t pop_flash_red;
static int8_t pickup_obj_type; /* -1 = меч (порт pickup_obj_type) */
/* do_pickup (seg006:1671): стереть предмет с карты (тайл → пол) и запомнить
@@ -801,6 +1054,13 @@ static void do_pickup(int8_t obj_type, uint8_t tilepos)
{
pickup_obj_type = obj_type;
g_fg[tilepos] = TILE_FLOOR;
/* curr_room_modif[curr_tilepos] = 0 (seg006:1676): у зелья в модификаторе
* лежал тип+фаза пузырька, у меча счётчик блеска. Тайл стал полом
* мусор в модификаторе не нужен (в исходное его вернёт только рестарт). */
{
uint8_t *m = pop_trob_modif(g_room);
if (m) m[tilepos] = 0;
}
pop_item_taken = (uint8_t)(tilepos + 1);
}
@@ -816,7 +1076,34 @@ void pop_proc_get_object(void) __banked
* чёрное поле экрана. У нас так же (gfx_pal_set 0), см. roomtest. */
pop_flash_time = 8;
}
pickup_obj_type = 0; /* зелья (типы 1..6) не портированы */
else {
/* proc_get_object (seg006:1927), ветка зелий. Типы 3/4/6
* (перо/переворот/открыть все ворота) свойства поздних уровней,
* их портируем вместе с этими уровнями. */
switch (pickup_obj_type) {
case 1: /* здоровье: +1 HP, красная вспышка */
if (hitp_curr != hitp_max) {
hitp_delta = 1;
pop_flash_red = 1;
pop_flash_time = 2;
}
break;
case 2: /* большая склянка: +1 к ПОТОЛКУ HP */
pop_kid_add_life();
pop_flash_red = 1;
pop_flash_time = 4; /* seg006:1878 — вспышка длиннее */
break;
case 5: /* «злое» зелье: −1 HP */
hitp_delta = -1;
pop_flash_red = 1;
pop_flash_time = 2;
break;
default: /* 3 перо / 4 переворот / 6 открыть —
* TODO вместе с их уровнями (7/9) */
break;
}
}
pickup_obj_type = 0;
}
/* check_get_item + get_item (seg005:061F/073E). Возврат: 0 — предмета нет
@@ -845,9 +1132,17 @@ uint8_t pop_get_item_action(void) __banked
Kid.x = (uint8_t)char_dx_forward((int8_t)((t == TILE_POTION) - 2));
return 1;
}
if (t != TILE_SWORD) return 0; /* питьё зелий не портировано */
do_pickup(-1, (uint8_t)(Kid.curr_row * 10 + icol));
return 2;
{
uint8_t tp = (uint8_t)(Kid.curr_row * 10 + icol);
if (t == TILE_SWORD) {
do_pickup(-1, tp);
return 2; /* поднять меч (seq_91) */
}
/* Зелье: тип лежит в СТАРШИХ битах модификатора (get_item,
* seg005: do_pickup(curr_room_modif[curr_tilepos] >> 3)). */
do_pickup((int8_t)(pop_trob_modif(g_room)[tp] >> 3), tp);
return 3; /* пить (seq_78) */
}
}
/* ---- Диспетчер ----------------------------------------------------- */
@@ -864,8 +1159,25 @@ static void check_on_floor(void)
static void check_action(void)
{
uint8_t action = Kid.action, frame = Kid.frame;
/* Зацеп прямо в прыжке (улучшение SDLPoP, seg006:0914) — ДО всего
* остального: если получилось, кадр уже сменён на вис. */
if (POP_ENABLE_JUMP_GRAB && action == ACT_RUNJUMP &&
pop_ctrl_shift_held() && check_grab_run_jump())
return;
if (action == ACT_BUMPED || action == ACT_HANGSTRAIGHT) {
if (frame == 109 || (frame >= 110 && frame <= 119))
/* ТОЛЬКО кадр 109 (присед) — как в ванильном seg006:909. Кадры
* вставания 110..119 сюда добавляет ОПЦИОНАЛЬНЫЙ FIX_STAND_ON_THIN_AIR,
* и он состоит из ТРЁХ частей: (1) эта ветка, (2) отмена падения,
* если в 2 px позади ещё пол (seg006:1079), (3) ПРАВКА seqtbl
* dx(1)dx(0) и dx(4)dx(3) в standup. У нас seqtbl извлечён из
* данных оригинала, третью часть не применить, а без неё вставание
* уносит весовую точку на 4 px вперёд в стену. Именно половинчатый
* порт этого фикса ронял Кида сквозь пол: комната 5, приземление с
* уроном на щебень (2,7) у стены (2,8), вставание колонка 8 = стена
* start_fall выпадение в комнату 6 (BUG-STANDUP-1). Ванильное
* поведение (кадр 109) от этого свободно; цена «встать на воздухе»
* у самой кромки, ровно как в оригинале 1989 года. */
if (frame == 109)
check_on_floor();
} else if (action == ACT_FREEFALL) {
do_fall();
@@ -885,9 +1197,13 @@ static void bumped_fall(void)
if (Kid.action == ACT_FREEFALL) {
Kid.fall_x = 0;
} else {
kid_set_seq(SEQ_45_BUMPFALL); /* (TODO: bumped_sound) */
kid_set_seq(SEQ_45_BUMPFALL);
pop_kid_play();
}
/* bumped_sound (seg004:05F1) — ВНЕ ветвления, как в оригинале: удар о
* стену слышен и в свободном падении. Звука у нас нет, но «Кид нашумел»
* взводить обязаны см. BUG-GUARD-DEAF-1. */
is_guard_notice = 1;
}
/* bumped_floor (seg004:053B): удар о стену, когда под персонажем ПОЛ.
@@ -911,7 +1227,23 @@ static void bumped_floor(void)
return;
}
Kid.fall_y = 0;
if (Kid.action == ACT_BUMPED) return; /* уже в отскоке — не рестартить */
/* `if (Char.alive)` (seg004:0520) — у ЖИВОГО (alive = −1) сиквенс удара
* ставится ВСЕГДА, даже если персонаж уже в отскоке. Раньше здесь стоял
* наш guard `if (Kid.action == ACT_BUMPED) return;` («не рестартить») и
* он был корнем BUG-STANDUP-1:
* 1) вставание из приседа (medland) двигает Кида на 1 px в стену
* срабатывает бамп;
* 2) bumped_floor ПРИЖИМАЕТ y к полу (181), отменяя dy(2), который
* medland сделал в начале;
* 3) наш guard возвращает управление, medland продолжается и делает
* свои dy(+1), dy(+1) теперь уже ОТ уровня пола, то есть Кид
* оказывается НИЖЕ пола (y = 182);
* 4) следующий бамп видит «персонаж ниже пола» (беззнаковая разность
* заворачивается) и уводит его в bumped_fall seq_45 выпадение
* в комнату снизу.
* У оригинала шага (3) нет: seq_47 обрывает medland, и лишних dy не
* случается. */
if (!Kid.alive) return; /* труп — только выравнивание */
frame = Kid.frame;
/* Кадры прыжков и начала падения — «жёсткий» удар с приседанием
* (24/25 = standjump, 40..42 = runjump, 102..106 = start_fall). */
@@ -919,108 +1251,253 @@ static void bumped_floor(void)
(frame >= 102 && frame < 107))
kid_set_seq(SEQ_46_HARDBUMP);
else
kid_set_seq(SEQ_47_BUMP); /* (TODO: bumped_sound) */
kid_set_seq(SEQ_47_BUMP);
pop_kid_play();
is_guard_notice = 1; /* bumped_sound (seg004:05F1) */
}
/* check_bumped (seg004): если передний край ЗАШЁЛ за грань стены — выровнять
* к грани и отдать исход в bumped_floor/bumped_fall (порт bumped, seg004).
* Это даёт «шаг назад со звуком» при беге/шаге в стену вместо падения. Уже
* в отскоке (action bumped) только выравниваем, без рестарта сиквенса.
* В падении/висе не трогаем. */
/* ---- check_collisions (seg004:0004) -------------------------------- *
* ПОЛНЫЙ порт: флаги перекрытия считаются для ВСЕХ колонок ряда, а колонка
* удара выбирается обходом 90 по переходу флага 01.
*
* ПОЧЕМУ НЕ «колонка переднего края» (как было до 2026-08-03, BUG-COLL-1):
* колонка края попадает на стену лишь пока край внутри её габаритного окна,
* а окно узкое. Для решётки в col9 (x_bump[14]=184, coll_left=191, левая
* грань 191+10=201) это ровно 201..204 четыре пикселя. Беговой кадр
* сдвигает край больше чем на четыре, край перескакивает с 200 сразу на
* 205 (это уже колонка 10, соседняя комната, не стена) бампа нет, а
* check_leave тут же видит char_x_right 201 и уводит Кида СКВОЗЬ
* ЗАКРЫТУЮ РЕШЁТКУ. Флаг колонки 9 в оригинале взводится при любом
* перелёте, потому что сравнивается не колонка края, а КООРДИНАТЫ:
* 0x0F = left_wall_xpos < char_x_right_coll («в левую грань стены»)
* 0xF0 = right_wall_xpos > char_x_left_coll («в правую грань»)
* Тем же корнем лечится влёт в стену на длинном прыжке (комн. 6) и провал
* сквозь пол после приземления у стены (комн. 5).
*
* ТРИ РЯДА как в оригинале (было: один, и это оказалось BUG-JUMPWALL-1).
* Оригинал каждый кадр считает флаги для рядов row1, row, row+1, а
* move_coll_to_prev (seg004:00DF) в начале СЛЕДУЮЩЕГО кадра выбирает из них
* тот, что соответствует ряду прошлого кадра. Так «прошлые» флаги остаются
* настоящими и на кадре смены ряда.
*
* Наш прежний упрощённый вариант (prev = 3 = «уже перекрывал») подавлял бамп
* ровно на этом кадре а в падении ряд меняется чуть ли не каждый кадр, и
* переход 01 на стене приходится как раз на него. Симптом: недолетевший
* прыжок уходит СКВОЗЬ кладку по свободной траектории (ур. 3 комн. 14).
* Цена трёх рядов 3×14 get_tile на кадр вместо 14; замерено, что это
* дешевле любого из обходных вариантов, а поведение совпадает с оригиналом.
*
* Ряды вне комнаты (1 и 3) считаются штатно: get_tile сам резолвит ряд 1
* через комнату сверху (g_above), а ряд 3 отдаёт WALL то же «через край
* не бампим», что и раньше. */
/* Колонки считаем от −2 до 11, а не 0..9: колонки за швом — это тайлы
* СОСЕДНЕЙ комнаты (get_tile резолвит их через g_lcol/g_rcol), и решётка
* соседа обязана бампить так же, как своя (иначе Kid уходит сквозь
* закрытые ворота шва старый BUG-SEAM-PINGPONG). Оригинал делает то же
* самое: get_row_collision_data идёт от col(char_x_left)1 до
* col(char_x_right)+2 и резолвит комнату через find_room_of_tile, а
* координата грани (coll_tile_left_xpos) остаётся в системе ОТРИСОВАННОЙ
* комнаты как у нас pop_x_bump[col + FIRST_ONSCREEN_COLUMN]. */
#define COLL_C0 (-2) /* первая считаемая колонка */
#define COLL_N 14 /* колонки 2..11 */
#define COLL_IDX(c) ((c) - COLL_C0)
#define COLL_ROW_NONE ((int8_t)-128)
static uint8_t coll_prev[COLL_N], coll_curr[COLL_N];
static uint8_t coll_above[COLL_N], coll_below[COLL_N];
static int8_t coll_prev_row = COLL_ROW_NONE; /* ряд, к которому относится coll_prev */
/* «нет колонки»: НЕ −1, потому что −1/−2 — законные колонки шва. */
#define BUMP_NONE ((int8_t)-128)
static int8_t bump_col_l; /* bump_col_left_of_wall (Kid лицом ВПРАВО) */
static int8_t bump_col_r; /* bump_col_right_of_wall (Kid лицом ВЛЕВО) */
static int coll_xl, coll_xr; /* char_x_left_coll / char_x_right_coll */
/* Смена комнаты: колонки перепроецированы, прошлые флаги недействительны.
* В оригинале это несовпадение prev_coll_room и curr_row_coll_room
* на кадре входа бампа нет. */
static void pop_coll_invalidate(void) { coll_prev_row = COLL_ROW_NONE; }
/* set_char_collision (seg006:0723), горизонтальная часть: габарит персонажа
* в комнатных координатах. char_x_left = obj_x/2 + 58 (минус полуширина при
* взгляде вправо) это ровно наш char_x_forward_edge для переднего края. */
static void set_char_collision(void)
{
int e = char_x_forward_edge();
int wh = ((int)kid_fp_width() + 1) >> 1; /* char_width_half */
if (Kid.direction < 0) { coll_xl = e; coll_xr = e + wh; }
else { coll_xr = e; coll_xl = e - wh; }
if (kid_cur_flags() & FRAME_THIN) { coll_xl += 4; coll_xr -= 4; }
}
/* get_row_collision_data (seg004:0185) для одного ряда: бит0 = «персонаж
* достал левую грань стены колонки», бит1 = «правую». Оригинал считает
* колонки от col(char_x_left)1 до col(char_x_right)+2 и оставляет прочие
* как есть; мы считаем все четырнадцать они дешёвые (индекс в массив +
* switch), зато не остаётся протухших ячеек (FIX_COLL_FLAGS SDLPoP). */
/* move_coll_to_prev (seg004:00DF): в prev кладём флаги ряда, в котором
* персонаж был НА ПРОШЛОМ кадре. Массивы curr/above/below в этот момент
* ещё хранят прошлый кадр, поэтому нужный ряд просто выбирается из них по
* разнице рядов. Условия дословно оригинальные, включая ±3: ряд может
* «завернуться» при смене комнаты (2 -> 0 вниз, 0 -> 2 вверх), и тогда
* геометрия колонок та же, что у текущего ряда.
*
* Первый кадр (и кадр после смены комнаты, pop_coll_invalidate) прошлого
* ряда не имеет: prev = «уже перекрывал всё», бампа на нём нет. */
static void move_coll_to_prev(int8_t row)
{
const uint8_t *src;
int8_t i;
if (coll_prev_row == COLL_ROW_NONE) {
for (i = 0; i < COLL_N; i++) coll_prev[i] = 3;
return;
}
if (row == coll_prev_row || row + 3 == coll_prev_row || row - 3 == coll_prev_row)
src = coll_curr; /* ряд не менялся */
else if (row + 1 == coll_prev_row || row - 2 == coll_prev_row)
src = coll_above; /* поднялся на ряд */
else
src = coll_below; /* опустился на ряд */
for (i = 0; i < COLL_N; i++) coll_prev[i] = src[i];
}
static void get_row_collision_data(int8_t row, uint8_t *flags)
{
int8_t col;
for (col = COLL_C0; col < COLL_C0 + COLL_N; col++) {
uint8_t f = 0;
uint8_t wt = wall_type(get_tile(col, row));
if (wt) {
int coll_left = pop_x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
if (wall_dl[wt] + coll_left < coll_xr) f |= 1;
if (coll_left - wall_dr[wt] + TILE_RIGHTX > coll_xl) f |= 2;
}
flags[COLL_IDX(col)] = f;
}
}
static void check_collisions(void)
{
int8_t col;
bump_col_l = bump_col_r = BUMP_NONE;
/* seg004:0044 — на кадрах разворота коллизии не считаются вовсе. */
if (Kid.action == ACT_TURN) return;
/* Вне рядов комнаты (падение мимо пола) get_tile отдаёт WALL во всех
* колонках считать по нему нечего. Стену в падении держит
* do_fall/in_wall, выход из комнаты check_leave_below. */
if (Kid.curr_row < 0 || Kid.curr_row > 2) { coll_prev_row = COLL_ROW_NONE; return; }
set_char_collision();
move_coll_to_prev(Kid.curr_row);
coll_prev_row = Kid.curr_row;
/* Порядок важен: prev уже забран, теперь три ряда пересчитываются
* НА ЭТОТ кадр (в оригинале ровно так же, seg004:0004). */
get_row_collision_data(Kid.curr_row, coll_curr);
get_row_collision_data((int8_t)(Kid.curr_row + 1), coll_below);
get_row_collision_data((int8_t)(Kid.curr_row - 1), coll_above);
/* Обход СВЕРХУ ВНИЗ, как в оригинале (9→0): побеждает МЛАДШАЯ колонка,
* в которой флаг перешёл 01. */
for (col = COLL_C0 + COLL_N - 1; col >= COLL_C0; --col) {
uint8_t p = coll_prev[COLL_IDX(col)], c = coll_curr[COLL_IDX(col)];
if (!(p & 1) && (c & 1)) bump_col_l = col;
if (!(p & 2) && (c & 2)) bump_col_r = col;
}
}
/* is_obstacle (seg004:037E): препятствие ли тайл колонки на самом деле.
* Побочно оставляет тайл в g_curr_tile (как curr_tile2 у оригинала). */
static uint8_t is_obstacle_at_col(int8_t col)
{
uint8_t t = get_tile(col, Kid.curr_row);
if (t == TILE_POTION) return 0; /* зелье не бампит */
if (t == TILE_GATE) return (uint8_t)!gate_passable(col, Kid.curr_row);
if (t == TILE_CHOMPER) /* закрыт только при modif==2 */
return (uint8_t)(col >= 0 && col <= 9 &&
pop_trob_modif(g_room)[Kid.curr_row * 10 + col] == 2);
/* ветка «прыжок сквозь зеркало» (seg004:239) — уровень 4, не портируем */
return 1;
}
/* bumped (seg004:0448). delta_x — сдвиг персонажа В КООРДИНАТАХ (не
* «вперёд»), push_dir куда толкает: <0 влево, >=0 вправо. Чем кончится
* удар, решает тайл, на котором персонаж оказался после отжатия: сквозь
* стену/верх двери он стоит на СОСЕДНЕЙ клетке (в сторону толчка), для
* прочих препятствий (ворота, зеркало, кусаки) на клетке препятствия. */
static void bumped(int delta_x, int8_t push_dir, int8_t tcol)
{
uint8_t ct;
if (Kid.alive >= 0) return; /* труп не бампит */
Kid.x = (uint8_t)((int)Kid.x + delta_x);
determine_col();
ct = g_curr_tile; /* тайл препятствия */
if (push_dir < 0) { /* толчок ВЛЕВО */
if (ct == TILE_WALL)
ct = get_tile((int8_t)(tcol - 1), Kid.curr_row);
} else { /* толчок ВПРАВО */
if (ct == TILE_DOORTOP || ct == TILE_DOORTOP_FLOOR || ct == TILE_WALL)
ct = get_tile((int8_t)(tcol + 1), Kid.curr_row);
}
if (tile_is_floor(ct)) bumped_floor();
else bumped_fall();
determine_col();
}
/* check_bumped_look_right (seg004:030A): Kid смотрит ВПРАВО и достал ЛЕВУЮ
* грань стены отжать влево. */
static void check_bumped_look_right(void)
{
int8_t col = bump_col_l;
uint8_t wt;
int coll_left;
if (Kid.direction != 0) return; /* (меча в руке у нас нет) */
if (!is_obstacle_at_col(col)) return;
/* Прежде чем удариться о стену — попытка зацепиться за её верх
* (seg004:0205, то же улучшение). Не вышло тайл препятствия надо
* перечитать: проба зацепа сбила g_curr_tile. */
if (POP_ENABLE_JUMP_GRAB && pop_ctrl_shift_held()) {
if (check_grab_run_jump()) return;
if (!is_obstacle_at_col(col)) return;
}
wt = wall_type(g_curr_tile);
if (!wt) return;
coll_left = pop_x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
bumped(wall_dl[wt] + coll_left - coll_xr, -1, col);
}
/* check_bumped_look_left (seg004:02D2): Kid смотрит ВЛЕВО и достал ПРАВУЮ
* грань стены отжать вправо. */
static void check_bumped_look_left(void)
{
int8_t col = bump_col_r;
uint8_t wt;
int coll_left;
if (Kid.direction >= 0) return;
if (!is_obstacle_at_col(col)) return;
if (POP_ENABLE_JUMP_GRAB && pop_ctrl_shift_held()) { /* seg004:0186 */
if (check_grab_run_jump()) return;
if (!is_obstacle_at_col(col)) return;
}
wt = wall_type(g_curr_tile);
if (!wt) return;
coll_left = pop_x_bump[col + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
bumped(coll_left - wall_dr[wt] + TILE_RIGHTX - coll_xl, 0, col);
}
/* check_bumped (seg004:029D). */
static void check_bumped(void)
{
uint8_t t, wt; int8_t tcol; int d;
/* Не бампим в падении/висе/подтягивании (seg004 check_bumped guard'ы:
* action hang_climb/hang_straight + кадры подъёма 135..148 иначе
* climb-up у стены прерывается отскоком). FREEFALL наш guard: стену
* в падении держит do_fall/in_wall. */
if (Kid.action == ACT_FREEFALL || Kid.action == ACT_HANGCLIMB ||
Kid.action == ACT_HANGSTRAIGHT || Kid.action == ACT_MIDAIR) return;
/* Гарды РОВНО как в оригинале: вис и подтягивание. ПАДЕНИЕ И ПОЛЁТ НЕ
* ИСКЛЮЧЕНЫ bumped_fall (seg004:04E4) специально разбирает случай
* actions_4_in_freefall (гасит fall_x и отжимает на 4). Наш прежний
* guard `action == FREEFALL || MIDAIR -> return` и был вторым корнем
* BUG-COLL-1: длинный прыжок в стену (комната 6, между (0,6) и (0,7))
* ничем не останавливался, и Кид оказывался ВНУТРИ кладки. */
if (Kid.action == ACT_HANGCLIMB || Kid.action == ACT_HANGSTRAIGHT) return;
if (Kid.frame >= 135 && Kid.frame < 149) return; /* кадры подъёма/спуска */
/* Кадры виса 87..99: в спуске (seq_68) кадр frame_91 идёт ДО act(hang_climb),
* т.е. action ещё midair без этого guard'а bump у стены рвёт цепочку
* hang->hang_fall->seq_11 (правильное приземление). */
if (Kid.frame >= 87 && Kid.frame < 100) return;
/* Вне рядов комнаты (падение мимо пола): get_tile даёт WALL, а
* dist_from_wall_forward/pop_x_bump[] мусор с большим отриц. сдвигом
* underflow X и уход в стену. fell_out ловит do_fall. */
if (Kid.curr_row < 0 || Kid.curr_row > 2) return;
/* seg004 check_collisions: на кадрах разворота коллизии НЕ считаются
* (bump_col_* сбрасываются и функция выходит) bump'а в развороте нет. */
if (Kid.action == ACT_TURN) return;
/* Стена ищется по колонке, в которую упирается ПЕРЕДНИЙ КРАЙ персонажа
* (порт seg004: bump_col_left/right_of_wall колонка, где край пересёк
* грань; НЕ curr_col!). Через curr_col было ДВА расхождения: (1) весовая
* точка кадра заезжает за грань раньше края, curr_col становился 1 на
* шве, ворота оказывались «под персонажем» и гард односторонней стены их
* снимал Kid уходил в соседнюю комнату СКВОЗЬ ОПУЩЕННУЮ РЕШЁТКУ;
* (2) тот же гард был нужен только из-за отсутствия проверки разворота
* (см. выше). Колонка переднего края даёт и то, и другое даром: стоя
* СПРАВА от ворот лицом вправо, край в соседней колонке ворота не
* бампят; заехав краем за их грань лицом влево бампят. */
tcol = get_tile_div_mod_m7(char_front_coll());
t = get_tile(tcol, Kid.curr_row);
wt = wall_type(t);
/* EDGE-триггер (порт check_collisions + get_row_collision_data, seg004:0004).
* Оригинал считает флаги перекрытия от ГАБАРИТА персонажа, БЕЗ направления:
* 0x0F = left_wall_xpos < char_x_right_coll («в левую грань стены»)
* 0xF0 = right_wall_xpos > char_x_left_coll («в правую грань»)
* и зовёт bumped() ТОЛЬКО когда нужный флаг перешёл 0 -> 1 (взгляд вправо
* смотрит на 0x0F, влево на 0xF0). Без этого проверка «уровнем» ловит
* ЛЮБОЕ перекрытие: Kid, стоящий В проёме опущенной решётки, при развороте
* на месте получал ложный бамп и выпихивался сквозь неё в соседнюю
* комнату. С флагами от габарита разворот их не меняет бампа нет. */
{
uint8_t f = 0, prev, need;
if (wt) {
int coll_left = pop_x_bump[tcol + FIRST_ONSCREEN_COLUMN] + TILE_MIDX;
int lw = wall_dl[wt] + coll_left; /* get_left_wall_xpos */
int rw = coll_left - wall_dr[wt] + TILE_RIGHTX; /* get_right_wall_xpos */
int e = char_x_forward_edge(), wh = ((int)kid_fp_width() + 1) >> 1;
int xl, xr;
if (Kid.direction < 0) { xl = e; xr = e + wh; } /* габарит персонажа */
else { xr = e; xl = e - wh; }
if (kid_cur_flags() & FRAME_THIN) { xl += 4; xr -= 4; }
if (lw < xr) f |= 1;
if (rw > xl) f |= 2;
}
/* прошлый кадр: та же колонка — её флаги; другая — считаем, что не
* перекрывали; «нет данных» (смена комнаты) считаем, что перекрывали
* всё, чтобы на кадре входа бампа не было (в оригинале это даёт
* несовпадение prev_coll_room и curr_row_coll_room). */
prev = (bump_prev_col == BUMP_COL_NONE) ? 3u
: (bump_prev_col == tcol ? bump_prev_flags : 0u);
bump_prev_col = tcol;
bump_prev_flags = f;
if (!wt) return;
need = (Kid.direction < 0) ? 2u : 1u;
if (!(f & need) || (prev & need)) return; /* не перекрыл / уже перекрывал */
}
if (t == TILE_GATE && gate_passable(tcol, Kid.curr_row))
return; /* открытые ворота (шов/внутри) — Kid проходит (не бамп) */
d = dist_from_wall_forward(t, tcol);
if (d >= 0) return; /* ещё не зашёл за грань */
Kid.x = (uint8_t)char_dx_forward((int8_t)d); /* край -> грань */
determine_col();
/* Хвост bumped (seg004:04E4): чем КОНЧИТСЯ удар, решает тайл, на котором
* персонаж оказался после отжатия от грани. Сквозь стену/верх двери он
* стоит на СОСЕДНЕЙ клетке (в сторону толчка), для прочих препятствий
* (ворота, зеркало, кусаки) на клетке самого препятствия (у ворот пол
* есть, поэтому tile_is_floor их считает полом). */
{
uint8_t ct = t;
if (Kid.direction < 0) { /* смотрит влево => толчок ВПРАВО */
if (ct == TILE_DOORTOP || ct == TILE_DOORTOP_FLOOR || ct == TILE_WALL)
ct = get_tile((int8_t)(tcol + 1), Kid.curr_row);
} else { /* смотрит вправо => толчок ВЛЕВО */
if (ct == TILE_WALL)
ct = get_tile((int8_t)(tcol - 1), Kid.curr_row);
}
if (tile_is_floor(ct)) bumped_floor();
else bumped_fall();
}
determine_col();
if (bump_col_l != BUMP_NONE) check_bumped_look_right();
else if (bump_col_r != BUMP_NONE) check_bumped_look_left();
}
/* ---- Loose floors (seg007) ---------------------------------------- *
@@ -1066,10 +1543,13 @@ static void check_press(void)
int8_t c = Kid.curr_col;
if (g_above && g_link_u && c >= 0 && c <= 9 &&
(g_above[c] & 0x1F) == TILE_LOOSE && !(g_above[c] & 0x20) &&
(int8_t)pop_ceil_modif[c] <= 0)
(int8_t)pop_ceil_modif[c] <= 0) {
pop_ceil_modif[c] = 1; /* make_loose_fall(1) */
is_guard_notice = 1; /* seg006:1734 */
}
} else if (get_tile_above_char() == TILE_LOOSE) {
make_loose_fall((Kid.curr_row - 1) * 10 + Kid.curr_col, 1);
is_guard_notice = 1; /* seg006:1734 */
}
return;
}
@@ -1083,8 +1563,10 @@ static void check_press(void)
uint8_t tp = (uint8_t)(Kid.curr_row * 10 + Kid.curr_col);
if (t == TILE_OPENER || t == TILE_CLOSER)
pop_trigger_button(g_room, tp, t, pop_trob_modif(g_room)[tp]);
else if (t == TILE_LOOSE)
else if (t == TILE_LOOSE) {
make_loose_fall(tp, 1);
is_guard_notice = 1; /* seg006:1734 */
}
}
}
@@ -1124,7 +1606,8 @@ void pop_check_knock(void) __banked
/* Сброс состояния loose (при смене комнаты): pop_loose_modif/bake/rest
* индексируются позицией тайла и не должны утечь из старой комнаты в новую.
* Плюс останавливает падающий кусок (pop_bg). */
* Уже ПАДАЮЩИЕ куски при этом не трогаем они привязаны к своей комнате и
* должны долететь (do_mobs, seg007:1063; см. pop_loose_mob_room_changed). */
void pop_loose_reset(void) __banked
{
int i;
@@ -1137,7 +1620,7 @@ void pop_loose_reset(void) __banked
pop_ceil_fell = 0;
pop_debris_at = 0;
pop_loose_landed = 0;
pop_loose_mob_reset();
pop_loose_mob_room_changed();
}
/* fell_on_your_head (seg007:1205): плита прилетела Киду по голове. В беге
@@ -1281,6 +1764,21 @@ void pop_loose_tick(void) __banked
* check_spike_below колонки берутся get_tile_div_mod БЕЗ 7.
*
* Возврат: КОМНАТНЫЙ y верхней границы (0 = клипа нет). */
/* Вторая половина ветки двери уровня из clip_char (seg006:1231):
* obj_clip_right = leveldoor_right ЭКРАННЫЙ X, правее которого спрайт не
* рисуется. 0 = обрезки нет.
*
* Почему обрезка, а не fore-слой: створка и косяк уходят в оригинале
* ЦЕЛИКОМ в backtable (draw_leveldoor, все add_backtable), то есть рисуются
* ПОД персонажем и перекрыть его не могут. Единственный способ спрятать
* поднимающегося по лестнице срезать сам спрайт. */
int pop_clip_char_right(void) __banked
{
if (Kid.frame >= 224 && Kid.frame < 229)
return pop_leveldoor_right;
return 0;
}
int pop_clip_char_top(int obj_x, int obj_y, uint16_t w, uint16_t h) __banked
{
static const int16_t y_clip[5] = { -60, 3, 66, 129, 192 };
@@ -1290,6 +1788,14 @@ int pop_clip_char_top(int obj_x, int obj_y, uint16_t w, uint16_t h) __banked
int8_t cL, cR, trow, clip_row;
uint8_t t;
/* Кадры подъёма по лестнице двери уровня (clip_char, seg006:1231):
* персонаж режется по низу поднятой створки. ВНИМАНИЕ: комментарий в
* SDLPoP говорит «frames 217..228», а КОД проверяет
* `>= frame_224_exit_stairs_8`, то есть 224..228 портируем по коду.
* Правую кромку отдаёт pop_clip_char_right (парная половина ветки). */
if (Kid.frame >= 224 && Kid.frame < 229)
return pop_leveldoor_ybottom + 1;
if (Kid.direction >= 0) xl -= wh; /* лицом вправо: левый край внутрь */
xr = xl + wh; /* char_x_right */
top_y = obj_y - (int)h + 1; /* char_top_y */
@@ -1489,6 +1995,7 @@ void pop_phys_tick(void) __banked
fall_accel();
fall_speed();
determine_col();
check_collisions(); /* seg004: флаги перекрытия по колонкам ряда */
check_bumped(); /* удержать у стены до check_action (порядок PoP) */
check_action();
check_press(); /* seg006: стойка/пробой loose → make_loose_fall */
+40 -1
View File
@@ -30,13 +30,33 @@ void pop_map_set_edges(uint8_t left, uint8_t right, uint8_t up, uint8_t down,
* Звать в enter_room. */
void pop_map_set_room(uint8_t room) __banked;
/* Тайл-код текущей комнаты по клетке (col 0..9, row 0..2) — порт
* get_tile(room,col,row) для текущей комнаты. Нужен лучу видимости стража
* (guards.c): он идёт по ряду между Кидом и стражем. */
uint8_t pop_tile_at(int8_t col, int8_t row) __banked;
/* determine_col (seg006:014D): Kid.curr_col = m7(dx_weight()). Наружу — для
* pop_load_fram_det_col (pop_kid.c), порт load_fram_det_col. */
void pop_determine_col(void) __banked;
/* HP/смерть. pop_kid_dead=1 когда Kid убит (пики); hitp_curr — текущее HP.
* pop_kid_hp_reset() ставит старт HP и снимает смерть (звать в kid_init/
* респавн). */
extern uint8_t pop_kid_dead;
extern uint8_t hitp_curr;
extern uint8_t hitp_max; /* стартовое HP (для индикатора) */
extern int8_t hitp_delta; /* урон за кадр от меча; применяет pop_do_delta_hp */
void pop_kid_hp_reset(void) __banked;
/* hitp_beg_lev (seg003): HP, с которым НАЧАТ текущий уровень. Рестарт
* уровня (смерть/выпадение) откатывает HP ровно к нему; пройденный уровень
* подтягивает его к hitp_max (play_level_2, seg003:410) так «жизнь» из
* большой склянки переезжает на следующий уровень. Правит главный цикл. */
extern uint8_t hitp_beg_lev;
#define POP_START_HITP 3 /* custom->start_hitp */
#define POP_MAX_HITP 10 /* custom->max_hitp_allowed */
void pop_kid_hp_newgame(void) __banked; /* начало ИГРЫ: hitp_beg_lev = 3 */
/* Предметы (seg005 check_get_item/get_item, seg006 do_pickup/proc_get_object).
* pop_have_sword «меч у Кида» (порт have_sword). pop_item_taken 0 или
* tilepos+1 только что поднятого предмета: вызывающий обязан запечь тайл
@@ -46,7 +66,9 @@ extern uint8_t pop_item_taken;
/* Вспышка фона (do_flash/set_bg_attr, seg001:0908): счётчик кадров, пока
* НУЛЕВАЯ запись палитры подменена ярко-жёлтым. Тикает вызывающий. */
extern uint8_t pop_flash_time;
uint8_t pop_get_item_action(void) __banked; /* 0 нет / 1 присесть / 2 поднять меч */
/* Цвет вспышки: 0 = ярко-жёлтая (подобран меч), 1 = красная (зелье). */
extern uint8_t pop_flash_red;
uint8_t pop_get_item_action(void) __banked; /* 0 нет / 1 присесть / 2 поднять меч / 3 пить */
void pop_proc_get_object(void) __banked; /* эффект — из play_seq (опкод GET_ITEM) */
/* Уход из комнаты ВБОК/ВВЕРХ: 0=нет; 1=left,2=right,3=up (= side+1 для
@@ -113,6 +135,10 @@ void pop_phys_tick(void) __banked;
* Возврат: КОМНАТНЫЙ y границы, 0 = клипа нет. Зовёт kid_draw. */
int pop_clip_char_top(int obj_x, int obj_y, uint16_t w, uint16_t h) __banked;
/* Парная половина clip_char: ЭКРАННЫЙ X, правее которого спрайт не рисуется
* (правый косяк двери уровня в кадрах подъёма 224..228). 0 = обрезки нет. */
int pop_clip_char_right(void) __banked;
/* Тип препятствия/края ВПЕРЕДИ по направлению (pop_edge_type после
* pop_edge_distance): для control (бежать / осторожный шаг / стоп у стены). */
#define EDGE_CLOSER 0 /* край уступа над ямой (дистанция = до кромки) */
@@ -128,6 +154,13 @@ uint8_t pop_edge_type(void) __banked;
* control_running (чистый стоп у стены). */
int pop_wall_ahead(void) __banked;
/* Разбег-прыжок: выравнивание по кромке пола (run_jump, seg005:0AA8).
* Смотрит на 12 тайла вперёд; если там провал/пика подгоняет Kid.x под
* толчок ровно с кромки. 1 = прыгать, 0 = ОТМЕНИТЬ прыжок (Кид не в той
* фазе относительно кромки; control_up гасить НЕЛЬЗЯ попытка повторится
* на следующем кадре). Без этого провал в три тайла непроходим. */
uint8_t pop_run_jump_align(void) __banked;
/* Прыжок вверх в стойке (↑): seq чистого прыжка (seq_28/seq_14) ЛИБО
* прыжка-с-зацепом за уступ выше (seq_8/24/16) + выравнивание Kid.x.
* check_jump_up/grab_up (K4.4). control_up гасит caller. */
@@ -142,4 +175,10 @@ int8_t pop_hang_wall(void) __banked; /* Shift в висе: 1=у сте
uint8_t pop_hang_fall_seq(void) __banked; /* seq_23 / seq_11 (сорваться), +Kid.x */
uint8_t pop_down_action(void) __banked; /* 1=обработано(спуск/отступ), 0=присед */
/* Дверь уровня рядом и ОТКРЫТА ПОЛНОСТЬЮ? → поставить Кида в проём (x +
* разворот влево, go_up_leveldoor) и вернуть 1; иначе 0, Кид не тронут.
* Ветка двери уровня из up_pressed (seg005:0482). Последовательность
* seq_70 ставит вызывающий (pop_ctrl) тайлы наши, управление его. */
uint8_t pop_leveldoor_enter(void) __banked;
#endif
+1
View File
@@ -59,6 +59,7 @@ void pop_redraw_needed(void)
case POP_RD_LOOSE_GONE: pop_loose_bake_empty(row, col); break;
case POP_RD_FLOOR: pop_floor_bake(row, col); break;
case POP_RD_LEVELDOOR: pop_leveldoor_redraw(row, col); break;
case POP_RD_GATE: pop_gate_redraw(row, col); break;
default: break;
}
if (--rd_cnt[i]) left++;
+1
View File
@@ -29,6 +29,7 @@
#define POP_RD_LOOSE_GONE 3 /* loose провалился: запечь пусто */
#define POP_RD_FLOOR 4 /* пол изменился: кнопка/щебень/поднятый меч */
#define POP_RD_LEVELDOOR 5 /* створка двери уровня (ПРАВАЯ половина) */
#define POP_RD_GATE 6 /* решётка ворот (tilepos = САМИ ворота) */
/* Виды перерисовки полосы у потолка (по колонке). */
#define POP_RDA_CEIL 1 /* плита-потолок: дрожащий кадр / покой */
+45
View File
@@ -11,5 +11,50 @@
* Модуль обязан оставаться в W1/W2: его читают и главный цикл, и банки.
*/
#include "pop_state.h"
#include "pop_ctrl.h" /* объявления шины control_* (живёт здесь) */
uint8_t pop_loose_landed;
/* ---- Шина управления (seg006 globals) ------------------------------ *
* Объявлены в pop_ctrl.h, а ЛЕЖАТ здесь: к ним обращаются ТРИ банка
* pop_ctrl (банк 5) пишет с клавиатуры, guards.c (банк 1) подаёт
* синтетический ввод ИИ (autocontrol_*, seg002), pop_map (банк 3) читает
* через pop_ctrl_shift_held. Сегодня оно уцелело бы и в pop_ctrl.c
* (собираем без --bank-data, данные банков остаются в _DATA), но это
* флаг сборки: включат его и шина уедет в страницу банка 5.
*
* control_x/y/shift «сырое» состояние осей ЭТОГО кадра (абсолютно:
* X LEFT/-1..RIGHT/+1); *_forward/backward/up/down/shift2 конечный
* автомат held/released/ignore (авто-повтор). */
int8_t control_x, control_y, control_shift;
int8_t control_forward, control_backward, control_up, control_down, control_shift2;
/* Чит «бессмертие» (клавиша I). Отладочный: Кид получает анимацию удара,
* но HP не теряет иначе проверять боёвку приходится с трёх попаданий. */
uint8_t pop_immortal;
/* Габариты последней нарисованной двери уровня (порт leveldoor_right /
* leveldoor_ybottom): пишет draw_leveldoor из банка pop_bg, читает
* clip_char из pop_map. См. pop_state.h. */
int pop_leveldoor_right;
int pop_leveldoor_ybottom;
/* ---- Отладочная «пустышка» для брейкпоинтов из БАНКОВ ---------------- *
* Зачем. PC-брейкпоинт видит только логический адрес, а 0xC000+ это
* окно, куда мапятся ВСЕ банки: точка на адресе банковой функции ловит
* заодно чужой код, случайно легший по тому же смещению (проверено:
* точка на start_fall из банка 3 срабатывала на каждом кадре попадала
* в pop_bg из банка 2). Условные брейкпоинты этот отладчик MAME не
* поддерживает («error in assignment expression» на `==`).
*
* Приём (идея пользователя, 2026-08-04): позвать ЭТУ функцию ровно из
* того места банкового кода, которое отлаживаем, и поставить брейкпоинт
* на неё она резидентна в W1, её адрес однозначен. По возврату из неё
* читаем что нужно. Условие «когда именно ловить» пишется обычным `if`
* в C это гибче любых выражений отладчика.
*
* Счётчик нужен, чтобы вызов не выкинул оптимизатор; заодно видно, сколько
* раз точка прошла, если ловим не останавливаясь. */
uint8_t pop_dbg_hits;
void pop_dbg_trap(void) { pop_dbg_hits++; }
+21
View File
@@ -14,4 +14,25 @@
* разбирает логика loose-полов (pop_map, W1/W2). */
extern uint8_t pop_loose_landed;
/* Чит «бессмертие» (I): Кида бьют, но HP не убавляется. Читают пути урона
* (боёвка в банке, пики в pop_map), переключает главный цикл. */
extern uint8_t pop_immortal;
/* Габариты ПОСЛЕДНЕЙ нарисованной двери уровня — порт глобалов
* leveldoor_right / leveldoor_ybottom (seg008 draw_leveldoor). Их ставит
* отрисовка (банк pop_bg), а читает clip_char (pop_map, W1/W2): кадры
* подъёма по лестнице (224..228) обрезаются по правому косяку проёма и по
* низу поднятой створки. Дверь рисуется ПОД персонажем, поэтому перекрыть
* его не может оригинал именно режет спрайт.
* right экранный X правой кромки; ybottom room-space Y (без POP_YOFF).
* 0 = двери в отрисованной комнате нет. */
extern int pop_leveldoor_right;
extern int pop_leveldoor_ybottom;
/* Отладочная «пустышка» для брейкпоинтов из банков — см. pop_state.c.
* Звать из отлаживаемого места под нужным `if`, брейкпоинт ставить на
* _pop_dbg_trap (резидент W1, адрес однозначен). */
extern uint8_t pop_dbg_hits;
void pop_dbg_trap(void);
#endif
+55 -17
View File
@@ -13,6 +13,8 @@
* пометки, поэтому pop_process_trobs единственная функция модуля, которую
* нельзя звать из __banked-кода. */
#include "pop_bg.h"
#include "pop_guard.h" /* is_guard_notice — нажатая кнопка слышна стражу */
#include "pop_tune.h" /* POP_DBG_GATE_HOLD — отладочное растягивание окна */
/* Коды тайлов (fg & 0x1F), которые анимируются. */
#define TILE_SPIKE 0x02
@@ -21,6 +23,7 @@
#define TILE_DEBRIS 0x0E /* button_type для «насовсем открыть» (died_on_button) */
#define TILE_OPENER 0x0F
#define TILE_POTION 0x0A
#define TILE_LOOSE 0x0B /* проваливающийся пол (фаза тряски в modif) */
#define TILE_SWORD 0x16 /* меч-предмет на полу */
#define TILE_LEVELDOOR 0x10 /* левая половина двери уровня (на ней modif) */
#define TILE_TORCH 0x13
@@ -47,12 +50,12 @@ static uint8_t trob_drawn[POP_ROOMTILES];
/* prandom (seg009.c:321, 32-бит LCG) — нужен для стартовой фазы факела и
* выбора следующего кадра пламени. */
static unsigned long trob_seed;
static pop_rnd_t trob_seed;
#define trob_prandom(maxv) pop_prandom(&trob_seed, (maxv))
void pop_trob_reset(void)
void pop_trob_reset(void) __banked
{
uint8_t i;
for (i = 0; i < POP_NROOMS; i++) room_seen[i] = 0;
@@ -61,18 +64,37 @@ void pop_trob_reset(void)
trobs_count = 0;
}
uint8_t *pop_trob_modif(uint8_t room)
uint8_t *pop_trob_modif(uint8_t room) __banked
{
if (room < 1 || room > POP_NROOMS) return 0;
if (!room_seen[room - 1]) {
uint8_t i;
pop_level_room_bg(room, room_modif[room - 1]);
/* seg009 load_level: у ЗЕЛЬЯ модификатор сдвигается влево на 3 —
* старшие биты = тип зелья, младшие 3 = фаза пузырька. */
/* load_alter_mod (seg008:198E): модификатор В ФАЙЛЕ уровня и
* модификатор В РАНТАЙМЕ разные величины, оригинал переводит их
* при загрузке. Порт того, что нас касается:
*
* ворота 1 = «открыты» (Table 8 спецификации DAT) -> 188, то есть
* рабочая высота подъёма; ЛЮБОЕ другое значение -> 0
* (закрыты). Без этого перевода ворота, которые в
* уровне открыты, стартуют закрытыми, и кнопка-closer
* рядом с ними теряет смысл (ур. 2, комн. 13).
* loose -> 0: фаза тряски всегда начинается с нуля.
* зелье -> <<3: старшие биты = тип, младшие 3 = фаза пузырька.
*
* Ветка стен (blue-line/связи кладки) НЕ портируется намеренно: у
* нас pop_bg считает связи по типам СОСЕДЕЙ прямо при отрисовке
* (wall_modifier), сохранённый модификатор стены не читается вовсе. */
pop_level_access_begin();
for (i = 0; i < POP_ROOMTILES; i++)
if ((pop_level_tile_raw(room, i) & 0x1F) == TILE_POTION)
room_modif[room - 1][i] = (uint8_t)(room_modif[room - 1][i] << 3);
for (i = 0; i < POP_ROOMTILES; i++) {
uint8_t *m = &room_modif[room - 1][i];
switch (pop_level_tile_raw(room, i) & 0x1F) {
case TILE_GATE: *m = (uint8_t)((*m == 1) ? 188 : 0); break;
case TILE_LOOSE: *m = 0; break;
case TILE_POTION: *m = (uint8_t)(*m << 3); break;
default: break;
}
}
pop_level_access_end();
room_seen[room - 1] = 1;
}
@@ -87,7 +109,7 @@ static int find_trob(uint8_t room, uint8_t tilepos)
return -1;
}
void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type)
void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type) __banked
{
int f = find_trob(room, tilepos);
if (f >= 0) { trobs[f].type = type; return; }
@@ -98,7 +120,7 @@ void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type)
trobs_count++;
}
void pop_trob_room_changed(void)
void pop_trob_room_changed(void) __banked
{
uint8_t i;
for (i = 0; i < POP_ROOMTILES; i++) trob_drawn[i] = 0;
@@ -109,7 +131,7 @@ void pop_trob_room_changed(void)
* тайлов: факелам случайная стартовая фаза пламени (start_anim_torch,
* seg007:0847: modif = prandom(8)), зельям случайная фаза пузырька
* (start_anim_potion: младшие 3 бита = prandom(6)+1, старшие = тип). */
void pop_trob_anim_room(uint8_t room)
void pop_trob_anim_room(uint8_t room) __banked
{
uint8_t *mod = pop_trob_modif(room);
uint8_t i, code;
@@ -259,13 +281,20 @@ static void do_trigger_list(uint8_t index, uint8_t button_type)
}
void pop_trigger_button(uint8_t room, uint8_t tilepos,
uint8_t button_type, uint8_t modifier)
uint8_t button_type, uint8_t modifier) __banked
{
uint8_t link_timer = get_doorlink_timer(modifier);
if (link_timer != 0x1F) { /* не заклинено */
set_doorlink_timer(modifier, 5);
if (link_timer < 2)
pop_add_trob(room, tilepos, 1); /* кнопка нажимается (+ TODO sound) */
/* Оригинал ставит 5; вынесено в pop_tune.h, потому что проверять
* руками проход в закрывающуюся решётку при таком окне почти
* невозможно (см. BUG-GATE-PASS-1). */
set_doorlink_timer(modifier, POP_DBG_GATE_HOLD);
if (link_timer < 2) {
pop_add_trob(room, tilepos, 1); /* кнопка нажимается */
/* seg007:766: щелчок кнопки — шум, страж оборачивается. Звука у
* нас нет, флаг обязан быть (BUG-GUARD-DEAF-1). */
is_guard_notice = 1;
}
do_trigger_list(modifier, button_type);
}
}
@@ -337,7 +366,7 @@ static void animate_door(uint8_t *m, int8_t *type)
*m = mod;
}
uint8_t pop_trob_room_active(uint8_t room)
uint8_t pop_trob_room_active(uint8_t room) __banked
{
uint8_t i;
for (i = 0; i < trobs_count; i++)
@@ -362,7 +391,7 @@ static uint8_t torch_tick;
#define TORCH_ANIM_NOW 1
#endif
void pop_process_trobs(uint8_t cur_room)
void pop_process_trobs(uint8_t cur_room) __banked
{
uint8_t i, w = 0;
#if TORCH_ANIM_DIV > 1
@@ -460,6 +489,15 @@ void pop_process_trobs(uint8_t cur_room)
* страницы: живой trob метит каждый кадр по одной. */
pop_set_redraw(tp, POP_RD_SPIKE, (uint8_t)(type < 0 ? 2 : 1));
}
if (room == cur_room && code == TILE_GATE) {
/* draw_trob (seg007:01E6), которым заканчивается animate_door:
* пока trob жив решётка едет каждый кадр, помечаем ТЕКУЩУЮ
* страницу; последний кадр (type<0, ворота встали) ОБЕ, иначе
* на второй странице дабл-буфера застынет предыдущее положение
* и решётка дрожит через кадр. Тайлпос САМИ ворота, соседа
* справа выбирает pop_gate_redraw. */
pop_set_redraw(tp, POP_RD_GATE, (uint8_t)(type < 0 ? 2 : 1));
}
if (room == cur_room && code == TILE_LEVELDOOR) {
/* Створка едет каждый кадр — перерисовываем ПРАВУЮ половину (там
* её рисует draw_tile_anim_right) С ЗАПЕЧКОЙ в ОЗУ-копию: иначе
+8 -8
View File
@@ -25,41 +25,41 @@
/* Сброс всего состояния (новый уровень / рестарт): забыть все комнаты и
* очистить список trob. */
void pop_trob_reset(void);
void pop_trob_reset(void) __banked;
/* Указатель на persistent modifier-массив комнаты room (1..24), 30 байт.
* При ПЕРВОМ обращении лениво инициализируется из backtable уровня
* (pop_level_room_bg). Далее ЖИВЁТ (не перезагружается). room вне
* диапазона 0. */
uint8_t *pop_trob_modif(uint8_t room);
uint8_t *pop_trob_modif(uint8_t room) __banked;
/* Добавить/обновить trob (room, tilepos, type). Если уже есть — сменить
* type (как SDLPoP add_trob/find_trob). type<0 = будет удалён. */
void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type);
void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type) __banked;
/* Продвинуть все trob на кадр (animate_* по коду тайла из уровня), записать
* modif, перерисовать тайлы ТЕКУЩЕЙ (cur_room) комнаты на back-странице.
* Звать в основном цикле ПОСЛЕ set_draw_page(back), рядом с loose. */
void pop_process_trobs(uint8_t cur_room);
void pop_process_trobs(uint8_t cur_room) __banked;
/* Кид встал на кнопку (opener/closer): взвести таймер связи + обойти цепочку
* LINKLOC, добавив trob целям (ворота). Порт seg007 trigger_button.
* room/tilepos кнопка; button_type её тайл-код; modifier её modif
* (= индекс в LINKLOC). Звать из check_press (pop_map). */
void pop_trigger_button(uint8_t room, uint8_t tilepos,
uint8_t button_type, uint8_t modifier);
uint8_t button_type, uint8_t modifier) __banked;
/* Есть ли активные trob в комнате room? Для перерисовки ворот соседа через
* шов (roomtest не дёргает шов, пока сосед не анимируется). */
uint8_t pop_trob_room_active(uint8_t room);
uint8_t pop_trob_room_active(uint8_t room) __banked;
/* Смена комнаты: сбросить отложенные перерисовки «покоя» текущей комнаты
* (trob-состояние в room_modif персистентно и НЕ трогается). Звать в
* enter_room. */
void pop_trob_room_changed(void);
void pop_trob_room_changed(void) __banked;
/* anim_tile_modif (seg000:0B12): завести анимации тайлов комнаты (факелы,
* зелья) со случайной стартовой фазой. Звать в enter_room. */
void pop_trob_anim_room(uint8_t room);
void pop_trob_anim_room(uint8_t room) __banked;
#endif
+66
View File
@@ -0,0 +1,66 @@
/*
* pop_tune.h НАСТРАИВАЕМЫЕ КОНСТАНТЫ движка (аналог custom_options
* SDLPoP) в одном месте.
*
* Зачем отдельный заголовок. У оригинала «особые события» уровней зашиты
* магическими числами прямо в код (`current_level == 3 && Char.room == 7`),
* и SDLPoP вынес их в структуру `custom_options_type` (types.h) с
* загрузкой из SDLPoP.ini и правкой через меню Settings/Mods. Нам нужно
* то же самое по двум причинам:
*
* 1. ОТЛАДКА. Некоторые сцены руками почти не проверить (успеть в
* закрывающуюся решётку), и единственный вменяемый способ временно
* растянуть тайминг. Такие крутилки обязаны жить в одном месте, а не
* прятаться числами по трём модулям.
* 2. МОДЫ. Тот, кто захочет свой набор уровней, меняет ровно эти числа.
*
* Пока это только `#define` ни INI, ни меню нет (см. TASKS.md, запись
* TUNE-1). Но правило уже действует: НОВОЕ «особое событие» или тайминг
* заводится ЗДЕСЬ, а не константой по месту. Имена как у SDLPoP
* (custom->имя), чтобы сверка с оригиналом оставалась механической.
*/
#ifndef POP_TUNE_H
#define POP_TUNE_H
/* ---- Чекпойнт уровня 3 (SDLPoP: checkpoint_*) ----------------------- *
* Оригинал: level3_set_chkp (seg002:0665) взводит флаг, когда Кид уходит
* ВЛЕВО ИЗ комнаты 7 (вызов стоит до goto_other_room, поэтому Char.room там
* ещё старая); do_startpos (seg003:0141) подменяет по
* нему стартовую позицию и убирает loose-плиту, через которую туда
* попадают (иначе после респавна дорога назад была бы уже провалена). */
#define POP_CHKP_LEVEL 3 /* checkpoint_level */
#define POP_CHKP_ROOM 7 /* комната, ИЗ КОТОРОЙ уходят влево (у
* SDLPoP хардкод; это НЕ комната
* назначения см. roomtest.c) */
#define POP_CHKP_RESPAWN_ROOM 2 /* checkpoint_respawn_room */
#define POP_CHKP_RESPAWN_TILEPOS 6 /* checkpoint_respawn_tilepos */
#define POP_CHKP_RESPAWN_DIR (-1) /* checkpoint_respawn_dir: влево */
#define POP_CHKP_CLEAR_ROOM 7 /* checkpoint_clear_tile_room */
#define POP_CHKP_CLEAR_COL 4 /* checkpoint_clear_tile_col */
#define POP_CHKP_CLEAR_ROW 0 /* checkpoint_clear_tile_row */
/* ---- Отладочные крутилки (в оригинале и в SDLPoP их НЕТ) ------------ *
* POP_DBG_GATE_HOLD сколько кадров решётка держится поднятой после
* нажатия кнопки, ПРЕЖДЕ чем пойдёт вниз. В оригинале это таймер связи
* дверей: trigger_button ставит 5, animate_button вычитает по единице, и
* решётка стоит, пока таймер > 1 (то есть ~4 кадра после того, как Кид
* сошёл с кнопки). Проверять руками проход в закрывающуюся решётку при
* таком окне почти невозможно, поэтому значение вынесено сюда: поставь
* 30 и успеешь дойти спокойно. ОБЯЗАН быть 5 в игровой сборке.
*
* ПОТОЛОК 30. Таймер связи пять бит модификатора кнопки (маска 0x1F), а
* значение 31 у оригинала означает «событие заклинено» (`link_timer != 0x1F`
* в trigger_button). Больше 30 ставить нельзя кнопка перестанет
* срабатывать вовсе. */
#define POP_DBG_GATE_HOLD 5
/* ---- Улучшения SDLPoP (в ОРИГИНАЛЕ их нет) -------------------------- *
* POP_ENABLE_JUMP_GRAB зацепиться за кромку можно ПРЯМО В ПРЫЖКЕ, если
* Shift зажат заранее (SDLPoP `enable_jump_grab`, seg006:1228). Ваниль
* разрешает зацеп только в начале падения (`check_grab`, кадры 102..105),
* то есть Shift приходится нажимать уже в полёте это и раздражало на
* приёмке. 0 как в оригинале, 1 как в SDLPoP с включёнными
* enhancements. */
#define POP_ENABLE_JUMP_GRAB 1
#endif /* POP_TUNE_H */
+388 -72
View File
@@ -16,8 +16,10 @@
#include "pop_level.h"
#include "pop_trob.h"
#include "pop_redraw.h"
#include "pop_geom.h" /* pop_y_to_row, TILE_SIZEX — тайл объекта */
#include "pop_guard.h"
#include "pop_cheat.h"
#include "pop_tune.h"
__sfr __at (0xFE) io_border;
@@ -46,23 +48,58 @@ __sfr __at (0xFE) io_border;
#define PROF(n) ((void)0)
#endif
/* Сколько банков грузит crt0_banked (читает его стартовый код). */
const uint8_t n_banks = 3;
/* Сколько банков грузит crt0_banked (читает его стартовый код).
* 1 = guards (ИИ/боёвка), 2 = pop_bg (фон, fore, loose), 3 = pop_map
* (коллизия/физика), 4 = pop_gdraw (отрисовка стража + полоса HP),
* 5 = pop_ctrl (диспетчер управления).
* Отрисовка стража вынесена из банка 2 отдельно: тот подошёл к 16 КБ
* вплотную (16 021 из 16 384), а расти ему ещё есть куда тайлы поздних
* уровней, чомперы, зеркало.
*
* ДЕРЖАТЬ В СОГЛАСИИ с числом --bank в Makefile. Забыть это не ошибка
* сборки, а зависание: crt0 выделит меньше страниц, _bank_pages[N] так и
* останется нулём, и трамплин прыгнет по 0xC000 в мусор (см. шапку
* runtime/bank.s). Проверено 2026-08-05: с n_banks=4 и пятым банком игра
* доходила до конца загрузки ресурсов и вставала намертво в DSS. */
const uint8_t n_banks = 6;
/* Режим читов (pop_cheat.h): на время разработки включаем в main. */
uint8_t pop_cheats;
/* ПОТЕРЯ НАЖАТИЙ ПРИ ЗАЖАТОМ Shift — лечится ПЛОТНЫМ опросом клавиатуры.
*
* При зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», и
* одно нажатие стрелки превращается в 5 байт вместо 2 (отпускание тоже
* 5). Приёмный FIFO SIO 3 байта, а импульс запроса прерывания здесь
* теряется примерно в 44 % случаев, поэтому байты копятся и один из
* полусотни пропадает ДО чтения порта: нажатие «не срабатывает».
*
* Что НЕ помогает (замерено, TASKS.md/KBD-1): редкий опрос (несколько
* вызовов за кадр они попадают туда, где прерывания и так разрешены, и
* лишь дублируют трамплин) и укорачивание DI-окон графики (в кадре вообще
* без блитов потерь БОЛЬШЕ). Снимать `di` в accel-ядрах нельзя машина
* уходит в перезагрузку.
*
* Что помогает: опрос раз в ~0.5 мс. Столько времени берётся из ожидания
* кадра процессор всё равно крутит там ~42 мс из 60, ничего не делая.
* Поэтому kbd_raw_poll вешается idle-хуком на gfx_wait_vsync (см.
* kbd_idle ниже); полезной работы это не отнимает. Потолок приёма
* подтверждён отдельно (tests/kbdpoll: 25 нажатий 25, 250 байт из 250). */
/* Обёртка ради типа: хук — void(*)(void), а kbd_raw_poll возвращает
* «было ли что вычерпано». Приводить указатель кастом не хочется. */
static void kbd_idle(void)
{
kbd_raw_poll();
}
/* seq_2_stand (types.h seqids) — Kid стоит, ждёт ввода. */
#define SEQ_STAND 2
#define ROOM_1 1
#define LINK_DOWN 3 /* сторона links: 0=L,1=R,2=U,3=D */
/* Тест-старт для отладки пик (Фаза S): комната 6 = тестовая (пики в col3/col4
* row2, шахта col3). Kid на полу row0 col1, идёт вправо падает в шахту
* пики. TODO: вернуть на pop_level_start_room (комната 1) после отладки. */
#define START_ROOM 1 /* обход комнат: старт с 1-й, дальше +/- (ROOMNAV) */
#define START_COL 3
#define START_ROW 0
#define FIRST_LEVEL 1 /* с какого уровня начинается игра */
#define RESPAWN_DELAY 400 /* авто-респавн (фолбэк), если не нажали ↑ */
#define RESPAWN_SETTLE 20 /* тиков до приёма ↑ (труп успел лечь) */
@@ -73,6 +110,7 @@ uint8_t pop_cheats;
* Убирается закомментированием ОДНОЙ строки ниже. */
#define ROOMNAV 1
/* Рабочая копия ТЕКУЩЕЙ комнаты (W2, мутабельная: loose→empty) + срезы
* соседей для кромок. Заполняются pop_room_load из уровня. File-scope,
* чтобы enter_room переключал комнату без длинного списка параметров. */
@@ -80,7 +118,7 @@ static uint8_t room_fg[30], 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[10];
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) */
static uint8_t kid_room; /* РЕАЛЬНАЯ комната Kid (S1: == cur_room; расходятся в S3 straddle) */
@@ -98,6 +136,89 @@ static void update_kid_render_dx(void)
pop_kid_set_render_dx(dx);
}
/* ---- Порядок «Kid vs страж» (BUG-DRAWORDER-1) -----------------------
*
* В оригинале никто не «поверх» по определению: и Кид, и страж попадают в
* midtable при обработке СВОЕГО тайла (set_objtile_at_char, seg006:13F3), а
* тайлы обходятся в фиксированном порядке redraw_needed_tiles (seg008:1B06)
* идёт рядами 2,1,0 и внутри ряда колонками 0..9. Кто нарисован ПОЗЖЕ тот
* поверх. Отсюда «Кид проходит ЗА телом стража»: тело лежит правее и/или
* выше, его тайл в обходе позже. Совпали тайлы решает sort_curr_objs
* (seg008:203C): у обычных объектов (не тень, не плита) ниже по obj_y = позже.
*
* Мы рисовали стражКид всегда, то есть Кид был поверх безусловно.
*
* Ключ обхода: чем больше, тем позже. Ряды идут 2,1,0 (2 - row). */
#define OBJ_ORDER_KEY(row, col) ((int)(2 - (row)) * 10 + (int)(col))
/* get_tile_div_mod (seg006:750): колонка комнаты по X (БЕЗ поправки −7). */
static int8_t tile_div_mod(int x)
{
int v = x - 58; /* pop_x_bump[FIRST_ONSCREEN_COLUMN] */
int c = v / TILE_SIZEX;
if (v % TILE_SIZEX < 0) --c;
return (int8_t)c;
}
/* char_x_left из set_char_collision (seg006:0723) — левая граница габарита.
* ВАЖНО: величина НЕ утоньшённая; поправку THIN оригинал применяет только к
* *_coll, а колонки char_col_left/right считает по этой. */
static int char_x_left_of(uint8_t x, int8_t dir, int8_t dx,
uint8_t flags, uint16_t w)
{
int e = (int)x + ((dir < 0) ? -dx : dx); /* char_dx_forward */
uint8_t fl = flags;
if (dir < 0) fl ^= 0x80;
/* odd-pixel + усечение деления к нулю — та же поправка, что в pop_map */
if (!(fl & 0x80) && e <= 57) e++;
if (dir >= 0) e -= (int)((w + 1) >> 1); /* char_width_half */
return e;
}
/* set_objtile_at_char (seg006:13F3) — ТАЙЛ ОБЪЕКТА персонажа.
*
* Ветка actions_1_run_jump обязательна, а не «упрощаемая» (как я решил
* сначала): в беге оригинал берёт НЕ curr_row/curr_col, а нижний ряд и ЛЕВУЮ
* колонку ГАБАРИТА. При беге вправо левая колонка меньше curr_col, поэтому
* бегущий Кид уходит ЗА объекты справа от себя. Без этой ветки часть кадров
* бега рисовала Кида поверх лежащего стража. Считаем одинаково для обоих:
* enter_guard (seg002) тоже ставит стражу action = 1, и односторонний учёт
* дал бы перекос в другую сторону. */
static void objtile_at_char(int8_t *row, int8_t *col,
const pop_char_t *ch, int8_t dx, int8_t dy,
uint8_t flags, uint16_t w)
{
int8_t r = ch->curr_row, c = ch->curr_col;
if (ch->action == 1) { /* actions_1_run_jump */
int8_t br = pop_y_to_row((int16_t)((int)ch->y + dy));
if (br == -1) br = 3; /* char_bottom_row */
r = br;
c = tile_div_mod(char_x_left_of(ch->x, ch->direction, dx, flags, w));
if (c < 0) c = 0; /* MAX(char_col_left, 0) */
}
if ((ch->frame >= 135 && ch->frame < 149) ||
ch->action == 2 || ch->action == 3 ||
ch->action == 4 || ch->action == 6) c--;
*row = r; *col = c;
}
/* 1 = страж рисуется ПОСЛЕ Кида (поверх). */
static uint8_t guard_over_kid(void)
{
int8_t kr, kc, gr, gc;
int kk, gk;
if (Guard.charid == 0 || Guard.room != cur_room) return 0;
objtile_at_char(&kr, &kc, &Kid, kid_cur_dx(), kid_cur_dy(),
kid_cur_flags(), kid_fp_width());
objtile_at_char(&gr, &gc, &Guard, pop_gframe.dx, pop_gframe.dy,
pop_gframe.flags, pop_guard_fp_width());
kk = OBJ_ORDER_KEY(kr, kc);
gk = OBJ_ORDER_KEY(gr, gc);
if (gk != kk) return (uint8_t)(gk > kk);
/* один тайл — ниже по obj_y рисуется позже (sort_curr_objs) */
return (uint8_t)((int)Guard.y + pop_gframe.dy > kid_fp_obj_y());
}
/* Change-driven редрой ЛЕВОГО шва (ворота соседа room8 видны в col0): тяжёлый
* draw_tile(0,0) в pop_room_redraw_seam_left зовём ТОЛЬКО когда openness гейта
* реально изменился. Пока Кид держит кнопку и гейт открыт modifier стабилен
@@ -111,17 +232,15 @@ static uint8_t seam_redraw; /* оставшиеся кадры редроя (
static uint8_t room_label_sig = 0xFF;
static uint8_t room_label_redraw;
/* Персистентные переопределения тайлов уровня: упавший loose-кусок оставил
* щебень (floor debris), а на месте самой плиты пусто (в т.ч. в комнате
* СВЕРХУ: провалившийся потолок = колодец). Минимальный per-room override:
* живёт между переходами, применяется в enter_room поверх загруженных тайлов
* (и для ряда 2 верхнего соседа). (Полноценное per-room modifier-состояние
* в docs/gates_spikes_plan.md, P0.) */
/* Изменения тайлов (упавшая плита → пусто, щебень на месте приземления
* куска, поднятый предмет пол) пишутся ПРЯМО В УРОВЕНЬ
* pop_level_set_tile, как в оригинале (curr_room_tiles[] = ). Раньше тут
* жила таблица переопределений на 8 записей, и девятая правка молча
* терялась: разбитые плиты и выпитые зелья «возвращались» при возврате в
* комнату (BUG-LVLSTATE-1). */
#define TILE_DEBRIS 0x0E
#define TILE_EMPTY 0x00
#define TILE_FLOOR 0x01
#define OVR_MAX 8
static uint8_t ovr_room[OVR_MAX], ovr_pos[OVR_MAX], ovr_tile[OVR_MAX], ovr_cnt;
/* Поднятый предмет (меч): tilepos и счётчик «запечь пол» по страницам.
* flash_on вспышка фона активна (нулевая запись палитры подменена). */
@@ -137,36 +256,15 @@ static void flash_bg(uint8_t r, uint8_t g, uint8_t b)
gfx_pal_set(1, 0, r, g, b);
}
static void add_tile_override(uint8_t room, uint8_t pos, uint8_t tile)
{
uint8_t i;
for (i = 0; i < ovr_cnt; i++)
if (ovr_room[i] == room && ovr_pos[i] == pos) { ovr_tile[i] = tile; return; }
if (ovr_cnt < OVR_MAX) {
ovr_room[ovr_cnt] = room; ovr_pos[ovr_cnt] = pos;
ovr_tile[ovr_cnt] = tile; ovr_cnt++;
}
}
/* Применить переопределения комнаты room к массиву тайлов: base = tilepos
* первого элемента (0 для полной комнаты, 20 для ряда 2 верхнего соседа). */
static void apply_overrides(uint8_t room, uint8_t *tiles, uint8_t base, uint8_t n)
{
uint8_t i;
for (i = 0; i < ovr_cnt; i++)
if (ovr_room[i] == room && ovr_pos[i] >= base && ovr_pos[i] < base + n)
tiles[ovr_pos[i] - base] = ovr_tile[i];
}
/* Загрузить комнату room в рабочие массивы и отрисовать её фон в ОБЕ
* страницы дабл-буфера (у каждой своя видео-ОЗУ/теневая копия). Оставляет
* активной страницу 1 вызывающий восстанавливает нужную draw-страницу. */
static void enter_room(uint8_t room)
{
uint8_t pg;
pop_guard_leave(); /* запомнить стража уходящей комнаты */
uint8_t *room_mod;
pop_room_load(room, room_fg, room_bg, lcol_fg, lcol_bg, rcol_fg, rcol_bg, below_fg);
apply_overrides(room, room_fg, 0, 30); /* щебень/пустота от упавших плит */
pop_map_set(room_fg);
pop_map_set_edges(pop_room_link(room, 0), pop_room_link(room, 1),
pop_room_link(room, 2), pop_room_link(room, 3),
@@ -181,7 +279,6 @@ static void enter_room(uint8_t room)
pop_level_access_begin();
for (c = 0; c < 10; c++) above_fg[c] = pop_level_tile_raw(up, (uint8_t)(20 + c));
pop_level_access_end();
apply_overrides(up, above_fg, 20, 10); /* провалившийся потолок = колодец */
for (c = 0; c < 10; c++) above_mod[c] = um[20 + c];
pop_room_set_above(above_fg, above_mod);
pop_map_set_above(above_fg); /* плита-потолок: пробой снизу + провал */
@@ -191,10 +288,16 @@ static void enter_room(uint8_t room)
}
}
pop_map_set_room(room);
/* Kid.room — как в оригинале: «комната персонажа». Полная модель
* (Kid.room отрисованной у шва) ещё не портирована, но сравнение
* Kid.room == Guard.room нужно уже сейчас: без него луч видимости
* стража никогда не срабатывает. */
Kid.room = room;
pop_loose_reset(); /* loose-состояние не тянем из старой комнаты */
pop_trob_room_changed(); /* отложенные перерисовки пик — не из старой комнаты */
pop_trob_anim_room(room); /* факелы/зелья: стартовая фаза + trob */
pop_guard_enter(room); /* страж комнаты (порт enter_guard) */
pop_hp_invalidate(); /* фон перерисован — полоса HP тоже */
/* Модификаторы тайлов (openness ворот, состояние пик) — ПЕРСИСТЕНТНЫЕ
* per-room (room_modif живёт между входами); рисуем и коллидируем по ним,
* а не по свежему backtable. Фаза P0. */
@@ -222,6 +325,119 @@ static void enter_room(uint8_t room)
pop_leave_timer = 2; /* exit_room_timer: 2 кадра блокировки leave (анти-ping-pong/#4) */
}
/* start_level (seg003:0055) + do_startpos (seg003:01A3) + set_start_pos
* (seg003:028A) поставить Кида в стартовую позицию УРОВНЯ. Один вход и
* для запуска, и для рестарта после смерти / выпадения из уровня: в
* оригинале это буквально один и тот же путь (play_level_2 зовёт
* start_level заново), поэтому и у нас не три копии четырёх строк.
*
* Позиция берётся ИЗ ДАННЫХ уровня (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(). */
#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 */
/* 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)
{
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);
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
@@ -232,7 +448,8 @@ int main(void)
#ifdef ROOMNAV
uint8_t nav_prev = 0;
#endif
uint8_t cheat_prev = 0;
uint8_t cheat_prev = 0, immo_prev = 0, sword_prev = 0, nlvl_prev = 0;
uint8_t xdec_prev = 0, xinc_prev = 0;
if (pop_bg_load() != 0) { /* атласы — ДО initgraph (снимает DSS-страницу W0) */
puts("pop_bg_load failed");
@@ -250,11 +467,13 @@ int main(void)
puts("kid atlas load failed");
return 1;
}
if (pop_level_load("LEVELS\\res2001.bin") != 0 && /* сырой уровень 1 в EMM-страницу */
pop_level_load("a:\\res2001.bin") != 0) {
if (pop_level_load_num(FIRST_LEVEL) != 0) { /* сырой уровень в EMM-страницу */
puts("level load failed");
return 1;
}
pop_next_level = pop_current_level; /* current_level = next_level (load_lev_spr) */
pop_kid_hp_newgame(); /* init_game: hitp_beg_lev = start_hitp */
pop_checkpoint = 0; /* init_game (seg003:37) */
initgraph();
gfx_set_visible_page(0);
@@ -268,15 +487,11 @@ int main(void)
* ТОЛЬКО прямоугольник Кида (kid_heal), а не весь экран. */
/* Извлечь и отрисовать стартовую комнату (в обе страницы). P0: сбросить
* trob/room_modif (ленивая инициализация из уровня при первом входе). */
pop_trob_reset();
enter_room(START_ROOM);
/* Старт — ПО ДАННЫМ УРОВНЯ (комната/тайл/направление/поза входа), см.
* pop_start_level. Управление: / бег, присед, + прыжок с места,
* Shift шаг, ESC выход. Карта коллизии задана внутри enter_room. */
pop_start_level();
gfx_set_draw_page(0);
/* K2b: Kid стоит на уступе (row1), управляется с клавиатуры.
* / бег, присед, + прыжок с места, Shift шаг (K3). ESC выход.
* Карта коллизии (pop_map_set) уже задана в enter_room. */
kid_init(SEQ_STAND, START_COL, START_ROW, 0);
pop_kid_hp_reset();
kid_draw();
pop_fore_over_kid(kid_fp_obj_x(), kid_fp_obj_y(),
kid_fp_width(), kid_fp_height(),
@@ -287,6 +502,7 @@ int main(void)
puts("kbd_raw open failed");
return 1;
}
gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */
while (!kbd_raw_down(KBD_ESC)) {
/* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один
@@ -309,11 +525,47 @@ int main(void)
* стоит, можно спокойно разбирать позу Kid и окклюзию. */
if (kbd_raw_down(0x16)) frozen = 1;
if (kbd_raw_down(0x1E)) frozen = 0;
{ /* Читы (seg000: switch под if (cheats_enabled)). K — убить
* стража; по фронту нажатия, чтобы не срабатывало каждый кадр. */
{ /* Читы (seg000: switch под if (cheats_enabled)). Все — по
* ФРОНТУ нажатия, иначе срабатывали бы каждый кадр.
* K убить стража;
* I бессмертие Кида (наш отладочный, в оригинале его нет:
* иначе боёвку проверять получается ровно три раза);
* S выдать меч, не бегая за ним в комнату 15. Штатно меч
* берётся с пола (pop_have_sword ставит подбор);
* Shift+L следующий уровень (seg000:698, ветка next_level =
* current_level + 1). Единственный вменяемый способ
* тестировать уровни 2..15. NB: L без Shift отладочный
* «осторожный шаг вправо» (pop_ctrl KBD_DBG_STEPR); с
* Shift шаг тоже пройдёт, но уровень тут же сменится, так
* что конфликта на практике нет;
* [ / ] подвинуть Кида на ПИКСЕЛЬ влево/вправо (seg000:1828,
* `--Char.x` / `++Char.x`). Нужен потому, что мост MAME
* теряет нажатия при быстрой отправке, и подогнать Кида в
* нужную позу скриптом нельзя (на это упёрлись BUG-LOOSE-2
* и BUG-GATE-PASS-1). Пишем прямо в Kid.x: геометрию
* персонажа в этом порте задаёт не диспетчер (грабли
* L1-EXIT). Колонку не трогаем её пересчитает физика
* следующего кадра. */
uint8_t k = kbd_raw_down(KBD_CHEAT_KILL);
uint8_t i = kbd_raw_down(KBD_CHEAT_IMMO);
uint8_t s = kbd_raw_down(KBD_CHEAT_SWORD);
uint8_t nl = (uint8_t)(kbd_raw_down(KBD_CHEAT_NEXTLVL) &&
(kbd_raw_down(KBD_LSHIFT) || kbd_raw_down(KBD_RSHIFT)));
uint8_t xd = kbd_raw_down(KBD_CHEAT_XDEC);
uint8_t xi = kbd_raw_down(KBD_CHEAT_XINC);
if (pop_cheats && k && !cheat_prev) pop_guard_kill();
if (pop_cheats && i && !immo_prev) pop_immortal = !pop_immortal;
if (pop_cheats && s && !sword_prev) pop_have_sword = 1;
if (pop_cheats && nl && !nlvl_prev && pop_current_level < POP_LAST_LEVEL)
pop_next_level = (uint8_t)(pop_current_level + 1);
if (pop_cheats && xd && !xdec_prev) Kid.x = (uint8_t)(Kid.x - 1);
if (pop_cheats && xi && !xinc_prev) Kid.x = (uint8_t)(Kid.x + 1);
cheat_prev = k;
immo_prev = i;
sword_prev = s;
nlvl_prev = nl;
xdec_prev = xd;
xinc_prev = xi;
}
if (frozen) {
PROF(0); /* чёрный: ждём vsync */
@@ -324,8 +576,17 @@ int main(void)
#ifdef ROOMNAV
/* Обход комнат по номеру: '+' (0x79 numpad / 0x55 '=') — вперёд,
* '-' (0x7B / 0x4E) назад. Edge по nav_prev: одно нажатие = одна
* комната. pop_trob_reset ДО enter_room каждая комната смотрится в
* исходном состоянии (пики убраны, ворота закрыты). */
* комната.
*
* Здесь БЫЛ pop_trob_reset() перед enter_room «чтобы каждая комната
* смотрелась в исходном состоянии». Убран: reset обнуляет room_seen,
* и модификаторы перечитываются из уровня заново, то есть чит
* ОТМАТЫВАЕТ МИР открытые/закрытые ворота, выдвинутые пики, нажатые
* кнопки. Пока ворота с bg=1 ошибочно стартовали закрытыми, откат был
* не виден; после порта load_alter_mod он выглядит как баг «ворота
* снова открылись» (уровень 1, решётка комнаты 5 (0,9), которую
* закрывает стартовый триггер do_startpos). Навигация обязана только
* телепортировать; исходное состояние даёт перезапуск уровня. */
{
uint8_t nav = (uint8_t)((kbd_raw_down(0x79) || kbd_raw_down(0x55)) |
((kbd_raw_down(0x7B) || kbd_raw_down(0x4E)) << 1));
@@ -333,7 +594,6 @@ int main(void)
uint8_t r = cur_room, i, c, pos = 20, any = 0xFF;
r = (nav & 1) ? (uint8_t)(r == 24 ? 1 : r + 1)
: (uint8_t)(r == 1 ? 24 : r - 1);
pop_trob_reset();
enter_room(r);
/* Предпочитаем обычный пол; иначе — любой tile_is_floor
* (seg001): всё, кроме пустоты/стены/верхов колонны и двери. */
@@ -356,6 +616,7 @@ int main(void)
back = dbuf ? (uint8_t)(front ^ 1) : 0;
gfx_set_draw_page(back);
pop_check_can_guard_see_kid(); /* луч видимости — ДО логики персонажей */
pop_ctrl_tick(); /* ввод -> control(): смена seq */
kid_heal(); /* стереть прошлый кадр ЭТОЙ страницы */
pop_guard_heal(); /* и прошлый кадр стража */
@@ -364,22 +625,42 @@ int main(void)
kid_tick(); /* play_seq: следующий кадр */
pop_phys_tick(); /* K3: падение/приземление/стена */
pop_guard_tick(); /* логика стража — В БАНКЕ (guards.c) */
/* Боёвка, порядок как в play_frame (seg000:872): сначала пометить
* попадания с обеих сторон, потом применить, потом свести HP. */
pop_check_sword_hurting();
pop_check_sword_hurt();
pop_do_delta_hp();
if (pop_fell_out) { /* Kid пересёк нижнюю границу комнаты */
uint8_t down = pop_room_link(cur_room, LINK_DOWN);
pop_fell_out = 0;
if (down) { /* L2: переход в комнату снизу — продолжает падать */
enter_room(down); /* координаты Kid уже репроецированы pop_map */
gfx_set_draw_page(back); /* вернуть текущую back для kid_draw */
} else { /* выпал из уровня (нет комнаты снизу) — респавн */
pop_trob_reset(); /* рестарт уровня: пики/ворота в исходное */
enter_room(START_ROOM);
} else { /* выпал из уровня (нет комнаты снизу) — рестарт */
pop_start_level();
gfx_set_draw_page(back);
kid_init(SEQ_STAND, START_COL, START_ROW, 0);
pop_kid_hp_reset();
}
} else if (pop_leave_dir) { /* L3: уход вбок/вверх (координаты уже репроецированы) */
uint8_t nb = pop_room_link(cur_room, (uint8_t)(pop_leave_dir - 1));
uint8_t left = (uint8_t)(pop_leave_dir == 1);
pop_leave_dir = 0;
/* level3_set_chkp (seg002:0665). ВНИМАНИЕ на комнату: оригинал
* зовёт его из leave_room ДО goto_other_room, то есть Char.room
* там ещё СТАРАЯ. Значит условие `Char.room == 7` читается как
* «Кид УХОДИТ ИЗ комнаты 7 влево» (в комнату 2), а не «входит в
* неё». Смысл: чекпойнт даётся за то, что комната 7 пройдена
* НАСКВОЗЬ, и респавн ставится туда, куда Кид только что попал.
*
* Сначала я поставил проверку на комнату НАЗНАЧЕНИЯ и это
* поймал пользователь прогоном в SDLPoP: он умер В комнате 7, но
* влево из неё не выходил, и оригинал вернул его в стартовую
* комнату 9, а плита осталась цела. Заодно тут запоминается
* достигнутый потолок HP: смерть после чекпойнта его не отбирает. */
if (left && pop_current_level == POP_CHKP_LEVEL &&
cur_room == POP_CHKP_ROOM) {
pop_checkpoint = 1;
hitp_beg_lev = hitp_max;
}
if (nb) {
enter_room(nb);
gfx_set_draw_page(back);
@@ -410,16 +691,35 @@ int main(void)
}
}
}
if (pop_next_level != pop_current_level) {
/* Порт play_level_2 (seg003:0386..0410) + play_level (seg003:81):
* цикл уровня кончается, когда next_level разошёлся с
* current_level (END_LEVEL после seq_70 в двери уровня либо чит
* Shift+L), и play_level грузит следующий.
*
* HP переносится: hitp_beg_lev = hitp_max ДО загрузки так
* поднятая на уровне «жизнь» (большая склянка) переезжает
* дальше, а урон нет (do_startpos дольёт до потолка). */
hitp_beg_lev = hitp_max;
pop_checkpoint = 0; /* уровень пройден (seg003:413) */
if (pop_level_load_num(pop_next_level) != 0) {
pop_next_level = pop_current_level; /* файла нет — остаёмся */
} else {
/* Всё состояние прошлого уровня сносит сам pop_start_level:
* pop_trob_reset обнуляет room_seen (модификаторы комнат
* перечитаются из НОВОЙ страницы), тайлы/стражи приезжают
* из неё же, loose чистит enter_room. */
pop_start_level();
}
gfx_set_draw_page(back);
}
if (pop_kid_dead) { /* напоролся на пики: труп лежит; респавн по ↑ */
dead_frames++;
if (dead_frames > RESPAWN_SETTLE &&
(kbd_raw_down(KBD_UP) || dead_frames > RESPAWN_DELAY)) {
dead_frames = 0;
pop_trob_reset(); /* рестарт уровня: пики/ворота в исходное */
enter_room(START_ROOM);
pop_start_level();
gfx_set_draw_page(back);
kid_init(SEQ_STAND, START_COL, START_ROW, 0);
pop_kid_hp_reset();
}
}
if (pop_loose_fell) { /* упавший кусок → debris в комнате снизу */
@@ -427,26 +727,26 @@ int main(void)
uint8_t fcol = (uint8_t)(fpos % 10);
uint8_t down = pop_room_link(cur_room, LINK_DOWN);
pop_loose_fell = 0;
add_tile_override(cur_room, fpos, TILE_EMPTY); /* сама плита исчезла */
pop_level_set_tile(cur_room, fpos, TILE_EMPTY); /* сама плита исчезла */
if (down) {
int8_t lrow = pop_room_col_landing(down, fcol);
if (lrow >= 0) add_tile_override(down, (uint8_t)(lrow * 10 + fcol), TILE_DEBRIS);
if (lrow >= 0) pop_level_set_tile(down, (uint8_t)(lrow * 10 + fcol), TILE_DEBRIS);
}
}
if (pop_ceil_fell) { /* провалилась плита-ПОТОЛОК (ряд 2 комнаты сверху) */
uint8_t ccol = (uint8_t)(pop_ceil_fell - 1);
uint8_t up = pop_room_link(cur_room, 2);
pop_ceil_fell = 0;
if (up) add_tile_override(up, (uint8_t)(20 + ccol), TILE_EMPTY); /* колодец */
if (up) pop_level_set_tile(up, (uint8_t)(20 + ccol), TILE_EMPTY); /* колодец */
}
if (pop_debris_at) { /* кусок лёг в ЭТОЙ комнате → щебень */
add_tile_override(cur_room, (uint8_t)(pop_debris_at - 1), TILE_DEBRIS);
pop_level_set_tile(cur_room, (uint8_t)(pop_debris_at - 1), TILE_DEBRIS);
pop_debris_at = 0;
}
if (pop_item_taken) { /* предмет поднят → тайл стал полом */
item_pos = (uint8_t)(pop_item_taken - 1);
item_bake = 2; /* запечь на ОБЕИХ страницах */
add_tile_override(cur_room, item_pos, TILE_FLOOR);
pop_level_set_tile(cur_room, item_pos, TILE_FLOOR);
pop_add_trob(cur_room, item_pos, -1); /* меч убран — блеска больше нет */
pop_item_taken = 0;
}
@@ -463,8 +763,12 @@ int main(void)
* Приоритет как в оригинале: активный flash_time > кадр урона
* (hitp_delta<0 -> ярко-красный, ровно на один кадр). */
if (pop_flash_time) {
flash_bg(255, 255, 85); /* color_14_brightyellow */
/* Цвет вспышки: меч — ярко-жёлтый, зелье — красный (flash_color
* оригинала; нам хватает этих двух). */
if (pop_flash_red) flash_bg(255, 0, 0); /* color_4_red */
else flash_bg(255, 255, 85); /* color_14_brightyellow */
pop_flash_time--;
if (!pop_flash_time) pop_flash_red = 0;
flash_on = 1;
} else if (pop_kid_hurt) {
flash_bg(255, 85, 85); /* color_12_brightred */
@@ -472,15 +776,26 @@ int main(void)
}
update_kid_render_dx(); /* straddle: смещение спрайта, если kid_room≠cur_room */
PROF(6); /* спрайты: страж, Kid, fore поверх */
pop_guard_draw(); /* страж — ДО Kid (порядок как в оригинале) */
{ /* Кто позже — тот поверх; порядок задаёт обход тайлов, а не роль
* персонажа (см. guard_over_kid). */
uint8_t g_after = guard_over_kid();
if (!g_after) pop_guard_draw();
PROF(6); /* kid_draw (тот же циан) */
kid_draw(); /* нарисовать новый кадр (детект выхода за поле) */
if (g_after) pop_guard_draw();
}
pop_loose_mob_draw_over(); /* падающая плита НИЖЕ Kid → поверх него (y-порядок) */
if (pop_kid_hurt) { /* HP убавилось → «брызги» удара (draw_hurt_splash) */
kid_draw_splash();
pop_kid_hurt = 0;
}
pop_hp_draw(); /* полоса HP — только при изменении */
PROF(6); /* fore поверх Kid + борта */
/* Окно fore-клипа общее, и pop_guard_draw ставит своё. При «страж
* поверх» он отрабатывает ПОСЛЕ kid_draw, поэтому окно Кида
* восстанавливаем явно иначе передние грани у Кида отсекаются и
* он рисуется поверх столбов. */
kid_fore_clip_restore();
pop_fore_over_kid(kid_fp_obj_x(), kid_fp_obj_y(),
kid_fp_width(), kid_fp_height(),
Kid.direction, Kid.frame, Kid.action);
@@ -531,6 +846,7 @@ int main(void)
}
}
gfx_set_idle_hook(0); /* снять хук ДО закрытия raw-канала */
pop_ctrl_close();
closegraph();
pop_bg_free();
@@ -0,0 +1 @@
build/
@@ -0,0 +1,52 @@
# Модульные тесты движка roomtest под ucsim_z80. Обвязка общая (testkit/),
# здесь — только сами наборы и список модулей, которые в них линкуются.
TESTKIT := $(abspath $(CURDIR)/../../../../testkit)
ENGINE_DIR := $(abspath $(CURDIR)/..)
# t_phys гоняет НАСТОЯЩУЮ физику, а она ходит по таблицам анимации
# абсолютными адресами от KD_DATA_OFF (0x100) — там, куда на устройстве
# мапится страница W0. Значит блоб kid_data.bin лежит по 0x100, и код
# набора обязан начинаться выше его конца (0x100 + 3720 = 0x0F88).
CODE_LOC := 0x1000
# Заголовки libc/libbgi нужны потому, что pop_kid.c включает gfx/sprite/
# sprinter: под тестом эти функции заглушены (stubs.c), но объявления
# берутся настоящие — иначе разъедутся сигнатуры.
PROJ_ROOT := $(abspath $(TESTKIT)/..)
EXTRA_INC := -I$(PROJ_ROOT)/libc/include -I$(PROJ_ROOT)/libbgi/include -I$(CURDIR)
OBJS_geom := build/eng_pop_geom.rel
OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
build/eng_pop_map.rel build/eng_pop_ctrl.rel \
build/eng_pop_state.rel \
build/stubs.rel build/scene.rel build/kid_blob.rel \
build/bank_stub.rel
# Пробный набор (разведка, без эталонов) линкуется тем же составом.
OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys)
include $(TESTKIT)/host-tests.mk
# --- t_phys: блоб таблиц анимации ------------------------------------
KID_BIN := $(abspath $(CURDIR)/../../poc/res/kid/kid_data.bin)
build/kid_blob.c: gen_kid_blob.py $(KID_BIN) | $(BUILD)
@python3 gen_kid_blob.py $(KID_BIN) $@
build/kid_blob.rel: build/kid_blob.c kidblob.h | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/stubs.rel: stubs.c stubs.h kidblob.h | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/scene.rel: scene.c scene.h stubs.h kidblob.h | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/bank_stub.rel: bank_stub.s | $(BUILD)
@$(SDASZ80) -plosgff $@ $<
# Эталоны трасс: пересобрать набор, если их правили (см. шапку t_phys.c).
build/phys.rel: $(wildcard golden/*.h)
@@ -0,0 +1,105 @@
# tests-host — модульные тесты движка roomtest
Прогоняются под `ucsim_z80`, обвязка общая — `testkit/` (там же объяснение,
почему под z80, а не хостовым gcc, и грабли).
```
make host-tests # из корня, вместе с остальными
make -C applications/PoP/roomtest/tests-host # только эти
make -C applications/PoP/roomtest/tests-host t_geom
```
## Что покрыто
| набор | модуль | что проверяет |
|-------|--------|---------------|
| `t_geom` | `pop_geom.c` | геометрия комнаты (`x_bump`, `y_land`, `y_to_row`) и PRNG оригинала |
| `t_phys` | `pop_map.c` + `pop_kid.c` + `pop_ctrl.c` | покадровые трассы физики Кида: стойка, бег, шаг с Shift, разворот, присед, прыжок вверх, падение с кромки, loose-плита, разбег-прыжок через провал |
Главный тест в `t_geom``geom_lcg_matches_reference`. LCG оригинала
(`s = s*214013 + 2531011`) написан в `pop_geom.c` на ассемблере по схеме
Горнера ради обхода `__mullong`, и заявка «бит-в-бит как в SDLPoP» до
появления теста держалась только на комментарии. Тест сверяет рукописный
asm с наивной 32-битной формулой на 128 шагах — и по возвращаемому
значению, и по обеим половинам сида.
## t_phys: характеризация, а не проверка правильности
Набор существует ради **страховки под рефакторинг**. Эталоны сняты с
текущего билда, то есть консервируют и текущие баги — правильность
по-прежнему проверяется сверкой с `SDLPoP/src/`. Ценность в другом:
ближайший крупный шаг (перенос физики с `Kid.` на `Char.`, чтобы её мог
переиспользовать страж) обязан быть **поведение-сохраняющим**, и «трасса до
== трасса после» ловит ровно тот класс ошибок, который там возможен.
Поэтому эталон **переснимается только осознанно**, и в коммите должно быть
написано, что и почему поменялось. Молча обновлённый эталон обесценивает
весь набор.
```
python3 record_golden.py # переснять все трассы
python3 record_golden.py 7 9 # только сценарии 7 и 9
```
Трасса — по одной записи на кадр: `frame, x, y, dir, col, row, action` и
смещение `curr_seq` от `SEQTBL_BASE`. Кадр гоняется ровно в том порядке,
что и в главном цикле `roomtest.c`:
```
pop_ctrl_tick(); // ввод -> control(): смена последовательности
kid_tick(); // play_seq: следующий кадр
pop_phys_tick(); // падение/приземление/стена
pop_loose_tick(); // досчёт тряски и снятие провалившейся плиты
```
Покадровость здесь принципиальна: порт seg005/seg006 — конечный автомат, и
ошибка почти всегда проявляется не в одной функции, а в расхождении
траектории через несколько кадров. Плюс главный риск переноса на `Char`
не арифметика, а то, **кто владеет окном `Char` внутри кадра**; поймать это
можно только прогоном полного кадра.
Проверка, что харнесс воспроизводит устройство: сценарий `stand` даёт
`frame=15, x=114, y=118, curr_seq=0x19A3` — те же значения, что читаются из
`_Kid` в живом MAME.
### Как это заработало под ucsim
Три шва, каждый закрыт без правок продуктового кода:
1. **Таблицы анимации.** `pop_kid.c` ходит по ним абсолютными адресами от
`KD_DATA_OFF` (0x100) — там, куда на устройстве их приводит маппинг W0.
В тесте `kid_data.bin` просто кладётся по 0x100 (`gen_kid_blob.py`), а
`gfx_w0_map` заглушен. Следствие: код набора линкуется с
`--code-loc 0x1000` (`CODE_LOC` в Makefile), чтобы не налезть на блоб.
2. **Окружение.** `stubs.c` — libc/libbgi (графика, память, файлы), соседние
модули (`pop_bg`/`pop_trob`/`pop_level`/`pop_redraw`) и состояние стража.
Заглушки не пустые там, где это меняет смысл: файловое чтение реально
отдаёт `kid_data.bin` (иначе `pop_kid_data_load` тихо сдаётся, `kdat_ok`
остаётся нулём и `play_seq` не делает ничего — трасса выходит из одного
застывшего кадра), клавиатура отвечает по набору «нажатых» скан-кодов
(так под тест попадают `read_input` и `read_user_control`, а не только
диспетчер), а обращения к соседям **журналируются** (`tk_log`) — можно
проверять «плита отвалилась и попросила перерисовку», а не только
координаты.
3. **`__banked`.** Модули физики помечены как банковые, SDCC генерирует на
них трамплин `___sdcc_bcall_ehl`, которому нужны `set_bank`/`get_bank`.
В тесте память плоская — `bank_stub.s` отдаёт нулевой банк и пустое
переключение, соблюдая контракт по регистрам.
Что харнесс НЕ покрывает и остаётся за MAME: отрисовка и окклюзия, реальные
переключения банков, тайминги кадра, настоящая клавиатура.
## Что нужно, чтобы двинуться дальше
- **Доступ к странице уровня.** `pop_level.c` ходит по абсолютным адресам
(`(uint8_t *)(LVL_DATA_OFF + …)` после `gfx_w0_map`). Для `pop_map` это не
потребовалось — карту комнаты в него ИНЪЕКТИРУЮТ через `pop_map_set`, —
но набор на сам `pop_level`/`pop_trob` в это упрётся. Решение то же, что
для `kid_data`: положить страницу по нужному адресу и поднять `CODE_LOC`.
- **Сценарии из `bug_closed.md`.** У каждой закрытой записи есть симптом и
ожидаемое поведение — готовый список регрессионных кейсов, который стоит
переносить в `t_phys` по мере надобности.
- **BUG-LOOSE-2** (гонка «уйти из комнаты раньше, чем долетит плита») —
через мост MAME воспроизвести не удалось, а на уровне логики это
несколько строк: заспавнить кусок, сменить комнату, тикать до
приземления, проверить щебень.
@@ -0,0 +1,25 @@
;--------------------------------------------------------------------------
; bank_stub.s — set_bank/get_bank для прогона под ucsim.
;
; Модули движка помечены __banked (в продукте они лежат в страницах W3), и
; SDCC генерирует на такие вызовы трамплин ___sdcc_bcall_ehl из своей
; библиотеки. Трамплин обязан найти две пользовательские подпрограммы:
;
; get_bank — вернуть в A номер текущего банка, прочие регистры не трогать
; set_bank — переключить банк на A, прочие регистры не трогать
;
; В тесте банков нет: вся память плоская, все модули слинкованы вместе.
; Поэтому «банк» всегда нулевой, а переключение — пустое. Важно только
; соблюсти контракт по регистрам, иначе трамплин испортит аргументы вызова.
;--------------------------------------------------------------------------
.area _CODE
.globl set_bank
.globl get_bank
get_bank::
ld a, #0x00
ret
set_bank::
ret
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
gen_kid_blob.py kid_data.bin как C-массив для тестов под ucsim.
Зачем. Таблицы кадров и seqtbl живут в EMM-странице, замапленной в W0, и
pop_kid ходит по ним АБСОЛЮТНЫМИ адресами от KD_DATA_OFF (0x100). В ucsim
никакого W0 нет, зато есть ровная 64-КБ память, поэтому тест просто кладёт
файл по 0x100 и глушит gfx_w0_map. Единственное следствие для сборки
код набора линкуется с --code-loc выше конца блоба (см. Makefile).
Файл берётся ровно тот, что уходит на устройство, pop_extract_kid_data.py
делает его из оригинального PRINCE.EXE, так что тест проверяет порт против
НАСТОЯЩИХ данных игры, а не против переписанной от руки таблицы.
"""
import sys
from pathlib import Path
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
data = src.read_bytes()
out = [
"/* Сгенерировано gen_kid_blob.py — не править руками. */",
'#include "kidblob.h"',
"",
f"const unsigned char kid_blob[{len(data)}] = {{",
]
for i in range(0, len(data), 16):
out.append(" " + ",".join(str(b) for b in data[i:i + 16]) + ",")
out += [
"};",
"",
f"const unsigned int kid_blob_size = {len(data)};",
"",
"/* Разложить блоб по KD_DATA_OFF. Без memcpy: тестовый бинарь не",
" * линкуется с libc, а тянуть ради этого z80-рантайм незачем. */",
"void kid_blob_install(void)",
"{",
" unsigned char *d = (unsigned char *)KID_BLOB_ADDR;",
" const unsigned char *s = kid_blob;",
f" unsigned int n = {len(data)};",
" while (n--) *d++ = *s++;",
"}",
"",
]
dst.write_text("\n".join(out))
print(f"gen_kid_blob: {src.name} -> {dst.name} ({len(data)} Б)")
@@ -0,0 +1,22 @@
/* Эталон трассы: crouch. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x6B, 0x81, 0x76, 0x00, 0x04, 0x01, 0x01, 0x3E, 0x06,
0x6C, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x41, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6D, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x42, 0x06,
0x6E, 0x84, 0x76, 0x00, 0x04, 0x01, 0x05, 0x4A, 0x06,
0x6F, 0x84, 0x76, 0x00, 0x04, 0x01, 0x05, 0x4B, 0x06,
0x70, 0x86, 0x76, 0x00, 0x04, 0x01, 0x05, 0x4E, 0x06,
0x71, 0x86, 0x76, 0x00, 0x04, 0x01, 0x05, 0x4F, 0x06,
0x72, 0x87, 0x76, 0x00, 0x04, 0x01, 0x05, 0x52, 0x06,
0x73, 0x87, 0x76, 0x00, 0x04, 0x01, 0x05, 0x53, 0x06,
0x74, 0x87, 0x76, 0x00, 0x04, 0x01, 0x05, 0x54, 0x06,
0x75, 0x83, 0x76, 0x00, 0x04, 0x01, 0x05, 0x57, 0x06,
0x76, 0x83, 0x76, 0x00, 0x04, 0x01, 0x05, 0x58, 0x06,
0x77, 0x83, 0x76, 0x00, 0x04, 0x01, 0x05, 0x59, 0x06,
0x0F, 0x83, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x83, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
@@ -0,0 +1,36 @@
/* Эталон трассы: fall_ledge. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x01, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x08, 0x00,
0x02, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x09, 0x00,
0x03, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0A, 0x00,
0x04, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0B, 0x00,
0x05, 0x5E, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0E, 0x00,
0x06, 0x61, 0x76, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
0x07, 0x64, 0x76, 0x00, 0x01, 0x01, 0x01, 0x14, 0x00,
0x08, 0x69, 0x76, 0x00, 0x02, 0x01, 0x01, 0x17, 0x00,
0x09, 0x6A, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x6C, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x70, 0x76, 0x00, 0x02, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x75, 0x76, 0x00, 0x03, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x77, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x7A, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x7E, 0x76, 0x00, 0x03, 0x01, 0x01, 0x14, 0x00,
0x08, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x17, 0x00,
0x09, 0x84, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x86, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x8A, 0x76, 0x00, 0x04, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x8F, 0x76, 0x00, 0x05, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x91, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x94, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x98, 0x76, 0x00, 0x05, 0x01, 0x01, 0x14, 0x00,
0x66, 0x9E, 0x79, 0x00, 0x06, 0x02, 0x03, 0x77, 0x02,
0x67, 0xA0, 0x7F, 0x00, 0x06, 0x02, 0x03, 0x7C, 0x02,
0x68, 0x9F, 0x88, 0x00, 0x06, 0x02, 0x03, 0x81, 0x02,
0x69, 0x9F, 0x94, 0x00, 0x06, 0x02, 0x03, 0x84, 0x02,
0x6A, 0x9E, 0xA6, 0x00, 0x06, 0x02, 0x04, 0xDE, 0x03,
0x6B, 0xA0, 0xB5, 0x00, 0x06, 0x02, 0x05, 0xC3, 0x06,
0x6C, 0xA2, 0xB5, 0x00, 0x06, 0x02, 0x05, 0xC6, 0x06,
0x6D, 0xA2, 0xB5, 0x00, 0x06, 0x02, 0x01, 0xC9, 0x06,
0x6E, 0xA3, 0xB5, 0x00, 0x06, 0x02, 0x05, 0x4A, 0x06,
0x6F, 0xA3, 0xB5, 0x00, 0x06, 0x02, 0x05, 0x4B, 0x06,
0x70, 0xA5, 0xB5, 0x00, 0x06, 0x02, 0x05, 0x4E, 0x06,
@@ -0,0 +1,22 @@
/* Эталон трассы: jump_up. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x43, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x12, 0x04,
0x44, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x13, 0x04,
0x45, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x14, 0x04,
0x46, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x15, 0x04,
0x47, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x16, 0x04,
0x48, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x17, 0x04,
0x49, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x18, 0x04,
0x4A, 0x80, 0x76, 0xFF, 0x05, 0x01, 0x01, 0x19, 0x04,
0x4B, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x1A, 0x04,
0x4C, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x1B, 0x04,
0x4D, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x1C, 0x04,
0x4E, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x1D, 0x04,
0x4F, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x1E, 0x04,
0x4F, 0x80, 0x72, 0xFF, 0x04, 0x01, 0x01, 0x21, 0x04,
0x4F, 0x80, 0x70, 0xFF, 0x04, 0x01, 0x01, 0x24, 0x04,
0x4F, 0x80, 0x70, 0xFF, 0x04, 0x01, 0x01, 0x25, 0x04,
0x4F, 0x80, 0x72, 0xFF, 0x04, 0x01, 0x01, 0x28, 0x04,
0x51, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0xB8, 0x03,
0x52, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x01, 0xB9, 0x03,
0x53, 0x80, 0x76, 0xFF, 0x04, 0x01, 0x05, 0xBC, 0x03,
@@ -0,0 +1,36 @@
/* Эталон трассы: loose. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x01, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x08, 0x00,
0x02, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x09, 0x00,
0x03, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0A, 0x00,
0x04, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0B, 0x00,
0x05, 0x5E, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0E, 0x00,
0x06, 0x61, 0x76, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
0x07, 0x64, 0x76, 0x00, 0x01, 0x01, 0x01, 0x14, 0x00,
0x08, 0x69, 0x76, 0x00, 0x02, 0x01, 0x01, 0x17, 0x00,
0x09, 0x6A, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x6C, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x70, 0x76, 0x00, 0x02, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x75, 0x76, 0x00, 0x03, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x77, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x7A, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x7E, 0x76, 0x00, 0x03, 0x01, 0x01, 0x14, 0x00,
0x08, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x17, 0x00,
0x09, 0x84, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x86, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x8A, 0x76, 0x00, 0x04, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x8F, 0x76, 0x00, 0x05, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x91, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x94, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x98, 0x76, 0x00, 0x05, 0x01, 0x01, 0x14, 0x00,
0x08, 0x9D, 0x76, 0x00, 0x06, 0x01, 0x01, 0x17, 0x00,
0x09, 0x9E, 0x76, 0x00, 0x06, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0xA0, 0x76, 0x00, 0x06, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0xA4, 0x76, 0x00, 0x06, 0x01, 0x01, 0x22, 0x00,
0x0C, 0xA9, 0x76, 0x00, 0x07, 0x01, 0x01, 0x25, 0x00,
0x0D, 0xAB, 0x76, 0x00, 0x07, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0xAE, 0x76, 0x00, 0x07, 0x01, 0x01, 0x2D, 0x00,
0x07, 0xB2, 0x76, 0x00, 0x07, 0x01, 0x01, 0x14, 0x00,
0x08, 0xB7, 0x76, 0x00, 0x08, 0x01, 0x01, 0x17, 0x00,
0x09, 0xB8, 0x76, 0x00, 0x08, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0xBA, 0x76, 0x00, 0x08, 0x01, 0x01, 0x1F, 0x00,
@@ -0,0 +1,36 @@
/* Эталон трассы: rjump_gap3. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x01, 0xB8, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x08, 0x00,
0x02, 0xB8, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x09, 0x00,
0x03, 0xB8, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x0A, 0x00,
0x04, 0xB8, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x0B, 0x00,
0x05, 0xB0, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x0E, 0x00,
0x06, 0xAD, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x11, 0x00,
0x07, 0xAA, 0x76, 0xFF, 0x08, 0x01, 0x01, 0x14, 0x00,
0x08, 0xA5, 0x76, 0xFF, 0x07, 0x01, 0x01, 0x17, 0x00,
0x09, 0xA4, 0x76, 0xFF, 0x07, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0xA2, 0x76, 0xFF, 0x07, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x9E, 0x76, 0xFF, 0x07, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x99, 0x76, 0xFF, 0x06, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x97, 0x76, 0xFF, 0x06, 0x01, 0x01, 0x2A, 0x00,
0x22, 0x95, 0x76, 0xFF, 0x06, 0x01, 0x01, 0x64, 0x01,
0x23, 0x90, 0x76, 0xFF, 0x06, 0x01, 0x01, 0x67, 0x01,
0x24, 0x8A, 0x76, 0xFF, 0x06, 0x01, 0x01, 0x6A, 0x01,
0x25, 0x87, 0x76, 0xFF, 0x05, 0x01, 0x01, 0x6D, 0x01,
0x26, 0x82, 0x76, 0xFF, 0x05, 0x01, 0x01, 0x72, 0x01,
0x27, 0x7B, 0x76, 0xFF, 0x05, 0x01, 0x01, 0x75, 0x01,
0x28, 0x6F, 0x73, 0xFF, 0x04, 0x01, 0x01, 0x7A, 0x01,
0x29, 0x67, 0x6A, 0xFF, 0x03, 0x01, 0x01, 0x7F, 0x01,
0x2A, 0x5F, 0x68, 0xFF, 0x02, 0x01, 0x01, 0x84, 0x01,
0x2B, 0x5B, 0x73, 0xFF, 0x01, 0x01, 0x01, 0x89, 0x01,
0x2C, 0x57, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x8E, 0x01,
0x07, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x14, 0x00,
0x32, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x05, 0x93, 0x04,
0x33, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x05, 0x94, 0x04,
0x34, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x05, 0x95, 0x04,
0x0F, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x00, 0x35, 0x00,
0x10, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x28, 0x01,
0x11, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x29, 0x01,
0x12, 0x50, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x2C, 0x01,
0x13, 0x4E, 0x76, 0xFF, 0x01, 0x01, 0x01, 0x2F, 0x01,
0x32, 0x52, 0x76, 0xFF, 0x01, 0x01, 0x05, 0x93, 0x04,
@@ -0,0 +1,26 @@
/* Эталон трассы: run_right. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x01, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x08, 0x00,
0x02, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x09, 0x00,
0x03, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0A, 0x00,
0x04, 0x56, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0B, 0x00,
0x05, 0x5E, 0x76, 0x00, 0x01, 0x01, 0x01, 0x0E, 0x00,
0x06, 0x61, 0x76, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
0x07, 0x64, 0x76, 0x00, 0x01, 0x01, 0x01, 0x14, 0x00,
0x08, 0x69, 0x76, 0x00, 0x02, 0x01, 0x01, 0x17, 0x00,
0x09, 0x6A, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x6C, 0x76, 0x00, 0x02, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x70, 0x76, 0x00, 0x02, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x75, 0x76, 0x00, 0x03, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x77, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x7A, 0x76, 0x00, 0x03, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x7E, 0x76, 0x00, 0x03, 0x01, 0x01, 0x14, 0x00,
0x08, 0x83, 0x76, 0x00, 0x04, 0x01, 0x01, 0x17, 0x00,
0x09, 0x84, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x86, 0x76, 0x00, 0x04, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x8A, 0x76, 0x00, 0x04, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x8F, 0x76, 0x00, 0x05, 0x01, 0x01, 0x25, 0x00,
0x0D, 0x91, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2A, 0x00,
0x0E, 0x94, 0x76, 0x00, 0x05, 0x01, 0x01, 0x2D, 0x00,
0x07, 0x98, 0x76, 0x00, 0x05, 0x01, 0x01, 0x14, 0x00,
0x08, 0x9D, 0x76, 0x00, 0x06, 0x01, 0x01, 0x17, 0x00,
@@ -0,0 +1,14 @@
/* Эталон трассы: stand. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x72, 0x76, 0xFF, 0x03, 0x01, 0x00, 0x35, 0x00,
@@ -0,0 +1,22 @@
/* Эталон трассы: step_shift. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x79, 0x72, 0x76, 0x00, 0x03, 0x01, 0x01, 0x6F, 0x05,
0x7A, 0x73, 0x76, 0x00, 0x03, 0x01, 0x01, 0x72, 0x05,
0x7B, 0x74, 0x76, 0x00, 0x03, 0x01, 0x01, 0x75, 0x05,
0x7C, 0x77, 0x76, 0x00, 0x03, 0x01, 0x01, 0x78, 0x05,
0x7D, 0x7B, 0x76, 0x00, 0x03, 0x01, 0x01, 0x7B, 0x05,
0x7E, 0x7E, 0x76, 0x00, 0x03, 0x01, 0x01, 0x7E, 0x05,
0x7F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x81, 0x05,
0x80, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x82, 0x05,
0x81, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x83, 0x05,
0x82, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x84, 0x05,
0x83, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x85, 0x05,
0x84, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x01, 0x86, 0x05,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
0x0F, 0x7D, 0x76, 0x00, 0x04, 0x01, 0x00, 0x35, 0x00,
@@ -0,0 +1,18 @@
/* Эталон трассы: turn. Снят record_golden.py — не править руками.
* Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */
0x2D, 0x7A, 0x76, 0xFF, 0x04, 0x01, 0x07, 0xD1, 0x01,
0x2E, 0x79, 0x76, 0xFF, 0x04, 0x01, 0x07, 0xD4, 0x01,
0x2F, 0x77, 0x76, 0xFF, 0x04, 0x01, 0x07, 0xD7, 0x01,
0x30, 0x78, 0x76, 0xFF, 0x04, 0x01, 0x07, 0xDA, 0x01,
0x01, 0x79, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x08, 0x00,
0x02, 0x79, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x09, 0x00,
0x03, 0x79, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x0A, 0x00,
0x04, 0x79, 0x76, 0xFF, 0x04, 0x01, 0x01, 0x0B, 0x00,
0x05, 0x71, 0x76, 0xFF, 0x03, 0x01, 0x01, 0x0E, 0x00,
0x06, 0x6E, 0x76, 0xFF, 0x03, 0x01, 0x01, 0x11, 0x00,
0x07, 0x6B, 0x76, 0xFF, 0x03, 0x01, 0x01, 0x14, 0x00,
0x08, 0x66, 0x76, 0xFF, 0x03, 0x01, 0x01, 0x17, 0x00,
0x09, 0x65, 0x76, 0xFF, 0x02, 0x01, 0x01, 0x1C, 0x00,
0x0A, 0x63, 0x76, 0xFF, 0x02, 0x01, 0x01, 0x1F, 0x00,
0x0B, 0x5F, 0x76, 0xFF, 0x02, 0x01, 0x01, 0x22, 0x00,
0x0C, 0x5A, 0x76, 0xFF, 0x02, 0x01, 0x01, 0x25, 0x00,
@@ -0,0 +1,18 @@
/*
* kidblob.h таблицы анимации Кида в памяти теста.
*
* KID_BLOB_ADDR обязан совпадать с KD_DATA_OFF из pop_kid.c: продукт
* адресует таблицы именно так, и в этом весь смысл под тестом работает
* тот же код доступа, что на устройстве, просто без маппинга W0.
*/
#ifndef POP_TESTS_KIDBLOB_H
#define POP_TESTS_KIDBLOB_H
#define KID_BLOB_ADDR 0x0100
extern const unsigned char kid_blob[];
extern const unsigned int kid_blob_size;
void kid_blob_install(void);
#endif
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
record_golden.py снять эталонные трассы для t_phys.
Зачем скрипт, а не один прогон. Отчёт tcheck ограничен 768 байтами лога, а
трасс на все сценарии сильно больше. Поэтому набор умеет снимать РОВНО
ОДИН сценарий за прогон (-DSC_RECORD=<n>), а перебор делает этот скрипт:
собирает набор N раз, каждый раз забирает из отчёта имя сценария и его
трассу и пишет golden/<имя>.h.
Эталон фиксирует ТЕКУЩЕЕ поведение. Это осознанно: набор страхует
поведение-сохраняющие правки (перенос физики Kid -> Char), а не доказывает
правильность. Правильность сверка с SDLPoP. Поэтому переснимать эталоны
можно только вместе с внятным объяснением, что и почему поменялось.
Использование:
python3 record_golden.py # снять все сценарии
python3 record_golden.py 7 9 # только эти номера
"""
import re
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
GOLDEN = HERE / "golden"
# Число сценариев = число TC_RUN в t_phys.c: список ведётся там, здесь его
# дублировать незачем.
NSCEN = len(re.findall(r"^\s*TC_RUN\(", (HERE / "t_phys.c").read_text(), re.M))
def record(n: int) -> str:
# make не видит смены -DSC_RECORD (в зависимостях только файлы), поэтому
# объектник набора сносим руками — иначе следующий прогон молча снимет
# трассу предыдущего сценария под новым именем.
for f in ("phys.rel", "phys.ihx"):
(HERE / "build" / f).unlink(missing_ok=True)
subprocess.run(["make", "-s", "build/phys.ihx", f"EXTRA_CFLAGS=-DSC_RECORD={n}"],
cwd=HERE, check=True)
p = subprocess.run(["make", "-s", "run-phys", f"EXTRA_CFLAGS=-DSC_RECORD={n}"],
cwd=HERE, capture_output=True, text=True)
out = p.stdout
m = re.search(r"^\s*### (\S+)\s*$", out, re.M)
if not m:
raise SystemExit(f"сценарий {n}: в отчёте нет строки '### имя'\n{out}")
name = m.group(1)
rows = re.findall(r"^\s*([0-9A-F]{18})\s*$", out, re.M)
if not rows:
raise SystemExit(f"сценарий {n} ({name}): трасса пустая\n{out}")
lines = [f"/* Эталон трассы: {name}. Снят record_golden.py — не править руками.",
" * Поля кадра: frame, x, y, dir, col, row, action, seq_lo, seq_hi. */"]
for r in rows:
lines.append(" " + ", ".join(f"0x{r[i:i+2]}" for i in range(0, 18, 2)) + ",")
GOLDEN.mkdir(exist_ok=True)
(GOLDEN / f"{name}.h").write_text("\n".join(lines) + "\n")
return f"{name}: {len(rows)} кадров"
def main() -> int:
nums = [int(a) for a in sys.argv[1:]] or list(range(1, NSCEN + 1))
for n in nums:
print(f"[{n}/{NSCEN}] " + record(n))
# Итоговая пересборка в обычном режиме, чтобы каталог не остался с
# объектниками, собранными под SC_RECORD.
subprocess.run(["make", "-s", "clean"], cwd=HERE, check=True)
print("эталоны сняты; проверить: make -C", HERE.name, "t_phys")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,163 @@
/*
* scene.c реализация покадрового раннера (см. scene.h).
*/
#include <stdint.h>
#include <kbd_raw.h>
#include "tcheck.h"
#include "pop_kid.h"
#include "pop_map.h"
#include "pop_ctrl.h"
#include "pop_geom.h"
#include "pop_guard.h"
#include "kid_data.h"
#include "scene.h"
#include "stubs.h"
#include "kidblob.h"
sc_rec sc_trace[SC_TRACE_MAX];
uint8_t sc_len;
static uint8_t sc_fg[30];
/* Кромки шва: соседей у сцены нет, поэтому по краям стена (тайл 20). */
static const uint8_t sc_edge[6] = { 20, 20, 20, 20, 20, 20 };
void sc_init(void)
{
kid_blob_install(); /* таблицы туда, куда смотрит pop_kid */
pop_kid_data_load("KID\\KID_DATA.BIN");
pop_kid_hp_newgame();
}
void sc_room(const uint8_t *fg, uint8_t room)
{
uint8_t i;
for (i = 0; i < 30; i++) { sc_fg[i] = fg[i]; tk_modif[i] = 0; }
pop_map_set(sc_fg);
pop_map_set_room(room);
/* Изолированная сцена: соседей нет. Тип TILE_WALL (20) с обеих сторон
* и сверху/снизу тогда решения физики зависят только от sc_fg, и
* трасса не поедет, если когда-нибудь поменяется загрузка кромок. */
pop_map_set_edges(0, 0, 0, 0, sc_edge, sc_edge);
tk_start_room = room;
tk_log_clear();
}
void sc_kid_at(uint8_t col, uint8_t row, int8_t dir)
{
kid_init(2 /* seq_2_stand */, (int8_t)col, (int8_t)row, dir);
pop_kid_hp_reset();
}
void sc_kid_at_x(uint8_t col, uint8_t row, uint8_t x, int8_t dir)
{
kid_init(2 /* seq_2_stand */, (int8_t)col, (int8_t)row, dir);
Kid.x = x; /* тайл задаёт ряд, X — фазу внутри тайла */
pop_determine_col(); /* колонку пересчитать под новый X */
pop_kid_hp_reset();
}
void sc_trace_clear(void) { sc_len = 0; }
static void sc_record(void)
{
sc_rec *r;
if (sc_len >= SC_TRACE_MAX) return;
r = &sc_trace[sc_len++];
r->frame = Kid.frame;
r->x = Kid.x;
r->y = Kid.y;
r->dir = Kid.direction;
r->col = Kid.curr_col;
r->row = Kid.curr_row;
r->action = Kid.action;
r->seq = (uint16_t)(Kid.curr_seq - SEQTBL_BASE);
}
uint8_t sc_run(uint8_t keys, uint8_t n)
{
uint8_t i, done = 0;
for (i = 0; i < n; i++) {
tk_keys_clear();
if (keys & SC_L) tk_key_down(KBD_LEFT);
if (keys & SC_R) tk_key_down(KBD_RIGHT);
if (keys & SC_U) tk_key_down(KBD_UP);
if (keys & SC_D) tk_key_down(KBD_DOWN);
if (keys & SC_SHIFT) tk_key_down(KBD_LSHIFT);
/* Порядок ровно как в главном цикле roomtest.c (kid_heal и
* отрисовка выпадают они ничего не решают). pop_loose_tick
* оставлен: он не рисование, а МЕХАНИКА досчитывает тряску и
* убирает провалившуюся плиту из карты, то есть меняет то, по чему
* физика в следующем кадре принимает решения. */
pop_ctrl_tick();
kid_tick();
pop_phys_tick();
pop_loose_tick();
sc_record();
done++;
if (sc_len >= SC_TRACE_MAX) break;
}
return done;
}
/* ---- сверка с эталоном ---------------------------------------------- */
void sc_expect(const uint8_t *golden, uint8_t nrec)
{
uint8_t i;
const uint8_t *g = golden;
tc_check((int16_t)sc_len, (int16_t)nrec, "sc_len", __LINE__);
for (i = 0; i < nrec && i < sc_len; i++, g += SC_REC_BYTES) {
const sc_rec *r = &sc_trace[i];
/* Номер кадра идёт первым полем каждой проверки: в отчёте видно,
* на каком именно кадре трасса разошлась. */
tc_check((int16_t)r->frame, (int16_t)g[0], "frame", i);
tc_check((int16_t)r->x, (int16_t)g[1], "x", i);
tc_check((int16_t)r->y, (int16_t)g[2], "y", i);
tc_check((int16_t)r->dir, (int16_t)(int8_t)g[3], "dir", i);
tc_check((int16_t)r->col, (int16_t)(int8_t)g[4], "col", i);
tc_check((int16_t)r->row, (int16_t)(int8_t)g[5], "row", i);
tc_check((int16_t)r->action, (int16_t)g[6], "action", i);
tc_check((int16_t)r->seq,
(int16_t)((uint16_t)g[7] | ((uint16_t)g[8] << 8)), "seq", i);
}
}
/* ---- снятие эталона -------------------------------------------------- */
static void put(char c)
{
if (tc_result.logn < TC_LOGSZ) tc_result.log[tc_result.logn++] = (uint8_t)c;
}
static void put_byte(uint8_t v)
{
static const char hex[] = "0123456789ABCDEF";
put(hex[v >> 4]); put(hex[v & 15]);
}
void sc_dump(const char *name)
{
uint8_t i;
tc_result.magic = TC_MAGIC;
tc_result.total = 1;
tc_result.failed = 1; /* иначе раннер лог не напечатает */
put('#'); put('#'); put('#'); put(' ');
while (*name) put(*name++);
put('\n');
for (i = 0; i < sc_len; i++) {
const sc_rec *r = &sc_trace[i];
put_byte(r->frame);
put_byte(r->x);
put_byte(r->y);
put_byte((uint8_t)r->dir);
put_byte((uint8_t)r->col);
put_byte((uint8_t)r->row);
put_byte(r->action);
put_byte((uint8_t)r->seq);
put_byte((uint8_t)(r->seq >> 8));
put('\n');
}
}
@@ -0,0 +1,88 @@
/*
* scene.h покадровый сценарный раннер для тестов физики Кида.
*
* Почему покадровый, а не «вызвать функцию и сверить возврат». Порт
* seg005/seg006 это конечный автомат: диспетчер выбирает
* последовательность, интерпретатор проигрывает её кадр за кадром, физика
* между кадрами двигает персонажа и решает, не пора ли падать. Ошибка
* почти всегда проявляется не в одной функции, а в РАСХОЖДЕНИИ ТРАЕКТОРИИ
* через несколько кадров. Плюс главный риск ближайшего рефакторинга
* (Kid -> Char) не арифметика, а то, КТО владеет окном Char внутри кадра;
* поймать это можно только прогоном полного кадра, как в главном цикле:
*
* pop_ctrl_tick(); // ввод -> control(): смена последовательности
* kid_tick(); // play_seq: следующий кадр
* pop_phys_tick(); // падение/приземление/стена
*
* Отсюда формат: сцена (комната + стартовая поза) + скрипт ввода, на
* выходе трасса состояния Кида по кадрам. Сравнение трассы с эталоном
* и есть тест.
*/
#ifndef POP_TESTS_SCENE_H
#define POP_TESTS_SCENE_H
#include <stdint.h>
/* Одна запись трассы: то, чем оперируют решения движка. curr_seq —
* СМЕЩЕНИЕ от SEQTBL_BASE (адрес зависит от базы, смещение нет). */
typedef struct {
uint8_t frame;
uint8_t x, y;
int8_t dir;
int8_t col, row;
uint8_t action;
uint16_t seq;
} sc_rec;
#define SC_TRACE_MAX 48
extern sc_rec sc_trace[SC_TRACE_MAX];
extern uint8_t sc_len;
/* Кнопки скрипта ввода — битовая маска на кадр. */
#define SC_L 0x01
#define SC_R 0x02
#define SC_U 0x04
#define SC_D 0x08
#define SC_SHIFT 0x10
/* Инициализация окружения набора: таблицы анимации, обнуление состояния.
* Звать один раз в начале прогона. */
void sc_init(void);
/* Задать комнату: fg[30] — коды тайлов по рядам (row*10+col), room —
* номер (нужен trob'ам). Модификаторы обнуляются; ставить их тест может
* сам через tk_modif. Соседи комнаты стены (изолированная сцена). */
void sc_room(const uint8_t *fg, uint8_t room);
/* Поставить Кида: колонка/ряд задают позицию по центру тайла, как это
* делает do_startpos (seg003) так сцена описывается в терминах карты, а
* не в пикселях. */
void sc_kid_at(uint8_t col, uint8_t row, int8_t dir);
/* То же, но с точным X: тайл задаёт ряд и пол под ногами, X — фазу внутри
* тайла (нужно, когда исход зависит от того, где именно Кид стоит). */
void sc_kid_at_x(uint8_t col, uint8_t row, uint8_t x, int8_t dir);
/* Прогнать n кадров с ЗАЖАТОЙ маской keys, записывая трассу.
* Возвращает число записанных кадров (обрезается по SC_TRACE_MAX). */
uint8_t sc_run(uint8_t keys, uint8_t n);
/* Сбросить трассу (перед новым сценарием). */
void sc_trace_clear(void);
/* Сверить трассу с эталоном: golden — плоский массив по 8 байт на кадр
* (frame,x,y,dir,col,row,action,seq_lo,seq_hi 9), nrec записей.
* Расхождения уходят в отчёт tcheck с номером кадра и поля. */
void sc_expect(const uint8_t *golden, uint8_t nrec);
#define SC_REC_BYTES 9
/* Снятие эталона. Лог tcheck всего 768 байт — на все сценарии сразу трасс
* не хватит, поэтому за прогон снимается РОВНО ОДИН сценарий (его номер
* задаёт -DSC_RECORD=<n>), а перебором занимается record_golden.py.
* Формат: строка «### имя», дальше по строке на кадр, по два хекс-символа
* на байт. Раннер печатает лог только при провале sc_dump ставит
* failed=1 намеренно. */
void sc_dump(const char *name);
#endif
@@ -0,0 +1,200 @@
/*
* stubs.c окружение движка для прогона под ucsim_z80.
*
* Под тестом крутится НАСТОЯЩАЯ физика (pop_map) с настоящим
* интерпретатором последовательностей (pop_kid): это единственный способ
* проверить порт seg005/seg006, потому что вся его суть в точных
* величинах кадров и seqtbl. А вот всё, что вокруг, в ucsim не существует:
* видеопамять, EMM-страницы, файлы, клавиатура. Здесь они заглушены.
*
* Три категории:
* 1. libc/libbgi (графика, память, файлы, клавиатура) пустышки. Код
* отрисовки из pop_kid.c линкуется, но не вызывается.
* 2. Соседние модули движка (pop_bg/pop_trob/pop_level/pop_redraw)
* минимальные модели: room_modif живёт здесь массивом, пометки
* перерисовки и падающие куски ЖУРНАЛИРУЮТСЯ (тест может проверить,
* что физика их дёрнула), уровень отдаёт стартовую комнату.
* 3. Состояние стража просто данные: страж в тестах физики не тикает,
* но pop_map/pop_ctrl на его поля смотрят.
*
* Клавиатура сделана НЕ пустышкой: `tk_keys` набор «нажатых» скан-кодов,
* и `kbd_raw_down` отвечает по нему. Так под тест попадают и read_input, и
* read_user_control, то есть весь путь ввода, а не только диспетчер.
*/
#include <stdint.h>
#include <graphics.h>
#include <gfx.h>
#include <sprite.h>
#include <sprinter.h>
#include <sprinter_mem.h>
#include "pop_kid.h"
#include "pop_guard.h"
#include "pop_bg.h"
#include "pop_map.h"
#include "pop_trob.h"
#include "pop_level.h"
#include "pop_redraw.h"
#include "_pop_draw.h"
#include "stubs.h"
#include "kidblob.h"
/* ---- 1. libc / libbgi ---------------------------------------------- */
uint8_t _gfx_visible_page, _gfx_draw_page, _gfx_bank;
/* ISR-стаб W0-страницы: продукт берёт его АДРЕС и вписывает в страницу.
* Здесь достаточно, чтобы символ существовал. */
void _gfx_w0_isr(void) { }
void gfx_w0_map(uint8_t page) { (void)page; }
void gfx_w0_unmap(void) { }
void gfx_blit_cols(int x, int y, const void *img, uint8_t flip)
{ (void)x; (void)y; (void)img; (void)flip; }
void gfx_blit_cols_part(int x, int y, const void *img, uint8_t flip,
int skip, int rows)
{ (void)x; (void)y; (void)img; (void)flip; (void)skip; (void)rows; }
void gfx_blit_cols_part_w(int x, int y, const void *img, uint8_t flip,
int skip, int rows, uint8_t maxw)
{ (void)x; (void)y; (void)img; (void)flip; (void)skip; (void)rows; (void)maxw; }
void gfx_blit_cols_part_noclip(int x, int y, const void *img, uint8_t flip,
uint8_t skip, uint8_t rows)
{ (void)x; (void)y; (void)img; (void)flip; (void)skip; (void)rows; }
int atlas_load(atlas_t *a, const char *path) { (void)a; (void)path; return -1; }
void atlas_free(atlas_t *a) { (void)a; }
const void *atlas_image(const atlas_t *a, uint8_t idx)
{ (void)a; (void)idx; return 0; }
/* EMM: страниц нет, но блок обязан выделиться — иначе pop_kid_data_load
* сдастся на первом же шаге и молча оставит kdat_ok = 0, а play_seq тогда
* просто ничего не делает (и трасса выходит из одного застывшего кадра). */
uint8_t mem_alloc_pages_bios(uint8_t n) { (void)n; return 1; }
void mem_free_block_bios(uint8_t blk) { (void)blk; }
uint8_t mem_get_page_bios(uint8_t blk, uint8_t idx) { (void)blk; (void)idx; return 1; }
/* Файловая система: единственный «файл» — таблицы анимации. Сделано не
* пустышкой, а рабочим чтением, чтобы pop_kid_data_load отработал целиком
* (включая правку ISR-стаба страницы) и сам взвёл свой kdat_ok тест не
* лезет во внутренние статики продукта. Данные, по которым потом ходят
* аксессоры, кладёт kid_blob_install по KID_BLOB_ADDR: на устройстве туда
* же их приводит маппинг W0. */
int open(const char *p, int f) { (void)p; (void)f; return 3; }
int read(int fd, void *b, int n)
{
unsigned char *d = (unsigned char *)b;
const unsigned char *s = kid_blob;
unsigned int i, cnt = kid_blob_size;
if (fd != 3) return -1;
if ((unsigned int)n < cnt) cnt = (unsigned int)n;
for (i = 0; i < cnt; i++) d[i] = s[i];
return (int)cnt;
}
int close(int fd) { (void)fd; return 0; }
/* ---- клавиатура: управляемая тестом --------------------------------- */
uint16_t tk_keys[TK_KEYS_MAX];
int kbd_raw_open(void) { return 0; }
void kbd_raw_close(void) { }
void kbd_raw_sync(void) { }
uint8_t kbd_raw_down(uint16_t code)
{
uint8_t i;
for (i = 0; i < TK_KEYS_MAX; i++)
if (tk_keys[i] == code) return 1;
return 0;
}
void tk_keys_clear(void)
{
uint8_t i;
for (i = 0; i < TK_KEYS_MAX; i++) tk_keys[i] = 0;
}
void tk_key_down(uint16_t code)
{
uint8_t i;
for (i = 0; i < TK_KEYS_MAX; i++)
if (tk_keys[i] == 0) { tk_keys[i] = code; return; }
}
/* ---- 2. соседние модули движка -------------------------------------- */
/* room_modif текущей комнаты: пики (счётчик фазы), ворота (высота),
* зелья (тип). Физика читает и пишет его напрямую через pop_trob_modif. */
uint8_t tk_modif[30];
uint8_t tk_start_room;
uint8_t *pop_trob_modif(uint8_t room) __banked { (void)room; return tk_modif; }
uint8_t pop_level_start_room(void) { return tk_start_room; }
/* Журнал: что физика попросила у соседей. Тест сверяет ФАКТ и параметры
* (какой тайл помечен, где заспавнен кусок) заглушка-пустышка этого не
* дала бы, а именно такие баги (BUG-GATE-ANIM-1) мы и ловим. */
tk_log_t tk_log;
static void log_put(uint8_t kind, uint8_t a, uint8_t b, uint8_t c)
{
if (tk_log.n >= TK_LOG_MAX) { tk_log.overflow = 1; return; }
tk_log.e[tk_log.n].kind = kind;
tk_log.e[tk_log.n].a = a;
tk_log.e[tk_log.n].b = b;
tk_log.e[tk_log.n].c = c;
tk_log.n++;
}
void tk_log_clear(void) { tk_log.n = 0; tk_log.overflow = 0; }
uint8_t tk_log_count(uint8_t kind)
{
uint8_t i, c = 0;
for (i = 0; i < tk_log.n; i++) if (tk_log.e[i].kind == kind) c++;
return c;
}
void pop_add_trob(uint8_t room, uint8_t tilepos, int8_t type) __banked
{ log_put(TK_TROB, room, tilepos, (uint8_t)type); }
void pop_trigger_button(uint8_t room, uint8_t tilepos,
uint8_t button_type, uint8_t modifier) __banked
{ (void)modifier; log_put(TK_BUTTON, room, tilepos, button_type); }
void pop_set_redraw(uint8_t tilepos, uint8_t kind, uint8_t pages)
{ log_put(TK_REDRAW, tilepos, kind, pages); }
void pop_set_redraw_above(uint8_t col, uint8_t kind, uint8_t pages)
{ log_put(TK_REDRAW_ABOVE, col, kind, pages); }
void pop_loose_mob_spawn(int row, int col) __banked
{ log_put(TK_MOB_SPAWN, (uint8_t)row, (uint8_t)col, 0); }
void pop_loose_mob_tick(void) __banked { }
void pop_loose_mob_room_changed(void) __banked { }
uint8_t pop_loose_mob_pos(int *col, int *y) __banked
{ (void)col; (void)y; return 0; }
/* Отрисовочные хвосты, которые физика дёргает напрямую. */
void pop_fore_set_clip(int x, int y, int w, int h) __banked
{ (void)x; (void)y; (void)w; (void)h; }
void pop_clip_sprite(int x, int w, int sy_top, int h) __banked
{ (void)x; (void)w; (void)sy_top; (void)h; }
void pop_heal_fast(int x, int y, int w, int h)
{ (void)x; (void)y; (void)w; (void)h; }
/* ---- 3. состояние стража -------------------------------------------- */
pop_char_t Guard;
kframe pop_gframe;
int8_t can_guard_see_kid;
int8_t guard_refrac, kid_sword_strike;
int8_t offguard, holding_sword;
/* «Кид нашумел» — физика взводит его на ударе о стену, приземлении и
* обрушенной плите (BUG-GUARD-DEAF-1); читает только автопилот стража,
* которого в наборе нет. */
int8_t is_guard_notice;
@@ -0,0 +1,43 @@
/*
* stubs.h интерфейс тестового окружения (см. stubs.c).
*
* Что тест может задавать: «нажатые» клавиши (tk_key_down) и модификаторы
* тайлов комнаты (tk_modif). Что может проверять: журнал обращений
* физики к соседним модулям (tk_log).
*/
#ifndef POP_TESTS_STUBS_H
#define POP_TESTS_STUBS_H
#include <stdint.h>
/* ---- клавиатура ------------------------------------------------------ */
#define TK_KEYS_MAX 6
extern uint16_t tk_keys[TK_KEYS_MAX];
void tk_keys_clear(void);
void tk_key_down(uint16_t code);
/* ---- комната --------------------------------------------------------- */
extern uint8_t tk_modif[30]; /* room_modif текущей комнаты */
extern uint8_t tk_start_room; /* что вернёт pop_level_start_room */
/* ---- журнал обращений ------------------------------------------------ */
#define TK_LOG_MAX 24
enum {
TK_TROB = 1, /* a=room b=tilepos c=type */
TK_BUTTON, /* a=room b=tilepos c=type */
TK_REDRAW, /* a=tilepos b=kind c=pages */
TK_REDRAW_ABOVE, /* a=col b=kind c=pages */
TK_MOB_SPAWN /* a=row b=col */
};
typedef struct { uint8_t kind, a, b, c; } tk_log_e;
typedef struct { uint8_t n, overflow; tk_log_e e[TK_LOG_MAX]; } tk_log_t;
extern tk_log_t tk_log;
void tk_log_clear(void);
/* Сколько записей данного вида в журнале (0 = не было). */
uint8_t tk_log_count(uint8_t kind);
#endif
@@ -0,0 +1,146 @@
/*
* t_geom.c тесты pop_geom.c (геометрия комнаты + PRNG оригинала).
*
* Модуль выбран первым, потому что не тянет за собой ничего: ни графики,
* ни данных уровня, ни клавиатуры только свой заголовок.
*
* Главный тест здесь geom_lcg_matches_reference. В pop_geom.c LCG
* оригинала (s = s*214013 + 2531011) написан на ассемблере по схеме
* Горнера ради обхода __mullong, и заявка «бит-в-бит как в SDLPoP» до сих
* пор держалась только на комментарии. Тест сверяет рукописный asm с
* наивной 32-битной формулой на каждом шаге и по возвращаемому значению,
* и по обеим половинам сида.
*/
#include "tcheck.h"
#include "pop_geom.h"
/* ---- геометрия ------------------------------------------------------ */
TC_TEST(geom_xbump_layout)
{
/* Шаг колонок равномерный и равен TILE_SIZEX — на этом стоит вся
* арифметика колонок (x_bump[col + FIRST_ONSCREEN_COLUMN]). */
uint8_t i;
for (i = 1; i < 20; i++)
TC_EQ(pop_x_bump[i] - pop_x_bump[i - 1], TILE_SIZEX);
/* Центр тайла колонки 0 — та самая величина, которую кладут в Char.x
* do_startpos и pos_guards (seg003). */
TC_EQ(pop_x_bump[0 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 72);
TC_EQ(pop_x_bump[9 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 198);
}
TC_TEST(geom_yland_rows)
{
/* y_land[row + 1]; [0] — ряд «над комнатой». */
TC_EQ(pop_y_land[0], -8);
TC_EQ(pop_y_land[1], 55);
TC_EQ(pop_y_land[2], 118);
TC_EQ(pop_y_land[3], 181);
TC_EQ(pop_y_land[4], 244);
/* Шаг между рядами — TILE_SIZEY. */
TC_EQ(pop_y_land[2] - pop_y_land[1], TILE_SIZEY);
TC_EQ(pop_y_land[3] - pop_y_land[2], TILE_SIZEY);
}
TC_TEST(geom_y_to_row)
{
/* Пол каждого ряда должен давать номер этого ряда. */
TC_EQ(pop_y_to_row(pop_y_land[1]), 0);
TC_EQ(pop_y_to_row(pop_y_land[2]), 1);
TC_EQ(pop_y_to_row(pop_y_land[3]), 2);
/* Ряд над комнатой. */
TC_EQ(pop_y_to_row(pop_y_land[0]), -1);
/* Оборот mod 4: ряд «под комнатой» сворачивается обратно в -1 —
* ровно на это опирается спавн падающего куска у нижней кромки. */
TC_EQ(pop_y_to_row(pop_y_land[4]), -1);
}
/* ---- PRNG: сверка asm-LCG с эталонной формулой ---------------------- */
#if POP_PRANDOM_EXACT
#define LCG_ITERS 128
TC_TEST(geom_lcg_matches_reference)
{
pop_rnd_t s;
unsigned long ref = 12345UL; /* произвольный ненулевой старт */
uint16_t i, got, want;
pop_prandom_set(s, 12345u);
for (i = 0; i < LCG_ITERS; i++) {
got = pop_prandom(&s, 255u);
ref = ref * 214013UL + 2531011UL;
want = (uint16_t)((uint16_t)(ref >> 16) % 256u);
/* Обрываемся на первом расхождении: иначе одна ошибка в умножении
* забила бы весь буфер отчёта однотипными строками. */
if (got != want ||
s.lo != (uint16_t)ref ||
s.hi != (uint16_t)(ref >> 16)) {
TC_EQ(got, want);
TC_EQ(s.lo, (uint16_t)ref);
TC_EQ(s.hi, (uint16_t)(ref >> 16));
break;
}
}
TC_EQ(i, LCG_ITERS); /* прошли всю дистанцию без расхождений */
}
TC_TEST(geom_lcg_seed_zero)
{
/* Ноль — законный сид (кладку сеют номером комнаты + ряд + колонка,
* что вполне даёт 0). У LCG ноль не является неподвижной точкой
* убеждаемся, что asm это воспроизводит. */
pop_rnd_t s;
unsigned long ref = 0UL;
pop_prandom_set(s, 0u);
pop_prandom(&s, 255u);
ref = ref * 214013UL + 2531011UL;
TC_EQ(s.lo, (uint16_t)ref);
TC_EQ(s.hi, (uint16_t)(ref >> 16));
}
#endif /* POP_PRANDOM_EXACT */
TC_TEST(geom_prandom_range)
{
/* Оба генератора обязаны укладываться в 0..maxv, в том числе когда
* maxv+1 НЕ степень двойки (там другая ветка pop_rnd_fit). */
pop_rnd_t s;
uint16_t i, v, seen_hi = 0;
uint16_t out_of_range = 0;
pop_prandom_set(s, 1u);
for (i = 0; i < 200; i++) {
v = pop_prandom(&s, 4u); /* n = 5, не степень двойки */
if (v > 4) out_of_range++;
if (v == 4) seen_hi = 1;
}
TC_EQ(out_of_range, 0);
TC_TRUE(seen_hi); /* верхняя граница достижима */
pop_prandom_set(s, 7u);
out_of_range = 0;
for (i = 0; i < 64; i++) {
v = pop_prandom(&s, 1u); /* n = 2, маска */
if (v > 1) out_of_range++;
}
TC_EQ(out_of_range, 0);
}
int main(void)
{
TC_RUN(geom_xbump_layout);
TC_RUN(geom_yland_rows);
TC_RUN(geom_y_to_row);
#if POP_PRANDOM_EXACT
TC_RUN(geom_lcg_matches_reference);
TC_RUN(geom_lcg_seed_zero);
#endif
TC_RUN(geom_prandom_range);
return 0;
}
@@ -0,0 +1,159 @@
/*
* t_grab.c прыжок с места через провал в три тайла с зацепом за дальнюю
* кромку (уровень 2, комната 9: Кид на краю (1,1) прыгает и должен
* повиснуть на (1,5)).
*
* Зачем отдельный набор. Трюк требует точной фазы и вручную выходит далеко
* не всегда в SDLPoP тоже. Поэтому проверяем не «получилось с первого
* раза», а то, что действительно важно: **окно существует и достижимо**.
* Разница между «сложно» и «невозможно» это разница между «так в
* оригинале» и «баг у нас», и глазами в MAME её не различить.
*
* Два утверждения:
* 1. есть хотя бы одна стартовая X, из которой зацеп удаётся;
* 2. короткий шаг (Shift+вперёд) приводит Кида В ЭТО окно иначе окно
* было бы недостижимо для игрока, что равно его отсутствию.
*
* При провале в отчёт уходит карта исходов по всей ширине плиты:
* G зацепился, L приземлился на дальнюю плиту, . сорвался.
*
* Механику зацепа задаёт check_grab (seg006:0A28): она работает ТОЛЬКО на
* кадрах 102..105 (начало падения) и только при зажатом Shift. mininim,
* для сравнения, разрешает повиснуть прямо в дуге прыжка (kid-jump.c,
* кадры 7..10) но это переработка, а не порт, и источник истины у нас
* SDLPoP.
*/
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#define E 0
#define F 1
#define P 3
#define W 20
/* Уровень 2, комната 9, ряд 1: факел(0), кнопка(1), провал(2,3,4),
* колонна(5). Ряд 2 под провалом пуст падение уходит из комнаты. */
static const uint8_t room9[30] = {
E, E, E, E, E, W, W, W, W, W,
F, F, E, E, E, P, F, F, F, P,
W, W, E, E, E, P, F, W, W, W,
};
/* Судить по curr_row нельзя: у улетающего вниз Кида ряд заворачивается
* (0,1,2,0,), и падение выглядит как «остался наверху». Надёжный признак
* ДЕЙСТВИЕ: 2 = вис/подтягивание, 6 = вис у стены. */
#define ACT_STAND 0
#define ACT_RUN_JUMP 1
#define ACT_HANG_CLIMB 2
#define ACT_HANG_STRAIGHT 6
#define X_LO 76
#define X_HI 99
static char probe(uint8_t x, uint8_t k1, uint8_t k2)
{
uint8_t i;
sc_room(room9, 9);
sc_kid_at_x(1, 1, x, 0 /* лицом вправо */);
sc_trace_clear();
sc_run(k1, 4); /* фаза 1: ↑+вперёд — прыжок с места */
sc_run(k2, 26); /* фаза 2: Shift — попытка зацепа */
for (i = 0; i < sc_len; i++) {
uint8_t a = sc_trace[i].action;
if (a == ACT_HANG_CLIMB || a == ACT_HANG_STRAIGHT) return 'G';
}
i = sc_len ? (uint8_t)(sc_len - 1) : 0;
if (sc_trace[i].col >= 5 &&
(sc_trace[i].action == ACT_STAND || sc_trace[i].action == ACT_RUN_JUMP))
return 'L';
return '.';
}
static void put(char c)
{
if (tc_result.logn < TC_LOGSZ) tc_result.log[tc_result.logn++] = (uint8_t)c;
}
static void puts_(const char *s) { while (*s) put(*s++); }
/* Карта исходов — только когда что-то сломалось: по ней сразу видно,
* окно съехало или исчезло. */
static void dump_map(const char *tag, uint8_t k1, uint8_t k2)
{
uint8_t x;
puts_(tag);
for (x = X_LO; x <= X_HI; x++) put(probe(x, k1, k2));
put('\n');
}
/* Куда встаёт Кид после n-го короткого шага от x0. */
static uint8_t step_x(uint8_t x0, uint8_t nsteps)
{
uint8_t n, fx = x0;
sc_room(room9, 9);
sc_kid_at_x(1, 1, x0, 0);
for (n = 0; n < nsteps; n++) {
sc_trace_clear();
sc_run(SC_SHIFT | SC_R, 4); /* шаг = нажатие, не удержание */
sc_run(0, 6); /* отпустить, дать доиграть */
if (sc_len) fx = sc_trace[sc_len - 1].x;
}
return fx;
}
TC_TEST(grab_window_exists)
{
uint8_t x, n = 0, lo = 0, hi = 0;
for (x = X_LO; x <= X_HI; x++)
if (probe(x, SC_U | SC_R, SC_SHIFT) == 'G') {
if (!n) lo = x;
hi = x;
n++;
}
/* Окно обязано существовать: без него трюк непроходим в принципе, а
* уровень 2 без него не пройти. */
TC_TRUE(n > 0);
if (!n) {
dump_map("no-window: ", SC_U | SC_R, SC_SHIFT);
} else {
/* Границы фиксируем: если поедут — это осознанное изменение физики,
* а не случайность. */
TC_EQ(lo, 91);
TC_EQ(hi, 95);
}
}
TC_TEST(grab_window_reachable_by_safe_step)
{
/* Игрок доходит до кромки короткими шагами. Если они не попадают в
* окно, существование окна ничего не стоит. */
TC_EQ(step_x(80, 1), 91);
TC_EQ(step_x(80, 2), 95);
}
TC_TEST(grab_same_for_all_key_patterns)
{
/* Живой игрок держит «вперёд» и жмёт Shift по-разному. Исход не должен
* зависеть от того, отпущена ли стрелка и когда нажат Shift. */
uint8_t x;
for (x = X_LO; x <= X_HI; x++) {
char a = probe(x, SC_U | SC_R, SC_SHIFT);
char b = probe(x, SC_U | SC_R, SC_SHIFT | SC_R);
char c = probe(x, SC_U | SC_R | SC_SHIFT, SC_SHIFT | SC_R);
TC_EQ(b, a);
TC_EQ(c, a);
}
}
int main(void)
{
sc_init();
TC_RUN(grab_window_exists);
TC_RUN(grab_window_reachable_by_safe_step);
TC_RUN(grab_same_for_all_key_patterns);
return 0;
}
@@ -0,0 +1,248 @@
/*
* t_phys.c характеризация физики Кида (pop_map + pop_kid + pop_ctrl).
*
* Назначение набора СТРАХОВКА ПОД РЕФАКТОРИНГ, а не проверка
* правильности. Эталоны здесь сняты с текущего билда, то есть
* консервируют и текущие баги; правильность по-прежнему проверяется
* против SDLPoP. Смысл в другом: перенос физики с `Kid.` на `Char.`
* обязан быть поведение-сохраняющим, и «трасса до == трасса после» ловит
* ровно тот класс ошибок, который там возможен.
*
* Поэтому при осознанном изменении поведения эталон ПЕРЕСНИМАЕТСЯ (см.
* SC_RECORD ниже), а в коммите должно быть написано, почему трасса
* поменялась. Молча обновлённый эталон обесценивает весь набор.
*
* Снятие эталона:
* make -C applications/PoP/roomtest/tests-host t_phys SC_RECORD=1
* Трассы уходят в отчёт tcheck готовыми C-строками вставить в golden_*.
*/
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
/* Коды тайлов (pop_map): 0 пусто, 1 пол, 3 колонна, 11 loose, 20 стена. */
#define E 0
#define F 1
#define P 3
#define L 11
#define W 20
/* Ровный пол во всю комнату: базовая сцена для ходьбы/бега/разворота. */
static const uint8_t room_flat[30] = {
W, E, E, E, E, E, E, E, E, E,
W, F, F, F, F, F, F, F, F, F,
W, W, W, W, W, W, W, W, W, W,
};
/* Пол с обрывом после колонки 5: падение с ряда 1 на ряд 2. */
static const uint8_t room_ledge[30] = {
W, E, E, E, E, E, E, E, E, E,
W, F, F, F, F, F, E, E, E, E,
W, F, F, F, F, F, F, F, F, W,
};
/* Проваливающаяся плита на (1,4) — сцена под loose-механику. */
static const uint8_t room_loose[30] = {
W, E, E, E, E, E, E, E, E, E,
W, F, F, F, L, F, F, F, F, F,
W, F, F, F, F, F, F, F, F, W,
};
/* Провал в ТРИ пустых тайла (колонки 2,3,4) — геометрия из BUG-RJUMP-1
* (ур. 2, комнаты 1 и 9). Разбег-прыжок влево с колонки 5 обязан
* доставать до колонки 1; у нас Кид сваливается в провал. */
static const uint8_t room_gap3[30] = {
W, E, E, E, E, E, E, E, E, E,
W, F, E, E, E, F, F, F, F, F,
W, F, F, F, F, F, F, F, F, W,
};
/* SC_RECORD=<n> — снять эталон СЦЕНАРИЯ n (нумерация в порядке вызовов в
* main). По одному за прогон: лог tcheck 768 байт, на все трассы сразу
* его не хватает. Перебором занимается record_golden.py. */
#ifndef SC_RECORD
#define SC_RECORD 0
#endif
/* Снять или сверить — одной строкой в каждом сценарии, чтобы режимы не
* разъезжались. Имя сценария = имя файла эталона. */
#if SC_RECORD
static uint8_t sc_idx;
#define SC_CHECK(g, nm) \
do { if (++sc_idx == (SC_RECORD)) sc_dump(nm); } while (0)
#else
#define SC_CHECK(g, nm) sc_expect((g), (uint8_t)(sizeof(g) / SC_REC_BYTES))
#endif
/* ---- эталоны (сняты SC_RECORD=1) ------------------------------------ *
* В режиме снятия эталонов файлов golden/ ещё нет и не должно быть:
* первый прогон их и порождает. */
#if !SC_RECORD
static const uint8_t golden_stand[] = {
#include "golden/stand.h"
};
static const uint8_t golden_run_right[] = {
#include "golden/run_right.h"
};
static const uint8_t golden_step_shift[] = {
#include "golden/step_shift.h"
};
static const uint8_t golden_turn[] = {
#include "golden/turn.h"
};
static const uint8_t golden_crouch[] = {
#include "golden/crouch.h"
};
static const uint8_t golden_jump_up[] = {
#include "golden/jump_up.h"
};
static const uint8_t golden_fall_ledge[] = {
#include "golden/fall_ledge.h"
};
static const uint8_t golden_loose[] = {
#include "golden/loose.h"
};
static const uint8_t golden_rjump_gap3[] = {
#include "golden/rjump_gap3.h"
};
#else
/* SC_RECORD: имена нужны только чтобы SC_CHECK() раскрылся. */
#define golden_stand 0
#define golden_run_right 0
#define golden_step_shift 0
#define golden_turn 0
#define golden_crouch 0
#define golden_jump_up 0
#define golden_fall_ledge 0
#define golden_loose 0
#define golden_rjump_gap3 0
#endif
/* ---- сценарии -------------------------------------------------------- */
TC_TEST(phys_stand_is_stable)
{
/* Никакого ввода: Кид на ровном полу обязан стоять неподвижно. Тест
* дешёвый, но ловит самое обидное «сам поехал» после правки
* геометрии или окна Char. */
sc_room(room_flat, 1);
sc_kid_at(3, 1, -1);
sc_trace_clear();
sc_run(0, 12);
SC_CHECK(golden_stand, "stand");
}
TC_TEST(phys_run_right)
{
sc_room(room_flat, 1);
sc_kid_at(1, 1, 0);
sc_trace_clear();
sc_run(SC_R, 24);
SC_CHECK(golden_run_right, "run_right");
}
TC_TEST(phys_step_with_shift)
{
/* Shift + вперёд — осторожный шаг: одна из самых чувствительных к
* distance_to_edge веток. */
sc_room(room_flat, 1);
sc_kid_at(3, 1, 0);
sc_trace_clear();
sc_run(SC_R | SC_SHIFT, 20);
SC_CHECK(golden_step_shift, "step_shift");
}
TC_TEST(phys_turn_around)
{
sc_room(room_flat, 1);
sc_kid_at(4, 1, 0);
sc_trace_clear();
sc_run(SC_L, 16);
SC_CHECK(golden_turn, "turn");
}
TC_TEST(phys_crouch_and_stand_up)
{
sc_room(room_flat, 1);
sc_kid_at(4, 1, 0);
sc_trace_clear();
sc_run(SC_D, 8);
sc_run(0, 12);
SC_CHECK(golden_crouch, "crouch");
}
TC_TEST(phys_jump_up_nothing_above)
{
/* Над головой пусто — seq_28; проверяем и смещение по x, которое
* оригинал делает перед прыжком. */
sc_room(room_flat, 1);
sc_kid_at(4, 1, -1);
sc_trace_clear();
sc_run(SC_U, 20);
SC_CHECK(golden_jump_up, "jump_up");
}
TC_TEST(phys_run_off_ledge)
{
/* Бег в обрыв: check_on_floor -> start_fall -> land на ряду 2.
* Сценарий, вокруг которого крутился BUG-FALL-SWORD-1. */
sc_room(room_ledge, 1);
sc_kid_at(1, 1, 0);
sc_trace_clear();
sc_run(SC_R, 34);
SC_CHECK(golden_fall_ledge, "fall_ledge");
}
TC_TEST(phys_loose_floor_breaks)
{
/* Пробежка по loose-плите: тайл обязан затрястись и провалиться, а
* физика сообщить об этом соседям (trob + перерисовка). */
sc_room(room_loose, 1);
sc_kid_at(1, 1, 0);
sc_trace_clear();
sc_run(SC_R, 34);
SC_CHECK(golden_loose, "loose");
#if !SC_RECORD
/* Журнал заглушек: плита обязана была отвалиться (кусок отрывается —
* pop_loose_mob_spawn) и попросить перерисовку. Это уже не
* характеризация, а содержательная проверка заглушка-пустышка её не
* дала бы. Через trob loose у нас НЕ идёт (только пики/ворота), у него
* свой pop_loose_modif поэтому спрашиваем именно про кусок. */
TC_TRUE(tk_log_count(TK_MOB_SPAWN) > 0);
TC_TRUE(tk_log_count(TK_REDRAW) > 0);
#endif
}
TC_TEST(phys_running_jump_over_3tile_gap)
{
/* BUG-RJUMP-1. Разбег влево от колонки 8, дальше Up — разбег-прыжок.
* Ожидание оригинала: перелёт провала в три тайла и посадка на (1,1).
* Сейчас эталон фиксирует НАШЕ поведение (падение в провал) когда
* баг починим, трасса поменяется осознанно и эталон переснимется. */
sc_room(room_gap3, 1);
sc_kid_at(8, 1, -1);
sc_trace_clear();
sc_run(SC_L, 8); /* разбег */
sc_run(SC_L | SC_U, 26); /* прыжок и посадка */
SC_CHECK(golden_rjump_gap3, "rjump_gap3");
}
/* ---- точка входа ----------------------------------------------------- */
void main(void)
{
sc_init();
TC_RUN(phys_stand_is_stable);
TC_RUN(phys_run_right);
TC_RUN(phys_step_with_shift);
TC_RUN(phys_turn_around);
TC_RUN(phys_crouch_and_stand_up);
TC_RUN(phys_jump_up_nothing_above);
TC_RUN(phys_run_off_ledge);
TC_RUN(phys_loose_floor_breaks);
TC_RUN(phys_running_jump_over_3tile_gap);
}
@@ -0,0 +1,95 @@
/*
* t_wall.c удар о стену В ВОЗДУХЕ (BUG-JUMPWALL-1).
*
* Наблюдение с приёмки уровня 3 (комната 14): Кид прыгает, не долетает и
* вместо удара о кладку с последующим сползанием ВДОЛЬ неё пролетает
* сквозь стену «по свободной траектории». Класс отказа шире одной
* комнаты, поэтому проверяем его отдельным набором, а не сценарием в
* t_phys: там характеризация (трасса == эталон), здесь утверждение о
* поведении («сквозь кладку нельзя»), которое не должно молча
* переснашиваться вместе с эталонами.
*
* Оригинал: bumped_fall (seg004:04E4) на ударе гасит `Char.fall_x`, то есть
* горизонтальный снос обнуляется и дальше Кид падает вертикально. Кто
* решает, что удар был, check_collisions (seg004:0004) + check_bumped.
*
* Проверка свипом по стартовому X, а не одним прыжком: исход зависит от
* фазы, и «с одной X работает» ничего не доказывает (ровно так и вышло с
* первым сценарием из центра плиты стена держит).
*/
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#define E 0
#define F 1
#define W 20
/* Комната 14 уровня 3 с одной правкой: шахте колонок 6–7 подложено дно
* (ряд 2), потому что соседних комнат у сцены нет, а в игре Кид улетает
* вниз в комнату 13. Остальное как в данных: площадка (0,8), провал
* (0,6)/(0,7), СТЕНА в колонке 5 рядов 12, площадка (0,5) за ней. */
static const uint8_t room14[30] = {
F, F, F, E, E, F, E, E, F, E,
W, W, W, E, E, W, E, E, W, E,
W, W, W, F, F, W, F, F, W, W,
};
/* Плита (0,8) по X: колонка 8 — это [177, 191), плюс шов с пустой (0,9). */
#define X_LO 177
#define X_HI 196
/* Прыгнуть влево со стартовой X. Возврат — код исхода:
* 'L' долетел до площадки (0,5) и стоит на ней (ряд 0) законно;
* '.' не долетел и упал на дно шахты колонок 67 тоже законно;
* цифра колонка, в которой Кид оказался ЛЕВЕЕ стены, то есть прошёл
* сквозь кладку колонки 5 (это и есть баг). */
static char jump_left_from(uint8_t x)
{
sc_room(room14, 14);
sc_kid_at_x(8, 0, x, -1 /* лицом влево */);
sc_trace_clear();
/* Клавиши держим только на сам прыжок: если удерживать их всю сцену,
* Кид, ДОЛЕТЕВШИЙ до (0,5), тут же прыгает с неё второй раз и исход
* говорит уже не о стене, а о втором прыжке. */
sc_run(SC_L | SC_U, 6);
sc_run(0, 34);
if (!sc_len) return '?';
{
int8_t col = sc_trace[sc_len - 1].col;
int8_t row = sc_trace[sc_len - 1].row;
if (row == 0) return 'L'; /* остался на верхнем ряду */
if (col >= 6) return '.'; /* сполз вдоль стены в шахту */
return (char)('0' + (col < 0 ? 0 : col));
}
}
static void put(char c)
{
if (tc_result.logn < TC_LOGSZ) tc_result.log[tc_result.logn++] = (uint8_t)c;
}
static void puts_(const char *s) { while (*s) put(*s++); }
TC_TEST(wall_stops_undershot_jump)
{
uint8_t x, bad = 0;
/* Карта исходов по всей ширине плиты: 'L' — долетел до (0,5), '.' —
* сполз вдоль стены в шахту, цифра просочился сквозь кладку. */
puts_("x=177..196: ");
for (x = X_LO; x <= X_HI; x++) {
char r = jump_left_from(x);
put(r);
if (r != 'L' && r != '.') bad++;
}
put('\n');
TC_EQ(bad, 0);
}
int main(void)
{
sc_init();
TC_RUN(wall_stops_undershot_jump);
return 0;
}
@@ -14,7 +14,7 @@ seqtbl адресуется базой SEQTBL_BASE=0x196E: элемент = kid_
import os
import re
SRC = "/Users/alex/Projects/DIY/Z80/Sprinter/C-Compiler/applications/PoP/SDLPoP/src"
SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "SDLPoP", "src")
OUT_BIN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "poc", "res", "kid")
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
@@ -44,6 +44,18 @@ def parse_frame_table(txt, name="frame_table_kid"):
return frames
def parse_sword_tbl(txt):
"""sword_tbl[] (seg006:2047) -> список (id, x, y). id 255 = клинка нет.
Индекс sword_frame (cur_frame.sword & 0x3F); один и тот же chtab_0 и у
Кида, и у стража."""
m = re.search(r'const sword_table_type sword_tbl\[\]\s*=\s*\{(.*?)\n\};', txt, re.S)
rows = []
for row in re.findall(r'\{([^}]*)\}', m.group(1)):
a, b, c = [int(p.strip(), 0) for p in row.split(',')]
rows.append((a & 0xFF, b & 0xFF, c & 0xFF))
return rows
def parse_byte_array(txt, name):
m = re.search(r'const byte %s\[\]\s*=\s*\{(.*?)\}' % name, txt, re.S)
return [int(x, 0) for x in re.findall(r'0x[0-9A-Fa-f]+|\d+', m.group(1))]
@@ -69,6 +81,7 @@ def main():
gframes = parse_frame_table(seg006, "frame_tbl_guard")
seqtbl = parse_byte_array(seqc, "original_seqtbl")
offs = parse_word_array(seqc, "original_seqtbl_offsets")
swtbl = parse_sword_tbl(seg006)
with open(OUT, "w") as f:
f.write("/* kid_data.h — данные анимации Kid (frame_table + seqtbl +\n"
@@ -102,7 +115,22 @@ def main():
f.write("#define KID_NSEQ %d\n" % len(offs))
f.write("static const uint16_t kid_seq_off[KID_NSEQ] = {\n")
f.write(emit_c(offs, 8, "0x%04X") + "\n};\n\n")
f.write("#endif\n")
# sword_tbl — макро-инициализаторами, а не готовыми массивами: так
# 159 байт таблицы лягут в ОДИН TU (pop_kid.c), а не в каждый, кто
# включил заголовок.
f.write("/* sword_tbl (seg006:2047): клинок отдельным спрайтом chtab_0.\n"
" * Индекс — sword_frame (кадр.sword & 0x3F), id 255 = клинка нет.\n"
" * Смещения x/y — ЭКРАННЫЕ (применяются после calc_screen_x_coord),\n"
" * x — «вперёд» по направлению персонажа. */\n")
f.write("#define KID_NSWORD %d\n" % len(swtbl))
for i, (nm, fmt) in enumerate((("ID", "0x%02X"), ("DX", "%d"), ("DY", "%d"))):
# dx/dy — знаковые: печатаем со знаком, иначе int8_t = overflow
vals = [r[i] if i == 0 else (r[i] - 256 if r[i] > 127 else r[i])
for r in swtbl]
f.write("#define KID_SWORD_%s_INIT { \\\n" % nm)
f.write(emit_c(vals, 12, fmt).replace("\n", " \\\n") + " \\\n}\n")
f.write("\n#endif\n")
# kid_data.bin: кадры (по 5 байт) + seqtbl подряд
os.makedirs(OUT_BIN_DIR, exist_ok=True)
+5 -3
View File
@@ -26,7 +26,10 @@ OUT_DIR = os.path.join(HERE, "..", "poc", "res", "kid")
PAL_BASE = 0x70 # слоты палитры Sprinter под Kid
SWORD_PAL_BASE = 0x80 # chtab_0 (меч в руке) -> слоты 0x80..0x8F
GUARD_PAL_BASE = 0x90 # chtab_5 (страж) -> слоты 0x90..0x9F
SWORD_IDS = tuple(range(20, 28)) # sword_tbl для кадров 229..236 (найти-убрать)
# chtab_0 целиком: sword_tbl (seg006:2047) адресует id 0..33 — подъём меча,
# ножны И вся боёвка. Меч в оригинале ОДИН на всех: и Кид, и страж рисуют
# клинок из этого же chtab_0 (add_sword_to_objtable, seg006:1798).
SWORD_IDS = tuple(range(0, 34))
SHIFT = 3 # kid[img>>3], idx img&7 (Kid-кадры крупные: 8/стр ≤16КБ)
MASK = (1 << SHIFT) - 1
@@ -150,8 +153,7 @@ def main():
names.append(None)
print(f" (kid стр {pg}: пусто)")
# chtab_0: меч в руке. Пакуем ТОЛЬКО кадры подъёма/ножен (sword_tbl
# 35..42 -> id 20..27); индекс в атласе = id - 20 (бой не портирован).
# chtab_0: меч в руке — весь chtab (id 0..33), индекс в атласе = id.
sw = {}
for i, id in enumerate(SWORD_IDS):
w, h, pix = load_sword(id)
+71 -9
View File
@@ -5,6 +5,15 @@
## Ближайшее
- [ ] **Модульные тесты libc и libbgi под ucsim_z80** — план:
`docs/host-tests-plan.md`. Обвязка уже готова и обкатана
(`testkit/`, первый потребитель — PoP/roomtest). Не срочно, но
обязательно: это основной продукт репозитория, а покрыт он сейчас
только интеграционными тестами в MAME. Начинать с `time/` и
`stdio/` (нулевые швы), затем геометрия `libbgi/common`, затем
фейковые ESTEX/BIOS в тестовом crt0 — они открывают `file/`, `io/`,
`conio/` и проверку гардов (`_fd_guard`).
Порядок реализации связки: **сначала цепочка irq, потом FPS-делитель
поверх неё** (делитель — просто один слот цепи; так снимается EBUSY
на сосуществование со своим тиком приложения).
@@ -52,6 +61,35 @@
## Auto-banking (memory/banking_roadmap.md)
- [ ] **Сверять `n_banks` с реальным числом банков — ошибкой сборки.**
Сейчас число банков задаётся ДВАЖДЫ и вручную: флагами `--bank N=…`
у `sprinter-cc` и константой `const uint8_t n_banks = N;` в
приложении (её читает `crt0_banked` при старте, чтобы выделить
EMM-страницы и загрузить в них образы банков). Рассогласование не
даёт ни ошибки компиляции, ни ошибки линковки — только зависание в
рантайме: `_bank_pages[N]` остаётся нулём, трамплин отображает в
окно страницу 0 и прыгает по 0xC000 в мусор (предупреждение об этом
уже есть в шапке `runtime/bank.s`).
**Найдено 2026-08-05** на PoP/roomtest: добавили пятый банк
(`--bank 5=pop_ctrl.c`), забыли `n_banks` — игра доходила до конца
загрузки ресурсов и вставала намертво в дисковом коде DSS. Диагноз
занял заметно больше, чем сама правка: симптом (зависание в чужом
коде) никак не указывает на причину.
Сверять надо не количество, а **максимальный индекс** банка: `--bank`
нумеруются с 1 и в принципе могут идти с дырами, а `crt0_banked`
заполняет `_bank_pages[1..n_banks]` подряд.
Варианты, от простого к правильному:
1. **Проверка после линковки** — вытащить значение `_n_banks` из
`.ihx`/`.map` (это `const` в `_CODE`, адрес известен из карты) и
сравнить с `max(N)` по флагам; не совпало — `sprinter-cc` падает
с внятным сообщением. Ничего не ломает в существующих
приложениях, ставится сразу.
2. **Генерировать самим `sprinter-cc`** — он уже так делает, когда
банков нет (`const unsigned char n_banks = 0;`). Правильнее по
сути (единственный источник истины), но требует убрать константу
из всех банковых приложений, иначе дубль символа: `tests/banked`,
`banktest`, `banklocl`, `bankedbg`, `applications/PoP/roomtest`.
Phase 1 — file-level bin-packing (`toolchain/auto_bank.py`) — когда
проект перерастёт ~30 KB кода: парсинг размеров из .rel/.map,
first-fit-decreasing, уважение `#pragma codeseg BANKn`, перелинковка,
@@ -279,15 +317,39 @@ Quick wins:
- [ ] **kbd_raw: частые Rx-overrun в MAME при тапах** (roomtest,
2026-07-22) — замеры watchpoint-счётчиками: при стабильном
удержании клавиши overrun'ов ноль, но каждый быстрый тап (5 байт:
E0 74 + E0 F0 74 при FIFO 3) даёт overrun. Подозрение: dev-MAME
не эмулирует прерывание SIO на каждый принятый байт (байты
вычерпываются только кадровым IRQ 50 Гц) и/или подаёт пачку без
реальных ~1 мс/байт — на железе per-byte INT должен делать
overrun'ы редкостью. Проверить mame/sources/MAME/src/mame/
sinclair/sprinter.cpp (путь байта клавиатуры → INT) и при
желании поправить dev-MAME. Recovery-политика уже терпима к
частым overrun'ам (селективный wipe, docs/kbd-games.md), но
аккорд «держу →, тапнул ↑» остаётся уязвим.
E0 74 + E0 F0 74 при FIFO 3) даёт overrun.
**Уточнение 2026-08-01 (по коду драйвера, прежняя гипотеза
НЕВЕРНА):** dev-MAME per-byte INT ДАЁТ —
`mame/sources/MAME/src/mame/sinclair/sprinter.cpp`
`on_kbd_data()` ставит `m_irqs->in_set<1>()` на каждый принятый
байт. Но тут же заводит `m_irq_off_timer` на **32 такта CPU**, и
`irq_off()` снимает линию — то есть импульс, пришедшийся на наше
DI-окно, теряется НАСОВСЕМ (байт остаётся в FIFO до следующего
IRQ или кадрового 50 Гц). А DI-окна у нас длинные: ядра
акселератора держат `di` на весь блит (`libbgi/bgi256/
_bgi_blit_cols_raw.c`), это сотни микросекунд против 32 тактов.
**Замерено 2026-08-01 (PoP roomtest, счётчики в MAME): обе
«наши» гипотезы отпали.** (а) `kbd_raw_poll()` из главного цикла
не меняет ничего (9/10 с ним и без); (б) снятие `di` в accel-ядрах
УРОНИЛО машину — режим «акселератор при EI» тут недоступен; и сама
длина DI ни при чём (в замороженном кадре без блитов потерь
БОЛЬШЕ). Байт теряется ДО чтения порта: на 49 прочитанных байт
только 28 входов в клавиатурную ветку, т.е. ~44 % импульсов
запроса не обслужены и 3-байтовый FIFO переполняется. Главный
подозреваемый — `m_irq_off_timer` в sprinter.cpp: он ОДИН на два
источника (экран + клавиатура), `irq_off()` гасит обе линии, так
что кадровое прерывание способно обрезать клавиатурный импульс.
**ЗАКРЫТО в рабочем объёме 2026-08-01:** лечится ПЛОТНЫМ опросом —
`kbd_raw_poll` повешен idle-хуком графики (`gfx_set_idle_hook`,
новый API libbgi) на ожидание кадра, где процессор всё равно
простаивает ~2/3 периода. 35 нажатий с зажатым Shift → 35
дошедших make против 9 из 10 без хука. Пользователь на ручной
проверке отмечает, что редкие пропуски всё же ощущаются — остаток
отложен до финальной полировки, следующий шаг описан там же.
Полный протокол — applications/PoP/roomtest/TASKS.md, KBD-1.
Recovery-политика уже терпима к частым overrun'ам (селективный
wipe, docs/kbd-games.md), но аккорды «держу →, тапнул ↑» и
«держу Shift, тапаю ←» остаются уязвимы.
## Known quirks (зафиксированы, обходы в libc)
+129
View File
@@ -0,0 +1,129 @@
# План: модульные тесты libc и libbgi под ucsim_z80
Статус: **не начато**, задача на будущее. Обвязка уже готова и обкатана —
`testkit/` (см. `testkit/README.md`); первый потребитель —
`applications/PoP/roomtest/tests-host/`. Этот документ — про то, как накрыть
тем же способом основной продукт репозитория.
## Что это НЕ заменяет
В `tests/` уже лежат 62 каталога — это **интеграционные** тесты: одна фича =
одна программа, которая пакуется на дискету и гоняется в MAME
(`docs/mame-autotest.md`). Они проверяют, что API работает на живой машине:
ESTEX, BIOS, диск, экран, тайминги.
Модульные тесты их не отменяют, а дополняют с другой стороны:
| | `tests/` (MAME) | `tests-host` (ucsim) |
|---|---|---|
| что проверяет | работает ли на машине | верна ли логика |
| граничные случаи | 1–2 на фичу | десятки, дёшево |
| время прогона | десятки секунд | миллисекунды |
| ловит | железо, тайминги, банки | арифметику, краевые условия, регрессии |
Правило разделения то же, что уже записано для PoP: **что можно проверить
без железа — проверять в ucsim, MAME оставить железу.**
## Три группы модулей
### 1. Чистая логика — тестируется как есть, швов не нужно
Здесь можно начинать в тот же день, когда задачу возьмут в работу.
**libc:**
- `time/``_tm_is_leap`, `_tm_mdays`, `_tm_month_days`, `_tm_year_days`,
`mktime`, `gmtime`, `localtime`, `asctime`, `ctime`. Классическая
календарная арифметика: високосные годы, границы месяцев, переходы через
год, круговой прогон `mktime(gmtime(t)) == t`. Идеальный первый набор —
много краевых случаев и ноль зависимостей.
- `stdio/``dec_print`, `hex8/16/32`, `_scanf_core`, `sscanf`. Формат и
разбор: ширина, знак, переполнение, мусор на входе.
- `string/`, `stdlib/``strlwr`, `strupr`, `max`, `min`.
**libbgi/common** (115 модулей, почти всё mode-agnostic):
- `_bgi_isqrt`, `_bgi_trig` — сверить с эталонной формулой на всём
диапазоне, ровно как сделано для LCG в `t_geom` у PoP;
- `_bgi_lineseg`, `_bgi_styled_line`, `_bgi_poly_edge`, `_bgi_arc_draw`
геометрия и отсечение: линия целиком вне окна, по диагонали через угол,
вырожденная в точку;
- `_spr_ysort` — порядок сортировки спрайтов;
- `_bgi_hspan`, `_bgi_fill_span` — заливка: краевые span'ы, нулевая ширина.
### 2. Нужны швы — но швы дешёвые
**Фейковый ESTEX и BIOS прямо в тестовом crt0.** Оба вызываются через
`rst #0x10` и `rst #0x08`, то есть через фиксированные векторы в первых
байтах памяти. В тестовом бинаре эти адреса наши: можно положить туда
обработчик, который эмулирует крошечную файловую систему в ОЗУ и текстовый
экран в буфере. Это открывает:
- `file/` (32 модуля) и `io/` (21) — `fopen`/`fread`/`fwrite`/`fseek`,
буферизация (`docs/file-buffering-design.md`), поведение на EOF и
ошибках, `errno`;
- `conio/` (38) — вывод в буфер вместо экрана, проверка атрибутов,
скроллинга, границ окна.
Отдельно ценно: **гарды**, которые обязаны быть в обеих сборках. `_fd_guard`
(девятый `OPEN` вешает DSS) — это ровно тот случай, где нужен тест, а не
вера в комментарий: открыть восемь, убедиться, что девятый вернул ошибку и
не дошёл до ESTEX.
**Кадровый буфер в ОЗУ для libbgi.** Если рисующие ядра умеют писать в
обычный буфер, а не только в видеопамять, растеризацию можно проверять
снимком: нарисовать фигуру, сравнить с эталонным массивом. Начинать с
маленьких (8×8, 16×16) — эталон читаемый прямо в исходнике.
### 3. Только MAME
Банки и W-окна, EMM, реальный ESTEX/DSS, клавиатурный трамплин и IM2,
CBL-звук (`cbl/`), мышь (`mouse/`), `mem/` (это банки и страницы, а не
куча), тайминги и бюджет кадра, ускоритель.
## Обе сборки: fast и safe
Библиотеки собираются в двух вариантах (`-D*_NOCHECK` вырезает
валидацию параметров). Наборы стоит гонять **против обоих**:
- в safe — что валидация ловит мусорные аргументы и ставит `errno`;
- в fast — что вырезание валидации не поменяло поведение на корректных
входах.
Это дешёвая параметризация Makefile (тот же набор, два `OBJS_*`), и она
пресекает целый класс расхождений между вариантами.
## Фазы
1. **Календарь и формат.** `time/`, `stdio/`. Нулевые швы, максимальная
плотность краевых случаев. Цель — обкатать поток работы на libc.
2. **Геометрия libbgi.** `common/`: isqrt, тригонометрия, отсечение линий,
рёбра полигонов. Тоже без швов.
3. **Фейковые ESTEX/BIOS в crt0.** Открывает `file/`, `io/`, `conio/` и
гарды. Самый крупный кусок работы и самая большая отдача.
4. **Растр libbgi по снимкам** — если окажется, что ядра можно нацелить на
буфер в ОЗУ без правок продукта; иначе отложить.
5. **Параметризация fast/safe** — после того, как наборов станет заметно.
## Критерии
- Тест не считается написанным, пока не проверен мутацией: сломать
проверяемое место, убедиться, что набор краснеет. Пустые тесты хуже
отсутствующих.
- Каждый закрытый баг libc/libbgi получает регрессионный кейс, если он
ловится без железа.
- `make host-tests` остаётся быстрым: если суммарно перевалит за несколько
секунд, делить на быстрый и полный прогон.
## Организация
Наборы кладутся рядом с кодом, обвязка общая:
```
libc/tests-host/ наборы по областям: t_time.c, t_stdio.c, …
libbgi/tests-host/ t_geom.c, t_raster.c, …
```
Каждому — `Makefile` на пять строк (`TESTKIT`, `ENGINE_DIR`, `OBJS_*`,
`include`), и добавить каталог в `HOST_TEST_DIRS` корневого `Makefile`.
Подробности — `testkit/README.md`.
+34
View File
@@ -313,6 +313,7 @@ Set 2: `0xF0` — префикс отпускания, `0xE0` — префикс
| `void kbd_raw_close(void)` | выключить, вернуть клавиатуру DSS; идемпотентно; висит на atexit |
| `uint8_t kbd_raw_down(uint16_t code)` | зажата ли code ПРЯМО СЕЙЧАС (0/1); code вне 0..511 — 0 |
| `void kbd_raw_sync(void)` | звать РАЗ В КАДР до опроса: recovery после Rx-overrun SIO — сбрасывает held-состояние всех клавиш КРОМЕ модификаторов (те не перечитываются typematic'ом; docs/kbd-games.md) |
| `uint8_t kbd_raw_poll(void)` | вычерпать FIFO ОПРОСОМ, не дожидаясь прерывания: 0 — было пусто (~40 тактов), 1 — что-то декодировано. Звать МЕЖДУ фазами кадра, после тяжёлых блитов — не раз в кадр (см. ниже, «почему одного прерывания мало») |
| `KBD_EXT` | ИЛИ-флаг кода: клавиша была расширенной (0xE0-префикс на проводе) |
| `KBD_UP/DOWN/LEFT/RIGHT/SPACE/ENTER/ESC/LSHIFT/RSHIFT` | позиционные коды PS/2 Set 2 — LEFT/ESC/UP подтверждены (см. ниже); остальные — по стандарту, не перепроверены поштучно |
| `KBD_LCTRL/LALT/RCTRL/RALT` | коды модификаторов (R* — расширенные, с KBD_EXT); добавлены 2026-07-22 |
@@ -335,6 +336,39 @@ git как урок: при повторных «нет эффекта» на с
брейкпоинтом/watchpoint'ом на конкретный адрес кода, не полагаться
только на визуальный снимок с произвольным таймингом.
**ПОЧЕМУ ОДНОГО ПРЕРЫВАНИЯ МАЛО (`kbd_raw_poll`, 2026-08-01).** Приёмный
FIFO SIO — 3 байта, и рассчитывать на «каждый байт разбудит нас» нельзя:
запрос прерывания клавиатуры держится единицы микросекунд (в dev-MAME —
32 такта CPU, `sprinter.cpp` `on_kbd_data``irq_off_timer`), а ядра
акселератора держат `DI` на весь блит — сотни микросекунд. Импульс,
попавший в такое окно, теряется насовсем; байт лежит в FIFO до следующего
прерывания (следующий байт либо кадровое, 50 Гц). Трафик же идёт пачками:
тап стрелки = 5 байт, а при УДЕРЖИВАЕМОМ Shift PS/2 обрамляет расширенный
код «фиктивным шифтом» (`E0 F0 12``E0 12`) — по 5 байт и на нажатие, и
на отпускание. Три ячейки такую пачку не держат: потерянный make = «нажатие
не сработало», потерянный break = залипание. Отсюда `kbd_raw_poll()`
вычерпывание тем же декодером, но из главного цикла. Тело идёт под `DI`
(чтение порта 0x18 деструктивно, а read-modify-write карты гонится с
трамплином) и БЕЗУСЛОВНО делает `EI` на выходе — из ISR звать нельзя.
**Как её звать (замеры 2026-08-01, PoP roomtest — читать до применения!).**
Расстановка «несколько вызовов за кадр, после тяжёлых фаз» **не даёт
ничего**: 9 дошедших make-байт из 10 нажатий и с ней, и без неё — такие
вызовы попадают ровно в участки с разрешёнными прерываниями и лишь
дублируют трамплин. Там же измерено, что длина DI-окон графики на потери
НЕ влияет (в кадре вообще без блитов потерь больше), а теряется байт ДО
чтения порта: примерно 44 % импульсов запроса прерывания не обслуживаются,
и трёхбайтовый FIFO переполняется.
**Работает только ПЛОТНЫЙ опрос — порядка раза в 0.5 мс.** Штатный способ
взять эту частоту даром — повесить функцию idle-хуком графики:
`gfx_set_idle_hook()` (`<gfx.h>`) зовёт её, пока `gfx_wait_vsync` крутит
опрос луча, а это ~2/3 периода кадра. Проверено: 35 нажатий стрелки с
зажатым Shift → 35 дошедших make против 9 из 10 без хука. Остаточные
редкие потери возможны (пачка целиком внутри DI-окна одного accel-прохода);
полный протокол и что делать дальше — `applications/PoP/roomtest/TASKS.md`,
задача KBD-1.
**ГЛАВНОЕ СЛЕДСТВИЕ:** пока `kbd_raw_open()` активен, `kbhit/getch/getkey/
kbd_mod_state` НЕ получают новых событий (в т.ч. CTRLKEY подряд отдаёт
то же самое, что было на момент открытия — резидентный обработчик DSS,
+7 -13
View File
@@ -4,7 +4,6 @@ argv 3431
assrtest 3847
atlas 9193
attrprob 4087
balls 6031
banked 1056
bankedbg 1067
banklocl 4683
@@ -15,9 +14,9 @@ bios_text 4461
blitperf 5860
blitw 4939
cat 913
cblstream 6431
cbltest 6580
cblwav 6648
cblstream 6464
cbltest 6613
cblwav 6681
conio 4605
conio2 3929
dec_test 860
@@ -27,30 +26,25 @@ fdmax 6023
filetest 10547
fpsdiv 4870
gets 509
gfx_dbuf 5112
gfx_dbuf 5134
gfx_demo 4117
gfxbanks 6044
hello 4167
hello2 4244
irqtest 6647
irqtest 6680
kbdpoll 1219
kbdraw 4911
ls 4835
malloc 4461
mdview 18878
mdview2 27031
mem_test 4555
mouse 4382
openenv 6124
palfile 5406
ptime 5744
rpgprof 12082
rpgwalk 10309
rt_test 5154
scroll 2974
rt_test 5187
seek 4188
simple 955
solidt 11531
space 11272
spranim 9431
spriteclip 4119
sprites 6791
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
4 assrtest
5 atlas
6 attrprob
balls
7 banked
8 bankedbg
9 banklocl
14 blitperf
15 blitw
16 cat
17 cblstream
18 cbltest
19 cblwav
20 conio
21 conio2
22 dec_test
26 filetest
27 fpsdiv
28 gets
29 gfx_dbuf
30 gfx_demo
31 gfxbanks
32 hello
33 hello2
34 irqtest
35 kbdpoll
36 kbdraw
37 ls
38 malloc
mdview
mdview2
39 mem_test
40 mouse
41 openenv
42 palfile
43 ptime
44 rpgprof rt_test
rpgwalk
rt_test
scroll
45 seek
46 simple
47 solidt
space
48 spranim
49 spriteclip
50 sprites
+8 -5
View File
@@ -175,11 +175,14 @@ void gfx_scroll_v(uint8_t src_page, uint8_t dst_page,
прогон: нарисовать фон, поверх спрайт банком `0x5C`, сделать `scroll_h` на
dx>0, убедиться что спрайт **не размазался** (копировался фон, не VRAM со
спрайтом). Заодно выяснить, нужен ли сброс Port_Y на колонку (§4).
2. **ОЗУ-копия per-page или общая.** `C-Compiler/applications/PoP/docs/double_buffer_plan.md`
пишет «теневая копия одна — общая». Если тень физически одна (а не адресуется
по базе `0xC000/0xC140` как VRAM), page0 и page1 не удержат фон **разных**
положений камеры во время скролла → пинг-понг (§8) сломается. Проверить:
записать разный фон в page0 и page1 банком `0x50`, сверить чтение обеих.
2. **ОЗУ-копия per-page или общая.** Ранний план дабл-буфера PoP исходил из
«теневая копия одна — общая»; **практикой это опровергнуто**: у каждой
страницы СВОЯ теневая ОЗУ-копия, heal берёт фон из копии той страницы, в
которую рисуем (`applications/PoP/roomtest/CLAUDE.md`, раздел
«Дабл-буфер»; `roomtest.c enter_room` рисует фон в обе страницы по
отдельности именно поэтому). Значит пинг-понг (§8) в принципе рабочий,
но перед опорой на него — подтвердить на своём коде: записать разный фон
в page0 и page1 банком `0x50`, сверить чтение обеих.
3. **(только для `scroll_v`, путь A)** accel-буфер переживает `OUT Port_Y` между
fill и flush одного burst'а. Доковый straight-copy этого не проверяет.
+5
View File
@@ -347,6 +347,11 @@ void _bgi_poly_edge(int x0, int y0, int x1, int y1);
extern void _cbl_port_ref(void);
extern void _cbl_port_unref(void);
/* Idle-хук ожидания кадра (common/_gfx_idle_state.c, ставится
* gfx_set_idle_hook). NULL = нет; зовётся из лучевого цикла
* gfx_wait_vsync. */
extern void (*_gfx_idle_hook)(void);
/* ---- FPS-делитель (frame pacing) --------------------------------- *
* Фоновый счётчик кадров через цепочку кадровых IRQ (libc/irq). Данные
* в common/_gfx_fps_state.c; при _gfx_fps_div<=1 путь не активен и
+10
View File
@@ -0,0 +1,10 @@
/*
* _gfx_idle_state указатель на idle-хук, который gfx_wait_vsync зовёт,
* пока ждёт луч. Отдельный data-модуль: на него ссылаются и установщик
* (gfx_set_idle_hook), и сам цикл ожидания, а тянуть один из-за другого
* в программу, которая хук не ставит, ни к чему. Без инициализации
* (crt0 зануляет _DATA NULL = хука нет).
*/
#include "../_bgi.h"
void (*_gfx_idle_hook)(void);
+4 -43
View File
@@ -9,9 +9,9 @@
* сверху для column-major бесплатна: колонка = непрерывный кусок ОЗУ, старт
* просто сдвигается на skip байт, длина accel-блока уменьшается.
*
* Здесь же живёт полное тело блита колонками gfx_blit_cols() тонкая
* обёртка (skip=0, rows=0), чтобы не дублировать клип-логику.
* Подробности зеркала/клипа см. шапку gfx_blit_cols.c.
* Тело (вместе с клип-логикой) в gfx_blit_cols_part_w.c: этот вызов =
* «без ограничения ширины». Подробности зеркала/клипа см. шапку
* gfx_blit_cols.c.
*/
#include <stdint.h>
#include "../_bgi.h"
@@ -19,44 +19,5 @@
void gfx_blit_cols_part(int x, int y, const void *img, uint8_t flip,
int skip, int rows)
{
const uint8_t *p = (const uint8_t *)img;
int full_w = p[0] | (p[1] << 8);
int full_h = p[2] | (p[3] << 8);
const uint8_t *px = p + 4;
int w = full_w, h;
int sstride = flip ? -full_h : full_h; /* шаг между колонками в ОЗУ */
int sy; /* пропуск строк сверху колонки */
int cx = 0; /* пропуск колонок слева экрана */
const uint8_t *src;
uint8_t *dst;
if (full_w <= 0 || full_h <= 0)
return;
if (skip < 0) skip = 0;
if (skip >= full_h)
return; /* обрезано целиком */
sy = skip;
h = full_h - skip;
if (rows > 0 && rows < h)
h = rows;
/* Клип по экрану — только если хоть один край вылезает. */
if (x < 0 || y < 0 || x + w > _bgi_maxx + 1 || y + h > _bgi_maxy + 1) {
if (x < 0) { cx = -x; w += x; x = 0; }
if (y < 0) { sy -= y; h += y; y = 0; }
if (x + w > _bgi_maxx + 1) w = _bgi_maxx + 1 - x;
if (y + h > _bgi_maxy + 1) h = _bgi_maxy + 1 - y;
if (w <= 0 || h <= 0) return;
}
/* Первая ВИДИМАЯ колонка (с учётом флипа) + первая видимая строка. */
src = flip ? (px + (uint16_t)(full_w - 1) * (uint16_t)full_h) : px;
if (cx)
src += cx * sstride;
src += sy;
_bgi_begin();
dst = (uint8_t *)(_gfx_addr_base + (uint16_t)x);
_bgi_blit_cols_raw(src, dst, (uint8_t)w, (uint8_t)h, sstride, (uint8_t)y);
_bgi_end();
gfx_blit_cols_part_w(x, y, img, flip, skip, rows, 0);
}
+49
View File
@@ -0,0 +1,49 @@
/*
* gfx_blit_cols_part_noclip блит спрайта column-major БЕЗ клипа по экрану,
* с обрезкой сверху (как gfx_blit_cols_part). Пара к gfx_blit_cols_part
* ровно так же, как gfx_blit_part_noclip к gfx_blit_part.
*
* Зачем: у персонажей PoP спрайт почти всегда целиком на экране, а общий
* gfx_blit_cols_part платит за проверку четырёх границ, пересчёт w/h/cx/sy и
* 16-битную арифметику независимо от того, вылезает край или нет замер в
* MAME (roomtest, страж + клинок): 5 622 такта подготовки против 13 596 на
* сам accel-проход. Здесь та же колонка уходит в _bgi_blit_cols_raw без
* подготовки.
*
* Требования (проверяет ВЫЗЫВАЮЩИЙ, кода проверок здесь нет):
* - прямоугольник назначения целиком на экране;
* - ширина и высота кадра <= 255, y <= 255;
* - skip < высоты кадра.
* Не выполняется зовите gfx_blit_cols_part.
*
* Флип, как и в gfx_blit_cols_part, бесплатный: подаём src с последней
* колонки и отрицательный страйд accel сам кладёт её в левый x.
*
* W3-скобка ставится ЗДЕСЬ (код libbgi живёт в W1): из приложения,
* собранного с --w3, её вызывать нельзя после _bgi_begin окно W3 занято
* видеобанком и код вызывающего исчезает из адресного пространства.
*/
#include <stdint.h>
#include "../_bgi.h"
void gfx_blit_cols_part_noclip(int x, int y, const void *img, uint8_t flip,
uint8_t skip, uint8_t rows)
{
const uint8_t *p = (const uint8_t *)img;
uint8_t w = p[0]; /* ширина кадра (< 256) */
uint8_t fh = p[2]; /* высота кадра (< 256) */
const uint8_t *src = p + 4;
uint8_t h;
if (w == 0 || fh == 0 || skip >= fh) return;
h = (uint8_t)(fh - skip);
if (rows && rows < h) h = rows;
if (flip) src += (uint16_t)(w - 1) * fh; /* последняя колонка -> левый x */
src += skip;
_bgi_begin();
_bgi_blit_cols_raw(src, (uint8_t *)(_gfx_addr_base + (uint16_t)x),
w, h, flip ? -(int)fh : (int)fh, (uint8_t)y);
_bgi_end();
}
+70
View File
@@ -0,0 +1,70 @@
/*
* gfx_blit_cols_part_w блит спрайта column-major с обрезкой СВЕРХУ
* (skip/rows) И ОГРАНИЧЕНИЕМ ШИРИНЫ (maxw): рисуются только первые maxw
* колонок футпринта, считая от x. maxw = 0 «вся ширина кадра».
*
* Здесь живёт ПОЛНОЕ тело блита колонками; gfx_blit_cols_part (maxw=0) и
* gfx_blit_cols (ещё и skip=rows=0) тонкие обёртки, чтобы клип-логика не
* дублировалась. Подробности зеркала/клипа см. шапку gfx_blit_cols.c.
*
* Зачем ограничение ширины. Обрезка СПРАВА для column-major это ровно
* уменьшение числа колонок, то есть внутри уже есть (так же клипается край
* экрана: `w = _bgi_maxx + 1 - x`), наружу просто не было выведено.
* Понадобилось для порта clip_char в PoP: персонаж, поднимающийся по
* лестнице за дверью уровня, обязан уходить за правый косяк проёма
* (obj_clip_right), а дверь рисуется ПОД ним и перекрыть его не может.
*
* Важно: ограничение работает и при flip. При зеркале источник читается с
* последней колонки к первой (sstride < 0), но первые w нарисованных
* колонок в любом случае ложатся в ЛЕВУЮ часть футпринта [x, x+w) то
* есть уменьшение w всегда срезает справа, как и требуется.
*/
#include <stdint.h>
#include "../_bgi.h"
void gfx_blit_cols_part_w(int x, int y, const void *img, uint8_t flip,
int skip, int rows, uint8_t maxw)
{
const uint8_t *p = (const uint8_t *)img;
int full_w = p[0] | (p[1] << 8);
int full_h = p[2] | (p[3] << 8);
const uint8_t *px = p + 4;
int w = full_w, h;
int sstride = flip ? -full_h : full_h; /* шаг между колонками в ОЗУ */
int sy; /* пропуск строк сверху колонки */
int cx = 0; /* пропуск колонок слева экрана */
const uint8_t *src;
uint8_t *dst;
if (full_w <= 0 || full_h <= 0)
return;
if (maxw && (int)maxw < w)
w = (int)maxw; /* обрезка справа (см. шапку) */
if (skip < 0) skip = 0;
if (skip >= full_h)
return; /* обрезано целиком */
sy = skip;
h = full_h - skip;
if (rows > 0 && rows < h)
h = rows;
/* Клип по экрану — только если хоть один край вылезает. */
if (x < 0 || y < 0 || x + w > _bgi_maxx + 1 || y + h > _bgi_maxy + 1) {
if (x < 0) { cx = -x; w += x; x = 0; }
if (y < 0) { sy -= y; h += y; y = 0; }
if (x + w > _bgi_maxx + 1) w = _bgi_maxx + 1 - x;
if (y + h > _bgi_maxy + 1) h = _bgi_maxy + 1 - y;
if (w <= 0 || h <= 0) return;
}
/* Первая ВИДИМАЯ колонка (с учётом флипа) + первая видимая строка. */
src = flip ? (px + (uint16_t)(full_w - 1) * (uint16_t)full_h) : px;
if (cx)
src += cx * sstride;
src += sy;
_bgi_begin();
dst = (uint8_t *)(_gfx_addr_base + (uint16_t)x);
_bgi_blit_cols_raw(src, dst, (uint8_t)w, (uint8_t)h, sstride, (uint8_t)y);
_bgi_end();
}
+27
View File
@@ -0,0 +1,27 @@
/*
* gfx_set_idle_hook что вызывать, пока gfx_wait_vsync ждёт луч.
*
* ЗАЧЕМ. Ожидание кадра это самый длинный простой в кадре: у игры с
* пейсингом «3 растровых кадра на логический тик» процессор проводит там
* ~42 мс из 60, крутя `in a,(#0xFE)` и больше ничего не делая. Хук
* позволяет занять это время полезным опросом, не отнимая ни такта у
* полезной работы.
*
* Первый потребитель (и повод завести) клавиатура: приёмный FIFO SIO
* всего 3 байта, а одно нажатие расширенной клавиши при зажатом Shift
* даёт пачку из 5 байт («fake shift»); если импульс запроса прерывания
* потерян, байты копятся и один теряется. Лечится вычерпыванием по
* опросу (`kbd_raw_poll` из <kbd_raw.h>) но только ПЛОТНЫМ, раз в
* ~0.5 мс, а не пару раз за кадр. Замер: applications/PoP/roomtest/
* TASKS.md, задача KBD-1.
*
* ТРЕБОВАНИЯ К ХУКУ: он зовётся в тесном цикле, поэтому обязан быть
* дешёвым и НЕ рисовать (окно W3 в этот момент принадлежит вызывающему,
* а не графике) и не ждать сам. fn = 0 снять хук.
*/
#include "../_bgi.h"
void gfx_set_idle_hook(void (*fn)(void))
{
_gfx_idle_hook = fn;
}
+22
View File
@@ -62,6 +62,7 @@ static void gfx_wait_vsync_beam(void)
;; --- фаза 1: дождаться bit5==1 (гарантированно в blank) ---
ld bc, #0 ; ~65536 попыток на фазу таймаут
_gwv_wait1:
call _gwv_idle ; занять простой (см. gfx_set_idle_hook)
in a, (#0xFE)
bit 5, a
jr nz, _gwv_have1
@@ -75,6 +76,7 @@ static void gfx_wait_vsync_beam(void)
;; --- фаза 2: дождаться bit5==0 (сам переход начало кадра) ---
ld bc, #0
_gwv_wait0:
call _gwv_idle
in a, (#0xFE)
bit 5, a
jr z, _gwv_done
@@ -83,6 +85,26 @@ static void gfx_wait_vsync_beam(void)
or c
jr nz, _gwv_wait0
;; таймаут и здесь переходим к тому же фолбэку
jr _gwv_fallback
;; --- вызов idle-хука ------------------------------------- *
;; BC (счётчик таймаута) сохраняем: хук обычная C-функция и
;; клобберит всё, кроме IX. Косвенный вызов через push адреса
;; возврата + jp (hl): `call (hl)` в Z80 нет. Цена холостого
;; прохода около двух десятков тактов в цикле, который и так
;; сжигает время впустую.
_gwv_idle:
ld hl, (__gfx_idle_hook)
ld a, h
or l
ret z ; хук не поставлен
push bc
ld de, #_gwv_idle_ret
push de ; адрес возврата для jp (hl)
jp (hl)
_gwv_idle_ret:
pop bc
ret
_gwv_fallback:
ei

Some files were not shown because too many files have changed in this diff Show More