Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.VehicleHacking.cs
T

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