Files
Sprinter-SDCC/applications/PoP/docs/perf_registry.md
T
snark13 6faf81016a Замер цианной фазы: крупного лишнего в отрисовке персонажа нет
Циан 188 004 не двигался ни от P1, ни от P5, ни от P2b — разложил его
зондами.

Хорошая новость: Кид УЖЕ пропускается (204 такта на pop_char_draw), то
есть надежда P3 сбылась после P1 — метка от чомпера до него больше не
дотягивается.  Страж же перерисовывается каждый кадр честно: пламя
правого факела (0,7) рисуется в ячейке (0,8), где он стоит, и реально
накрывает ему голову (пламя занимает y 5..22, страж 12..62).

Отрисовка стража — 148 302:

  pop_char_fore (2 трамплина в банк 2 + обход тайлов)  62 778   42 %
  клинок (sword_draw + overlay_add + clip_add)         27 522   19 %
  блит спрайта + clip_char_right                       20 982   14 %
  загрузка кадра и геометрия                           12 696    9 %
  pop_clip_char_top (трамплин банк 4 -> банк 3)         8 658    6 %
  снимок прямоугольника + cd_clip_add                   7 890    5 %
  gfx_w0_unmap + cd_sig_make                            4 968    3 %
  вход + cd_heal                                        2 946    2 %

Единственная явно лишняя статья — трамплин clip_char_top, и снять его
непросто: функции нужны get_tile и таблицы деления из банка 3, перенос в
резидент вернёт тот же трамплин внутрь.  Остальное — работа, которую
персонаж действительно делает.

Зонды переставлены с уже закрытых замеров (loose_tick, физика) внутрь
pop_cdraw; оснастка снимается позицией P12, когда оптимизация закончится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:53:49 +03:00

33 KiB
Raw Blame History

Реестр оптимизаций: всё отложенное, в одном списке

Собрано 2026-08-19 из perf_green_phase.md (G1-G9), perf_cyan_phase.md (C1-C7), perf_backlog.md (позиции 1-7), ../roomtest/TASKS_OPEN.md (HEAL-WIDTH) и из свежего разбора сцены perf_l11_room15.md.

База для процентов — работа кадра в 11/15: 801 768 тактов (замер 09f32ce). Где эффект относится к другой сцене, это сказано явно.

Оценки помечены: [замер] — измерено; [модель] — посчитано по измеренным составляющим; [гипотеза] — не мерено, нужен прогон.


1. Цена одного блита фона — разложена [замер 2026-08-19]

Метод: брейкпоинты на резидентных адресах внутри pop_blit_b (0x5C50), temp0 на входе, разница totalcycles на каждом вызове. 1603 блита.

этап такты постоянство
пролог + аргументы + грубый отсев 810 ровно, всегда
atlas_image 672 ровно, всегда
gfx_w0_map + чтение шапки ленты + арифметика клипа 2 400 ровно, всегда
ядро блита (пиксели) 4 380 … 26 592 по размеру кадра
pop_cd_touch 2 069 (пакетный путь) / 4 115 (настоящая пометка) почти ровно
gfx_w0_unmap + эпилог 175 ровно, всегда
весь блит 10 458 … 32 718, медиана 16 674

Фиксированная накладная = 6 126 тактов на любой блит, хоть 8×8. У самого дешёвого блита (10 458) это 59 % цены; у пламени факела 16×18 пиксели тянут ~1 700 из ~14 000, то есть 12 %.

Это и есть ответ на вопрос «почему маленький блит стоит 14 000». Причины ровно те, о которых спрашивал пользователь:

  • 810 на прологcall ___sdcc_enter_ix, IX-фрейм и шесть чтений N(ix): третий и четвёртый аргументы (int x, int ybottom) идут СТЕКОМ, каждое обращение 19 тактов Z80;
  • 2 069 на pop_cd_touch даже по пакетному пути, где вся работа — четыре сравнения. Сигнатура (int x, int y, int w, int h) = 8 байт аргументов, два из них через стек; w/h никогда не больше 64, y не больше 255, то есть три из четырёх могли быть uint8_t;
  • 2 400 на map + шапкуgfx_w0_map (1 086) + unmap (264, платится в конце) + ~1 000 на чтение четырёх байт заголовка и арифметику;
  • 672 на atlas_image — маппинг W3, чтение записи каталога, возврат W3; размеры при этом читаются и выбрасываются (см. §3, C6).

