using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; // ★ R309:载具瞬移契约 `SyncTeleportVehicle4229938` 在 GameScene 组(与改装店共用)。 using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; using Ananta.Server.RpcTypes.Client4229938; // ★ R309:Vec3(载具记录位置) using System.IO; using System.Text.Json; using System.Threading; // ★ R306:Interlocked / Volatile(代际护栏) namespace Ananta.Server.Handlers.Game; /// /// Server-side racing auto-start — turns "点开始挑战" into an actual race WITHOUT the manual /// /api/racing/zone-probe call. /// /// WHY THIS IS THE MISSING PIECE (skill drmk-ananta-rpc-diag §59.14–§59.17) /// The private server never sends SceneSpoonNames, so the client never merged the racing /// Spoon graph and the C# chain /// /// SyncGameGroundZoneInfo(68004609) /// -> L50.Spoon.CarRaceManager.SyncCarRaceGame /// -> InitClientRaceData -> InitClientData -> CarRaceData.InitCarRaceData -> CarRaceData.Init /// /// NRE'd on a null SpoonCarRaceData. That was fixed on the CLIENT side (the fastpatch injects /// "<raidId>_RacingDriver" into EnterSceneInfo.SceneSpoonNames before the C# loader runs). /// With the graph loaded the NRE is gone — but nothing PUSHES the zone, so the race never stages. /// /// The client cannot start it either: it never sends AskStartCompetitionRacing(63221200) in /// offline mode, and the challenge panel's 开始挑战 only calls gTaskManager:SetCurrentTask() /// locally (GPS arming). The zone packet is server-authoritative — so the server has to drive it. /// /// WHAT IT DOES /// On AskAcceptAndSetCurrentTask for a task owned by a Racing challenge, it later pushes /// SyncGameGroundZoneInfo four times: ZoneState=Display (stage the grid, with a real /// DisplayCountDownEndTime so the client's countdown panel actually runs and releases vehicle /// control), ZoneState=GameStart (start the lap clock with a real unix StartTime), then /// ZoneState=GameOver and a null-zone Dispose once the race length elapses — so the zone can never /// stay armed (and the player can never be left in a permanently undrivable car). The delays let the /// client finish arming the challenge (SyncCurrentTask / SyncPlayerAllTask) before the zone lands. /// /// 🔴 It is fire-and-forget and reply-neutral: AskAcceptAndSetCurrentTask4229938 still returns /// its usual empty OK, so task-accept semantics are unchanged. The only new observable is the log /// line [RACE] auto-start ... plus the zone packets. /// /// 🔴 AI OPPONENTS are OPT-IN. The LinkAIAgentInfo / LinkAIFashionInfo contracts are now declared /// (RacingZoneMethods.cs), and RacingZoneProbe.BuildRacingAiGrid populates /// AIVehicleInfos + ParticipantInfos from the track's HighAI/MiddleAI/LowAI pools when /// asked. It is OFF by default (RacingAiGridCount = 0 in RacingZoneProbe.cs, and the debug /// /api/racing/zone-probe accepts an aiCount override) because a non-empty grid changes /// what the client parses on zone sync — enable only after confirming the wire format via the debug /// API, or leave it 0 and experiment per-shot there. /// internal sealed partial class GameRouter { /// How long to wait before staging the grid, so the client's challenge arming lands first. private const int RacingZoneDisplayDelayMs = 1200; /// Gap between Display and GameStart (the client builds the grid during this window). private const int RacingZoneGameStartDelayMs = 3000; /// Race length used when the track row carries no MaxGameTime. private const uint RacingZoneFallbackGameTimeSeconds = 180; /// Gap between GameOver and the null-zone Dispose teardown. private const int RacingZoneTeardownGapMs = 1500; // ── R234 · 把「跑完全程」变成「真的结束」────────────────────────────────────── // // 现象(2026-10-01 19:03 与 19:05 两轮实测):客户端**确实跑完了** —— // `[RACE][COUNTER] index=0..15`(16 个检查点)**再加一条 `index=16`**, // 而 `index=16 == self.taskCounterCount` 正是 `CarRaceManager.OnCurrentTaskChanged` // 的**完成分支**(`CarRaceManager.lua:201-204`:`AskSetTaskCounterValue(taskID, // taskCounterCount, 1)` + `gPanelManager:Close(S_RACING_PANEL)`)。 // 但服务端**什么都不回** ⇒ 客户端关掉赛车 HUD 后没有任何结算/结束画面, // 玩家只能自己退出(用户原话:「没有办法到终点结束」)。 // 实测同轮服务端日志:`GAMEOVER`/`SyncRacingResultData` **一条都没有** // —— 因为旧实现只会 `await Task.Delay(MaxGameTime = 300s)`,玩家早在 79 秒就跑完了。 // // ⇒ 修法:把「等 300 秒」改成「**等客户端完成上报,或超时(谁先到)**」。 // 收到 `index == RacingClientCheckpointCount` 的计数器上报即立刻结算 // (GAMEOVER + SyncRacingResultData,与超时路径**完全同一段代码**,只是提前触发)。 // `MaxGameTime` 仍然保留作兜底(客户端崩了/中途退出的情况)。 /// /// 客户端自建检查点的总数 —— ★ **必须与客户端补丁 `racing_guard.lua` 里的 /// R255_CP_COUNT 保持一致**(两边都改,否则完成上报认不出来,只能等 300 秒兜底)。 /// /// 语义(实测):`CarRaceManager.OnCurrentTaskChanged` 在**最后一个检查点通过后**, /// 用 `counterIndex = taskCounterCount`(= 本常量)上报一次 `value=1`, /// 这就是「玩家跑完全程」的唯一权威信号。 /// /// ★★★ R255(2026-10-02):16 → 26。16 是 R225 自己猜的值 /// (R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值)。 /// 官方权威值 = 26:关卡块 vfc_819 的明文导出 JSON /// (ZoneShapes_WorldMap_Release_Saidao.prefab)里每个赛道点带 /// checkpointIndex,值域 0..25 ⇒ 含终点共 26 个。 /// 这也正是用户当时说的「全程包含终点有 26 个检查点」(他跟着视频一个一个数的)。 /// 详见 GameRouter.RacingGates.OfficialTrack3Checkpoints 的注释。 /// internal const int RacingClientCheckpointCount = 26; /// /// 每局赛车一个「可以结算了」的信号。key = taskId(单人赛车同一时刻只跑一局), /// 由 RunRacingAutoStartAsync 登记、AskSetTaskCounterValueAccept 触发、 /// 结算完成后移除。用 ConcurrentDictionary + TrySetResult 保证 /// **多次上报只结算一次**(客户端可能重发)。 /// private static readonly System.Collections.Concurrent.ConcurrentDictionary> RacingSettleSignals = new(); // ═══════════════════════════════════════════════════════════════════════════════════════════ // ★★★★★ R306(2026-10-03 00:5x)· 「跑了两三个检查点就被强制结算」的两个服务端根因 // // 实机铁证(`logs/console-latest.log`,用户同一晚连跑三局): // · 第 1 局:`auto-start scheduled` **两行**(00:34:55 ×2)、`DISPLAY` **两条**(00:34:56/57)、 // `GAMESTART` **两条**(00:35:01 ×2)⇒ **一次点击打了两条流水线**。 // · 00:36:32 客户端的 FINISH 只叫醒了**后登记的那条**(`RacingSettleSignals[taskId]` 被覆盖) // ⇒ 另一条没人取消,它的 `Task.Delay(300)` 在 **00:40:01** 醒来。 // · 00:39:28 开的第 2 局(玩家只跑了 2 个点)在 **00:40:01** 被那条**上一局的**流水线结算掉。 // · 第 2 局自己的 `Task.Delay` 又跑到 **00:44:28** 把第 3 局掐了(玩家只跑了 3 个点)。 // ⇒ 症状 = **永久错位一局**:每一局都被「上一局的 300 s 兜底」结算。 // // 修法(两条,缺一不可): // ① **arming 去重**:同一次「点开始挑战」的第二条流水线在建起来之前就丢掉 // (窗口 = 接受 → GAMESTART 推完;见 ); // ② **代际护栏**:每条流水线进场领一个号,只有**最新**那条允许结算 // ⇒ 任何残留的旧流水线醒来后只会自杀,绝不会再结算别人的局。 // ═══════════════════════════════════════════════════════════════════════════════════════════ /// /// R306:arming 闸(key = taskId)。`TryAdd` 成功 = 本局的第一条流水线; /// 失败 = 同一次点击的第二条 ⇒ **直接忽略**。GAMESTART 推完即释放。 /// private static readonly System.Collections.Concurrent.ConcurrentDictionary RacingAutoStartArming = new(); /// /// R306:流水线代际号。由每条流水线进场时 Interlocked.Increment 领取 ⇒ 号码 /// **只增、唯一**;领到**最新**号码的那条 = **唯一有权结算**的那条。 /// 任何较早的代(同一次点击被重复调度出来的、或上一局残留的)走到结算点时都会看到 /// Volatile.Read(ref RacingRunGeneration) != gen ⇒ **就地自杀**, /// 绝不结算「别人那一局」(这正是 00:40:01 / 00:44:28 两次提前结算的机制)。 /// 单字段即可:`Increment` 本身既发号又原子地把所有权交给最新那条。 /// private static int RacingRunGeneration; /// /// 「玩家跑完全程了」——由计数器上报 handler 调用。返回 true 表示这次真的叫醒了等待中的那局 /// (false = 当时没有在跑的比赛 / 已经结过账,两种都属于正常幂等)。调用方负责打日志。 /// internal static bool RequestRacingSettle(uint taskId) => RacingSettleSignals.TryGetValue(taskId, out var tcs) && tcs.TrySetResult(true); /// /// ★ R294:本局客户端**最后上报到的检查点下标**(key = taskId)。 /// 只在超时兜底结算时用来回答「为什么这局没在终点结算」—— /// 例:`settlement by TIMEOUT ... lastCounter=20/26 (还差 5 个检查点)` 直接说明是 300s 到点、 /// 不是终点判据坏了。由计数器 handler 写、结算时读并清。 /// private static readonly System.Collections.Concurrent.ConcurrentDictionary RacingLastCounterIndex = new(); /// R294:记录本局最后上报的检查点下标(计数器 handler 调)。 internal static void NoteRacingCounter(uint taskId, int counterIndex) => RacingLastCounterIndex[taskId] = counterIndex; /// R294:取并清除本局最后上报的检查点下标(结算时调)。返回 -1 = 一条都没上报。 internal static int TakeRacingLastCounter(uint taskId) => RacingLastCounterIndex.TryRemove(taskId, out var v) ? v : -1; // ══════════════════════════════════════════════════════════════════════════════════ // ★★★★★ R301(2026-10-02 23:4x)· 「上车才启动挑战」——玩家上车前不点火 // // 用户原话(连起来读): // 「我看别人视频开始赛车…**在路中间上车后才开始倒计时**,排名上有大大的 321 倒计时, // 还有音效,然后检查点出现,开始赛车」 // 「我根据别人视频里推测出来的情况是,我们点击开始挑战后,车出现在起点位置,然后步行 // 前往车辆位置,**上车后开始倒计时**,比赛正式开始」 // (R300 交付 321 过场之后)「**有倒计时了,就差上车启动挑战了**」 // // ⇒ 缺口只剩**触发时机**:R300 的 484 过场是挂在 zone `ZoneState==1`(Display 边)上的, // 而 zone 由 `ScheduleRacingAutoStart` 在**点开始挑战的那一刻**就用固定延时推出去 // ⇒ 无论玩家有没有上车、人在哪,1.2 s 后 321 都会放。官方是**上车**那一刻才放。 // // 做法(最小侵入):不动任何包的内容与顺序,只把「1) Zone Display」这一步**推迟**到 // 「玩家上车」之后 —— 即在 `Task.Delay(RacingZoneDisplayDelayMs)` 之前插一道闸。 // · 任务图 / AI 车(0) 段)照旧在点开始挑战时立刻做(客户端 arming 需要,早做无害); // · zone / 门 / 倒计时 / 结算 / teardown 全部跟在闸后面 ⇒ 时间轴自动整体平移, // 客户端侧一行都不用改(R300 的 484 仍然挂在 Display 边上,只是 Display 变晚了)。 // // 判据(服务端日志,两行都要有且顺序正确): // `[RACE] boarding gate: ARMED task=…` ← 点开始挑战 // `[RACE] boarding gate: RELEASED by …` ← 玩家上车(随后 1.2 s 才出现 pushed RacingZoneInfo DISPLAY) // 若只看到 ARMED + `TIMEOUT … starting anyway` ⇒ 上车包没到(先查 // `grep -c "C2S DECODED.*EnterOrExitVehicle" packets-*.log`,R301 之前它恒 0)。 // // 兜底:`RacingBoardingWaitTimeoutMs` 到点自动开 —— 决不允许因为"没上车"把比赛卡死。 // 一键回退:`config\racing-ablation.txt` 加一行 `boarding` ⇒ 恢复「等玩家上车」的时序 // (R303 起默认是官方时序:不加任何词 = 接受即走)。 // ══════════════════════════════════════════════════════════════════════════════════ /// 等上车的上限。到点无条件开赛(见上方 R301 段注释「兜底」)。 private const int RacingBoardingWaitTimeoutMs = 90_000; /// /// 正在等「玩家上车」的那一局(key = taskId)。上车包(两个 EnterOrExitVehicle 之一)到达时 /// 由 SignalRacingBoarding 置真 ⇒ 闸放行。单人赛车同一时刻只有一局,但用字典是为了 /// 与 RacingSettleSignals 同形,且天然幂等(重复信号不会重复放行)。 /// private static readonly System.Collections.Concurrent.ConcurrentDictionary> RacingBoardingSignals = new(); /// /// 玩家上车(或下车,但只取上车那一侧)—— 由 GameRouter.Vehicle 的两个 /// EnterOrExitVehicle handler 在**解析成功**之后调用。返回 true = 这次真的放行了等待中的一局。 /// 没有等待中的局(旗标关掉 / 手动开车 / 比赛进行中上车)⇒ 静默 false。 /// internal static bool SignalRacingBoarding(RpcContext ctx, ulong vehicleId, int seat, string source) { var woke = false; foreach (var kv in RacingBoardingSignals) if (kv.Value.TrySetResult(true)) { woke = true; ctx.Session.Log.Info( $"[RACE] boarding gate: RELEASED by {source} task={kv.Key} " + $"vehicle={vehicleId} seat={seat} ⇒ Display/321/GameStart 现在才发"); } return woke; } /// R301:等玩家上车。★ R303 起**默认不启用** —— 只有 ablation 里显式写了 /// boarding 才会真的等(官方实测没有这个闸,见 RacingTasks.cs 的长注释)。 private static async Task WaitForRacingBoardingAsync(RpcContext ctx, uint taskId) { if (!RacingStartRequiresBoarding) return; // 点「开始挑战」时人已经在车里(上一局没下车、或坐在车里点的挑战)⇒ 没什么可等的。 try { var runtime = GetVehicleRuntime(ctx); lock (runtime.SyncRoot) { if (runtime.CurrentVehicleId != 0) { ctx.Session.Log.Info( $"[RACE] boarding gate: already driving vehicle={runtime.CurrentVehicleId} " + $"task={taskId} ⇒ no wait"); return; } } } catch (Exception vex) { ctx.Session.Log.Info( $"[RACE] boarding gate: cannot read vehicle runtime ({vex.Message}) ⇒ skip the gate"); return; } var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); RacingBoardingSignals[taskId] = tcs; ctx.Session.Log.Info( $"[RACE] boarding gate: ARMED task={taskId} — 点开始挑战只做 arming;" + $"Display/321/GameStart 等玩家上车后才发({RacingBoardingWaitTimeoutMs / 1000}s 未上车则自动开;" + "⚠ 非官方行为,由 ablation 的 boarding 一词启用 —— 官方实测是接受即开赛)"); try { var first = await Task.WhenAny(tcs.Task, Task.Delay(RacingBoardingWaitTimeoutMs)); if (first != tcs.Task) ctx.Session.Log.Info( $"[RACE] boarding gate: TIMEOUT after {RacingBoardingWaitTimeoutMs / 1000}s task={taskId} " + "— starting anyway (上车包没到:查 packets-*.log 的 `C2S DECODED .*EnterOrExitVehicle`)"); } finally { RacingBoardingSignals.TryRemove(taskId, out _); } } /// /// AI opponents injected into the normal "点开始挑战" race — how many entries land in both /// AIVehicleInfos (the plain Dictionary<ulong,uint> = vehicleInstanceId → cfgId /// that the client's GetAllVehicleAIIds() enumerates) and ParticipantInfos (sent as /// GameGroundParticipantInfo4229938 — the element shape the client's own Auto.WriteRacingZoneInfo /// uses: 0xFF marker + 8 base fields; R203 replaced the old RacingParticipantInfo/TypeMark=14 shape /// which desynced the packet by 28 B per participant). /// /// 📌 SOLVED (2026-09-23 evening, §59.28). The wire format was never the problem — the ENTITY /// was. AIVehicleInfos is sceneVehicleEntityId → racingAiCfgId, and the client /// resolves the key against a real scene entity /// (DriveUtils.GetBaseVehicle(id) → element:BindVehicle(id) → the rank map), while /// the value indexes RacingDriverRacingAIConfig → VehicleID (which car it must be /// driving) + VehicleTaskID (the AI task that laps the circuit). Inventing ids /// (0x6000_0000_0000_0000 + n) could therefore never materialise: the v3 probe read /// csharp.aiCount=5 entityFound=0 entityMissing=5. /// /// ⇒ RunRacingAutoStartAsync now calls SpawnRacingAiVehiclesAsync (see /// GameRouter.RacingAiVehicles.cs) FIRST, building each opponent from /// RacingAIConfig[aiCfgId].VehicleID, and feeds those real entity ids back into the grid. /// This count is the number of opponents requested; if the spawn fails the race runs with an /// honest EMPTY grid rather than a ghost one. /// internal const int RacingAutoStartAiCount = 5; /// /// Gate for the SyncCarRaceGame(49071) push + SyncRacingResultData(68006935) settlement. /// The 14:25 attempt pushed 49071 with 5 AI and regressed the whole race flow (even the minimap /// route vanished), so this is OFF by default — the server sends ONLY the zone packets (grid + /// route), which is the known-good 14:07 baseline. Flip to true (keeping RacingAutoStartAiCount /// = 0) to test whether 49071 ALONE restores the countdown / AI minimap dots WITHOUT breaking the /// route; only after that succeeds do we raise the AI count. /// internal const bool RacingAutoStartPushSyncCarRaceGame = true; /// /// Serialises one race-completion record per finished race. Offline single-player mode has no /// client→server result report, so this is the honest first cut of "成绩/排行存储": it records /// WHO finished WHICH track WHEN (a real completion log the user can inspect in /// .runtime/racing_records.jsonl). Per-lap times / final ranking would need the client to report /// the result back (no such C2S exists in offline mode) — that is a documented follow-up. /// private static readonly object RacingRecordLock = new(); /// /// Gate for the REAL countdown push — IGameSceneToClient_SyncOpenPanelCountDown(68652507), /// signature (uint panelId, float countDown, bool redTenSeconds, bool isCountDown, /// bool showMillisecond) (dump.cs:184843 / 1901295 / 2056210; the client's own /// RPCMethodIdToName.lua:3277 maps 68652507 → "SyncOpenPanelCountDown", which is how we /// know the wire id is real and not another Spoon IDMAP number). /// /// The client's ChallengeCountDownRacingPanelStore.OnShow reads exactly /// countDownSecondNum / isCountDown / redTenSeconds / showMillisecond — a 1:1 match with /// this signature — so this is the panel the official server uses for the racing 3-2-1. /// CarRaceManager.OpenChallengeCountDownPanel(float,double) is private, so this RPC /// is the ONLY way a private server can open it. /// /// panelId comes from the probe: gPanelId.CHALLENGE_COUNT_DOWN_RACING_PANEL = 95 /// ([DRMK-TRUTH] 2026-09-23 13:14:49). The v3 probe now enumerates gPanelId so a wrong guess is /// correctable from the log alone. Off = the previous behaviour (route, no countdown). /// /// ⛑ R218 曾把这里置 false(想在「倒计时面板」与「DISPLAY 包」之间二选一)。 /// ⛔⛔ **R219 已作废该消融并改回 true** —— 二选一实验做了,结论是**两个都不是崩因**: /// · 本轮(10-01 09:32,本 const = false)`SyncOpenPanelCountDown` 推送 **0 次**(消融生效), /// 但客户端**仍然死**; /// · 真正的时间线(用探针心跳判定,比服务端日志准): /// 客户端按钮 LEAVE ok = t=137.301(09:32:13.99) /// 服务端 DISPLAY 推送 ≈ t=138.3(09:32:15) ← **客户端已死 1 秒** /// 服务端 IOException = 09:32:17 /// `auto-start failed ... disposed object` = 09:32:18 /// ⇒ 客户端**死在服务端任何推包之前**;R218 的「死亡时刻 == displayEnd」是 /// **服务端断链检测延迟(约 2~3 秒)造成的假相关**,不是因果。 /// · `RacingZoneInfo GAMESTART` 推不出去,只是因为它在 DISPLAY 之后 3 秒才发, /// 而那时客户端早就没了 —— 它是**结果**,不是判据。 /// 保留 true(倒计时面板是游戏正常流程的一部分,关掉会少一个可见特性)。 /// internal const bool RacingAutoStartPushCountDown = true; /// Panel id of the racing countdown (gPanelId.CHALLENGE_COUNT_DOWN_RACING_PANEL). internal const uint RacingCountDownPanelId = 95; /// /// ★★★ R295 · 「中上比赛计时器」推送开关(第二次 SyncOpenPanelCountDown)。 /// /// 为什么需要它(R295 的实测证据链) /// 用户 2026-10-02 原话:「中间的计时器还是没有」(连问三轮)。 /// R253/R254 曾判定「中上那块是 native HUD,不需要 Lua 面板」—— /// 这条判定**在 R295 被实机日志推翻**: /// /// 客户端 log/Player.log 21:01 那一局的面板装载清单里只有 /// S_ChallengeCountDownPanel(= 官方 PanelConfig[485], /// ChallengeCountDownPanelStore),**从来没有** /// S_ChallengeCountDownRacingPanel(= PanelConfig[95]); /// 而 485 那次装载的存活时长恰好是 3.15 s ≈ 我们喂的 countDown = 3 /// (3 s + 0.15 s 关闭动画)⇒ 中上那块**确实是被 SyncOpenPanelCountDown /// 打开的**,只是我们一局只喂了 3 秒; /// ChallengeCountDownRacingPanelStore.OnShow 一次性把 /// countDownSecondNum 转成 endTime = startTime + countDown, /// 之后 OnUpdate 到点自己 Close ⇒ **没有第二次机会**。 /// /// ⇒ 用户要的「计时器」= 整场剩余时间(服务端自己判超时用的那个 /// maxGameTime),而我们只推过开赛的 3-2-1。本开关就是那第二次推送。 /// /// 为什么取 maxGameTime 而不是别的数:它是 /// RacingDriverTrackInformationConfig 里本赛道(track 3)的官方字段, /// 也正是本文件下方 raceSeconds 超时兜底所依据的同一个值 ⇒ 屏幕上的 /// 倒计时与服务端真正结账的时刻**天然对齐**,不引入任何自造常量。 /// (R294 已实证:用户中途停 3 分 29 秒 ⇒ 到点走 TIMEOUT 兜底;有了这个可见倒计时, /// 这种「莫名其妙就结束了」就不会再出现。) /// /// 为什么是「再推一次」而不是把第一次的 3 改成 maxGameTime:第一次那条 /// 是官方语义的开赛 3-2-1(DISPLAY 阶段),改动它会同时改掉开赛节奏; /// 第二次落在 GAMESTART 之后 ⇒ 两个语义各归其位,且**可以单独消融** /// (config\racing-ablation.txt 加 notimer ⇒ 一键回退到改动前)。 /// /// panelId 仍写 95:官方面板表实证 95 = S_ChallengeCountDownRacingPanel /// 属于赛车挑战;但实测客户端实际装的是 485 —— 选哪个由客户端 /// gChallengeManager:IsCurrentRacingChallenge()(= 当前挑战的 /// ChallengeType == Racing)决定。**两者都是「中上计时器」**,对用户诉求等价, /// 所以不为此跟客户端较劲;判据看「屏幕上有没有一个真在走的倒计时」。 /// internal const bool RacingTimerPushCountDown = true; /// /// Wire args for SyncOpenPanelCountDown(68652507) — a method-frame body (Inline, no root /// 0xFF marker): panelId u32 + countDown f32 + redTenSeconds bool + isCountDown bool + /// showMillisecond bool = 11 bytes. Booleans are 1 byte each, the same convention as /// RacingZoneInfo4229938.GameTypeRandom. A wrong width shows up immediately as a client /// deserialize failed for 68652507 and is then fixed from the log. /// [UxContract(Inline = true)] internal sealed class SyncOpenPanelCountDownArgs { public uint panelId; public float countDown; public bool redTenSeconds; public bool isCountDown; public bool showMillisecond; } private static void RecordRacingCompletion(uint trackId, uint taskId, uint maxGameTime, ulong elapsedMs = 0) { try { var path = PrivateServerConfigStore.ResolveProjectPath(".runtime/racing_records.jsonl"); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var record = new { pid = Profile.PlayerPid, trackCfgId = trackId, taskId, maxGameTime, // ★ R241:真实用时(毫秒 + 秒两种写法,方便人读)。此前这张表里没有用时, // 而结算面板显示的是写死的 1000ms —— 两边都无从核对。 elapsedMs, elapsedSeconds = Math.Round(elapsedMs / 1000.0, 3), finishedAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), finishedAtLocal = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"), }; var line = JsonSerializer.Serialize(record); lock (RacingRecordLock) { File.AppendAllText(path, line + "\n"); } } catch (Exception ex) { // A record-write failure must never break the race teardown. try { /* swallow — best effort */ } finally { _ = ex; } } } /// /// The racing Spoon graph name of a raid. The client fastpatch injects exactly this string /// (see __DRMK_RacingSpoonNameForRaid in client_game_switch_source.lua), and it is the /// RaidSpoonName authored on the matching RacingDriverTrackInformationConfig rows /// (raid 23300888 -> track 3, 5, 8, 9, 10, 13). /// internal static string RacingSpoonNameForRaid(uint raidId) => raidId == 0 ? string.Empty : raidId + "_RacingDriver"; /// /// Which playable track belongs to . /// /// Candidates = rows whose RaidSpoonName is "<raidId>_RacingDriver" AND MaxGamePeople > 0 /// (the Id 1000+ block are map POI placeholders, not tracks — §59.16 D). /// Several tracks share one Spoon graph, so a tie is broken by preferring a track that a /// RacingDriverCompetitionGroupConfig group actually races (that is the authored "real" circuit /// for the map; for raid 23300888 that selects track 3 / event 2040, group 1 = C级赛 新启01). /// internal static uint RacingTrackIdForRaid(uint raidId) { if (raidId == 0) return 0; var spoon = RacingSpoonNameForRaid(raidId); var tracks = ClientConfigRepository.RacingTracks(); var candidates = tracks.Values .Where(t => t.MaxPlayers > 0 && string.Equals(t.RaidSpoonName, spoon, StringComparison.OrdinalIgnoreCase)) .OrderBy(t => t.Id) .ToArray(); if (candidates.Length == 0) return 0; if (candidates.Length == 1) return candidates[0].Id; var groupTracks = new HashSet(ClientConfigRepository.RacingCompetitionGroups().Values .SelectMany(g => g.Tracks) .Select(t => t.TrackId)); var preferred = candidates.FirstOrDefault(t => groupTracks.Contains(t.Id)); return (preferred ?? candidates[0]).Id; } /// /// ★★★★★ R334:服务端**已有成套检查点表**的赛道 id 白名单。 /// /// 为什么必须有这道闸:客户端的赛道几何(`RacingPathName` → ZoneShape 赛道线) /// 与服务端摆的**检查点门表**是**成套**的 —— 只放行「几何已发布」的 track 还不够, /// 服务端得**同时**有一张与之对齐的门表,否则门会摆到别人家赛道上去。 /// /// 加一条 = **先给它备好门表**(见 `RacingGates.cs`)。 /// private static readonly HashSet RacingPlayableTrackIds = [3]; /// /// R334:`RaidSpoonName` 的 raid 段(去掉可选的 `spoon` 前缀)是否等于 。 /// /// "23300888_RacingDriver" -> 23300888 (track 3) /// "23300999_Taffy_RaceGuide" -> 23300999 (track 2) /// "spoon23301294_23301294_Race_Chaos_Gadgets" -> 23301294 (track 6,混厄) /// /// 不能只做 `StartsWith(raidId)` —— 那样 2330129**4** 会被 2330129 前缀误配。 /// private static bool RacingSpoonRaidMatches(string raidSpoonName, uint raidId) { if (raidId == 0 || string.IsNullOrEmpty(raidSpoonName)) return false; var s = raidSpoonName; if (s.StartsWith("spoon", StringComparison.OrdinalIgnoreCase)) s = s[5..]; var sep = s.IndexOf('_'); var head = sep > 0 ? s[..sep] : s; return uint.TryParse(head, out var id) && id == raidId; } /// /// ★★★★★ R334:**按任务选轨**(取代「按 raid 猜」)。返回 false = 不该开赛。 /// /// 为什么非改不可:闸门只认任务 id、赛道却由 RacingTrackIdForRaid(Profile.RaidId) /// 决定 —— 后者对 raid 23300888 **恒返 3**。于是主城**任何**一条赛车挑战被点开后: /// 跑的都是 track 3 的 Downtown 路线,人也被传到 track 3 起点 /// (用户原话:「王牌02 新启新岸有开始挑战的点,但是开始后启动的是我们已经做完的那条赛道」)。 /// /// 判据链(全部读服务端自己持有的生效配表,零硬编码坐标): /// /// task → event:TaskEventCatalog4229938.EventsForTask(taskId) /// event → track 行:RacingDriverTrackInformationConfig.EventIds 相交 /// track 行必须可用:MaxPlayers > 0 ∧ RacingPathName 非空 /// (几何**随客户端包发布过**的才有值;其余连一个字节都没有) /// 同 raid: /// ★ :服务端已有成套门表的赛道 /// /// /// 语义(关键):任务在事件链上(events 非空)但筛不出成套赛道 ⇒ 返回 false ⇒ /// 「不启动」并打一行写明原因的日志,**绝不回退去乱开一条别人的赛道**。 /// 只有「任务压根不在任何事件链上」(events 为空)才回退 。 /// /// 一键回退:旗标文件加一行 trackbytaskoff ⇒ 完全回到改前的「按 raid 猜」 /// (见 RacingTasks.cs 的 RacingTrackSelectStrict)。 /// internal static bool TryPickRacingTrack(uint raidId, uint taskId, out uint trackId, out string reason) { trackId = 0; reason = string.Empty; if (taskId == 0) { trackId = RacingTrackIdForRaid(raidId); reason = $"taskId=0 ⇒ fall back to by-raid pick ⇒ track={trackId}"; return trackId != 0; } if (!RacingTrackSelectStrict) { trackId = RacingTrackIdForRaid(raidId); reason = $"trackbytaskoff (R334 ablation) ⇒ by-raid pick ⇒ track={trackId}"; return trackId != 0; } var events = TaskEventCatalog4229938.EventsForTask(taskId); if (events.Count == 0) { trackId = RacingTrackIdForRaid(raidId); reason = $"task not on any event chain ⇒ fall back to by-raid pick ⇒ track={trackId}"; return trackId != 0; } var all = ClientConfigRepository.RacingTracks(); var geoOk = all.Values .Where(t => t.MaxPlayers > 0 && !string.IsNullOrWhiteSpace(t.RacingPathName) && t.EventIds.Any(e => events.Contains(e))) .OrderBy(t => t.Id) .ToArray(); var picked = geoOk.FirstOrDefault(t => RacingSpoonRaidMatches(t.RaidSpoonName, raidId) && RacingPlayableTrackIds.Contains(t.Id)); if (picked is not null) { trackId = picked.Id; reason = $"task={taskId} events=[{string.Join(',', events)}] ⇒ track={picked.Id} " + $"(path={picked.RacingPathName}, spoon={picked.RaidSpoonName})"; return true; } // ★ 这里是本改动真正的语义:**知道它属于哪个事件,但那个事件没有成套赛道 ⇒ 不开赛**。 reason = $"task={taskId} events=[{string.Join(',', events)}] raid={raidId}: " + $"no playable track (geometry-published candidates=[{string.Join(',', geoOk.Select(t => t.Id))}], " + $"server-implemented tracks=[{string.Join(',', RacingPlayableTrackIds.OrderBy(x => x))}]) " + "⇒ REFUSING to fall back to another circuit's route (R334)"; return false; } /// /// ★★★★★ R309(2026-10-03):把玩家连人带车瞬移到赛道起点(官方检查点表第 0 个 = cp00)。 /// /// 为什么是我们自己实现(用户授权):官方抓包 packets-*.log 10/10 /// 证明官方不传送 —— 官方的前置条件是「你已经在车里、且车在起点」: /// 赛前声明页对赛车挑战先调 gRacerManager:TrySetCompetitionVehicle(trackId),它取 /// gDriveVehiclesManager.cs_manager.CurrentPlayerBaseVehicle,取不到就报 /// 「未找到玩家载具,无法设置赛事车辆!」。私服里玩家常常就地点挑战 ⇒ 人和车留在原地, /// 离赛道起点可能 2.9 km ⇒ 321 倒计时照走,屏幕上却是别的街景、也没有起跑线。 /// /// 用户原话:「…就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到 /// 比赛起点,然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个, /// 这个我允许自创」。 /// /// 通道(两条都是本仓已经在用、已实机验证的): /// /// 人 = SyncUnitPositionAndFacing(68030312) —— 同 GM 传送 /// (TeleportPlayerInstantAsync)。选它而不是 SyncTeleport 的原因: /// 它无 loading 流程,不会在 1.2 s / 3 s 的开赛时序里插一段黑屏。 /// 车 = SyncTeleportVehicle(68088911) Reset=true —— 同改装店把车钉在工位上 /// (GameRouter.Teleport.cs:155 / GameRouter.VehicleTuning.cs:229)。 /// /// /// 位置与朝向:OfficialTrack3Checkpoints[0] —— 官方关卡块 vfc_819 /// 的 259 点折线上第 0 个检查点(26 点逐点偏差 ≤ 0.009 m);朝向 = 该点处的道路切向 /// (沿路前进方向,R293 定案),正是玩家发车时应有的车头方向。 /// /// 失败绝不抛出:传送只是「让手感顺」,比赛本身不能因为它而开不起来 —— /// 任何一步异常都只打一行日志。 /// /// 消融:config/racing-ablation.txt 加 noteleport(本文件不用改)。 /// /// /// ★ R310b:开赛传送的落点,从 OfficialTrack3Checkpoints[0](cp00) /// **沿发车方向后退** 这么多米。0 = 正落在 cp00(R309 的旧行为)。 /// /// 为什么非退不可:官方 HUD 那个「蓝色菱形 + 距离」画不画的唯一开关是客户端 /// RacingPanelStore.lua:224-237 的 /// local isShow = gCS.LuaUtils.IsInCameraView(MainCamera, uiPos), /// 而 uiPos = arrowPos + Quaternion.Euler(arrowRot) * Vector3(0, zOffset, 0) /// —— arrowPos = 那件 3D 指示牌(arrowGO)的世界位置,zOffset 恒 10。 /// /// R309 把玩家**精确送到 cp00** ⇒ 指示牌锚点与玩家**重合** ⇒ /// uiPos 落在玩家**头顶 10 m**,摄像机在车后、俯角很小 ⇒ /// IsInCameraView 判假 ⇒ **菱形整块不画**。 /// /// 这正是用户 2026-10-03 01:4x 报的「起点、第二个检查点都看不到了, /// 到第三个检查点才会显示」—— 官方 taskCounterList 的 index=1/2 都指向 /// cp00 同一块区域(实测 uar1 与 t+3/t+4.5 推的都是 /// (2988.4, −0.1, 969.0) = cp00),所以前两个一起丢。 /// /// 方向约定(用两帧实测反证):cp00 → cp01 的 Δ 归一化 = (−0.884, +0.468), /// 而 (sin yaw, cos yaw) @ yaw = −61.54° = (−0.879, +0.477) /// ⇒ 一致(差 <1°)⇒ 前进 = (sin yaw, 0, cos yaw),落点 = cp00 − 前进 × 偏移。 /// /// y 仍取 cp00 的高度:这一段 259 点折线在路面上几乎全平 /// (cp00..cp07 的 y 都是 −0.11)⇒ 退 30 m 不会造成悬空/陷地。 /// /// ★★ 这个偏移量必须 > 22 m(不是 18):客户端 `LTConfig.PoiGameConfig` /// 里 BabyDriver_CheckPointRange = 22 —— 那就是「到达检查点」的判定半径。 /// 落点只要仍落在门周围 22 m 内,客户端**开赛瞬间就上报「已通过该检查点」** /// ⇒ 服务端 `[RACEGATE] cpNN passed -> removed` 立刻把那扇门撤掉 /// (实测:开赛 01:37:24 传送 → 01:37:25 摆好 52 扇 → 01:37:29 就 `cp00 passed` /// 把 ids=[…52,53] 撤了)⇒ **玩家根本看不到起点那扇门**。这正是用户 /// 2026-10-03 01:4x 报的「不光是没有菱形啊,是检查点门也没有」。 /// /// ⇒ 退到 30 m(> 22 且有 8 m 余量)= 一举两得: /// ① 门不会被"秒过"撤掉,玩家看得到起点门; /// ② cp00 落在玩家正前方,HUD 菱形能被 IsInCameraView 判为可见。 /// private const float RacingStartBackOffsetMeters = 30f; private static async Task TeleportPlayerAndVehicleToRacingStartAsync(RpcContext ctx) { if (RacingSkipTeleportToStart) { ctx.Session.Log.Info("[RACE][ABLATION] noteleport: race-start teleport SKIPPED " + "(player and vehicle stay where the player clicked 开始挑战)"); return; } if (OfficialTrack3Checkpoints.Length == 0) { ctx.Session.Log.Warn("[RACE] R309 race-start teleport skipped: " + "OfficialTrack3Checkpoints is empty"); return; } var cp = OfficialTrack3Checkpoints[0]; var yaw = cp.Yaw; // ★★★★★ R310b:落点沿发车方向**后退** —— 见 `RacingStartBackOffsetMeters` 的文档注释 // (根因:玩家与指示牌锚点重合 ⇒ 官方 `IsInCameraView` 判假 ⇒ 菱形前两个检查点不画)。 var tx = cp.X; var ty = cp.Y; var tz = cp.Z; if (RacingStartBackOffsetMeters > 0f) { var rad = yaw * System.MathF.PI / 180f; tx = cp.X - System.MathF.Sin(rad) * RacingStartBackOffsetMeters; tz = cp.Z - System.MathF.Cos(rad) * RacingStartBackOffsetMeters; ctx.Session.Log.Info($"[RACE] R310b race-start back-offset: cp00=({cp.X:F2},{cp.Y:F2},{cp.Z:F2}) " + $"yaw={yaw:F2} − {RacingStartBackOffsetMeters:F1}m ⇒ 落点=({tx:F2},{ty:F2},{tz:F2}) " + "(让 cp00 落在玩家正前方 ⇒ 官方 HUD 菱形才会被 IsInCameraView 判为可见)"); } // ★ 先后顺序与改装店 `PushPlayerAndVehicleAsync`(`GameRouter.Teleport.cs:140`)一致: // **先人、后车** —— 人在车里时「人」是 leader(相机 / 控制都跟它),先把 leader 放对, // 再把车摆到同一个点,客户端的一致性最好。反过来(先车后人)会多一帧「车在远处、人还在原地」。 try { await TeleportPlayerInstantAsync(ctx, tx, ty, tz, yaw, "R309-race-start"); } catch (Exception pex) { ctx.Session.Log.Warn($"[RACE] R309 player teleport failed (race continues): {pex.Message}"); } // 车:`SyncTeleportVehicle`(68088911) `Reset=true` = 真跳变(同改装店把车钉在工位上)。 ulong vehicleId; var runtime = GetVehicleRuntimeState(ctx.Session); lock (runtime.SyncRoot) { vehicleId = runtime.CurrentVehicleId != 0 ? runtime.CurrentVehicleId : runtime.LastDrivenVehicleId; } if (vehicleId != 0) { try { await ctx.NotifyAsync(MethodId.SyncTeleportVehicle, UxSerializer.Serialize(new SceneMethods.SyncTeleportVehicle4229938 { EntityId = vehicleId, Position = new SceneMethods.UxVector3(tx, ty, tz), Rotation = new SceneMethods.UxVector3(0f, yaw, 0f), Velocity = 0f, Reset = true, // ★ 真跳变 ⇒ 客户端直接落位,不插值 MoveToken = 0, })); lock (runtime.SyncRoot) { // 服务端自己的一致性:AdminWeb 面板位置、改装店「最近开过的车」都读这个。 if (runtime.Vehicles.TryGetValue(vehicleId, out var rec)) { rec.Position = new Vec3(tx, ty, tz); rec.Facing = yaw; } } ctx.Session.Log.Info($"[RACE] R309 teleport VEHICLE entity={vehicleId} " + $"→ ({tx:F2},{ty:F2},{tz:F2}) yaw={yaw:F2} (SyncTeleportVehicle 68088911 Reset=true)"); } catch (Exception vex) { ctx.Session.Log.Warn($"[RACE] R309 vehicle teleport failed (race continues): {vex.Message}"); } } else { ctx.Session.Log.Info("[RACE] R309 race-start teleport: no current/last vehicle recorded " + "⇒ moving the player only (on foot)"); } } /// /// Called from the AskAcceptAndSetCurrentTask handler. No-ops unless the accepted task belongs to /// a Racing challenge; otherwise schedules the Display -> GameStart zone push. /// internal static void ScheduleRacingAutoStart(RpcContext ctx, uint taskId) { if (taskId == 0) return; if (!RacingChallengeTaskIds.Value.Contains(taskId)) return; // ★★★★★ R306①:**arming 去重**(同一次「点开始挑战」只允许一条流水线)。 // 铁证(`logs/console-latest.log`,用户同一晚连跑三局):00:34:55 同一个 task 打了 // **两行** `auto-start scheduled`、两条 `DISPLAY`、两条 `GAMESTART`;两条都在 00:35:01 // 登记了 `RacingSettleSignals[taskId]`(后者**覆盖**前者)⇒ 前者永远收不到客户端的 // FINISH,它那条 `Task.Delay(300)` 无人取消,在 **00:40:01** 醒来,把**当时正在跑的 // 第 2 局**结算掉;第 2 局的定时器又跑到 00:44:28 掐了第 3 局 ⇒ **永久错位一局**。 // 窗口 = 「接受 → GAMESTART 推完」;正常在 GAMESTART 后立刻释放(下方 3) 之后), // 异常/提前 return 由 RunRacingAutoStartAsync 的 finally 保底释放。 if (!RacingAutoStartArming.TryAdd(taskId, 0)) { ctx.Session.Log.Warn( $"[RACE] auto-start DUPLICATE dropped task={taskId}: another pipeline for the " + "same click is still arming (R306 dedup; window = accept .. GAMESTART pushed)"); return; } var raidId = Profile.RaidId; // ★★★★★ R334:**按 task/EventId 精确选轨**。 // 改前 = `RacingTrackIdForRaid(raidId)` —— 对主城 23300888 恒返 3 ⇒ 点**任何**一条赛车 // 挑战都被拉去跑 Downtown(用户报的「王牌02 新启新岸…开始后启动的是我们已经做完的那条赛道」)。 // 判据链 / 语义 / 一键回退见 `TryPickRacingTrack` 的文档注释。 var hasTrack = TryPickRacingTrack(raidId, taskId, out var trackId, out var pickReason); if (!hasTrack || trackId == 0) { ctx.Session.Log.Info($"[RACE][R334] auto-start skipped task={taskId} raid={raidId}: {pickReason}"); // ★ R306①:提前离场必须把闸还回去,否则该 taskId 之后永远被自己的去重挡住。 RacingAutoStartArming.TryRemove(taskId, out _); return; } ctx.Session.Log.Info($"[RACE][R334] track pick task={taskId} ⇒ track={trackId} | {pickReason}"); var maxGameTime = ClientConfigRepository.RacingTracks().TryGetValue(trackId, out var row) ? row.MaxGameTime : 0u; ctx.Session.Log.Info( $"[RACE] auto-start scheduled task={taskId} raid={raidId} track={trackId} " + $"maxGameTime={maxGameTime} (+{RacingZoneDisplayDelayMs}ms display, " + $"+{RacingZoneDisplayDelayMs + RacingZoneGameStartDelayMs}ms gameStart)"); _ = RunRacingAutoStartAsync(ctx, taskId, trackId, maxGameTime); } private static async Task RunRacingAutoStartAsync(RpcContext ctx, uint taskId, uint trackId, uint maxGameTime) { try { // ★★★★★ R306②:进场先领一个**唯一且最新**的代际号 ⇒ 本流水线成为「唯一有权结算」的那条。 // (配对的两道护栏:GAMESTART 之后 / 真正醒来要结算那一刻;见下方注释。) var gen = Interlocked.Increment(ref RacingRunGeneration); ctx.Session.Log.Info($"[RACE] run generation claimed task={taskId} track={trackId} gen={gen}"); // 🔴 REAL RACE START (§59.25): the zone packet (68004609) only stages the grid + minimap // route. The actual race simulation — countdown panel, AI vehicles, lap clock, ranking — // is driven by SyncCarRaceGame(49071), which is what the official server pushes and what // L50.Spoon.CarRaceManager.SyncCarRaceGame consumes (→ lua OnCarRaceStart → AI minimap // dots via GetAllVehicleAIIds). We never sent it before, which is exactly why the player // saw a route but no countdown / no AI / no ranking. So we push BOTH: the zone (grid + // route) and SyncCarRaceGame (the race itself). // ⛑ 消融旗标(R191b,与 RacingTasks.cs 的 RacingAblationMode 同源)。定位后删除。 if (RacingAblationMode == "norace") { ctx.Session.Log.Info("[RACE][ABLATION] norace: ignition chain SKIPPED at entry " + "(task packets were still pushed by the handler; crash here = task packets guilty)"); return; } var aiCount = RacingAutoStartAiCount; if (RacingAblationMode == "noai") { aiCount = 0; ctx.Session.Log.Info("[RACE][ABLATION] noai: AI vehicle spawn DISABLED (task packets + zone + countdown still fire)"); } // 0) 🔴 ORIGINAL TASK-GRAPH LOAD (2026-09-23 evening, "还原大于一切"). // The racing Spoon graph was being force-merged by a client fastpatch that injected // "_RacingDriver" into EnterSceneInfo.SceneSpoonNames — but the only official // captures we own (logs\packets-20260913-*.log) show the official server sends // SceneSpoonNames: [] / SceneSpoonMd5s: [] inside the racing raid, so that is NOT how // the graph arrives. The native channel is the TASK graph: SyncPlayerSingleTask // (64917604) carrying TaskSpoonViewInfo{SpRaidId, Alias} — the very two keys authored // inside RacingDriverTrackInformationConfig.RaidSpoonName. It also runs // LoadTaskWorkActionData, which is what fills the racing task's work actions — the // reason the client spams "nextRankPoint is nil" and our probe read task.workActions=0. // Additive + best-effort; kill switch RacingTaskSpoonLoadEnabled. await PublishRacingTaskSpoonAsync(ctx, taskId, trackId, Profile.RaidId); // 0b) 🔴 AI OPPONENT ENTITIES (§59.28) — spawn BEFORE the zone lands. The client resolves // every AIVehicleInfos key against the scene (DriveUtils.GetBaseVehicle → minimap // BindVehicle → the rank map), so a grid whose entities do not exist yet is a grid it // can never materialise. This is the fix for the 13:3x probe reading // `csharp.entityFound=0 csharp.entityMissing=5`. List aiVehicles = []; if (RacingAiSpawnEnabled && aiCount > 0) { aiVehicles = await SpawnRacingAiVehiclesAsync(ctx.Session, trackId, aiCount); } else if (!RacingAiSpawnEnabled) { // ⚠ R203c:AI spawn 默认关闭(⚠ 不是「已定案的真凶」——15:54 实测已推翻该定案, // 见 RacingAiVehicles.cs 的四会话矩阵)。关着只为先保单人挑战可用。 ctx.Session.Log.Info("[RACE] AI opponent spawn is OFF (R203c gate; the spawn=" + " crash-root-cause verdict was REFUTED by the 15:54 run). Solo timed" + " challenge runs with an honest empty grid." + " Set env DRMK_RACE_AI_SPAWN=1 to re-enable for diagnosis."); } // Real entities win. If the spawn produced nothing we send an EMPTY grid on purpose — // falling back to invented ids would silently recreate the ghost-grid bug. var gridCount = aiVehicles.Count; // ★ R203c 消融:noaigrid = 实体照常 spawn,但网格推空(区分「spawn 副作用」vs「网格数据」)。 var gridSendEmpty = RacingAblationMode == "noaigrid"; if (gridSendEmpty) { ctx.Session.Log.Info($"[RACE][ABLATION] noaigrid: {gridCount} AI entities spawned," + " but zone ParticipantInfos/AIVehicleInfos pushed EMPTY" + " (isolates spawn side-effects from the grid payload)"); gridCount = 0; } ctx.Session.Log.Info($"[RACE] AI grid ready real={gridCount} requested={aiCount}" + $" spawnGate={RacingAiSpawnEnabled} (empty ⇒ honest no-AI race)"); // ★★★ R301:**上车闸**(见本文件 R301 段注释)。 // 位置是刻意选的:上面 0) 任务图 + AI 车都已经发完(客户端 arming 与起跑线布车 // 不依赖玩家位置),从这一行往下 = 「比赛真的开始」,全部等玩家上车再走 // ⇒ zone Display(→客户端 484 全屏 321) / 摆门 / 3-2-1 / GameStart / 计时 / 结算 // 整体平移,客户端补丁一行都不用改。 // 旗标 `boarding`(**默认不加 = 官方时序**,R303 起):不加则本调用**立刻返回**, // 时序 = 官方实测的「接受 → 1.2 s Display → +3 s GameStart」(见 // `GameRouter.RacingTasks.cs` 的 `RacingStartRequiresBoarding` 注释与 // `MEMORY.racing.md` R303 的官方抓包 10/10 证据)。 await WaitForRacingBoardingAsync(ctx, taskId); // ★★★★★ R309(2026-10-03)「点开始挑战 ⇒ 连人带车瞬移到赛道起点」─────────────── // 用户原话:「就直接最简单的,在面板点击开始挑战后把角色连人带车一起传送到比赛起点, // 然后正常倒计时321,开始比赛就行」+「如果官方没有,就我们自己实现一个,这个我允许自创」。 // 官方抓包 10/10 证明官方不传送(官方要求你**自己开到起点**,见 // `RacingTasks.cs` 的 `RacingSkipTeleportToStart` 长注释)。 // 位置刻意选在这里 = 上车闸之后、DISPLAY(→ 客户端 484 全屏 321)之前: // 传送先落地,然后才是「1.2 s DISPLAY → 3 s GAMESTART」的官方时序, // 客户端补丁一行都不用改。方法内部任何异常都只打日志、绝不抛出。 await TeleportPlayerAndVehicleToRacingStartAsync(ctx); // 1) Zone Display — stage the grid + minimap route. Carries a real DisplayCountDownEndTime // so the native vehicle-control-unlock path arms (see §14.3 of the racing notes). await Task.Delay(RacingZoneDisplayDelayMs); var displayAt = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var displayEnd = displayAt + (uint)(RacingZoneGameStartDelayMs / 1000); // +3s await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize( BuildRacingZoneProbe(trackId, taskId, ZoneStateDisplay, SyncReasonEnter, null, 0, 0, 0, 0, displayEnd, gridCount, gridSendEmpty ? null : aiVehicles))); ctx.Session.Log.Info( $"[RACE] pushed RacingZoneInfo DISPLAY track={trackId} task={taskId} displayEnd={displayEnd} ai={aiCount}"); // ★★★ R247(2026-10-01):把「绿框门」摆出来。 // 用户报「开始赛车了,没有看到绿框门」。门 = gadget // Racing_challenge01_CheckPoint / _CheckPointEnd,官方按检查点位置运行时 spawn, // 而检查点来自**从未合并进客户端**的 Spoon 图 ⇒ 官方那条链一次都没跑过,门从来没人 spawn。 // R244 试过客户端 Lua 自己 Instantiate:16/16 建成、active=true、scale=1/1/1、 // 零 Load asset FAILED —— 但**肉眼一道都看不见**(同「家具全无 / 健身器材」的病根: // 客户端不会自己实例化这类物件)⇒ 改走已验证通道 SpawnGadgetAsync。 // 位置 = DISPLAY 刚推出去(布置阶段)⇒ 倒计时 3 秒期间门就该立好了。 // 拿不到坐标表时会在玩家四周摆 4 道「验证信标」,把「通道对不对」与「坐标对不对」解耦。 try { await SpawnRacingGatesAsync( ctx.Session, GetStateStatic(ctx.Session), trackId, taskId); } catch (Exception gex) { // 摆门失败绝不能拖累比赛本身。 ctx.Session.Log.Warn($"[RACE] racing gate spawn skipped: {gex.Message}"); } // 2) 🔴 THE REAL COUNTDOWN (§59.27): IGameSceneToClient_SyncOpenPanelCountDown(68652507). // Neither the zone packet nor the (dead) 49187 push opens the 3-2-1 panel, and // CarRaceManager.OpenChallengeCountDownPanel is private — so this RPC is the only way // in. Fired on the Display edge so the panel expires exactly when GameStart lands. // redTenSeconds = false: the red-10s alert animation only makes sense for a >=10s timer, // and it would fire instantly on a 3s countdown. if (RacingAutoStartPushCountDown) { try { var countDownSeconds = (float)(RacingZoneGameStartDelayMs / 1000.0); // 3.0 var countdownBytes = UxSerializer.Serialize(new SyncOpenPanelCountDownArgs { panelId = RacingCountDownPanelId, countDown = countDownSeconds, redTenSeconds = false, isCountDown = true, showMillisecond = false, }); await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncOpenPanelCountDown, countdownBytes); ctx.Session.Log.Info( $"[RACE] pushed SyncOpenPanelCountDown(68652507) panelId={RacingCountDownPanelId} " + $"countDown={countDownSeconds} bytes={countdownBytes.Length}"); } catch (Exception cex) { // A countdown-panel hiccup must never block the race itself. ctx.Session.Log.Info($"[RACE] countdown push skipped: {cex.Message}"); } } // 🔴 DISABLED & REMOVED (§59.26, Lyco): this spot used to push SyncCarRaceGame(49071) // and OpenChallengeCountDownPanel(49187). Both ids are Spoon IDMAP action numbers, NOT // wire RPC ids — the client answered `#NoCreateIssue method not found` for each. The // race itself is NOT missing: the zone packet above already carries RacingZoneInfo -> // C# CarRaceManager.SyncCarRaceGame (IsGameStart=TRUE). What the client needs is DATA // (real AI entities matching AIVehicleInfos + the task checkpoint list) and the // countdown through IGameSceneToClient_SyncOpenPanelCountDown(68652507), both above. // The dead mid references were dropped in R190 so v2.1 compiles without inventing // MethodId entries for Spoon action numbers. await Task.Delay(RacingZoneGameStartDelayMs); // let the 3s countdown run // 3) Zone GameStart — actually start the race (lap clock / car unlock via the watchdog). var goAt = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var goGameTimeEnd = maxGameTime > 0 ? goAt + maxGameTime : 0u; await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize( BuildRacingZoneProbe(trackId, taskId, ZoneStateGameStart, SyncReasonEnter, null, 0, goAt, goGameTimeEnd, 0, 0, gridCount, gridSendEmpty ? null : aiVehicles))); ctx.Session.Log.Info( $"[RACE] pushed RacingZoneInfo GAMESTART track={trackId} task={taskId} start={goAt} end={goGameTimeEnd} ai={gridCount}"); // ★ R306①:GAMESTART 已推完 ⇒ arming 窗口结束,把闸还回去(保底释放见 finally)。 RacingAutoStartArming.TryRemove(taskId, out _); // ★★★ R295 · 「中上比赛计时器」——把整场剩余时间交给客户端自己去走。 // 上面那条 DISPLAY 边上的 3-2-1 到点就自行 Close(store 的 OnUpdate), // 所以这里必须在 GAMESTART 之后再推一次,`countDown = maxGameTime`, // 客户端就会一直显示到 00:00 才关 —— 也就是用户说的「中间的计时器」。 // ★★★ R296(2026-10-02 21:41 用户原话):「中间的计时器不对,要桌面最新截图样式的 // (`00:30.199`),之前不是已经调出来过吗,不过那个时候调出来的是倒计时」 // ⇒ R295 首版推的 `isCountDown = true` 是**倒计时**(`05:00 → 00:00`),形态不对。 // 官方(截图)要的是**正计时 + 毫秒** `MM:SS.mmm`: // · `isCountDown = false` ⇒ store 里 `reverseCountDown = not isCountDown = true` // ⇒ `GetFormatCountDownTime` 取 `nowTime - startTime` = **已用时间**(递增); // · `showMillisecond = true` ⇒ 走 `gString.Format("%02d:%02d.%03d", …)` // = 截图里那个 `00:30.199`(两个 store 的这三个分支逐字相同,已核对 // `ChallengeCountDownRacingPanelStore.lua:41/46/166-187` 与 // `ChallengeCountDownPanelStore.lua` 的同名函数)。 // `countDown` 仍取 `maxGameTime`:正计时下它同时是**上限** // (`endTime = startTime + countDown`,到点 store 自己 `Close`) // ⇒ 屏幕走到 `05:00.000` 与服务端超时结账仍是同一时刻。 // `redTenSeconds = true` ⇒ 最后 10 秒走官方的红色告警动画 // (第一次那条 3 秒的必须为 false,否则开赛瞬间就闪红)。 // 消融:config\racing-ablation.txt 加 `notimer`。 if (RacingTimerPushCountDown && !RacingSkipRaceTimer && maxGameTime > 0) { try { var timerSeconds = (float)maxGameTime; var timerBytes = UxSerializer.Serialize(new SyncOpenPanelCountDownArgs { panelId = RacingCountDownPanelId, countDown = timerSeconds, redTenSeconds = true, isCountDown = false, // ★ R296:false = 正计时(已用),截图样式 showMillisecond = true, // ★ R296:true = `MM:SS.mmm`(截图 `00:30.199`) }); await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncOpenPanelCountDown, timerBytes); ctx.Session.Log.Info( $"[RACE] pushed SyncOpenPanelCountDown(68652507) ** RACE-TIMER(R296 counting-UP ms) ** " + $"panelId={RacingCountDownPanelId} countDown={timerSeconds}s " + $"(== MaxGameTime, the same value raceSeconds uses) " + $"isCountDown=false showMillisecond=true bytes={timerBytes.Length}"); } catch (Exception tex) { // A countdown-panel hiccup must never block the race itself. ctx.Session.Log.Info($"[RACE] race-timer push skipped: {tex.Message}"); } } else if (RacingSkipRaceTimer) { ctx.Session.Log.Info("[RACE][ABLATION] notimer: race-timer SyncOpenPanelCountDown NOT pushed"); } // ★★★ R241(2026-10-01):**赛道时钟起点**。用户报「到终点能结算了,但结算页的用时 // 永远是 1 秒」。原因在 BuildRacingResultData:离线模式没有真实圈速,三个时间字段 // 一直被写死 `1000`(ms)当占位符 ⇒ 面板 `time = TotalTime/1000 = 1.000s`。 // 这里在 GAMESTART 推出去的那一刻起表,结算那一刻停表 ⇒ 得到真实比赛用时。 // ⚠ 这只是**服务端视角**的用时(含一次单向网络延迟,几十毫秒量级);客户端自己 // 还有更精确的计时(`ChallengeManager.beginTime`),R241 的客户端补丁会用 // 游戏自己的钟覆盖面板显示值。服务端这个值是「无补丁时的诚实兜底」。 // ★★★★★ R306②(代际护栏 · 第一道):走到这里 = DISPLAY / 摆门 / 3-2-1 / GAMESTART / // 整场计时器都推完了。若已经有**更新的**流水线接手(同一次点击的重复调度、或玩家 // 在比赛中又点了一次开始),本代必须**就地自杀**:既不登记结算信号,也不再往这个 // 世界推任何东西(否则会把新一代的 DISPLAY/GAMESTART 覆盖成旧值)。 if (Volatile.Read(ref RacingRunGeneration) != gen) { ctx.Session.Log.Warn( $"[RACE] stale run gen={gen} (current={Volatile.Read(ref RacingRunGeneration)}) " + $"task={taskId} track={trackId} — ABORT before settle " + "(R306: refusing to take over a newer pipeline's race)"); return; } var raceClockStart = DateTimeOffset.UtcNow; // ---- teardown ---------------------------------------------------------------- // ★★★ R234:不再死等 `MaxGameTime`(track 3 = 300s)——玩家实测 79 秒就跑完全程, // 剩余 220 秒里客户端已经把赛车面板关了,却什么都等不到(「没有办法到终点结束」)。 // 改成「**客户端的完成上报** 或 **超时**,谁先到谁触发」,两条路进的是**同一段结算**。 var raceSeconds = maxGameTime > 0 ? maxGameTime : RacingZoneFallbackGameTimeSeconds; var settleSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // ★ R241:真实用时(毫秒)。客户端的完成上报(index == taskCounterCount)一到达就停表; // 走超时兜底时等于 `MaxGameTime`(没跑完,时间打满 —— 与官方「超时即失败」同义)。 ulong elapsedMs = 0; RacingSettleSignals[taskId] = settleSignal; try { var settleTimeout = Task.Delay(TimeSpan.FromSeconds(raceSeconds)); var firstToFinish = await Task.WhenAny(settleTimeout, settleSignal.Task); // ★★★★★ R306②(代际护栏 · 第二道,**这才是「提前结算」的正解**): // 醒来的第一件事是核对代际,而不是直接去结算。 // 铁证:上一局残留的 `Task.Delay(300)` 在 00:40:01 醒来时,被它结算掉的是 // **当时正在跑的第 2 局**(`lastCounter=1/26`,玩家只跑了 2 个点); // 第 2 局的定时器又在 00:44:28 掐了第 3 局(`lastCounter=2/26`)。 // 不是最新的一代 ⇒ 直接走人,绝不动 GAMEOVER / 成绩存储 / 结果面板。 if (Volatile.Read(ref RacingRunGeneration) != gen) { ctx.Session.Log.Warn( $"[RACE] settlement ABORTED: stale run gen={gen} " + $"(current={Volatile.Read(ref RacingRunGeneration)}) task={taskId} track={trackId} " + $"wokenBy={(firstToFinish == settleSignal.Task ? "client-report" : $"{raceSeconds}s-timeout")} " + "— 这是**上一局**残留的流水线,拒绝结算当前这一局"); return; } var byClient = firstToFinish == settleSignal.Task; elapsedMs = (ulong)Math.Max(0L, (long)(DateTimeOffset.UtcNow - raceClockStart).TotalMilliseconds); // ★ R294:把「最后跑到第几个检查点」一起打出来 —— 超时结算时这是唯一能区分 // 「终点判据坏了」和「300s 到点没跑完」的证据。 var lastCounter = TakeRacingLastCounter(taskId); ctx.Session.Log.Info(byClient ? $"[RACE] settlement EARLY by client completion report task={taskId} track={trackId}" + $" (counter index={RacingClientCheckpointCount} == RacingClientCheckpointCount;" + $" {raceSeconds}s MaxGameTime backstop skipped)" + $" — ** real race time {elapsedMs}ms ({elapsedMs / 1000.0:0.000}s) **" + $" lastCounter={lastCounter}" : $"[RACE] settlement by TIMEOUT after {raceSeconds}s task={taskId} track={trackId}" + $" (no client completion report arrived; lastCounter={lastCounter}" + (lastCounter >= 0 ? $"/{RacingClientCheckpointCount} ⇒ 只跑到第 {(lastCounter < RacingClientCheckpointCount ? lastCounter + 1 : RacingClientCheckpointCount)} 个检查点," + $"距终点还差 {System.Math.Max(0, RacingClientCheckpointCount - lastCounter)} 个" + " —— **这是超时兜底,不是终点判据坏了**)" : " ⇒ 本局一条检查点上报都没有)")); } finally { // ★ R306:**值比较式移除** —— 只删自己登记的那个 TCS。 // 旧写法 `TryRemove(taskId, out _)` 会把**后一代**登记的信号一起删掉, // 让真正在跑的那一局再也等不到客户端的完成上报(只能吃满 300s 超时)。 RacingSettleSignals.TryRemove( new KeyValuePair>(taskId, settleSignal)); } await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, UxSerializer.Serialize( BuildRacingZoneProbe(trackId, taskId, ZoneStateGameOver, SyncReasonEnter, null, 0, goAt, goGameTimeEnd, 0, 0, gridCount, gridSendEmpty ? null : aiVehicles))); ctx.Session.Log.Info($"[RACE] pushed RacingZoneInfo GAMEOVER track={trackId} task={taskId} ai={gridCount}"); // ★★★★★ R294(2026-10-02)—— 比赛结束 ⇒ 把这一局摆的赛车门**全清**。 // 用户原话:「而且比赛结束了所有检查点还在」。放在 GAMEOVER 之后、结算面板之前 // ⇒ 玩家看到的是「比赛一结束,赛道上的门一起消失」。 // 与「过点即撤」(`GameRouter.RacingVehicle` 的计数器 handler)共用同一个闸门 // `racingGates.removeOnPass`;撤门失败绝不能拖垮结算,所以整块包 try。 if (PrivateServerConfigStore.Current.World.Content.RacingGates.RemoveOnPass) { try { await RemoveAllRacingGatesAsync(ctx.Session, GetStateStatic(ctx.Session)); } catch (Exception gex) { ctx.Session.Log.Info($"[RACEGATE] teardown clear skipped: {gex.Message}"); } } // 成绩存储 (§59.24): persist a completion record now that the race clock has stopped. RecordRacingCompletion(trackId, taskId, maxGameTime, elapsedMs); // 4) SyncRacingResultData(68006935) — offline settlement. In offline mode the client's // OnSyncRacingResultData seals the challenge, pulls the result and shows the ending / // ranking panel. Ranks are FABRICATED (offline has no client→server lap times). Guarded // so a serialization hiccup can never block the teardown below. if (RacingAutoStartPushSyncCarRaceGame) { try { var results = BuildRacingResultData(trackId, taskId, gridSendEmpty ? null : aiVehicles, elapsedMs); await ctx.NotifyAsync(MethodId.IGameSceneToClient_SyncRacingResultData, UxSerializer.Serialize(results)); ctx.Session.Log.Info( $"[RACE] pushed SyncRacingResultData(68006935) track={trackId} task={taskId}" + $" entries={results.Count} playerTime={elapsedMs}ms ({elapsedMs / 1000.0:0.000}s)"); } catch (Exception rex) { ctx.Session.Log.Info($"[RACE] ranking push skipped: {rex.Message}"); } } // Retire the opponent cars together with the race (§59.28). They exist only for this // race; leaving them would litter the circuit and keep stale uids warm in the client's // vehicle LOD cache. await DestroyRacingAiVehiclesAsync(ctx.Session, aiVehicles, "racing-teardown"); await Task.Delay(RacingZoneTeardownGapMs); await ctx.NotifyAsync(MethodId.SyncGameGroundZoneInfo, BuildRacingZoneClearBody()); ctx.Session.Log.Info($"[RACE] pushed RacingZoneInfo DISPOSE (null zone) track={trackId} task={taskId}"); } catch (Exception ex) { ctx.Session.Log.Info($"[RACE] auto-start failed task={taskId} track={trackId}: {ex.Message}"); } finally { // ★ R306①:arming 闸的**保底释放**。正常路径在 GAMESTART 推完时已经释放过 // (那时 TryRemove 返回 false ⇒ 这里不打印);只有异常/提前 return 的路径会走到 // 「真的还在这里」这一支 —— 也正因为要能区分,才把日志放在 TryRemove 返回值里。 if (RacingAutoStartArming.TryRemove(taskId, out _)) { ctx.Session.Log.Info( $"[RACE] auto-start arming released on abnormal exit task={taskId} (R306 finally)"); } } } /// /// Build a (FABRICATED, offline) ranking for SyncRacingResultData(68006935). Offline /// single-player mode has no client→server lap times, so a real ranking is impossible — we just /// put the player at rank 1 and the AI opponents at ranks 2..N with increasing (fake) total times /// so the client's _BuildRaceRankData produces a sensible ordered leaderboard and triggers /// the ending panel. /// /// 🔴 AI ENTRY KEY (fixed 2026-09-23, user report "名称没看到"): /// the client resolves each AI entry like this (ChallengeManager.lua:_BuildRaceRankData): /// /// local isAIEntry = ulong.equals(data.Pid, 0) /// local key = isAIEntry and data.AIVehicle or data.Pid /// local info = self.racingParticipantInfo[key] -- nil ⇒ "[...] key not match, isAI=true" /// /// This method used to write the SAME invented ids as the old ghost grid /// (0x6000_0000_0000_0000 + n), while racingParticipantInfo is built by C# from the /// participants whose ids are the REAL spawned entity ids — so every AI row missed and all /// opponent names came out empty. It now takes the spawned vehicles and reuses /// , i.e. exactly the ids that went into /// AIVehicleInfos/ParticipantInfos (AgentInstanceId / VehicleInstanceId / /// AIAgentInfo.Uid all carry that same value). /// private static List BuildRacingResultData( uint trackId, uint taskId, IReadOnlyList? aiVehicles, ulong elapsedMs) { // ★★★ R241(2026-10-01):真实用时。此前三个时间字段**写死 1000**(占位符), // 用户实测「到终点能结算了,但不管开多久结算页都显示 1 秒」 // (客户端 `_BuildRaceRankData` 是 `time = TotalTime / 1000` ⇒ 面板显示 0:00:01.000)。 // 单位是**毫秒**(`ulong`),`elapsedMs = 0` 只可能是异常路径 ⇒ 兜到 1000 保持旧行为。 // `track 3` 的 `RacingDriverTrackInformationConfig.NumberOfLaps = 1` ⇒ 单圈赛, // 最快圈 == 总用时,所以 BestLapTime 与 TotalTime 同值(不是偷懒,是这张表就这么定义的)。 var playerTime = elapsedMs > 0 ? elapsedMs : 1000UL; var results = new List { // Player — real pid, AIVehicle = 0, rank 1 (winner). new GameMethods.RacingResultData4229938 { SeatIndex = 0, Pid = Profile.PlayerPid, AIVehicle = 0, FinishTime = playerTime, TotalTime = playerTime, BestLapTime = playerTime, IsBest = true, Rank = 1, }, }; for (var i = 0; aiVehicles is not null && i < aiVehicles.Count; i++) { var instanceId = aiVehicles[i].EntityId; // ★ R241:AI 的假成绩必须与 Rank **单调一致**。旧实现是 `1000 * (i + 2)`(2s/3s…), // 在玩家真实用时 ~90s 之后,会出现「AI 用时 2 秒却排在玩家后面」的自相矛盾榜。 // 现在 = 玩家用时 + 每名 1 秒 ⇒ 名次越大耗时越长。 var t = playerTime + 1000UL * (ulong)(i + 1); results.Add(new GameMethods.RacingResultData4229938 { SeatIndex = i + 1, Pid = 0, // AI ⇒ the client keys on AIVehicle instead AIVehicle = instanceId, // MUST equal the spawned entity id (see summary) FinishTime = t, TotalTime = t, BestLapTime = t, IsBest = false, Rank = i + 2, }); } return results; } }