SprPoP: автономное приложение, выделенное из roomtest

Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:12:28 +03:00
parent 4b74478d19
commit 31b82661eb
235 changed files with 51293 additions and 10 deletions
+239
View File
@@ -0,0 +1,239 @@
/*
* pop_guard.c — СОСТОЯНИЕ и ЛОГИКА стража (W1/W2). См. pop_guard.h.
*
* Здесь только то, что должно оставаться достижимым из банка (guards.c):
* поля Guard, HP, вход в комнату, чит-убийство, чтение кадра из таблицы.
* ОТРИСОВКА — в pop_cdraw.c (банк 4), общая на всех Char.
*/
#include <stdint.h>
#include "pop_guard.h"
#include "pop_geom.h"
#include "pop_level.h"
#include "pop_kid.h" /* Char/play_seq — интерпретатор общий */
#include "pop_map.h" /* hitp_* — HP Кида */
#include "pop_state.h" /* pop_immortal — чит бессмертия */
#include "pop_tile.h" /* POP_TILE_DIV — колонка по X без деления */
#include "kid_data.h"
pop_char_t Guard;
uint8_t guardhp_curr, guardhp_max;
int8_t guardhp_delta;
uint8_t pop_guard_hurt;
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;
int8_t pop_united_shadow; /* см. pop_guard.h: 0 / счётчик / 1 */
uint8_t pop_shadow_init;
int8_t pop_guard_notice_timer; /* фора Джафара (уровень 13) */
/* Сид бросков боёвки. В оригинале генератор один на всё, у нас
* последовательности разведены по доменам (кладка / анимация тайлов / бой),
* см. pop_geom.h — иначе перерисовка стены сбивала бы броски стража. */
pop_rnd_t pop_fight_seed;
/* Кадр стража (порт cur_frame для Char). Глобальный, а не статик: его
* заполняет логика (W1/W2), а читает отрисовка из резидента W3. */
kframe pop_gframe;
uint8_t pop_guard_present(void)
{
return (uint8_t)(Guard.charid != 0 && Guard.alive < 0 && guardhp_curr != 0);
}
void pop_guard_reset(void)
{
Guard.charid = 0;
Guard.alive = 0;
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):
* if (Guard.charid != charid_4_skeleton) {
* guardhp_delta = -guardhp_curr;
* Guard.alive = 0;
* }
* Скелета чит не убивает — он и в оригинале бессмертен. */
void pop_guard_kill(void)
{
if (Guard.charid == CHARID_4_SKELETON) return;
if (!pop_guard_present()) return;
/* HP не обнуляем — ставим дельту, её применит pop_do_delta_hp. Дальше
* play_guard увидит «жив, но HP ноль», сам переведёт в alive = 0 и
* вызовет on_guard_killed: брызги, вспышка, а на 13-м уровне — флаг
* «выход разрешён».
*
* ОСОЗНАННОЕ РАСХОЖДЕНИЕ (решение пользователя 2026-08-13, ради скорости
* прогонов): оригинал (seg000:786) ставит здесь ещё и `Guard.alive = 0`,
* а гейт в play_guard (seg006:1490) требует `alive < 0` — то есть у него
* чит убивает стража В ОБХОД события смерти, и победа над Джафаром по
* читу выхода на 14-й уровень НЕ открывает. Для отладки это дорого:
* каждый прогон приходится честно выигрывать бой. Убрав одну строку,
* получаем ровно «как будто убил Кид». На игру не влияет — читы в
* релизной сборке выключены (pop_cheats). */
guardhp_delta = (int8_t)-guardhp_curr;
}
/* take_hp (seg006:06BD) — общий для боёвки и физики; см. pop_guard.h. */
uint8_t pop_take_hp(uint8_t count)
{
if (Char.charid == CHARID_0_KID) {
/* ЧИТ БЕССМЕРТИЯ, уровень 2 (pop_state.h): гасим ТОЛЬКО мелкий урон
* — падение с двух этажей, придавливание плитой. Мгновенная смерть
* (пики, чомпер, падение с трёх этажей, удар вне боевой стойки)
* приходит с count = 100 и проходит всегда: у читa нет задачи
* отменять смерть, у него задача отменить «минус одно деление».
* Уровень 1 сюда не вмешивается вовсе — он живёт в hurt_by_sword и
* гасит только удары мечом. */
if (pop_immortal >= 2 && count < 100) 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;
}
/* timers (seg003:0735) — кадровые счётчики спецсобытий.
*
* Вспышка слияния с тенью досчитывает до нуля и УХОДИТ В −1: это «уже
* слились», состояние до конца уровня. Именно на −1 смотрят check_shadow
* (больше не поднимать) и «появление плит» в комнатах 2/13 (pop_map).
* Ноль как конечное значение не годится — он неотличим от «не было». */
void pop_frame_timers(void)
{
if (pop_united_shadow > 0) {
--pop_united_shadow;
if (pop_united_shadow == 0) pop_united_shadow = -1;
}
if (pop_guard_notice_timer > 0) --pop_guard_notice_timer;
}
/* do_delta_hp (seg003): применить накопленную за кадр дельту HP и обнулить
* её. Звать раз в кадр ПОСЛЕ тиков персонажей, как в play_frame. */
void pop_do_delta_hp(void)
{
int16_t hp;
/* Спецсобытие уровня 12 (seg000:1518): ранил ТЕНЬ — ранил себя. Урон
* не делится, а КОПИРУЕТСЯ: тень теряет 1 HP и Кид теряет 1 HP. Без
* этого бой с тенью теряет смысл — её можно было бы просто зарубить.
* Проверка идёт по слоту соперника (у оригинала — по Opp, но в момент
* do_delta_hp там всегда пара Кид/соперник). */
if (pop_current_level == SHADOW_FIGHT_LEVEL &&
Guard.charid == CHARID_1_SHADOW && guardhp_delta != 0)
hitp_delta = guardhp_delta;
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;
/* Брызги и красная вспышка — только на УРОН: и draw_kid (seg008:1643),
* и flash_if_hurt (seg003:785) смотрят именно `hitp_delta < 0`. С
* условием «дельта не ноль» их получало и зелье здоровья (+1). */
if (hitp_delta < 0) 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;
if (guardhp_delta < 0) pop_guard_hurt = 1; /* -> брызги поверх стража */
guardhp_delta = 0;
}
}
/* check_guard_fallout (seg002:0241): персонаж слота Guard провалился НИЖЕ
* комнаты. Уйти в комнату снизу он не может (у стража нет перехода), и
* оригинал его просто убирает: guards_tile комнаты становится −1, то есть
* стража там больше нет и после возврата. У нас тот же смысл — запомнить
* состояние с tile = 30 («стража в комнате больше нет»).
*
* Ветка ТЕНИ (seg002:0241): она исчезает, только если РЕАЛЬНО падает
* (`action == actions_4_in_freefall`) — иначе тень, зашедшую низко по
* своим делам (уровень 5 идёт по нижнему ряду за зельем), убрало бы с
* экрана на ровном месте. Ветка скелета — ниже, он не гибнет, а
* возрождается.
*
* Полосу HP перерисовывать отдельно не надо: pop_hp_draw сравнивает числа
* и сам увидит, что стража не стало. */
/* Куда и как возрождается упавший СКЕЛЕТ (seg002:252): если под комнатой,
* из которой он выпал, лежит комната 3 — он появляется там снова. Убить
* его нельзя вовсе, падение лишь переносит. */
#define SKEL_REAPPEAR_ROOM 3
#define SKEL_REAPPEAR_X 133
#define SKEL_REAPPEAR_ROW 1
/* actions_4_in_freefall (seg005) — тот же номер, что ACT_FREEFALL в
* pop_map.c; здесь свой define, потому что тот приватный для карты. */
#define ACT_4_IN_FREEFALL 4
void pop_guard_fallout(void)
{
if (Guard.charid == 0 || Guard.y < 211) return;
if (Guard.charid == CHARID_1_SHADOW) {
/* Тень исчезает только в свободном падении; иначе — не наше дело. */
if (Guard.action != ACT_4_IN_FREEFALL) return;
Guard.direction = DIR_56_NONE;
Guard.alive = 0;
Guard.action = 0;
Guard.charid = 0;
guardhp_curr = 0;
return;
}
if (Guard.charid == CHARID_4_SKELETON) {
uint8_t down = pop_room_link(Guard.room, 3 /* вниз */);
if (down == SKEL_REAPPEAR_ROOM) {
Guard.room = down;
Guard.x = SKEL_REAPPEAR_X;
Guard.curr_row = SKEL_REAPPEAR_ROW;
Guard.direction = 0; /* dir_0_right */
Guard.alive = -1;
pop_guard_leave(); /* запомнить его в новой комнате */
return;
}
}
pop_guard_state_save(Guard.room, 30, 0, 0, 0, 0, 0);
Guard.charid = 0;
Guard.direction = 0;
Guard.alive = 0;
guardhp_curr = 0;
}
/* load_frame для стража (seg006:0293 ветка charid_2_guard): таблица у
* стражей СВОЯ (frame_tbl_guard), индекс = frame + add_frame 149, где
* add_frame = 70 для кадров 102..106 (seg006:535). */
void pop_guard_load_frame(void)
{
int16_t idx = (int16_t)Guard.frame;
if (idx >= 102 && idx < 107) idx += 70; /* add_frame */
idx -= 149;
if (idx < 0 || idx >= KID_NGFRAMES) { pop_gframe.image = 255; return; }
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; /* стража не было */
/* Тень и МЫШЬ в данные уровня не записываются (seg002:02F5): они не
* «стражи комнаты», а спецсобытия — вернувшись, их надо получить заново
* от check_shadow / check_mouse, а не поднять из guards_tile. */
if (Guard.charid == CHARID_1_SHADOW || Guard.charid == CHARID_24_MOUSE) return;
pop_guard_state_save(Guard.room,
(uint8_t)(Guard.curr_row * 10), /* get_tilepos(0,row) */
Guard.direction, Guard.x, guard_skill,
Guard.curr_seq,
(uint8_t)(Guard.alive >= 0));
}