using System.Runtime.CompilerServices;
using System.Text.Json;
using Ananta.SDK.Network;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
///
/// 赛车检查点门(R247 · 2026-10-01)—— 服务端显式摆放。
///
/// 症状:整条赛道上一道绿框门都没有(用户:「开始赛车了,没有看到绿框门」)。
///
/// 为什么没有:门是 gadget 对
/// Racing_challenge01_CheckPoint / Racing_challenge01_CheckPointEnd,
/// 官方**按检查点位置在运行时 spawn**;而检查点来自
/// RacingDriverTrackInformationConfig.RaidSpoonName 指向的 Spoon 图 ——
/// 这张图**从未合并进客户端**(SpoonMd5="" ⇒ SpoonRaidMgr 走
/// #NoCreateIssue),所以官方那条链一次都没执行过,门**从来没人 spawn**。
/// 不是坏了,是没装。
///
/// 为什么必须服务端摆:R244 已经试过「客户端 Lua 自己
/// gResourceManager:LoadAssetWithCallBack + GameObject.Instantiate」,
/// 实机证据是 16/16 全部实例化成功、go0.active=true、scale=1/1/1、
/// children=2、全程**零** Load asset FAILED —— 但**肉眼一道都看不见**。
/// 这与「家具全无 / 健身器材 / 摊位补给」是同一个病根:**客户端不会自己实例化这类物件**,
/// 必须由服务端经 gadget 通道下发。
///
/// 通道:SpawnGadgetAsync(健身器材 / PropRestock / 摊位补给都走它,实测可见)。
/// ⛔ iScale 必须传 0:100(GM 面板默认)出来是 transform scale (0,0,1)
/// 的**零缩放空壳、没有 prefab** —— 正是保龄球球瓶「有物理有音效但看不见」的根因。
///
/// 坐标:客户端 R225 已用 LoadRaceTrackData 采出 16 个检查点(沿弧长等分),
/// R247 把它们与**服务端那套 pathId** 一起落到 config/racing-gates.json。
/// 服务端读表摆放 —— 与健身器材 _gym_capture.py 的「离线采样 → 落配置 → 服务端摆」完全同构。
///
/// 拿不到表时:在玩家四周摆 4 道「验证信标」。它的价值是把
/// 「通道对不对」和「坐标对不对」两个变量解耦:看得见信标 ⇒ 通道没问题,只差坐标。
///
/// 回滚:world.content.racingGates.enabled = false(不用重编译)。
///
internal sealed partial class GameRouter
{
///
///
/// Racing_challenge01_CheckPoint(官方赛道检查点门)+ ..._CheckPointEnd(终点门)。
/// 服务端 GadgetCatalog 那套 id 空间。
///
/// ★★★ R256 更正:R252 写的「**这两个 prefab 的资源根本没随这个客户端包**
/// ⇒ 摆上去也是隐形的」**是错的**。硬证据(gym_tools/_r256_gate_instances.py,
/// 全库 1682 块 + 88B 记录头校准):关卡块 vfc_1479 / vfc_859 里官方
/// **静态摆了 16 个** Racing_challenge01_CheckPoint(如
/// (1914.60, 0, 2234.30) yaw=255.71)+ 2 个 ...End
/// + SelfRacing_PortalGuide + SelfRacing_EffectGuideLine01
/// ⇒ 官方自己就在关卡里摆它们 ⇒ 客户端必然能渲染 ⇒ **资源一定随包**。
/// 当轮「看不见」的真因见 说明
/// (yaw 被算成 atan(dx) 恒 ±90 + pairMirror 同坐标重叠)。
///
/// 这两个常量同时用于:① 默认摆位(pathIdOverride = 0 时);
/// ② 清理旧批次(SpawnRacingGatesAsync ①)。
///
internal const int RacingGateCheckPointPathId = 884611825;
/// ★ R256:Racing_challenge01_CheckPointEnd(终点门)。
private const int RacingGateEndPathId = 271608686;
///
/// R252:SelfRacing_PortalGuide(单人赛车·传送门指引)——
/// 从客户端自己的注册表块 `vfc_907` 逐字读出(`name`/`GraphId`/`path`/`pathHash`/`pathId`
/// 五元组,全表 2932 条已导出为 gym_tools/_r252_vfc907_registry.json)。
/// 它是「单人赛车」族里**唯一**有资源块的物件。
///
internal const int SelfRacingPortalGuidePathId = 2017204172;
///
/// R253:Jobrace_gongmen(竞速·拱门)—— 本轮锁定的目标物件。
///
/// 为什么是它(三条独立证据):
///
/// - 名字直译就是拱门:Jobrace(Job Race,竞速)+ gongmen(拱门)。
/// 全库唯一一个名字里带「拱门」的 gadget。
/// - 它的美术结构就是拱门:资源块 vfc_1583 / vfc_1582 里 dump 出
/// gp_arch_gongmen_blue_left / _right / _up 三件套
/// (arch = 拱)—— **左柱 + 右柱 + 顶拱**,与参考视频里
/// 「路两侧各一根光柱、顶端向内弯折成拱」完全同构;
/// 另有 gp_arch_gongmen_yellow(黄门)与 fx_gp_arch_gongmen_blue_lod1/2。
/// - 它**明确绑定本玩法**:vfc_904 里 Jobrace_ 族与
/// 23300888 同现 —— Jobrace_Spurt01_23300888 / 23300888.Jobrace_F01 /
/// 23300888.Racing_challenge02 / ClientActionRacing_challenge01_23300888。
/// 而 23300888 正是本赛道玩法 id(RacingDriverTrackInformationConfig.RaidSpoonName
/// = "23300888_RacingDriver")。
///
///
/// 与 gp_wangpaicheshen_men_blue/yelllow 的区别:后者名字里有 men,
/// 但只在 vfc_1582/vfc_1583 里作为**特效资源**出现,且没有 left/right/up 的三段结构;
/// arch_gongmen 才是拱形门本体。
///
internal const int JobraceGongmenPathId = 64493331;
///
/// ★ R273:S6_Racing_CheckPoint(`440353630`)—— R270 那轮误把它当成门摆了上去。
/// 只要它没进 purgeIds,一旦把 pathIdOverride 换回官方默认(0),
/// 上一批 S6 门就不会被清 ⇒ 赛道上叠一层残留。故此处登记,纳入每局清理。
///
internal const int S6RacingCheckPointPathId = 440353630;
///
///
/// ★★★★★ R255(2026-10-02)—— **官方权威检查点表**:track 3 = Nova(`23300888`),
/// `RacingDriverTrackInformationConfig.RacingPathName = "RT_888_001"`,
/// **含终点共 26 个**。
///
/// 数据来源(离线可复现,零注入):关卡块
/// Ananta_Data/StreamingAssets/Blocks/vfc_819 里躺着一份**明文 JSON**
/// (偏移 ≈ 58662107),exportName =
/// "Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab":
///
/// {"exportName":"...Saidao.prefab","simplifyTolerance":0.1,
/// "tracks":[{"trackIndex":0,"pointCount":259,"isClosed":false,
/// "points":[{"x":3112.446,"y":-0.11,"z":899.959,
/// "checkpointIndex":-1,"distanceToNextCheckpoint":141.98}, ...]}]}
///
/// ⇒ 官方把检查点划分烘焙在每个赛道点的 `checkpointIndex` 里
/// (同族类 MassAI.ZoneGraph.RacingTrackPoint 的字段 =
/// x,y,z,checkpointIndex,distanceToNextCheckpoint)。
/// checkpointIndex 值域 = -1 与 0..25;-1 是起点前的引导段
/// ⇒ **真正的检查点是 0..25 共 26 个**(与用户「跟着别人的视频一个一个数出 26 个」完全一致)。
///
/// 检查点 k 的位置 = 第 k 组的第一个点(已由 distanceToNextCheckpoint 自证:
/// cp0 组末点 s=215.07, d_last=5.93 ⇒ 221.00,而 cp1 组起点 s=220.99,吻合;
/// -1 组末点 s=120.77, d_last=21.21 ⇒ 141.98 = cp0 起点 s=141.98,吻合)。
///
/// 为什么必须内置、不能用 R225 那套采样:
/// ① R225 走 LX6.Utils.LuaUtils.LoadRaceTrackData →
/// RaceTrackExportData.ExtractFilteredTrackPoints(),那条链**只取 x/y/z、把
/// `checkpointIndex` 削掉了**;
/// ② 它还做了**不等距重采样** —— 官方 259 点 / 总长 3382.44 m,探针只拿到
/// 256 点 / 3225 m ⇒ 在它上面做「弧长等分」**必然错位**;
/// ③ R225 的数量本身就是猜的(R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值);
/// ④ 那张采集表还是**客户端探针每局重写**的(见 PathIdOverride),坐标同样不可托付。
///
/// 交叉验证:R225 采出的 16 个点在官方 259 点列上**逐点命中、距离 0.00 m**
/// ⇒ 同一个几何路径,只是点序列/弧长不同 —— 这既证明「赛道认对了」,
/// 也证明「用探针曲线等分」的方案从根上就不可能对齐官方检查点。
///
/// yaw(R293 起)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点折线里
/// **含该点那一段**的方向(atan2(Δx, Δz))。语义 = 门面法向沿路 ⇒ 门横跨路面。
///
/// ★★★ R255d 的现象真因(R256 定案):**不是**朝向差 90°,而是两个独立问题叠加 ——
/// ① 成对镜像:当时 pairMirror=true 在**同一坐标**又摆了一扇,
/// 于是「两扇门叠在一块 / 都在中间」(服务端日志实证那局 n=26,但视觉上重叠);
/// ② 朝向从来没算对过:客户端探针 racing_guard.lua 的 yawOf() 写的是
/// math.atan(dx, dz),而 LuaJIT 的 math.atan **只有单参形式、第二参被静默丢弃**
/// ⇒ 实际算的是 atan(dx),恒在 ±90 附近。逐条对上(gym_tools/_r256_yawsemantics.py):
/// cp00→cp01 Δx=-69.83 ⇒ atan(-69.83) = -89.18°,表里实测 **-89.20**;
/// cp04→cp05 Δx=+0.25 ⇒ atan(0.25) = 14.00°,表里实测 **14.00**。
/// 外面包着 pcall 不报错 ⇒ 注释里写的 atan2 兜底**从未执行**。R256 已改为显式 atan2。
///
/// ⇒ yawOffset 保持 0,不要用 ±90 去「掰」。R255d 那条「必须 +90」是
/// 被上面两个假象误导后加的**猜测**,已被官方数据推翻。
///
/// 生成脚本:gym_tools/_r255_extract_tracks.py +
/// _r255_reduce_cp.py + _r255_gen_official_gates.py;
/// 报告 gym_tools/2.1诊断_赛车门官方权威位置_R255.md。
///
internal static readonly (float X, float Y, float Z, float Yaw)[] OfficialTrack3Checkpoints =
[
// ★★★★★ R293(2026-10-02)定案:yaw = **该检查点处的「道路方向」(局部切向)**
// —— ⛔ **R290 那版「指向下一个检查点」作废**。
// 用户原话(21:0x):「检查点可以不用朝向下一个点,就和道路平行就行了」。
//
// ✅ **为什么「指向下一个点」是错的**(复算脚本 `gym_tools/_r293_road_tangent.py`):
// 「道路方向」= 含该点的**那一段**官方折线方向;
// 「指向下一个检查点」= 取的是**下一段路的弦**。直道上二者等价,**弯道上差得离谱** ——
// 26 个点里 **9 个偏 > 20°**:
// cp07 偏 **114.8°**(入向 -91.6° vs 出向 +23.2°,急弯)
// cp22 偏 71.1° · cp10 偏 70.9° · cp14 偏 62.8° · cp05 偏 51.5°
// cp19 偏 50.5° · cp02 偏 39.1° · cp21 偏 34.9° · cp15 偏 34.5°
// `Jobrace_gongmen` 是 **X-Y 平面薄板**(`Box Volume ls=(21.56,14.46,2.42)` ⇒ 法向 = local Z)
// ⇒ 只有 **local Z 沿路** 门才「横跨路面」。用弦向就把门拧成「顺路躺」;
// 更糟的是 `ExpandGatePairs` 的 ±8 m 侧移是沿 `right(yaw)` 的 ⇒ 也被拧成
// **沿路前后串**(cp07 实测 A 在前、B 在后,相距 15.6 m)⇒ 两半根本合不成一扇门。
//
// ★ **数据来源(官方、可复算、零猜测)**:关卡块 `vfc_819` 明文 JSON
// `Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab`
// track0 = **259 点 / 3382.44 m / isClosed=false**。
// 26 个检查点**逐点落在折线上,最大偏差 0.009 m** ⇒ 直接取「含该点那一段」的方向。
// ⚠ 与本仓 `_r255_official_cp.txt` 里那列 yaw 最大差 **0.61°**(同源互证)。
//
// ⛔ **别拿箭头公式来套门**:官方 `CarRaceManager.lua:UpdateArrowPosAndRot` 走
// `LookRotation(nextTargetPos - targetPos)` ⇒ 那确实是「指向下一个检查点」,
// 但那是**指示牌 / 箭头**(`CheckPointArrow`,客户端的 `csToLua` 小地图箭头)的语义。
// 门(`Jobrace_gongmen`)是另一件东西。**R290 翻车就是拿箭头公式去套门**。
//
// ⚠ 官方箭头还做 `math.floor(y/10 + 0.5) * 10` 的 **10° 吸附**;本表用原始切向,不吸附。
//
// ★ A/B 开关:`racingGates.yawMode` = `road`(默认,本表)/ `next`(= 下面
// `OfficialTrack3YawNext`,R290 那套弦向)。改它**不用重编译、不用重启服务端**
// (`private-server.json` 热重载 + 每局开赛重读),下一局即生效。
( 2988.36f, -0.11f, 968.96f, -61.54f), // cp00
( 2918.53f, -0.11f, 1005.92f, -63.52f), // cp01
( 2758.34f, -0.11f, 1065.55f, -73.69f), // cp02 ← 弦向 -35.23(偏 39.1°)
( 2711.20f, -0.11f, 1132.29f, 31.30f), // cp03
( 2759.20f, -0.11f, 1228.22f, 0.18f), // cp04
( 2759.45f, -0.11f, 1308.83f, 0.18f), // cp05 ← 弦向 -51.27(偏 51.5°)
( 2691.94f, -0.11f, 1362.97f, -91.57f), // cp06
( 2540.19f, -0.11f, 1358.80f, -91.57f), // cp07 ← 弦向 +23.22(偏 114.8° 最狠)
( 2601.51f, -0.11f, 1501.76f, 27.93f), // cp08
( 2607.47f, -0.11f, 1639.18f, -0.78f), // cp09
( 2608.00f, -0.11f, 1717.72f, 0.56f), // cp10 ← 弦向 -70.32(偏 70.9°)
( 2518.33f, -0.11f, 1749.80f, -88.35f), // cp11
( 2442.01f, -0.11f, 1751.01f, -89.15f), // cp12 ← 弦向 -119.32(偏 30.1°)
( 2252.23f, -0.11f, 1644.42f, -124.37f), // cp13
( 2174.30f, -0.10f, 1626.09f, -89.92f), // cp14 ← 弦向 -27.12(偏 62.8°)
( 2147.08f, -0.10f, 1679.23f, 4.82f), // cp15 ← 弦向 -29.68(偏 34.5°)
( 2106.91f, -0.11f, 1749.70f, -88.48f), // cp16
( 2039.88f, -0.11f, 1765.89f, -87.05f), // cp17
( 1907.99f, -0.11f, 1756.08f, -88.33f), // cp18
( 1716.35f, -0.11f, 1765.58f, -90.16f), // cp19 ← 弦向 -140.65(偏 50.5°)
( 1671.20f, -0.04f, 1710.52f, -175.27f), // cp20
( 1681.39f, -0.10f, 1655.91f, 161.19f), // cp21 ← 弦向 +126.34(偏 34.9°)
( 1875.95f, -0.10f, 1512.77f, 113.35f), // cp22 ← 弦向 -175.56(偏 71.1°)
( 1868.70f, -0.11f, 1419.32f, -169.76f), // cp23
( 1805.49f, -0.11f, 1301.48f, -92.36f), // cp24
( 1582.79f, -0.11f, 1266.68f, -99.18f), // cp25 = 终点(含终点第 26 个)
];
///
/// R290 遗留:**「指向下一个检查点」的弦向 yaw**(atan2(Δx, Δz),末点回退用前一点)。
/// 只在 racingGates.yawMode = "next" 时使用,留作 A/B 对照与回滚。
/// ⚠ **默认不要用**:弯道上 9 个点偏 > 20°(cp07 偏 114.8°),见
/// 的说明。
/// 生成脚本 gym_tools/_r290_official_yaw_table.py。
///
internal static readonly float[] OfficialTrack3YawNext =
[
-62.11f, -69.58f, -35.23f, 26.58f, 0.18f, -51.27f, -91.57f, 23.22f,
2.48f, 0.39f, -70.32f, -89.09f, -119.32f, -103.24f, -27.12f, -29.68f,
-76.42f, -94.25f, -87.16f, -140.65f, 169.43f, 126.34f, -175.56f, -151.79f,
-98.88f, -98.88f,
];
/// R255:把内置官方表变成摆位计划(含 YawOffset 修正)。
///
/// ★★★ R256 —— **默认改用官方自己的检查点 prefab**(),
/// 不再用 R253 选的 Jobrace_gongmen(拱门)。
///
/// 为什么换回去:config/racing-gates.json 里(客户端探针写的)
/// 每个检查点本来就是 pathId 884611825 / 终点 271608686 ——
/// 也就是 Res/Prefab/Gadget/Racing_challenge01_CheckPoint.prefab。
/// 是 R253 起用 PathIdOverride=64493331 把它**覆盖**成了拱门。
///
/// R254 的注释写过「884611825 资源从未随包 ⇒ 隐形」,**该判断已被推翻**:
/// 关卡块 vfc_1479 里官方**实实在在地摆了 16 个该 prefab**
/// (+2 个 ...End,见 _r256_gate_instances.py / _r190_racing_checkpoints.json)
/// ⇒ 客户端一定能渲染它,资源必然随包。
/// 当轮"看不见"的真因是 **yaw 算错**(见下)把门转到了错误方向/位置。
///
/// 切换开关:racingGates.pathIdOverride。
/// 0(默认)= 官方检查点 prefab(终点自动用 ...End);
/// 64493331 = 回退到 R253~R255 的拱门(此时终点同 prefab)。
///
internal static List BuildOfficialTrackGates(RacingGateSettings cfg)
{
var list = new List(OfficialTrack3Checkpoints.Length);
var last = OfficialTrack3Checkpoints.Length - 1;
var overridden = cfg.PathIdOverride != 0;
// ★★★★★ R293 —— 朝向来源二选一(`racingGates.yawMode`):
// "road"(默认)= 表里的 c.Yaw = **该点道路局部切向** ⇒ 门横跨路面、两半跨路并排;
// "next" = R290 的弦向(OfficialTrack3YawNext)⇒ 只在弯道会偏,留作 A/B。
// 改这个键**不用重编译、不用重启服务端**(private-server.json 热重载,每局开赛重读)。
var useNext = string.Equals(cfg.YawMode, "next", StringComparison.OrdinalIgnoreCase);
for (var i = 0; i < OfficialTrack3Checkpoints.Length; i++)
{
var c = OfficialTrack3Checkpoints[i];
var isEnd = i == last;
var pid = overridden
? cfg.PathIdOverride
: (isEnd ? RacingGateEndPathId : RacingGateCheckPointPathId);
var baseYaw = useNext && i < OfficialTrack3YawNext.Length
? OfficialTrack3YawNext[i]
: c.Yaw;
list.Add(new RacingGatePlacement(
pid,
c.X, c.Y, c.Z,
baseYaw + cfg.YawOffset,
isEnd ? $"cp{i:D2}-END" : $"cp{i:D2}"));
}
return list;
}
private const string RacingGateLogKey = "client4229938-racing-gates";
/// 一条待摆放的门。
internal sealed record RacingGatePlacement(
int PathId, float X, float Y, float Z, float Yaw, string Label);
///
/// ⛔⛔ R258 定案:这个开关 = 「我们自己造的门宽」,不是官方数据 ⇒ 默认必须关。
///
/// 用户的两条纠正(R258,决定了本开关的命运):
/// 「路宽不一定代表门宽啊,不要查错方向了」+「赛道的宽度也是根据检查点的宽度来计算的」
/// ⇒ 门宽是「门」这件物件自带的,赛道按它建;所以**没有**需要我们填的宽度,
/// 也就不该有 pairMirror / pairOffset 这两个旋钮。
/// R257 把它们打开,等于「我们自己在给门调宽」—— 已被明确叫停。
///
/// R258 的官方摆位判据(这才是「一个实例够不够」的正确证法):
/// gym_tools/_r190_racing_checkpoints.json 里官方静态摆了 **14 个**
/// Racing_challenge01_CheckPoint,两两**最近距离 131.69 m、完全不成对**
/// ⇒ 官方就是「**一个检查点摆一个实例**」。
/// 再加上 gadget 注册表(vfc_907)里带「拱门」语义的物件**只有一个**
/// (Jobrace_gongmen 64493331,其 prefab 自带 _left/_right/_up)
/// ⇒ **一扇门 = 一个实例**,宽度由 prefab 自身决定,我们没有可填的数。
///
/// R254 的假设(已推翻):看到画面只有一根立柱 + 顶端向内弯折 ⇒ 认为
/// 一个 Jobrace_gongmen 实例只是**半扇拱门**,要同坐标摆两个(yaw 差 180°)才成整扇。
///
/// R255d 的实测证据(推翻它):
/// ① 资产扫描(`gym_tools/_r255c_gate_prefab_scan.py`,全库 1682 块)——
/// vfc_1583 里 **同一个 prefab** 自带三件套:
/// gp_arch_gongmen_blue_left(+`_left_lod2`)、
/// gp_arch_gongmen_blue_right(+`_right_lod12`)、
/// gp_arch_gongmen_blue_up(+`_up_lod`)⇒ **一个实例就是完整一扇门**。
/// R254 当时看到的「只有右半」,最可能是**左半被棕榈树 / 那栋黑楼挡住**。
/// ② 打开本开关后用户实测原话:「**门叠在一块了**」——
/// 因为两个完整门被摆在**同一坐标**,只能互相 z-fighting 重叠。
///
/// 结论:`pairMirror` 保持 **false**(配置 world.content.racingGates.pairMirror)。
///
/// ⛔ R258 更正本条:R257 把 A、B 拆到对侧(-ox/-oz)本身没错,
/// 但**整个「成对」模型是自造的**(见上)。官方每检查点只摆 1 个实例
/// ⇒ 一扇门 = 一个 prefab,不需要第二个实例,也就没有「柱到中线的距离」要调。
/// 所以 pairOffset 的真实语义只是「把整道门横向平移」,与「门宽」无关。
///
private static List ExpandGatePairs(
List plan, RacingGateSettings cfg)
{
if (!cfg.PairMirror || plan.Count == 0)
return plan;
var shift = cfg.PairOffset;
var outp = new List(plan.Count * 2);
for (var i = 0; i < plan.Count; i++)
{
var p = plan[i];
// ★★★★★ R291 定案(这是 `yawOffset` 从「空操作」变成「真旋钮」的那一行):
// **侧移方向必须用「未加 `yawOffset` 的基准 yaw」,朝向才用含 offset 的 `p.Yaw`。**
//
// 为什么必须解耦(代数,可直接验算):
// `yawOffset` 加在 `BuildOfficialTrackGates:254`(本函数之前)⇒ `p.Yaw` 已含 offset。
// 若侧移也用它:`right(yaw+180) = −right(yaw)` ⇒ A 拿到旧 B 的位置 + 旧 B 的朝向,
// B 拿到旧 A 的位置 + 旧 A 的朝向 ⇒ **{A,B} 集合完全不变** ⇒ `yawOffset` 是**空操作**。
// (R289 据此白烧一局;R290 立铁律 529。)
// 解耦后:位置由 `baseYaw` 定(与 offset 无关 ⇒ 位置永不随 offset 动),
// 朝向 = `p.Yaw` = `baseYaw + offset` ⇒ **每一半绕自己的根部原地转 `offset` 度**
// ⇒ `yawOffset = 180` 真正把每一半**原地翻 180°**。
//
// ★ R291 为什么要翻这一下:**官方 prefab 自己给出了答案(实例级 dump,不是像素反推)**:
// 客户端运行时对象树 `Jobrace_gongmen_` → `fx_gp_arch_gongmen_blue_lod2(Clone)`
// → `..Box Volume` 的局部位置 = **x = −8.94**、尺寸 **21.56 × 14.46 × 2.42**
// (薄边在 local Z ⇒ 门面是 local X‑Y 平面、法向是 local Z)。
// ⇒ **门体整体偏在 local −X 侧**(不是以根部为中心)。
// 我们给 `yaw = 该点指向下一检查点`(= 官方公式,R290 已与客户端 `arRot=(0,300,10)` 对上),
// Unity 约定 local +X = 赛道右侧 ⇒ **local −X = 赛道左侧** ⇒ 现门体朝**路外**长。
// ⇒ 与「两柱贴路两侧、顶端**朝路中央**收拢」正好相反 ⇒ **这就是用户说的「反了」**。
//
// Unity 约定:`forward = (sin yaw, 0, cos yaw)` ⇒ `right = (cos yaw, 0, −sin yaw)`。
// 直线段上本式与旧的中心差分**完全等价**(旧 n = (−cos yaw, +sin yaw) = −right)
// ⇒ R288 的 bug 修复性质不变。
var baseYaw = p.Yaw - cfg.YawOffset; // ★ R291:侧移基准(去掉朝向偏置)
var ox = 0f;
var oz = 0f;
if (shift != 0f)
{
var rad = baseYaw * (float)System.Math.PI / 180f;
ox = (float)System.Math.Cos(rad) * shift;
oz = -(float)System.Math.Sin(rad) * shift;
}
// A 摆 −right 侧、朝向 `p.Yaw`;B 摆 +right 侧、朝向 `p.Yaw+180°`
// ⇒ 两半的连线**严格垂直于路面**(同横截面、零前后错位);
// 两半各自绕根部转 `yawOffset` ⇒ `180` = 两臂朝路中央(R291 默认态)。
outp.Add(new RacingGatePlacement(
p.PathId, p.X - ox, p.Y, p.Z - oz, p.Yaw, p.Label + "-A"));
outp.Add(new RacingGatePlacement(
p.PathId, p.X + ox, p.Y, p.Z + oz, p.Yaw + 180f, p.Label + "-B"));
}
return outp;
}
private static readonly object RacingGateTableLock = new();
/// 同一个 session 只提醒一次「表还没有」——缺采集文件是设置步骤,不是运行错误。
private static bool RacingGateNoticed(TcpSession session)
{
lock (session.Items)
{
if (session.Items.ContainsKey(RacingGateLogKey))
return false;
session.Items[RacingGateLogKey] = true;
return true;
}
}
///
/// 在一局赛车开始时摆门。由 RunRacingAutoStartAsync 在 DISPLAY 推出去之后调用
/// (倒计时 3 秒期间门就该在了)。
///
internal static async Task SpawnRacingGatesAsync(
TcpSession session, WorldEntryState state, uint trackId, uint taskId)
{
var cfg = PrivateServerConfigStore.Current.World.Content.RacingGates;
if (!cfg.Enabled)
{
session.Log.Info("[RACEGATE] ABLATION racingGates.enabled=false — no gates placed");
return;
}
var table = LoadRacingGateTable(session, cfg);
// ① 先清上一局的残留 —— 按 pathId 过滤,绝不动健身器材 / 摊位 / 补给
// (R234 教训:守卫拿 taskId 做「一局一次」键,两局同 taskId 会原地早退;
// 所以这里不复用「已摆过」标记,而是**每局先清再摆**,天然幂等。)
// ★ R252:清哪些 pathId **不能只写死那两个常量** —— 表里可以换成别的物件
// (本轮就把检查点门从 `Racing_challenge01_CheckPoint` 换成 `SelfRacing_PortalGuide`),
// 写死常量会漏清 ⇒ 每局在原地叠一批、越跑越多。
// 所以 = {常量} ∪ {表里出现过的所有 pathId}。
try
{
var purgeIds = new List
{
RacingGateCheckPointPathId,
RacingGateEndPathId,
SelfRacingPortalGuidePathId, // ★ R253:R252 那轮摆的 16 个「指示牌」必须一起清
JobraceGongmenPathId, // ★ R253:本轮摆的拱门(自清,防换物件后残留)
S6RacingCheckPointPathId, // ★ R273:R270 误用的 S6 门(自清,否则换回官方件后叠一层)
};
foreach (var p in table)
{
if (!purgeIds.Contains(p.PathId))
purgeIds.Add(p.PathId);
}
var removed = 0;
foreach (var pid in purgeIds)
{
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid);
removed += n;
}
if (removed > 0)
{
session.Log.Info($"[RACEGATE] cleared previous batch n={removed} "
+ $"ids=[{string.Join(",", purgeIds)}]");
}
}
catch (Exception ex)
{
session.Log.Warn($"[RACEGATE] clear previous batch failed: {ex.Message}");
}
// ② 决定摆什么
// ★★★ R255:**默认走服务端内置的官方表**(26 个检查点,含终点)——
// 客户端那张采集表既丢 `checkpointIndex` 又做了不等距重采样,还被探针**每局重写**,
// 坐标不能托付给它。`UseOfficialTrack=false` 才回落到「表 / 信标」的 R254 行为。
List plan;
string source;
if (cfg.UseOfficialTrack && OfficialTrack3Checkpoints.Length > 0)
{
plan = BuildOfficialTrackGates(cfg);
source = "official-R255";
}
else if (table.Count > 0)
{
plan = table;
source = "table";
}
else if (cfg.BeaconEnabled)
{
plan = BuildRacingGateBeacons(state, cfg);
source = "beacon";
}
else
{
if (RacingGateNoticed(session))
session.Log.Info($"[RACEGATE] no table at {cfg.TablePath} and beacons off — "
+ "nothing to place (run one lap so the client writes the checkpoint table)");
return;
}
// ⛔ R258:本块默认**不执行**(`pairMirror: false`)。理由见 `ExpandGatePairs` 的说明 ——
// 「一个实例只是半扇、要摆两个」是 R254 起的**自造模型**,已被官方摆位数据推翻
// (官方 14 个静态检查点最近相距 131.69 m,不成对 ⇒ 一个检查点一个实例)。
// 用户明确要求「门宽由门自带宽,不要我们去调」⇒ 保留代码仅为应急,别默认打开。
if (cfg.PairMirror)
{
var before = plan.Count;
plan = ExpandGatePairs(plan, cfg);
session.Log.Info($"[RACEGATE] pair-mirror on: {before} -> {plan.Count} "
+ $"halfSpan={cfg.PairOffset:F1} (A=center-h, B=center+h)");
}
session.Log.Info($"[RACEGATE] spawn track={trackId} task={taskId} source={source} "
+ $"n={plan.Count} channel={(GadgetSendChannel)cfg.Channel} iScale={cfg.IScale} "
+ $"logOnly={cfg.LogOnly}");
if (cfg.LogOnly)
{
foreach (var p in plan)
session.Log.Info($"[RACEGATE] (dry) {p.Label} pathId={p.PathId} "
+ $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}) yaw={p.Yaw:F1}");
return;
}
// ③ 摆
var placed = 0;
foreach (var p in plan)
{
try
{
var result = await SpawnGadgetAsync(
session, state, p.PathId, p.X, p.Y, p.Z, p.Yaw,
(GadgetSendChannel)cfg.Channel, 0UL, cfg.IScale);
if (result.Ok)
{
placed++;
}
else
{
session.Log.Warn($"[RACEGATE] place FAILED {p.Label}({p.PathId}) "
+ $"at ({p.X:F1},{p.Y:F1},{p.Z:F1}): {result.Message}");
}
}
catch (Exception ex)
{
session.Log.Warn($"[RACEGATE] place threw {p.Label}({p.PathId}): {ex.Message}");
}
}
// ★ R252:这里打印**实际用到的** pathId(去重),别再只打那两个常量 ——
// 否则换表之后日志会骗人(「done 16/16 cpPathId=884611825」而实际摆的是 2017204172)。
var usedIds = new SortedSet();
foreach (var p in plan) usedIds.Add(p.PathId);
session.Log.Info($"[RACEGATE] done {placed}/{plan.Count} source={source} "
+ $"pathIds=[{string.Join(",", usedIds)}]");
// ★ R311:记下「这批门是什么时候摆出去的」—— 撤门宽限期(见 `RemoveCheckpointGatesAsync`)
// 要靠它算「客户端到底有没有时间把 prefab 加载出来」。
GateSpawnTick.Remove(session);
GateSpawnTick.Add(session, [Environment.TickCount64]);
}
// ────────────────────────────────────────────────────────────────────────────────
// ★★★★★ R294(2026-10-02)—— **过点即撤门 / 结束清场**
//
// 用户原话:「检查点开过去不会消失,而且比赛结束了所有检查点还在」。
//
// **真因**:`SpawnRacingGatesAsync` 一局只摆一次(26 点 × 2 半 = 52 个),摆完**从不回收**。
// 而官方的检查点标记是**跟着当前目标走的**:客户端 `CarRaceManager.lua` 只有一个
// `self.arrowGO`(`AddArrow` 加载、`RemoveArrow`/`Destroy` 销毁),过点后它被重建到下一个点,
// 跑完连它一起销毁 ⇒ 玩家看到的是「过点即消失」。我们 spawn 的是**服务端 gadget**,
// 客户端不知道它们的存在,所以永远不会自己消失。
//
// **撤哪几个**:客户端每过一个检查点就上报一条
// `AskSetTaskCounterValue(task=60000147, index=k, value=1)`(见 `GameRouter.RacingVehicle`),
// 而 **index 恰好等于我们的检查点下标** —— 客户端 `taskCounterList` 就是我们 Lua 喂的
// `R255_OFFICIAL_CP`(`racing_guard.lua:1077-1080, 1211`),
// 过 `list[k+1]`(= 我们的 cp k)时报 `index = (k+1) - 1 = k`。
//
// ⛔ **不能按 pathId 撤**:一局的 52 个门**同一个 pathId**(`Jobrace_gongmen`),
// 按 pathId 撤会把还没跑到的一起抹掉。所以按**坐标就近**挑出那一对:
// 官方 259 点折线上相邻检查点最近相距 **79 m**(cp00→cp01),而 A/B 只差 ±8 m,
// ⇒ 20 m 半径既能捞出完整的 2 个,又绝不会误伤隔壁。
// ────────────────────────────────────────────────────────────────────────────────
/// 过点撤门时的坐标匹配半径(米)。见上方 R294 段:相邻检查点最小间距 79 m。
private const float RacingGateRemoveRadiusM = 20f;
///
/// R311(2026-10-03)· 本会话**最近一次摆门**的时刻()。
/// 只服务「撤门宽限期」一件事;`ConditionalWeakTable` ⇒ 会话结束自动回收,不留引用。
///
private static readonly ConditionalWeakTable GateSpawnTick = new();
///
/// R312(2026-10-03)· 被「**前 N 个门延迟撤**」规则挂起的检查点下标(每会话一份)。
/// 用户原话:「前两个门设置为**到第三个门之后再消失**」。这里就是那个「挂起名单」——
/// `cp00`/`cp01` 上报通过时**不撤**,只登记;等 `cp02` 通过时连同 `cp02` 一起撤。
/// 元素类型用 `List<int>`(而非 `HashSet`)是为了日志里能按「通过的先后」原样打出来。
/// `ConditionalWeakTable` ⇒ 会话结束自动回收,不跨局/跨人泄漏。
///
private static readonly ConditionalWeakTable> DeferredGatePass = new();
/// R294:撤这一局可能摆过的**所有**赛车门 pathId。
internal static int[] RacingGatePathIdSet(RacingGateSettings cfg)
{
var set = new SortedSet
{
RacingGateCheckPointPathId,
RacingGateEndPathId,
SelfRacingPortalGuidePathId,
JobraceGongmenPathId,
S6RacingCheckPointPathId,
};
if (cfg.PathIdOverride != 0) set.Add(cfg.PathIdOverride);
return [.. set];
}
///
/// R294:撤掉**第 个检查点**那对门(A/B 两半)。
/// 返回撤掉的个数(0 = 这个点本来就没摆 / 已经撤过)。
///
internal static async Task RemoveCheckpointGatesAsync(
TcpSession session, WorldEntryState state, int cpIndex)
{
if (cpIndex < 0 || cpIndex >= OfficialTrack3Checkpoints.Length)
return 0;
// ★★★★★ R311(2026-10-03)· **撤门宽限期** —— 用户原话「**不是所有门都没有了啊,还是前两个**」。
//
// `console-20261003-015034.log` 时序(一局 52 个门在 `01:53:50` 同一秒全摆完):
// 摆门 +0 s → cp00 上报通过 +5 s → cp01 +9 s → cp02 +13 s → cp03 +16 s …
// 客户端按 `BabyDriver_CheckPointRange=22`(m) 上报「已过点」,服务端**上报即撤**
// ⇒ **前两个检查点的门在「客户端把 `Jobrace_gongmen` prefab 加载完并实例化」之前
// 就被 remove 抵消掉了** —— 这就是「前两个看不到、第三个起正常」的全部原因
// (第三个那对活过了首次加载的冷启动窗口)。
//
// ⛔ 不在这里 `await Task.Delay`:本方法的调用者 `GameRouter.RacingVehicle`
// 紧接着要 `await conn.ReturnEmptyOkAsync(msg)` 回执,拖十几秒客户端会判超时。
// ⇒ **丢给后台任务到点补撤**(`session.Active` 当守卫;`RemoveCheckpointGatesAsync`
// 本身按坐标重算 id 且幂等 ⇒ 重复调用安全)。
var gateCfg = PrivateServerConfigStore.Current.World.Content.RacingGates;
// ★★★★★ R312 · **用户指定规则**(2026-10-03 02:5x 原话):
// 「把前两个门的消失规则改一下,前两个门设置为**到第三个门之后再消失**,
// 第三个门后的所有门都维持现状」
//
// 【为什么不再靠「猜 prefab 加载耗时」】R311 把宽限期从「上报即撤」抬到 12 s,
// 用户实测仍是「没有任何变化」;而 `R311 kids` 诊断已证明门 GameObject
// **确实在客户端里**(`findGate(this cp)=11`,cp1..cp5 全命中)。
// ⇒ 与其继续调常量,不如把前两个门的生存期**锚到玩家的真实进度**上:
// 活到「第三个门被通过」为止 —— 这比任何秒数都稳。
//
// 【实现】前 `gateKeepFirstNCount` 个检查点:**登记挂起、不撤**;
// 走到 `cpIndex >= gateKeepFirstNCount`(= 第三个门)时,把**挂起的 + 当前的**
// 一次性撤掉。`gateKeepFirstNCount = 0` ⇒ 本条完全关闭,回到 R311 纯宽限期行为。
// ⛔ 撤挂起时走 `...CoreAsync` **绕过本分支** —— 否则那两个点会被再次挂起 ⇒ 永远撤不掉。
// ⚠ 挂起表按 session 存(`ConditionalWeakTable`)⇒ 换局/换人自动隔离,不跨局泄漏。
if (gateCfg.GateKeepFirstNCount > 0)
{
var held = DeferredGatePass.GetOrCreateValue(session);
if (cpIndex < gateCfg.GateKeepFirstNCount)
{
lock (held)
{
if (!held.Contains(cpIndex)) held.Add(cpIndex);
}
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass HELD (R312: first "
+ $"{gateCfg.GateKeepFirstNCount} gates survive until cp"
+ $"{gateCfg.GateKeepFirstNCount:D2} is passed)");
return 0;
}
List release;
lock (held)
{
release = [.. held];
held.Clear();
}
if (release.Count > 0)
{
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} reached -> releasing held gates "
+ $"[{string.Join(",", release)}] (R312)");
foreach (var h in release)
{
try
{
await RemoveCheckpointGatesCoreAsync(session, state, h, gateCfg);
}
catch (Exception ex)
{
session.Log.Warn($"[RACEGATE] cp{h:D2} held-release failed: {ex.Message}");
}
}
}
}
if (gateCfg.GateMinLifetimeMs > 0 && GateSpawnTick.TryGetValue(session, out var spawnBox))
{
var gateAge = Environment.TickCount64 - spawnBox[0];
if (gateAge < gateCfg.GateMinLifetimeMs)
{
var waitMs = (int)(gateCfg.GateMinLifetimeMs - gateAge);
var dsess = session;
var dstate = state;
var dIndex = cpIndex;
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} pass deferred {waitMs}ms "
+ $"(gate age {gateAge}ms < gateMinLifetimeMs={gateCfg.GateMinLifetimeMs}) "
+ "— 客户端可能还没把 Jobrace_gongmen 加载出来(R311 宽限期)");
_ = Task.Run(async () =>
{
try
{
await Task.Delay(waitMs);
// ⚠ `TcpSession` 没有 `Active` 成员(`Balloon.cs` 里的 `session.Active`
// 是另一套运行时对象的字段)⇒ 这里用最廉价的连通性判据 + try/catch 兜底。
if (dsess.Client is null || !dsess.Client.Connected)
{
dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove skipped (session gone)");
return;
}
// ⛔ R312:这里**必须**走 `...CoreAsync` —— 走公共方法会重新命中
// 「前 N 个挂起」分支,把自己又挂回去(永不撤除)。
var n = await RemoveCheckpointGatesCoreAsync(dsess, dstate, dIndex, gateCfg);
dsess.Log.Info($"[RACEGATE] cp{dIndex:D2} deferred remove done n={n}");
}
catch (Exception ex)
{
dsess.Log.Warn($"[RACEGATE] cp{dIndex:D2} deferred remove failed: {ex.Message}");
}
});
return 0;
}
}
return await RemoveCheckpointGatesCoreAsync(session, state, cpIndex, gateCfg);
}
///
/// R312:**真正的按坐标撤门**(纯执行,不含任何策略/宽限期/挂起逻辑)。
/// 抽出来是为了让「R311 撤门宽限期」和「R312 前 N 个门延迟到第 N+1 个通过再撤」
/// 两条策略都能调它,而**不会互相重入** —— 否则 `cpIndex < keepFirstN` 的那两个点
/// 会在递归里被**再次挂起** ⇒ 永远撤不掉(自己给自己下套)。
///
private static async Task RemoveCheckpointGatesCoreAsync(
TcpSession session, WorldEntryState state, int cpIndex, RacingGateSettings gateCfg)
{
var c = OfficialTrack3Checkpoints[cpIndex];
var keep = RacingGatePathIdSet(gateCfg);
var r2 = RacingGateRemoveRadiusM * RacingGateRemoveRadiusM;
List ids;
lock (state.SyncRoot)
{
ids = state.SpawnedGadgets.Values
.Where(r => Array.IndexOf(keep, r.PathId) >= 0)
.Where(r =>
{
var dx = r.X - c.X;
var dz = r.Z - c.Z;
return dx * dx + dz * dz <= r2;
})
.Select(r => r.InstanceId)
.ToList();
}
if (ids.Count == 0)
return 0;
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, 0, ids);
session.Log.Info($"[RACEGATE] cp{cpIndex:D2} passed -> removed n={n} "
+ $"ids=[{string.Join(",", ids)}] (at {c.X:F1},{c.Z:F1})");
return n;
}
/// R294:撤掉这一局摆过的**全部**赛车门(比赛结束 / 完成上报时调)。
internal static async Task RemoveAllRacingGatesAsync(TcpSession session, WorldEntryState state)
{
var keep = RacingGatePathIdSet(PrivateServerConfigStore.Current.World.Content.RacingGates);
var total = 0;
foreach (var pid in keep)
{
var (_, _, n) = await RemoveGadgetsAsync(session, state, null, pid);
total += n;
}
if (total > 0)
session.Log.Info($"[RACEGATE] cleared ALL racing gates n={total} "
+ $"pathIds=[{string.Join(",", keep)}]");
return total;
}
///
/// 读客户端 R247 落下的检查点表。文件不存在 / 解析失败都只是「还没有坐标」,
/// 退回信标模式,绝不抛。
///
private static List LoadRacingGateTable(TcpSession session, RacingGateSettings cfg)
{
var result = new List();
try
{
string path;
if (Path.IsPathRooted(cfg.TablePath))
{
path = cfg.TablePath;
}
else
{
var root = PrivateServerConfigStore.ResolveProjectPath(".");
path = Path.Combine(root, cfg.TablePath);
}
if (!File.Exists(path))
{
if (RacingGateNoticed(session))
session.Log.Info($"[RACEGATE] checkpoint table not found at {path}");
return result;
}
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("items", out var items)
|| items.ValueKind != JsonValueKind.Array)
{
if (RacingGateNoticed(session))
session.Log.Warn($"[RACEGATE] {path} has no 'items' array");
return result;
}
foreach (var node in items.EnumerateArray())
{
if (!node.TryGetProperty("pathId", out var pidNode)
|| pidNode.ValueKind != JsonValueKind.Number
|| !pidNode.TryGetInt32(out var pathId)
|| pathId == 0)
continue;
// ★★ R254:钉死 pathId —— 这张表是**客户端 R247 探针每局重写**的,
// 写进去的是客户端那套 id(884611825 / 271608686,资源从未随包 ⇒ 隐形)。
// R253 手工改成 64493331 后,用户跑一局就被覆盖回去了 ⇒ 必须服务端强制。
if (cfg.PathIdOverride != 0)
pathId = cfg.PathIdOverride;
if (!node.TryGetProperty("pos", out var posNode)
|| posNode.ValueKind != JsonValueKind.Array
|| posNode.GetArrayLength() < 3)
continue;
var xyz = new float[3];
var i = 0;
foreach (var c in posNode.EnumerateArray())
{
if (i >= 3) break;
xyz[i++] = c.ValueKind == JsonValueKind.Number ? c.GetSingle() : 0f;
}
var yaw = 0f;
if (node.TryGetProperty("yaw", out var yawNode) && yawNode.ValueKind == JsonValueKind.Number)
yaw = yawNode.GetSingle();
var label = node.TryGetProperty("name", out var nameNode)
&& nameNode.ValueKind == JsonValueKind.String
? nameNode.GetString() ?? ""
: $"gate{result.Count + 1}";
result.Add(new RacingGatePlacement(pathId, xyz[0], xyz[1], xyz[2], yaw, label));
}
if (result.Count > 0)
session.Log.Info($"[RACEGATE] table loaded n={result.Count} from {path}");
else if (RacingGateNoticed(session))
session.Log.Info($"[RACEGATE] table at {path} parsed to 0 usable rows");
}
catch (Exception ex)
{
if (RacingGateNoticed(session))
session.Log.Warn($"[RACEGATE] table read failed: {ex.Message}");
}
return result;
}
///
/// 「验证信标」:在玩家四周各摆一道门。用途只有一个 ——
/// 把「通道对不对」与「坐标对不对」解耦。看得见 ⇒ 通道没问题,只差那张坐标表。
///
private static List BuildRacingGateBeacons(
WorldEntryState state, RacingGateSettings cfg)
{
var list = new List();
float px, py, pz;
lock (state.SyncRoot)
{
var p = state.LastReportedPlayerPosition;
px = p.X;
py = p.Y;
pz = p.Z;
}
var ahead = cfg.BeaconAhead <= 0f ? 40f : cfg.BeaconAhead;
(float Dx, float Dz, string Tag)[] dirs =
[
(1f, 0f, "E"),
(-1f, 0f, "W"),
(0f, 1f, "N"),
(0f, -1f, "S"),
];
foreach (var d in dirs)
{
list.Add(new RacingGatePlacement(
SelfRacingPortalGuidePathId,
px + d.Dx * ahead,
py,
pz + d.Dz * ahead,
0f,
"beacon-" + d.Tag));
}
return list;
}
}