using System.Buffers.Binary; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Gameplay; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; // T1-16 载具骇入 — ported from donor_7k GameRouter.VehicleHacking.cs. // // Deviation from donor: it gates every hack on CharacterCapabilities.IsSeymour(), a hard-coded // list of three spirit ids (one of which, 15022061, is actually jobless). We gate on the authored // urban job instead — FightSpiritConfig.DefaultUrbanJob == 401 — so any character the config // marks as a hacker works, without touching this file again. internal sealed partial class GameRouter { private static async Task RegisterHackVehicleAsync(TcpSession session, VehicleRuntimeState runtime, VehicleRecord vehicle) { // Do not feed aircraft, boats or model-less test props to road-traffic AI. // // MilkCar (VehicleSerializedType.MilkCar == 13) is excluded as well. It is the player's // personal base vehicle (wardrobe + armory), not road traffic, and the stock client's // VehicleSpawnConfig has no row for it. Registering it anyway makes the client drop the // whole entity at AetherCity.Manager.AetherVehicleManager:AddVehicleDataEntity — // Player.log shows "VehicleInit_Drop_NoConfig ... not found in VehicleSpawn" followed by // "Attach failed: VDE is null" — so no BaseVehicle view and no interact point is ever // built. Skipping the registration keeps the entity alive and leaves it unhackable and // undrivable, which is what MilkCar is supposed to be anyway. if (!VehicleCatalog4229938.TryGet(vehicle.ConfigId, out var config) || config.VehicleType is < 2 or > 18 || config.VehicleType == 13 || string.IsNullOrWhiteSpace(config.Model)) return; // ⛑⛑ 2026-09-30:上面那段注释讲的坑,以前只有注释、代码不检查 ⇒ 玩家召到 // 「客户端 VehicleSpawnConfig 里没有」的车时,日志一片正常、游戏里却是 // 一台不会动、上下车没动画的摆设,完全查不出原因。这里补一条显式告警。 // 典型受害者:**81004001**(4 辆 VehicleType==8 出租车里唯一不在表里的那辆), // 实测 Player.log 三连 VehicleInit_Drop_NoConfig → VDE is null → Flush failed, // 表现为「坐得进去、开不起来、没上下车动画」。可用的是 81004004 / 81004015 / 81004038。 // // ⛑ 只告警、**不改行为**:不注册会让车变成「不可骇入不可开」,注册了客户端会 drop 整台 // 实体 —— 两种都是坏结果,而哪些车依赖注册还跑得通没有全量验证过,先不动既有行为。 if (!VehicleSpawnCatalog4229938.IsTrafficVehicle(vehicle.ConfigId)) session.Log.Warn($"[VEHICLE-HACK] config={vehicle.ConfigId} is NOT in client VehicleSpawnConfig " + "=> client will drop the entity (VehicleInit_Drop_NoConfig / VDE is null) " + "=> no movement, no enter/exit animation. Spawn a different configId."); GameMethods.ClientVehicleInitData4229938 data; lock (runtime.SyncRoot) { if (!runtime.Vehicles.TryGetValue(vehicle.EntityId, out var current) || !ReferenceEquals(current, vehicle)) return; data = new() { VehicleConfigId = vehicle.ConfigId, Id = vehicle.EntityId, Position = new(vehicle.Position.X, vehicle.Position.Y, vehicle.Position.Z), Facing = vehicle.Facing, EulerAngles = new(0f, vehicle.Facing, 0f), Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0, LaneHandle = -1, ControlType = 2, }; } // Stock client attaches Aether data/view to the existing BaseVehicle with // this same ID. ControlType=RaidVehicle avoids activating lane simulation. await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData, UxSerializer.Serialize(data), CancellationToken.None); lock (runtime.SyncRoot) vehicle.AetherRegistered = true; session.Log.Info($"[VEHICLE-HACK] aether-registration sent entity={vehicle.EntityId} config={vehicle.ConfigId} control=raid lane=-1"); } private static async Task CompleteHackVehicleInitializationAsync(TcpSession session, VehicleRuntimeState runtime, VehicleRecord vehicle) { try { // Model loading and Aether's pending-create queue are asynchronous. // Only replay initial HP once, not a permanent healing/background loop. await Task.Delay(2000); lock (runtime.SyncRoot) { if (!runtime.Vehicles.TryGetValue(vehicle.EntityId, out var current) || !ReferenceEquals(current, vehicle) || vehicle.MoveLogged || vehicle.IsDriving || vehicle.SeatOccupants.Count != 0) return; } await session.NotifyAsync(MethodId.SyncVehicleBrokenCollision, UxSerializer.Serialize( new GameMethods.VehicleBrokenCollisionInfo4229938 { VehicleEntityId = vehicle.EntityId, CurrentHp = 10_000f, MaxHp = 10_000f, }), CancellationToken.None); session.Log.Info($"[VEHICLE-HACK] deferred initial HP sent entity={vehicle.EntityId}"); } catch (Exception ex) { session.Log.Warn($"[VEHICLE-HACK] initialization stopped entity={vehicle.EntityId}: {ex.Message}"); } } [Handler(MethodId.AskHack, HandlerPacketKind.Invoke)] private async Task AskHack(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); var templateId = state.ActiveSpiritTemplateId; var args = msg.GetArgs(); // The client selects the target; vehicle actions then arrive as AskHackVehicle. // Here the server authorizes the energy spend, acknowledges the invoke, // and refreshes the six-point HackerConfig battery so the private-server sandbox // remains usable without a missing retail recovery scheduler. await conn.ReturnEmptyOkAsync(msg); if (!CharacterCapabilities.CanHack(templateId)) { conn.Log.Warn($"[HACK] accepted without hacker job job={CharacterCapabilities.UrbanJobOf(templateId)} template={templateId} type={args.hackType}"); return; } await conn.NotifyAsync(MethodId.SyncHackerBatteryCurrentAndTotal, new GameMethods.HackerBatteryCurrentAndTotalCount4229938 { BatteryTotalCount = 6, BatteryCurrentCount = 6 }); conn.Log.Info($"[HACK] accepted template={templateId} job=401 type={args.hackType} battery=6/6"); } // UI label: 加速. RushForwardSkill sends u64 vehicleId + u8 actionType(1) + null parameter(0). // Reject unsupported variants instead of acknowledging an action we did not run. internal static bool TryDecodeVehicleRush(byte[] body, out ulong vehicleId) { vehicleId = 0; if (body.Length != 10 || body[8] != 1 || body[9] != 0) return false; vehicleId = BinaryPrimitives.ReadUInt64LittleEndian(body); return vehicleId != 0; } internal static bool CanRushVehicle(VehicleRuntimeState runtime, VehicleRecord vehicle, float playerX, float playerY, float playerZ, DateTime now) { var dx = vehicle.Position.X - playerX; var dy = vehicle.Position.Y - playerY; var dz = vehicle.Position.Z - playerZ; var distanceSquared = dx * dx + dy * dy + dz * dz; return vehicle.AetherRegistered && vehicle.Interactable && (vehicle.ControllerPid == 0 || vehicle.ControllerPid == Profile.PlayerPid) && runtime.CurrentVehicleId != vehicle.EntityId && vehicle.SeatOccupants.Count == 0 && vehicle.SeatReservations.Count == 0 && !vehicle.IsDriving && vehicle.HackBusyUntilUtc <= now && float.IsFinite(distanceSquared) && distanceSquared <= 30f * 30f; } // Called under runtime.SyncRoot. Network physics ownership is independent of // seat occupancy: hacking must not board the player or mark them as driving. internal static bool TryPrepareVehicleRush(VehicleRuntimeState runtime, VehicleRecord vehicle, float playerX, float playerY, float playerZ, DateTime now, ulong token) { if (token == 0 || !CanRushVehicle(runtime, vehicle, playerX, playerY, playerZ, now)) return false; vehicle.ControllerPid = Profile.PlayerPid; vehicle.TaskToken = token; vehicle.HackTaskToken = token; vehicle.HackTaskStatus = 0; vehicle.HackBusyUntilUtc = now.AddSeconds(2); return true; } [Handler(MethodId.AskHackVehicle, HandlerPacketKind.Invoke)] private async Task AskHackVehicle(Connection conn, UxRpcMessage msg) { var state = GetWorldState(msg.Context); if (!CharacterCapabilities.CanHack(state.ActiveSpiritTemplateId) || !state.HasLastReportedPlayerTransform || !TryDecodeVehicleControl(msg.Body, out var vehicleId, out var action, out var targetId)) { conn.Log.Warn($"[VEHICLE-HACK] rejected invalid request template={state.ActiveSpiritTemplateId} bytes={msg.Body.Length}"); await conn.ReturnEmptyAsync(msg, 1); return; } var result = await ExecuteVehicleControlAsync(conn.Session, vehicleId, action, targetId); if (!result.Ok) conn.Log.Warn($"[VEHICLE-HACK] rejected action={action} entity={vehicleId}: {result.Message}"); await conn.ReturnEmptyAsync(msg, result.Ok ? 0 : 1); } internal static bool IsAllowedHackTaskTransition(byte current, byte next) => current switch { 0 => next is 1 or 4 or 5, // Pending -> Running / Abort / Override 1 => next is 2 or 3 or 4 or 5, 3 => next is 1 or 4 or 5, // Resume, or terminate a paused task. _ => false, // Terminal tasks cannot be restarted by stale packets. }; internal static bool TryApplyHackTaskStatus(VehicleRecord vehicle, ulong token, byte next) { if (token == 0 || token != vehicle.HackTaskToken || token != vehicle.TaskToken || !IsAllowedHackTaskTransition(vehicle.HackTaskStatus, next)) return false; vehicle.HackTaskStatus = next; return true; } [Handler(MethodId.AskUpdateVehicleAITaskStatus, HandlerPacketKind.Notify)] private async Task AskUpdateVehicleAITaskStatus(Connection conn, UxRpcMessage msg) { // Native wire: vehicle u64 + task token u64 + VehicleAIStatus byte, no marker. // The stock client requests Running(1), but keeps its task Pending until // SyncChangeVehicleAITaskState acknowledges the SAME vehicle and task token. if (msg.Body.Length != 17 || !msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[VEHICLE-HACK] invalid AI status packet bytes={msg.Body.Length}"); return; } var runtime = GetVehicleRuntime(msg.Context); await runtime.HackDispatchGate.WaitAsync(); try { bool accepted; bool isAutoDrive; bool isTrafficCruise; lock (runtime.SyncRoot) { accepted = runtime.Vehicles.TryGetValue(args.VehicleEntityId, out var vehicle) && TryApplyHackTaskStatus(vehicle, args.TaskToken, args.Status); // ★ 2026-09-28:自驾下发的 Cruise 任务也走这条回路。之前只认骇入的 // HackTaskToken,自驾 token 被当成陌生包 ignore ⇒ 任务卡在 Pending ⇒ 车不动。 isAutoDrive = !accepted && TryApplyAutoDriveTaskStatus(runtime, args.TaskToken, args.Status); accepted |= isAutoDrive; // ★ 2026-10-01 方案 A:**车流 Cruise** 任务也走这条回路。 // 不认领的话它们被当成陌生包丢掉(日志刷 `ignored stale/... status=2`), // 于是车开到目标点后服务端不知道,只能干等 trafficCruiseRefreshSec 才续发。 // 认领后车流侧会把刷新时刻归零 ⇒ 下一 tick 立刻续发目标点。 isTrafficCruise = !accepted && TryApplyTrafficCruiseStatus(msg.Context.Session, args.TaskToken, args.Status); accepted |= isTrafficCruise; } if (!accepted) { conn.Log.Info($"[VEHICLE-HACK] ignored stale/duplicate/unowned AI status entity={args.VehicleEntityId} token={args.TaskToken} status={args.Status}"); return; } await conn.NotifyAsync(MethodId.SyncChangeVehicleAITaskState, args); conn.Log.Info($"{(isTrafficCruise ? "[TRAFFIC]" : isAutoDrive ? "[VEHNAV]" : "[VEHICLE-HACK]")}" + $" AI status acknowledged entity={args.VehicleEntityId} token={args.TaskToken} status={args.Status}"); // ★ 2026-10-03:召唤车「从车道开过来」到达(VehicleAIStatus=2 Success)之后收尾: // 撤 AI 任务 → 关自驾状态 → 位置对齐 → 重开交互。 // 不做 ⇒ 车开不动 / 下车后再上不去 / "叫车小助手忙"叫不了第二台 // (三个症状同源:召唤演出从没结束过,见 // 的根因注释)。 // ⛑ 只认「自驾通道 + 状态 2」,且 helper 内部再校验这是不是当前那台召唤车 // ⇒ 出租车 / 车流 / 骇入的到达都不受影响。 if (isAutoDrive && args.Status == 2) await CompleteSummonDriveInAsync(conn, runtime, args.VehicleEntityId); } finally { runtime.HackDispatchGate.Release(); } } }