Блитов за кадр в 11/15: 8 [замер] — все в зелёной фазе (6 на draw_tile чомпера, 2 на пламя факелов). Синяя и циан через pop_blit_b не ходят вовсе: heal и персонажи идут своими путями. Значит фиксированные накладные блита стоят сцене 8 × 6 126 = 49 000 тактов/кадр (6,1 % работы).


2. Модель зелёной фазы 11/15 [модель, сходится с 320 916 замера]

статья такты доля фазы
8 блитов фона (из них 6 126×8 = 49 000 накладных) 133 400 41 %
диспетчер draw_tile (один тайл чомпера) 56 200 18 %
цикл pop_process_trobs без блитов пламени 59 100 18 %
pop_loose_tick (плит в комнате НЕТ) 28 872 9 %
heal 32×64 в pop_chomp_redraw 34 000 11 %
шов / ворота соседа 9 336 3 %

Больше половины фазы — не пиксели, а обвязка вокруг них.


3. Список приёмов, отсортированный по эффекту

P1. Чомпер: перерисовка неизменной позы СДЕЛАНО 2026-08-19 — 110 802

Диагноз, с которым позиция заводилась, оказался неполным. Я приписал 190 260 тактов пометке от факела; зонд pop_dbg_kind показал, что все 312 перерисовок прогона — вид POP_RD_CHOMP (полная), а пометку соседа она просто перебивала. Настоящая причина нашлась сверкой с animate_chomper (seg007:0448): оригинал перерисовывает чомпер только при фазе < 6, пять кадров из пятнадцати, потому что с фазы 5 поза не меняется (chomper_fram1 = {3,2,0,1,4,3,3}). Мы метили тайл каждый кадр.

Сделано три вещи: условие фазы (с пометкой обеих страниц на фазе 5), новый вид POP_RD_CHOMP_ANIMpop_chomp_anim_draw (три блита поверх огня, порт ветки redraw_frames_anim) и приоритет полной перерисовки над anim в pop_set_redraw. Обе половины работают: замер даёт 40 % полных перерисовок и 60 % лёгких.

Работа 768 684 → 657 882 (медиана), зелёная 294 510 → 183 420. В 40 % кадров цена осталась прежней — там поза действительно меняется, и это уже честная работа; резать её дальше только через P7 (раскол draw_tile) или P8 (ширина heal).

Полный разбор — perf_l11_room15.md §6.

Исходная (неполная) постановка

Разбор в perf_l11_room15.md §3. Сейчас пометка от факела обрабатывается как heal 32×64 + полный draw_tile (190 260 тактов на единственный перерисованный тайл кадра); оригинал в этом случае рисует ТОЛЬКО draw_tile_anim — графику чомпера поверх свежего пламени.

Останется 1-2 блита челюстей ≈ 20 000-33 000. Риск низкий: это сближение с оригиналом, а не отход от него. Побочно снимает широкую пометку «фон трогали» вокруг тайла чомпера — см. P3.

P2. Синяя фаза разложена [ЗАМЕР 2026-08-19] — гипотеза не подтвердилась

Замер зондами внутрь обеих половин синей (286 518 тактов):

участок такты доля работы
ввод + читы 14 154 1,8 %
три спецсобытия уровней (skel / mouse / killed_shadow) 1 650 0,2 %
pop_frame_timers + луч видимости стража 37 032 4,8 %
pop_ctrl_tick 18 648 2,4 %
skip_mask + heal двух Char 67 734 8,8 %
mirror_heal + fore_heal 2 040 0,3 %
kid_tick (play_seq) 10 098 1,3 %
pop_phys_tick (физика Кида) 61 266 8,0 %
pop_guard_tick (логика стража) 19 932 2,6 %
pop_guard_phys_tick (физика стража) 43 980 5,7 %
боёвка (sword_hurting / sword_hurt / delta_hp) 9 558 1,2 %
guard_fallout + уход из комнаты 366

Что оказалось не так, как ждали. Я предполагал, что дорогие тут банковые трамплины на спецсобытиях (по аналогии с pop_clip_char_top, 8 892 такта за трамплин ради одной проверки). Замер это отверг: три спецсобытия уровней вместе стоят 1 650 — они гейтятся внутри и на уровне 11 честно выходят сразу.

