Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.RacingAiVehicles.cs
T

501 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// RACE AI OPPONENT VEHICLES (R190, ported from the Lyco fork's GameRouter.RacingAiVehicles.cs,
/// adapted to v2.1's own vehicle/Aether helpers) — the entity half of "AI 车 + 小地图蓝点 + 真名次".
///
/// 🔑 WHY REAL ENTITIES (§59.28): the zone packet carries <c>RacingZoneInfo.AIVehicleInfos</c> =
/// a plain <c>Dictionary&lt;ulong,uint&gt;</c> of <b>scene vehicle entity id → racing AI cfg id</b>.
/// Every client consumer resolves that key against a REAL scene entity:
/// <code>
/// CarRaceManager.lua AddEntity(id) -> LX6.Drive.DriveUtils.GetBaseVehicle(id) (miss = no entity)
/// MapSubSystem_CarRace.lua -> element:BindVehicle(id) (the minimap blue dot)
/// C# CarRaceManager.RaceSpeedManager -> rank / lap map keyed by that same id
/// </code>
/// Inventing ids (the legacy 0x6000… grid) therefore can never materialise. So each opponent is
/// spawned as a real BaseVehicle + Aether registration + NPC driver + ForceGo, and the (entityId,
/// aiCfgId, vehicleCfgId) triples are fed into AIVehicleInfos/ParticipantInfos verbatim.
///
/// v2.1 ADAPTATION NOTES (vs the Lyco original):
/// • Spawn = the SAME three-packet sequence as v2.1's own SpawnDirectAsync (SyncLogicVehicleEnter
/// + SyncSpawnVehicle + SyncChangeVehicleInteractable), but with a GIVEN entity id, no
/// summon-registry/replacement side effects and a free CreateSourceType (4 = Online; 2 = Summon
/// would file the car as the PLAYER'S own — exactly what registerSummoned:false exists to avoid).
/// • Aether entity registration reuses v2.1's PROVEN wire shape (bare ClientVehicleInitData4229938
/// with LaneHandle = -1 / ControlType = 2, the shape RegisterHackVehicleAsync has been shipping
/// since the 载具骇入 work) instead of the Lyco wrapper.
/// • Aether init = SendDonorAetherInitAsync (v2.1's one proven SyncAetherAIInitDatas channel).
/// • Player anchor = GetStateIfExists + LastReportedPlayerPosition / WorldEntryCreateHeroPosition.
/// • The driver formwork pool: env override → known-good fallback (v2.1 has no Aether
/// VehicleNpcFormworkIds config section).
/// • Best-effort everywhere: empty world state / missing track row / empty roster ⇒ empty list,
/// and RunRacingAutoStartAsync then sends an HONEST EMPTY grid (never invented ids again).
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// Gate for spawning real AI opponent vehicles before the race zone is pushed.
///
/// ⚠ R203c(2026-09-30)——**两次定案,第二次推翻第一次。当前 = 未定案**。
///
/// 第一次定案(❌ 已推翻):据 `noaigrid` 消融(照常 spawn、网格推空,`console-20260930-153727.log`)
/// **仍然崩**(死在 GAMESTART 当帧)⇒ 曾判「wire 层无关、AI 实体 spawn = 真凶」。
///
/// ★ 第二次(15:54 实测,`console-20260930-155057.log`):把本门关掉(`spawnGate=False`、
/// `AI grid ready real=0 requested=5`)+ 客户端 guard **打开** ⇒ **照样崩**(GAMESTART 后 1 s
/// `IOException`,`PSO Collect`=0)⇒ **关掉 AI spawn 不能治崩 ⇒ 「AI spawn 是真凶」不成立**。
///
/// **四会话矩阵**(唯一有效方法 = 差分,★ 必须「除目标变量外全体一致」):
/// 08:11 spawn-OFF + guard **未注入** = ✅ 跑完整局;
/// 14:54 spawn-ON + guard-OFF = 崩;15:37 spawn-ON + guard-OFF(空网格) = 崩;
/// 15:54 spawn-OFF + guard-**ON** = 崩。
/// ⇒ **唯一成功 = 「spawn OFF 且 guard 未注入」**;真凶收敛到 **AI spawn 副作用** 或
/// **客户端 `racing_guard.lua` 补丁**(或二者合力)。★ `racing_guard.lua` mtime = 09:36:02
/// (**在 08:11 成功之后被改**)⇒ 头号嫌疑。
/// wire 层仍被排除:08:11 与 15:54 的 `SyncGameGroundZoneInfo` DISPLAY 包均 71 B **逐字节相同**。
///
/// ⇒ **默认关闭**(R203c 方向 1「先让玩法可用」)。单人计时挑战可正常游玩。
/// ⛔ 待办:**「spawn OFF + guard 不注入」单变量实测**(= 08:11 已知成功配置,见
/// `config/racing-ablation.txt` = 单行 `noguard`):不崩 ⇒ guard 有罪;仍崩 ⇒ 还有第四变量。
///
/// 临时打开(免重编):设环境变量 `DRMK_RACE_AI_SPAWN=1`,或把下面的常量改回 true 重编。
/// </summary>
internal static readonly bool RacingAiSpawnEnabled = ResolveRacingAiSpawnEnabled();
private static bool ResolveRacingAiSpawnEnabled()
{
var env = Environment.GetEnvironmentVariable("DRMK_RACE_AI_SPAWN");
if (!string.IsNullOrWhiteSpace(env))
{
var v = env.Trim().ToLowerInvariant();
if (v is "1" or "true" or "on" or "yes") return true;
if (v is "0" or "false" or "off" or "no") return false;
}
// ⚠ R203c:默认 **关**(「AI spawn = 崩」已被 15:54 实测推翻;关着只是为了先保玩法可用,
// 别再引用这条当定案 —— 见上方四会话矩阵与待办)。
return false;
}
/// <summary>
/// Gate for the AETHER DRIVER half — BaseVehicle alone renders but has no Aether entity,
/// therefore no NPC driver and no vehicle AI (a statue). The four steps in
/// <see cref="AttachRaceAetherDriversAsync"/> (init → add-data → npc → force-go) turn it on.
/// Set false to instantly degrade back to statues.
/// </summary>
internal const bool RacingAiAetherDriverEnabled = true;
/// <summary>
/// <c>VehicleCreateSourceType</c> for the opponent cars. 4 = Online is the semantic default:
/// the opponents only exist inside a race competition. Summon(2) is deliberately excluded —
/// it arms the ownership handshake and would file the opponents as the player's OWN cars.
/// Lyco probe conclusion (2026-09-23): the client accepts every candidate, 4 is just the
/// honest one.
/// </summary>
internal const byte RacingAiCreateSourceType = 4; // Online
/// <summary>Entity-id range for race opponents: 770_000_000_000, deliberately outside the
/// summon segment (300_000_000_000) and the traffic segment (750_000_000_000). The generation
/// step exists because the client caches uid → LOD state — re-used uids make it ignore new
/// init data — so every race's opponents look brand-new.</summary>
private const long RacingAiVehicleIdBase = 770_000_000_000L;
private const int RacingAiVehicleGenerationSpan = 1_000;
private const int RacingAiVehicleGenerationMod = 100;
private static int _racingAiVehicleGeneration;
/// <summary>NPC-driver id segment: 770_500_000_000 + generation*1000 + slot — same
/// "bump per race" trick as the cars, and outside every other id family we use.</summary>
private const long RacingAiDriverIdBase = 770_500_000_000L;
private static ulong RacingAiDriverId(int generation, int slot)
=> (ulong)(RacingAiDriverIdBase + (long)generation * RacingAiVehicleGenerationSpan + slot);
/// <summary>Fallback driver NPC formwork (client <c>VehicleSpawnConfig.NewNpcType</c> family).</summary>
private const uint RacingAiDriverFormworkFallback = 45200091;
/// <summary>Driver NPC template ids — DRMK_RACE_DRIVER_FORMWORK=45200091,45200092 overrides;
/// otherwise the known-good fallback.</summary>
private static readonly uint[] RacingAiDriverFormworks = ResolveRacingAiDriverFormworks();
private static uint[] ResolveRacingAiDriverFormworks()
{
var env = Environment.GetEnvironmentVariable("DRMK_RACE_DRIVER_FORMWORK");
if (!string.IsNullOrWhiteSpace(env))
{
var parsed = env
.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries)
.Select(s => uint.TryParse(s.Trim(), out var v) ? v : 0u)
.Where(v => v != 0)
.Distinct()
.ToArray();
if (parsed.Length > 0) return parsed;
}
return [RacingAiDriverFormworkFallback];
}
/// <summary>One spawned opponent: the scene entity, its racing-AI cfg id (the AIVehicleInfos
/// value), the vehicle config it was built from, the VehicleCreateSourceType it was created
/// with and the world transform it was placed at (re-used verbatim by the Aether packets).</summary>
internal sealed record RacingAiVehicle(
ulong EntityId, uint AiCfgId, uint VehicleCfgId, byte SourceType, Vec3 Position, float Facing);
/// <summary>
/// Spawn the opponent cars of one race. Returns one entry per successfully created entity;
/// callers must treat an empty list as "no AI this race" and send an empty grid.
/// </summary>
internal static async Task<List<RacingAiVehicle>> SpawnRacingAiVehiclesAsync(
TcpSession session, uint trackCfgId, int count,
CancellationToken token = default)
{
var spawned = new List<RacingAiVehicle>();
if (!RacingAiSpawnEnabled || count <= 0)
return spawned;
var state = GetStateIfExists(session);
if (state is null)
{
session.Log.Warn("[RACE] AI spawn skipped: no WorldEntryState (client not in world yet)");
return spawned;
}
if (!ClientConfigRepository.RacingTracks().TryGetValue(trackCfgId, out var track))
{
session.Log.Warn($"[RACE] AI spawn skipped: no RacingDriverTrackInformationConfig row for track={trackCfgId}");
return spawned;
}
var roster = track.HighAI
.Concat(track.MiddleAI)
.Concat(track.LowAI)
.Where(id => id != 0)
.Distinct()
.ToArray();
if (roster.Length == 0)
{
session.Log.Warn($"[RACE] AI spawn skipped: track={trackCfgId} has an empty HighAI/MiddleAI/LowAI roster");
return spawned;
}
var aiRows = ClientConfigRepository.RacingAiRows();
// 🔴 Keep only opponents the CLIENT can actually build. v2.1 has no VehicleSpawnConfig
// loader, so the check is soft (VehicleConfig dump presence + a model): a miss logs a
// warning but does NOT drop the entry (Lyco measured the client accepting all
// candidates; a missing model only degrades to invisible-or-dropped for that one car).
var known = roster.Where(id =>
aiRows.TryGetValue(id, out var r) && r.VehicleId != 0
&& VehicleCatalog4229938.TryGet(r.VehicleId, out var vr) && !string.IsNullOrWhiteSpace(vr.Model))
.ToArray();
if (known.Length > 0 && known.Length != roster.Length)
session.Log.Info($"[RACE] AI roster: {known.Length}/{roster.Length} entries known to the vehicle dump "
+ "(others are spawned unfiltered — the client drops unknown ones itself)");
// Anchor = the player's last reported transform (that is where the grid is staged).
var anchorPos = state.HasLastReportedPlayerTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
var facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : 0f;
var yaw = facing * (MathF.PI / 180f);
var fwdX = MathF.Sin(yaw);
var fwdZ = MathF.Cos(yaw);
var generation = Interlocked.Increment(ref _racingAiVehicleGeneration)
% RacingAiVehicleGenerationMod;
for (var i = 0; i < count; i++)
{
var aiCfgId = roster[i % roster.Length];
// The vehicle the CLIENT will expect this AI id to be driving.
var vehicleCfgId = aiRows.TryGetValue(aiCfgId, out var aiRow) ? aiRow.VehicleId : 0u;
if (vehicleCfgId == 0)
{
session.Log.Warn($"[RACE] AI spawn: no vehicle config for aiCfgId={aiCfgId} (RacingDriverRacingAIConfig miss)");
continue;
}
var entityId = (ulong)(RacingAiVehicleIdBase
+ (long)generation * RacingAiVehicleGenerationSpan
+ i);
var sourceType = RacingAiCreateSourceType;
// Grid layout (Lyco retune, 2026-09-23): two columns, rows marching AWAY from the
// player, generous pitch — the authored racers are TRUCKS, tight offsets visually
// merge into one lump. Slot 0/1 form the front row ~8 m ahead so the opponents stay
// on screen during the countdown.
var row = i / 2;
var side = i % 2 == 0 ? -1f : 1f;
var lateral = side * (4.0f + row * 0.5f); // ±4.0 m, widening slightly per row
var along = 8f + row * 9f; // 8 m, 17 m, 26 m ... ahead
// A real grid is never perfectly parallel; a small per-slot yaw removes the
// "every car is a clone of mine" look. Kept to ±3° so it cannot matter for physics.
var slotFacing = facing + ((i % 3) - 1) * 3f;
var nearPos = new Vec3(
anchorPos.X + fwdX * along,
anchorPos.Y,
anchorPos.Z + fwdZ * along);
var spawnOk = await SpawnRaceAiVehicleAsync(
session, vehicleCfgId, nearPos, slotFacing, lateral, sourceType, entityId, $"racing-ai#{i}", token);
if (!spawnOk)
{
session.Log.Warn($"[RACE] AI spawn failed slot={i} ai={aiCfgId} veh={vehicleCfgId} src={sourceType}");
continue;
}
spawned.Add(new RacingAiVehicle(entityId, aiCfgId, vehicleCfgId, sourceType, nearPos, slotFacing));
}
session.Log.Info($"[RACE] AI vehicles spawned={spawned.Count}/{count} track={trackCfgId} gen={generation}"
+ " entries=" + string.Join(",", spawned.Select(v => $"id{v.EntityId}=ai{v.AiCfgId}/veh{v.VehicleCfgId}/src{v.SourceType}"))
+ $" anchor=({anchorPos.X:F1},{anchorPos.Y:F1},{anchorPos.Z:F1}) facing={facing:F1}"
+ " expected=csharp.entityFound+=spawned csharp.entityMissing=0");
// AETHER DRIVER HALF — "车上没有 NPC" + "车也不会动". Additive and gated: a failure here
// leaves the (already working) BaseVehicle spawn untouched, i.e. statue behaviour.
if (RacingAiAetherDriverEnabled && spawned.Count > 0)
await AttachRaceAetherDriversAsync(session, spawned, generation, token);
return spawned;
}
/// <summary>
/// Direct scene spawn for ONE AI opponent: the same three-packet sequence as
/// <see cref="SpawnDirectAsync"/> (SyncLogicVehicleEnter + SyncSpawnVehicle +
/// SyncChangeVehicleInteractable) with three differences that matter:
/// 1. the entity id is GIVEN (770_000_000_000 segment) — the zone grid must name exactly
/// these ids;
/// 2. NO summon-registry side effects — <c>SummonedEntity</c>/<c>CurrentVehicleId</c> are
/// never touched (the record still lands in <c>Vehicles</c> so the teardown channel works);
/// 3. CreateSourceType is free (4 = Online) instead of the hardcoded Summon(2).
/// </summary>
private static async Task<bool> SpawnRaceAiVehicleAsync(
TcpSession session, uint vehicleId, Vec3 exactPos, float facing, float lateral,
byte sourceType, ulong entityId, string reason, CancellationToken token)
{
if (vehicleId == 0)
return false;
var seatCount = 2;
if (VehicleCatalog4229938.TryGet(vehicleId, out var config))
{
if (config.SeatCount is >= 1 and <= 16)
seatCount = config.SeatCount;
if (string.IsNullOrWhiteSpace(config.Model))
session.Log.Warn($"[RACE] AI vehicle config {vehicleId} has no prefab model in dump, trying anyway");
}
else
{
session.Log.Warn($"[RACE] AI vehicle config {vehicleId} not in VehicleConfig dump, trying with {seatCount} seats");
}
var spawn = new Vec3(exactPos.X, exactPos.Y + 0.15f, exactPos.Z);
var runtime = GetVehicleRuntimeState(session);
var record = new VehicleRecord
{
EntityId = entityId, ConfigId = vehicleId, SeatCount = seatCount,
Position = spawn, Facing = facing, ControllerPid = 0, Interactable = true, TaskToken = 0,
};
lock (runtime.SyncRoot)
{
runtime.Vehicles[entityId] = record; // teardown channel only — no SummonedEntity write
}
var euler = new SceneMethods.UxVector3(0f, facing, 0f);
var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z);
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new GameMethods.LogicVehicleClientInfo4229938
{
EntityId = entityId, VehicleConfigId = vehicleId, CreateSourceType = sourceType,
Parts = [], SuitId = 0, LicensePlate = string.Empty, Interactable = true, MoveToken = 0,
Position = pos, EulerAngles = euler, VehicleSpoonName = null,
}), token);
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new GameMethods.VehicleClientInfo4229938
{
ControllerPid = 0, CreateSourceType = sourceType, EntityId = entityId, VehicleConfigId = vehicleId,
Parts = [], SuitId = 0, Position = pos, Facing = facing, EulerAngles = euler, Velocity = 0,
IsStatic = false, DeformStatus = 0,
SeatInfos = Enumerable.Range(0, seatCount).Select(i => new GameMethods.RaidVehicleSeatInfo4229938
{ EntityId = 0, SeatIndex = (byte)i, SeatState = 0, DestroyRelated = false }).ToList(),
SpoonId = 0, IsDynamicGo = false, VehicleEnemyId = 0, DisableNavigation = false,
Interactable = true, MoveToken = 0, LicensePlate = string.Empty,
}), token);
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
new GameMethods.SyncChangeVehicleInteractable4229938 { vehicleInstanceId = entityId, interactable = true }),
token);
session.Log.Info($"[RACE] AI DIRECT_SPAWN reason={reason} config={vehicleId} entity={entityId} src={sourceType} "
+ $"lateral={lateral:F1} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1}) facing={facing:F1}");
return true;
}
/// <summary>
/// The AETHER half of the opponent grid: registers each car with the client's vehicle-AI
/// subsystem, binds an NPC driver to it and switches its AI on. Every step is best-effort and
/// individually guarded: a wiring mistake can only degrade to the statue behaviour.
/// </summary>
private static async Task AttachRaceAetherDriversAsync(
TcpSession session, IReadOnlyList<RacingAiVehicle> cars, int generation,
CancellationToken token)
{
var okInit = 0;
var okAdd = 0;
var okNpc = 0;
var okGo = 0;
// (1) Turn the client's vehicle-AI subsystem on. Reuses v2.1's proven
// SyncAetherAIInitDatas channel (variant list + real ZoneStorageDataHandle from config).
try
{
await SendDonorAetherInitAsync(session);
okInit = 1;
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] Aether init for race drivers failed: {ex.Message}");
}
for (var i = 0; i < cars.Count; i++)
{
var car = cars[i];
var pos = new SceneMethods.UxVector3(car.Position.X, car.Position.Y, car.Position.Z);
var euler = new SceneMethods.UxVector3(0f, car.Facing, 0f);
try
{
// (2) Aether entity — SAME id as the BaseVehicle we just built. v2.1's proven wire
// shape (RegisterHackVehicleAsync): BARE data, LaneHandle = -1 (keep the
// explicit world transform), ControlType = 2 = RaidVehicle (register with
// Aether without activating lane simulation).
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData,
UxSerializer.Serialize(new GameMethods.ClientVehicleInitData4229938
{
VehicleConfigId = car.VehicleCfgId,
Id = car.EntityId,
Position = pos,
Facing = car.Facing,
EulerAngles = euler,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0,
LaneHandle = -1,
ControlType = 2,
}), token);
okAdd++;
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] Aether add-data failed id={car.EntityId}: {ex.Message}");
}
var driverId = RacingAiDriverId(generation, i);
var formwork = RacingAiDriverFormworks[i % RacingAiDriverFormworks.Length];
try
{
// (3) The driver NPC, sitting in seat 0 of THIS car.
await session.NotifyAsync(MethodId.SyncAetherAIVehicleNpcAdd,
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleNpcAdd4229938
{
initData = new GameMethods.ClientVehicleNpcInitData4229938
{
Id = driverId,
NpcFormworkId = formwork,
BindVehicleId = car.EntityId,
SeatIndex = 0,
},
}), token);
okNpc++;
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] Aether driver add failed id={car.EntityId}: {ex.Message}");
}
try
{
// (4) Simulation owner for the driver. Without it the client's ApplyMasterPolicy(false)
// shuts the model state machine down and the driver never renders.
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
UxSerializer.Serialize(new SceneMethods.SyncManagedLogicAgent
{
agentId = driverId,
pid = Profile.PlayerPid,
moveId = 0,
}), token);
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] ManagedLogicAgent for driver failed id={driverId}: {ex.Message}");
}
try
{
// (5) Switch the vehicle AI ON. From here the CLIENT's own VehicleAINext drives the
// car along the ZoneGraph lanes (the server stops pushing coordinates). Both
// the generic and the Aether-specific twin are sent — the generic entry is not
// necessarily the one the Aether vehicle manager listens to (Lyco conclusion).
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleForceGo,
UxSerializer.Serialize(new GameMethods.SyncVehicleForceGo4229938
{
vehicleEntityId = car.EntityId,
isForceGo = true,
}), token);
await session.NotifyAsync(MethodId.SyncAetherAIVehicleForceGo,
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleForceGoArgs4229938
{
instanceId = car.EntityId,
forceGo = true,
}), token);
okGo++;
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] ForceGo failed id={car.EntityId}: {ex.Message}");
}
}
session.Log.Info($"[RACE] AI aether-drivers init={okInit} addData={okAdd}/{cars.Count}"
+ $" npc={okNpc}/{cars.Count} forceGo={okGo}/{cars.Count}"
+ $" formworks=[{string.Join(",", RacingAiDriverFormworks)}] gen={generation}"
+ " expected=AI cars get a driver and start driving along the ZoneGraph lanes");
}
/// <summary>Remove this race's opponents (teardown). Best-effort, never throws.</summary>
internal static async Task DestroyRacingAiVehiclesAsync(
TcpSession session, IReadOnlyList<RacingAiVehicle> vehicles, string reason)
{
if (vehicles.Count == 0)
return;
var runtime = GetVehicleRuntimeState(session);
foreach (var v in vehicles)
{
try
{
await DestroyVehicleAsync(session, runtime, v.EntityId, reason);
}
catch (Exception ex)
{
session.Log.Warn($"[RACE] AI destroy failed id={v.EntityId}: {ex.Message}");
}
}
session.Log.Info($"[RACE] AI vehicles destroyed={vehicles.Count} reason={reason}");
}
}