Решили: тайминг удара объявляется в данных (AnimationArchetypeData.WindupShare и ContactShares), покадровый путь удалён целиком, а инвариант «симуляция не читает клип» закрыт текстовым гейтом SimulationReadsNoClipTests. Округление доли — вниз.

Почему: аудит 06.08 показал, что сим считал замах как hitFrame × duration / frameCount, то есть разметка анимации задавала боевой тайминг — вплоть до решения «идти или бить» (CombatPositioning.CanLandWindup). Правка маркера на два кадра двигала баланс всему ростеру, и узнать об этом было неоткуда. Отвергнуто: оставить как есть (клип теперь ОДИН на 47 юнитов, цена ошибки выросла на два порядка) и кэшировать чтение клипа (владельцем числа всё равно остаётся ассет из другого окна).

Грабли:

  • Смена первого параметра с int на float СКРЫВАЕТ старые вызовы. FollowThroughTicks(hitFrame, frameCount, interval, windup) после смены сигнатуры на (share, interval, windup, maxAnim) — всё ещё компилируется: int неявно поднимается до float, аргументы съезжают на позицию влево, и функция считает совсем другое. Компилятор молчит, тест падает В ДРУГОМ МЕСТЕ с невнятным «предусловие: у юнита есть хвост». Урок: удалять старую перегрузку надо ТЕМ ЖЕ шагом, что менять сигнатуру — иначе она не мёртвый код, а ловушка.
  • Округление — условие переезда, а не вкусовщина. Покадровый путь делил целочисленно (floor). Оставь у доли округление к ближайшему — и момент удара сдвинется на тик у 40% длительностей свинга. Замер: floor(share × duration) совпадает с (hit × duration) / frameCount на всех значениях, round — расходится на 24 из 60. Проверено перебором до правки, а не после.
  • Цена всё равно есть, и её выбрал Макс. Любая унификация двигает кого-то: floor переносит 37 замеренных юнитов бит в бит, но сдвигает 10, кому долю задавали вручную. Решение — «время замаха должно быть у всех одинаковым», то есть персональные доли сняты сознательно.
  • Побочная выгода: ContactSubTick стал честным. Пока путь доли округлял к ближайшему, истинный момент мог оказаться раньше тика модели, и дробь [0..1) его не выражала — метод отдавал ноль. С округлением вниз остаток есть всегда, и подача получает настоящую долю тика.
  • Тестовая фабрика обязана делать то же, что редакторный замер. TestVisual.Make строила клип с маркерами, но не объявляла долю — то есть собирала юнита, невозможного в проде: с клипом, но с телеграф-полом в три тика вместо настоящего замаха. 14 тестов падали именно на этом.
  • Гейт инварианта — текстовый, и это осознанно. Возврат зависимости выглядит как строка visual.AttackHitFrame: она компилируется, работает и молчит. Проверять надо исходник, как это уже делает FeelToggleCoverageTests для тумблеров.

Владелец правды: AttackTiming.WindupTicksFromShare (формула и округление), AnimationArchetypeData.WindupShare/ContactShares (числа), SimulationReadsNoClipTests (инвариант), ГД-журнал 2026-08-06/6 (решение Макса).