Настоящие статьи — три:

  1. Физика двух Char — 105 246 (61 266 + 43 980), при том что оба персонажа СТОЯТ и кадр позы не меняется. Это 13,7 % работы кадра и самая крупная статья синей. Нужен ещё один уровень разбора — внутрь pop_phys_tick (позиция P2a, отдельным заходом).
  2. Луч видимости стража — до 37 032 (вместе с pop_frame_timers, но тот заведомо копеечный: три счётчика). Считается КАЖДЫЙ кадр, хотя ни Кид, ни страж не сдвинулись. Кандидат на гейт «пересчитывать только при смене позиции или комнаты любого из двоих» — позиция P2b, ожидание −30 000, риск низкий.
  3. heal двух Char — 67 734. Отдельной правки не требует: он платится ровно потому, что skip_mask никого не пропускает, и уйдёт вместе с P1/P3.

Новое, найдено 2026-08-19.

P3. Персонажи будятся каждый кадр [модель: −70 000, следствие P1]

heal 141 048 + отрисовка 148 566 = 290 000 тактов (36 % работы) уходят на то, что pop_char_skip_mask не может пропустить ни Кида, ни стража: фон трогают каждый кадр.

  • Кида спасает P1: широкая пометка вокруг чомпера исчезнет;
  • стража спасти нельзя — пламя правого факела (0,7) рисуется в ячейке (0,8), где он и стоит. Там фон честно меняется, и оригинал персонажа тоже перерисовывает.

P4. Накладные блита — четыре независимые правки [модель: −28 000 в 11/15; больше в сценах с 20+ блитами]

правка на блит источник
pop_cd_touch: uint8_t вместо int для y/w/h, ранний выход пакетного пути ~1 300 новое
один gfx_w0_map/unmap на ГРУППУ блитов ~1 350 C5 / backlog §3
размеры ленты из каталога, без atlas_image и чтения шапки ~670 C6 / backlog §2
pop_blit_b: аргументы в 8 бит, где хватает ~400 новое

Все четыре — низкий риск, механическая работа. Вместе снимают ~3 700 из 6 126 фиксированных.

P5. pop_loose_tick при пустой комнате — 28 872 → 2 760 СДЕЛАНО 2026-08-19

Получено −26 112 внутри функции, −33 840 на кадре (замер до/после в 11/15). Оценка была 28 000.

Раскладка холостого хода (замер зондами m9..m12) и что с ней стало:

участок было стало
два цикла по тайлам (30 + 10 позиций) 9 852 132
pop_loose_mob_tick (обход 14 слотов) 12 090 996
check_loose_fall_on_kid (трамплин + обход) 5 868 546
вход + хвост 1 062 1 086
итого 28 872 2 760

Сделано двумя гейтами:

  • loose_any (статик pop_map.c) — «идёт ли анимация плит». Ставится в пяти местах записи ненулевой фазы, снимается САМИМ циклом по факту прохода, где не осталось ни одной живой фазы;
  • pop_mob_busy (резидент pop_state.c) — «занят ли хоть один слот падающего куска» (active или дочистка clean). Ставит mob_alloc, снимает обход по факту пустой таблицы. В резиденте, а не в pop_room.c, потому что читает его pop_map из банка 3.

Важно про границу: гейт отвечает не на «есть ли в комнате плиты», а на «идёт ли анимация». У лежащей плиты-потолка фаза 0, и крутить нечего — вопрос пользователя 2026-08-19. Асимметрия намеренная: ложная единица стоит одного холостого прохода, ложный ноль — застывшей навсегда плиты, поэтому взвод стоит рядом с КАЖДОЙ записью, а снятие только по факту.

Покрытие: phys_loose_floor_breaks (взвод от шага и сотрясения) и новый phys_loose_gate_survives_room_change — на пятое место взвода (фаза восстановлена входом в комнату), которое не покрывал никто. Мутационная проверка: со снятым взводом тест падает.

P6. pop_process_trobs разложен [ЗАМЕР 2026-08-19] — 89 784

участок такты
вход + префетч кодов тайлов (маппинг окна 0) 11 058
цикл: два pop_torch_draw ~36 000
цикл: обход самих trob'ов ~43 000

Цена одного pop_pot_b (пламя факела) измерена отдельно, брейкпоинтами на резидентных адресах: 17 346 тактов, и это ЕДИНСТВЕННАЯ группа в распределении — то есть pop_pot_b в кадре зовут только два факела. При канвасе пламени 16×18 сами пиксели там 1 716, то есть 10 % цены; всё остальное — накладные (см. §1) плюс ~6 900 сверх pop_blit_b на самом pop_pot_b.

