Решили: долю тика для удара берём остатком того же целочисленного деления, которым считается
замах: (hitFrame * durationTicks) % frameCount / frameCount. До записи в ленту она едет полем-окном
на носителе (RuntimeUnit.ContactSubTick), которое AutoAttackSystem.Land ставит и снимает вокруг
нанесения удара.
Почему остатком, а не вторым расчётом: точный момент контакта можно было получить тем же выражением во float, но тогда у формулы тайминга появился бы второй владелец, способный разойтись с первым на границах округления — а разойдётся он молча, потому что урон при этом остаётся правильным. Остаток даёт ту же дробь и физически не может дать другую целую часть.
Почему поле-окно, а не параметр события: у OnDamageDealt полтора десятка подписчиков (рекордер,
метрики баланса, трасса боя, десяток тестов) и одна сигнатура на все источники урона — авто-атака,
периодика, реактив, способность. Менять её ради подачи дорого и шумно; поле, живущее весь свинг,
отдало бы дробь от меча тику горения того же носителя. Окно вокруг Land — единственный вариант,
который не врёт и не задевает шов.
Доля обнуляется там, где точности нет (кламп замаха, множитель разбега или комбо, кит без покадрового клипа, тик канала, второй и последующие контакты серии). Это не осторожность: у скелетного рига момент задан долей свинга и округляется к БЛИЖАЙШЕМУ тику, поэтому истинный момент бывает раньше тика модели — дробью [0..1) это невыразимо.
Грабли: первая проверка была unit.Phase == AttackPhase.Windup, и она обнуляла долю ровно у того,
для кого всё делалось — у обычного одноударного кита. Resolve переводит юнита в хвост (или в канал)
до вызова TryQueueHit, поэтому «ещё в замахе» на этом месте ложно. Верное условие — «фаза не
Channel».
Вторая: урон наносится не там, где снимаются цифры. TryQueueHit только кладёт заявку в _hits, а
прилёт идёт позже одним проходом (двухфазность тика), поэтому доля обязана ехать в заявке
(ResolvedHit.SubTick) — по той же причине, по которой в ней уже едут тип урона и заряд усиления.
Владелец правды: Combat/Systems/AttackTiming.ContactSubTick (обе перегрузки — арифметика отделена
от чтения данных юнита именно чтобы её мог проверить тест без ассетов),
Combat/Systems/AutoAttackSystem.Land, Combat/Units/RuntimeUnit.ContactSubTick; инварианты — тесты
TapeSubTickTests.ContactSubTick_*.