Files

1156 lines
62 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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);
}