Files

1155 lines
62 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 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).
/// </summary>
internal sealed partial class GameRouter
{
internal static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, VehicleRecord> GlobalVehicles = new();
internal sealed record SummonedVehicleSnapshot(ulong EntityId, uint ConfigId, float X, float Y, float Z, float Yaw, bool HasFix);
internal static List<SummonedVehicleSnapshot> 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;
/// <summary>
/// <see cref="_lastInteractTargetUnitId"/> 的<b>静态镜像</b>,在下面同一处赋值。
///
/// 为什么要有两份:<c>_lastInteractTargetUnitId</c> 是实例字段,而
/// <c>GameRouter.CollectPoliceTargets</c>(GM 面板 / <c>/api/police/targets</c>)是静态方法,
/// 拿不到实例 ⇒ 只能读镜像。用镜像而不是把原字段改成 static,是为了不动
/// <c>AskInvitePlayerInteractionAction</c> 那条既有链路的可见性语义。
/// </summary>
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;
/// <summary>Call when despawning NPCs or resetting state to abort any active interaction monitor.</summary>
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<byte> LastBits = [];
public bool MoveLogged;
public double DistanceDriven;
public bool IsDriving;
public float LastNitroValue;
public float TopSpeed;
public bool HasTopSpeed;
public Dictionary<byte, ulong> SeatReservations { get; } = new();
public Dictionary<byte, ulong> 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<uint> Unlocked { get; } = new();
public Dictionary<ulong, VehicleRecord> Vehicles { get; } = new();
public ulong SummonedEntity;
public ulong CurrentVehicleId;
public int CurrentVehicleSeat = -1;
/// <summary>
/// 自驾状态(<c>GameRouter.VehicleNav.cs</c>,2026-09-27)。
/// 客户端的自动驾驶是「一辆车」的开关:开关状态只由 S2C
/// <c>SyncVehicleAutonomousDrivingState</c> 决定(<c>DriveVehiclesManager.autoDrivingVehicleDict</c>),
/// 所以服务端必须记住「哪辆车正开着」,才能在 Stop / 下车时回推正确的 false。
/// 存 session 而不是持久化:重进场景客户端会重新发 Start。
/// </summary>
public ulong AutoDrivingVehicleId;
public bool AutoDriving;
/// <summary>
/// 自驾下发的最后一个 AI 任务 token(2026-09-28 第三轮)。
/// ⛑️ 必须记住它:客户端收到 SyncAssignVehicleAITask 后会回 AskUpdateVehicleAITaskStatus,
/// 但**会一直把任务停在 Pending,直到服务端用 SyncChangeVehicleAITaskState 确认同一个
/// entity + token**。不确认 ⇒ 任务永远不进 Running ⇒ 车纹丝不动。
/// </summary>
public ulong AutoDriveTaskToken;
public byte AutoDriveTaskStatus;
public bool AutoDriveHasTarget;
public float AutoDriveTargetX;
public float AutoDriveTargetY;
public float AutoDriveTargetZ;
/// <summary>
/// 上次真正下发的巡航点集(2026-09-30,GM 调速用)。
/// 改速度必须重发 Cruise 任务(速度写在任务参数里),而重发需要点集;
/// 现算要走 <c>SolveRoute</c>,那是实例方法且要 Connection,GM 侧两者都没有。
/// ⇒ 下发时缓存一份,热应用直接复用,顺带保证「换速度不换路线」。
/// </summary>
public List<SceneMethods.UxVector3>? AutoDriveLastPoints;
/// <summary>上次下发时用的速度(km/h)。值没变就不重发,避免 GM 手抖连点刷包。</summary>
public float AutoDriveSpeedKmh;
/// <summary>
/// 出租车行程(2026-09-30 新增,<c>GameRouter.Taxi.cs</c>)。<b>null = 当前没有行程</b>。
///
/// <para>⛑ 只记「服务端自己派发的那一次」。客户端的原生出租车 UI(选目的地面板、
/// 到达提示)状态全在客户端,服务端只负责:认单 → 派 Cruise → 判到达 → 收车费。</para>
///
/// <para>⛑ 与 <see cref="AutoDriveTaskToken"/> 的关系:出租车复用自驾那条
/// <c>SyncAssignVehicleAITask</c> 通道,所以行程的 token 就是 <c>AutoDriveTaskToken</c>;
/// 本字段额外记住「这趟是出租车」+ 目的地 + 起始里程,用来算车费和判到达。</para>
/// </summary>
public TaxiTrip4229938? TaxiTrip;
/// <summary>
/// 玩家最近<b>开过</b>的车(上车时写,下车不清)。改装店事务要它:位移前客户端已经
/// 把玩家自动请下车了(retail 的 SetSeatInteractOperation(0,6)),此时
/// <see cref="CurrentVehicleId"/> 已被客户端的 exit 通知清零,只有这个还记得是谁。
/// </summary>
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<long> StoryPendingRpcIds { get; } = new();
public HashSet<long> 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<GameMethods.PlayerVehicleClientDetail4229938> 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 记录与日志,不再触发盘查。
/// <summary>
/// Interact heartbeat/sync channel. Decoded + logged so a future F-press via the
/// interact system is visible; currently accepted without side effects.
/// </summary>
[Handler(MethodId.AskInteractCmd, HandlerPacketKind.Notify)]
private async Task AskInteractCmd(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskInteractCmdArgs4229938>(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<uint> { 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<byte>(), 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<uint> { 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<byte>());
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<GameMethods.AskSummonVehicleArgs4229938>(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 任务
// 开到玩家身边的**路边**停好(终点取车道上离玩家最近的点,不是玩家脚下)。
//
// 原生本来就有这一半:<c>SummonVehicleResult.TaskToken</c> 回执之后,客户端
// <c>SummonVehicleManager</c> 拿同一个 token 去 <c>RegisterTaskAIStartedEvent</c>
// ⇒ 命中才播 <c>OnSummonShowStart</c>。所以我们给到达任务<b>复用同一个 token</b>。
//
// ⛑⛑ 这里任何一步失败(没车道 / 室内 / 孤岛车道 / 落车失败)都<b>退回旧路径</b>
// (刷在身边 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,
});
}
/// <summary>
/// 召唤车「开过来」到达之后,把权威位置钉死在<b>它现在真正的位置</b>(2026-10-03 实机修瞬移)。
///
/// <para>症状:车能开到身边,玩家一上车就瞬移回生成点。</para>
///
/// <para>根因:服务端在生成时下发了 <c>SyncAetherAIVehicleAddData</c>(<c>ClientVehicleInitData</c>),
/// 里面写的是<b>生成点坐标</b>且 <c>LaneHandle=-1</c>(不参与车道推进)⇒ 这份 Aether 坐标
/// 之后再也没人更新。车是被 <c>SyncAssignVehicleAITask</c> 的 Cruise(Raid 通道)开过来的,
/// 客户端两套表示就此分叉。玩家上车时客户端拿 Aether 那份数据对齐实体 ⇒ 车被拽回生成点。
/// 服务端上车那几秒<b>一条 S2C 位置包都没发</b>(14:46:42–45 全是 C2S),所以不是我们回填错了,
/// 是客户端自己的那份旧值。</para>
///
/// <para>对策:<c>SyncTeleportVehicle</c>(68088911) 是 Raid 通道的权威位置包(赛车 R309 /
/// 改装店钉车 / 传送都在用,<c>Reset=true</c> = 客户端直接落位不插值)。位置取
/// <c>AskVehicleMove</c> 上报的最新值 —— AI 开车期间客户端每秒都在报,所以这一发
/// 视觉上是「原地不动」,只是把两边强行对齐。</para>
/// </summary>
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}");
}
/// <summary>
/// 召唤载具的「开过来」那一半(2026-10-03,<see cref="SummonDriveInSettings"/>)。
///
/// <para>三步,<b>顺序不能改</b>:
/// <list type="number">
/// <item><c>SyncAetherAIChangeVehicleControl(2)</c> —— 把车切回 <b>RaidVehicle</b>。
/// 客户端会把有 VDE 的车交给 Aether 车道模拟,位移每帧被车道覆盖 ⇒ Cruise 变成
/// 「轮胎在转、车在原地进行」(<c>GameRouter.Taxi.cs:147-166</c>,出租车踩过的)。</item>
/// <item><c>SyncVehicleAutonomousDrivingState(true)</c> —— 必须<b>早于</b> AI 任务,
/// 否则客户端把这条任务当成「别人的车被接管」渲染 ⇒ 车门消失 / 角色缺模
/// (2026-09-30 §43.1)。</item>
/// <item><c>SyncAssignVehicleAITask</c> + Cruise,<b>token 复用召唤回执那个</b>。</item>
/// </list>
/// </para>
///
/// <para>⛑ 终点是「车道上离玩家最近的点」(靠边停车),不是玩家脚下:自定义标点吸附失败时
/// 客户端一条 Cruise 都不下发,表现是「叫了车没反应」。解不出车道路径就只给终点,
/// 交给客户端 C# <c>NavigationHelper</c> 自己导航(自驾已验证能到任意 POI)。</para>
/// </summary>
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<SceneMethods.UxVector3> { 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");
}
/// <summary>
/// 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.
/// </summary>
/// <param name="exactPos">
/// 给了就<b>原样</b>落在这(只 +0.15m 抬高),不再做「玩家右手边 5.5m」的偏移。
/// 改装店换展示车必须走这条:工位在地下(y&lt;0),按玩家朝向偏移会把车摆进墙里
/// (donor 13:38 实测「车在你看的地方/卡墙」)。
/// </param>
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<GameMethods.PlayerVehicleDriveStateInfo4229938>(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<GameMethods.PlayerVehicleDriveStateInfo4229938>(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<GameMethods.RaidVehicleSyncData4229938>(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);
}