This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

259 lines
14 KiB
C#
Raw Permalink Normal View History

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(); }
}
}