using System; using System.Linq; using System.Threading.Tasks; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; // v2.1: the task contracts (SyncPlayerSingleTaskData4229938 / TaskSpoonViewInfo4229938 / // TaskStateData4229938) live in Methods.Game, not Methods.GameScene like the Lyco fork. using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 原版赛车图加载通道 — SyncPlayerSingleTask(64917604) + TaskSpoonViewInfo. /// /// WHY THIS FILE EXISTS (2026-09-23 evening, "还原大于一切") /// Until now the racing Spoon graph was force-merged by a CLIENT fastpatch that injected /// "<raidId>_RacingDriver" into EnterSceneInfo.SceneSpoonNames. That is NOT the original /// behaviour and we can now prove it: in the only 4 official-server captures we own /// (logs\packets-20260913-*.log; 09-14 onward is our own private server — the discriminator is /// that SyncAetherAIVehicleAddData first appears 09-14 15:41), the official server sends /// SceneSpoonNames: [] AND SceneSpoonMd5s: [] while the player stands inside the racing /// raid 23300888. RaidConfig carries no spoon field either. So the racing graph is NOT delivered /// as a scene spoon. /// /// The remaining native channel is the TASK graph: /// /// SpoonTaskMgr (client, [ExportToLua]) /// TASK_SPOON_FILE_PATH / TASK_RAID_FILE_PATH ← graphs are local files /// GetSpoonTaskFilePath(spRaidId, alias, out fileName) ← keyed by (spRaidId, alias) /// SyncPlayerSingleTask(taskId, TaskViewData, TaskSpoonViewInfo, TaskStateData) ← RPC entry /// LoadTaskWorkActionData(taskId, taskSpoon) → GetTaskWorkActionByTask /// /// TaskSpoonViewInfo = { SpoonMd5, SpRaidId, StartTaskId, EndTaskId, Alias, EventId, /// EventStartTaskId } — and the racing track table authors exactly those two keys inside /// RacingDriverTrackInformationConfig.RaidSpoonName: /// /// "23300888_RacingDriver" → SpRaidId 23300888, Alias "RacingDriver" /// "spoon23301289_RacingDriver" → SpRaidId 23301289, Alias "RacingDriver" /// /// GameRouter.Task.cs already speaks this RPC, but ONLY for the demo story task and with a /// PLACEHOLDER SpoonMd5; the racing task 60000147 was never sent through it. That is the /// documented gap this file closes. /// /// WHAT IT SHOULD FIX /// The 2026-09-23 probes read task.workActions=0 and, in the client log, /// CarRaceManager.lua:291/417 nextRankPoint is nil + counter is nil spam at ~60/s — i.e. /// the racing task has NO work actions, which is exactly what LoadTaskWorkActionData fills in. /// Without them the client cannot build the checkpoint / rank-point list, so the arrow, the GPS track /// and the rank logic are all dead. Those checkpoints are also the only thing that could give the /// racing AI its route, which is why the AI opponents currently spawn but never drive. /// /// IS IT ORIGINAL? /// The RPC, the payload types and the key derivation are all native (client dump + client-generated /// readers). What is NOT original is the CONTENT: we synthesise the task-graph reference from /// RacingDriverTrackInformationConfig because the official server's task-Spoon config /// (TASK_SPOON_FILE_PATH / TASK_RAID_FILE_PATH source) is server-side and we do not have /// it. SpoonMd5 is therefore left empty, and the client then silently ignores the whole /// push (no error, no graph, workActions = 0). The real md5 is the one genuinely missing piece. /// /// ⛔ 已证伪的两个旧说法(别再用): /// 1. 「空 md5 客户端会容忍,只警告版本不一致、照旧跑图」—— 那是**场景**通道的行为 /// (SpoonRaidMgr.cs:87),**任务图**通道不是。任务图这边是静默忽略。 /// 2. 「填个非空假 md5 就能逼客户端走加载分支」—— 实测**硬崩**(R211b,2026-09-30 23:05)。 /// 见 。 /// /// SAFETY /// Additive only: nothing already being sent changes, and a failure degrades to today's behaviour. /// Kill switch: . /// internal sealed partial class GameRouter { /// /// Master switch for the original task-graph load path (see the class doc). Set to false to fall /// back to exactly the pre-2026-09-23-evening behaviour (scene-spoon injection only). /// /// ★ R233(2026-10-01 19:0x)**实验性置 false**:客户端在 accept 之后 0.2 秒静默终止 /// (`AC wrapper done` → 心跳永久缺席;`R229 tasksync: ENTER` 从未出现 = 崩在 C# 层)。 /// 本包(`SyncPlayerSingleTask` 64917604)是 accept 之后**服务端推的第一个 ntf**, /// 其注释自述「非空假 md5 会让客户端**在收到本包那一瞬间硬崩**」。 /// 而「空 md5 = 客户端静默忽略」⇒ **推空值 ≙ 不推**(行为等价)。 /// ⇒ 关掉它 = **零行为差异的单变量实验**: /// · 关掉后**不崩** ⇒ 「客户端其实收到就崩」,本包即崩因; /// · 关掉后**仍崩** ⇒ 干净排除本包,去别的通道找。 /// 检查点不依赖本包(R225 用本地赛道 `RT_888_001` 弧长采样自建 16 点)⇒ 可玩性不受影响。 /// **回滚:改回 true 即可。** /// 注:用 static readonly 而非 const,避免 `if (!X) return;` 之后的 /// 代码被编译器判为不可达(CS0162);单使用点,二者行为等价。 /// internal static readonly bool RacingTaskSpoonLoadEnabled = false; /// /// Empty = auto-resolve. Set to a literal (e.g. "XQ_RacingDriver01") to force one specific /// alias without rebuilding anything else — the fastest way to A/B a candidate alias when the /// auto-resolved value turns out not to exist client-side. /// internal static readonly string RacingTaskSpoonAliasOverride = ""; /// /// ★★ R211b (2026-09-30 23:05) — DO NOT put a fake value here. It hard-crashes the client. /// /// R211 briefly sent 32 hex zeroes to force the client past its "nothing to load" bail-out. /// The client DID take the load path — and died instantly, at the exact instant the push landed: /// /// server 23:05:53 -> rpc AskAcceptAndSetCurrentTask 9b /// server 23:05:53 [RACE][TASKGRAPH] pushed SyncPlayerSingleTask … bytes=133 md5='000…0' /// client 23:05:53.217 [DRMK-PROBE] ENTER racer.TrySetCompetitionVehicle ← last client log /// client 23:05:53.219 [DRMK-PROBE] LEAVE racer.TrySetCompetitionVehicle /// client 23:05:53.2+ process gone (probe heartbeat stops dead, no further log) /// /// The 22:39 (R210) run sent the same push with bytes=101 (empty md5, 32 bytes shorter) /// at the same point in the same flow and the client sailed on for another 100 s. /// ⇒ The md5 is not a validity flag, it is the lookup key for the graph. A /// syntactically valid but nonexistent key does not produce a polite /// Not Have File '…' — it goes off the end of the load path and kills the process. /// /// So an empty md5 is the safe value: the client silently ignores the push /// (workActions = 0 ⇒ no checkpoints), which is exactly R210's behaviour — no crash. /// The real md5 must be recovered, never guessed. See R204 (铁律 255) and R211b report. /// internal static readonly string RacingTaskSpoonMd5 = string.Empty; /// /// 背景(R211 观察,仍然成立):空 md5 时,服务端确实推了 SyncPlayerSingleTask for task 60000147 /// (spRaid=23300888 alias=XQ_RacingDriver01 bytes=101),客户端也没报错,但它产生 零 /// task-graph 日志 —— 没有 LoadTaskResidentInfo error、没有 LoadGraphGroup Fail、 /// 尤其没有 @liufuqiang01 Not Have File '…'(该消息由 ReadRawTaskFile 发出)。 /// 探针读到 workActions = 0 ⇒ 图根本没加载。整场唯一的 Spoon 日志来自**场景**通道 /// (SpoonRaidMgr.cs:87 客户端与服务器版本不一致 23300888_RacingDriver)。 /// ⇒ 空 md5 = 客户端提前「无图可加载」静默返回。 /// /// ★ R211b 更正:上文曾据此推论「那就发个非空的假 md5,看客户端走哪支」——**这个推论已被证伪**。 /// 假 md5 不是让客户端走加载分支,而是让它硬崩(见 的注释)。 /// 所以现在只能保持空值(安全),真 md5 必须**取到**、不能猜。 /// /// /// Task-graph aliases read straight out of the CLIENT's own graph registry, which is what /// SpoonTaskMgr.GetSpoonTaskFilePath(spRaidId, alias) actually keys on. /// /// Found by byte-scanning the client asset block /// E:\anatacbt\Ananta_Data\StreamingAssets\Blocks\vfc_1378 (67 MB): at offset /// ~59 348 000 it serialises a flat list of (raidId, alias) pairs for the 新启 raid — /// (23300888, HotelBomb), (23300888, Modify_HackerCamp_12/13), /// (23300888, TaffyFight9/10/11/13new), (23300888, WeiRen2_part2) and, the only /// racing one, (23300888, XQ_RacingDriver01); then (23300999, FangYeFight1), /// (23300999, HongFight1), (23300999, SideMissions_ShadowPuzzle02). /// "RacingDriver" (the literal we get by parsing RaidSpoonName) appears NOWHERE in /// that block — XQ_RacingDriver01 is the only "Racing" string in the whole 67 MB. /// /// This matters: RacingDriverTrackInformationConfig.RaidSpoonName = /// "23300888_RacingDriver" is the SCENE-spoon name (and the official server sends /// SceneSpoonNames: [] anyway), while the TASK graph uses its own alias. They are not the /// same string, so parsing RaidSpoonName produces a key the client has no graph for. /// /// Keep this table tiny and evidence-backed: an entry is only added when the alias was literally /// read out of a client asset. Anything else falls back to . /// private static readonly System.Collections.Generic.Dictionary RacingTaskSpoonVerifiedAliases = new() { // 新启 racing circuit — client asset vfc_1378 @59348589. { 23300888u, "XQ_RacingDriver01" }, }; /// /// Splits a RacingDriverTrackInformationConfig.RaidSpoonName into the two keys /// TaskSpoonViewInfo actually wants: SpRaidId (the numeric prefix, with an optional /// "spoon" word in front) and Alias (everything after the first underscore). /// /// Real values observed in the client config: /// /// 23300888_RacingDriver → (23300888, "RacingDriver") /// 23300999_RacingDriver → (23300999, "RacingDriver") /// spoon23301289_RacingDriver → (23301289, "RacingDriver") /// spoon23301305_main → (23301305, "main") /// spoon23301294_23301294_Race_Chaos_Gadgets → (23301294, "23301294_Race_Chaos_Gadgets") /// /// Returns false (and zero/empty) when the string cannot be parsed — the caller then skips the /// push rather than sending a nonsense graph key. /// internal static bool TryParseRaidSpoonName(string? raidSpoonName, out uint spRaidId, out string alias) { spRaidId = 0; alias = string.Empty; if (string.IsNullOrWhiteSpace(raidSpoonName)) return false; var text = raidSpoonName.Trim(); var sep = text.IndexOf('_'); if (sep <= 0 || sep >= text.Length - 1) return false; // need "prefix_alias" with both parts var prefix = text[..sep]; if (prefix.StartsWith("spoon", StringComparison.OrdinalIgnoreCase)) prefix = prefix[5..]; if (!uint.TryParse(prefix, out spRaidId) || spRaidId == 0) return false; alias = text[(sep + 1)..]; return alias.Length > 0; } /// /// Racing task-graph reference for a track: SpRaidId + Alias parsed from the track's /// authored RaidSpoonName, plus the track's EventIds[0] for /// TaskSpoonViewInfo.EventId. /// /// The authored name is authoritative and self-contained (its numeric prefix IS the spoon raid), /// so is NOT used as a filter — the caller logs it for inspection only, /// because the client keys the graph on (spRaidId, alias) rather than on the player's raid. /// /// Alias precedence (highest first): /// 1. — manual A/B knob. /// 2. — alias literally read from a client asset. /// 3. parsed from RaidSpoonName (known to be the SCENE-spoon spelling, so it is only a /// last resort and is logged as such). /// internal static bool TryResolveRacingTaskSpoon( uint trackId, out uint spRaidId, out string alias, out uint eventId, out string aliasSource) { spRaidId = 0; alias = string.Empty; eventId = 0; aliasSource = "none"; if (!ClientConfigRepository.RacingTracks().TryGetValue(trackId, out var track)) return false; eventId = track.EventIds.Length > 0 ? track.EventIds[0] : 0u; if (!TryParseRaidSpoonName(track.RaidSpoonName, out spRaidId, out var parsedAlias)) return false; alias = parsedAlias; aliasSource = "parsed"; if (!string.IsNullOrWhiteSpace(RacingTaskSpoonAliasOverride)) { alias = RacingTaskSpoonAliasOverride.Trim(); aliasSource = "override"; } else if (RacingTaskSpoonVerifiedAliases.TryGetValue(spRaidId, out var verified)) { alias = verified; aliasSource = "client-asset"; } return alias.Length > 0; } /// Back-compat overload without the alias-provenance output. internal static bool TryResolveRacingTaskSpoon( uint trackId, out uint spRaidId, out string alias, out uint eventId) => TryResolveRacingTaskSpoon(trackId, out spRaidId, out alias, out eventId, out _); /// /// Pushes SyncPlayerSingleTask(64917604) for one racing task so the client loads the racing /// TASK graph and populates its work actions (checkpoints / rank points). /// /// Wire (method-frame body, Inline — no root 0xFF marker; nested entities are Complex): /// TaskId = uint32 /// TaskInfo = TaskViewData (0xFF) /// SpoonViewInfo = TaskSpoonViewInfo (0xFF) /// StateData = TaskStateData (0xFF) /// /// Best-effort: any failure is logged and swallowed so the race itself is unaffected. /// internal static async Task PublishRacingTaskSpoonAsync(RpcContext ctx, uint taskId, uint trackId, uint raidId) { if (!RacingTaskSpoonLoadEnabled) return; if (!TryResolveRacingTaskSpoon(trackId, out var spRaidId, out var alias, out var eventId, out var aliasSource)) { ctx.Session.Log.Info( $"[RACE][TASKGRAPH] skipped task={taskId} track={trackId}: RaidSpoonName not parseable " + $"(row present={ClientConfigRepository.RacingTracks().ContainsKey(trackId)})"); return; } if (raidId != 0 && spRaidId != raidId) ctx.Session.Log.Info( $"[RACE][TASKGRAPH] note task={taskId}: authored SpRaidId={spRaidId} != player raid={raidId} " + "(RacingTrackIdForRaid picked another raid's circuit; pushing the authored key anyway)"); try { var body = new GameMethods.SyncPlayerSingleTaskData4229938 { TaskId = taskId, TaskInfo = BuildChallengeTaskViewData(taskId), SpoonViewInfo = new GameMethods.TaskSpoonViewInfo4229938 { // ★★ R211b:**必须留空**。空值 = 客户端静默忽略(R210 行为:不崩、但 workActions=0 // ⇒ 无检查点)。填任何非空假值都会让客户端在收到本包的那一瞬间硬崩 // (23:05:53 实测)。真 md5 只能取到,不能猜。详见 RacingTaskSpoonMd5 的注释。 SpoonMd5 = RacingTaskSpoonMd5, SpRaidId = spRaidId, StartTaskId = taskId, EndTaskId = taskId, Alias = alias, EventId = eventId, EventStartTaskId = taskId, }, StateData = new GameMethods.TaskStateData4229938 { State = GameMethods.TaskState4229938.Accepted, // 1 Reason = GameMethods.ChangeSingleTaskReason4229938.Accept, // 0 FailTextId = 0, CanSkip = false, }, }; var bytes = UxSerializer.Serialize(body); await ctx.NotifyAsync(MethodId.SyncPlayerSingleTask, bytes); ctx.Session.Log.Info( $"[RACE][TASKGRAPH] pushed SyncPlayerSingleTask(64917604) task={taskId} " + $"spRaid={spRaidId} alias={alias} (src={aliasSource}) event={eventId} bytes={bytes.Length} " + $"md5='' (R211b: intentionally EMPTY = safe value; the client ignores the push and " + $"workActions stays 0. A non-empty fake md5 hard-crashes the client — do not fill it in.)"); } catch (Exception ex) { ctx.Session.Log.Info($"[RACE][TASKGRAPH] push failed task={taskId}: {ex.Message}"); } } }