Решили: ArenaLayoutData печёт только мир. Боевой скоуп свою копию не строит и не регистрирует — он резолвит готовую из предка, а границы поля и зону камеры отдаёт симуляции лямбдой от резолвера, а не значением.

Почему лямбдой: значение нужно в момент Configure, то есть его пришлось бы держать в боевом скоупе — и второй владелец layout вернулся бы через заднюю дверь. Лямбда резолвит его в момент создания симуляции, когда родительский контейнер уже отдаёт готовое.

Грабли, которых не видно из кода (обе проверены по исходнику VContainer 1.18 в Library/PackageCache, а не по памяти и не по сайту — сайт показывает только две простые перегрузки):

  • RegistrationBuilder.WithParameter(string name, Func<IObjectResolver, object> value) существует (и ещё четыре родственные формы). Без неё пришлось бы регистрировать CombatSimulation ручной фабрикой и перечислять все её зависимости — то есть терять свойство «добавил систему в ctor, в скоупе править нечего».
  • LifetimeScope.Parent заполняется до вызова Configure (строки 192 и 290). Значит дочерний скоуп имеет право спросить, есть ли над ним родитель, и по-разному собраться. Мы этим пользуемся: боевая сцена, поднятая в одиночку (dev-арена без мира), регистрирует бесконечное поле явно и говорит об этом вслух — это другой состав сцены, а не фолбэк на отказ.

Чем дефект был опасен: до правки боевой скоуп искал авторинг сам и регистрировал вторую ArenaLayoutData — при том что комментарий в WorldLifetimeScope утверждал обратное. Никто не замечал, потому что VContainer отдаёт ближайшую регистрацию, а значения совпадали: оба скоупа находили один и тот же объект в загруженных сценах. Разошлись бы они в первый же день, когда арен станет больше одной.

Владелец правды: Game/WorldLifetimeScope.cs (единственный, кто печёт арену), тест ArenaHasOneOwnerTests (ровно один файл ищет авторинг, и это мир).