using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Vehicle ownership + direct summon (Phase 2). Ported from the proven newcityps /// (4091149) direct-scene-spawn path, with 4229938 wire deltas applied /// (LogicVehicleClientInfo.VehicleSpoonName, PlayerVehicleClientDetail.IsPersistent). /// No road-planner/AI-task leg: the vehicle materializes beside the player, /// already interactable, and the stock client seat/drive lifecycle takes over. /// Enter/exit/move notifies are logged + tracked; pushes for those come after /// live-traffic observation (see KNOWLEDGEBASE.md §4). /// internal sealed partial class GameRouter { internal static readonly System.Collections.Concurrent.ConcurrentDictionary GlobalVehicles = new(); internal sealed record SummonedVehicleSnapshot(ulong EntityId, uint ConfigId, float X, float Y, float Z, float Yaw, bool HasFix); internal static List SummonedSnapshot() { return GlobalVehicles.Values .Select(v => new SummonedVehicleSnapshot(v.EntityId, v.ConfigId, v.Position.X, v.Position.Y, v.Position.Z, v.Facing, true)) .ToList(); } internal static (bool InVehicle, byte SeatIndex) VehicleSeatSnapshot(TcpSession? session) { if (session is null) return (false, 0); var runtime = GetVehicleRuntimeState(session); lock (runtime.SyncRoot) { return (runtime.CurrentVehicleId != 0, (byte)Math.Max(0, runtime.CurrentVehicleSeat)); } } internal static async Task DestroyDirectAsync(TcpSession session, ulong entityId, string reason) { var runtime = GetVehicleRuntimeState(session); await DestroyVehicleAsync(session, runtime, entityId, reason); } internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token, Vec3 SpawnPos)> SpawnDirectAsync( TcpSession session, uint configId, Vec3 pos, float facing, float rightOffset, int sourceType, string reason) { var yaw = facing * (MathF.PI / 180f); var spawn = new Vec3(pos.X + MathF.Cos(yaw) * rightOffset, pos.Y + 0.15f, pos.Z - MathF.Sin(yaw) * rightOffset); var res = await SpawnDirectAsync(session, configId, pos, facing, reason, exactPos: spawn, createSourceType: sourceType, destroyPrevious: false); return (res.Ok, res.Message, res.EntityId, res.Token, spawn); } internal static bool TryGetSummoned(ulong entityId, out VehicleRecord? record) => GlobalVehicles.TryGetValue(entityId, out record); internal static void TrackVehicleMove(ulong entityId, float x, float y, float z, float yaw) { if (GlobalVehicles.TryGetValue(entityId, out var rec)) { rec.Position = new Vec3(x, y, z); rec.Facing = yaw; } } private const string VehicleRuntimeKey = "client4229938-vehicles"; // 高水位落盘(IdCounterStore §6.10):entityId / taskToken 重启后不能复用。 private const long VehicleEntityFloor = 8_200_000_000_000_000_000L; private const string VehicleEntityCounter = "Vehicle.EntityId"; private const long VehicleTokenFloor = 7_100_000_000_000_000_000L; private const string VehicleTokenCounter = "Vehicle.TaskToken"; // Tracks the NPC/entity the player currently has targeted (from AskInteractCmd cmd=33 s1). // Used as the inviteePid when the player sends AskInvitePlayerInteractionAction. private ulong _lastInteractTargetUnitId; /// /// 的静态镜像,在下面同一处赋值。 /// /// 为什么要有两份:_lastInteractTargetUnitId 是实例字段,而 /// GameRouter.CollectPoliceTargets(GM 面板 / /api/police/targets)是静态方法, /// 拿不到实例 ⇒ 只能读镜像。用镜像而不是把原字段改成 static,是为了不动 /// AskInvitePlayerInteractionAction 那条既有链路的可见性语义。 /// private static ulong s_lastInteractTargetUnitId; // True while a two-person interaction animation is playing. Detect the end by watching // AskInteractCmd cmd=34: its offset-24 u32 is !=1 during the action and returns to 1 when done. // The value can briefly flicker to 1 during animation transitions, so require a quiet window. private static bool _interactionActive; private static DateTime _lastInteractingUtc = DateTime.MinValue; /// Call when despawning NPCs or resetting state to abort any active interaction monitor. public static void ResetInteractionState() { _interactionActive = false; _lastInteractingUtc = DateTime.MinValue; } internal sealed class VehicleRecord { public ulong EntityId; public uint ConfigId; public int SeatCount; public Vec3 Position; public float Facing; public ulong ControllerPid; public bool Interactable; public ulong TaskToken; public List LastBits = []; public bool MoveLogged; public double DistanceDriven; public bool IsDriving; public float LastNitroValue; public float TopSpeed; public bool HasTopSpeed; public Dictionary SeatReservations { get; } = new(); public Dictionary SeatOccupants { get; } = new(); // T1-16 载具骇入 (ported from donor_7k). AetherRegistered is what makes a spawned // car selectable by HackTargetSelector: it iterates AetherVehicleManager's // VehicleId2IndexDict, which SyncSpawnVehicle alone never populates. public bool AetherRegistered; public DateTime HackBusyUntilUtc; public ulong HackTaskToken; public byte HackTaskStatus; // ★ 真实车流(T3-7):由 GameRouter.Traffic.cs 生成、每 tick 被车道流推着跑的车。 // true ⇒ 仍在车道流里,服务端每 tick 用 SyncAetherAIVehicleLaneDatas 覆盖它的里程。 // false ⇒ 已被玩家骇入接管,脱离车道流(由 DetachTrafficVehicleFromLaneAsync 翻转)。 // // 为什么必须能翻转:骇入下发的 SyncAssignVehicleAITask 是 RaidVehicle 通道, // 而车道流每 50ms 覆盖一次里程 —— 两套驱动源同时作用的话, // 车会表现为「加速一瞬间就被拽回原处」。所以接管的第一件事是把车摘出车道流。 public bool IsTraffic; } internal sealed class VehicleRuntimeState { public object SyncRoot { get; } = new(); public HashSet Unlocked { get; } = new(); public Dictionary Vehicles { get; } = new(); public ulong SummonedEntity; public ulong CurrentVehicleId; public int CurrentVehicleSeat = -1; /// /// 自驾状态(GameRouter.VehicleNav.cs,2026-09-27)。 /// 客户端的自动驾驶是「一辆车」的开关:开关状态只由 S2C /// SyncVehicleAutonomousDrivingState 决定(DriveVehiclesManager.autoDrivingVehicleDict), /// 所以服务端必须记住「哪辆车正开着」,才能在 Stop / 下车时回推正确的 false。 /// 存 session 而不是持久化:重进场景客户端会重新发 Start。 /// public ulong AutoDrivingVehicleId; public bool AutoDriving; /// /// 自驾下发的最后一个 AI 任务 token(2026-09-28 第三轮)。 /// ⛑️ 必须记住它:客户端收到 SyncAssignVehicleAITask 后会回 AskUpdateVehicleAITaskStatus, /// 但**会一直把任务停在 Pending,直到服务端用 SyncChangeVehicleAITaskState 确认同一个 /// entity + token**。不确认 ⇒ 任务永远不进 Running ⇒ 车纹丝不动。 /// public ulong AutoDriveTaskToken; public byte AutoDriveTaskStatus; public bool AutoDriveHasTarget; public float AutoDriveTargetX; public float AutoDriveTargetY; public float AutoDriveTargetZ; /// /// 上次真正下发的巡航点集(2026-09-30,GM 调速用)。 /// 改速度必须重发 Cruise 任务(速度写在任务参数里),而重发需要点集; /// 现算要走 SolveRoute,那是实例方法且要 Connection,GM 侧两者都没有。 /// ⇒ 下发时缓存一份,热应用直接复用,顺带保证「换速度不换路线」。 /// public List? AutoDriveLastPoints; /// 上次下发时用的速度(km/h)。值没变就不重发,避免 GM 手抖连点刷包。 public float AutoDriveSpeedKmh; /// /// 出租车行程(2026-09-30 新增,GameRouter.Taxi.cs)。null = 当前没有行程。 /// /// ⛑ 只记「服务端自己派发的那一次」。客户端的原生出租车 UI(选目的地面板、 /// 到达提示)状态全在客户端,服务端只负责:认单 → 派 Cruise → 判到达 → 收车费。 /// /// ⛑ 与 的关系:出租车复用自驾那条 /// SyncAssignVehicleAITask 通道,所以行程的 token 就是 AutoDriveTaskToken; /// 本字段额外记住「这趟是出租车」+ 目的地 + 起始里程,用来算车费和判到达。 /// public TaxiTrip4229938? TaxiTrip; /// /// 玩家最近开过的车(上车时写,下车不清)。改装店事务要它:位移前客户端已经 /// 把玩家自动请下车了(retail 的 SetSeatInteractOperation(0,6)),此时 /// 已被客户端的 exit 通知清零,只有这个还记得是谁。 /// public ulong LastDrivenVehicleId; public bool OwnershipSent; public bool AetherInitSent; // T1-16 载具骇入: serializes hack dispatch so a stale status notify cannot // retarget a newer task while its SyncAssignVehicleAITask is still in flight. public SemaphoreSlim HackDispatchGate { get; } = new(1, 1); // S011 story/boarding state (single-player: one active enter/exit transaction). public bool StoryRootSent; public bool StorySeenAnyRpc; public long StoryHighestConfirmedRpcId = -1; public SortedSet StoryPendingRpcIds { get; } = new(); public HashSet StoryCommittedRpcIds { get; } = new(); public ulong StoryEnterVehicleId; public byte StoryEnterSeat; public bool StoryExitInProgress; public ulong StoryBoardingUnitId; public void Clear() { lock (SyncRoot) { Vehicles.Clear(); SummonedEntity = 0; CurrentVehicleId = 0; CurrentVehicleSeat = -1; OwnershipSent = false; AetherInitSent = false; StoryRootSent = false; StorySeenAnyRpc = false; StoryHighestConfirmedRpcId = -1; StoryEnterVehicleId = 0; StoryEnterSeat = 0; StoryExitInProgress = false; StoryPendingRpcIds.Clear(); StoryCommittedRpcIds.Clear(); AutoDrivingVehicleId = 0; AutoDriving = false; AutoDriveTaskToken = 0; AutoDriveTaskStatus = 0; AutoDriveHasTarget = false; AutoDriveLastPoints = null; AutoDriveSpeedKmh = 0f; } } } internal static VehicleRuntimeState GetVehicleRuntimeState(TcpSession session) { if (session.Items.TryGetValue(VehicleRuntimeKey, out var raw) && raw is VehicleRuntimeState existing) return existing; var created = new VehicleRuntimeState(); foreach (var vehicle in VehicleCatalog4229938.AllSummonable) created.Unlocked.Add(vehicle.Id); session.Items[VehicleRuntimeKey] = created; return created; } private static VehicleRuntimeState GetVehicleRuntime(RpcContext ctx) => GetVehicleRuntimeState(ctx.Session); private static List VehicleOwnershipSnapshot(VehicleRuntimeState runtime) { // Full summonable catalog (237 vehicles) so the "owned vehicles" view and the phone // F1 car-call list every drivable vehicle in the game. User request: 所有车辆. // IsPersistent=true matches the proven sibling 08.05 contract (fleetIds-only with // IsPersistent=false never produced a client F1 summon). return VehicleCatalog4229938.AllSummonable .Select(v => new GameMethods.PlayerVehicleClientDetail4229938 { Id = v.Id, Parts = [], SuitId = 0, IsPersistent = true }) .ToList(); } internal static async Task PublishVehicleOwnership(RpcContext ctx) { if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled) return; var runtime = GetVehicleRuntime(ctx); lock (runtime.SyncRoot) { if (runtime.OwnershipSent) return; runtime.OwnershipSent = true; } var snapshot = VehicleOwnershipSnapshot(runtime); await ctx.NotifyAsync(MethodId.SyncAllUnlockedVehicles, snapshot); ctx.Session.Log.Info($"[VEHICLE] ownership published count={snapshot.Count}"); } internal static async Task EnsureAetherVehicleInit(RpcContext ctx) { if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled) return; var runtime = GetVehicleRuntime(ctx); lock (runtime.SyncRoot) { if (runtime.AetherInitSent) return; runtime.AetherInitSent = true; } var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles; var activeRaidId = GetStateIfExists(ctx.Session)?.ActiveRaidId ?? Profile.RaidId; var hasZoneGraph = activeRaidId == PrivateServerConfigStore.Current.World.RaidId; var handle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0; var variants = PrivateServerConfigStore.Current.Gameplay.Aether.InitVariants; if (variants is null || variants.Count == 0) variants = ["top-int32"]; // 带 -isec- 的变体用它;其余变体忽略(保持空表)。 var isecCount = PrivateServerConfigStore.Current.Gameplay.Aether.InitIntersectionProbeCount; // 真路口(-real 变体)一次装载一次记录,便于和客户端日志对账。 var realRows = GameAdminBridge.RealIntersectionRows(); ctx.Session.Log.Info($"[VEHICLE] aether-init intersections real=" + $"{PrivateServerConfigStore.Current.Gameplay.Aether.InitIntersectionsReal} " + $"rows={realRows.Length}" + (realRows.Length > 0 ? $" first={realRows[0].InstanceId}/zone{realRows[0].ZoneIndex} " + $"last={realRows[^1].InstanceId}/zone{realRows[^1].ZoneIndex}" : " (empty)") + $" catalog={IntersectionCatalog4229938.Count}"); foreach (var variant in variants) { var wire = GameAdminBridge.BuildAetherInitWire(variant, activeRaidId, hasZoneGraph, handle, isecCount); if (wire is null) { ctx.Session.Log.Info($"[VEHICLE] aether-init unknown variant '{variant}' (skipped)"); continue; } await ctx.NotifyAsync(MethodId.SyncAetherAIInitDatas, wire); ctx.Session.Log.Info($"[VEHICLE] aether-init variant={variant} raid={activeRaidId} zoneGraph={hasZoneGraph} " + $"handle={handle} bytes={wire.Length} hex={Convert.ToHexString(wire)}"); if (variants.Count > 1) await Task.Delay(800, CancellationToken.None); } // ★★ 2026-10-02:路口相位 / 红绿灯 —— init 把路口表交给客户端之后, // ① 给每个路口推一次初始相位(灯从"没被接管"变成"亮"), // ② 起周期切档循环。 // ⛑⛑ **独立于车流开关**:用户 19:38 把 trafficEnabled 关了,灯照样要亮 // ⇒ 挂在这里(Aether init 链路)而不是 TrafficLoop 里。 var (isecTotal, isecMulti, isecLanes, isecSample) = ProbeIntersections(); ctx.Session.Log.Info($"[SIGNAL] 路口表 路口={isecTotal} 多相位={isecMulti} 车道={isecLanes} 首行={isecSample}"); await PushIntersectionUpdatesAsync(ctx.Session, CancellationToken.None).ConfigureAwait(false); StartIntersectionSignals(ctx.Session); } // --- Aether city simulation: C2S calls ------------------------------------------- // The client owns the simulation (pedestrians / static NPCs / traffic). These handlers // exist so the tasks it awaits after SyncAetherAIInitDatas are answered instead of // stalling the spawner. [Handler(MethodId.AskMassHideAreas, HandlerPacketKind.Invoke)] private Task AskMassHideAreas(Connection conn, UxRpcMessage msg) { var reply = ParseHexBytes(PrivateServerConfigStore.Current.Gameplay.Aether.MassHideAreasReplyHex); conn.Log.Info($"[AETHER] mass-hide-areas -> {reply.Length}b hex={Convert.ToHexString(reply)}"); return conn.ReturnRawAsync(msg, reply); } [Handler(MethodId.AskAetherChangeQuality, HandlerPacketKind.Notify)] private Task AskAetherChangeQuality(Connection conn, UxRpcMessage msg) { var characterQ = msg.Body.Length >= 4 ? BitConverter.ToInt32(msg.Body, 0) : -1; var vehicleQ = msg.Body.Length >= 8 ? BitConverter.ToInt32(msg.Body, 4) : -1; conn.Log.Info($"[AETHER] quality character={characterQ} vehicle={vehicleQ} bytes={msg.Body.Length}"); return Task.CompletedTask; } [Handler(MethodId.AskAetherAIBorrowVehicle, HandlerPacketKind.Invoke)] private Task AskAetherAIBorrowVehicle(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyAsync(msg); [Handler(MethodId.AskChangePedToVehicleNpc, HandlerPacketKind.Invoke)] private Task AskChangePedToVehicleNpc(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyAsync(msg); [Handler(MethodId.AskReportAetherLeaflet, HandlerPacketKind.Invoke)] private Task AskReportAetherLeaflet(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyAsync(msg); [Handler(MethodId.AskAetherAICreateRaidVehicleAndClaimSeatByAether, HandlerPacketKind.Invoke)] private Task AskAetherAICreateRaidVehicleAndClaimSeatByAether(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, (byte)0); [Handler(MethodId.AskAetherAIRaidVehicleRegressAether, HandlerPacketKind.Invoke)] private Task AskAetherAIRaidVehicleRegressAether(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyAsync(msg); [Handler(MethodId.AskRaidVehicleConvertToAether, HandlerPacketKind.Invoke)] private Task AskRaidVehicleConvertToAether(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyAsync(msg); private static byte[] ParseHexBytes(string? hex) { if (string.IsNullOrWhiteSpace(hex)) return []; var clean = hex.Replace(" ", string.Empty).Replace("-", string.Empty); if (clean.Length % 2 != 0) return []; try { return Convert.FromHexString(clean); } catch (FormatException) { return []; } } // ⛑ 2026-10-02 对齐捐赠端:原来这里的 `FireExamAuto`(cmd=33/34 ⇒ 自动开盘查)已随 // `GameRouter.PoliceExamAuto.cs` 一起移除 —— 捐赠端**没有**"准星对准/对话 ⇒ 自动盘查"这条链, // 它的入口只有原生 F(`PoliceFRequest`)和 GM `/api/lixi/police`。 // cmd=33/34 现在只保留 uid 记录与日志,不再触发盘查。 /// /// Interact heartbeat/sync channel. Decoded + logged so a future F-press via the /// interact system is visible; currently accepted without side effects. /// [Handler(MethodId.AskInteractCmd, HandlerPacketKind.Notify)] private async Task AskInteractCmd(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { // Heartbeat CmdType=19 from pid 666 arrives ~2x/sec; log only anything else. if (args.CmdType != 19) { var hex = args.CommandData is { Count: > 0 } ? Convert.ToHexString(args.CommandData.ToArray()) : "-"; conn.Log.Info($"[INTERACT] cmd={args.CmdType} sender={args.sender} receiver={args.receiver} broadcast={args.broadCastType} data={args.CommandData?.Count ?? -1}b hex={hex} s1={args.stringParam1} s2={args.stringParam2}"); // cmd=33 = target selection; s1 is the targeted unit id (NPC or self). if (args.CmdType == 33 && ulong.TryParse(args.stringParam1, out var tid)) { _lastInteractTargetUnitId = tid; s_lastInteractTargetUnitId = tid; // 静态镜像,见字段注释 } // cmd=34 是交互心跳,offset-24 u32 是交互状态;见下面那段。 // cmd=34 = interaction heartbeat. Offset 24 u32 is the interaction state: // 1 = idle/not interacting, !=1 = ensemble action playing. // Update the last-interacting timestamp; a background monitor detects the end. if (args.CmdType == 34 && args.CommandData is { Count: >= 28 }) { var interactState = BitConverter.ToUInt32(args.CommandData.ToArray(), 24); if (interactState != 1) { _lastInteractingUtc = DateTime.UtcNow; } } } } else { conn.Log.Warn($"[INTERACT] AskInteractCmd undecodable bytes={msg.Body.Length}"); } } // Two-person interaction (双人 ACTION) invite channel. The client sends // AskInvitePlayerInteractionAction as an Invoke (12b = inviteePid u64 + actionItemId u32) // when the player picks an action. The NPC then shows "思考中" until the server acks. // We auto-accept: push inviter+invitee state and SyncStartPlayerInteractionAction. [Handler(MethodId.AskInvitePlayerInteractionAction, HandlerPacketKind.Invoke)] private async Task AskInvitePlayerInteractionAction(Connection conn, UxRpcMessage msg) { // 12b payload: inviterPid (u64 LE, the player) + actionItemId (u32 LE) // The invitee (NPC) is tracked separately from AskInteractCmd cmd=33 s1. ulong inviterPid = 0; uint actionItemId = 0; if (msg.Body.Length >= 12) { inviterPid = BitConverter.ToUInt64(msg.Body, 0); actionItemId = BitConverter.ToUInt32(msg.Body, 8); } var inviteePid = _lastInteractTargetUnitId; conn.Log.Info($"[INTERACT-INVITE] AskInvite bytes={msg.Body.Length} inviterPid={inviterPid} inviteePid={inviteePid} actionItemId={actionItemId} hex={Convert.ToHexString(msg.Body.ToArray())}"); // Invoke must return something. await conn.ReturnEmptyAsync(msg, 0); var session = msg.Context.Session; // Step 1: tell the inviter the invite is outstanding. // SyncInviterPlayerInteractionAction (64201952): inviterState(byte) + inviteePid(u64) + actionItemId(u32) var inviterPayload = new byte[13]; inviterPayload[0] = 1; // inviterState = inviting BitConverter.TryWriteBytes(inviterPayload.AsSpan(1, 8), inviteePid); BitConverter.TryWriteBytes(inviterPayload.AsSpan(9, 4), actionItemId); await session.NotifyAsync(64201952, inviterPayload, CancellationToken.None); // Step 2: tell the invitee (NPC) it received an invite. // SyncInviteeInvitePlayerInteractionAction (64752919): inviteePid(u64) + actionItemId(u32) var inviteeInvitePayload = new byte[12]; BitConverter.TryWriteBytes(inviteeInvitePayload.AsSpan(0, 8), inviteePid); BitConverter.TryWriteBytes(inviteeInvitePayload.AsSpan(8, 4), actionItemId); await session.NotifyAsync(64752919, inviteeInvitePayload, CancellationToken.None); // Step 3: NPC auto-accepts — set invitee state to accepted. // SyncInviteePlayerInteractionAction (64485670): inviteeState(byte) + inviterPid(u64) + actionItemId(u32) var inviteePayload = new byte[13]; inviteePayload[0] = 2; // inviteeState = accepted BitConverter.TryWriteBytes(inviteePayload.AsSpan(1, 8), inviterPid); BitConverter.TryWriteBytes(inviteePayload.AsSpan(9, 4), actionItemId); await session.NotifyAsync(64485670, inviteePayload, CancellationToken.None); // Step 4: tell the inviter the invitee replied (accepted). // SyncInviterReplyInvitePlayerInteractionAction (64298537): replyState(byte) var replyPayload = new byte[1]; replyPayload[0] = 0; // replyState = accepted (0=accept, 1=reject) await session.NotifyAsync(64298537, replyPayload, CancellationToken.None); // Carry/bind-type actions (Bridal Carry 99320003, Hand Hold 99320039) have baked client // config (MultiInteractType 1/59, CanJoyStickBreakAction=false) that locks input and // sticks pose after any server-side cancel — no known RPC can reset the client local // anim state. Skip SyncStart so the client never enters the interaction lock; the invite // UI still shows accepted but the player can move freely. var carryBindActions = new HashSet { 99320003, 99320039 }; if (carryBindActions.Contains(actionItemId)) { conn.Log.Info($"[INTERACT-INVITE] carry/bind action {actionItemId}, skipped SyncStart to avoid pose-lock"); return; } // Step 5: start the ensemble animation. // SyncStartPlayerInteractionAction (64579648): empty payload await session.NotifyAsync(64579648, Array.Empty(), CancellationToken.None); conn.Log.Info($"[INTERACT-INVITE] auto-accepted: inviter={inviterPid} invitee={inviteePid} action={actionItemId}"); // Capture player transform for cleanup teleport — loop actions like Bridal Carry // attach the player to the NPC and only a position reset breaks the pose. var pState = GetStateIfExists(conn.Session); // pState?.X != 0 is true when pState is null (null != 0), so the old ternary dereferenced // a null state in its true branch — a real NullReferenceException, not just a CS8602. var playerUnitId = pState?.ActiveSpiritUnitId is { } spiritUnitId && spiritUnitId != 0 ? spiritUnitId : 100000000000UL; var playerPos = pState?.LastReportedPlayerPosition ?? new Vec3(0, 0, 0); var playerFace = pState != null ? pState.LastReportedPlayerRotation.Y : 0f; // Mark interaction active; cmd=34 updates _lastInteractingUtc while the action plays. // A background monitor detects the end (quiet window for one-shot actions, fixed // timeout for loop actions like Bridal Carry / Salute / Kneel / instruments). _interactionActive = true; _lastInteractingUtc = DateTime.UtcNow; var monitorSession = session; var monitorLog = conn.Log; var monitorInvitee = inviteePid; // Loop / carry-type actions: client config is baked (IsLoopAction / CanJoyStickBreakAction // / MultiInteractType cannot be changed server-side), so force-cancel after 3s + parkour reset. // Includes true loops (Salute/Kneel/ThrowMoney/Champagne/instruments), Bridal Carry (carry), // and Hand Hold test (MultiInteractType=59 bind). var loopActionIds = new HashSet { 99320000, 99320003, 99320004, 99320007, 99320008, 99323001, 99323004, 99320039 }; var isLoop = loopActionIds.Contains(actionItemId); _ = Task.Run(async () => { var start = DateTime.UtcNow; while (_interactionActive) { await Task.Delay(500); var elapsed = (DateTime.UtcNow - start).TotalSeconds; var quiet = (DateTime.UtcNow - _lastInteractingUtc).TotalSeconds; // Loop actions: 8s timeout. One-shot: 5s quiet window or 20s hard cap. bool shouldCancel = isLoop ? elapsed > 8.0 : quiet > 5.0 || elapsed > 20.0; if (shouldCancel) { if (!_interactionActive) break; _interactionActive = false; try { // Simple cancel: both sides. No staged delays / parkour resets — those // were found to leave the client stuck after normal one-shot actions. await monitorSession.NotifyAsync(64853035, new byte[] { 1 }, CancellationToken.None); await monitorSession.NotifyAsync(64112304, new byte[] { 1 }, CancellationToken.None); monitorLog.Info($"[INTERACT] action ended ({(isLoop ? "loop 8s" : "quiet")} {elapsed:F1}s), cancelled invitee={monitorInvitee}"); } catch (Exception ex) { monitorLog.Warn($"[INTERACT] monitor cancel failed: {ex.Message}"); } break; } } }); } [Handler(MethodId.AskStartPlayerInteractionAction, HandlerPacketKind.Invoke)] private async Task AskStartPlayerInteractionAction(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[INTERACT-START] AskStart bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body.ToArray())}"); // 单人 pose(乐器独奏/普通表情,ActionItemConfig 里 MultiInteractType=0 的条目, // 含 Type=3 的 12 件乐器 99323001..99323015)从动作菜单选完后就发这一个空包, // 只有收到 SyncStartPlayerInteractionAction(64579648) 才真正播放并广播 —— // 客户端 reader 是空函数(RPCDeserializeAuto.lua:3571),所以 payload 必须是空。 // ⛑ 双人链路不能重复 push:AskInvite 第 5 步已经推过同一个 Sync,而客户端在 // AskReply 之后仍会再发本包(packets-20260917-151512.log:38870-38874 实证), // ⇒ 用 _interactionActive 去重,否则动作会被二次触发。 if (!_interactionActive) { await conn.NotifyAsync(MethodId.SyncStartPlayerInteractionAction, Array.Empty()); conn.Log.Info("[INTERACT-START] solo pose -> echo SyncStartPlayerInteractionAction(64579648) empty payload"); } else { conn.Log.Info("[INTERACT-START] two-person flow already pushed SyncStart, skipped (no double push)"); } await conn.ReturnEmptyAsync(msg, 0); } [Handler(MethodId.AskReplyInvitePlayerInteractionAction, HandlerPacketKind.Invoke)] private Task AskReplyInvitePlayerInteractionAction(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[INTERACT-REPLY] AskReply bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body.ToArray())}"); return conn.ReturnEmptyAsync(msg, 0); } [Handler(MethodId.AskGetUnlockedVehicles, HandlerPacketKind.Invoke)] private Task AskGetUnlockedVehicles(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, VehicleOwnershipSnapshot(GetVehicleRuntime(msg.Context))); [Handler(MethodId.AskSummonVehicle, HandlerPacketKind.Invoke)] private async Task AskSummonVehicle(Connection conn, UxRpcMessage msg) { if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled) { await conn.ReturnEmptyAsync(msg, 1); return; } if (!msg.TryGetArgs(out var args) || args is null || msg.Body.Length != 20 || args.vehicleconfigid == 0 || !float.IsFinite(args.position.X) || !float.IsFinite(args.position.Y) || !float.IsFinite(args.position.Z) || !float.IsFinite(args.facingdirection)) { conn.Log.Warn($"[VEHICLE] AskSummonVehicle bad args bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } // Unknown / model-less ids are allowed with a warning (newer client data may know // more vehicles than our dump); the client validates against its own configs. Proven // behavior of the sibling 08.05 build — rejecting here would break client F1 summon. var playerPos = new Vec3(args.position.X, args.position.Y, args.position.Z); // ★★ 2026-10-03「召唤载具从车道开过来」:车不再凭空出现在玩家右手边,而是 // ① 先落在附近一条车道上(距玩家 [min,max] 米),② 再给它一个 Cruise 任务 // 开到玩家身边的**路边**停好(终点取车道上离玩家最近的点,不是玩家脚下)。 // // 原生本来就有这一半:SummonVehicleResult.TaskToken 回执之后,客户端 // SummonVehicleManager 拿同一个 token 去 RegisterTaskAIStartedEvent // ⇒ 命中才播 OnSummonShowStart。所以我们给到达任务复用同一个 token。 // // ⛑⛑ 这里任何一步失败(没车道 / 室内 / 孤岛车道 / 落车失败)都退回旧路径 // (刷在身边 5.5m)—— 宁可没演出,也不能让玩家叫不出车。 var driveInCfgs = PrivateServerConfigStore.Current.Gameplay.Vehicles.SummonDriveIn; if (driveInCfgs.Enabled && VehicleLaneGraph.Available && VehicleLaneGraph.TrySnapLane(playerPos.X, playerPos.Z, driveInCfgs.StopSnapMeters, driveInCfgs.MaxLateralDeviationMeters, out var stopSnap) && VehicleLaneGraph.TryPickDriveInSpawn(playerPos.X, playerPos.Z, driveInCfgs.MinDistanceMeters, driveInCfgs.MaxDistanceMeters, driveInCfgs.MaxLateralDeviationMeters, Random.Shared, out var sx, out var sy, out var sz, out var spawnFacing)) { var spawnPos = new Vec3(sx, sy, sz); var driveIn = await SpawnDirectAsync(msg.Context.Session, args.vehicleconfigid, playerPos, spawnFacing, "client-summon-drivein", exactPos: spawnPos); if (driveIn.Ok) { await StartSummonDriveInAsync(conn, msg.Context.Session, driveIn.EntityId, driveIn.Token, spawnPos, new Vec3(stopSnap.X, stopSnap.Y, stopSnap.Z), driveInCfgs); await conn.ReturnAsync(msg, new GameMethods.SummonVehicleResult4229938 { VehicleEntityId = driveIn.EntityId, TaskToken = driveIn.Token, }); return; } if (!driveInCfgs.FallbackToBesidePlayer) { conn.Log.Warn($"[VEHICLE] summon drive-in spawn failed: {driveIn.Message}"); await conn.ReturnEmptyAsync(msg, 3); return; } conn.Log.Warn($"[VEHICLE] summon drive-in spawn failed, falling back to beside-player: {driveIn.Message}"); } else if (driveInCfgs.Enabled && driveInCfgs.LogEverySummon) { // 车道表没载入 / 附近没合格车道 ⇒ 静默走旧路径,但留一条日志好排查。 conn.Log.Info($"[VEHICLE] summon drive-in unavailable (lanes={VehicleLaneGraph.Available}, " + $"player=({playerPos.X:F0},{playerPos.Y:F0},{playerPos.Z:F0})) -> beside-player"); } var result = await SpawnDirectAsync(msg.Context.Session, args.vehicleconfigid, playerPos, args.facingdirection, "client-summon"); if (!result.Ok) { await conn.ReturnEmptyAsync(msg, 3); return; } await conn.ReturnAsync(msg, new GameMethods.SummonVehicleResult4229938 { VehicleEntityId = result.EntityId, TaskToken = result.Token, }); } /// /// 召唤车「开过来」到达之后,把权威位置钉死在它现在真正的位置(2026-10-03 实机修瞬移)。 /// /// 症状:车能开到身边,玩家一上车就瞬移回生成点。 /// /// 根因:服务端在生成时下发了 SyncAetherAIVehicleAddData(ClientVehicleInitData), /// 里面写的是生成点坐标且 LaneHandle=-1(不参与车道推进)⇒ 这份 Aether 坐标 /// 之后再也没人更新。车是被 SyncAssignVehicleAITask 的 Cruise(Raid 通道)开过来的, /// 客户端两套表示就此分叉。玩家上车时客户端拿 Aether 那份数据对齐实体 ⇒ 车被拽回生成点。 /// 服务端上车那几秒一条 S2C 位置包都没发(14:46:42–45 全是 C2S),所以不是我们回填错了, /// 是客户端自己的那份旧值。 /// /// 对策:SyncTeleportVehicle(68088911) 是 Raid 通道的权威位置包(赛车 R309 / /// 改装店钉车 / 传送都在用,Reset=true = 客户端直接落位不插值)。位置取 /// AskVehicleMove 上报的最新值 —— AI 开车期间客户端每秒都在报,所以这一发 /// 视觉上是「原地不动」,只是把两边强行对齐。 /// internal static async Task CompleteSummonDriveInAsync( Connection conn, VehicleRuntimeState runtime, ulong entityId) { var cfg = PrivateServerConfigStore.Current.Gameplay.Vehicles.SummonDriveIn; if (!cfg.Enabled) return; ulong token; lock (runtime.SyncRoot) { // 只管「玩家刚叫的那台车」:出租车 / 车流 / 骇入的到达不该被我们收尾。 if (entityId == 0 || entityId != runtime.SummonedEntity) return; if (!runtime.Vehicles.ContainsKey(entityId)) return; token = runtime.AutoDriveTaskToken; } var session = conn.Session; // ① 撤掉 Cruise 任务(= 出租车的 StopTaxiTaskAsync)。 // ⛑⛑ 这一步是三个症状的共同解药:客户端 SummonVehicleManager 用 // RegisterTaskAIDeletedEvent(token) 等**任务被删除**才走 OnSummonShowFinish; // 任务不删 ⇒ 演出永远不结束 ⇒ ① 车还在 AI 手里,玩家方向盘无效("车开不动") // ② 客户端认为这台车还在召唤流程中,不给再上车 ③ "叫车小助手忙",不能再叫一台。 if (cfg.EndTaskOnArrival) { await AssignAutonomousStopAsync(session, runtime, entityId); // ②⛑⛑ 交还主控权("车开不动"的真凶)。 // Cruise 下发时我们把控制权锁成了 Profile.PlayerPid(不锁的话玩家方向盘会盖掉 // AI 任务,车开不过来);到达后必须还原成 0,否则主控权一直在服务端手里, // 玩家即使坐在主驾(seat=0)也只算乘客 —— 18:38:55 那次坐 seat=0 一样开不动, // 就是这条没做。 if (cfg.ReleaseControlOnArrival) { await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize( new GameMethods.SyncChangeVehicleController4229938 { vehicleInstanceId = entityId, controllerPid = 0, }), CancellationToken.None); conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN release control entity={entityId} controllerPid=0"); } // ③ 关掉自驾状态。顺序照抄出租车:先 Stop 任务,再推 false。 // 不关 ⇒ 客户端一直当这车在自动驾驶中,玩家输入被 AI 盖掉。 lock (runtime.SyncRoot) { runtime.AutoDriving = false; runtime.AutoDrivingVehicleId = 0; runtime.AutoDriveTaskToken = 0; runtime.AutoDriveHasTarget = false; runtime.AutoDriveLastPoints = null; } await PushVehicleAutonomousStateAsync(conn, entityId, false); conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN finish entity={entityId} token={token} " + "(stop AI task + auto-drive state off)"); } // ④ 位置对齐放在最后:Stop / 控制权重置 / 状态翻转都可能让客户端拿旧坐标复位, // 这一发把权威位置钉死在真实位置,覆盖掉任何复位。 if (cfg.SyncPositionOnArrival) { float px, py, pz, facing; lock (runtime.SyncRoot) { if (!runtime.Vehicles.TryGetValue(entityId, out var rec)) return; px = rec.Position.X; py = rec.Position.Y; pz = rec.Position.Z; facing = rec.Facing; } await session.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize( new SceneMethods.SyncTeleportVehicle4229938 { EntityId = entityId, Position = new SceneMethods.UxVector3(px, py, pz), Rotation = new SceneMethods.UxVector3(0f, facing, 0f), Velocity = 0f, Reset = true, // 真跳变:客户端直接落位,不插值(与赛车/改装店一致) MoveToken = 0, }), CancellationToken.None); conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN align entity={entityId} " + $"at=({px:F1},{py:F1},{pz:F1}) yaw={facing:F1} (SyncTeleportVehicle 68088911 Reset=true)"); } // ⑤ 把交互重新打开:车开过来的过程中客户端可能把它标成不可交互 // (AI 接管中的车不给按 F),到站了必须还回来,否则下车后就再也上不去。 await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize( new GameMethods.SyncChangeVehicleInteractable4229938 { vehicleInstanceId = entityId, interactable = true, }), CancellationToken.None); conn.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN interactable=true entity={entityId}"); } /// /// 召唤载具的「开过来」那一半(2026-10-03,)。 /// /// 三步,顺序不能改: /// /// SyncAetherAIChangeVehicleControl(2) —— 把车切回 RaidVehicle。 /// 客户端会把有 VDE 的车交给 Aether 车道模拟,位移每帧被车道覆盖 ⇒ Cruise 变成 /// 「轮胎在转、车在原地进行」(GameRouter.Taxi.cs:147-166,出租车踩过的)。 /// SyncVehicleAutonomousDrivingState(true) —— 必须早于 AI 任务, /// 否则客户端把这条任务当成「别人的车被接管」渲染 ⇒ 车门消失 / 角色缺模 /// (2026-09-30 §43.1)。 /// SyncAssignVehicleAITask + Cruise,token 复用召唤回执那个。 /// /// /// /// ⛑ 终点是「车道上离玩家最近的点」(靠边停车),不是玩家脚下:自定义标点吸附失败时 /// 客户端一条 Cruise 都不下发,表现是「叫了车没反应」。解不出车道路径就只给终点, /// 交给客户端 C# NavigationHelper 自己导航(自驾已验证能到任意 POI)。 /// private async Task StartSummonDriveInAsync(Connection conn, TcpSession session, ulong entityId, ulong token, Vec3 spawn, Vec3 stop, SummonDriveInSettings cfg) { var runtime = GetVehicleRuntimeState(session); if (cfg.ForceRaidControl) await session.NotifyAsync(MethodId.SyncAetherAIChangeVehicleControl, new UxWriter().U64(entityId).U8(2).ToArray(), CancellationToken.None); await PushVehicleAutonomousStateAsync(conn, entityId, true); var path = new List { new(stop.X, stop.Y, stop.Z) }; var laneRoute = false; if (cfg.UseLaneRoute) { var route = SolveRoute(conn, spawn.X, spawn.Y, spawn.Z, stop.X, stop.Y, stop.Z, needCenter: false, "summon-drivein"); if (route is not null && route.Value.Ok && route.Value.DrivePoints.Count >= 2) { path = route.Value.DrivePoints; laneRoute = true; } } await AssignAutonomousCruiseAsync(session, runtime, entityId, path, cfg.SpeedKmh / 3.6f, tokenOverride: token); var flat = MathF.Sqrt((spawn.X - stop.X) * (spawn.X - stop.X) + (spawn.Z - stop.Z) * (spawn.Z - stop.Z)); session.Log.Info($"[VEHICLE] SUMMON_DRIVE_IN entity={entityId} token={token} " + $"spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1}) " + $"stop=({stop.X:F1},{stop.Y:F1},{stop.Z:F1}) straight={flat:F1}m " + $"points={path.Count} laneRoute={laneRoute} speed={cfg.SpeedKmh:0.#}km/h"); } /// /// Direct scene spawn shared by client summon and the admin panel: /// SyncLogicVehicleEnter + SyncSpawnVehicle (interactable) + SyncChangeVehicleInteractable. /// Returns the new entity id + task token. Replaces the previous summoned vehicle. /// /// /// 给了就原样落在这(只 +0.15m 抬高),不再做「玩家右手边 5.5m」的偏移。 /// 改装店换展示车必须走这条:工位在地下(y<0),按玩家朝向偏移会把车摆进墙里 /// (donor 13:38 实测「车在你看的地方/卡墙」)。 /// internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token)> SpawnDirectAsync( TcpSession session, uint vehicleId, Vec3 nearPos, float facing, string reason, Vec3? exactPos = null, int createSourceType = 2, bool destroyPrevious = true) { if (vehicleId == 0) return (false, "vehicle 0 does not exist", 0, 0); // Unknown / model-less ids are allowed with a warning (newer client data may know // more vehicles than our dump); the client validates against its own configs. 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($"[VEHICLE] config {vehicleId} has no prefab model in dump, trying anyway"); } else { session.Log.Warn($"[VEHICLE] config {vehicleId} not in VehicleConfig dump, trying with {seatCount} seats"); } var runtime = GetVehicleRuntimeState(session); ulong oldEntity; lock (runtime.SyncRoot) { oldEntity = runtime.SummonedEntity; if (oldEntity != 0 && runtime.CurrentVehicleId == oldEntity) return (false, "current summoned vehicle is occupied (exit it first)", 0, 0); } if (destroyPrevious && oldEntity != 0) await DestroyVehicleAsync(session, runtime, oldEntity, "replacement"); // Spawn beside the player: 5.5m to the right, +0.15m up (proven offsets). // exactPos 走改装店工位:不偏移(见参数注释)。 var yaw = facing * (MathF.PI / 180f); var spawn = exactPos is { } exact ? new Vec3(exact.X, exact.Y + 0.15f, exact.Z) : new Vec3(nearPos.X + MathF.Cos(yaw) * 5.5f, nearPos.Y + 0.15f, nearPos.Z - MathF.Sin(yaw) * 5.5f); var entityId = unchecked((ulong)IdCounterStore.Next(VehicleEntityCounter, VehicleEntityFloor)); var token = unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor)); var record = new VehicleRecord { EntityId = entityId, ConfigId = vehicleId, SeatCount = seatCount, Position = spawn, Facing = facing, ControllerPid = 0, Interactable = true, TaskToken = token, }; lock (runtime.SyncRoot) { runtime.Vehicles[entityId] = record; GlobalVehicles[entityId] = record; runtime.SummonedEntity = entityId; } var state = GetStateIfExists(session); if (state is not null) { lock (state.SyncRoot) { state.LastAdminVehicleId = vehicleId; state.LastSpawnedVehicleUid = entityId; } } var euler = new SceneMethods.UxVector3(0f, facing, 0f); var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z); // 改装店外观回声(2026-09-26 自 repair-shop.zip 移植):默认套件展开成底座 // (Suit[] + 两条轮轴 + 套件车漆),买过的件按 PartTag 顶替。 // ⛑ 只在改装店开关打开时生效 —— 关掉就是改造前的样子(Parts=[] / SuitId=0, // 客户端回落到配置默认),既保住现有可用行为,也是一个干净的回滚开关。 // ⛑ 纯数值件默认不进刷车包(StatOnlyPartTags),开关 repairShop.sendStatParts 做 A/B。 var tuningOn = PrivateServerConfigStore.Current.Gameplay.RepairShop.Enabled; BuildAppearance(tuningOn ? GetStateIfExists(session) : null, vehicleId, out var rememberedParts, out var rememberedSuit, includeStatParts: PrivateServerConfigStore.Current.Gameplay.RepairShop.SendStatParts); if (!tuningOn) { rememberedParts = []; rememberedSuit = 0; } if (tuningOn) session.Log.Info($"[VEHICLE] spawn appearance config={vehicleId} suit={rememberedSuit} " + $"parts=[{string.Join(",", rememberedParts.Select(p => p.Type + ":" + p.ConfigId))}]"); await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new GameMethods.LogicVehicleClientInfo4229938 { EntityId = entityId, VehicleConfigId = vehicleId, CreateSourceType = (byte)createSourceType, Parts = rememberedParts, SuitId = rememberedSuit, LicensePlate = string.Empty, Interactable = true, MoveToken = 0, Position = pos, EulerAngles = euler, VehicleSpoonName = null, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new GameMethods.VehicleClientInfo4229938 { ControllerPid = 0, CreateSourceType = (byte)createSourceType, EntityId = entityId, VehicleConfigId = vehicleId, Parts = rememberedParts, SuitId = rememberedSuit, 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, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize( new GameMethods.SyncChangeVehicleInteractable4229938 { vehicleInstanceId = entityId, interactable = true }), CancellationToken.None); // 改装店门禁(2026-09-25 移植):客户端 ApplyCarManager.IsMySummonVehicle 读的是 // DriveVehiclesManager.CurrentSummonVehicle,而它只在收到 68681875 时才写 —— 光有 // SyncSpawnVehicle 不算「自己的车」,进店第一关就弹「只能驾驶自己的载具进入」。 // 载荷 = SummonVehicleResult(u64 entity + u64 token),客户端 C# 原生接收(CSharpOnly, // Lua 侧没有 reader)。回滚:gameplay.repairShop.pushSummonStart=false。 if (PrivateServerConfigStore.Current.Gameplay.RepairShop is { Enabled: true, PushSummonStart: true }) { await session.NotifyAsync(MethodId.SyncStartSummonVehicle, UxSerializer.Serialize( new GameMethods.SummonVehicleResult4229938 { VehicleEntityId = entityId, TaskToken = token }), CancellationToken.None); session.Log.Info($"[VEHICLE] SyncStartSummonVehicle pushed entity={entityId} token={token} (repair-shop gate)"); } // T1-16 载具骇入 (ported from donor_7k): a spawned car is not hackable until three // things are true — it has HP, it is flagged hackable, and Aether knows it. // CanHack() requires CurrentHp > 0; SyncSpawnVehicle carries no HP fields. await session.NotifyAsync(MethodId.SyncVehicleBrokenCollision, UxSerializer.Serialize( new GameMethods.VehicleBrokenCollisionInfo4229938 { VehicleEntityId = entityId, CurrentHp = 10_000f, MaxHp = 10_000f, }), CancellationToken.None); await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleHackableState, UxSerializer.Serialize( new SceneMethods.SyncVehicleHackableState4229938 { vehicleEntityId = entityId, isHackable = true, }), CancellationToken.None); // HackTargetSelector iterates AetherVehicleManager.VehicleId2IndexDict and // requires a VehicleViewEntity. SyncSpawnVehicle alone never populates it. await RegisterHackVehicleAsync(session, runtime, record); _ = CompleteHackVehicleInitializationAsync(session, runtime, record); session.Log.Info($"[VEHICLE] DIRECT_SPAWN reason={reason} config={vehicleId} entity={entityId} token={token} seats={seatCount} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1})"); return (true, $"spawned vehicle {vehicleId} (entity={entityId}) beside you — walk up and press F", entityId, token); } internal static async Task DestroyVehicleAsync(TcpSession session, VehicleRuntimeState runtime, ulong entityId, string reason) { // ★ 捐赠端 GameRouter.Vehicle.cs:562:车没了要把还挂在车上的警察乘客一并释放, // 否则押送状态机永远等不到"下车",座位预约也解不掉。 await ReleasePoliceVehiclePassengers4229938(session, entityId); lock (runtime.SyncRoot) { runtime.Vehicles.Remove(entityId); GlobalVehicles.TryRemove(entityId, out _); if (runtime.SummonedEntity == entityId) runtime.SummonedEntity = 0; if (runtime.CurrentVehicleId == entityId) { runtime.CurrentVehicleId = 0; runtime.CurrentVehicleSeat = -1; } } await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize( new GameMethods.DestroyVehicle4229938 { VehicleEntityId = entityId, VehicleDestroyType = 0, Distance = 0, DynamicGoId = 0 }), CancellationToken.None); session.Log.Info($"[VEHICLE] destroy entity={entityId} reason={reason}"); } // --- Enter / exit / move tracking (notifies; logged + recorded, no speculative pushes yet) --- [Handler(MethodId.AskPlayerStartEnterOrExitVehicle)] private async Task AskPlayerStartEnterOrExitVehicle(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { var runtime = GetVehicleRuntime(msg.Context); VehicleRecord? boarded = null; lock (runtime.SyncRoot) { if (args.EnterOrLeave) { runtime.CurrentVehicleId = args.VehicleEntityId; runtime.CurrentVehicleSeat = args.SeatIndex; runtime.LastDrivenVehicleId = args.VehicleEntityId; if (runtime.Vehicles.TryGetValue(args.VehicleEntityId, out var record)) { record.ControllerPid = args.Pid; // ★ 车流车:坐进去之前先摘出车道流,否则玩家会被车道拽着跑, // 方向盘完全不起作用(每 50ms 里程被服务端覆盖一次)。 if (record.IsTraffic) boarded = record; } } else if (runtime.CurrentVehicleId == args.VehicleEntityId) { runtime.CurrentVehicleId = 0; runtime.CurrentVehicleSeat = -1; } } if (boarded is not null) await DetachTrafficVehicleFromLaneAsync(conn.Session, boarded); conn.Log.Info($"[VEHICLE] {(args.EnterOrLeave ? "ENTER" : "EXIT")} pid={args.Pid} vehicle={args.VehicleEntityId} seat={args.SeatIndex} force={args.IfForce}"); // ★ 赛车(2026-10-03 移植):上车 = 「等玩家上车才开赛」那道闸的放行信号。 // 只在 config/racing-ablation.txt 写了 `boarding` 时那道闸才存在; // 没有等待中的比赛时本调用是**静默 no-op**,不影响任何既有行为。 // 两个包(Start / Finish)都监听是有意的:谁先到用谁,闸本身幂等。 if (args.EnterOrLeave) SignalRacingBoarding( msg.Context, args.VehicleEntityId, args.SeatIndex, "AskPlayerStartEnterOrExitVehicle"); } else { conn.Log.Warn($"[VEHICLE] AskPlayerStartEnterOrExitVehicle undecodable bytes={msg.Body.Length}"); } } [Handler(MethodId.AskPlayerFinishEnterOrExitVehicle)] private Task AskPlayerFinishEnterOrExitVehicle(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { conn.Log.Info($"[VEHICLE] FINISH {(args.EnterOrLeave ? "enter" : "exit")} vehicle={args.VehicleEntityId} seat={args.SeatIndex}"); // ★ 赛车:同 Start 那条 —— 上车动画**完成**(人已坐好)也放行(见上方注释)。 if (args.EnterOrLeave) SignalRacingBoarding( msg.Context, args.VehicleEntityId, args.SeatIndex, "AskPlayerFinishEnterOrExitVehicle"); } else conn.Log.Warn($"[VEHICLE] AskPlayerFinishEnterOrExitVehicle undecodable bytes={msg.Body.Length}"); return Task.CompletedTask; } [Handler(MethodId.AskVehicleMove)] private Task AskVehicleMove(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { var runtime = GetVehicleRuntime(msg.Context); bool firstLog = false; lock (runtime.SyncRoot) { if (runtime.Vehicles.TryGetValue(args.Id, out var record)) { record.Position = new Vec3(args.Position.X, args.Position.Y, args.Position.Z); record.Facing = args.facingDirection; record.LastBits = args.Bits ?? []; firstLog = !record.MoveLogged; record.MoveLogged = true; } // While driving, the vehicle IS the player position (admin panel follows). if (runtime.CurrentVehicleId == args.Id) { var state = GetStateIfExists(msg.Context.Session); if (state is not null) { state.LastReportedPlayerPosition = new Vec3(args.Position.X, args.Position.Y, args.Position.Z); state.HasLastReportedPlayerTransform = true; } } } if (firstLog) conn.Log.Info($"[VEHICLE] move-start entity={args.Id} pos=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1})"); } return Task.CompletedTask; } // AskClaimVehicleSeat lives in GameRouter.VehicleStory.cs (story-aware seat re-pin). // --- Drive-loop accepts (proven set from the sibling 08.05 build) --- // The client keeps driving client-side; the server only has to accept the drive-loop // notifies and answer the drive-state invokes with an empty OK so the loop never stalls. [Handler(MethodId.AskVehicleStartMove)] [Handler(MethodId.AskVehicleStopMove)] [Handler(MethodId.AskEnterVehicleIndoor)] [Handler(MethodId.AskExitVehicleIndoor)] [Handler(MethodId.AskKillVehicle)] private Task VehicleNotifyAccept(Connection conn, UxRpcMessage msg) => Task.CompletedTask; // AskVehicleHorn / ReportDrivingVehicle / AskVehicleNitro already have logging/tracking // handlers in GameRouter.UnhandledBatch.cs — do not re-register them here. [Handler(MethodId.VehicleDriveStateChange)] [Handler(MethodId.AskChangeGoVehicleDriveState)] [Handler(MethodId.AskVehicleDeadEnd)] [Handler(MethodId.AskVehicleStuck)] [Handler(MethodId.AskVehicleHit)] [Handler(MethodId.AskVehicleHitEnd)] private Task VehicleInvokeAccept(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg); }