Направления:

  • P6a: pop_trob_modif(room) зовётся банковым вызовом на КАЖДЫЙ trob внутри цикла, хотя комната у них одна и та же — вынести наружу;
  • P6b: префетч 11 058 маппит окно 0 каждый кадр, а коды тайлов trob'ов меняются редко — кэшировать с инвалидацией по смене тайла/комнаты;
  • P6c: цена факела — это цена блита, то есть позиция P4.

Новое, найдено 2026-08-19.

P7. G5. Раскол draw_tile на узкие части [оценка дока: −50 000 … 60 000]

Диспетчер + контекст оплачиваются целиком всегда; у оригинала это девять независимых функций. В 11/15 это те самые 56 200 на один тайл — но если сделан P1, draw_tile чомпера вообще не вызывается, и здесь эффект пропадёт. Ценность приёма — в ДРУГИХ сценах (13/23, любая комната с плитами).

Риск средний: в draw_tile собрано много инвариантов (BUG-LOOSE-3, BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1) — только отдельным заходом с прогоном всех уровней.

P8. HEAL-WIDTH — ширина heal'ов по фактическому следу [оценка: 5-6 % цены heal'ов]

ОБЯЗАТЕЛЬНАЯ по решению пользователя (2026-08-18). След плиты 58 px в подземелье / 57 во дворце против используемых 60 и 64. Постановка — TASKS_OPEN.md, якорь heal-width.

P9. G8 — пометку СОСЕДА ставить узкой полосой (28 px), а не тайлом [гипотеза]

Парная к HEAL-WIDTH. Относится к сценам с падающими плитами (13/23), в 11/15 не играет. Разбор — perf_green_phase.md §G8, там же три условия, из-за которых это не «просто уменьшить число».

P10. Футпринт персонажа — из физики, а не считать заново [замер: −11 574 на fore-проход]

backlog §1. С двумя персонажами — ~23 000 за кадр. Мешает то, что физика (банк 3) держит char_col_left/right в статиках, а слой фона — банк 2. Риск средний: окно fore-клипа заводилось под клинок и брызги.

P11. Мелочи с известной ценой [замер, backlog §7]

что цена где
pop_clip_char_top — трамплин банк 4 → банк 3 ради одной проверки 8 892 pop_cdraw.c
cd_sig_make + возврат из pop_char_draw 7 944 pop_cdraw.c
pop_loadkid + расчёт координат кадра 7 410 pop_cdraw.c
obj_x * 8 / 7 — последнее __divsint в горячем пути ~2 400 pop_char_draw

P12. G9 — снять временную оснастку [замер: −6 000]

pop_dbg_b1..b6 в pop_blit_b (~400 на блит), pop_dbg_kind/m16, pop_dbg_m5..m15, счётчик rd_cnt в pop_redraw_needed. Только ПОСЛЕ окончания оптимизации — без них не мерить.

P13. Крупные рефакторинги — брать, только если понадобится ещё запас

  • C7 / backlog §5-6: objtable + отложенные таблицы back/mid/fore. У оригинала «посетить тайл» стоит копейки, потому что таблицы только копят записи, а рисует один draw_table() в конце. У нас блит идёт сразу из обхода, и fore-проход отдельный НА КАЖДОГО персонажа.
  • backlog §4: единый проход по тайлам вместо трёх (pop_redraw_needed, pop_process_trobs, pop_fore_over_char) и семь счётчиков причин перерисовки вместо одного kind.
  • G6: меньше блитов в RD_FLOOR, G7: mob_tick_one в file-scope.

4. ПЛАН РАБОТ — состояние между сессиями

Рабочий чеклист. Правило: одна позиция = один заход = один коммит с замером до/после на сцене 11/15. Замер обязателен даже когда «очевидно» — из шести сделанных позиций две дали не то, что ожидалось.

# что статус факт
P5 loose_tick: гейты холостого хода сделано 2026-08-19 −33 840 на кадре (ждали −28 000)
P2 замер синей фазы замерено 2026-08-19 гипотеза «трамплины» отвергнута
P6 замер process_trobs замерено 2026-08-19 11 058 префетч + ~43 000 цикл
P1 чомпер: перерисовка только при фазе < 6 сделано 2026-08-19 110 802 (ждали 160 000)
P2a coll_scan в 8 бит + снят с IX сделано 2026-08-19 −3 486 в коллизиях, −2 892 на кадре
P2b луч видимости: колонки + один банковый вызов сделано 2026-08-19 26 448 (ждали 30 000)
P4 накладные блита (4 правки) следующее ждём 28 000
P11 мелочи с известной ценой ждём 26 000
P10 футпринт персонажа из физики ждём 23 000
P6a/P6b trob_modif из цикла, кэш префетча ждём 20 000
P7 раскол draw_tile (G5) для 13/23, не для 11/15
P8/P9 HEAL-WIDTH + G8 обязательная для сцен с плитами
P12 снять оснастку ПОСЛЕДНЕЙ без неё не мерить

