Compare commits

...

10 Commits

Author SHA1 Message Date
snark13 aaa480d0f2 SprPoP: в CLAUDE.md — игра на образе лежит в D:\GAMES\SPRPOP
С обобщения HDD-сборки (ea8efdb) один образ рассчитан на несколько
приложений, и EXE переехал в подкаталог.  В инструкции по запуску это не
отразили, поэтому старая последовательность (`d:` + `sprpop`) отвечает
`Bad command or file name`, а `dir D:\` показывает только каталог GAMES —
на это можно потратить время впустую.

Записаны и сам путь, и исправленная цепочка клавиш для моста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:40 +03:00
snark13 8548aa9132 SprPoP: кнопка под Кидом больше не заливается кладкой (палас)
Симптом (пользователь, 2026-09-02, уровень 4 комната 18): Кид
подтягивается на открывающую кнопку (0,4), и кромка, которая его
прячет, нарисована кирпичом вместо кнопки.

РАЗБОР ПО ЖИВОМУ КАДРУ (заморозка): Kid frame 139, x 123, col 4, row 1,
room 18 — то есть персонаж действительно под кромкой кнопки (0,4).
Отсюда путь: кадр подъёма -> climb_overlay_tile; тайл не floor-подобный
(кнопка) -> other_overlay_tile; сосед слева (0,3) пуст -> overlay_mid_tile,
полная перерисовка тайла ПОВЕРХ персонажа.

ПРИЧИНА.  draw_tile_base оригинала подменяет базу кнопки на «левую
половину без пола слева» (id 148) по ТРЁМ условиям (seg008:628): тайл —
opener, слева пусто И tbl_level_type[current_level] == 0, то есть только
в ПОДЗЕМЕЛЬЕ.  Третье условие у нас было потеряно ИМЕННО В ОВЕРЛЕЕ:
статическая отрисовка (pop_room.c, draw_tile_base) его имеет и даже
ссылается на ту же строку оригинала, а pop_bg.c — нет.  Уровень 4
дворцовый, поэтому оверлей брал спрайт 148, а в дворцовом наборе это
другая картинка.

Этим же объясняется, почему баг не виден «просто так»: пока тайл рисует
статика, кнопка правильная — кирпич появляется ровно в тот момент, когда
персонаж встаёт под кромку и включается оверлей.

Цена: +6 байт в банке 2 (8976 -> 8982, свободно 7402).

Проверять стоит на любом ДВОРЦОВОМ уровне, где у открывающей кнопки
слева пусто и персонаж лезет на неё снизу.  Живая проверка в MAME
пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:29 +03:00
snark13 400f5cba63 SprPoP: поправка в CLIMB-VS-GUARD — звук 11 значит ПРОМАХ
В записи стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет.  Это
было описание НАШЕГО кода, а не SDLPoP: ровно так звук у нас и стоял —
внутри ветки «не парировано» и до присвоения Opp.action = 99_hurt (снято
предыдущим коммитом).  Классическое нарушение правила «источник истины —
SDLPoP»: за оригинал приняли собственную реализацию, и вывод из неё уехал
в доску как факт.

На деле звук 11 в оригинале защищён условием Opp.action != 99_hurt, то
есть звучит ТОЛЬКО на промахе: на попадании играет боль, на парировании
Opp.frame уже 161.

Что это меняет для самого бага:

- УЦЕЛЕЛО наблюдение «в оригинале 11, у нас 8» — это данные.  Арифметика
  приоритетов их подкрепляет: snd_prio[11] = 0x12 против snd_prio[8] =
  0x4B (меньше значит важнее), взмах не мог быть заглушён упором в стену.
- ОТПАЛ вывод «оригинал ведёт стража атакой с промахом, а мы —
  столкновением»: он опирался на неверную посылку.
- УСИЛИЛАСЬ версия про ПОРОГ ДИСТАНЦИИ: раз в оригинале слышен именно 11,
  укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ.  Прежняя «версия про столкновение»
  вытеснила дистанцию зря — возвращаем её в главные подозреваемые.

Отдельно предупреждение тому, кто вернётся к багу: диагностика по звуку
теперь значит другое, старые заметки прогона будут вводить в заблуждение,
сцену надо переснимать.  Положена таблица соответствий (11 = промах, 13 =
попадание, 8 = столкновение, тишина = ранний выход из check_hurting).

На механику урона правки не влияют — расхождение «удар убивает» остаётся
открытым как было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:07 +03:00
snark13 cb995bf0fc SprPoP: непрерывный взмах клинка у безоружного Кида
Симптом (пользователь, 2026-09-02): на подъёме Кида шёл нескончаемый
свист клинка, хотя меча у него нет.

ОПОЗНАНИЕ.  Звук снят с живой машины, а не угадан: snd_curr == 11
(sound_11_sword_moving).  Заодно проверено, что тракт исправен —
указатели насоса (pg 2, ptr 0x2B80, left 512) сошлись с записью 11 в
SND/snd.idx (страница 2, off 0x2880, длина 1280, конец 0x2D80) байт в
байт.  То есть играл честно заявленный эффект из своих данных, и виновата
была ЗАЯВКА, а не звук.

ПРИЧИНА.  Хвост check_hurting был портирован не до конца.  В оригинале
(seg002:1039..1044) звук 11 стоит В КОНЦЕ функции, после ОБЕИХ веток, и
защищён тремя вещами: ранним выходом по dir_56_none, кадром 154 и
условием Opp.action != actions_99_hurt.  У нас он стоял ВНУТРИ ветки «не
парировано», до присвоения Opp.action = 99_hurt, без проверки на
попадание и — главное — без гарда по dir_56_none, который автор SDLPoP
подписал прямым текстом: «Fix looping sword moving sound».  Направление
dir_56_none означает «персонаж выключен» (clear_char), махать ему нечем.

Наш движок эту константу знает и применяет в pop_guard_tick — в
check_hurting она просто не доехала.

ТОНКОСТЬ ПОРТА: хвост оригинала ПЕРЕЧИТЫВАЕТ Char.frame и Opp.frame у
персонажей, а не берёт снимки начала функции.  Ветка парирования только
что записала Opp.frame, а play_seq в ней — Char.frame; на кэшированных
cf/of условие дало бы неверный ответ.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:45 +03:00
snark13 38fb3c03bb SprPoP: зацеп больше не срывается на кадре после захвата
Регресс от 4e12aa5: glide_through_wall_guard() звался в do_fall сразу
после check_grab и отменял ТОЛЬКО ЧТО СОСТОЯВШИЙСЯ зацеп.

Причина в имени последовательности: seq_15 — это grab_ledge_MIDAIR, и
после удачного захвата Char.action == 3, то есть персонаж формально всё
ещё «в воздухе», уже подтянутый вплотную к кромке.  А под кромкой в
комнатах оригинала стоит кладка.  Guard видел ровно её (замерено на живой
сцене: t == TILE_WALL, d == 10, col 0, row 1), считал это пролётом сквозь
стену, отбрасывал персонажа на 5 пикселей назад и гасил fall_x.  Зацеп
РИСОВАЛСЯ и тут же срывался — ловилось на длинном прыжке уровня 3
(комната 7) и в attract-демо (комната 2, прыжок с места на кромку 0,2,
после срыва Кид падал на пики).

ФИКС.  check_grab() возвращает признак «зацепился», и при нём guard не
зовётся.  Цена — один тест байта на кадр падения; фикс «падение сквозь
стену» цел, t_wall зелёный.

ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ — и главный урок.  t_grab, t_phys
(1733 проверки) и t_wall судят по «действие стало вис», а вис-то
наступал, он просто не жил.  Первый A/B guard'а по этой же метрике дал
ЛОЖНО-ОТРИЦАТЕЛЬНЫЙ ответ, и подозрение с него было снято зря.  Разница
между «зацепился» и «зацепился и держится» — это и есть разница между
багом и нормой.

Отсюда новый набор t_hang: критерий — вис ДЕРЖИТСЯ три кадра подряд, и в
отчёте различаются «не наступило» и «наступило и сорвалось».  Окно — 34
фазы разбега из 41; при намеренно возвращённой поломке 0 из 41, тест
краснеет (проверено).  Сцена — геометрия уровня 3 комнаты 7, приведённая
внутрь одной комнаты, чтобы шов не примешивался; кромка-пол и
кромка-решётка проверяются отдельно.

Живая проверка в MAME пользователем: корректно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:40:23 +03:00
snark13 879f2bae31 size_baseline: принять atlas — эталон был записан со старым .map
atlas значился 9193 Б против 9627 фактических, и расхождение держалось
через все правки этой сессии: замерял со СПРЯТАННЫМИ правками — те же
9627, то есть к ним оно отношения не имеет.

Похоже на механику самого инструмента: size_check.py читает .map С ДИСКА,
а не пересобирает.  Программа, которую в тот раз не пересобрали, попадает
в эталон со СТАРЫМ числом — и дальше висит расхождением, пока её однажды
не соберут заново.  Сегодня после make clean впервые за долгое время
пересобралось всё, и накопленный рост libc/libbgi стал видимым разом.

Записываю честное число.  Полезный вывод на будущее: `make size-baseline`
имеет смысл только после полной пересборки тестов, иначе он фиксирует
смесь свежих и устаревших размеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:42 +03:00
snark13 b3f9a7430c SprPoP: тихие наборы звука и музыки в dist/
Выхлоп tools/pop_quiet.py на текущих наборах: те же snd.arc и m*.bin, но
вдвое тише (out = in/2 + 0x40, тишина 0x80 остаётся на месте), плюс
неизменённые индексы.  Раскладка совпадает с диском игры.

Это ПРОИЗВОДНЫЙ артефакт: восстанавливается из assets/packed одной
командой `python3 tools/pop_quiet.py` за секунды.  Лежит в репозитории по
той же логике, что и assets/packed — чтобы вариант был под рукой без
пересборки; если решим, что 3,6 МБ того не стоят, снимается одним
git rm --cached плюс строка в .gitignore.

На образ игры dist/ автоматически не попадает: make hdd берёт assets/packed
через build/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:12 +03:00
snark13 fee3bdb354 SprPoP: ALLOCS по умолчанию 10000; обёртка dist_to_edge — за своим хелпером
Правки пользователя, разобранные по диффу и закоммиченные как есть.

Makefile: умолчание --max-allocs поднято с 6000 до 10000 — плотнее
упаковка регистров.  Напоминание из шапки остаётся в силе: занятость
банков сравнима только при ОДНОМ значении ALLOCS, иначе сравниваются не
правки, а уровни оптимизации.

pop_map.c: банковая обёртка pop_dist_to_edge_weight() переехала НИЖЕ
статического distance_to_edge_weight(), который она зовёт, — раньше стояла
до его определения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:56 +03:00
snark13 6a97124e0d sprinter-cc: --bank-data принимает номер банка
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Было «всё или ничего»: --bank-data уводил писучие данные В СТРАНИЦУ для
ВСЕХ банков сразу.  Теперь флаг принимает необязательный номер и
повторяется: --bank-data 5 --bank-data 6.  Без аргумента поведение прежнее
(все банки), поэтому существующие сборки не меняются.

Зачем поштучно: данные в странице банка НЕ ВИДНЫ снаружи, поэтому модуль с
экспортируемым глобалом обязан остаться на общем _DATA в W1/W2 — а его
сосед в это же время может держать большой приватный буфер вне резидентного
бюджета.  Одним флагом на всю программу это не выражается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:43 +03:00
snark13 8610a8c178 toolchain: предупреждать, когда ISR-стаб W0-страниц остался в W1
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Программа может временно маппить свою EMM-страницу в окно 0 (атласы
спрайтов, gfx_w0_map).  Пока страница там, любое прерывание уходит на
#0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare прошивает туда переход на
_gfx_w0_isr.  Сам стаб живёт в _CODE, а в режимах small и huge _CODE
начинается с 0x4100 — то есть попадает в W1, окно, которое DSS перемаплет
на время СВОИХ вызовов.  Прерывание в этот момент уходит по адресу,
которого сейчас нет: W1 читается как #FF.

Ловится тяжело: собирается молча, проявляется недетерминированно —
зависанием примерно на каждом третьем холодном старте.  Поймано вживую
2026-09-01.

check_w0_isr.py берёт адрес __gfx_w0_isr из .map: символа нет — W0-путь не
слинкован, молчим; адрес >= 0x8000 — он в W2, всё хорошо; иначе WARNING.
Сборку не валит намеренно: свои страницы в W0 кладут не все.  Вызов
добавлен в app.mk рядом с check_bank_calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:26 +03:00
37 changed files with 372 additions and 50 deletions
+9 -1
View File
@@ -92,11 +92,19 @@ RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
# ПРОВЕРКА ISR-СТАБА W0-СТРАНИЦ — там же, по свежей карте. Ловит стаб
# _gfx_w0_isr, оставшийся в W1: программа, кладущая свои страницы в W0
# (атласы спрайтов, gfx_w0_map), получает недетерминированные зависания,
# когда прерывание приходит во время вызова DSS и W1 перемаплен. Только
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
+7 -1
View File
@@ -133,9 +133,15 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
**Игра лежит на образе в `D:\GAMES\SPRPOP\`**, а НЕ в корне диска (так с
обобщения HDD-сборки, коммит ea8efdb — один образ рассчитан на несколько
приложений). `dir D:\` показывает только каталог `GAMES`; запуск из корня
отвечает `Bad command or file name`.
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
старт `run_bridge.sh` → 6 с`go` → 8 с`keyseq d:{ENTER}` +
`keyseq sprpop{ENTER}` → 5 с программа работает.
`keyseq cd games\sprpop{ENTER}` + `keyseq sprpop{ENTER}` → 5 с
программа работает.
Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
+1 -1
View File
@@ -47,7 +47,7 @@ MEMORY ?= huge
#
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000
ALLOCS ?= 10000
# Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell git log -1 --format=%cs 2>/dev/null || echo unknown)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54 -14
View File
@@ -38,7 +38,7 @@
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные; толкование звука ИСПРАВЛЕНО 2026-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
@@ -1374,23 +1374,63 @@ uint16_t pop_music_left(void) { uint16_t a, b;
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
А звучащий вместо него упор в стену говорит, что у нас сработало
СТОЛКНОВЕНИЕ, а не атака.
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
«атака с промахом», а мы — по ветке «столкновение с персонажем»
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
сквозь пол стража: столкновение двигает его координату, а не атака.
Проверять надо ветку выбора действия стража, а не только дистанцию.
**ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
Здесь стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет. Это
описание НАШЕГО кода, а не оригинала: ровно так звук стоял у нас, внутри
ветки «не парировано» и до присвоения `Opp.action = 99_hurt`. В SDLPoP
(seg002:1039..1044) он стоит в ХВОСТЕ `check_hurting` и защищён условием
`Opp.action != actions_99_hurt`:
if (Char.direction == dir_56_none) return; // Fix looping "sword moving" sound.
if (Char.frame == frame_154_poking && Opp.frame != frame_161_parry &&
Opp.action != actions_99_hurt)
play_sound(sound_11_sword_moving);
То есть звук 11 в оригинале — это индикатор **ПРОМАХА**, а не укола: на
попадании его нет (играет боль), на парировании нет (`Opp.frame` уже 161).
Расхождение найдено и исправлено 2026-09-02 по симптому «непрерывный взмах
клинка у безоружного Кида на подъёме» (звук опознан в живой сессии по
`snd_curr == 11`; указатели насоса при этом сошлись с `snd.idx` байт в
байт, то есть тракт звука был исправен). Заодно приехал и пропущенный
ранний выход по `dir_56_none` — тот самый, что автор SDLPoP подписал
«Fix looping sword moving sound».
**Что из прежних выводов уцелело, а что нет.**
* УЦЕЛЕЛО: наблюдение «в оригинале слышен 11, у нас 8» — это данные, и они
остаются. Арифметика приоритетов их подкрепляет: `snd_prio[11] = 0x12`
(18) против `snd_prio[8] = 0x4B` (75), меньше значит важнее, поэтому
взмах не мог быть заглушён упором в стену. При ТОГДАШНЕМ коде звук 11
звучал на любом уколе, значит его отсутствие действительно означало, что
до кадра укола дело не дошло.
* ОТПАЛО: вывод «оригинал ведёт стража атакой с промахом, а мы —
столкновением» опирался на неверную посылку и больше не следует из звука
сам по себе.
* УСИЛИЛОСЬ: то, что в оригинале в этой сцене слышен ИМЕННО 11, теперь
доказывает, что укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ. А это ровно версия
про ПОРОГ ДИСТАНЦИИ (см. выше про пару пикселей и нижний порог 8), а не
про потерянную ветку. Прежняя «версия про столкновение» её вытеснила
зря — возвращаем дистанцию в главные подозреваемые.
**ВНИМАНИЕ тому, кто вернётся к этому багу: диагностика по звуку с
2026-09-02 ЗНАЧИТ ДРУГОЕ.** Старые заметки прогона будут вводить в
заблуждение — переснимать сцену заново. Новая таблица:
| слышно | что это значит |
|---|---|
| 11 (взмах) | укол состоялся и ПРОМАЗАЛ — поведение оригинала в этой сцене |
| 13 (боль Кида) | укол ПОПАЛ, `Opp.action = 99_hurt` |
| 8 (упор в стену) | укола не было вовсе, сработало столкновение |
| тишина | `check_hurting` вышел раньше: меч не вынут / ряды не совпали / кадр не 153-154 |
На саму механику урона правка 2026-09-02 НЕ влияет (тронут только звук),
поэтому расхождение «удар убивает» остаётся открытым как было.
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному
+23 -2
View File
@@ -1102,8 +1102,6 @@ static void check_hurting(void)
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
/* Соперник НЕ парирует. */
if (cf == FRAME_154_POKING) {
/* seg002:0DAE — свист клинка мимо цели. */
pop_sfx_play(11);
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
distance = pop_char_opp_dist();
if (distance >= (int16_t)min_range && distance < 29)
@@ -1116,6 +1114,29 @@ static void check_hurting(void)
pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
play_seq();
}
/* СВИСТ КЛИНКА МИМО ЦЕЛИ (seg002:1039..1044) — в оригинале он стоит в
* ХВОСТЕ функции, после ОБЕИХ веток, а не внутри ветки «не парировано».
* Мы его туда и переносим; раньше он звучал раньше времени и без двух
* условий оригинала, отчего на подъёме Кида шёл непрерывный взмах
* клинка у безоружного (наблюдение пользователя 2026-09-02, звук
* опознан по snd_curr == 11 в живой сессии).
*
* Три отличия от прежнего кода, все из оригинала:
*
* 1. Ранний выход по dir_56_none это ИМЕННО анти-зацикливание, автор
* SDLPoP так его и подписал («Fix looping sword moving sound»).
* Направление dir_56_none означает «персонаж выключен» (clear_char),
* и махать клинком ему нечем.
* 2. Условие `Opp.action != ACTION_99_HURT`: попали играет звук боли,
* а не свист мимо. Поэтому звук обязан идти ПОСЛЕ присвоения выше.
* 3. Кадры перечитываются у персонажей, а не берутся из cf/of: ветка
* парирования только что записала Opp.frame, а play_seq в ней
* Char.frame, и снимки выше уже устарели. */
if (Char.direction == DIR_56_NONE) return;
if (Char.frame == FRAME_154_POKING && Opp.frame != FRAME_161_PARRY &&
Opp.action != ACTION_99_HURT)
pop_sfx_play(11);
}
/* check_sword_hurting (seg002:0D1A): прогнать проверку с ОБЕИХ сторон. */
+14 -3
View File
@@ -771,15 +771,26 @@ static void overlay_mid_tile(int row, int col)
pop_env_b(42, x, pop_tile_table[1].right_y + dmy);
{ /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id:
* у loose верх плиты берётся из loose_fram_left (в pop_tile_table base_id=0),
* у opener'а без пола слева 148. Раньше здесь стоял голый base_id, и
* у opener'а без пола слева 148, НО только в ПОДЗЕМЕЛЬЕ. Условие
* `tbl_level_type[current_level] == 0` в оригинале стоит третьим
* (seg008:628) и у нас было потеряно ИМЕННО ЗДЕСЬ: статическая
* отрисовка (pop_room.c, draw_tile_base) его имеет, а оверлей нет.
* Отсюда симптом «кнопка, на которую лезет Кид, залита кирпичом»:
* пока тайл рисует статика, кнопка правильная, но как только Kid
* встаёт под кромку, оверлей перерисовывает её поверх него уже
* спрайтом 148, а во дворце это другая картинка (кладку палас
* вообще рисует заливками, а не спрайтами). Уровень 4, комната 18,
* кнопка (0,4) найдено пользователем 2026-09-02.
*
* Раньше здесь стоял голый base_id, и
* верх loose-плиты в оверлей не попадал: передняя грань ложилась поверх
* Kid (foretable), а сама плита нет, и подтягивающийся Kid рисовался
* поверх её верхней плоскости (комната 12). */
uint8_t base_id = t->base_id;
if (code == 11 && row >= 0)
base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])];
else if (code == 0x0F && lcode == 0)
base_id = 148;
else if (code == 0x0F && lcode == 0 && !pop_palace)
base_id = 148; /* ТОЛЬКО ПОДЗЕМЕЛЬЕ (seg008:628) */
if (base_id)
pop_env_b(base_id, x, t->base_y + dmy);
}
+37 -22
View File
@@ -555,11 +555,6 @@ void pop_determine_col(void) __banked { determine_col(); }
* Обе давно работают от Char, а не от Kid; не хватало только объявлений
* (docs/sdlpop_audit.md, находка 12). */
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
@@ -570,8 +565,15 @@ static int distance_to_edge(int xpos)
if (Char.direction == 0) d = TILE_RIGHTX - d; /* dir_0_right */
return d;
}
static int distance_to_edge_weight(void) { return distance_to_edge(dx_weight()); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* ---- ТОЧНАЯ коллизия со стеной (seg004) --------------------------- *
* Передний край персонажа = char_x_right (взгляд вправо) / char_x_left
* (влево) из set_char_collision (seg006:0723): obj_x/2 + 58, где obj_x =
@@ -837,30 +839,35 @@ static uint8_t can_grab_front_above(void)
/* check_grab (seg006:0A28): в падении при зажатом Shift — зацепиться за
* уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота
* подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит
* grab_timer (блок climb-up на несколько кадров). */
static void check_grab(void)
* grab_timer (блок climb-up на несколько кадров).
*
* ВОЗВРАТ: 1 зацеп СОСТОЯЛСЯ на этом кадре. Нужен вызывающему (do_fall):
* дальше по кадру идёт glide_through_wall_guard, и ему нельзя трогать
* персонажа, которого мы только что повесили на кромку (см. там же). */
static uint8_t check_grab(void)
{
uint8_t old_x;
if (!pop_ctrl_shift_held()) return; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return;
if (!pop_ctrl_shift_held()) return 0; /* Shift не зажат */
if ((uint8_t)Char.fall_y >= 32) return 0; /* падает слишком быстро */
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return 0;
old_x = Char.x;
Char.x = (uint8_t)char_dx_forward(-8);
determine_col();
if (!can_grab_front_above()) {
Char.x = old_x; /* не за что — назад */
determine_col();
} else {
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
pop_char_set_seq(SEQ_15_GRAB_LEDGE_MIDAIR);
play_seq();
determine_col();
grab_timer = 12;
pop_sfx_play(9); /* seg006 check_grab */
is_screaming = 0; /* seg006:1219 */
return 0;
}
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
Char.fall_y = 0;
pop_char_set_seq(SEQ_15_GRAB_LEDGE_MIDAIR);
play_seq();
determine_col();
grab_timer = 12;
pop_sfx_play(9); /* seg006 check_grab */
is_screaming = 0; /* seg006:1219 */
return 1;
}
static void make_loose_fall(int pos, uint8_t modifier);
@@ -970,8 +977,16 @@ static void do_fall(void)
}
if (nrow > 4) nrow = 4; /* защита pop_y_land[] от выхода */
if ((uint16_t)pop_y_land[nrow] > (uint16_t)Char.y) {
check_grab(); /* ещё летит — попытка зацепа */
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
/* ЗАЦЕП ОТМЕНЯЕТ GUARD. seq_15 — «grab ledge MIDAIR», то есть после
* удачного зацепа Char.action == 3, персонаж уже подтянут вплотную к
* кромке, а под кромкой в оригинальных комнатах стоит КЛАДКА. Guard,
* позванный следом, видел ровно её (t == TILE_WALL, d == 10), считал
* это «пролётом сквозь стену», отбрасывал Кида на 5 пикселей назад и
* гасил fall_x зацеп рисовался и тут же срывался. Ловилось на
* длинном прыжке уровня 3 (комната 7 -> кромка комнаты 2) и в
* attract-демо; регресс-набор tests/host/t_glide.c. */
if (!check_grab()) /* ещё летит — попытка зацепа */
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
} else if (Char.curr_row <= 2) {
if (get_tile_at_char() == TILE_WALL)
in_wall();
+2
View File
@@ -49,6 +49,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys)
# t_hang — зацеп обязан ДЕРЖАТЬСЯ (регресс glide_through_wall_guard).
OBJS_hang := $(OBJS_phys)
# t_gate — открытая решётка не должна считаться стеной у осторожного шага
# (нужны pop_map + физика, то есть тот же состав).
OBJS_gate := $(OBJS_phys)
+95
View File
@@ -0,0 +1,95 @@
/*
* t_hang.c ЗАЦЕП ОБЯЗАН ДЕРЖАТЬСЯ, а не срываться на следующем кадре.
*
* Регресс 2026-09-02. glide_through_wall_guard() (порт fix_glide_through_wall,
* взят в билд 2026-08-31) звался в do_fall СРАЗУ ПОСЛЕ check_grab. Беда в
* том, что seq_15 называется grab_ledge_MIDAIR: после удачного зацепа
* Char.action == 3 (в воздухе), персонаж уже подтянут вплотную к кромке а
* под кромкой в комнатах оригинала стоит КЛАДКА. Guard видел ровно её
* (t == TILE_WALL, d == 10), считал это «пролётом сквозь стену», отбрасывал
* персонажа на 5 пикселей назад и гасил fall_x. Зацеп РИСОВАЛСЯ и тут же
* срывался уровень 3 (комната 7, длинный прыжок через четыре пролёта) и
* attract-демо (комната 2, прыжок с места).
*
* ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ. t_grab судит по «действие стало
* вис» а вис-то наступал, он просто не жил. Отсюда мера здесь другая:
* вис обязан ДЕРЖАТЬСЯ подряд несколько кадров. Разница между «зацепился»
* и «зацепился и держится» это и есть разница между багом и нормой.
*
* СЦЕНА (геометрия уровня 3, комната 7, приведённая внутрь одной комнаты,
* чтобы шов не примешивался): кромка на (0,0), под ней кладка (1,0), четыре
* пролёта (0,1..4), толчок с плиты (0,5), разбег справа.
*/
#include <stdint.h>
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#define E 0x00
#define F 0x01
#define G 0x04
#define L 0x0B
#define T 0x13
#define W 0x14
static uint8_t room[30] = {
F, E, E, E, E, L, F, T, F, T,
W, E, E, E, E, W, W, W, W, W,
W, E, E, E, E, W, W, W, W, W,
};
#define ACT_HANG_CLIMB 2
#define ACT_HANG_STRAIGHT 6
#define HOLD_FRAMES 3
/* Вис ДЕРЖИТСЯ: действие «вис» стоит HOLD_FRAMES кадров подряд. */
static uint8_t hang_holds(uint8_t ledge, uint8_t nrun)
{
uint8_t i, run = 0;
room[0] = ledge;
sc_room(room, 7);
sc_kid_at(9, 0, -1); /* лицом влево, у правого края */
sc_run(SC_L, nrun); /* разбег */
sc_run(SC_L | SC_U, 6); /* толчок в прыжок */
sc_trace_clear();
sc_run(SC_SHIFT, 30); /* полёт, зацеп и жизнь после него */
for (i = 0; i < sc_len; i++) {
uint8_t a = sc_trace[i].action;
if (a == ACT_HANG_CLIMB || a == ACT_HANG_STRAIGHT) {
if (++run >= HOLD_FRAMES) return 1;
} else run = 0;
}
return 0;
}
/* Сколько фаз разбега из 41 дают ЖИВОЙ вис. До регресса — все, кроме
* первой (слишком близкий толчок), то есть 34; при сорванном зацепе ноль. */
static uint8_t window(uint8_t ledge)
{
uint8_t k, n = 0;
for (k = 6; k <= 46; k++) if (hang_holds(ledge, k)) n++;
return n;
}
TC_TEST(long_jump_hang_survives_wall_under_ledge)
{
/* Кромка-пол: под ней кладка — та самая, на которую срабатывал guard. */
TC_TRUE(window(F) >= 30);
}
TC_TEST(long_jump_hang_survives_on_gate)
{
/* Кромка-решётка: тайл другой, кладка под ним та же. Проверяется
* отдельно, потому что зацеп за решётку идёт своей веткой can_grab. */
TC_TRUE(window(G) >= 30);
}
int main(void)
{
sc_init();
TC_RUN(long_jump_hang_survives_wall_under_ledge);
TC_RUN(long_jump_hang_survives_on_gate);
return 0;
}
+24 -5
View File
@@ -33,9 +33,16 @@
# --data-loc 0xN override SDCC --data-loc (default: derived from --memory)
# -Wl FLAG extra linker flag (repeatable)
# --bank N=FILE.c compile FILE.c as bank N; repeatable; pulls crt0_banked
# --bank-data put bank modules' writable data INTO the bank page
# --bank-data [N] put bank modules' writable data INTO the bank page
# (default: it goes to the shared _DATA in W1/W2, which
# stays mapped and is visible from everywhere)
# stays mapped and is visible from everywhere).
# Without an argument — for ALL banks (old behaviour).
# With a bank number — only for that bank; repeatable:
# --bank-data 5 --bank-data 6
# Per-bank matters because bank-local data is NOT visible
# from outside the bank: a module with an exported global
# must stay on the shared _DATA while its neighbour keeps
# a big private buffer off the resident budget.
# automatically and adds -Wl-b_BANKN=0x{N}C000
# --w3 FILE.c place FILE.c resident in window 3 (0xC000), called
# DIRECTLY (no trampoline). Repeatable. Defaults to
@@ -111,7 +118,8 @@ MKEXE_EXTRA=()
BANK_SPECS=() # entries like "1=engine.c"
W3_SPECS=() # entries like "mod.c" — резидентные модули окна W3 (--w3)
USER_DEFS=() # -DFOO / -DFOO=bar — пробрасываются в sdcc как есть
BANK_LOCAL_DATA=0 # 1 (--bank-data): писучие данные банков — В СТРАНИЦУ банка
BANK_LOCAL_DATA=0 # 1 (--bank-data без аргумента): данные ВСЕХ банков — в страницу
BANK_LOCAL_LIST=() # номера банков из --bank-data N (по одному на банк)
W3_RELS=() # заполняется при компиляции W3-модулей
W3_LD_FLAGS=() # -Wl-b_W3CODE=0xC000, если есть --w3
USER_SET_MEMORY="" # непусто, если --memory задан явно (для --w3 авто-small)
@@ -147,7 +155,14 @@ while [[ $# -gt 0 ]]; do
-Wl) LD_EXTRA+=("$2"); shift 2;;
-D*) USER_DEFS+=("$1"); shift;;
--bank) BANK_SPECS+=("$2"); shift 2;;
--bank-data) BANK_LOCAL_DATA=1; shift;;
--bank-data)
# Аргумент необязателен: «--bank-data» = все банки (как было),
# «--bank-data N» = только банк N, флаг повторяемый.
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
BANK_LOCAL_LIST+=("$2"); shift 2
else
BANK_LOCAL_DATA=1; shift
fi;;
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
--gfx) GFX_MODE="$2"; shift 2;;
@@ -464,7 +479,11 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
# W1/W2, но требует ручного контроля видимости, поэтому
# включается явно через --bank-data.
bank_data_flags=()
[[ $BANK_LOCAL_DATA -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
bank_local=$BANK_LOCAL_DATA
for bl in "${BANK_LOCAL_LIST[@]}"; do
[[ "$bl" == "$bank_n" ]] && bank_local=1
done
[[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
run "$SDCC" "${CC_FLAGS[@]}" \
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \
"${bank_data_flags[@]}" \
+1 -1
View File
@@ -3,7 +3,7 @@ accfill 3797
accop 7067
argv 3445
assrtest 3861
atlas 9193
atlas 9627
attrprob 4101
banked 1070
bankedbg 1081
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
3 accop
4 argv
5 assrtest
6 atlas
7 attrprob
8 banked
9 bankedbg
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
check_w0_isr.py предупредить, что ISR-стаб W0-страниц оказался в W1.
Зачем. Программа может временно маппить свою EMM-страницу в W0 (атласы
спрайтов в sprite_update, ресурсы через gfx_w0_map). Пока страница там,
ЛЮБОЕ прерывание уходит на #0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare()
прошивает туда `JP _gfx_w0_isr`, а стаб возвращает страницу ядра DSS, зовёт
честный обработчик и восстанавливает страницу обратно.
Мина. Сам стаб живёт в _CODE. В режимах small и huge _CODE начинается с
0x4100, то есть попадает в W1 окно, которое DSS перемаппит на время СВОИХ
вызовов (docs/new/04-memory.md §4.2, §4.9). Если прерывание придёт, пока
наша страница в W0 И идёт вызов DSS, переход уйдёт по адресу, которого в этот
момент нет: W1 читается как #FF, процессор уползает в бесконечный пролог
системного прерывания. Само libbgi это документирует в common/_gfx_w0_isr.c
(«обязан быть в W2»).
Собирается это МОЛЧА и проявляется НЕДЕТЕРМИНИРОВАННО зависанием примерно
на каждом третьем холодном старте, что легко списать на случайность. Поймано
вживую 2026-09-01 в порте Loom (huge): стаб был на 0x6769.
Как проверяем. По .map берём адрес __gfx_w0_isr. Если символа нет W0-путь
в программу не слинкован, молча выходим. Если адрес >= 0x8000 он в W2, всё
хорошо. Иначе печатаем WARNING: сборку не валим, потому что программа могла
и не класть свои страницы в W0.
Лечение (со стороны приложения): вернуть модули из банков в резидент или
разгрузить _DATA так, чтобы _CODE перевалил за 0x8000 и хвост libbgi уехал в
W2. Порядок линковки сам по себе не помогает: пока весь _CODE влезает в W1
(ёмкость 0x8000-0x4100 = 16128 Б), расстановка модулей ничего не меняет.
"""
import re
import sys
from pathlib import Path
W2_BASE = 0x8000
SYM = '__gfx_w0_isr'
MAP_RE = re.compile(r'^\s+([0-9A-Fa-f]{4,8})\s+(\S+)\s')
def find_symbol(map_path, name):
for line in map_path.read_text(errors='replace').splitlines():
m = MAP_RE.match(line)
if m and m.group(2) == name:
return int(m.group(1), 16)
return None
def main(argv):
if len(argv) != 2:
print(__doc__.strip())
return 2
build = Path(argv[1])
maps = list(build.glob('*.map'))
if not maps:
print(f'check_w0_isr: нет .map в {build}', file=sys.stderr)
return 2
addr = find_symbol(maps[0], SYM)
if addr is None:
return 0 # W0-путь не слинкован — проверять нечего
if addr >= W2_BASE:
print(f'check_w0_isr: {SYM} @ 0x{addr:04X} — в W2, ок')
return 0
print(f'check_w0_isr: ВНИМАНИЕ — {SYM} @ 0x{addr:04X} лежит в W1.',
file=sys.stderr)
print(' Если программа маппит свои страницы в W0 (атласы спрайтов, '
'gfx_w0_map,', file=sys.stderr)
print(' atlas_attach), прерывание во время вызова DSS уйдёт в '
'перемапленное W1 —', file=sys.stderr)
print(' недетерминированные зависания на старте. Стаб обязан быть в W2 '
f'(>= 0x{W2_BASE:04X}).', file=sys.stderr)
print(' Лечение: увести _CODE за 0x8000 (модули из банков в резидент / '
'разгрузить', file=sys.stderr)
print(' _DATA). Подробности — в шапке этого скрипта и '
'docs/new/04-memory.md §4.2.', file=sys.stderr)
return 0 # предупреждение, сборку не валим
if __name__ == '__main__':
sys.exit(main(sys.argv))