using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using NavMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 载具路线规划 / 自驾(2026-09-24 移植自第九份捐赠端 GameRouter.VehicleNav.cs,但图换成我们自己的)。 /// /// ★★ 为什么值得做:`logs/unknown-methods.log` 里今天 06:05 UTC 连着 **89 次** /// `AskVehicleNavigationPathPointsFromPos`(每秒一次,起点在动、终点固定)——玩家正在开车跑导航, /// 而我们每条都回的是 `DefaultReturnCatalog` 的 typed default:**空点集 + 长度 0**。 /// 也就是客户端在问「怎么开过去」,服务端一直答「不知道」。 /// /// ★★ 为什么不搬捐赠端的 `vehicle_nav_graph.bin`(2.36MB):那份是他们自己 generator 从另一份 /// 数据打的私有格式(magic `ANGV` / ver 2 / 每车道 40B),provenance 不可考;我们手上已经有 /// `vehicle_lanes.csv`(14171 条) + `vehicle_lane_links.csv`(多值后继覆盖 98.2%,见 /// `GameRouter.VehicleLanes.cs` 头注),用同一份数据算出来的路线才是自洽的。 /// /// ⛑️ 我们这份表的几何是「弦」不是真实样条 —— `_gen_vehicle_lanes.py` 压掉了中间点。 /// 所以画出来的导航线是折线、切内弯;想提高精度要把 lane 折线点补回来,不是改这里。 /// ⛑️ 只走**前向**后继(本帧生成车道的方向),与 T3-7 车流 `Advance` 的规则一致; /// 客户端这次带的是 `IgnoreDirection=true`,它接受单方向绕行,不构成违约。 /// ⛑️ 回滚开关 = `gameplay.vehicleNavigation.enabled=false` ⇒ 逐字节回到空结果(见 EmptyResult)。 /// internal sealed partial class GameRouter { private static VehicleNavigationSettings VehicleNavCfg => PrivateServerConfigStore.Current.Gameplay.VehicleNavigation; private static NavMethods.VehicleNavResult4229938 EmptyResult(uint navReqId) => new() { NavReqId = navReqId, Points = [], CenterPoints = [] }; /// /// 当前生效的巡航速度(米/秒)。 /// /// 优先级:config/autonomous-driving.json(GM 面板写的) > gameplay.vehicleNavigation.cruiseSpeed。 /// 前者不存在 = 没在 GM 里调过速 ⇒ 完全用主配置的值,与 2026-09-30 之前逐字节一致。 /// ⛑ 单位换算(km/h → m/s)只在这里做; 对外一律 km/h。 /// ⛑ 文件被写坏时不能让玩家开不了车:吞掉异常退回主配置,GM 重新保存即可恢复。 /// private static float EffectiveCruiseSpeedMs() { try { if (File.Exists(AutonomousDriveSpeedSettings.FilePath)) return AutonomousDriveSpeedSettings.Read() / 3.6f; } catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) { // 同上:坏配置只影响「用哪个速度」,不影响「能不能开车」。 } return VehicleNavCfg.CruiseSpeed; } // ----------------------------------------------------------------------------------------- // AskVehicleNavigationPathPoints (67317431) — 起点由服务端自己找(最后一次上报的位置) // ----------------------------------------------------------------------------------------- [Handler(MethodId.AskVehicleNavigationPathPoints)] private Task OnAskVehicleNavigationPathPoints(Connection conn, UxRpcMessage msg) { if (!VehicleNavCfg.Enabled) return AnswerNavAsync(conn, msg, EmptyResult(0), null); if (!msg.TryGetArgs(out var args) || args is null) { LogNavOnce(conn, "[VEHNAV] AskVehicleNavigationPathPoints 参数解析失败,回空结果", msg.Body.Length); return AnswerNavAsync(conn, msg, EmptyResult(0), null); } var (sx, sy, sz) = CurrentVehiclePosition(msg); return AnswerNavAsync( conn, msg, EmptyResult(args.NavReqId), SolveRoute(conn, sx, sy, sz, args.TargetPosition.X, args.TargetPosition.Y, args.TargetPosition.Z, args.NeedCenterPoints && VehicleNavCfg.IncludeCenterPoints, "AskVehicleNavigationPathPoints")); } // ----------------------------------------------------------------------------------------- // AskVehicleNavigationPathPointsFromPos (67650626) — 客户端自带起点(今天实际在用的就是这条) // ----------------------------------------------------------------------------------------- [Handler(MethodId.AskVehicleNavigationPathPointsFromPos)] private Task OnAskVehicleNavigationPathPointsFromPos(Connection conn, UxRpcMessage msg) { if (!VehicleNavCfg.Enabled) return AnswerNavAsync(conn, msg, EmptyResult(0), null); if (!msg.TryGetArgs(out var args) || args is null) { LogNavOnce(conn, "[VEHNAV] AskVehicleNavigationPathPointsFromPos 参数解析失败,回空结果", msg.Body.Length); return AnswerNavAsync(conn, msg, EmptyResult(0), null); } var start = args.StartPosition; if (start.X == 0f && start.Z == 0f) (start.X, _, start.Z) = CurrentVehiclePosition(msg); return AnswerNavAsync( conn, msg, EmptyResult(args.NavReqId), SolveRoute(conn, start.X, start.Y, start.Z, args.TargetPosition.X, args.TargetPosition.Y, args.TargetPosition.Z, args.NeedCenterPoints && VehicleNavCfg.IncludeCenterPoints, "AskVehicleNavigationPathPointsFromPos")); } // ----------------------------------------------------------------------------------------- // AskVehicleNavigationPathLength (67519267) — 只要里程(float) // ----------------------------------------------------------------------------------------- [Handler(MethodId.AskVehicleNavigationPathLength)] private Task OnAskVehicleNavigationPathLength(Connection conn, UxRpcMessage msg) { if (!VehicleNavCfg.Enabled) return conn.ReturnAsync(msg, 0f); if (!msg.TryGetArgs(out var args) || args is null) return conn.ReturnAsync(msg, 0f); var (sx, sy, sz) = CurrentVehiclePosition(msg); var route = SolveRoute(conn, sx, sy, sz, args.TargetPosition.X, args.TargetPosition.Y, args.TargetPosition.Z, needCenter: false, "AskVehicleNavigationPathLength"); return conn.ReturnAsync(msg, route is { Ok: true } ok ? ok.Length : 0f); } // ----------------------------------------------------------------------------------------- // 自驾开关 Start / Stop / ChangeTarget / CancelTarget // // ★★ 2026-09-27 修「点了自动驾驶车不动」。根因不在 Start 的回执 —— 客户端的自动驾驶 // 开关是 **服务端推下来的 S2C**,不是本地状态: // BaseVehicleControllerStore.OnAutoDriveBtnClick → AskVehicleStartAutonomousDriving // GameSceneToClientImpl:1438 SyncVehicleAutonomousDrivingState(u64 车, bool isStart) // → DriveVehiclesManager.autoDrivingVehicleDict[车] = true / nil // → BaseVehicleControllerStore.SetAutoDriving(GetVehicleInAutonomousDriving(车)) // 真正的循迹 AI 在客户端 C# 的 LX6.Drive.DriveManager(包是 LuaAndCSharp,两边都收)。 // ⇒ 只要服务端不发 68349264,UI 上那个按钮按下去会立刻弹回来、车纹丝不动。 // ⇒ 反过来也解释了为什么 previously「挂了 Start/Stop 回执」毫无效果:回执只影响 // `AutoDrive.AskWait`,不影响 Driving 状态。 // // ⛑️ 78636393(CancelTarget) 之前压根没注册 ⇒ 玩家在地图上删掉导航点时会 INVOKE 无回执、await 卡死(规则 84)。 // ⛑️ 服务端**不负责每帧开车**:车还是客户端本地物理。服务端只管「给不给自动驾驶许可」。 // 真要让另一端的观众也看到车在动,是另一件事(要服务端推位置环回),本次不做。 // ----------------------------------------------------------------------------------------- [Handler(MethodId.AskVehicleStartAutonomousDriving)] private async Task OnAskVehicleStartAutonomousDriving(Connection conn, UxRpcMessage msg) { var runtime = GetVehicleRuntimeState(msg.Context.Session); if (!msg.TryGetArgs(out var args) || args is null) { LogNavOnce(conn, "[VEHNAV] AskVehicleStartAutonomousDriving 参数解析失败", msg.Body.Length); await conn.ReturnEmptyOkAsync(msg); return; } ulong vehicleId; lock (runtime.SyncRoot) { vehicleId = runtime.CurrentVehicleId; runtime.AutoDriveHasTarget = args.HasValidTargetPosition; runtime.AutoDriveTargetX = args.TargetPosition.X; runtime.AutoDriveTargetY = args.TargetPosition.Y; runtime.AutoDriveTargetZ = args.TargetPosition.Z; } conn.Log.Info($"[VEHNAV] start autonomous driving vehicle={vehicleId} " + $"validTarget={args.HasValidTargetPosition} " + $"target=({args.TargetPosition.X:0.#},{args.TargetPosition.Y:0.#},{args.TargetPosition.Z:0.#})"); if (vehicleId == 0) { // 没在车上就没法说「哪辆车开始自动驾驶」——这不构成协议错误,回 Ok 让按钮解锁即可。 LogNavOnce(conn, "[VEHNAV] 收到 Start 但当前不在任何车上,不下发自动驾驶状态", 0); await conn.ReturnEmptyOkAsync(msg); return; } if (!VehicleNavCfg.AutonomousDrivingEnabled) { LogNavOnce(conn, "[VEHNAV] 自驾已由 gameplay.vehicleNavigation.autonomousDrivingEnabled=false 关闭", 0); await conn.ReturnEmptyOkAsync(msg); return; } // 先把玩家的三个开关同步一次:这样 UI 的 blocked/interactable 有确定值, // 而不是靠 lua 里 nil 的巧合。三个全 false = 「允许自驾、不锁沉浸模式」。 await PushPlayerAutonomousStateAsync(conn); // ★★ 真正让车动起来的一步(2026-09-28 第二轮)。 // 第一轮只推了状态包,结果「能开自动驾驶但车不动」——因为 68349264 只管 UI 与许可, // 车的循迹由 SyncAssignVehicleAITask 的 **Cruise 任务**驱动,而它带 TargetPointList // (VehicleCruiseParameters4229938),正是「沿一串点巡航」。路径就用 SolveRoute 算的。 var (sx, sy, sz) = VehiclePositionFor(runtime, vehicleId, msg); var route = SolveRoute(conn, sx, sy, sz, args.TargetPosition.X, args.TargetPosition.Y, args.TargetPosition.Z, needCenter: false, "auto-drive-cruise"); if (route is { Ok: true } ok && ok.DrivePoints.Count >= 2) { // ★★ 2026-09-30:状态包必须**先于** AI 任务下发。 // 捐赠端 `StartAutonomousDriving4229938` 的顺序是 controller → PublishAutoDriveState // → SyncAssignVehicleAITask;而我们以前是发完任务、确认完才补状态包。 // 客户端只有先进入「自动驾驶中」才会按自动驾驶那套表现搭好车内视角/角色动作, // 否则它就是收到一个**陌生人发来的 AI 任务**(和骇入自动驾驶同一条通道), // 表现层可能按「AI 接管」处理 —— 这正是「车门消失 / 角色缺模 / 没驾驶动作」的嫌疑点。 await PushVehicleAutonomousStateAsync(conn, vehicleId, true); await AssignAutonomousCruiseAsync(conn.Session, runtime, vehicleId, ok.DrivePoints); lock (runtime.SyncRoot) { runtime.AutoDriving = true; runtime.AutoDrivingVehicleId = vehicleId; } } else { // 解不出路就别点亮 UI:否则玩家看到「自动驾驶中」而车不动,比不点亮更迷惑。 // ⛑⛑ 2026-09-30 顺手修一个真 bug:以前 `PushVehicleAutonomousStateAsync(true)` // 写在 if/else **外面**,于是这条日志说「不点亮」、下一行照样点亮了。 // 表现就是「按了自动驾驶,按钮亮了,车纹丝不动」。 LogNavOnce(conn, "[VEHNAV] 自驾路线解不出来,不点亮自动驾驶状态(见上一条车道/图日志)", 0); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskVehicleStopAutonomousDriving)] private async Task OnAskVehicleStopAutonomousDriving(Connection conn, UxRpcMessage msg) { var runtime = GetVehicleRuntimeState(msg.Context.Session); ulong vehicleId; lock (runtime.SyncRoot) { vehicleId = runtime.AutoDrivingVehicleId != 0 ? runtime.AutoDrivingVehicleId : runtime.CurrentVehicleId; runtime.AutoDriving = false; runtime.AutoDrivingVehicleId = 0; // 已经不在自驾了:丢掉巡航缓存,免得下次 GM 调速拿着过期路线重发。 runtime.AutoDriveLastPoints = null; runtime.AutoDriveSpeedKmh = 0f; } conn.Log.Info($"[VEHNAV] stop autonomous driving vehicle={vehicleId}"); if (vehicleId != 0) { // 撤掉 AI 任务(action 0 = 停止),否则车会继续跑完下发的巡航路线。 await AssignAutonomousStopAsync(conn.Session, runtime, vehicleId); await PushVehicleAutonomousStateAsync(conn, vehicleId, false); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskVehicleChangeAutonomousDrivingTarget)] private async Task OnAskVehicleChangeAutonomousDrivingTarget(Connection conn, UxRpcMessage msg) { var runtime = GetVehicleRuntimeState(msg.Context.Session); if (!msg.TryGetArgs(out var args) || args is null) { LogNavOnce(conn, "[VEHNAV] AskVehicleChangeAutonomousDrivingTarget 参数解析失败", msg.Body.Length); await conn.ReturnEmptyOkAsync(msg); return; } var (tx, ty, tz) = (args.TargetPosition.X, args.TargetPosition.Y, args.TargetPosition.Z); lock (runtime.SyncRoot) { runtime.AutoDriveHasTarget = true; runtime.AutoDriveTargetX = tx; runtime.AutoDriveTargetY = ty; runtime.AutoDriveTargetZ = tz; } conn.Log.Info($"[VEHNAV] change autonomous target=({tx:0.#},{ty:0.#},{tz:0.#})"); // ★★ 2026-09-30 修「开到一半换终点没反应,必须等跑完当前点」。 // 客户端 lua(BaseVehicleControllerStore.lua:600 OnVehicleNavTargetChange)在换/删导航点 // 时只发这一条 Change,**不会** Stop 再 Start。而我们以前这里只记账、不下发任何东西 // ⇒ 车继续开向老终点,玩家只能手动停了再开。现在:正在自驾就立刻换掉 Cruise 任务。 await RetargetAutonomousDrivingAsync(conn, runtime, msg, tx, ty, tz); await conn.ReturnEmptyOkAsync(msg); } /// /// 把正在进行的自动驾驶改道到新终点( 用)。 /// /// ⛑ 只重算 Cruise,**不重推** SyncVehicleAutonomousDrivingState:捐赠端那句 /// "Keep the native autonomous HUD active; replace only our own Cruise." 就是这个意思 —— /// 重推会闪一次 UI,且客户端本来就在自动驾驶状态里。 /// ⛑ 改道起点用车当前位置(),不是原来的出发位置。 /// ⛑ 解不出新路时保持现状(继续开向老终点)而不是停手:中途抛锚比开到旧目的地更难受, /// 玩家可以自己按 Stop。这与捐赠端不同(他们 no-route 就 Stop),是他们明确的选择差异。 /// private async Task RetargetAutonomousDrivingAsync( Connection conn, VehicleRuntimeState runtime, UxRpcMessage msg, float tx, float ty, float tz) { ulong vehicleId; ulong previousToken; lock (runtime.SyncRoot) { // 没在自驾 ⇒ 什么都不做:下次按下自动驾驶就带着新目标 Start。 if (!runtime.AutoDriving) return; vehicleId = runtime.AutoDrivingVehicleId != 0 ? runtime.AutoDrivingVehicleId : runtime.CurrentVehicleId; previousToken = runtime.AutoDriveTaskToken; } if (vehicleId == 0 || !VehicleNavCfg.AutonomousDrivingEnabled) return; var (sx, sy, sz) = VehiclePositionFor(runtime, vehicleId, msg); var route = SolveRoute(conn, sx, sy, sz, tx, ty, tz, needCenter: false, "auto-drive-retarget"); if (route is not { Ok: true } ok || ok.DrivePoints.Count < 2) { conn.Log.Info("[VEHNAV] retarget 解不出路线,保持当前任务(详见上面的车道/图日志)"); return; } if (previousToken != 0) { await AcknowledgeAutoDriveTaskAsync(conn.Session, vehicleId, previousToken, 4); // Abort conn.Log.Info($"[VEHNAV] retarget abort previous token={previousToken}"); } await AssignAutonomousCruiseAsync(conn.Session, runtime, vehicleId, ok.DrivePoints); conn.Log.Info($"[VEHNAV] retarget applied vehicle={vehicleId} points={ok.DrivePoints.Count}"); } /// /// AskVehicleCancelAutonomousDrivingTarget(67836393)—— 2026-09-27 补,之前漏注册。 /// 客户端在地图上删掉导航点时发,是 INVOKE ⇒ 不回执会让 lua 的 await 卡住。 /// 语义只是「忘记导航目标」:车照开,只是没了终点(客户端会自己决定何时停下)。 /// [Handler(MethodId.AskVehicleCancelAutonomousDrivingTarget)] private async Task OnAskVehicleCancelAutonomousDrivingTarget(Connection conn, UxRpcMessage msg) { var runtime = GetVehicleRuntimeState(msg.Context.Session); lock (runtime.SyncRoot) runtime.AutoDriveHasTarget = false; conn.Log.Info("[VEHNAV] cancel autonomous target"); await conn.ReturnEmptyOkAsync(msg); } /// /// 自驾起点:优先用车自己上报的位置(AskVehicleMove 写进 VehicleRecord), /// 退化才用玩家位置。Start 请求不带起点,而玩家坐在车里时这两个坐标差着车长, /// 用错了会把起点吸到旁边的车道上。 /// private static (float X, float Y, float Z) VehiclePositionFor( VehicleRuntimeState runtime, ulong vehicleId, UxRpcMessage msg) { lock (runtime.SyncRoot) if (runtime.Vehicles.TryGetValue(vehicleId, out var vehicle)) return (vehicle.Position.X, vehicle.Position.Y, vehicle.Position.Z); return CurrentVehiclePosition(msg); } /// /// 下发「沿路径巡航」的 AI 任务(SyncAssignVehicleAITask + VehicleCruiseParameters)。 /// /// 取证:GameRouter.VehicleControls.cs 的 action 4 就是用这个参数类型(带 /// TargetPointList)让空车倒车挪位;车流/T3-7 与载具骇入都走同一条 /// SyncAssignVehicleAITask 通道。 ⇒ 这就是服务端驱动车动的唯一入口。 /// /// ⛑️ Count 填的是点数量(语义未实测:若车只开到第一个点,改成 1 再试)。 /// /// ⛑️⛑️ 2026-09-30 两处按捐赠端 v0.5-2 对齐(都有回滚开关,见 PrivateServerConfig): /// ① DrivingFlags:以前只留 DFStopForCars(1) ⇒ 车不会绕开静止车辆/行人/物件, /// 前面一停就顶死,也不等红灯。现在默认 /// = 1|2|8|16|32|128|16384(枚举语义见 ux_rpc/payload_types.dump.cs:193)。 /// ② TargetPointList:默认只给终点一个点(), /// 让客户端 C# 的 NavigationHelper 自己导航 —— 我们这份车道几何是「弦」不是样条, /// 喂整串点会切内弯。PathFindFlags=0(正向,按道路方向)。 /// /// 而不是 Connection:GM 面板没有 Connection, /// 热应用调速()要复用同一条下发路径。 /// /// /// 下发 Cruise(沿一串点巡航)任务。自驾与出租车共用这一条通道 /// (2026-09-30:出租车接入)。 /// /// /// 速度覆盖(m/s)。null = 用自驾配置的速度()。 /// 出租车必须传自己的值:正常 15、加速 25(捐赠端 BuildTaxiDriveTask4229938 /// 原值),不能跟着 GM 的自驾调速走 —— 那会让「加速」按钮和 GM 滑条互相打架。 /// /// 复用一个已经有了的 token,而不是自己 mint 一个新的(2026-10-03,召唤载具开过来)。 /// 客户端 SummonVehicleManager 在 AskSummonVehicle 回执里拿到 /// SummonVehicleResult.TaskToken 后就用它 RegisterTaskAIStartedEvent(token) /// —— 两边对不上,原生那套「召唤演出」永远不会触发。⇒ 那台车的到达任务必须带同一个 token。 /// /// private static async Task AssignAutonomousCruiseAsync( TcpSession session, VehicleRuntimeState runtime, ulong vehicleId, List path, float? speedMsOverride = null, ulong? tokenOverride = null) { var cfg = VehicleNavCfg; var speedMs = speedMsOverride ?? EffectiveCruiseSpeedMs(); List points; if (cfg.CruiseSingleTarget) points = [path[^1]]; // 只给终点,交给客户端导航 else points = cfg.CruiseMaxPoints > 0 && path.Count > cfg.CruiseMaxPoints ? Decimate(path, cfg.CruiseMaxPoints) : path; var token = tokenOverride ?? unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor)); var packet = UxSerializer.Serialize( new GameMethods.AssignVehicleControl4229938 { VehicleUId = vehicleId, Parameters = new GameMethods.VehicleCruiseParameters4229938 { TargetPointList = points, CruiseType = 0, // GoToOneTarget Count = points.Count, ConfigFlags = 4, // CF_SlowDownWhenArriveTarget PathFindFlags = 0, // 正向,按道路方向 MaxSpeed = speedMs, ArrivalDistance = cfg.CruiseArrivalMeters, // 捐赠端显式 false:true 会让车按「离终点还有多远」压速度,GM 拉的 MaxSpeed 就不准了。 AdaptSpeedToTargetDistance = false, Base = new GameMethods.VehicleTaskTail4229938 { Token = token, DefaultSpeed = speedMs, DrivingFlags = cfg.CruiseDrivingFlags, }, }, }); // 主控权交给服务端:不给的话玩家的方向盘输入会盖掉 AI 任务。 await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize( new GameMethods.SyncChangeVehicleController4229938 { vehicleInstanceId = vehicleId, controllerPid = Profile.PlayerPid, }), CancellationToken.None); lock (runtime.SyncRoot) { runtime.AutoDriveTaskToken = token; runtime.AutoDriveTaskStatus = 0; // Pending:等客户端请求 Running,我们再确认。 // 缓存「这次真正下发了什么」,供 GM 热应用调速原样复用(换速度不换路线)。 runtime.AutoDriveLastPoints = new List(points); runtime.AutoDriveSpeedKmh = speedMs * 3.6f; } await session.NotifyAsync(MethodId.SyncAssignVehicleAITask, packet, CancellationToken.None); // ⛔ 这里曾经有一段 `SyncAetherAIVehicleForceGo(forceGo=true)`(2026-09-30 加,同日删除): // 当时怀疑「车门消失 / 角色缺模」是车被降级成 Aether 简化表示。已证伪 —— // 真因是状态包必须在 AI 任务之前下发(见本文件 Start handler 的注释)。 // ⛑ 别再把 `GameRouter.Traffic.cs` 里车流那条 forceGo=false 也删了,那个是有用的。 session.Log.Info($"[VEHNAV] cruise assigned entity={vehicleId} token={token} " + $"points={points.Count} speed={speedMs * 3.6f:0.#}km/h flags={cfg.CruiseDrivingFlags} " + $"bytes={packet.Length}"); // ★★ 主动确认一次(2026-09-28 第三轮)。 // 第二轮的症状:包发出去了、客户端也回了 status=1/2,但车不动 —— 因为 // AskUpdateVehicleAITaskStatus 的 handler 只认骇入的 HackTaskToken,把自驾 token // 当成陌生包 ignore 了。而客户端「keeps its task Pending until SyncChangeVehicleAITaskState // acknowledges the SAME vehicle and task token」(见 VehicleHacking.cs 的注释)。 // ⇒ 任务从没被确认 ⇒ 车不动。这里先主动推一次 Running(1),响应式确认在 // AskUpdateVehicleAITaskStatus 里也补上了(双保险,幂等)。 await AcknowledgeAutoDriveTaskAsync(session, vehicleId, token, 1); } /// /// GM 实时调速(2026-09-30,/api/vehicle/autodrive-speed)。 /// /// ① 先落盘 config/autonomous-driving.json(一定执行,与有没有在开车无关); /// ② 再对正在自动驾驶的那辆车热应用:撤回旧任务 → 用新速度重建 → 再确认一次。 /// /// ⛑ 返回 false 只代表「这次没法热应用」(没在车上 / 没在自驾 / 上次点集没缓存), /// 速度仍然已经保存,下一次按自动驾驶就会生效 —— 别把它当失败提示给用户。 /// /// ⛑⛑ 为什么复用 AutoDriveLastPoints 而不是重算路线:SolveRoute 是实例方法 /// (要 Connection + LogNavOnce),GM 这条路径上根本没有 Connection。 /// 缓存还有个好处:换速度时路线逐点不变,不会因为重算而突然改道。 /// internal static async Task ApplyAutonomousDriveSpeedAsync(TcpSession session, float kmh) { if (!AutonomousDriveSpeedSettings.Valid(kmh)) throw new ArgumentOutOfRangeException(nameof(kmh), $"速度必须为 {AutonomousDriveSpeedSettings.MinKmh:0.#}–{AutonomousDriveSpeedSettings.MaxKmh:0.#} km/h。"); var saved = AutonomousDriveSpeedSettings.Save(kmh); var runtime = GetVehicleRuntimeState(session); ulong vehicleId; ulong previousToken; float previousKmh; List? points; lock (runtime.SyncRoot) { if (!runtime.AutoDriving) return false; vehicleId = runtime.AutoDrivingVehicleId; points = runtime.AutoDriveLastPoints; previousKmh = runtime.AutoDriveSpeedKmh; previousToken = runtime.AutoDriveTaskToken; } if (vehicleId == 0 || points is null || points.Count == 0) return false; // 值没变就不重发:GM 手抖连点会刷出一串同速任务,客户端要一个个 Abort。 if (MathF.Abs(previousKmh - saved) < 0.05f) return true; // 旧任务必须先 Abort(4)。不撤的话客户端会同时挂着两个 Pending 任务, // 车会照旧速度跑完再切 —— 观感上就是「拖了半天才变速」。 if (previousToken != 0) { await AcknowledgeAutoDriveTaskAsync(session, vehicleId, previousToken, 4); session.Log.Info($"[VEHNAV] auto-drive speed abort previous token={previousToken}"); } await AssignAutonomousCruiseAsync(session, runtime, vehicleId, points); session.Log.Info($"[VEHNAV] auto-drive speed hot-applied vehicle={vehicleId} " + $"{previousKmh:0.#}→{saved:0.#} km/h points={points.Count}"); return true; } /// /// 回推 SyncChangeVehicleAITaskState 设置自驾任务状态。客户端靠这个包把任务从 Pending /// 推进到 Running —— **没有这一步车就不会动**。 /// VehicleAIStatus:0 Pending / 1 Running / 2 Success / 3 Pause / 4 Abort / 5 Override /// (客户端枚举,ux_rpc/ux_game_all.dump.cs:181155)。 /// private static async Task AcknowledgeAutoDriveTaskAsync(TcpSession session, ulong vehicleId, ulong token, byte status) { await session.NotifyAsync(MethodId.SyncChangeVehicleAITaskState, UxSerializer.Serialize( new GameMethods.VehicleTaskState4229938 { VehicleEntityId = vehicleId, TaskToken = token, Status = status, }), CancellationToken.None); session.Log.Info($"[VEHNAV] AI task ack entity={vehicleId} token={token} status={status}"); } /// /// 自驾任务的状态机:只要求 token 匹配。客户端会连发 Running(1), /// 所以 1→1 必须被接受(骇入那套 IsAllowedHackTaskTransition 会拒掉重复,自驾不能照抄)。 /// internal static bool TryApplyAutoDriveTaskStatus(VehicleRuntimeState runtime, ulong token, byte next) { if (token == 0 || token != runtime.AutoDriveTaskToken) return false; if (next is 0 or > 5) return false; runtime.AutoDriveTaskStatus = next; return true; } /// 停止 AI 任务(action 0)。与 成对。 private static async Task AssignAutonomousStopAsync(TcpSession session, VehicleRuntimeState runtime, ulong vehicleId) { var token = unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor)); var packet = UxSerializer.Serialize( new GameMethods.AssignVehicleControl4229938 { VehicleUId = vehicleId, Parameters = new GameMethods.VehicleStopParameters4229938 { Base = new GameMethods.VehicleTaskTail4229938 { Token = token, DefaultSpeed = 0f, DrivingFlags = 1, }, }, }); lock (runtime.SyncRoot) { // 停止任务同样要能收到确认,否则车停不下来(同一个 Pending 陷阱)。 runtime.AutoDriveTaskToken = token; runtime.AutoDriveTaskStatus = 0; } await session.NotifyAsync(MethodId.SyncAssignVehicleAITask, packet, CancellationToken.None); session.Log.Info($"[VEHNAV] cruise stop entity={vehicleId} token={token} bytes={packet.Length}"); await AcknowledgeAutoDriveTaskAsync(session, vehicleId, token, 1); } private static Task PushVehicleAutonomousStateAsync(Connection conn, ulong vehicleEntityId, bool isStart) => conn.Session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState, UxSerializer.Serialize(new NavMethods.SyncVehicleAutonomousDrivingState4229938 { vehicleEntityId = vehicleEntityId, isStart = isStart, }), CancellationToken.None); private static Task PushPlayerAutonomousStateAsync(Connection conn) => conn.Session.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerAutonomousDrivingState, UxSerializer.Serialize(new NavMethods.SyncPlayerAutonomousDrivingState4229938 { isInOverrideMode = false, isAutoDrivingBlocked = false, isImmersiveModeBlocked = false, }), CancellationToken.None); // ----------------------------------------------------------------------------------------- // 求解:把两张点吸到车道上,再在车道图上跑一次 Dijkstra(代价 = 米) // ----------------------------------------------------------------------------------------- /// /// = 给导航线的点(可能被 ReversePoints 翻成「终点 → 车」); /// = 给 AI 巡航任务的点,恒为「车 → 终点」正向 /// (2026-09-28:反转只对画线的箭头有意义,喂给 AI 会让车掉头)。 /// private readonly record struct NavRoute( bool Ok, List Points, List Center, List DrivePoints, float Length, int Hops, string Note); private NavRoute? SolveRoute( Connection conn, float sx, float sy, float sz, float tx, float ty, float tz, bool needCenter, string tag) { var cfg = VehicleNavCfg; if (!VehicleLaneGraph.Available) { LogNavOnce(conn, "[VEHNAV] 车道表未载入(vehicle_lanes.csv / vehicle_lane_links.csv 缺失)", 0); return null; } if (!VehicleLaneGraph.TrySnapLane(sx, sz, cfg.StartSnapMeters, out var start)) { LogNavOnce(conn, $"[VEHNAV] {tag} 起点 ({sx:0.#},{sz:0.#}) 在 {cfg.StartSnapMeters:0.#}m 内找不到车道", 0); return null; } if (!VehicleLaneGraph.TrySnapLane(tx, tz, cfg.TargetSnapMeters, out var dst)) { LogNavOnce(conn, $"[VEHNAV] {tag} 终点 ({tx:0.#},{tz:0.#}) 在 {cfg.TargetSnapMeters:0.#}m 内找不到车道", 0); return null; } var hops = Dijkstra(start.Handle, dst.Handle, cfg.MaxExpansions, out var laneList); if (hops == 0) { LogNavOnce(conn, $"[VEHNAV] {tag} 车道图不通 start={start.Handle} target={dst.Handle}", 0); return null; } // 几何:车的实际位置 →(每条车道的末端)→ 终点在最后一条车道上的投影点。 // 里程同理:首条只算「剩下的那截」,中间整条计入,末条只算到投影点为止。 var points = new List(laneList.Count + 2) { new(sx, sy, sz) }; var center = needCenter ? new List(laneList.Count) : []; var length = 0f; var prevX = sx; var prevZ = sz; for (var i = 0; i < laneList.Count; i++) { if (!VehicleLaneGraph.TryLaneGeometry(laneList[i], out var bx, out var by, out var bz, out var ex, out var ey, out var ez, out var laneLength)) continue; if (needCenter) center.Add(new NavMethods.UxVector3((bx + ex) * 0.5f, (by + ey) * 0.5f, (bz + ez) * 0.5f)); if (i == laneList.Count - 1) { var endT = i == 0 ? dst.T - start.T : dst.T; length += laneLength * (i == 0 ? endT : dst.T); AddPoint(points, ref prevX, ref prevZ, dst.X, ty, dst.Z, cfg.MinPointSpacing); break; } length += laneLength * (i == 0 ? 1f - start.T : 1f); AddPoint(points, ref prevX, ref prevZ, ex, ey, ez, cfg.MinPointSpacing); } length = MathF.Max(0f, length); if (points.Count < 2) { LogNavOnce(conn, $"[VEHNAV] {tag} 生成点数不足 ({points.Count})", 0); return null; } var forward = Decimate(points, cfg.MaxPathPoints); var final = new List(forward); // ★ 捐赠端自己探针的结论:下行顺序是「终点 → 车」,不是「车 → 终点」。 // 他们用 [VEHNAV] 线顺序自检 断言过(Points[0] 距目标 <1m、末点距车 <1m)。 // ⛑️ 这一条我们还没实测过,所以做成开关而不是写死。 if (cfg.ReversePoints) final.Reverse(); if (cfg.LogEveryRequest) conn.Log.Info($"[VEHNAV] {tag} start={start.Handle}({start.Distance:0.#}m) target={dst.Handle}" + $"({dst.Distance:0.#}m) hops={hops} length={length:0.#}m points={final.Count} center={center.Count}"); return new NavRoute(true, final, center, forward, length, hops, "ok"); } private Task AnswerNavAsync(Connection conn, UxRpcMessage msg, NavMethods.VehicleNavResult4229938 fallback, NavRoute? route) { if (route is { Ok: true } ok) { fallback.Points = ok.Points; fallback.CenterPoints = ok.Center; } return conn.ReturnAsync(msg, fallback); } private void LogNavOnce(Connection conn, string message, int bytes) { // 开车时客户端每秒问一次:抓不到车道的失败不能每秒刷屏,10 秒最多一条。 const string key = "vehnav.lastWarnTicks"; var now = Environment.TickCount64; if (conn.Session.Items.TryGetValue(key, out var raw) && raw is long last && now - last < 10_000) return; conn.Session.Items[key] = now; conn.Log.Info(bytes > 0 ? $"{message} bytes={bytes}" : message); } /// 服务端最后一次看到的玩家位置,退化时退回世界出生点。 private static (float X, float Y, float Z) CurrentVehiclePosition(UxRpcMessage msg) { var state = GetWorldStateStatic(msg.Context); if (state.HasLastReportedPlayerTransform) { var p = state.LastReportedPlayerPosition; return (p.X, p.Y, p.Z); } var spawn = Profile.WorldSpawn; return (spawn.X, spawn.Y, spawn.Z); } private static void AddPoint(List list, ref float prevX, ref float prevZ, float x, float y, float z, float minSpacing) { if (minSpacing > 0f) { var dx = x - prevX; var dz = z - prevZ; if (dx * dx + dz * dz < minSpacing * minSpacing) return; } list.Add(new NavMethods.UxVector3(x, y, z)); prevX = x; prevZ = z; } /// 点数超过上限时按等距抽稀,首位必有。 private static List Decimate(List source, int limit) { if (limit <= 0 || source.Count <= limit) return source; var result = new List(limit); var last = source.Count - 1; for (var i = 0; i < limit - 1; i++) result.Add(source[(int)MathF.Floor(i * last / (float)(limit - 1))]); result.Add(source[last]); return result; } /// /// 车道图上的前向 Dijkstra(代价 = 米)。返回跳数 0 表示不通, 是 /// 起点→终点的车道序列。第一跳的代价是「起点所在车道剩余里程」,这样同车道起步也算得准。 /// private static int Dijkstra(int startLane, int targetLane, int maxExpansions, out List lanes) { lanes = []; if (startLane == targetLane) { lanes.Add(startLane); return 1; } var dist = new Dictionary(); var prev = new Dictionary(); var queue = new PriorityQueue(); var visited = new HashSet(); VehicleLaneGraph.TryLaneGeometry(startLane, out _, out _, out _, out _, out _, out _, out var firstLength); dist[startLane] = firstLength; queue.Enqueue(startLane, firstLength); var expansions = 0; while (queue.Count > 0 && expansions < maxExpansions) { queue.TryDequeue(out var lane, out var cost); if (cost > 0f && dist.TryGetValue(lane, out var best) && cost > best + 0.01f) continue; if (!visited.Add(lane)) continue; expansions++; if (lane == targetLane) break; foreach (var next in VehicleLaneGraph.NextOf(lane)) { if (!VehicleLaneGraph.TryLaneGeometry(next, out _, out _, out _, out _, out _, out _, out var len)) continue; var candidate = cost + MathF.Max(0.5f, len); if (dist.TryGetValue(next, out var known) && known <= candidate) continue; dist[next] = candidate; prev[next] = lane; queue.Enqueue(next, candidate); } } if (!visited.Contains(targetLane)) return 0; var chain = new List { targetLane }; var cursor = targetLane; while (cursor != startLane) { if (!prev.TryGetValue(cursor, out var parent)) return 0; chain.Add(parent); cursor = parent; if (chain.Count > 4096) return 0; } chain.Reverse(); lanes = chain; return lanes.Count; } }