Достижим ли период 3 растра — арифметика после замеров

Работа сейчас 767 928, цель 430 000, снять надо 338 000. Что известно и посчитано:

источник ожидание
P1 (чомпер) 160 000сделано, 110 802
P3 (Кид перестанет будиться) 70 000 — под вопросом, см. ниже
P2b (гейт луча видимости) 30 000
P4 (накладные блита) 28 000
P11 (мелочи) 26 000
P10 (футпринт из физики) 23 000
P6a+P6b 20 000
сумма оставшегося 197 000

После P1 картина изменилась к худшему. Снять с дорогого кадра надо 336 000, а всё оставшееся в списке даёт 197 000 — и это при оптимистичных оценках. Вдобавок P3 (Кид перестанет будиться) теперь под вопросом: циан после P1 не сдвинулся ни на такт (187 761), то есть метки от чомпера и факелов продолжают будить обоих персонажей в каждом кадре.

Значит без P2a — разбора pop_phys_tick (105 246 тактов на физику двух НЕПОДВИЖНЫХ персонажей, цена измерена, причина не найдена) период 3 растра не берётся. Это и есть следующая крупная позиция.

Текущее состояние бюджета 11/15:

работа синяя зелёная циан период
до оптимизации 801 768 293 238 320 916 187 758 4 растра
после P5 767 928 285 864 294 384 187 764 4 растра
после P1 (медиана) 657 882 286 503 183 420 187 761 4 растра
после P1 (дорогой кадр, 40 %) 765 936 286 503 291 888 187 761 4 растра
после P2a (медиана) 654 990 283 215 183 798 187 812 4 растра
после P2b (медиана) 628 542 259 500 181 068 187 761 4 растра

Цель — уложить РАБОТУ в 430 000 (тогда период станет 3 растра вместо 4; хвост кадра — три gfx_wait_vsync). Осталось снять 228 000 с медианы и 336 000 с дорогого кадра. Считать надо по ДОРОГОМУ: период задаётся каждым кадром отдельно, и пока дорогие кадры не влезут, 40 % кадров будут идти по 4 растра.

Как воспроизвести сцену (важно для следующей сессии)

Сборка стартует прямо в ней: make (дефолты LEVEL=11 ROOM=15 POS=2) → make hddполный рестарт MAME (chdman -f даёт новый inode, memory mame_hdd_rebuild_restart) → в DSS набрать d: и roomtest. Кид встаёт в (0,2) лицом к чомперу, справа факел и страж — та самая сцена замеров. Штатный старт уровня возвращается через make ROOM=.

Проверка, что программа ЖИВА, обязательна перед любым чтением памяти: cur_room (0x97AA) должен лежать в 1..24 — на этом уже был сорван один замер (прочитаны два случайных байта остановленной машины).

Метод замера

Зонды — out (_io_border) в roomtest.c (база модуля 0x42AD) плюс резидентные пустышки pop_dbg_m* из pop_state.c. Адреса брать ЗАНОВО из .sprinter-cc-roomtest/roomtest.map после каждой пересборки. Скрипты сессии: perfrun.py <out> <сек> tag=addr ... и parseseq.py <файл> ПОСЛЕД.

Цену отдельной РЕЗИДЕНТНОЙ функции можно снять вообще без пересборки: bpset <вход>,1,{temp0=totalcycles; g} плюс bpset <точка>,1,{printf "… %d",totalcycles-temp0; g}. Так разложен блит в §1.


5. Сводка: что сколько даёт в 11/15

# приём эффект тип оценки риск
P1 чомпер: только anim-слой 160 000 модель низкий
P3 Кид перестанет будиться 70 000 модель следствие P1
P2 логика двух Char 40 000 … 70 000 гипотеза ?
P4 накладные блита (4 правки) 28 000 модель низкий
P5 loose_tick без плит 33 840 ФАКТ сделано
P6 цикл process_trobs 20 000 … 40 000 гипотеза ?
P10 футпринт из физики 23 000 замер средний
P11 мелочи (4 штуки) 26 000 замер низкий
P12 снять оснастку 6 000 замер нулевой
P7 раскол draw_tile 0 здесь (50 000 в 13/23) оценка средний
P8/P9 HEAL-WIDTH / G8 0 здесь (сцены с плитами) оценка низкий/средний

Верхняя часть списка (P1 + P3 + P4 + P5) — около 286 000 из 801 768, то есть 36 % работы кадра, и вся она низкого риска. Этого хватит, чтобы сцена ушла с 1,86 растрового кадра до ~1,2 — но НЕ хватит, чтобы период кадра упал с 4 растров до 3: для этого работа должна уложиться в 430 000, то есть нужны ещё ~90 000 сверху (P2 или P6).


5б. Цианная фаза разложена [ЗАМЕР 2026-08-19]

Фаза 188 004 тактов, и она НЕ менялась ни от P1, ни от P5, ни от P2b.

участок такты
check_mirror 3 198
loose_mob_draw + guard_over_kid + skip_mask 34 374
pop_char_draw(KID) 204
соперник: char_draw + char_fore 145 896
fore_needed + hp_draw 2 598
char_fore(KID) + борта 1 758

Кид уже пропускается — 204 такта, то есть надежда P3 всё-таки сбылась после P1: метка от чомпера до него больше не дотягивается. А страж перерисовывается каждый кадр, и это ЧЕСТНО: пламя правого факела (0,7) рисуется в ячейке (0,8), где он стоит, и реально накрывает ему голову (пламя занимает y 5..22, страж 12..62).

Отрисовка стража (148 302) по частям:

участок такты доля
pop_char_fore (два трамплина в банк 2 + обход тайлов) 62 778 42 %
клинок: pop_sword_draw + cd_overlay_add + cd_clip_add 27 522 19 %
блит спрайта + clip_char_right 20 982 14 %
загрузка кадра и геометрия 12 696 9 %
pop_clip_char_top (трамплин банк 4 → банк 3) 8 658 6 %
снимок прямоугольника + cd_clip_add 7 890 5 %
gfx_w0_unmap + cd_sig_make 4 968 3 %
вход + cd_heal 2 946 2 %

Вывод: крупного лишнего здесь нет. Единственная явно лишняя статья — трамплин clip_char_top (8 658), и убрать его непросто: функции нужны get_tile и таблицы деления из банка 3, а перенос в резидент вернёт тот же трамплин внутрь. Всё остальное — работа, которую персонаж действительно делает: рисует себя, клинок и передний слой поверх себя.

6. Иерархия референсов (уточнена 2026-08-19)

Сравнение трёх реализаций луча видимости показало, что источники не равноценны, и это важно для ЛЮБОЙ будущей оптимизации:

источник что берём чего НЕ берём
Apple II (Prince-of-Persia-Apple-II) как это делается на 8 битах: таблицы вместо делений, борьба за такты ничего — но код на 6502, читать сложнее
SDLPoP эталон ПОВЕДЕНИЯ (декомпиляция DOS-версии) реализацию: она нарочно «расслаблена» под 32 бита
mininim разбор краевых случаев, второе мнение о замысле алгоритмы — переписан с нуля, механика местами своя

Доказательство на конкретном месте: get_tile_div_mod в SDLPoP содержит комментарий

// DOS PoP does this:
//	obj_xl = tile_mod_tbl[xpos];
//	return tile_div_tbl[xpos];

а вместо этого делает x % TILE_SIZEX и x / TILE_SIZEX. Таблицы в файле лежат, но нужны только для эмуляции чтения DOS-версии ЗА ГРАНИЦЕЙ массива. Apple II (CTRLSUBS.S, GETBLOCKX) читает ровно BlockTable[x].

Правило: сверять поведение по SDLPoP, а реализацию под 8 бит — по Apple II и по комментариям вида «DOS PoP does this» в самом SDLPoP.

7. Повторяющийся источник цены: банковый трамплин в цикле

Уже трижды крупнейшей статьёй оказывался не алгоритм, а вызов __banked- функции ИЗ ЦИКЛА, идущего в другом банке:

место цена лечение
луч видимости: pop_tile_at по колонке (P2b) 36 786 → 13 002 один вызов на весь отрезок
pop_clip_char_top — банк 4 → банк 3 ради одной проверки 8 892 не сделано (P11)
pop_trob_modif(room) на каждый trob в цикле не мерено не сделано (P6a)

Что проверять в первую очередь при новом «дорогом» месте: не сколько там арифметики, а сколько раз за кадр пересекается граница банка.