using System.Text.Json; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; namespace Ananta.Server.Configuration; /// /// Human-editable private-server settings. /// /// Protocol constants (RPC ids, serializer layouts, client wire types) deliberately do NOT live /// here. Those belong to Protocol/Client4229938 and should only change when supporting a new client. /// public sealed class PrivateServerConfig { public ClientSettings Client { get; init; } = new(); public NetworkSettings Network { get; init; } = new(); public AdminSettings Admin { get; init; } = new(); public LoginSettings Login { get; init; } = new(); public PlayerSettings Player { get; init; } = new(); public WorldSettings World { get; init; } = new(); public ContentSettings Content { get; init; } = new(); public GameplaySettings Gameplay { get; init; } = new(); public UiSettings Ui { get; init; } = new(); public LoggingSettings Logging { get; init; } = new(); public PathSettings Paths { get; init; } = new(); public DebugSettings Debug { get; init; } = new(); internal void Validate() { static void Port(int value, string name) { if (value is < 1 or > 65535) throw new InvalidDataException($"{name} must be 1..65535, got {value}."); } if (Debug.Enabled) Port(Debug.Port, "debug.port"); if (Client.Version != ClientBuild.Version) throw new InvalidDataException($"client.version={Client.Version} is not supported by this source tree; expected {ClientBuild.Version}."); if (Client.ServerId <= 0) throw new InvalidDataException("client.serverId must be positive."); if (string.IsNullOrWhiteSpace(Client.RpcMd5)) throw new InvalidDataException("client.rpcMd5 must not be empty."); if (string.IsNullOrWhiteSpace(Network.BindHost) || string.IsNullOrWhiteSpace(Network.AdvertisedHost)) throw new InvalidDataException("network.bindHost and network.advertisedHost must not be empty."); if (Player.Pid == 0) throw new InvalidDataException("player.pid must not be zero."); if (Player.InitialUnitId == 0) throw new InvalidDataException("player.initialUnitId must not be zero."); if (Player.InitialSpiritTemplateId == 0) throw new InvalidDataException("player.initialSpiritTemplateId must not be zero."); if (string.IsNullOrWhiteSpace(Player.AccountId) || string.IsNullOrWhiteSpace(Player.UserName) || string.IsNullOrWhiteSpace(Player.DisplayName)) throw new InvalidDataException("player.accountId, player.userName and player.displayName must not be empty."); if (string.IsNullOrWhiteSpace(Player.LoginToken) || string.IsNullOrWhiteSpace(Player.GameToken) || string.IsNullOrWhiteSpace(Player.ShareToken) || string.IsNullOrWhiteSpace(Player.FpPassToken) || string.IsNullOrWhiteSpace(Player.Skey)) throw new InvalidDataException("player compatibility tokens must not be empty."); if (World.RaidId == 0 || World.SceneInstanceId == 0 || World.UniverseId == 0) throw new InvalidDataException("world.raidId, sceneInstanceId and universeId must not be zero."); if (World.Content.Enabled && string.IsNullOrWhiteSpace(Paths.WorldData)) throw new InvalidDataException("paths.worldData must not be empty while world.content.enabled."); if (World.Content.Enabled && string.IsNullOrWhiteSpace(World.Content.Scene)) throw new InvalidDataException("world.content.scene must not be empty."); ValidateStreamWindow(World.Content.SceneItems, "world.content.sceneItems"); ValidateStreamWindow(World.Content.Gadgets, "world.content.gadgets"); if (World.Content.DynamicGo.SeedAttempts is < 1 or > 60) throw new InvalidDataException($"world.content.dynamicGo.seedAttempts must be 1..60, got {World.Content.DynamicGo.SeedAttempts}."); // ⚠ 2026-10-01 订正:下面这句「值不会被用上」已随 donor《content-streaming》落地失效。 // 现在 unloadRadiusCells(卸载边界)与 retainVisitedCells(保留环)都被流式代码真的读取, // removeGridInfo 也会带内容下发 —— 硬规则 212「从不发真移除」废弃。 // ⛑ 仍然成立的那半:loadBudgetCells 至今仍是纯校验键(真正控量的是 maxCellsPerPush)。 // —— 历史原文(2026-09-28 实测):unloadRadiusCells 与 loadBudgetCells 只在这里被读一次, // 两个网格通道的流式代码既不按 unloadRadiusCells 卸载(removeGridInfo 恒为空), // 也不按 loadBudgetCells 控量(用的是 maxCellsPerPush)。 static void ValidateStreamWindow(T settings, string name) where T : class { var radius = (int)(typeof(T).GetProperty("RadiusCells")?.GetValue(settings) ?? 0); var unload = (int)(typeof(T).GetProperty("UnloadRadiusCells")?.GetValue(settings) ?? 0); var budget = (int)(typeof(T).GetProperty("LoadBudgetCells")?.GetValue(settings) ?? 0); if (radius is < 1 or > 8) throw new InvalidDataException($"{name}.radiusCells must be 1..8, got {radius}."); if (unload < radius || unload > radius + 8) throw new InvalidDataException($"{name}.unloadRadiusCells must be within radiusCells..radiusCells+8, got {unload}."); if (budget is < 1 or > 200) throw new InvalidDataException($"{name}.loadBudgetCells must be 1..200, got {budget}."); } if (Network.LoginPorts is null || Network.LoginPorts.Length != 2) throw new InvalidDataException("network.loginPorts must contain exactly two ports."); Port(Network.LoginPorts[0], "network.loginPorts[0]"); Port(Network.LoginPorts[1], "network.loginPorts[1]"); Port(Network.GamePort, "network.gamePort"); Port(Network.Proxy.HttpsPort, "network.proxy.httpsPort"); Port(Network.Proxy.HttpPort, "network.proxy.httpPort"); Port(Network.Proxy.LoginListPort, "network.proxy.loginListPort"); Port(Network.Proxy.LoginTcpPort, "network.proxy.loginTcpPort"); Port(Network.Proxy.GameTcpPort, "network.proxy.gameTcpPort"); Port(Network.Proxy.SceneSubPort, "network.proxy.sceneSubPort"); if (Admin.Enabled) { if (string.IsNullOrWhiteSpace(Admin.Host)) throw new InvalidDataException("admin.host must not be empty when admin.enabled=true."); Port(Admin.Port, "admin.port"); } if (string.IsNullOrWhiteSpace(Network.Proxy.UpdateHost) || string.IsNullOrWhiteSpace(Network.Proxy.CertificatePassphrase)) throw new InvalidDataException("network.proxy.updateHost and certificatePassphrase must not be empty."); if (Content.Roster.UnitIdBase == 0) throw new InvalidDataException("content.roster.unitIdBase must not be zero."); if (Content.Roster.TemplateIdMin >= Content.Roster.TemplateIdMaxExclusive) throw new InvalidDataException("content.roster.templateIdMin must be less than templateIdMaxExclusive."); if (Gameplay.Combat.MaxHp <= 0) throw new InvalidDataException("gameplay.combat.maxHp must be positive."); if (Gameplay.Combat.Skills.All().Any(x => x == 0)) throw new InvalidDataException("gameplay.combat.skills contains id 0."); if (Gameplay.Combat.Charges.Length == 0) throw new InvalidDataException("gameplay.combat.charges must not be empty."); foreach (var charge in Gameplay.Combat.Charges) { _ = Gameplay.Combat.Skills.ByName(charge.Skill); if (charge.Max == 0 || charge.Current > charge.Max || charge.Period < 0) throw new InvalidDataException($"Invalid gameplay.combat.charges entry for '{charge.Skill}'."); } if (Gameplay.WebTraversal.SharedBuffIds.Length == 0) throw new InvalidDataException("gameplay.webTraversal.sharedBuffIds must not be empty."); if (!Gameplay.WebTraversal.SharedBuffIds.Contains(Gameplay.WebTraversal.PersistentGrappleBuffId)) throw new InvalidDataException("persistentGrappleBuffId must also be present in sharedBuffIds."); if (Gameplay.WebTraversal.GrappleRearmDelayMs < 0) throw new InvalidDataException("gameplay.webTraversal.grappleRearmDelayMs must be >= 0."); if (Gameplay.Vehicles.Enabled) { if (Gameplay.Vehicles.FleetIds.Length == 0) throw new InvalidDataException("gameplay.vehicles.fleetIds must not be empty when vehicles are enabled."); if (Gameplay.Vehicles.MinimumApproachMeters <= 0 || Gameplay.Vehicles.DesiredApproachMeters < Gameplay.Vehicles.MinimumApproachMeters) throw new InvalidDataException("gameplay.vehicles approach distances are invalid."); if (Gameplay.Vehicles.SearchRadiusMeters < Gameplay.Vehicles.DesiredApproachMeters) throw new InvalidDataException("gameplay.vehicles.searchRadiusMeters must cover desiredApproachMeters."); if (string.IsNullOrWhiteSpace(Paths.ZoneGraphLanes)) throw new InvalidDataException("paths.zoneGraphLanes must not be empty while vehicles are enabled."); } if (Ui.UidLabel.Enabled && string.IsNullOrWhiteSpace(Ui.UidLabel.Text)) throw new InvalidDataException("ui.uidLabel.text must not be empty when ui.uidLabel.enabled=true."); if (Ui.CutscenePanel.Enabled) { if (string.IsNullOrWhiteSpace(Ui.CutscenePanel.Host)) throw new InvalidDataException("ui.cutscenePanel.host must not be empty when enabled."); Port(Ui.CutscenePanel.Port, "ui.cutscenePanel.port"); } if (string.IsNullOrWhiteSpace(Logging.Directory)) throw new InvalidDataException("logging.directory must not be empty."); if (Logging.Packets.MaxBodyBytes < 0) throw new InvalidDataException("logging.packets.maxBodyBytes must be >= 0 (0 means unlimited)."); if (string.IsNullOrWhiteSpace(Paths.ClientConfigs)) throw new InvalidDataException("paths.clientConfigs must not be empty."); if (string.IsNullOrWhiteSpace(Paths.RuntimeFastpatch)) throw new InvalidDataException("paths.runtimeFastpatch must not be empty."); } } public sealed class ClientSettings { public int Version { get; init; } public string ArtifactVersion { get; init; } = string.Empty; public int ServerId { get; init; } public int Aid { get; init; } public string RpcMd5 { get; init; } = string.Empty; public int NoMoreHotfixPatchError { get; init; } } public sealed class LoginSettings { /// /// Shape of the unsolicited SyncRoleList notify sent after TryLogin. /// /// list sends an empty List<ulong> (7 bytes on the wire: 5-byte /// notification header + FF 01). That is the only shape that works on a cold /// login. /// /// /// The legacy shape was a bare ulong pid (8 bytes). It looks plausible and is /// wrong: a notify named "RoleList" is expected to carry a collection. With the bare /// pid the client stalls inside its login state machine — heartbeats keep flowing on /// the same socket and RequestEnterGame simply never arrives, so the failure /// looks like a network hang with no error anywhere. /// /// /// pid restores the legacy shape for bisecting against an older capture; /// none suppresses the notify entirely. /// /// public string RoleListMode { get; init; } = "list"; } public sealed class NetworkSettings { public string BindHost { get; init; } = string.Empty; public string AdvertisedHost { get; init; } = string.Empty; public int[] LoginPorts { get; init; } = []; public int GamePort { get; init; } /// /// 服务端 → 客户端的传输层 keepalive 间隔(毫秒),帧 mode=0x03。 /// 默认 0 = 关闭(和 2026-10-01 之前的行为完全一致)。 /// 联机/多会话场景才需要:一个卡死的客户端不该把别人的会话一起拖死。 /// 🛑 回滚 = 设回 0,不用重新编译。修改后立即生效的是**新连接**,已建立的会话沿用旧值。 /// public int SocketHeartbeatMs { get; init; } /// /// 单次加密写的上限(毫秒)。超过就把这条连接退役,避免慢/卡死的接收方把写锁占死。 /// 0 = 不设上限(旧行为)。 /// ⛑ 本地单机跑的话给个宽松值就行;15000ms 足够容纳最大的那一批 SceneItem / PropRestock 推送。 /// public int WriteTimeoutMs { get; init; } = 15000; public ProxyNetworkSettings Proxy { get; init; } = new(); } public sealed class ProxyNetworkSettings { public int HttpsPort { get; init; } public int HttpPort { get; init; } public int LoginListPort { get; init; } public int LoginTcpPort { get; init; } public int GameTcpPort { get; init; } public int SceneSubPort { get; init; } public string UpdateHost { get; init; } = string.Empty; public string CertificatePassphrase { get; init; } = string.Empty; } public sealed class AdminSettings { /// Embedded localhost admin panel (no auth — bind 127.0.0.1 only). public bool Enabled { get; init; } = true; public string Host { get; init; } = "127.0.0.1"; public int Port { get; init; } = 17888; } public sealed class PlayerSettings { public ulong Pid { get; init; } public ulong InitialUnitId { get; init; } public uint InitialSpiritTemplateId { get; init; } public string AccountId { get; init; } = string.Empty; public string UserName { get; init; } = string.Empty; public string DisplayName { get; init; } = string.Empty; public string LoginToken { get; init; } = string.Empty; public string GameToken { get; init; } = string.Empty; public string ShareToken { get; init; } = string.Empty; public string FpPassToken { get; init; } = string.Empty; public string Skey { get; init; } = string.Empty; public uint LoginUniverseId { get; init; } } public sealed class WorldSettings { public uint RaidId { get; init; } public ulong SceneInstanceId { get; init; } public uint UniverseId { get; init; } public Vector3Settings Spawn { get; init; } = new(); public float Facing { get; init; } public bool EntryOpeningEnabled { get; init; } public WorldContentSettings Content { get; init; } = new(); } /// /// World content streaming. The client renders lampposts and street props from /// its baked SceneItemBlob once the server pushes the destructible grid window; /// gadgets (lifts, doors, vending machines) use the client build's own baked /// GadgetBlob through SyncGadgetGridAOIDecrease; DynamicGOs are re-seeded while /// the client finishes loading its world headers. All data comes from paths.worldData. /// public sealed class WorldContentSettings { /// Master switch for all world-content streaming. public bool Enabled { get; init; } = true; /// Legacy primary scene name retained for config compatibility. public string Scene { get; init; } = "WorldMap_Release"; /// /// Build-local scene data allowed to supplement the current free-roam scene. /// FIX23V uses only WorldMap_Release for this server/raid; SceneItems themselves are /// resolved from the client SceneItemBlob and do not use this list as a placement whitelist. /// public string[] ProductionScenes { get; init; } = ["WorldMap_Release"]; public SceneItemStreamSettings SceneItems { get; init; } = new(); public GadgetStreamSettings Gadgets { get; init; } = new(); public ElevatorSettings Elevators { get; init; } = new(); public SceneDeviceSettings SceneDevices { get; init; } = new(); public DynamicGoStreamSettings DynamicGo { get; init; } = new(); public DoorSettings Doors { get; init; } = new(); /// 赛车检查点门(2026-10-03 移植自捐赠分支)。见 。 public RacingGateSettings RacingGates { get; init; } = new(); /// Gym equipment the server places itself (squat / bench / treadmill / sit-up). public GymEquipmentSettings GymEquipment { get; init; } = new(); /// /// 街头篮球 QTE 的篮筐(2026-10-03)。MiniGame prefab 里没有篮筐模型, /// 得由服务端刷一个 Gadgets_Common_BasketballRim 到判定点位置。 /// ⛑ 偏移/角度放配置里是为了免编译微调:改 json + 面板 reload-config 即可, /// 不用再因为挪 2 米就停服重新编译。 /// /// /// 搜打撤(ExtractionShooter,「不要抬头!」)。默认关闭 —— 改 true 且 raid 名单命中才会走 /// 进图 / 容器 / 背包那套结构。回滚不需要重新编译。 /// public ExtractionShooterSettings Extraction { get; init; } = new(); /// Every other client-blob prop the server restocks by world cell. public PropRestockSettings PropRestock { get; init; } = new(); /// 家具摆放探针(T1-22 排障)。默认全关,开了才会在玩家脚下摆物件。 public DebugPlaceSettings DebugPlace { get; init; } = new(); /// Answer to the client's AskMetroGadgetIds(67809129) — carriage gadget ids. public MetroGadgetSettings MetroGadgets { get; init; } = new(); /// /// Per-prefab PackedGadgetInfo.spoonSpecialList overrides (2026-09-23). Some props stay /// inert unless the placement carries special params — the mini-game cabinets are the proven /// case, see . /// public GadgetSpoonParamSettings GadgetSpoonParams { get; init; } = new(); /// /// 室内扇区(sector_high_indoor_<IndoorId>)—— 服务端按 IndoorConfig 的最近条目 /// 推出玩家所在室内,然后推 SyncLoadIndoorSector(68095220) / SyncIndoorSectorControl(68971505)。 /// 默认关闭:线格式是静态推导出来的,先 logOnly 验一轮再开。 /// public IndoorSectorSettings IndoorSectors { get; init; } = new(); /// /// 派对场地家具(露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车)。 /// 与健身房器材同因同解:这批 Gadgets_Party_* 只活在客户端关卡 blob 里, /// grid-AOI 下发不驱动 GadgetBlob ⇒ 必须服务端按官方场地坐标显式摆一次。 /// 见 GameRouter.PartyCamp.cs。回滚:world.content.partyCamp.enabled = false。 /// public PartyCampSettings PartyCamp { get; init; } = new(); /// /// 龙栖村(raid 23300999)互动摊位机关 + 打气球玩法。 /// 见 GameRouter.MiniGameStall.cs / GameRouter.Balloon.cs。 /// 回滚:world.content.miniGameStalls.enabled = false。 /// public MiniGameStallSettings MiniGameStalls { get; init; } = new(); } /// /// Indoor sector streaming (2026-09-27). The client's sector_high_indoor_<N> roots are /// pure scene-subtree visibility switches — lights, mirrors, LOD decoration groups. They are a /// fifth channel, unrelated to the gadget / scene-item grids, and until now nothing on the /// server pushed them, which is one reason indoor official layouts never appeared. /// public sealed class IndoorSectorSettings { /// False = never push. Rollback lever, no rebuild needed. public bool Enabled { get; init; } = false; /// True = only log what would be pushed (safe way to validate the id derivation). public bool LogOnly { get; init; } = true; /// /// 进入判定半径(米)。玩家离某个 IndoorConfig 条目比这更近就算「在室内」。 /// 新启 54 个有坐标条目的最近邻距离中位数 ≈ 100 m、最小 33 m ⇒ 默认 60 m 是折中。 /// public float RadiusMeters { get; init; } = 60f; /// /// 离开判定半径(米)。0 = 用「进入半径 + 15」的迟滞,避免在门口反复 Load/Unload。 /// public float ExitRadiusMeters { get; init; } = 0f; /// /// 是否附带推 SyncIndoorSectorControl(68971505)(显式 true/false 开关)。 /// 注入端的 sectorstate 是两条一起喂的(SyncIndoorSectorControl + 引擎层 /// SetIndoorSectorState),所以默认开。 /// public bool PushControl { get; init; } = true; /// /// 状态没变时的重声明间隔(毫秒)。0 = 只在状态变化时推一次。 /// 与 gadget / sceneitem 网格同症状:不持续声明,客户端会把窗口丢掉。 /// public int HeartbeatMs { get; init; } = 5000; /// /// 除扇区 Id(176000xx)外,是否也把裸 IndoorId 推一遍。 /// ★ 2026-09-27 20:1x 实机 A/B 已定案:默认关。 /// 同一轮里推 9 个扇区 Id + 1 个裸 IndoorId,客户端日志: /// SetIndoorSectorState SectorControlId:0, indoorSectorStates:(17600101,True)…(17600109,True),(76,True) /// + config not found for id 76 —— 只有裸 IndoorId 查不到,9 个扇区 Id 全被接受。 /// ⇒ 这个字典的 key 就是 WorldStateIndoorSectorConfig.Id,不是 IndoorId。 /// (另一佐证:无扇区配置的 Bowtie Lounge indoorId=44 只推裸 44,客户端同样报 not found。) /// 留着这个开关只是给以后万一要重做 A/B 用。 /// public bool AlsoPushRawIndoorId { get; init; } = false; /// /// 是否优先采信客户端 SyncChangeIndoor 上报的 indoorConfigId(权威值), /// 而不是服务端的「几何最近邻」推算。 /// ★ 2026-09-27 20:3x 实机:ACD Nova Branch(76)与 ACD快乐房(129)的 /// IndoorConfig.MiniMapPos 矩形互相重叠,纯几何最近邻在重叠区每几秒就在 /// 76 / 129 之间跳一次 ⇒ 客户端每跳一次就把 9 个扇区的子树建了又删、删了又建。 /// 客户端自己上报的 Enter GpsBound: IndoorId: 76 才是不抖的那个(对得上 76)。 /// public bool PreferClientReport { get; init; } = true; /// /// 客户端上报值的保鲜期(毫秒)。超过这个时间没再上报就退回几何判定 /// (客户端只在进出 bound 时才发 SyncChangeIndoor,站着不动不会持续发)。 /// public int ClientReportGraceMs { get; init; } = 15000; /// /// 换室内前的确认延时(毫秒):候选 id 必须连续稳定这么久才真正切换。 /// 两个来源(客户端上报 / 几何)各自都会瞬时抖到 0 或隔壁场馆,光靠「优先采信」 /// 治不了 —— 必须再叠一层防抖。0 = 不防抖(回到 20:3x 抖动的旧行为)。 /// public int SwitchDelayMs { get; init; } = 3000; } /// /// Doors as gadgets (T2-11, donor rounds 16C-16I). AskPlayerEnterOrLeaveDoor arrives as a NOTIFY /// and the server answers by restating the entity through SyncGadgetRefresh(68765074) with the /// DoorModuleSyncInfo filled in - there is no dedicated door RPC in the client surface. /// public sealed class DoorSettings { /// False = log only, never push. Rollback lever, no rebuild needed. public bool Enabled { get; init; } = true; /// /// Also answer the client's own LEAVE reports (ProximityState = NoPlayer). Keep true: the /// client does not always emit LEAVE, which is why the donor added the timer below. /// public bool PushOnLeave { get; init; } = true; /// /// Degrees the leaf swings to while a player is inside it. Panel-owned semantics (round /// 16I-5): 90 is the hinge idiom and is safe for sliders too. 0 behaves like "no animation". /// public float OpenAngle { get; init; } = 90f; /// /// Also restate the door through SyncGadgetRefresh(68765074) / DoorModuleSyncInfo. False by /// default: that path is accepted but its leaf behaviour tops out at "the leaf disappears", /// which donor round 16H counted as success. Left here as a comparison lever only. /// public bool PushGadgetRefresh { get; init; } = false; /// /// Safety net from donor round 16I-4: N seconds after an ENTER with no LEAVE, push NoPlayer. /// Without it the leaf never closes because the client alone does not restate the module. /// 0 = disable the timer. /// public int AutoCloseSeconds { get; init; } = 10; } /// /// Scene devices — doors, vending machines, fridges, arcades. An Ask* device INVOKE is only a /// REQUEST: the client's device-state manager waits for the server to ECHO the transition before /// it plays the interaction, so replying "ok" without the echo leaves the device stuck. /// Every switch here is a rollback lever — see GameRouter.SceneDevices.cs. /// public sealed class SceneDeviceSettings { /// Master switch. Off = fall back to the generated neutral replies (no echo). public bool Enabled { get; init; } = true; /// Echo SyncSceneDeviceStateChange(68165561) on an accepted change-state. public bool EchoState { get; init; } = true; /// Echo SyncSceneDeviceValueChange(68332775) on an accepted record-value. public bool EchoValue { get; init; } = true; /// /// Reply to AskCheckSpoonConditionByType(67709054) — "may I run this device script?". /// /// ⛔⛔ **2026-09-28 实机订正(用户验证):这里必须是 false。** /// 旧注释写「生成的中性默认 0x00=false 会让客户端拒绝跑设备脚本,donor round 13 答 0x01」 /// —— 对**衣柜这一族是反的**,而且 donor 那条从来没有被实测过,只是照抄的推断。 /// 实测经过(塔菲公寓衣柜 uid 14013633775178039662, /// prefab `SpoonSlot_WardrobeDoor_FashionChange_tafeiapartment`,pathId 371305360): /// · 回 **0x01(true)**:F 提示一闪即没、换装打不开。 /// `logs/packets-20260928-204201.log` 21:03:18.961 S2C RETURN body=1b hex=**01** /// (同一条 60b C2S payload 尾部就是衣柜 uid 6E41E7CDAB6D7AC2), /// `[SPOON] action` 只发 2 次(21:03:59 / 21:04:05)就停。 /// · 回 **0x00(false)**:F 常驻;`logs/console-20260928-224215.log` 22:46:37 `condition -> False`、 /// 22:46:44 `[SPOON] action` 触发,其 60b payload 与对照组 v1.5 成功换装时**逐字节相同**。 /// 对照组 v1.5 根本没有本 handler,走 `DefaultReturnCatalog4229938` 兜底 0x00 ⇒ 衣柜一直正常。 /// ⇒ 结论:**false = 客户端走本地表现、脚本照跑;true = 客户端把交互交给服务端脚本流程, /// 而我们给不出脚本输出(AskDoSpoonServerActionByType 只回空 OK)⇒ 交互挂死。** /// 回滚:本值改回 true 即复现「衣柜 F 不显示」。配置键 = /// world.content.sceneDevices.spoonConditionTrue(配置里已显式写 false)。 /// ⛑ 默认值同步改成 false(旧默认 true 是坑:一旦配置里没有这个键就会复现 bug)。 /// public bool SpoonConditionTrue { get; init; } = false; /// Device ids that must never be echoed (client-local flows that regress). Decimal. public string[] EchoOffIds { get; init; } = []; } /// /// Lifts. The cab never moves on its own — the client asks, the server has to echo the movement /// back with SyncMobilePlatformInfo(68705244) or nothing happens at all. /// public sealed class ElevatorSettings { public bool Enabled { get; init; } = true; /// How long the ride takes before the arrival push lands (ms). public int TravelMs { get; init; } = 2500; /// /// LiftDoorMode sent with the ARRIVAL push (dump.cs:1750057 — Normal=0, StayOpen=1, Closed=2). /// The hall call (F) only produces AskMoveMobilePlatform; there is no "open the door" /// RPC on the wire, so if the cab parks with Normal the doors stay shut and the player can /// never step in. StayOpen is the only value that asks the client to hold the doors. /// Set to 0 to restore the old (doors stay shut) behaviour. /// public byte ArrivalDoorMode { get; init; } = 1; /// /// Push SyncLiftDoorMode(68799106) with after the cab /// parks. Without it the doors stay shut: LiftMgr keeps its own per-lift LiftDoorMode and /// nothing in the hall call asks it to open. /// public bool OpenDoorsOnArrival { get; init; } = true; } public sealed class GymEquipmentSettings { /// False = never place anything. Rollback lever, no rebuild needed. public bool Enabled { get; init; } = true; /// /// Which S2C channel places the prop: /// 0 = SyncGadgetAdd(68843189) one entity per push /// 1 = SyncGadgetAOIAddAndRemove(68169646) /// 2 = both of the above /// 3 = SyncGadgetAddList(68925813) one push for the whole venue /// /// 0 is the one proven to build the entity (name / position / interact point all correct, and /// the minigame is playable). ⚠️ It is NOT the channel AnantaGymLoader.exe uses — the loader /// calls SyncGadgetAddList(68925813) with iScale=0, see the IScale note. Try 3 (with IScale 0) /// if props still come out invisible on channel 0. Its wire shape is inferred /// (single-arg RPC ⇒ inline), not yet proven live. /// public int Channel { get; init; } = 0; /// /// PackedGadgetInfo.iScale sent with every gym prop. /// /// 0 is the value AnantaGymLoader.exe writes (agent.js gymspawn2: pk.field("iScale").value = 0) /// and the value carried by every one of the 400 blob records read out of /// GadgetBlob.tmpPackedGadgetExtendInfo.basePackedInfo. The client renders it 1:1, which is what /// self-streamed props (vending machines, cat cafe) end up with. /// 100 (the GM-panel default) makes gym entities come out with transform scale (0,0,1) — /// a zero-scale shell with no prefab. /// public int IScale { get; init; } = 0; /// /// Extra metres added to every venue's own radius before its equipment is placed. Raise it if /// the venue's anchor is outside the building; 0 keeps each spot's own radius. /// public float RadiusBonus { get; init; } = 0f; /// /// ★ 2026-10-01 千泊健身房实测:摆放撞在客户端「切室内」的那一瞬,19 条 SyncGadgetAdd 全部被吞 /// (服务端 19/19 下发成功、客户端零痕迹,场馆当场空着);而场馆每会话只铺一次 /// (GymEquipmentSessionState.Placed)⇒ 一次丢 = 整场会话都空。 /// 同一场馆、同样 19 条包,客户端切室内完成 12 秒后再摆就全部正常出现(用户实机确认)。 /// /// 判据 = 客户端 SyncChangeIndoor 的上报时刻(WorldEntryState.ClientIndoorReportMs): /// 失败那次上报与摆放同一秒,成功那次相隔 12 秒。0 = 关掉这个守卫(回到旧行为)。 /// ⛑ 别改用 IndoorSectorSessionState.LastPushMs —— 它每次心跳都刷新(连"该场馆无扇区 ⇒ /// 什么都不推"也算),拿它当守卫会让器材永远摆不出来。 /// public int SettleAfterIndoorChangeMs { get; init; } = 3000; /// 传送流程未结束(state.PendingTeleportId != 0)时不摆放 —— 同一类「客户端正在换场景」的窗口。 public bool SkipDuringTeleport { get; init; } = true; /// Log the placements but push nothing. Use it to dry-run a fresh capture. public bool LogOnly { get; init; } = false; } /// /// 搜打撤(ExtractionShooter,本季 Sanhok-free 的「不要抬头!」)的服务端侧总开关。 /// /// 这套内容与 AccountInstanceId / world-content 三条管线无关,是纯服务端状态驱动的: /// 服务端自己记住包里有什么、容器里有什么,客户端只有 Ctrl 出来的 view。 /// 所以关掉它不会有任何残留 —— 全部状态都在 session.Items 里。 /// public sealed class ExtractionShooterSettings { /// False = 所有搜打撤 handler 直接放行 / 跳过,零结构性改动。 public bool Enabled { get; init; } = false; /// /// 被当作搜打撤图的 raid 名单。 /// /// 为什么是名单而不是 RaidType:配置表里**没有** raid ↔ 搜打撤图的外键 /// (ExtractionShooterConfig 只给「图 → BirthPoints」),官方文档用落点包围盒判图, /// 而服务端这里只需要「当前 raid 要不要挂搜打撤 UI」⇒ 名单最直白。 /// 23301277 = 不要抬头!(scene 20001319);23301215 终局之战;23301311 古墓摸金。 /// public uint[] RaidIds { get; init; } = [23301277]; /// /// 退出请求(67504722)是否真的把人扔回主城。 /// /// ⛑ 关掉它 ≠ 关掉整套:等待房和主城也会发这条请求,必须有 /// IsExtractionRaid 守卫(handler 里已有),这个开关只是多一层。 /// public bool ExitToMainCity { get; init; } = true; /// 进图时给每个包下发的初始 token(代币)数。0 = 不下发。 public uint InitialTokenCount { get; init; } = 0; /// /// 容器一次刷出来的物件数上限。ExtractionShooterItemContainerConfig 自己有 /// QuantityMin/Max,这里是第二次夹紧(用于防止吃满带宽的大箱)。 /// public int MaxRollPer { get; init; } = 64; /// /// 伪造匹配:客户端点「开始」的那四条请求(AskStartSingleMatch 63347492 / /// StartBotMatch 63474603 / StartMatchInTeam 63563208 / /// StartGameInTeam 63121362)直接切进 。 /// /// 为什么要有这个:官方的开局要过真实匹配服 / 队伍服,服务端补不出那条链路, /// 而客户端点开始之后就在等切图 —— 不接住就是卡在开始界面。 /// ⛑ 默认关:这四条是通用请求(别的玩法也走),开着会把所有开局都截到搜打撤去。 /// 真要玩的时候在面板勾上(面板改动只在内存里,重启回落到这里)。 /// public bool FakeMatch { get; init; } = false; /// 伪造匹配要去的 raid。默认 23301277(不要抬头!)。 public uint PreferredRaid { get; init; } = 23301277; /// /// 退图之前先推一条 SyncRaidSettlement(68995324)。 /// /// 「帧全对但人卡住」里有一整类是缺出场:每伪造一次进场,都要反查客户端有没有对应的 /// 出场请求被空 OK 吞掉。结算帧是副本流程的收尾信号 —— 直接拔线回主城的客户端 /// 有时会把结算 UI 挂在屏幕上下不来。 /// 零战斗数据时这条包恒 32 字节(见 handshake 文档 §6.7)。 /// public bool ExitSettlement { get; init; } = true; /// /// 武器轮盘的实体化下发方式:full(默认,每次变动重发整表)/ incremental(只发变化的格) /// / off(不下发 = 修复前的行为,轮盘只显示物品、Tab 切不到)。 /// 写错的值按 full 处理。 /// public string WheelSync { get; init; } = "full"; /// /// 轮盘物品是否随 itemInfo 一起带 WeaponData。带上客户端才能把「背包那一格」和 /// 「那件实体化武器」连起来(否则枪的 tooltip 退化成普通物品描述)。关掉用于回退验证。 /// public bool WheelWeaponData { get; init; } = true; } /// /// Restocks client-blob props by world cell: vending machines, indoor chairs/sofas, claw machines, /// mini-game cabinets, basketball courts, bouncy beds, market stalls — everything the client keeps /// in LX6.Item.GadgetBlob but never instantiates from our grid-AOI push alone. /// Data comes from Configs/PropRestock.json (_gen_proprestock.py builds it out of a blob dump). /// public sealed class PropRestockSettings { /// False = never place anything. Rollback lever. public bool Enabled { get; init; } = true; /// /// How many cells around the player's own cell get stocked. 1 = the 3x3 neighbourhood /// (120 m across at a 40 m cell), which is roughly the client's own streaming window. /// public int RadiusCells { get; init; } = 1; /// /// Which S2C channel places the props: 0 = SyncGadgetAdd(68843189), 1 = AOI /// SyncGadgetAOIAddAndRemove(68169646), 3 = SyncGadgetAddList(68925813). /// 0 is the proven one. /// public int Channel { get; init; } = 0; /// /// PackedGadgetInfo.iScale sent with every prop. Must stay 0: 100 produces an entity with the /// right name and a working interact point but transform scale (0,0,1) — no prefab. /// public int IScale { get; init; } = 0; /// Log the placements but push nothing. Use it to dry-run a fresh capture. public bool LogOnly { get; init; } = false; } /// /// 家具摆放探针(T1-22 排障)。走已经验证过的 SpawnGadgetAsync 显式摆放通道, /// 在玩家脚下周围摆一圈指定 pathId 的真物件,用来判断「显式摆放到底通不通」。 /// 全部参数热重载;想再摆一次把 Gen +1。见 GameRouter.DebugPlace.cs。 /// public sealed class DebugPlaceSettings { /// False = 什么都不摆。回滚开关。 public bool Enabled { get; init; } = false; /// /// 要摆的 prefab pathId(来自 Configs/GadgetCatalog.json)。 /// 常用:2012164699 = Bench_chairs_p_common_prop_roadsystem_yiht01(公交站长椅)、 /// 192285565 = Bench_chairs_p_map01_indoor_prop_mcdonalds_seat001a(麦当劳椅)。 /// public int PathId { get; init; } = 0; /// 摆几个(沿圆周均匀分布)。 public int Count { get; init; } = 4; /// 离玩家多远(米)。 public float Radius { get; init; } = 3.5f; /// 相对玩家脚底的高度偏移(米)。 public float OffsetY { get; init; } = 0.05f; /// 通道:0 = SyncGadgetAdd(已验证) / 1 = AOI / 3 = SyncGadgetAddList。 public int Channel { get; init; } = 0; /// /// PackedGadgetInfo.iScale。必须是 0:100 会摆出一个名字对、交互点也有、 /// 但缩放是 (0,0,1) 的实体 —— 看不见模型。 /// public int IScale { get; init; } = 0; /// 只打日志不发包。 public bool LogOnly { get; init; } = false; /// 每次 +1 就重新摆一轮(热重载生效,不用重启、不用重编译)。 public int Gen { get; init; } = 0; } /// /// PackedGadgetInfo.spoonSpecialList — per-instance spoon parameters carried on a placement. /// /// ★ Proof this matters (frida dump of 161 live client instances, 2026-09-23): the ONLY props whose /// spoonSpecialList is non-null are the SpoonSlot_* doors and MiniGameMachine_Bee /// (pathId 223752202) carrying markId 31511937 = "True". Furniture/seats carry none, which /// is why "sit down" never depended on this field. The Bee cabinet is different: without the /// param the client builds the entity but the mini-game never arms. /// /// Keyed by pathId so every channel picks it up automatically — GM spawn, gym equipment and /// PropRestock all funnel through GameRouter.BuildGadgetEntityInfo. /// Rollback: enabled = false (props go back to a null spoonSpecialList). /// public sealed class GadgetSpoonParamSettings { public bool Enabled { get; init; } = true; /// pathId → params. JSON keys are strings (e.g. "223752202"). public Dictionary> ByPathId { get; init; } = new(); } public sealed class GadgetSpoonParamEntry { /// PackedGadgetSpecialParam.markId — 31511937 for the Bee cabinets. public int MarkId { get; init; } /// PackedGadgetSpecialParam.value — the string "True", not a boolean. public string Value { get; init; } = ""; } /// /// AskMetroGadgetIds(67809129) answer. The client builds the carriage prefab itself and then asks /// the server which gadget instance ids belong to it; before this existed the reply was the /// typed-default empty list, so the SpoonSlotComponent seats in the carriages never got a hold and /// no "sit down" button ever appeared. /// /// The ids here are invented (option A): the client only needs an id to bind its local seat slot /// to. If seats still stay dead, switch to really spawning the chairs through SyncGadgetAdd with /// the LightRailChair pathIds (843740076 / 1058671153 / 267309514 / 1181678445) and hand those /// instance ids back instead. /// public sealed class MetroGadgetSettings { /// False = keep answering the empty list (pre-2026-09-23 behaviour). Rollback lever. public bool Enabled { get; init; } = true; /// /// Fallback only: how many carriages to answer for when RailTrainConfig/RailLineConfig cannot /// resolve the metroId. With those configs present the real composition is used instead. /// public int Carriages { get; init; } = 3; /// Fallback only: seat gadget ids handed back per carriage (InnerGadgetIds). public int SeatsPerCarriage { get; init; } = 6; /// /// Seats per carriage template id (RailTrainConfig CarriageIds value -> seat slot count). /// These counts are NOT in any config table — they live in the carriage prefab, so the only way /// to fill them is to dump a live train. Verified 2026-09-23 on LightRail_4_405 (line 4, /// template 3, CarriageIds [4,5,9]): template 4 (head) has 4 seats, template 5 has 6. /// Anything missing falls back to . /// ⛑ 2026-09-27 21:3x 已知矛盾:Vehicle03/End 这一布局类里同时配了 /// tpl4=4 与 tpl9=6,而两者的 TemplateName/ViewModelName 完全相同(只差 /// IsRevert 镜像)⇒ 座位数本该一样,必有一个是实测数错的。 /// 代码里取用时按模板 id 升序取第一个并打一条 warn(GameRouter.MetroGadget.cs)。 /// ⛑ 仍缺实测值的模板:10/11(Vehicle01 头)、2/7(中)、3/8(尾)、12/14(CX01 端)、 /// 13(CX01 中)、15(游船)—— 目前全靠「借同车型 / 退化成 6」,座位数不对时客户端会把 /// 整节车厢静默丢弃(座位按不出「坐下」)。补法见 GameRouter.MetroGadget.cs 的 warn 文案。 /// ★ The client matches InnerGadgetIds index-for-index against the carriage's own seat slots, so /// a wrong count here means that whole carriage silently gets no "sit down" button at all. /// public Dictionary SeatsByCarriageTemplate { get; init; } = new() { [4] = 4, [5] = 6 }; /// Seat count used for carriage templates not listed in . public int DefaultCarriageSeats { get; init; } = 6; /// True = derive one answer entry per carriage from RailTrainConfig. False = flat Carriages x SeatsPerCarriage. public bool UseTrainConfig { get; init; } = true; /// /// Base of the invented id range. Gadget instance ids live in the 9.5e12 band (hard rule 23), /// and the formula below keeps every train/carriage/seat combination unique and stable: /// id = IdBase + metroId * 10000 + carriage * 100 + seat. /// public ulong IdBase { get; init; } = 9_500_000_000_000UL; /// Also fill OuterGadgetIds (things mounted outside the carriage). Off by default. public bool IncludeOuter { get; init; } = false; /// Log what would be answered but still return the empty list. public bool LogOnly { get; init; } = false; } /// /// Grid window for baked scene items (lampposts, props, breakables, 可破坏/可投掷物). /// /// 2026-09-24 起这条配置真的接上了实现:服务端按 40m 网格推 /// SyncDestructibleGridAOIDecrease(68969593),客户端从自己的 SceneItemBlob 出货 /// —— 等价于 AnantaWorldMod 注入器,不再需要 frida。见 GameRouter.SceneItemGrid.cs。 /// ⛑️ 之前它只是个空壳:没有代码读它,mid 也用错成 68934309(那条只有 aoiInfo 一个字段)。 /// public sealed class SceneItemStreamSettings { public bool Enabled { get; init; } = true; /// Window half-size in 40m cells (7x7 window at 3). public int RadiusCells { get; init; } = 3; /// Tighter ring for cells carrying interior sectors. public int RadiusInteriorCells { get; init; } = 4; /// /// ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3): /// 窗口外的格子在离开这个半径(Chebyshev,以当前所在格为中心)时才会被显式卸载。 /// 以前它是空头支票 —— 见下面那段保留的历史注释。 /// /// 历史(⛔ 2026-09-28 实测):当时这个值只被 Validate() 的范围校验读一次, /// 两个网格通道的 removeGridInfo 恒为空,改它不会有任何效果(硬规则 212)。 /// donor 文档 §1/§3/§6 给了完整语义 —— 吃这个值的叫 **卸载边界**: /// added = window − prevLoaded;keep = prevLoaded 中 ≤ unloadRadius 的那些; /// removed = prevLoaded − keep;next = window ∪ keep。 /// ⛑ 回滚真卸载:realUnload = false(回到「只增不减」的旧行为)。 /// public int UnloadRadiusCells { get; init; } = 6; /// /// ★ 2026-10-01 起**真的生效**:true = 走出窗口但仍留在 unloadRadiusCells /// 边界内的格子不下发卸载,也不重新加载(donor §3「保留环」)。 /// false = 出窗口即卸载(等价于 unload = radius,物件来回重加载的风险更高)。 /// /// 历史(⛔ 2026-09-28 实测):这个键当年是**纯空操作** —— 唯一引用在 /// GameRouter.GadgetGrid.cs 的 /// if (cfg.RetainVisitedCells && !cfg.LoadAllSectors) budget = Math.Max(budget, 1); /// 而 budget 本来就是 Math.Max(1, MaxCellsPerPush),那行恒等于原值(已删)。 /// public bool RetainVisitedCells { get; init; } = true; /// /// ★ donor《content-streaming-implementation》§3 —— 真正下发 removeGridInfo。 /// /// 关掉它 = 回到 2026-09-28 之前的行为:格子只增不减,走过的永不卸载 /// (客户端驻留对象数一路涨,2026-09-27 §10.9.3 内存项第 3 条)。 /// ⛑ 排查顺序:怀疑卸载把身边物件删掉了 ⇒ 先改 realUnloadLogOnly=true /// (只打「本来要卸哪些格子」,一条报文都不发),确认卸载集合是窗外的环形再放开。 /// public bool RealUnload { get; init; } = true; /// 只记录将要卸载的格子,不下发报文、也不从已声明集合里摘除(完全等价于旧行为)。见 。 public bool RealUnloadLogOnly { get; init; } = false; /// /// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳(两个窗口不相交)时 /// 清空已声明集合并按「首次进窗口」重铺。判据 = 格子 Chebyshev 距离 > unloadRadiusCells。 /// 关掉它 = 回到「Sent 只增不减,远眺留下一堆幽灵格子」的旧行为。 /// public bool ReseedOnFarJump { get; init; } = true; /// When true, request every authored object in each production cell instead of applying the extracted sector filter. public bool LoadAllSectors { get; init; } = false; /// /// ⛔ 零消费点 —— 全仓没有任何代码读它(连 Validate() 都不看), /// true / false 对行为没有任何影响。2026-09-28 核实。留字段只为兼容已落盘的 JSON。 /// public bool ExcludeTestItems { get; init; } = true; /// /// ⛔⛔ 名字骗人 —— 只被 Validate() 校验 1..200,真正的预算是 MaxCellsPerPush。 /// 推送代码用的是 var budget = Math.Max(1, cfg.MaxCellsPerPush), /// 所以改这个值只会改校验结果,不会改每轮发多少格。见硬规则 212。 /// public int LoadBudgetCells { get; init; } = 49; // ---- 以下为 2026-09-24 新增:68969593 网格流的时序参数(与 gadget 网格同构) ---- /// How often the loop looks at the player's cell (ms). public int TickMs { get; init; } = 1000; /// /// Grace period after world-ready before the first Loaded push. The client needs its grid /// data resident first — the injector waits for the same reason. Kept a little later than the /// gadget grid (12000) so the two streams don't hit the client in the same frame. /// public int FirstDelayMs { get; init; } = 16000; /// /// Re-declare the window this often even when the player stands still (ms). 0 = off. /// /// ⛔⛔ 2026-09-30 起本通道默认 0(关),理由见 : /// 站桩时整窗重推 = 客户端对已加载格子重新 AddByIndex = **重新实例化** ⇒ 玩家看到 /// 「可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位」。 /// 旧的注释「Symptom this fixes: items appear for a second and vanish」已被 AnantaWorldMod /// 反证(它从不重推,物件也不消失)—— 那条实证其实是**重推本身造成的闪**,因果写反了。 /// 想恢复旧行为:本键 5000 + 120000 + ResendWindowOnMove=true。 /// public int HeartbeatMs { get; init; } = 5000; /// /// Full re-send of the surrounding window after this long (ms). Safety net. /// ⛑ 0 = 关闭(GameRouter.SceneItemGrid.cs 里有 `cfg.RefreshMs > 0` 守卫; /// 没有这个守卫时填 0 会变成「恒真」⇒ 每个 tick 都整窗重推,比心跳还频繁)。 /// public int RefreshMs { get; init; } = 120000; /// Cells per 68969593 message. One message carries one sector list, never more. public int MaxCellsPerPush { get; init; } = 64; /// /// Re-send the WHOLE window on a cell crossing instead of only never-seen cells. The client /// treats addGridInfo as its current AOI snapshot, not as a delta. /// /// ⛔⛔ 2026-09-30 起默认 false —— 上面那句「客户端把 addGridInfo 当 AOI 快照」的假设 /// 已被参考实现反证:AnantaWorldMod(new-worldmod/world_mod.js:3525-3550)每 700ms 遍历 /// 窗口,`if (loaded.has(key)) continue;` —— **每格只 AddByIndex 一次,从不重推**,物件照样在。 /// 而我们 true 时每次跨格 / 每 5 s 心跳整窗重推 ⇒ 客户端对已加载格子重新实例化 ⇒ /// **物件每 5 秒闪一次、位移 / 破坏状态 / 念力 hold 全部被重置**(用户 2026-09-30 实测症状)。 /// ⇒ 本键 false + =0 + =0 = **严格增量**, /// 与 AnantaWorldMod 等价:已刷出的格子永不重推。 /// ⛑ 回滚 = 三个键改回 true / 5000 / 120000,不需要动代码。 /// public bool ResendWindowOnMove { get; init; } = true; /// /// Push SyncDestructibleSceneAOIActive(68418115) = true once per session before the /// first grid push. It belongs to the DESTRUCTIBLE family (that is the family this channel /// is named after), but hard rule 31 records that pushing it around interiors corrupted /// interior state, so it stays OFF by default. /// 排障顺序:物品一个都不出 → 先开这个;室内门/店员消失 → 立刻关回去。 /// public bool ArmActiveSwitch { get; init; } = false; /// /// 念动力探针(硬规则 28):用 aoiInfo.addInfos 在玩家正前方凭空摆一个可抓 SceneItem。 /// 这是 T1-22 里唯一能**直接验证**服务端显式摆放链路的手段 —— 箱子出现 ⇒ addInfos 这条 /// 路通,家具不出只是缺坐标;箱子都不出 ⇒ 先修通道,别再往坐标表里填数。默认关。 /// ⛑️ 只摆一次(会话级),否则心跳 / 跨格重声明每隔几秒就叠一个箱子。 /// public bool SpawnTestBox { get; init; } = false; /// /// 探针用的 SceneitemConfig id。80001877 = 官方测试盒,CanQInteraction=true, /// 客户端会实例化成真的 SceneItem 并被 MindPowerMgr:GetAimItem() 认成 Q 目标。 /// public int[] SpawnTestBoxCfgIds { get; init; } = [80001877]; } /// Grid window for client-baked gadgets (lifts, doors, vending machines). public sealed class GadgetStreamSettings { public bool Enabled { get; init; } = true; public int RadiusCells { get; init; } = 3; /// /// ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3): /// 走出本边界(Chebyshev > 本值,以当前所在格为中心)的格子会被显式卸载。 /// ⛔ 历史:见 —— 2026-09-28 实测时 /// 它只被 Validate() 读一次,本通道的 removeGridInfo 也恒为空(硬规则 212 作废)。 /// ⛑ 回滚真卸载:realUnload = false。 /// public int UnloadRadiusCells { get; init; } = 6; /// ★ 见 —— 2026-10-01 起真的生效:卸载边界内的保留环。 public bool RetainVisitedCells { get; init; } = true; /// ★ 见 —— 真正下发 removeGridInfo。默认开;false = 只增不减的旧行为。 public bool RealUnload { get; init; } = true; /// ★ 见 —— 只记不发。 public bool RealUnloadLogOnly { get; init; } = false; /// ★ 见 —— 传送 / 远跳重新铺窗。默认开。 public bool ReseedOnFarJump { get; init; } = true; /// When true, request every authored gadget in each production cell instead of applying the extracted sector filter. public bool LoadAllSectors { get; init; } = false; /// ⛔ 见 —— 只被 Validate() 校验, /// 真正的预算键是 maxCellsPerPush。 public int LoadBudgetCells { get; init; } = 49; /// /// Gadget UniqueIds the client must never instantiate — hard rule 25. Gadget 200448 /// ships with a baked sceneItemId of 0; the 4229938 client enters an infinite /// StateMachineContainer recursion the moment it LoadDynamicItem(200448, 0) and /// TryGetDjPathHashBySceneItemId fails on "SceneCfg at id 0 is null" — which on a live /// client looks exactly like a freeze-then-disconnect. /// The ids are restated on every grid push as addGridInfo.ExceptIds plus /// aoiInfo.removeIds / aoiInfo.inactiveIds, so the one broken gadget stays /// dormant while everything else in the same cell still streams normally. /// (donor reference value: [200448].) /// public ulong[] ExcludedGadgetIds { get; init; } = [200448]; // ---- Gadget grid streaming (SyncGadgetGridAOIDecrease 68956797) ---- // Replaces the AnantaGadgetLoader exe: the server tells the client which 40 m cells to load // and the client materializes its own authored props from its GadgetBlob. /// How often the loop looks at the player's cell (ms). public int TickMs { get; init; } = 1000; /// /// Re-declare the current window this often even when the player stands still (ms). 0 = off /// (legacy: push only on first load, cell crossings and the periodic refresh). /// Symptom this fixes: props showed up for a few seconds after a teleport and then vanished — /// the client appears to drop grid cells the server stops declaring. /// public int HeartbeatMs { get; init; } = 5000; /// /// Grace period after world-ready before the first Loaded push. The client needs its grid /// data resident first — the injector tool waits ~20 s for the same reason. /// public int FirstDelayMs { get; init; } = 8000; /// Full re-send of the surrounding window after this long (ms). Safety net, not the main path. public int RefreshMs { get; init; } = 120000; /// /// ★★ 2026-10-04「入世界守卫」—— 首次整窗推送后等这么久,强制做一次 整窗 remove → add /// (先下发 removeGridInfo 卸掉全部已声明格子,清空 Sent,下一次 tick 以 reason=1 重新铺窗)。 /// 0 = 关闭。realUnload=false 或 realUnloadLogOnly=true 时只清服务端集合、不发卸载报文。 /// /// 病因(已用 console-20261003-235851.log 坐实):客户端 /// SyncGadgetGridAOIAddDecrease 只在「格子从不在 AOI 到在 AOI」这一次跃变上实例化物件; /// 首推时客户端 GadgetBlob 还没常驻 ⇒ SyncGadgetGridAOIAddByBlob 静默 return, /// 之后 heartbeatMs=5000 的整窗重声明补不回来(4.5 分钟 ~50 次重推,洞不愈合)。 /// 玩家「跑远再跑回来」之所以好,唯一作用就是走出 unloadRadiusCells 触发一次真·卸载, /// 回来时格子变成新格 ⇒ 重新实例化。这里在服务端自己造一次同样的跃变,玩家不用跑。 /// /// ⛑ 只在每个会话做一次(SettleDone);远跳 reseed 已经做过时也算完成。 /// public int SettleReseedMs { get; init; } = 15000; /// Tighter ring for cells that carry interior sectors (rooms pop in on entry otherwise). public int RadiusInteriorCells { get; init; } = 4; /// Cells per 68956797 message. One message carries one sector list, never more. public int MaxCellsPerPush { get; init; } = 64; /// /// Re-send the WHOLE current window on every cell crossing instead of only the cells the client /// has never seen. The client appears to treat addGridInfo as its current AOI snapshot: an /// AOIMove carrying only the newly-entered cells made the previously streamed props vanish one /// frame after they appeared. Off = legacy incremental behaviour (Sent set filters the window). /// public bool ResendWindowOnMove { get; init; } = true; /// /// RESEARCH LEVER ONLY — default OFF. Pushes SyncDestructibleSceneAOIActive (68418115) once per /// session before the first grid push. /// 68418115 belongs to the DESTRUCTIBLE family, not the gadget grid: donor round 14C measured /// that streaming destructible windows around interiors corrupts interior state (vanishing /// doors, missing outfit NPC), and round 14E set the switch to disabled. Kept here so the /// claim stays testable, but it must stay false in normal play. /// public bool SceneAoiSwitchEnabled { get; init; } = false; } /// DynamicGo re-seeding after gameplay-ready. public sealed class DynamicGoStreamSettings { public bool Enabled { get; init; } = true; /// How many times SyncDynamicGoActiveInfo is re-sent (client headers load async). public int SeedAttempts { get; init; } = 12; /// Activate every DynamicGo id found in the build-4229938 probe. public bool ActivateAllKnown { get; init; } = true; /// Force explicitly-authored test/temp/debug DynamicGo blockouts inactive. public bool ExcludeTestPlaceholders { get; init; } = true; } public sealed class Vector3Settings { public float X { get; init; } public float Y { get; init; } public float Z { get; init; } internal Vec3 ToVec3() => new(X, Y, Z); } /// /// One entry of content.roster.rowOverrides. Mirrors the shape the fastpatch generator /// consumes; the server only needs the id and the optional display label. /// public sealed class RosterRowOverride { public uint SpiritId { get; init; } /// Panel display name for this row. Empty = keep the FightSpiritConfig name. public string Label { get; init; } = ""; } public sealed class ContentSettings { public RosterSettings Roster { get; init; } = new(); public SummonWheelSettings SummonWheel { get; init; } = new(); public uint[] ExcludedUnlockSystems { get; init; } = []; public bool IncludePhoneBottomApps { get; init; } } public sealed class RosterSettings { public uint TemplateIdMin { get; init; } public uint TemplateIdMaxExclusive { get; init; } public uint[] ExcludedTemplateIds { get; init; } = []; /// /// Roster rows that bypass BOTH roster filters and are force-included even though the normal /// intake would drop them. /// /// The stock intake drops a row for either of two reasons (ClientConfigRepository.Load): /// * it is listed in ; /// * its icon equals (28000032) — the "asset not /// authored yet" marker, meaning this filter is what really hides unfinished characters. /// A row named here escapes both, so it joins the roster (login payload spirits, the in-game /// character lists, and anything the admin switch panel can target) without having to weaken /// those filters globally. /// /// Currently used for 15021042 Lemon Sour (AgentId 40140076): a complete playable row /// (HavePrivateWeapon, WeaponSlot, Quality 3) that shipping never published — IsPublish=false, /// CanJoin=false, Invisible=true — and that never got a real head icon. /// /// A listed id escapes the TemplateIdMin..TemplateIdMaxExclusive window as well, which is what /// lets the 15022xxx "占位" (placeholder) rows join the roster without opening the window for the /// ~68 dev rows that live up there (test robots, 武学 folders, PVP dummies). /// public uint[] AdditionalTemplateIds { get; init; } = []; /// /// Per-row config surgery, shared with the client fastpatch (the generator turns the very same /// array into tools/spirit_row_override.lua). The server only reads two of the keys: /// spiritId and label. cloneFrom / set are client-side only and are /// ignored here on purpose — the client's own copy of FightSpiritConfig is what the game reads, /// so the agent swap must happen there, not in the server projection. /// /// label overrides the name the admin panel shows for that row. Without it a cloned row /// still carries the placeholder's own name (OC FlightSpirit占位_幼女 and friends), because the /// server projection keeps the stock AgentId and therefore resolves the stock display name. /// public RosterRowOverride[] RowOverrides { get; init; } = []; /// /// When true (default) a roster row is dropped unless it has a real head icon /// (SHeadIconID > 0 and not ). /// /// /// Set false to publish rows whose portrait was never authored. They render with the /// placeholder head on the Z wheel, but the character itself is playable — /// SHeadIconID == 0 means "no portrait authored", not "not playable", so the /// filter was silently hiding finished characters as if they were locked. /// (Same knob the 08.05_fix3 "Lifesession" donation build added.) /// /// public bool RequireIconId { get; init; } = true; public uint GenericPlaceholderIconId { get; init; } public ulong UnitIdBase { get; init; } public int DefaultUrbanJob { get; init; } /// /// Characters offered by the in-game switch wheel (Z), sent through SyncTaskRoleTeam (64534724). /// /// Accepts either id space — 8-digit numbers are read as NpcCultivationIds (91050014 = Taffy, /// the ids the player actually knows), anything shorter as a TaskRoleConfig.Id (102000 = Taffy). /// Both translate to TaskRoleConfig.Id before they go on the wire. /// /// Empty (default) = never send SyncTaskRoleTeam, leaving the client on its own fallback /// (the FightSpiritConfig rows with CanJoin=true). Non-empty takes the list over entirely, /// so anything you want to keep has to be listed. /// public uint[] SwitchableRoles { get; init; } = []; } /// /// Tab-wheel summoning (嘉姆 / 帮派Boss wheel, client method 63745420 AskSummonGangMember). /// /// The wheel has exactly seven slots and the client side owns that count, so slots cannot be /// added. What the server CAN own is what each slot resolves to — and that is what makes this /// build able to summon characters the stock wheel never offers. /// public sealed class SummonWheelSettings { /// /// Per-slot redirection: SentId is whatever the client puts on the wire for that slot, /// TargetId is what actually gets spawned. /// /// This exists because the id the client sends is not stable or meaningful to the server — /// measurements showed the same wheel sending either the gang-member row id or the display /// row's AgentQuoteId depending on build, and neither necessarily names a spawnable character. /// Mapping it server-side means the answer stops depending on guessing the client's shape. /// /// TargetId accepts the same three id shapes as SpawnNpcAsync: a FightSpirit id (1502xxxx), /// a cultivation id (9105xxxx), or an owning Agent id (40140041 = Ringo). Empty = pass the /// id through unchanged, which is the reference branch's behaviour. /// /// A settable array rather than init-only so a slot list could be swapped at runtime; in /// practice this is edited by hand in config/private-server.json (the panel card that used to /// edit it was removed), which means a restart is required for changes to take effect. public SummonSlotOverride[] SlotOverrides { get; set; } = []; } public sealed class SummonSlotOverride { public uint SentId { get; init; } public uint TargetId { get; init; } /// Optional note; only appears in console logs. public string Label { get; init; } = string.Empty; } public sealed class GameplaySettings { /// /// ★ 可玩角色「常驻大世界」:进世界后,名单里每个角色按它自己的日程站在城里对应的位置 /// (Richie 在跑步机、Garm 在娃娃机 …),不用先切走再切回。 /// 机制与 Aether.FunctionalNpcsEnabled 完全同款(跟随循环 + 进入/离开迟滞), /// 差别只有坐标来源:这里取 AgentDataSetsActivityConfig.MapPosition。 /// public RosterNpcSettings RosterNpc { get; init; } = new(); /// /// ★ Plate(盘)演出管线试点 —— gameplay.plate。 /// 角色光站在点位上只会发呆玩手机;要让里希真的去跑跑步机,服务端必须推 /// SyncPlateAddList(68554224) 把「盘」交给客户端(硬规则 157)。 /// ⛑ 客户端 reader 在 C# 侧(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly), /// 线格式只能靠试 ⇒ 这几组 *Encoding/*Marker 开关就是给实机 A/B 用的, /// 改配置不用重编译(每次推送时现读)。 /// public PlatePilotSettings Plate { get; init; } = new(); /// /// ★ 生活日程「正式通道」—— gameplay.npcLife。 /// 比 Plate 更靠前的一层:官方让一个好感 NPC 真的去跑跑步机,靠的是 /// SyncFavorNpcSpoonAgentId(64003213)(「这个 agentTag 现在占着几号 spoon 位、站哪儿」) /// 以及登录帧 FavorNpcDailyScheduleInfos 里的 /// CurrentSpoonAgentId / SpoonPosition / 每段的 SpoonAgentId。 /// 我们以前这三处全填 0(见 RuntimePayloadFactory)⇒ 客户端只有活动 id, /// 没有「人该站到哪个 spoon 节点上」⇒ 只能播默认 ambient(站着玩手机)。 /// ⛑ spoon 位号到底是什么值还没有官方表(客户端 Spoon 资产里),所以做成旋钮逐项试。 /// public NpcLifeSettings NpcLife { get; init; } = new(); /// /// ★ 按地块铺市民(T0-8)的 POI 二次筛选(2026-10-02 新增, /// 依赖 UrbanDiversityPOIActionConfig.json 全字段表)。 /// 两个开关默认全 false = 完全保持今天的行为,打开才会用全字段表过滤候选池。 /// public CivilianPoiSettings CivilianPoi { get; init; } = new(); /// /// 官方等待区站位(2026-10-02 新增,配置键 gameplay.civilianWaitArea)。 /// 数据来源 = paths.pedSpawns(zonegraph_ped_spawns.csv,4070 槽 / N 个等待区), /// 消费点 GameRouter.Civilians。⛑ Enabled 默认 false ⇒ 不改现有行为。 /// public CivilianWaitAreaSettings CivilianWaitArea { get; init; } = new(); /// /// ★ 钓鱼播种 —— gameplay.fishingSeedAllGear。 /// 登录帧 InfoMinor.FishingInfo.GearDict 是否整套播种 34 件渔具 /// (FishingGearConfig.json 全表)。只在 GearDict 为空时才播, /// 所以已经在官方服拿过渔具的存档不会被覆盖。 /// /// 为什么值得做:捐赠文档 §8 说 GearDict 在客户端 lua / C# 都无人读取、 /// 「往里塞数据零可见效果」—— 那条结论说的是 字典内容不被消费, /// 不等于 PlayerFishingInfo 这个结构体空着无所谓。客户端初始化链 /// (FishingGameFishMgr.container / FishingGameEffectMgr.objPool / /// FishingGameRod.animator)实测三连 nil,本文档作者的环境里播种是开着的。 /// ⇒ 这是一次性把"服务端少给了一块"这个变量消掉的开关,关掉即可回到原状。 /// public bool FishingSeedAllGear { get; init; } = true; /// /// ★ 第 50 轮(自 donor v2.1 移植,2026-09-29)—— **室内层判定门**(`gameplay.indoorVisibilityGate`)。 /// /// 作用:摆放派对场地家具(PartyCamp)时,若某件家具落在 /// 「VisibleOutside = false」的室内占地里、而玩家不在其中 ⇒ **本件不摆**。 /// 标本:舞会场地(danceOnly)的两件常驻机关(Gadgets_Party_DanceDoor 514708797 / /// Gadgets_Party_LightControl 1341214683)在 IndoorConfig#150 "Spiko Hotel" 的占地里, /// 我们却把它们摆到露天 y≈80 ⇒ 现场「道具在半空、地板不在」。 /// /// ⛑ 本门**只**作用于 PartyCamp 这一条链,不碰市民 / 机关网格 / 场景物等其它刷法 /// (donor 实测:露营点 / nova-spawn / 保龄球馆 / 公寓车库入口都不在任何室内矩形内)。 /// /// 回滚:gameplay.indoorVisibilityGate = false。 /// public bool IndoorVisibilityGate { get; init; } = true; /// /// 播种渔具的 RemainingDurability(gameplay.fishingGearDurability)。 /// ⛑ 小游戏本身用的是 lineStaminaMax(1000),不读这个字段 /// (捐赠文档 §8「渔具耐久字段无人使用」)。填它只是为了结构体完整、不留 0 值。 /// public int FishingGearDurability { get; init; } = 100; /// /// Hacker kit shape for characters authored with job 401 (Seymour / 赛默迪斯科球人): /// · "full" (default) — all 11 capability buffs (incl. 蜘蛛机器人 52606149 / 黑客无人机 /// 52606150), the 6/6 battery, and the EonBug app's BBS payload (SyncHackerJobInfo). /// · "safe" — the proven round-1 trio (52606133/167/168), no battery, no BBS payload /// (middle-mouse GadgetManager entry stays "visible but grey"). /// Round-1 measurements showed the wider sets can hide the middle-mouse hack entry while the /// EonBug APP path keeps working; flip this to "safe" to restore the baseline without a rebuild. /// public string HackerAbilitySet { get; init; } = "full"; /// /// Ability buffs the official server grants by spending talent points on the Hacker talent tree /// (TalentTreeTalentConfig, tree 99900010 — the only tree whose nodes carry 526xxxxx /// ability buffs). Published on top of the job-401 kit so Seymour has them without climbing the /// tree, because the talent pipeline itself is not implemented (see T1-6). /// Only ever handed to a character whose FightSpiritConfig.DefaultUrbanJob is 401. /// Empty array disables the extra layer entirely. /// public uint[] HackerTalentBuffIds { get; init; } = []; /// /// Shape of the runtime SyncSpiritJobInfo grant, i.e. which phone apps a character gets: /// · "single" — only the character's authored FightSpiritConfig.DefaultUrbanJob rank, and /// Jobless characters get no record at all (the round-1 baseline). Ceiling is 3-4 apps, /// because only 4 of the 8 authored careers map onto a class that gates an app. /// · "multi" — additionally register one rank for every job class that gates a /// phone app, so all 9 career-locked apps appear on every character. /// MainPhoneUtils.CheckJobCanShow walks the whole AvailableJobs dictionary /// (SpiritJobManager.GetCurSpiritAvailableJobIdList) and matches /// UrbanJobConfig.JobClass; it never reads CurrentJob, Level or Exp, so the /// extra ranks are free and unlock in parallel. /// Since this defeats the per-character exclusivity the apps were authored for, "single" is /// the default — flip to "multi" only to survey what the apps look like. /// Per-character additions on top of either mode: . /// public string UrbanJobGrantMode { get; init; } = "single"; /// /// SpiritJob.Level / SpiritJob.Exp published in SyncSpiritJobInfo: /// · "max" (default) — publish the top authored level of the rank's job class, plus the exp /// needed to stand on it, straight out of UrbanJobLevelConfig. The cap differs per /// class: Cat Express 24 · gang 20 · police 16 · tea farmer 10 · hacker 3 · most others 3. /// Classes with no level table at all (chef 11300020, 11300016, 11300018) stay at level 1, /// because SpiritJobManager.GetLevelCfg would resolve an invented level to nil. /// · "authored" — the 7k baseline, level 1 / exp 0 on every record. /// ⛑️ This affects content panels only (farmer inventory caps, the "Lv.N" labels in the /// cleaner/washer stores). Phone-app icons never read Level or Exp. /// public string UrbanJobLevel { get; init; } = "max"; /// /// Where a character stands on its promotion chain (UrbanJobConfig.PreJob, e.g. /// 401 -> 402 -> 403 for the Hacker career, each step gated by a UrbanJobConfig.Level /// threshold — police 301/302/303 need job level 1/5/10): /// · "top" (default) — publish every rank of the career and put the character on the highest /// one, i.e. the shape it would reach after finishing the chain by hand. /// · "authored" — publish only the single rank FightSpiritConfig.DefaultUrbanJob names. /// ⛑️ This is the other half of "no extra ability appeared": promotion changes rank, not power. /// Rank and career really only drive the career UI and the phone-app gate, and both key off the /// shared JobClass, so no new app appears either. Capabilities come from /// CharacterCapabilities / UrbanBadgeConfig — see T0-12. /// public string UrbanJobRank { get; init; } = "top"; /// /// Where a character's capability buffs come from: /// · "auto" (default) — additionally publish every buff listed on an UrbanBadgeConfig row /// whose JobId is a career the character is published with. This is the data-driven /// path: give any character job 501 (say, via ) and it receives /// Cat Express's 52606163 Acceleration Mastery without touching a line of C#. /// ⛔ OnlyServer rows are excluded at projection time, which also keeps the two negative /// debuffs (52606230 / 52606231) out of the set. /// · "off" — capabilities stay exactly what CharacterCapabilities.BuffIds hard-codes. /// Only careers 7 / 401 / 501 carry any buff today; jobs 301/302/303 (police) are all OnlyServer /// or empty, so Richie gains nothing visible — that is authored data, not a bug. /// public string JobBadgeBuffs { get; init; } = "auto"; /// /// 潜水(Diving)解锁 —— 让所有角色都能下潜,而不是只有水面游泳。 /// · "on"(默认)— 进世界后服务端自动做两件事:① 通过 /// SyncRunGmUtils(66042045) 让客户端反射执行 GmUtils 的潜水开关方法 /// (默认 EnableDive,可换成 ForceDive 走强制路径); /// ② 把 挂进该角色的 SyncUnitBuffList,解决氧气。 /// · "off" — 两件都不做,回到原始行为(只能游泳)。 /// /// 为什么是两件:潜水有两道彼此独立的门,缺一个都潜不下去。 /// ① 门控(native)— DiveManager.CheckIsEnableDiving() 读静态 enableInGamePlay, /// 服务端没有任何专用 S2C 能开它;66042045 是官方的远程 GM 反射通道 /// (GameGMToClientImpl.SyncRunGmUtils,RVA 0x12752F10 真实现), /// 客户端按 methodName 反射调用 LX6.Utils.GmUtils 的静态方法。 /// ② 氧气 — 下潜会掉氧气,耗尽被顶回水面(DiveLackOxygenCfg)。 /// 52606233 潜水服buff 的官方描述就是「不会掉氧气,且会更快的恢复氧气」, /// 挂上它就不必再维护一条氧气 tick(省掉 SyncPlayerOxygenSystemState 68251202 /// / SyncPlayerCurrentOxygenValue 68717877 的周期推包)。 /// ⛑️ 未实测:GmUtils 的有效方法名集合未知,EnableDive 是否被反射命中只能实机验证。 /// 若开了仍潜不下去,把 改成 ["ForceDive"] 再试。 /// public string DivingMode { get; init; } = "on"; /// /// ="on" 时挂进角色 buff 列表的潜水服 buff。 /// BuffConfig 52606233「潜水服buff」= 不会掉氧气,且会更快的恢复氧气。 /// public uint DivingSuitBuffId { get; init; } = 52_606_233; /// /// 白靖(15021039)时缓的第二条触发:把 TryStartBaiJingTime4229938 也挂到当前流派的 /// ActiveSkill(E 键)上 —— 这就是捐赠端 7k-v0.5 GameRouter.Combat.cs:72-79 的做法。 /// /// 默认 false(2026-09-27 用户拍板:走原生驾驶 HUD,不要 7K 自己加的那条)。 /// ⛑ 2026-09-27 14:2x 实机:切到白靖 → 手机叫车 → 上 Veloce·Quicksilver /// (VehicleConfig 81005007,VehicleType=5,走 DriveControlDriverStore, /// 也就是唯一带 PROFESSION_SKILL 按钮位的那个驾驶 HUD) /// ⇒ 驾驶 HUD 上没有出现职业技能按钮,尽管 52959495 确实在 SyncUnitBuffList 里 /// (packets 日志 14:27:25 entityId=100000000021 那条,InstanceId 1200067)。 /// 原生判据就一行 ShowProfessionSkillButton = HasBuff(Pid, BuffConfig.TimeScaleBadgeBuff), /// 所以要么那个常量在本版本解析不出来,要么它指的不是 52959495。 /// ⇒ 若进一步排查确认原生这条在本客户端版本不通,把本开关置 true 即可立刻用上, /// 不用重新编译。 /// public bool BaiJingTimeBindActiveSkill { get; init; } = false; /// /// 排查 BuffConfig.TimeScaleBadgeBuff 真值的应急旋钮:这里列的 buff 会额外挂进 /// 白靖的常驻 SyncUnitBuffList,用来做 A/B(改配置即可,不用重新编译)。 /// 默认空。候选(按可能性排序,都出自 UberSimConfig 那套网约车/货车玩法): /// · 52980302「货车职业徽章19001323(子弹时间持续时间提高)」—— TimeStopPara 的第二条; /// · 52980312 / 52980313 / 52980314 —— UberSimConfig.AddAcceptBuffList(接单 buff); /// · 52959496「斯安威斯坦」(5 秒状态本体)—— 不太可能(按钮得在没激活时显示), /// 但若游戏把它当"能力已解锁"标记也说得通。 /// ⛑ 前两个已经是白靖常驻的 52959495 / 52606175,别重复填。 /// public uint[] BaiJingTimeScaleProbeBuffs { get; init; } = []; /// /// 梅卡羊生成帧的 SyncUnitOccur.occurId。默认 0(与 GM 面板那次成功生成一致)。 /// 70000302 是 AgentConfig 40980002.EnemyOccurId,留作排查"模型透明"时用。 /// ⛑ 2026-09-27 实机:GM 面板生成 40980002 时日志是 occur=0(EnemyAiCatalog /// 没有这一行,SpawnAgentAsync 只能填 0),结果是「单位在、能出攻击特效, /// 但模型透明」⇒ 怀疑出场演出(淡入 / 材质)没跑。改成 70000302 验证。 /// public uint MechanikaSheepOccurId { get; init; } = 70_000_302; /// /// 梅卡羊生成帧的 AgentSyncClientInfo.treeName。 /// /// ⛑⛑ 默认已从 MeikaShip 改成 Main_Common(2026-09-27 15:3x)。 /// 四次实机的对照表(唯一始终跟着"不出场"走的变量就是这棵树): /// | 通道 | uid 段 | tree | occur | camp | 结果 | /// |---|---|---|---|---|---| /// | 手写帧 ×2 | 0x3000… | **MeikaShip** | 0 | 0 | 不出 | /// | spawnAgent + activate | 0x2000…(35184372188833) | **MeikaShip** | 70000302 | 0 | 不出 | /// | GM 面板 | 0x2000…(35184372188834) | Main_Common | 0 | 4 | **出场(模型透明)** | /// ⇒ uid 段已证伪(两次 uid 段不同、一次相同,都不出);occur 两边都取过值,排除; /// engage 批次补了 battleAiS 也不出。**剩下 MeikaShip 是 3/3、Main_Common 是 1/1。** /// 机制上也说得通:`GameAdminBridge.SpawnAgentAsync` 那条注释写过 /// "An empty tree name or AISetting 0 leaves the client unable to resolve a behaviour /// tree" ⇒ 这版客户端很可能根本没有 MeikaShip 这棵树(梅卡羊是被砍掉的内容, /// 模型 86952527 也是透明的),解不出树 ⇒ 单位不初始化 ⇒ 不渲染。 /// public string MechanikaSheepTreeName { get; init; } = "Main_Common"; /// /// 梅卡羊走哪条生成通道: /// · "spawnAgent"(默认)— GameAdminBridge.SpawnAgentAsync。 /// uid 落在 0x200000000000+100000 段(GM 面板生成 40980002 实测 35184372188833, /// 能出场),包序列与 AgentCatalog 校验都是已验证的那套。 /// · "handwritten" — 保留的手写帧(uid 落在 0x300000000000 段)。 /// ⛑ 换通道没能解决问题(uid 与 GM 完全相同仍不出),见 。 /// ⛑ 2026-09-27:手写帧服务端日志一直是 Ok=True、包也发了,但客户端完全不渲染; /// 同一模板走 SpawnAgentAsync 却能出场 ⇒ 怀疑就是 uid 段。默认已切到 spawnAgent, /// 留这个开关只为对照。 /// public string MechanikaSheepSpawnPath { get; init; } = "spawnAgent"; /// /// 羊用的 agent 模板。 /// /// ⛑⛑ 默认已从 40980002 改成 40120651(2026-09-27 15:5x)。 /// 决定性取证(packets-20260927-155019.log 15:52:55–15:53:15,uid=35184372188833): /// · AskInteractCmd CmdType=33/34 … stringParam1="35184372188833" ← 客户端在跟它交互 /// · AskUnitMoveActionSimple {UnitId:35184372188833, Pos:…, Rot:…} ← 客户端在给它上报移动 /// · AskClientUseCommonSkill … TargetId=35184372188833 ← 玩家拿它当攻击目标 /// ⇒ **羊一直都在**:单位被注册、能被锁定、能被攻击、客户端还在驱动它移动。 /// 它唯一的毛病是**模型 86952527 在这版客户端里是透明的**(GM 面板生成同一模板也是 /// "只有攻击特效、看不见模型",用户实机原话)。 /// ⇒ 前面三轮查的 uid 段 / engage 批次 / 行为树**全都查错了方向** —— 那些只影响"行为", /// 不影响"存不存在"。真正的 blocker 从头到尾就是**模型透明**。 /// /// 候选(AgentConfig.json): /// · **40120651「羊」模型 86952761** ← 有模型,现默认。代价:行为树是 /// Behavior_EmptyBehavior(`Behavior_Battle` 空串)⇒ 不会自己走,靠我们 follow 推包。 /// · 42090155「羊」同模型 86952761。 /// · 40980002 / 40120907「梅卡羊」= 原生那个,但模型 86952527 **透明**,不要再默认。 /// ⛑ 换模板的代价:客户端 IsHaveEnemyByDistance(40980002, 10) 认不到这只羊, /// 但我们服务端的 10 米判定用的是自己记的坐标,不受影响。 /// public uint MechanikaSheepAgentId { get; init; } = 40_120_651; /// /// 羊的阵营(SyncAgentCampInfo.camp)。默认 0(Unknown / 市民,硬规则 132: /// 友方 NPC 一律 0)。改成 4 就是敌对阵营(GM 面板生成 40120651 时就是 4, /// 用户实机反馈"会攻击我")。排查"羊不出现"时可试着切 4 做对照。 /// public uint MechanikaSheepCamp { get; init; } = 0; /// /// 生成之后要不要补"激活批次"。⛑⛑ 这是 2026-09-27 找出最关键的一处差异: /// GM 面板生成 40980002 能出场,我们 E 触发的同一个模板、同一个 uid(35184372188833) /// 却不出场——uid 段假设被证伪,剩下的差异里只有这个是"客户端行为"级别的: /// · "battleAiS"(默认)— 只发 SyncBattleAiS(uid, true) + SyncEnemyDetect, /// 1.5 s 后重发一次。GameRouter.LocalEnemyCombat.cs 注释写明: /// "native side flips BaseUnit.set_IsBattleAiS here, and that is what moves the unit /// off its idle/ambient tree onto CombatBehaviorTree" ⇒ 不发它单位就停在 idle 树。 /// ⛑ 不发 SyncUnitLockTarget / SyncEnemyFightEdict ⇒ **羊不会锁定也不会攻击玩家**。 /// · "full" — 走 GameRouter.EngageSpawnedEnemyAsync(= GM 面板那套,含锁玩家, /// **羊会攻击玩家**),只在排查时用。 /// · "none" — 一个都不发(旧行为,已实机证明不出场)。 /// public string MechanikaSheepActivate { get; init; } = "battleAiS"; /// /// 生成之后要不要把羊头顶那根血条收掉(默认 true)。 /// /// ⛑ 用户 2026-09-27 实机:「羊头顶上有个血条,不像机械狗这样不显示血条,是不是羊默认不是友方」。 /// 排查结论 —— 跟友方/敌对无关,血条第一判据是模板的 AgentConfig.ShowHPBar: /// · 40120651「羊」ShowHPBar=True /// · 机械狗 40120911 的 ShowHPBar 也=True,但它不显示血条 /// ⇒ 差在第二个判据:机械狗走 GameRouter.HackerSummon.cs 的 /// SyncAddUnitState(state=10266 /*UnitStateConfig.HideHp*/)。 /// 配置表是客户端压缩二进制池,服务端改不了 ShowHPBar ⇒ 只能写 UnitState。 /// 阵营那条路已经是对的(=0,且从不发 SyncForceEnemyRelation)。 /// public bool MechanikaSheepHideHpBar { get; init; } = true; /// /// 梅卡妮卡 E 键改成哪个技能(SyncChangeActiveSkill 下发给客户端的那个 id)。 /// 默认 51939201「混厄召唤师召唤精灵1」;置 0 = 不改写(回到流派 ActiveSkill /// 51943205 重击 —— 能出羊,但按 E 会先出一记重击)。 /// /// ⛑⛑ 51937721 已实机证伪(2026-09-27 16:4x),不要再试。 /// 下发 51937721 后客户端一个 AskUseSkill 都没发(packets 里 51937721 只出现 1 次, /// 就是我们下发的那一帧)⇒ 重击没了、羊也没了。根因: /// · E 槽(ActiveSkill)只认 SkillCastTypeTag = 6。 /// · 51943205「街头格斗_重击」tag=6;51937721 / 51937722 / 51934941 tag 全是 2。 /// · 全表 5536 个技能里名字含"念力/梅卡/羊"的**没有一个 tag=6**(全是 0 或 2) /// ⇒ 这版客户端里"念力"根本不是可释放的根技能,只能从跳转图里被替换出来。 /// ⇒ 结论:梅卡妮卡的羊在这版客户端里没有任何原生 E 入口,捐赠端 v0.5 的 E 绑定 /// 是他自己硬加的。想要"E 出羊且没有重击动作",只能借一个 tag=6 的召唤技能当壳子。 /// /// 现选 51939201 的理由:全表 492 个 tag=6 技能里,只有「混厄召唤师召唤精灵1~4」 /// (51939201–51939204)是召唤类;其余全是挥击/突进/重击。它不在任何 FightSkillConfig /// 行里(敌人向技能),动画资源可能缺失 ⇒ 客户端可能不播动作或 T-pose,但只要它肯发 /// AskUseSkill,服务端就能接住并召羊。这也是"没有重击动作"的唯一可行路径。 /// ⛑ 若 E 又变哑(不发任何包)⇒ 置 0 回到"重击 + 兜底召唤"(那条 100% 能出羊)。 /// /// ⛑⛑ 为什么必须改写:客户端 E 键放的就是 SyncChangeActiveSkill 下发的那个技能 /// (GameRouter.Profiles.cs 的 PublishSkillBindings;实机客户端回的正是 51943205)。 /// 我们一直给梅卡妮卡下发 Street Brawler 的 ActiveSkill 51943205「街头格斗_重击」 /// ⇒ 按 E 就是一记重击,然后服务端兜底再召羊 ⇒ 用户看到的"先重击再出羊"。 /// /// 51937721 的价值:它的 SkillReplace = [97011851, 97011078],客户端 SkillJumpManager /// 会自己判: /// · 97011851「切换为召唤梅卡羊」!IsHaveEnemyByDistance(40980002, 10) ⇒ 跳 51937722(召唤) /// · 97011078「原地念动」return true ⇒ 跳 51934941(念力砸击) /// ⇒ 双态在客户端完成,重击动作根本不会出现。服务端只要认这三个 id 即可。 /// /// ⛑ 未证伪的风险:没有任何 FightSkillConfig 行把 51937721 当 ActiveSkill,也没有任何 /// FightSkillConfig 行把 15021017 写进 SpiritId(2026-09-27 全表 grep)。 /// 也就是说「官方就是 E 触发」这件事没有配置表证据,只有 SkillReplace 的语义证据。 /// 下发后客户端若不认 ⇒ E 变成空放。回滚:把这项置 0。 /// public uint MechanikaSheepActiveSkillId { get; init; } = 51_939_201; /// /// 一次 E 放几只羊。默认 3(左前 / 右前 / 背后)。 /// ⛑ 落点角度见 ;本值 <=0 时取角度数组的长度。 /// public int MechanikaSheepPerCast { get; init; } = 3; /// /// 落点相对玩家朝向的角度(度),默认 [45, -45, 180] = 左前 / 右前 / 背后。 /// 落点距离固定 2.5 m(GameRouter.MechanikaSheep.cs 的 MechanikaSheepDropMeters4229938)。 /// ⛑ 硬规则 181:这些是度数,代码里会先 × π/180 再喂 sin/cos。 /// public int[] MechanikaSheepSpawnAngles { get; init; } = [45, -45, 180]; /// /// 场上同时存活的羊上限。默认 0 = 不限制(2026-09-27 用户要求解除上限)。 /// ⛑ 不限制意味着连按 E 可以无限堆 —— 若发现卡顿/客户端吃不消,填一个数字即可,不用改代码。 /// 切角色 / 切场景时会整批 SyncLogicAgentLeave 召回(StopMechanikaSheep4229938)。 /// public int MechanikaSheepMaxAlive { get; init; } = 0; /// /// ="on" 时依次下发给客户端执行的 GmUtils 方法名。 /// 默认只跑 EnableDive;实测无效时改 ForceDive(绕过条件检查), /// 水下滤镜是 SetUnderWaterFilter(可与前者并列,例如 ["EnableDive","SetUnderWaterFilter"])。 /// public string[] DivingGmMethods { get; init; } = ["EnableDive"]; /// /// 传给 每个方法的参数列表,默认 ["true"]。 /// ⛑⛑ 必须小写:客户端 GmUtils.RunGmUtilsCommand 用 Newtonsoft.Json 解析每个 /// 参数字符串,传 "True" 会抛 JsonReaderException(Unexpected character ... value: T) /// 并整条指令丢弃 —— 2026-09-22 21:52 实测踩过(日志见 Player.log 的 /// "GM指令 EnableDive 参数解析错误 arg=enable argString=True")。 /// public string[] DivingGmArgs { get; init; } = ["true"]; // ⛔ 载具 GM(vehicleGmEnabled / vehicleGmMethods / vehicleGmArgs)已于 2026-09-26 23:5x **整块删除**。 // 起因:默认那条 `SetUnlimitedNitrogen` 实测 = 刹车失效 + 下车后车仍持续加速,且它是客户端进程内 // 状态、服务端重启清不掉(用户被困在失控的车里,只能手发 args=["false"] 或重启客户端)。 // 关开关不够(残留状态还在),所以代码、配置、json 键三处一起删干净。 // 以后真要再开:别用 `SetUnlimitedNitrogen`,走纯数值指令 // `SetVehicleTopSpeed` / `AddVehicleSpeedBuff` / `SetHelicopterSimpleMode`(不劫持输入、无常驻状态)。 /// /// Extra urban jobs granted to individual characters, keyed by spirit template id /// ("15020967": [401]). The key is a string because System.Text.Json only deserializes /// string dictionary keys out of the box. /// /// This is the escape hatch for "exclusive, but I want this one character to have that app": /// it hands career-locked apps to a character with no authored job (or the wrong one) without /// switching the whole server to "multi". Job reference: /// 401 = EonBug · 301 = Duty Terminal and Mimic Database · 501 = Cat Express · /// 801 = Chaos Tide · 1201 = Top Cleaning · 4 = VWT · 1901 = 茶农 · 2000 = 重霄食谱. /// public Dictionary UrbanJobOverrides { get; init; } = new(); /// /// 单个角色的「当前职业」覆盖,key 同样是 spirit template id("15021039": 4)。 /// 与 的区别:那个只往 AvailableJobs 里加职业 /// (CurrentJob 保持 FightSpiritConfig.DefaultUrbanJob),这个直接改 CurrentJob。 /// /// 为什么要单独一个键(2026-09-27,白靖): /// · FightSpiritConfig 15021039.DefaultUrbanJob = 100「无业」,而 /// SkillResourcesConfig Id=4 写的是「赛车手职业时缓次数」、BuffConfig 52959495/52959496 /// 是斯安威斯坦 —— 原生这套能力属于赛车手职业(UrbanJobConfig 4/5/6,JobClass 11300002)。 /// · 捐赠端 7k-v0.5 用硬编码 CharacterProgression4229938.ExclusiveClassBySpirit[15021039] = 11300002 /// 把 CurrentJob 顶成 4。本服不硬编码角色 id ⇒ 走这份配置。 /// ⛑ 只填真的要改当前职业的角色;有自带职业的(里希 301 / 塔菲 501)别填,会顶掉作者设定。 /// public Dictionary UrbanJobCurrentOverrides { get; init; } = new(); /// /// T0-4: the int published into PlayerClientInfoSpirit.InfoPolice.TodayCompleteMissionCnt /// (spirit data). PlayerInfoSpiritData.lua:75-76 copies it straight into /// gPoliceJobManager.todayMissionCnt, which the police phone panel renders as /// "今日完成 N 单" (PoliceJobManager.lua:139/170/197/221) and re-broadcasts over /// POLICE_SYNC_MISSION_COUNT. /// /// ⛑️ The field was never missing: PlayerClientInfoPolice is a reflected /// [UxContract] nested in PlayerClientInfoSpirit, so the login payload has always /// carried it — with value 0. This knob only seeds a number so the panel is not stuck at zero; /// the server has no police mission system yet (that is T4-9), so any non-zero value is cosmetic. /// /// Runtime siblings the client also accepts: SyncPoliceCompleteMissionCnt (64618646, one /// uint) for live updates, and the GM call GmPoliceClearTodayCompleteMissionCnt (65635352). /// public uint PoliceTodayCompleteMissionCnt { get; init; } /// /// 层 2a / T4-9: publish the dispatch slots of PoliceDispatchCatalog whose /// ShowInApp is true (ids 2 "NCCA Dog" / 3 "Helicopter Search" / 4 "Armed Support") /// into SyncSpiritPoliceJobInfo.DispatchInfos. /// /// Without them PolicePanelManager.GetPoliceDispatchItemList (PolicePanelManager.lua:848) /// returns an empty table, which is why the Duty Terminal homepage renders blank: the list is /// built from self.dispatchInfo[spiritId], not from the config table. /// NextAvailableTime ships as 0 so GetDispatchCD yields a negative number and every /// chip renders as clickable rather than counting down. /// public bool PoliceDispatchEnabled { get; init; } = true; /// /// 黑客 APP 召唤物(蜘蛛机器人 51938181 / 送货无人机 51938183)。 /// /// 为 true 时,服务端接到这两个 temp-skill 后会在玩家身前刷出对应 agent /// (40120908 / 40120909),并推 SyncSwitchControl(68552741) 把玩家放进操控位; /// 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → 蜘蛛/无人机操控面板全在客户端。 /// /// 回滚:置 false 后服务端只打一行 [SUMMON] skipped 日志,不刷实体也不接管 —— /// 即恢复到 2026-09-23 之前的「点了按钮只有施法动作」状态。 /// public bool HackerSummonEnabled { get; init; } = true; /// /// 巡卫官执勤终端的巡卫犬(NCCA Dog)召唤物(2026-09-25)。 /// /// 与黑客那两个是同一套 android-control 通道:里希(15021021)的 /// FightSpiritConfig.TempSkill 里挂着 51938184「召唤无敌机器狗」与 /// 51938186「召唤无敌机器狗(驻场状态)」,客户端先在执勤面板上按 /// AskPoliceDispatch(2)(我们回空 OK),再由 PolicePanelManager.CallDispatch /// 走 UseSkillByPid 把技能打到这条路上。服务端做的事与蜘蛛/无人机逐字相同: /// 刷 40120911 机械狗(AgentSummonCatalog 里就在 40120908 蜘蛛 / 40120909 无人机 /// 隔壁,EnemyClassType=1、AI=40213004 完全一致),再推 SyncSwitchControl 交操控。 /// 客户端侧对应 RobotDogControlsStore.lua,召回/交还用的同样是 /// AskStopControlAgent(true/false) —— 所以两个 handler 直接复用。 /// /// ⛑ 与 HackerSummonEnabled 分开的原因:一个是黑客 APP、一个是巡卫官职业 APP, /// 出问题要能各自单独关掉,别共用开关互相拖累。 /// /// 回滚:置 false ⇒ 服务端只打 [SUMMON] skipped,回到「点了只有施法动作」。 /// public bool PoliceDogSummonEnabled { get; init; } = true; /// /// 巡卫官执勤终端的战术支援(Armed Support,2026-09-25)。 /// /// AskPoliceDispatch(4) 到来时在玩家身前刷 两个友方冲锋枪巡卫官 /// (AgentSummonCatalog 40120875 / 40120983,AI=40213022、InitWeapon=98005562, /// 对得上 PoliceDispatchCatalog Id=4 的 Des="冲锋枪2人" / Number=2)。 /// /// 与狗 / 蜘蛛 / 无人机不同:这条不交接操控,刷出来是给他们自己打的,所以 /// SpawnAgentAsync 走 engage:false + forceEnemyRelation:false + camp=0 /// —— 不注册进敌人账本(打不死),也不把玩家和他们标成敌对(否则玩家能锁定友军挥刀, /// 又因为没进账本永远不掉血)。 /// /// ⛑ 官方的解锁条件是「通过天赋树-盘查解锁」,天赋树我们只有壳,所以这里直接默认开放; /// 派兵本身不读 NeedActive / UnlockDes,客户端不会显示成锁定。 /// /// 回滚:置 false ⇒ 只打一行 [POLICE] skipped,回到「点了按钮回空 OK、什么也不发生」。 /// public bool PoliceArmedSupportEnabled { get; init; } = true; /// /// 直升机搜查的服务端记账(2026-09-25)。 /// /// 只影响 AskHelicopterNpc(67113443) / AskHelicopterScanChange(67898923) / /// AskPoliceStopHelicopterDispatch(63057829) 这三条的记账与 S2C 收尾; /// ⛑ 关掉它不会让客户端卡死 —— 三个 handler 依然回 empty OK,只是不打标记、 /// 不推 SynExitHelicopterView。这是故意的:这三个里有两条是 INVOKE,回执必须无条件给。 /// /// 官方玩法里这些标记会喂给「盘查 / 通缉 / 案件」,那条链我们是空的,所以这里的记账目前 /// 只有日志价值;真要接那条链时从这里取数据。 /// /// ⛔ 别指望把直升机"刷出来":SyncPoliceDispatchHelicopter(68359668) 的客户端实现是空函数体, /// 视角与面板由客户端 C# 侧自己开(lua 里 S_POLICE_HELICOPTER 没有任何 Open 调用点)。 /// 真要一台停在场景里的直升机,走载具通道 —— VehicleConfig 81006001(Reed·Ranger)/ /// 81007007(Helicopter01a_Rope,索降版),且必须用 SpawnVehicleAsync(force:true), /// 因为它们不在 gameplay.vehicles.fleetIds 里(飞行载具)。 /// public bool PoliceHelicopterEnabled { get; init; } = true; /// /// 掉落物拾取的两个 C2S 回执(AskPickUpSceneItemTransferToItem 67297154 / /// AskEnemyItemPickUp 67453017)是否记账。见 GameRouter.NpcPhoneDrop.cs。 /// /// ⛑ 关掉它也照样回空 OK —— 这两个是 INVOKE,不回执客户端会 await 到超时(硬规则 94)。 /// 这个开关只管"记不记日志/记不记账本",不管回不回执;这是故意的,回滚安全性优先于开关干净。 /// /// 顺带说明"路人手机内容"这条线:内容全在客户端本地表 /// (RandomChatManager.lua 按 npcId 的标签匹配 35 行 NPCChatAtmosphereNewConfig), /// 服务端塞不进文本;要让每个人手机不一样,动的是 npcId(AgentConfig id),不是这里。 /// public bool SceneItemPickupEnabled { get; init; } = true; /// /// 直升机搜查(派遣 id=3)落地时,是否真刷一台 VehicleConfig 81007007 /// (Helicopter01a_Rope,带索降绳)停在玩家旁边。 /// /// 为什么它是独立开关:官方的直升机搜查面板在 build 4229938 里是半成品 /// (面板没有打开入口、SyncPoliceDispatchHelicopter 是空实现、dump.cs 里没有对应 C# 类), /// 服务端接再多也只是在后台记账。刷这台载具是"让场景里真的出现一架直升机"的替代方案, /// 与官方玩法不是一回事,所以要能单独关掉。 /// /// 回滚:置 false 后点直升机搜查只记账、不刷载具(三条 C2S 回执照旧,客户端不会卡死)。 /// public bool PoliceHelicopterVehicleEnabled { get; init; } = true; /// /// 正向「逮捕 NPC」总开关(2026-09-25)。false ⇒ 收到盘查选项 RPC 也只回执、不推 /// SyncPoliceBeginArrest(68839124)。回滚用它,不用重新编译。 /// public bool PoliceArrestEnabled { get; init; } = true; /// /// SyncPoliceBeginArrest 的 ArrestType。反向链路(玩家被捕)用的是 1 /// (GameRouter.Wanted.cs:723),正向默认照抄;演出不对就在这里试 0 / 2。 /// public int PoliceArrestType { get; init; } = 1; /// 演出结束后是否把被捕的 NPC 用 SyncLogicAgentLeave(68281244) 带走。 public bool PoliceArrestRemoveEnabled { get; init; } = true; /// /// 逮捕演出播多久再把人撤走(毫秒)。太短会看不到演出,太长城里会一直堆人。 /// public int PoliceArrestRemoveDelayMs { get; init; } = 4000; /// /// 里希**牢房收押 / 带出**(2026-09-30,GameRouter.PolicePrison.cs)。 /// 只做「位置管理 + 账本/AI 托管」,入口是 GM 面板 /api/police/prison; /// 不做原生押送过场、不做 F 交互按钮(都要捐赠端那套 GATE Lua 通道,主线没有)。 /// public PolicePrisonSettings PolicePrison { get; init; } = new(); /// /// 敌人血条专项(2026-09-23)。三个都是对照开关,默认值 = 当前行为,改 false 就是一次 A/B。 /// /// 背景:kimika 捐赠端的 spawn 序列里没有 SyncAgentCampInfo 和 SyncAttachBattleModule, /// 也没有 SyncEnemyDisarmState / SyncUnitLockTarget;而我们四个都发。kimika 那边敌人血条是可 /// 以显示的,所以"我们多发"的这几个包是负向候选:特别怀疑 SyncAttachBattleModule 会 /// 把单位换到另一套战斗模块上,HUD 绑的是原来那套。反之 kimika 有而我们缺的是技能槽绑定。 /// 三条都能一键回滚,改完重启服务端即可(不需要重新编译之外的动作)。 /// public bool EnemySpawnCampInfo { get; init; } = true; /// 见 。false = spawn 时不推 SyncAttachBattleModule /// (注意:注释称它缺失会导致 T-pose,所以关掉后要同时看模型姿势)。 public bool EnemySpawnAttachBattleModule { get; init; } = true; /// /// spawn 时把模板自己的攻击技能绑进 6 个技能槽(SyncChangeCommonSkill / HeavyAttack / /// ActiveSkill / UniqueSkill / DodgeSkill / ControlSkill)。 /// /// 来源 = kimika 的 GameRouter.EnemySkills.cs:它说技能槽"不是让敌人出手的开关,但 /// some client builds read the actor's slot table for the HUD"—— 也就是说 HUD(含血条)可能 /// 读 actor 的技能槽表。我们一份都没发过,这是和捐赠端最实的一处差异。 /// ⛑ 用敌人自己的 entityId 当 spiritId,duration=99999(WorldCodec.SkillBinding 的约定)。 /// public bool EnemySpawnSkillSlots { get; init; } = true; /// /// Seymour 大停电(T2-31)控制器 gadget 的落点。 /// /// donor 文档 §6.3 要求 Position=(0,0,0) 的 identity transform —— 因为 /// Gadgets_Hacker_Tetris 的画面是 prefab 里 authored 的 6 块广告牌 /// (vx_sc_..._Hacker_Tetris),坐标由 prefab 自己决定,服务端给的只是逻辑锚点。 /// 但 identity transform 带来一个实测后果:交互锚点落在世界原点,而玩家坐标量级是 /// (3950, 0, 1817) 这种几千米级 ⇒ 交互点在 4 km 外,玩家走到死也按不到那个 hack 按钮, /// 表现就是「点了周围也没反应」(2026-09-23 用户实机反馈)。 /// /// 为 true(默认,验证优先)时把控制器刷在玩家最后上报的坐标上(没有上报就用 /// Profile.WorldSpawn),这样面板触发后玩家原地就能看到交互点并按 F ⇒ /// 客户端发 AskHack(63700695, hacktype=44) ⇒ 链路能走通。 /// 为 false 时回到 donor 原文的 identity transform(视觉/authored 绑定更"正确", /// 但只能靠服务端 GATE Lua 代按按钮,否则玩家永远点不到)。 /// /// ⛑️ 这是为了验证链路才偏离 donor 原文;等 GATE Lua(45437825)那条路打通, /// 应该把这个开关重新置 false。 /// // ⛑️ 2026-09-26 00:xx 用户拍板「还原到 Blackout_IMPLEMENTATION_RU.md 文档那版」:donor 原文 // 明确写着「Контроллер не надо размещать у ног игрока: его identity transform нужен для // authored-привязок」(不要把控制器摆在玩家脚下,identity transform 是给 authored 绑定用的)。 // 第一版(09-23 下午 T2-31)日志是 `controller spawned`(不带 at),就是 identity。 // ⛔ 改回 true 会重演「广告牌/菜单浮在半空」—— 那是 prefab 整体被平移的后果(规则 131)。 public bool BlackoutControllerAtPlayer { get; init; } = false; /// /// 为 true 时用 / / /// 这组广告牌坐标;面板「启动」里填了坐标则以面板为准。 /// 为 false(2026-09-24 21:3x 用户拍板后的默认值)时退回 /// 的玩家脚下逻辑。 /// /// ⛑️⛑️ 为什么默认改回 false(09-24 21:3x 用户令「不需要找什么官方广告板,和 09-23 19:23 /// 那一版一样,直接放在脚下即可」):广告牌路线是为了让小游戏相机对准屏幕而选的 /// (相机只转身不飞行,pivot 就是控制器坐标),但官方牌屏幕在 Y=70 高空, /// 玩家站落脚点 (1992.47,0,2313.44) 到控制器的 3D 距离约 75 m, /// 远超骇入射程 data.dis=15 ⇒ CheckHackCondition 直接 false ⇒ /// AskHack 失败 ⇒ 小游戏从来没开过局(全历史 AskStart/FinishHackerTetris 均 0 次)。 /// 刷在脚下会失去「必须走到广告牌前」的官方仪式感(距离恒为 0),但能骇、能开局、能玩; /// 09-23 19:23 那一版就是这个行为,用户确认回到它。 /// /// ⛑️ 别把它当成 donor §6.3 的 identity transform —— (0,0,0) 那条路已被实测否掉 /// (交互点在 4 km 外,玩家永远按不到)。 /// public bool BlackoutControllerAtBillboard { get; init; } = false; /// /// 控制器落点 X — 默认取官方牌 vx_sc_sm_map01_outdoor_build_business_mall006a_45_racing /// 的屏幕正中心(2001.479, 70, 2287.694)。 /// /// ⭐ 为什么是屏幕而不是牌脚(2026-09-24 用户定):小游戏相机只"转身" /// (SetFreeLookCameraDirection)不会飞过去,而面板拿到的 lookAtPivot /// 就是控制器自己的位置 ⇒ 控制器刷在屏幕上 = 相机瞄屏幕,画面一定对得上; /// 而且客户端只记住本局见过的第一个控制器坐标(实测 pivot 会陈旧到上一个 id), /// 所有控制器坐标一致时"记错了也没关系"。 /// ⛑️ 代价:屏幕在 70 m 高空,骇入交互有射程(CheckHackCondition 的 data.dis), /// 按不到就把这三个值改回落脚点(1992.47, 0, 2313.44),或让面板「退回牌子脚下」。 /// public float BlackoutBillboardX { get; init; } = 2001.479f; /// 控制器落点 Y — 官方牌屏幕离招牌原点高 70 m。 /// ⛑️ 要在地面骇入就填 0,并配合上面的落脚点 (1992.47, 0, 2313.44)。 public float BlackoutBillboardY { get; init; } = 70f; /// 控制器落点 Z — 官方牌屏幕中心 2287.694。 public float BlackoutBillboardZ { get; init; } = 2287.694f; /// /// 「控制器摆了但骇入点不出现」的补发次数(0 = 关闭补发)。 /// /// 服务端每次都报 `controller acked`,但玩家走到落点看不到骇入提示 ⇒ /// 客户端把这条 SyncGadgetAOIAddAndRemove 丢掉了(常见于按下启动时玩家还在 /// 几百米外,物件被客户端自己的流式/AOI 规则丢掉,之后传送过去也不会再加载)。 /// 补发用同一个 instanceId + 同一组坐标,幂等。调到 0 即回到接入前的行为。 /// public int BlackoutRearmMax { get; init; } = 6; /// 两次补发之间的间隔(秒)。默认 5 秒 × 6 次 = 30 秒的补救窗口。 public int BlackoutRearmSeconds { get; init; } = 5; /// /// 层 2a / T4-9: how many historical case rows to fabricate into /// SyncSpiritPoliceJobInfo.CaseInfos (0 = none, capped by /// PoliceConfig.static.CaseUpperLimit = 50). /// /// A case row is not a config table — the client builds it from the people it actually arrests — /// so the rows here are composed out of real tables only: NpcId comes from /// PoliceSuspectCatalog (real AgentConfig rows with a portrait; the client's /// PoliceCaseListPanelStore.OnRenderCaseItem silently skips any case whose NpcId is not in /// AgentConfig), Fines from PoliceFineCatalog shifted-pick so different rows show /// different charges, and BonusDrops from PoliceConfig.static.ArrestRewardDrop. /// Each case is written with InterrogationInfo.State = CanInterrogate so the panel offers /// the interrogation entry instead of claiming the case is closed. /// public uint PoliceCaseCount { get; init; } = 3; /// /// 层 2a: the number seeded into the homepage tally /// (DutyBasicInfo.ServiceData → DispatchTimes / PatrolTimes / ArrestTimes / FineCount, /// rendered by PolicePanelManager.RenderSummaryTemplate). 0 = derive from what is actually /// published (dispatches + cases), any other value is used verbatim for all four counters. /// Purely cosmetic — the server owns no police statistics. /// public uint PoliceServiceTally { get; init; } /// /// Escape hatch for the police payload: "full" (default) publishes the T4-9 content, /// "empty" publishes the bare skeleton (every collection empty, every complex field /// present with zero fields) — byte-for-byte the first version of /// SyncSpiritPoliceJobInfo, which is the only one shipped before 2026-09-21 that had no /// reported client crash. /// /// Why this exists: a wrong police frame does not fail loudly, it takes the client process /// down. On 2026-09-21 a missing trailing field in the case row /// (PoliceCaseInfo.CrimeDefaultItems) desynchronised the frame, the client then read the /// next row's unix Time as a list count and tried to build a ~1.7e9 entry table — the game /// died with no Lua error and no Windows crash report, only ~4 s after the packet. If a future /// edit ever brings that back, flip this to "empty" to restore a known-good frame while /// keeping the app openable. /// public string PolicePayloadMode { get; init; } = "full"; /// /// Publish the police app's Mimic Database archive (伪人档案) as unlocked. The client keeps that /// archive where SyncSpiritPoliceJobInfo leaves it, so with this off all 27 rows render /// as locked regardless of what the player does. Off = the pre-existing behaviour. /// public bool PoliceFakeFilesUnlock { get; init; } = true; /// /// Rollback switch for the NPC shop shelf pushed by AskNpcShop (63563263): /// "off" answers OK but pushes nothing (the 2026-09-22 behaviour — panel opens empty), /// "pilot" (default) restocks only Solution Pawn 94100024, "full" restocks every /// shop in NpcShopCatalog.json (86 shops / 8754 rows). /// /// Why the default is the narrowest setting: the shelf frame is hand-transcribed from the /// client's own reader (RPCDeserializeAuto Reader[366]) because the IL2CPP dump declares /// NpcShopCommodityView as an empty class, so a wrong field would desynchronise the /// frame and — as with the 2026-09-21 police payload — take the client process down with no /// Lua error. Pilot mode confines that blast radius to one shop until the layout is confirmed. /// public string NpcShopPayloadMode { get; init; } = "pilot"; /// /// Rollback switch for the 趴趴 (Bubble) post list returned by AskMomentsPostSimpleInfos /// (63452251): "full" (default) publishes every post in BubblePosts.json (55 rows), /// "empty" answers with an empty list — the behaviour of every build before 2026-09-24, /// and the setting to fall back to when the frame desynchronises. /// /// Why this needs a switch at all: PostSimpleClientInfo is declared as an empty class by /// the IL2CPP dump, so the field layout comes from the client's own writer /// (RPCSerializeAuto.lua:8310). A wrong field does not fail loudly — the client logs /// deserialize failed and the panel silently stays empty. /// public string BubblePayloadMode { get; init; } = "full"; /// /// Gap between two consecutive posts' Date, newest first. The panel renders /// "多久以前" from ServerUnixTime - info.Date, so the newest post is "now" and each /// older one steps further back. /// public uint BubblePostDateStepSeconds { get; init; } = 900; /// /// Publishes every post as already read (PostSimpleClientInfo.IsRead = true). /// /// This is what decides whether tapping an NPC head opens the NPC home page or jumps straight /// into a single picture: NewBubbleMgr.lua:750 OnClickNpcBubbleHead asks /// CheckHasNewStory first and, when that returns a post id, calls /// OpenDetailPanel(id) — the "one photo, no tabs" behaviour every NPC except 凛 had. /// CheckHasNewStory returns the first STORY/SHARE post whose IsRead is false, so /// with the honest "unread" value every NPC has new stories forever and the three-tab home page /// (日程 / 进展 / 快拍) is unreachable. 凛 was the exception only because her posts land in the /// POST bucket, which CheckHasNewStory never looks at. /// /// Cost of turning it on: the unread red dots disappear, which is meaningless here anyway. /// Set it to false to get the dots back and lose the home page again. /// public bool BubbleMarkAllRead { get; init; } = true; /// /// Publishes the NPC daily schedule in the login frame /// (PlayerClientInfoMinor.FavorNpcDailyScheduleInfos), which is what fills the 日程 tab /// of the 趴卜 NPC home page. /// /// Off by design until it is measured: the field is a nested dictionary of complex objects /// inside the biggest payload of the whole login, and a wrong layout there can desynchronise /// everything after it, so it needs a switch that can be flipped without a rebuild. /// public bool NpcDailyScheduleEnabled { get; init; } = true; /// /// Cat Express / 蓝猫物流 — serves the courier board (AskGetTruckJobOrders 63386469 and /// friends). Off gives the pre-2026-09-24 behaviour back: an empty board, because nothing else /// fills it. /// public bool DeliveryEnabled { get; init; } = true; /// /// Framing of the two board replies — "complex" (0xFF marker, the default) or "struct" (none). /// /// Which one is right is a property of the client's generated reader for /// ClientTruckOrderView, and that reader is not in the Lua auto-gen tables and the IL2CPP /// dump declares the type empty, so it cannot be read offline. Defaulting to complex matches /// every other INVOKE reply in this repo (Bubble / Extraction / Tuite's TuiteTimelineData). /// A wrong choice shows up as a dropped callback, never as a parse error, so this switch exists /// to test the other branch without a rebuild. /// public string DeliveryReplyFraming { get; init; } = "complex"; /// Rows on the take-order board. UberSimConfig authors 2 as the day's first batch. public int DeliveryBoardSize { get; init; } = 8; /// /// EstimatedFinishSeconds on every row (the "约 N 分钟" text). The server has no pickup/delivery /// coordinates to derive travel time from, so it is a flat configured number rather than a lie /// computed from a zero-length route. /// public int DeliveryEstimatedFinishSeconds { get; init; } = 300; public int DeliveryBasePointReward { get; init; } = 10; /// /// 蓝猫物流「部署 / Deploy」按钮(UberSimOrderSupportConfig Id=1 Drone,functionId 51938185) /// 按下后是否真的刷出送货无人机(AgentSummonCatalog 40120909)。 /// /// 与黑客 APP 那两个是同一套 android-control 通道:GameRouter.HackerSummon.cs 接住 /// temp-skill 后刷 agent 再推 SyncSwitchControl 交操控,之后面板全在客户端。 /// 按钮本身要 UnlockBuff 52800661 才显示,那个走 CharacterCapabilities,与本开关无关。 /// /// 回滚:置 false ⇒ 只打一行 [SUMMON] skipped,回到「点了 Deploy 只有施法动作、没有东西出来」。 /// public bool DeliveryDroneSummonEnabled { get; init; } = true; /// /// 配送类 RPC 回给客户端的"成功" errorId。默认 1。 /// /// ⛑⛑ 2026-09-25 04:35 实测定论:客户端日志打出 MessageConfig.Ok=0, /// 而配送这条线的回调全是 if errorId == MessageConfig.Ok then 报错; return end。 /// ⇒ 回 0 = 失败。接单那处(DeliveryHomepagePanelStore:810)命中后还会 /// RefreshOrderByAskTakeOrGiveUpFail() 把刚接的单回滚,表现就是"点了没反应"。 /// 所以默认必须是非 0。 /// /// ⛑⛑ 但**实测两难**(2026-09-25 04:49):改成 1 之后客户端日志变成 /// NO view: errorId=1 type(view)=nil —— 框架对 errorId != 0 的回包**根本不反序列化 /// body**,于是所有要拿 payload 的 RPC(大厅 / 结算预览 / 送货日志)全废,app 直接打不开。 /// ⇒ 结论:需要 payload 的 RPC 只能回 0,之后再想办法绕开 `== Ok` 的误判 /// (方向:fastpatch 里把接单回调也接管掉,或者补 AskQueryTruckPosInfo 的坐标)。 /// 默认回到 0,保住"能打开"。 /// /// ⛑ 这个 build 的极性离线定不死,别再推: /// · DeliveryTaskManager.PreSettleTruckOrder:572 写 /// if err ~= MessageConfig.Ok then 成功 /// · DeliveryTaskManager.FinishDeliveryOrder:706 写 /// if err == MessageConfig.Ok then 报错; return end /// 两处方向相反,而 MessageConfig.Ok 经 CompressTable 代理取回的值实测既像 0 又不像 0 /// (OpenUberSim 把回 0 判成错误,可 Tuite/BBChat 同样回 0 却是好的)。 /// 所以留成配置:实机哪一步不推进就改这个数,改 json 重启即可,不用重编译。 /// public int DeliverySuccessErrorId { get; init; } = 0; /// /// 取货点 / 送货点坐标是否用玩家最后一次上报的位置当锚点。 /// /// 背景:UberSimPickupConfig / UberSimDeliveryConfig 只有名字和标签,一行坐标都没有, /// 所以服务端无从得知真正的取送货点。开 = 取货点落在玩家脚下、送货点沿 X/Z 各偏移 /// DeliveryDropOffset,地图标记至少落在玩家够得着的地方;关 = 回到全 0(世界原点)。 /// 用于 AskQueryTruckPosInfo(63763434) 与看板行的 StartPos/EndPos。 /// 回滚:置 false。 /// public bool DeliverySpotsAtPlayer { get; init; } = true; /// /// 送货点相对取货点的偏移(世界单位,X/Z 各加这么远)。默认 120 —— 看得见、走得到, /// 又不至于远到要开车。仅 DeliverySpotsAtPlayer 为 true 时生效。 /// public float DeliveryDropOffset { get; init; } = 120f; /// /// 接单成功后是否补推 SyncAcceptTruckJobOrder(64910164)。 /// /// ⛑⛑ 接单面板**不是**靠 AskAcceptTruckJobOrder 的回包推进的: /// DeliveryHomepagePanelStore.lua:809 的回调只看 errorId、别的都不碰,而面板的 /// canInteract 闭锁只有 OnAcceptTruckJobOrder(:819) 会解开 —— 它挂在 /// ON_ACCEPT_TRUCK_JOB_ORDER 上,而那个消息是 /// GameToClientImpl.SyncAcceptTruckJobOrder(GameToClientImpl.lua:1761) 发的。 /// ⇒ 只回包不推送 = 服务端日志一片 success、界面纹丝不动(2026-09-25 用户报的"点了没反应")。 /// 回滚:置 false。 /// public bool DeliveryAcceptSyncEnabled { get; init; } = true; // ⛔ 2026-09-25 10:4x 曾在这里加过 deliveryCargoIntegrity / deliveryEvaluationStars // (给结算补 TruckJobOrderResult 用)。整条结算链实机失败,结算/评分/排行榜已回退, // 后来只把"送货日志"单独做成了 —— 那段用不到这两个开关。 // 回顾见 Drmk.Proxy/proxy/tools/_disabled/delivery_settle.lua.disabled 头部注释。 /// /// 「送货日志 / 账户」页预置几条历史已完成订单。默认 3,置 0 只剩真实结算的行。 /// /// 为什么需要预置:客户端的结算链(PreSettleTruckOrder:571 / /// FinishDeliveryOrder:705)只被任务系统的 eventId 驱动,面板上没有任何按钮调它, /// GMFinishOrderById(:1755) 也全仓零调用点 ⇒ 不预置的话日志页永远空的。 /// 这些行走的是同一套 UberSim 真实表(货物 / 取货点 / 送货点),只是**时间是被倒推的**, /// 第 n 条标成 n 小时前完成,好让排序和「今日累计」看得出来。 /// public int DeliveryLogSeedCount { get; init; } = 3; public int DeliveryRewardPointSum { get; init; } = 0; public int DeliveryTotalIncome { get; init; } = 0; /// Returned by AskGetTruckSatisfactionAverage and used as the board's average. 5 = five stars. public float DeliverySatisfaction { get; init; } = 5f; /// /// Vehicle preselected for 叫车. 0 lets the client fall back to /// UberSimOrderSupportConfig.CallCarCfg.vehicleId ([81003022, 81004024]). /// public uint DeliveryDefaultVehicleId { get; init; } = 0; /// /// Buffs published on every spirit so RefreshCallFunctionListView renders the two support /// buttons: 52800660 = 叫车 (CallCar), 52800661 = 无人机 (Drone). Without them the buttons are /// filtered out of the panel entirely and neither can be tapped. /// public uint[] DeliverySupportBuffIds { get; init; } = [52_800_660, 52_800_661]; /// /// Publishes the 眼界 (Tuite) post instances in the login frame /// (PlayerClientInfoMinor.PlayerTuiteInfo.TuiteList). /// /// On by default because it is the entire content of the app: every 眼界 list /// (推荐/发现/关注/收藏/同城) is a filter over that one list — /// SocialNetWorkUtils.lua:143/400/431/445/1687/3046 all read /// playerTuiteInfo.TuiteList and none of them has a server request of its own, so with /// the list empty the app has nothing to render regardless of what the detail handlers answer. /// /// Rollback: false restores the pre-2026-09-24 behaviour (empty app). The payload is a /// list7 of a 12-field struct, ~8 KB for all 204 rows, so size is not the risk — a wrong field /// layout is, and that is what this switch isolates. /// public bool TuiteEnabled { get; init; } = true; /// /// Marks every published 眼界 post as followed (ClientTuiteInfo.IsFollow = true). /// /// The 关注 tab is GetFollowList (SocialNetWorkUtils.lua:431), which keeps only entries /// whose IsFollow is true, and the only way that flag gets set for a catalogue post is /// AskTuiteFollowRole — a per-role action the player has to perform 159 times. On means /// the tab has content immediately; false means it starts empty and fills as the player /// follows authors one by one (the flag still sticks for the session). /// public bool TuiteFollowAll { get; init; } = true; /// /// Publishes every NPC conversation thread from Configs/NpcChats.json into the login frame's /// InfoNpcCultivation.NpcChats / NpcGroupChats. /// /// This is the entire content of BBChat (AppId 27 "Message", SystemId 146 — its red dot comes /// from gRedDotUtils.CheckNpcChatHasRedDot(), MainPhoneUtils.lua:688). There is no "pull /// the list" RPC: PlayerInfoMinorNpcCultivationData.lua:55 copies both lists and /// NpcChatManager_Story.lua:28 turns them into the manager's dictionaries, so leaving /// them empty is why the app had no conversations at all. /// /// Rollback: false restores the empty app. ~2.8k rows × 22 bytes ≈ 62 KB on the login /// frame, which is the one thing to watch if SyncPlayerInfo ever stops arriving. /// public bool NpcChatEnabled { get; init; } = true; /// /// Also publishes the threads whose head row is Dialog (ChatType 3) or Fake (ChatType 4). /// /// Those are world-triggered content — a cutscene hands the conversation over /// (ChatNpcsPhoneManager.OpenFakePhoneChatPanel, npc-phone props) — so pre-loading them /// fills the app with conversations the player has not reached yet and nearly doubles the /// payload (2791 rows → 5888). Off by default; the interact handlers still answer for those rows /// because the whole NPCChatConfig NextMessage map is loaded regardless. /// public bool NpcChatStoryThreads { get; init; } = false; /// /// 手机「拨号」:用 Configs/PhoneContacts.json 里的整张通讯录填充登录帧的 /// PlayerPhoneInfo.SpiritPhoneInfos[spirit].ContactList。 /// /// 通讯录条目只有号码 + 备注两个字段,名字/头像/台词全在客户端本地 PhoneContactConfig 里按号码回查, /// 所以这是纯数据下发,没有 wire 风险。回滚 false = 只剩两个叫车快拨号(2026-09-24 之前的行为)。 /// public bool PhoneContactsEnabled { get; init; } = true; /// /// 把 PhoneContactUnlockConfig 的**全部**行 id(79 条)标成已解锁(module 3 = PhoneContact 的 /// FinishedTemplateIdList)。 /// /// 判据在 EventConditionUtils.CheckHasUnlocked:列表里包含该行的 Id 即视为解锁; /// 原版靠任务/剧情推进,我们没有任务链,所以整表标完成。未解锁时客户端播 /// LockDialogId(号码忙),既不报错也不打日志,是这条线最容易误判成"服务端没发"的地方。 /// 回滚 false = 只解锁叫车那两行(Id 1/2),等于回到 2026-09-24 之前。 /// public bool PhoneContactsUnlockAll { get; init; } = true; /// /// 连 AutoAddFlag = false 的号码一起下发(诈骗电话 13342865 / 灵异电话 44444444 / /// 商店外卖类 17 条)。这些是原版要靠手动拨号或特定任务才出现的彩蛋与商线, /// 默认 false 只发会自动进通讯录的那 49 条。 /// public bool PhoneContactsIncludeHidden { get; init; } = false; /// /// Where the 趴卜 post pictures are fetched from (SyncOssBucketInfo 64833344). Off by /// default — see for what to fill in. /// public OssPictureSettings OssPicture { get; init; } = new(); public SwitchAnimationSettings SwitchAnimations { get; init; } = new(); public CombatSettings Combat { get; init; } = new(); public WebTraversalSettings WebTraversal { get; init; } = new(); public VehicleSettings Vehicles { get; init; } = new(); /// /// 车辆改装店(ApplyCar / RepairShop)进场 —— 2026-09-25 从俄语上游 donor 移植。 /// 三个开关都可以在不动代码的情况下回滚:enabled=false 会把整套逻辑退回 /// 移植前的行为(AskTeleport 只 ack 不 echo、两个 waypoint RPC 只 ack 不推位移、 /// 不再补推 SyncStartSummonVehicle)。 /// public RepairShopSettings RepairShop { get; init; } = new(); /// /// 载具路线规划(AskVehicleNavigationPathPoints / …FromPos / …PathLength)—— /// 2026-09-24 从第九份捐赠端移植,但图用的是我们自己的车道表。见 /// GameRouter.VehicleNav.cs 头注(里面有 unknown-methods.log 的取证数字)。 /// 关掉即逐字节回到「空点集 + 长度 0」的旧行为。 /// public VehicleNavigationSettings VehicleNavigation { get; init; } = new(); /// /// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30)—— 面板与判定全在客户端 /// (ToiletOstrichGamePanelStore.lua 的音游式三键,满分 100),服务端只负责 /// 「让骑乘坐实」+「接住失败上报」。见 GameRouter.ToiletQte.cs 头注。 /// 马桶鸵鸟(规范 id 42046401)是标准 Agent,用既有的服务端直生成通道即可出, /// 它已被置顶到面板「生成敌人」下拉框第一位(GameAdminBridge.AgentSummonCatalog)。 /// public ToiletQteSettings ToiletQte { get; init; } = new(); public TaxiSettings Taxi { get; init; } = new(); public AetherSettings Aether { get; init; } = new(); /// /// 派对 NPC 是否走「预制动作」。默认 true。 /// 关掉 = 回到第 37 轮之前的行为(PoiActionId 恒 0,NPC 只有兜底 idle)。 /// 只影响**派对系**(91055000 / 91056001 / 91056002)的 NPC,其它玩法与城市市民不受影响。 /// public bool PartyNpcPoiEnabled { get; init; } = true; /// /// 露营系(`NpcCultivationGameplayTypeConfig` Id = 91055000 露营派对 / /// 91056001 露营引导)NPC 的 POI 动作 id 表,按人轮转。空数组 ⇒ 该玩法回退 /// 。 /// /// ★ 第 39 轮换成官方**互动成套族**「喝茶聚集聊天 01~05」(ActionTag4 = `场景互动;道具`)。 /// 第 38 轮用的 `[236,239,237,238,174]` 是 4 单人 + 1 互动 ⇒ 实测「各做各的」,已作废。 /// /// ★★★ 第 47 轮:换成 **5 个官方「地面坐姿」** —— 用户现场反馈「『坐姿 + 聚集聊天』 /// 成套族……**他们没有桌子椅子啊,也没有面对着**」「我要的是**直接屁股坐在地面的姿势**, /// 也不用喝茶什么的,不然还要有桌子椅子杯子,**最简单的动作**」。 /// ⇒ 「喝茶聚集聊天」是 `BehaviorPostureGameplayTag = **35**`(**椅子坐姿**,自带茶杯 /// `BelongingSceneItems = [80003622]`),在没有任何家具的草地上播出来就是「凭空端杯」。 /// /// ⛔ 别再靠名字猜 —— **权威判据 = `BehaviorPostureGameplayTag`**: /// 34 = 站姿、35 = 坐姿(椅子/卡座)、39 = 地面坐姿、37 = 蹲姿、38 = 躺姿。 /// `UrbanDiversityPOIActionConfig.json`(3978 条)里 `P=39` 共 **64** 条,其中 /// **`UsageId = 0`(无道具)且 `MainActions` 非空(有可下发动作)= 46 条** ⇒ 就是本次的池子。 /// /// ⚠⚠ **第 47 轮用户要求「让我挑一下,不要什么动作都塞进随机里」** ⇒ 本表默认只放 /// **一条**(`[1504]`,人人同一种坐姿,结果完全可预期);想让人人不同,就按下面的 /// 备选往数组里加 id —— **顺序 = 第 i 个人拿第 i 条**(`poiActionIds[i % Count]`), /// 所以「加什么、加几条」全由你定,不会被自动打乱。 /// /// ★ 2026-09-26 用户**逐条筛过**的池(`P=39` / `UsageId=0` / `MainActions` 非空 / 去重 / 去道具), /// 目前**只剩这 9 条**是「真·坐在地上、无道具、服务端能下发」: /// 1504 坐地伸腿聊天A 1505 坐地撑地聊天B ← 官方 A/B 一对,`ActionTag2=通用` /// 934 沙滩_地上聊天A 313 沙滩_海滩闲坐合集02 MA[86264,86266] /// 5006 通用_坐地闲置动作组 MA[87131..87135] ← **一条=5 变体**(按 `entityId%5` 分) /// 929 沙滩_矜持坐着_女 930 沙滩_惬意坐着_男 931 沙滩_妩媚坐着_成女 932 沙滩_极道严肃坐着_成男 /// ⚠ 929~932 名字**带性别**:邀请的角色男女混着时,建议只用不带性别的那 5 条 /// (1504 / 1505 / 934 / 313 / 5006)—— **本表默认就是这 5 条**。 /// ⚠ 另有两条形态不同、单独列出:`531 大字型坐`(`ActionTag3=躺姿`,更像**躺**)、 /// `5012 蹲姿010_通用_马路边闲置动作组`(`P=39` 但名字是**蹲**)。 /// /// ⛔ **用户 2026-09-26 明确划掉的**(别再放回来): /// 166 冥想打坐 · 291/292 混混坐姿/混混地面坐姿 · 1112 不良混混坐地闲置 · /// 928 沙滩_悠哉松弛地坐着 · 421 酒吧_酒鬼坐地 · 300/301 酒吧_靠墙醉卧/靠墙抱胸醉卧 · /// 554 夜店_卡座醉汉B · 644 夜店_蹲着安抚倒下的人B · 1173 警察_犯人坐地上 /// ⇒ 归并成一句:**不要打坐 / 混混 / 醉态 / 蹲 / 犯人 / 躺 这类「有故事」的姿态**, /// 只要「**单纯坐在草地上**」。 /// /// ★ 选池顺序 = `poiActionIds[i % Count]`(**固定**,不会每次重开都变): /// 数组里放 1 条 = 5 人同一种;放 5 条 = 一人一种。 /// /// ⛔ **越界不可用(20 条)**:`MainActions` 为空、走 `ActionSetName`/`ActionName` 字符串的那些 /// (`2114 坐地面单人闲置` / `1665 盘腿坐地上合集` / `1667 1668 盘腿坐地上聊天A·B_男·女` / /// `16663 盘腿坐地上观看` / `501036 通用13_坐地面_idle` …)。服务端 /// `DonorPoiActionCatalog4229938.SelectMainAction` 只读数值 `MainActions`,读不到 ⇒ /// `DonorNpc.cs:255` 回落 `DefaultNpcActionId = 1001`(**站姿** idle)⇒ **会站着不动**。 /// 2026-09-26 找过它们背后的动作表(`ActionSetName=310` / `ActionName=5772` 这种): /// `ActionGroupBehaviorConfig.json` **是 0 条空表**,`AnimationSetAnimationSetNameConfig.json` /// 只有 10 条枚举(Quest/Gameplay/Worldlife/Common/Veh…)⇒ **映射没解出来,只能用数值那批**。 /// /// 曾经试过的 5 连(`地面坐姿_沙滩_*坐着`,含已被划掉的 928)—— 保留备查,不默认启用: /// 928 地面坐姿_沙滩_悠哉松弛地坐着_胖男女 MA[86883] U=0 /// 929 地面坐姿_沙滩_矜持坐着_女 MA[86884] U=0 /// 930 地面坐姿_沙滩_惬意坐着_男 MA[86885] U=0 /// 931 地面坐姿_沙滩_妩媚坐着_成女 MA[86886] U=0 /// 932 地面坐姿_沙滩_极道严肃坐着_成男 MA[86887] U=0 /// ⚠ 这 5 条的字段形状与**已实测跑通**的 1331~1335 完全一致(`MainActions` 单元素 + /// `MainActionGroup = 400` + `UsageId`/`BelongingSceneItems` 无道具需求)。 /// /// ★ 备选(同样 `P=39` / `U=0` / 有动作,想换一行就改这一行): /// `[1504]` 地面坐姿_居民_坐地伸腿聊天A MA[87657] /// `[1505]` 地面坐姿_居民_坐地撑地聊天B MA[87658] ← 1504/1505 是官方 A/B 一对 /// `[5006]` 地面坐姿_通用_坐地闲置动作组 MA[87131..87135] ← **一条=5 个变体**, /// 服务端按 `entityId % 5` 取值 ⇒ 单填 `[5006]` 也能让 5 个人拿到 5 种坐姿 /// `[166]` 地面坐姿_通用_冥想打坐 MA[86158] /// `[291]`/`[292]` 地面坐姿_居民_混混坐姿 / 混混地面坐姿 /// `[313]` 地面坐姿_沙滩_海滩闲坐合集02 MA[86264,86266] /// ⚠ 池子里还有 20 条 `MainActions` **为空**的(走 `ActionSetName`/`ActionName` 字符串, /// 如 `1665 盘腿坐地上合集` / `2114 坐地面单人闲置`)——**本服下发不了**:服务端 /// `SelectMainAction` 只读数值 `MainActions`,读不到就回落 `DefaultNpcActionId` /// (1001 = 通用**站**姿 idle)⇒ 会站着不动。**别选那 20 条。** /// /// ⚠ 「按人轮转」要**保留**:5 条 ⇒ 5 个人各拿一条,这正是用户要的「随机分配 5 个地面坐姿」。 /// ⚠ 「野餐」族(236/239/237/238)**不是**互动动作(tag4=`道具`/空),别再当露营主力, /// 只能当「单人补充」。第 38 轮把它们当主力是本次实测暴露的错误。 /// ⚠ 想回到第 38 轮行为 ⇒ 把 id 换回去;想完全不发 POI ⇒ 本表清空(回退兜底 17)。 /// public uint[] PartyNpcPoiCampActionIds { get; init; } = [1504, 1505, 934, 313, 5006]; /// /// 舞会(91056002)NPC 的 POI 动作 id 表,按人轮转。 /// /// ★ 第 39 轮换成官方**互动成套族**「站姿_夜店_开火车 01~04」(ActionTag4 = `人物互动`)。 /// 第 38 轮的 `[6,40,5,174,52]`(鼓掌/围观/交谈)同样是单人堆,已作废。 /// ⚠ 若觉得「开火车」不贴舞会,可换 `[783,784]`(跟着DJ摇摆动作双人A/B,但 tag4 为空)。 /// public uint[] PartyNpcPoiDanceActionIds { get; init; } = [797, 798, 799, 800]; /// /// 兜底动作表(玩法认不出来 / 打开但上面两张表为空)。 /// 17 = 站姿_通用_站姿闲置00,与现在的隐式兜底等价,但走正规 POI 槽位。 /// 配成空数组 ⇒ 完全不发 POI(等价于第 37 轮之前的行为)。 /// public uint[] PartyNpcPoiDefaultActionIds { get; init; } = [17]; /// /// ★ 派对 NPC 是否**跳过**「HackNpc 出场给手机」那一步。默认 true(跳过)。 /// /// WHY:SendCivilianAsync 的尾部会调 /// MarkNpcHackableOnSpawnAsync(..., skipPhone: false) ⇒ 推 /// SyncAgentBelongings(手机 80003481) + SyncAgentUseBelongingChanged(usage 6/1 玩手机·打电话)。 /// 这一步**晚于** POI 自己的 belongings/usage,所以会把 /// 「野餐吃喝(可乐)」直接改写成「玩手机」—— 预制动作等于白配。 /// (HackNpc 那边的注释原文就写着「其余情况(POI 是拍照/打字/吃东西…)换成手机」。) /// /// 默认 true = 跳过给手机,让预制动作与自带道具生效;可骇标记仍然照打, /// 只是这些 NPC 在骇入面板里只有名字、没有按钮(这是刻意的取舍)。 /// 想让派对 NPC 又能被骇(回到第 37 轮行为)就把它改成 false。 /// 只影响派对系 NPC,城市市民/店员/行人的行为不变。 /// public bool PartyNpcPoiSkipHackPhone { get; init; } = true; /// /// 派对 NPC 的驻留行为通道。取值: /// /// "waypoint"(**默认**)—— 服务端路点巡游(SyncLogicAgentCommand + /// TaskWayPointMove(tag 39),`Ambient.cs:24-26` 称其为 /// 「**唯一能产生走路步态的通道**」,街上行人在走的就是它)。 /// "wander" —— SyncLogicAgentEnter + SyncAgentForceGo /// + SyncMoveWandering 并周期续期。**2026-09-26 实测:对 Aether agent 无效**, /// 保留只为对照(见下方 ★★ 记录)。 /// "none"(**第 47 轮起默认**)—— 停走档:只发 /// SyncLogicAgentEnter + SyncAgentForceGo(把行为树起起来), /// 不发任何 locomotion 指令 ⇒ 角色停在各自那个半径 2.5 m 的圆周站位上、 /// 保持朝向圆心(SpawnPartyNpcsAsync 的 angle + π),并保持 POI 坐姿。 /// ⚠ 第 47 轮把 Enter/ForceGo 提到了模式判断**之前** —— 在这之前选 "none" /// 会把这两步一起丢掉(行为树没起来 ⇒ 连 POI 坐姿都保不住)。 /// /// /// ★★ 2026-09-26 现场实测后**默认由 `"wander"` 改成 `"waypoint"`**: /// 第一次用 `"wander"` 跑,服务端日志/报文全部正确 /// (`[PARTYNPC] 驻留行为循环启动 mode=wander`、`SyncMoveWandering` ×10 body=50b、 /// `SyncLogicAgentEnter` ×49、`SyncAgentForceGo` ×45), /// 客户端也确实把人生出来了(`Player.log` `[DRMK-HUD] RegisterHudCtrl id=830…41..45`), /// 但**一动没动**(`packets-20260926-033355.log` + 客户端 `Player.log` 双向确认)。 /// ⇒ 与 `Ambient.cs:33-34` 的证伪记录一致:SyncMoveWandering 走 /// `WanderingActionModule` = **BaseUnit 管线**,而 Aether agent 是 **ECS 管线** /// ⇒ 静默失败。`AmbientPedsChannel` 默认 `"crowd"` ⇒ /// `SpawnAmbientPedAsync` 里那句「static 通道必须发游荡包」的注释 /// **从没被真正跑过**,不能当依据。 /// /// ★★★ 第 47 轮再改:**默认由 `"waypoint"` 改成 `"none"`**。 /// 用户现场实测 waypoint 档:「**可以走了,不过是乱走**」,随即要求 /// 「有没有把角色**围成一圈**,**坐在地面的动作**……把他们在这个位置围成一圈」。 /// ⇒ 走路通道本身**已经跑通**(`SyncLogicAgentCommand` + `TaskWayPointMove` 有效, /// `SyncMoveWandering` 无效),但「走起来」和「坐着围一圈」是互斥的: /// walk 任务会把人**走散**(每 2 s 换一个随机方向的新路点),并且用行走姿态 /// **顶掉** POI 坐姿。位置与朝向**本来就对**(SpawnPartyNpcsAsync 圆周排布 + /// `angle + π` 朝圆心)⇒ 关掉 locomotion 就是正解,不需要改坐标、也不需要改坐姿。 /// ⚠ 想再看走路 ⇒ 本键填 `"waypoint"`(营地半径见 `PartyNpcRoamMaxDis`,默认 10 m 偏大, /// 建议先收到 3)。 /// public string PartyNpcRoamMode { get; init; } = "none"; /// 驻留行为是否补发 SyncLogicAgentEnter(68719890)。默认 true。 public bool PartyNpcLogicEnter { get; init; } = true; /// 驻留行为是否补发独立 SyncAgentForceGo(68698324)(启动行为树)。默认 true。 public bool PartyNpcForceGo { get; init; } = true; /// /// 游荡半径下限(米),SyncMoveWandering.MinDis。默认 2。 /// ⚠ 营地里有家具与碰撞体(铁律 17:落点必须实测站得住)⇒ 别调大,否则会走丢/穿模。 /// public float PartyNpcRoamMinDis { get; init; } = 2f; /// 游荡半径上限(米),SyncMoveWandering.MaxDis(waypoint 档同时用作巡游半径)。默认 10。 public float PartyNpcRoamMaxDis { get; init; } = 10f; /// 单次游荡时长(秒),MaxOnceWanderTime;MaxTime 自动 +5s。默认 25。 public float PartyNpcRoamSeconds { get; init; } = 25f; /// /// 续期间隔(秒)。MaxTime 走完客户端会**永久站住**,所以必须周期性补发。默认 18。 /// 必须小于 ,否则中间会出现「站住」的空档。 /// public float PartyNpcRoamRearmSeconds { get; init; } = 18f; /// 续期循环的节拍(毫秒)。默认 2000。 public int PartyNpcRoamTickMs { get; init; } = 2000; /// /// 游荡的 PathTags(客户端 AetherAI 的寻路标签)。默认 6,与 /// Aether.AmbientPedsWanderPathTags 同值(那条已在行人群上跑通)。 /// ⛑ 这是最容易踩的静默失败点之一(Ambient.cs 文件头)。 /// public uint PartyNpcRoamPathTags { get; init; } = 6; /// waypoint 档的巡游速度(m/s)。默认 1.0(散步)。 public float PartyNpcRoamSpeed { get; init; } = 1.0f; /// waypoint 档每腿的额外余量(毫秒),用来吸收寻路绕行。默认 1500。 public int PartyNpcRoamLegSlackMs { get; init; } = 1500; /// /// ★★★ 第 47 轮:**覆盖派对 NPC 的落点圆心**(世界坐标,3 个 float = X / Y / Z)。 /// 默认空数组 = 用官方配表 PartySpot(露营 13030000 = (1288.91, -0.09, 2374.47), /// 见 PartySpotCatalog4229938)。 /// /// 用户现场:「他们出现的位置还是之前的,没有出现在我想要的位置」⇒ 给了「按我站的位置 /// 1282.93, 0.97, 2374.78」。落点 = 以此点为圆心、 为半径的圆周, /// 人人**面朝圆心**(SpawnPartyNpcsAsync 的 angle + π)。 /// /// ⚠⚠ 只取 X / Z,Y 强制用官方 PartySpot 的 y(露营 = −0.09) —— /// 客户端 HUD 那行「位置」的 y 与配表/机关的 y **不是一个参考点**:同一片营地, /// 03:44:15 与 03:54:59 两次截图都读到 y ≈ 0.97~0.98,而官方把整个营地摆在 /// y = −0.09 ~ −0.30 上(差 ≈ **1.1 m**,约等于角色眼高/胶囊中心) /// ⇒ 直接把 0.97 用上去会让人**整队浮空 1.1 m**。 /// (想微调高度:让官方 PartySpot 查不到时才会用第 2 个数;正常情况第 2 个数被忽略。) /// /// ★ 该键随 config/*.json **热重载**(改完重新进一次派对即可,不必重启服务端)。 /// 只影响派对 NPC 的落点;配表 PartySpot 的官方语义在别处照旧,零影响。 /// public float[] PartyNpcAnchorOverride { get; init; } = []; /// /// ★ 第 48 轮: **只对这一个 partyId 生效** /// (默认 13030000 = 露营·新启)。 /// WHY:那个覆盖原先对**所有**派对无条件生效 ⇒ 一开舞会(13030003),NPC 会被摆到 /// **露营**的那个圆周上,而不是舞会的 PartySpot。 /// 要覆盖别的场地(例如舞会)请用 ,**不要改这个默认值**。 /// public uint PartyNpcAnchorOverridePartyId { get; init; } = 13030000; /// /// ★ 第 48 轮:**按 partyId 分别覆盖落点圆心**,键 = partyId 的字符串 /// ("13030003": [x, y, z];必须是字符串键 —— System.Text.Json 只原生支持字符串字典键)。 /// 优先级**高于** 。同样**只取 X/Z**,y 恒用官方 /// PartySpot 的 y(HUD 那个 y 是眼高,见 的说明)。 /// /// 例(舞会):"partyNpcAnchorOverrides": { "13030003": [2605.33, 80, 1269.74] } /// ⚠ 该键随 config/*.json 热重载;⚠⚠ private-server.json 里**不能写 // 注释** /// (Run-All.ps1 用 Windows PowerShell 5.1 的 ConvertFrom-Json 严格解析)。 /// public Dictionary PartyNpcAnchorOverrides { get; init; } = new(); /// /// ★★★ 2026-09-30:**就地开局**。列在这里的 partyId,开派对时整片场地 /// (家具 + 舞伴站位)会搬到**玩家当时所在的位置**,而不是配表里的官方坐标。 /// /// WHY:舞会 13030003 的官方场地在 y ≈ 80 的悬空层 —— 那层没有地板, /// 只有我们摆的 2 件常驻机关 + 客户端一个花坛,玩家实机只能踩道具 / 挂飞索才能不掉落 /// (见 2026-09-30.md §47)。「把玩家送上 y=80」这条路已被实机证伪, /// 于是反过来把场地搬到玩家脚下。 /// /// 例:"partyVenueAtPlayerPartyIds": [13030003](舞会就地开;露营不受影响)。 /// 默认 空数组 = 关闭,行为与改动前完全一致。 /// /// ⚠ 该键随 config/*.json 热重载(PrivateServerConfigStore), /// 改完不用重启服务端,下一场派对即生效。 /// ⚠⚠ private-server.json 里不能写 // 注释(PowerShell 5.1 严格解析)。 /// public uint[] PartyVenueAtPlayerPartyIds { get; init; } = []; /// /// 就地开局时,**NPC 落点 y 相对玩家上报 y 的偏移**(米;正 = 抬高)。默认 0。 /// 为什么默认 0:实测(2026-09-30)就地开局把 NPC 放在「玩家 y − 1.07」时 /// **全员陷进地里只露半个身子** ⇒ NPC 的落点 y 与玩家上报 y 是同一参考系 /// (都是角色根),不需要再减眼高。想微调(浮空/下陷)就改这一个键,**热重载生效**。 /// ⛑ 家具是**另一套**参考系(比角色根低 ≈1.07 m),别拿这个值去套家具。 /// public float PartyVenueNpcYOffset { get; init; } = 0f; /// /// 就地开局时,**以玩家为圆心把 NPC 随机散布**的半径(米)。0 = 关闭(保持官方摆法)。 /// 非 0 时角度与距离都随机(距离 ∈ [0.45×R, 1.0×R]),每人朝圆心(= 玩家)。 /// 例:"partyVenueScatterRadius": 3.5 ⇒ 四个人散在你周围 1.6~3.5 m 处、都面朝你。 /// public float PartyVenueScatterRadius { get; init; } = 0f; /// /// 就地开局时,**场地家具离玩家的最远距离**(米,只收距离、保留官方方向)。 /// 0 = 不限制(照抄官方相对摆位)。 /// 为什么需要:官方舞会里灯光控制台在锚点外 20 m、舞会大门在 4.7 m —— /// 搬到玩家脚下后控制台就在 20 m 开外,摸不到 ⇒ 设成 4~6 让它落在身边。 /// 例:"partyVenuePropMaxDistance": 5。 /// public float PartyVenuePropMaxDistance { get; init; } = 0f; /// /// 就地开局时,把整片家具**旋转**到「最远那件落在玩家正前方」(默认 true)。 /// WHY:官方布局的方位是写死的世界方向(舞会灯光控制台在 +x/+z、约 76°), /// 搬到玩家脚下后方向不变 ⇒ 玩家很可能背对着它,实测反馈「看不到控制台」。 /// 开启后按玩家朝向整体旋转(相对摆位不变,只是换个朝向)⇒ 控制台永远在你正前方。 /// 想保留官方世界方位就设 false。 /// public bool PartyVenuePropFacePlayer { get; init; } = true; /// /// 就地开局时,**场地家具在「玩家 y − 眼高(1.07)」基础上再抬/压的高度**(米,可正可负)。 /// 默认 0。想微调就改这一个键,**热重载生效**(不用重启服务端)。 /// WHY:实测(2026-09-30 21:18~21:20 四次开局)家具恒定落在「玩家 y − 1.20」 /// (= 眼高 1.07 + 官方相对摆位 −0.13)。露营点这套补偿刚好, /// 但在城里就地开局时地面起伏 / 站在台面上 ⇒ 控制台埋进地里 /// ⇒ 用户要求 +0.5。0.5 ⇒ 控制台落在「玩家 y − 0.70」。 /// ⛑ 只影响家具,不影响 NPC 落点(那个是 )。 /// public float PartyVenuePropYOffset { get; init; } = 0f; /// /// ★ 第 49 轮:**舞会(GamePlayType = 91056002)的落点锚用哪张表**。取值: /// /// "danceSpot"(默认)—— 用官方舞池点 /// PartyDanceSpotConfig.DanceSpot + 朝向 DanceRotation /// (13030003 新启舞会 = (2605.33, 80, 1269.74),DanceRotation = -84.1142578)。 /// 读表实现见 ClientData\Client4229938\PartyDanceSpotCatalog4229938.cs。 /// "partySpot" —— 退回 PartyConfig.PartySpot(派对场地锚; /// 13030003 = (2574.24, 80.135, 1271),与 PartyCamp.json 的 /// danceOnly.pos 是同一个点、也是舞会大门 Gadgets_Party_DanceDoor /// (2569.63) 所在处)。 /// /// /// ⚠ 两者不是同一个点(13030003 相距 ≈ 31 m): /// PartySpot = 场地入口/「派对在这里举行」的关卡标记; /// DanceSpot = 舞池(灯光控制台 2593.94 在其旁),也是客户端唯一消费的 /// 演出 timeline 位姿(ChooseDanceMusicPanelStore.lua:319-332 ⇒ PlayLiveHouseV2)。 /// 默认取舞池,因为那是官方专门为「舞会」开的表。 /// /// ⚠ 无论这一项怎么设, 里的 /// "13030003" 都优先(它在解析链最前面)—— 这是想手工挪舞者站位时的入口。 /// ⚠ 舞会档的 y 用官方舞池点的 y(80),不再套用「只取 X/Z、y 用 PartySpot」的老规矩 /// —— 那条规矩是为露营那个「HUD y 是眼高」的问题设的,舞池点本身就有官方 y。 /// public string PartyNpcDanceAnchor { get; init; } = "danceSpot"; /// /// ★★★ 第 52 轮(B 方案):室内归属声明开关(默认 true)。 /// /// 落点位于「VisibleOutside = false」的室内占地里时,照常落位, /// 但把官方字段 AgentSyncClientInfo.IndoorId(+ indoorList)声明给客户端 /// ⇒ 客户端自己按室内物处理:室外不渲染、走进那间室内才可见。 /// /// /// 为什么需要它:第 50 轮的 是「拦下不落位」, /// 判据只比 x/z 投影 —— 用户当时 x = 2613.6、房间 Spiko Hotel 东边界 2610 /// ⇒ 只差 3.6 m,往西一步就翻成「放行」,人又飞回空中。 /// 声明 IndoorId 才是官方语义:舞会的 NPC 本来就属于那间室内,只是我们以前 /// 当露天物摆(IndoorId = 0)。三处构造点在本次改动前全部填空/0。 /// /// /// 与 的关系:本项为 true 时, /// 那道门自动失效(不再拦截,改为声明);把本项设 false 即回到第 50 轮行为 /// (门按 生效)。 /// /// /// ⚠ 声明只作用于 SendCivilianAsync 这条静态 NPC 通道(含派对/市民/路人), /// 不碰机关(SyncGadgetAdd)与场地家具(PartyCamp)—— 后者仍按 /// 过滤,本轮刻意不动(避免误伤已收尾的功能)。 /// /// public bool NpcIndoorDeclaration { get; init; } = true; /// /// ★ 第 47 轮:派对 NPC 圆周的**半径**(米)。默认 2.0 /// (第 34 轮起的原硬编码值 = 2.5;第 47 轮用户现场定为 **2**)。 /// 想围得更紧就继续调小;太小会互相穿模(实测 < 1.2 开始互相挤)。 /// public float PartyNpcSpawnRadius { get; init; } = 2.0f; /// /// ★ 第 53 轮:派对 NPC 的**服装来源**。默认 "dresscode"。 /// /// /// 支持的值: /// /// "dresscode"(默认)—— 按官方配表给每个角色穿**它自己的**套装: /// ① 客户端真的送来了 Rents[spiritId](只有舞会那条路送得出来)就照它穿; /// ② 否则查 FashionSpiritDressCodeConfig(SpiritId + TagList ∋ PartyConfig.SuitTag, /// 取 RecommendPriority 最小者 = 原皮),拿不到再放宽 tag; /// ③ 都查不到 ⇒ 不指定 ⇒ 客户端回落 11190001。 /// "off" —— 完全不干预,保持第 53 轮之前的行为(SuitId = 0 ⇒ 所有角色 /// 都落 11190001「Graffiti Set」= Bansy 的涂鸦套装)。留作对照 / 回滚。 /// /// /// /// /// 为什么默认开:用户现场「派对功能可以自订邀请角色的外观服装,但是选择后开始露营显示的还是 /// 默认服装」。实测已证**露营这条路上客户端根本送不出玩家选的服装** /// (PartyConfig#13030000.FashionsCanRent 是空数组 ⇒ 换装面板的租借列表恒空 ⇒ /// 三条抓包报文 30 字节全同、都是 {Rents={}, UseDefaultFashion=true}) /// ⇒ 服务端**只能**自己按配表决定,否则永远是同一套默认衣服。 /// /// /// /// ⚠ 数据表在 ClientData/4229938/Configs/ 下:FashionSpiritDressCodeConfig.json、 /// FashionRentConfig.json、FashionSuitConfig.json(后者只用于日志里的人名/套装名)。 /// 缺表不会崩:PartyFashionCatalog4229938 全程 try/catch,空表 ⇒ 回落 SuitId = 0。 /// /// public string PartyNpcFashion { get; init; } = "dresscode"; /// /// ★ 覆盖下发给客户端的 AgentSyncClientInfo.AISetting(行为集 id, /// 表 AgentAIBehaviorSetsBattleAISBehaviorConfig)。默认 0 = 不覆盖, /// 即沿用 AgentConfig.AiSettingId(可邀角色是 Companion 档,无游荡行为)。 /// /// 想试「用市民档让客户端自己的 AI 去游荡」就填 40213001(普通男性市民战斗AI)或 /// 40213002(女性)—— 它们的 Behavior_Wander = Behavior_Common_Wander、 /// Behavior_StandBy = Behavior_Common_Patrol。 /// ⚠ 副作用未知:市民档带 Alert/Detect/Search/Goback(会对玩家起反应、可能「回家」), /// 所以**默认不开**;只作为 两条路都失败时的第三手。 /// public uint PartyNpcAISettingOverride { get; init; } = 0; /// /// ★ 覆盖下发给客户端的 AgentSyncClientInfo.treeName。默认 "" = 不覆盖 /// (沿用 "PedBase",donor 逐字移植的已验证值)。 /// 仅当 也填了、且怀疑 treeName 必须与行为集的 /// BehaviorTree 名对齐时才需要(市民档对应 "Main_Common_NPC")。 /// public string PartyNpcTreeNameOverride { get; init; } = ""; public TalentTreeSettings Talents { get; init; } = new(); public HackNpcSettings HackNpc { get; init; } = new(); public TaskPanelSettings TaskPanel { get; init; } = new(); /// /// 地图入口(大地图上的传送点 / 地铁站图标)—— 2026-09-27 从 kpect 捐赠端移植的 /// 「入口四连发 + 点击真传送」,数据用我们自己的 MapentranceConfig(93 行)。 /// 见 GameRouter.MapEntrance.cs。 /// public MapEntranceSettings MapEntrance { get; init; } = new(); /// /// 车载电台的服务端那一半:歌曲解锁在登录帧里已经做完了 /// (RuntimePayloadFactory 把 59 首全部塞进 PlayerRadioSongsData.SongInfoDict), /// 这里只管 AskGetVehicleRadioContent 返回的「串词内容」(DJ 口播 / 广告 / 新闻)。 /// 见 GameRouter.VehicleRadio.cs。 /// public VehicleRadioSettings VehicleRadio { get; init; } = new(); /// /// ★ 房产「自带家具」—— gameplay.houseFurniture(2026-09-30 新增)。 /// /// 症状:手机 app 买房成功、所有权也落地了,但走进房子是毛坯房, /// 一件家具都没有(用户实测:旧物仓公寓 / 素光小筑)。 /// /// 根因:客户端进房子后会发 AskLoadHouseData(63109556) 问 /// 「这栋房子长什么样」,我们一直回的是权威兜底表那份 57 字节全空的 /// HouseBuildingClientData(FloorBuildInfoDict 是空表)⇒ 客户端认为 /// 这房子没摆任何家具。日志铁证:logs/packets-20260930-010341.log 里 /// AskLoadHouseData 4 次(houseId = 11200003 / 11200005 / 11200004 / 11200006), /// 每次都是 body=4b → RETURN error=0 body=57b。 /// /// 打开后 handler 会按 /// houseId → HouseConfig.BuildId → HouseBuildConfig.FurnitureTemplateIds → /// HouseFurnitureTemplateConfig 把房子自带的那套家具生成进 /// FloorBuildInfoDict。回滚 = false(回到 57 字节空房)。 /// public HouseFurnitureSettings HouseFurniture { get; init; } = new(); /// /// ★ 车库 / 停车 —— gameplay.houseGarage(2026-09-30 新增)。 /// /// 症状:走到自家车位按 F「调取载具」能开选车面板(纯客户端 UI), /// 选完车后按 F「驶离车库」毫无反应;车也不出现在车位;车库大门不开。 /// /// 根因:车库这一族 9 条 RPC 在服务端一直是空 body 兜底,且两个关键 S2C /// SyncHouseParking(64277819) / SyncHouseParkingEntityBinding(64060781) /// 服务端从来不发。客户端 GarageManager.LeaveGarage 第一步就读 /// currentParkingInfo.ParkingVehicleUid(只能由 SyncHouseParkingEntityBinding 填) /// ⇒ 为空 ⇒ FatalLog + return ⇒ 连 AskTeleportFromHouseGarage 都发不出去。 /// /// 打开后:AskHouseParking / AskHouseMoveParkingSpace 会记账并回推 /// SyncHouseParking(客户端据此播进库演出 + 自己发 AskCreateHouseParkingVehicles); /// 后者让服务端 SpawnDirectAsync 出一辆车并回推 /// SyncHouseParkingEntityBinding(填 ParkingVehicleUid); /// AskTeleportFromHouseGarage 开门 + 结束出库流程。回滚 = false /// (回到 9 条空 body 兜底,与改动前逐字节一致)。 /// public HouseGarageSettings HouseGarage { get; init; } = new(); } /// /// 一个「实机实测的电梯落点」覆盖项(gameplay.mapEntrance.jiguanLandingOverrides[])。 /// ///

为什么需要它

/// JiguanElevatorCatalog4229938 推导出来的落点是门自己的坐标 —— 那是「门标记」的位置, /// 不等于「人站上去不会掉下去」的那一格。2026-09-30 实机:337 上高楼层时人出现在 /// (2277.54,160.60,1412.50) 的半空中、然后往下掉;玩家实测正确落点是 /// (2277.67,160.73,1411.35)(Z 差 1.15m,Y 高 0.13m —— 门标记在电梯井里侧,人在外侧)。 /// /// 所以:几何推导给「哪一层 / 哪扇门」,这张表给「那一格精确坐标」。 /// 配置是热重载的,量出来一条就能立刻验证,不用重编译、不用重启。 ///
public sealed class JiguanLandingOverride { /// 落点门 id(MapentranceConfig.Id,也就是 SyncTeleport.MapEntranceId 填的那个)。 public uint DestEntranceId { get; init; } /// 落点 X / Y / Z(世界坐标,米)。 public float X { get; init; } public float Y { get; init; } public float Z { get; init; } /// 朝向(度)。不填 / null ⇒ 沿用落点门自己的 EntranceFacing。 public float? Facing { get; init; } } /// /// 车库 / 停车(gameplay.houseGarage)。见 /// 的说明。 /// public sealed class HouseGarageSettings { /// 总开关。false = 全部回落到空 body 兜底(逐字节等于本次改动前)。 public bool Enabled { get; init; } = true; /// /// 车辆 spawn 相对玩家的前向偏移(米)。默认 0 = 就落在玩家脚下 /// (此时玩家正站在车位上 —— 进库演出刚把玩家放到 ParkingSpaceWaypoints[i])。 /// 若车和玩家/墙重叠,可调大让它往前挪一点。配置热重载,改完不用重启。 /// public float SpawnForwardOffset { get; init; } /// 出库时把车库大门推开(复用 PushDoorOpen + 缓存的门 gadgetId)。 public bool OpenDoorOnExit { get; init; } = true; /// /// 出库落点覆盖表(热重载)。默认空 = 用 HouseConfig.VisitorExit 的绝对坐标。 /// /// 为什么需要:客户端出库事务(AskTeleport config=7 beforeResName=loading_garage_out*) /// 自己算的落点是 HouseConfig.GarageOutWaypoint(Spoon 航点 id,服务端没有 /// Spoon 位置表);而且 ExitTimeline 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查 /// 都不做,afterPosition 直接留全零 ⇒ 过场结束会被甩到世界原点。 /// 服务端唯一现成的绝对坐标落点就是 VisitorExit,实测同层且就在房门外。 /// 如果实测落点不合适,在这里按 houseId 覆盖,改完即时生效。 /// /// /// "houseGarage": { /// "exitLandingOverrides": [ /// { "houseId": 11200006, "x": 1104.8, "y": 8.53, "z": 2391.8, "facing": 0 } /// ] /// } /// /// public List ExitLandingOverrides { get; init; } = []; /// 打印逐条车库日志。 public bool VerboseLog { get; init; } = true; } /// 出库落点覆盖的一条(见 )。 public sealed class HouseExitLandingSettings { /// HouseConfig.Id。 public uint HouseId { get; init; } public float X { get; init; } public float Y { get; init; } public float Z { get; init; } public float Facing { get; init; } } /// /// 房产自带家具(gameplay.houseFurniture)。 /// /// 数据源两张官方表(ClientData/4229938/Configs/,2026-09-30 从 /// E:\ConfigDump\ConfigDump_v3\ 原样拷入): /// /// HouseBuildConfig.json 4 行 buildId → IndoorId / Floors / FurnitureTemplateIds /// HouseFurnitureTemplateConfig.json 322 行 家具模板树(叶子带绝对世界坐标) /// /// 4 套房各自的模板:绿屿公馆 14001(147 件)/ 旧物仓公寓 10013(36 件)/ /// 观澜北岸 14000(86 件)/ 素光小筑 13000(44 件)。 /// public sealed class HouseFurnitureSettings { /// 总开关。false = 回到 57 字节空房(逐字节等于本次改动前)。 public bool Enabled { get; init; } = true; /// /// 家具机关(可互动家具)开关 —— 2026-10-01 新增,默认开。 /// /// false = AskCreateHouseFurnitureGadgets(63581446) 回到恒空映射 /// ([0xFF,0x01],逐字节等于本次改动前)⇒ 椅子坐不了、衣柜开不了、电视点不亮。 /// /// 背景:家具模型由 FloorBuildInfoDict 下发(已上线),但客户端还会再问一次 /// 服务端要「机关实例 id」(HouseGadgetManager.CreateGadgetsForFurnitures)。 /// 判据:请求的 id 个数逐套精确等于该房「HouseFurnitureConfig.PathId != 0」的家具数 /// (绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1)。详见 /// GameRouter.HouseFurnitureGadget.cs。 /// public bool Gadgets { get; init; } = true; /// /// 要不要真的用 SyncGadgetAdd(68843189) 把机关实体摆进世界 —— 默认开。 /// /// 这是实测分流开关,不是功能开关。客户端行为有两种可能: /// /// true(默认,H1):机关实体得服务端造,客户端 /// OnGadgetSlotCompLoaded 的 gGadgetManager:GetEntitySearchByInstanceId(gadgetId) /// 才找得到它。 /// false(H2):机关 GameObject 本来就在家具 prefab 里 /// (RegisterFurnitureLuaSlotReplace 用的就是 houseFurniture.gadgetGameObject), /// 服务端只需发一个唯一非 0 id,客户端 TrySetGadgetInstanceId 自己点亮。 /// /// /// 判据:H1 若把椅子 / 灯渲染成两遍(模型重了一层)就是猜错了 ⇒ 改成 false。 /// 机关 prefab 取自 HouseFurnitureConfig.PathId,位置取模板节点的世界坐标 + /// GadgetOffset,uniqueId = placedId(与返回的 gadgetInstanceId 同源)。 /// /// ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。 /// public bool SpawnGadgetEntities { get; init; } = true; /// /// 摆机关实体走哪条推送通道 —— 默认 add。 /// /// 为什么会有这个开关:2026-10-01 上机实测,add(SyncGadgetAdd) /// 在服务端日志上一切正常(44 条 [GADGET] add,id/pathId/坐标全对), /// 但客户端 Player-prev1.log 里 GadgetInstanceRoot 下只有 blob 自带的 /// NewLiftTest_…,没有任何家具机关实体 ⇒ 家具模型不出现(非编辑态客户端会 /// 隐藏自己那份 luaSlotReplaceGo,专等服务端实体补位)。 /// /// 这正是 GameRouter.Gadget.cs 里记过的那个场景:「Add builds the entity /// (name, position, interact point all correct) yet the prefab never appears」—— /// 两个客户端 handler 是不同方法(0x12787ED0 vs 0x1276CF20), /// 可能不共用资源路径。addList 就是外部工具 AnantaGymLoader.exe 走的那条。 /// /// 取值: /// /// add(默认)—— 逐件 SyncGadgetAdd(68843189)。 /// addList —— 一次 SyncGadgetAddList(68925813),N 件一个包。 /// both —— 两条都发(诊断用)。判据:若椅子/灯渲染成两遍, /// 说明两条通道都生效、真正的问题是别的;若只出现一遍,那出现的那条就是唯一可用通道。 /// /// /// ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。 /// public string Channel { get; init; } = "add"; /// /// ★★ 2026-10-01:每次网格 AOI 推送之后把家具机关实体补发一遍 —— 默认开。 /// /// 病因:SyncGadgetGridAOIDecrease 的 addGridInfo 在客户端是 /// 「当前 AOI 快照」语义(GameRouter.GadgetGrid.cs 的注释早有记录)。世界摆件撤掉后 /// 客户端能从自己的 blob 再铺出来;家具机关是纯服务端造的、blob 里没有 ⇒ 一撤就永久消失。 /// /// 实测(Player.log 2026-10-01 10:23–10:24): /// 10:23:26 SyncGadgetAddList → 10:23:34 entityHits=33/33 → /// 10:23:37 SyncGadgetGridAOIDecrease(之后每 5 秒一次)→ /// 10:23:56 entityHits=0/33。该窗口内唯一的机关类 S2C 就是它。 /// ⇒ 症状就是用户说的「沙发椅子已经生成了的没法交互」。 /// /// false = 回到「建出来 20 秒后被网格快照撤掉」的行为。 /// ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译。 /// public bool RespawnAfterGridPush { get; init; } = true; /// /// FloorBuildInfoDict 的键取什么。 /// /// 已定案(2026-09-30):键 = 楼层号,1 基,地面层 = 1,取值 1..Floors。 /// 默认 floor 就是正解,正常情况下不用动。 /// /// 证据(全部来自客户端自己的文件): /// /// HouseBuildConfig.FloorsName = [{floor:1,__RAW__name:"1F"}]; /// 素光小筑 [{1,"1F"},{2,"2F"}],长度恰等于 Floors。 /// BuildBound / CameraBound / CameraTopViewPoint 都用 floor: 1,2。 /// HomeBuildPanelStore_Floor.lua:SelectOption(initFloor - 1)、 /// houseCamera.floor = index + 1 ⇒ 游戏侧 1 基,只有 UI 选项 0 基; /// GetFloorByPlayerPosition 兜底 return 1。 /// HouseUtils.lua 的 floor = floor or 0 只是参数缺失兜底,0 是非法值 /// (会走 "无法找到楼层[0]的家具信息")。 /// ⚠️ LuaGmUtils.lua:166 的 /// for floorKey, indoorBuildInfo in pairs(buildData.FloorBuildInfoDict) /// 会对每个键都加载一遍家具(去重表建在键循环体内)⇒ 多写键 = 家具被创建多遍。 /// 所以不能「把所有候选键都写上碰运气」,默认必须是单一正确键。 /// /// /// 取值: /// /// floor(默认,正解)—— 挂 1..Floors。 /// indoor —— 只挂 IndoorId。诊断用:GmSyncHouseIndoorBuildInfo /// 那条 GM 链路确实按 indoorId 组织,若登录链路被证伪可切过去试。 /// all —— 挂 {1..Floors} ∪ {IndoorId}。诊断用,一定会重复创建家具, /// 只用于确认「客户端到底取哪个键」,别长期开。 /// /// /// ⛑ 这个值每次请求现读 ⇒ 改 config/private-server.json 后 /// 不用重启,出房子再进去一次即可换一种试。 /// public string KeyMode { get; init; } = "floor"; /// /// 打印每个家具节点的逐条日志(147 件 × 每件一行)。默认关 —— 只在排查线格式时才开。 /// public bool VerboseLog { get; init; } } /// /// 地图入口(gameplay.mapEntrance)。 /// /// 客户端的三步走:SyncMapEntrance(64067363) 全量清单 → /// UpdateMapEntrance(64239821) 逐点开关 → SyncPrepareMapEntrance(64610549) 逐点准备; /// 再加一条 SyncSubwayFare(64660858) 给地铁支付环节一个金额。 /// 四帧在此之前一条都没发过(全服 grep 零命中),所以大地图上的传送点/地铁图标 /// 根本没有可见性数据。逐项可关,回滚不用重编译。 /// public sealed class MapEntranceSettings { /// 总开关:false = 四帧都不发、点入口也不传送,逐字节回到移植前。 public bool Enabled { get; init; } = true; /// 发 SyncMapEntrance 全量清单(openEntrance / displayableEntrances 两个列表)。 public bool PushSync { get; init; } = true; /// 发 UpdateMapEntrance 逐点开关(isOpen/isShow 都设 true)。 public bool PushUpdate { get; init; } = true; /// /// 发 SyncPrepareMapEntrance 逐点准备。 /// 客户端 lua 里没有这一帧的消费点(只在 RPCMethodIdToName 出现),但 kpect 的判据是 /// 「少了这步,交通层(地铁 / 机场,ShowType=2)的入口不会真正注册进地图」。留开关做 A/B。 /// public bool PushPrepare { get; init; } = true; /// 发 SyncSubwayFare(地铁票价,单位见 )。 public bool PushSubwayFare { get; init; } = true; /// SyncSubwayFare 的金额(u32)。0 = 免费。 public uint SubwayFare { get; init; } = 200; /// /// AskTouchMapEntrance(63284182):点入口是否真的传送。 /// 客户端是 void notify(不等回包),最坏情况就是「和以前一样没反应」。 /// public bool TouchTeleport { get; init; } = true; /// /// 允许跨 raid 传送(目标入口的 RaidId ≠ 当前 ActiveRaidId 时走 SwitchSceneAsync)。 /// 关掉的话跨城点击只记日志不动作,同图传送仍然生效。 /// public bool TouchCrossRaid { get; init; } = true; /// /// 回显带 timeline 的 AskTeleport 事务(SyncPreTeleportOption)。 /// /// ⛑⛑ 2026-09-29 实测根因:长按 F / 点「交通直达」时客户端**确实**发了 /// AskTeleport config=5 after=(1878.8,-1.1,2250.5 res=loading_metro_end_1) /// —— 就是地铁乘车动画事务。但服务端只 ack 不回显 ⇒ 客户端的 loading 流程停在第 2 步 /// (AskTeleport → SyncPreTeleportOption → ReportPreTeleportFinish → domain RPC), /// 回调不执行 ⇒ domain RPC 一个都不发(日志里 AskPlayerChangePositionByScenePortal / /// AskPublicSwitchToPublicScene 全是 0 次)⇒ 表现为「长按 F 没反应」。 /// /// 判据(见 GameRouter.Teleport.cs):beforeResName / loadingResName / afterResName /// 任一非空,即「这个事务要播 timeline」,原样回显客户端自己发来的 option。 /// 改装店(config=8)走它自己那条 PrepareCarShopOption 分支,优先级更高。 /// 回滚 = false(只 ack 不回显,逐字节回到本次改动前)。 /// public bool EchoPreTeleport { get; init; } = true; /// /// 景点 / 电梯传送门(MapentranceConfig.Type == 1)—— 2026-09-30 新增。 /// /// ⛑⛑ 根因(2026-09-30 01:07:55 实测):客户端 RpcUtils.AskEnterRaidByMapEntrance 走 /// AskTeleport(config=2) → SyncPreTeleportOption → ReportPreTeleportFinish → /// AskEnterRaidByMapEntrance(337),其中 afterPosition 是全零、customAfterTrans=false /// ⇒ 客户端不知道目的地,等的是服务端那一帧 SyncTeleport。 /// 而原来那个 handler 只 ReturnEmptyOkAsync ⇒ 客户端永远停在 loading /// (黑屏、ESC 返回不了),86 秒后 ReportPostTeleportFinish result=2。 /// /// 打开后 handler 会推 SyncTeleport(同 raid)或走 SwitchSceneAsync(跨 raid)。 /// 目的地推导见 JiguanElevatorCatalog4229938(不需要 Spoon 数据)。 /// 回滚 = false(只 ack,逐字节回到本次改动前)。 /// public bool JiguanElevator { get; init; } = true; /// /// 电梯分组半径(米):两条景点门三维距离小于它就当成同一台电梯。 /// /// 为什么需要它:绿屿公馆的竖井跨「大厅 Y=0 ↔ 高楼层 Y=160.6」共 168m, /// 光靠距离连不起来,靠的是「同目标 id」和「同坐标」两条规则;而 317/318 这种 /// 只有两个门、跨度 38.7m 的短竖井,只能靠半径连。 /// 默认 60 已经能覆盖全表且不会误并相邻建筑(最近的跨建筑对是 318↔329 = 126m)。 /// public float JiguanGroupRadius { get; init; } = 60f; /// /// SyncTeleport.MapEntranceId 填哪个 id(2026-09-30 二修)。 /// /// 为什么这是个开关而不是写死:一修把「黑屏」修好了(ReportPostTeleportFinish /// result=0),但玩家落地还在原地。对照同一会话里成功的大地图传送那一帧, /// 唯一的差别是 MapEntranceId 填的是目的地入口,而景点链路填的是玩家按下的 /// 那扇起点门。客户端 CommonFuncTeleportFlowInfo 有 /// AfterTargetMapEntranceId 与不吃参数的 ReParse_AfterData()(只能读服务端推下来的 /// teleportOption),而本链路客户端自己的 afterPosition 是全零 ⇒ 落点只能靠 /// MapEntranceId 反查 ⇒ 填起点 = 原地。 /// /// /// "dest"(默认)= 落点那一层的门 id。 /// "source" = 起点门 id。留作 A/B / 回滚,已知症状是「原地不动」。 /// "none" = 填 0。2026-09-30 四修加的 A/B: /// 客户端 CommonFuncTeleportFlowInfo.OnSyncTeleport() → ReParse_AfterData() 在 /// MapEntranceId != 0 时会去 MapentranceConfig / Spoon 航点反查落点, /// 而本地没有 Spoon 图(见 JiguanElevatorCatalog4229938 的说明)⇒ 反查可能落空。 /// 填 0 就是逼客户端只用 Position。改配置即时生效,用来判断「反查落空」这个猜想。 /// /// public string JiguanEntranceIdMode { get; init; } = "dest"; /// /// 电梯落点兜底(2026-09-30 四修)。 /// ///

为什么还需要它

/// 前三修已经把服务端这一侧做到了「逐字节正确」:14:19:11 的实测报文里 /// SyncPreTeleportOption 的 afterPosition 已改写成 /// (2277.536,160.595,1412.501)、customAfterTrans=true; /// 14:19:16 的 SyncTeleport 也带着同一坐标 + MapEntranceId=343。 /// 但客户端 [INDOOR] 在 14:19:12 / :17 / :22 三次都报 indoorId=332(绿屿大厅) /// —— 它一帧都没采纳,玩家原地不动。 /// /// 所以这一层不再猜客户端的 loading 流程,直接用已被实测证明有效的硬设位置通道: /// 同一会话 14:16:48 后台「瞬移」把玩家放到 (2627.48,188.74,2626.95), /// 14:17:15 客户端立刻报 [INDOOR] 进入 indoorId=298(观澜公馆主体)—— /// 高楼层是可达的,通道是通的。 /// /// true(默认)= ReportPostTeleportFinish 时消费 /// AskEnterRaidByMapEntrance 记下的落点,用 SyncUnitPositionAndFacing(68030312) /// 把人放到目的地(立刻一帧 + 500ms 后再补一帧,防客户端还在收尾时吞掉第一帧)。 /// 代价:过场动画照播,但落地是「瞬移」而不是过场自己走完 —— 观感上可以接受。 /// /// false = 只推 SyncTeleport(前三修的行为),用于 A/B 与回滚。 ///
public bool JiguanHardSet { get; init; } = true; /// /// 实机实测的落点覆盖表(热重载)。按落点门 id 匹配,优先于几何推导 /// 与 JiguanElevatorCatalog4229938.MeasuredLandings。 /// /// 例(把绿屿高楼层的落点从「门标记」挪到玩家实测能站稳的那一格): /// /// "mapEntrance": { /// "jiguanLandingOverrides": [ /// { "destEntranceId": 343, "x": 2277.67, "y": 160.73, "z": 1411.35 } /// ] /// } /// /// 只改 X/Y/Z,不填 facing 就沿用门自己的朝向。改完即时生效。 /// public List JiguanLandingOverrides { get; init; } = new(); } /// /// 车载电台(gameplay.vehicleRadio)。 /// /// 服务端这一半只有一件事:AskGetVehicleRadioContent(67725622) 是 INVOKE, /// 返回值是 UX.Game.RadioRandomContent(ContentType u8 / ContentId u32 / /// StartContentId u32 / EndContentId u32 / MarkType u8),客户端拿它去 /// RadioPlayerManager.GetVoicesByContent 查 RadioContentDJs / Ads / News / SpecialNews /// 四条表,播「歌与歌之间的 DJ 串词」。目前回的是全零 typed-default ⇒ 四条表全查不到 /// (客户端日志会是四行「配表找不到」)⇒ 只有歌、没有串词。 /// public sealed class VehicleRadioSettings { /// 总开关:false = 继续回全零 typed-default(移植前的行为)。 public bool Enabled { get; init; } = true; /// /// 串词播在歌的哪个 cue 上(UX.Game.RadioMarkType:1 = Middle,2 = End)。 /// 客户端 OnRadioMusicMark 的判断是反编译出来的(~= 看着像被翻转过的 ==), /// 两个值都留着实机试,改配置不重编译。 /// public uint MarkType { get; init; } = 1; /// /// 同一类串词的最小间隔秒数。官方静态表 RadioContentConfig 是 600 秒 /// (DJChatCd / DJCommentCd / NewsCd / AdCd 全都是 600);设 0 表示不限制。 /// public uint ContentCooldownSeconds { get; init; } = 0; /// /// 允许抽到的串词类型(UX.Game.RadioContentType:1=Ad 2=News 3=Chat(DJ 口播) /// 4=Comment(DJ 口播) 5=SpecialNews)。留空 = 按权重自动抽全部。 /// public uint[] ContentTypes { get; init; } = []; } /// /// Task / Mission panel publication. /// /// The panel list is fed by eventPanelInfo.EventsInfo inside SyncPlayerAllTask(64260853) — /// it renders TaskEventConfig rows (events), NOT TaskConfig rows. Every option here can be turned /// off from config, no rebuild needed, because the whole feature was parked twice already: pushing /// rows without events shows nothing, and pushing them as Accepted once made ESC offer "settle the /// current challenge" instead of opening the phone. /// public sealed class TaskPanelSettings { /// /// Master switch. False (default) ships nothing and leaves the panel to the client — the state /// this feature was parked in. /// public bool Enabled { get; init; } /// /// Which TaskConfig rows go into taskInfos: "catalog" keeps the historical full dump /// (all 4342 rows), "configured" publishes only (how far smaller an /// authored task list the reference branch uses — one task plus its event). /// public string TaskIdsSource { get; init; } = "catalog"; /// Explicit TaskConfig ids used when is "configured". public uint[] TaskIds { get; init; } = []; /// TaskState stamped on every published row. Accepted is what an in-progress challenge /// looks like to the client, Submited is completed history — Submited is the ESC-safe one. public string TaskState { get; init; } = "Submited"; public bool PublishRaidRows { get; init; } = true; public bool PublishTitleUnlocks { get; init; } = true; /// Also push the CSharpOnly twin SyncPlayerAllTaskDebug(64423039), the only packet that /// reaches the client's native task manager (64260853 has a Lua impl that drops taskInfos). public bool PublishDebugRows { get; init; } = true; /// /// R238(2026-10-03 随赛车移植)—— 进世界时**只**发布赛车挑战那几条任务行, /// 让客户端的 task manager 在玩家点「开始挑战」之前就拥有 60000147 的运行时节点。 /// /// 因果链(捐赠分支实测):没有节点 ⇒ `gTaskManager:SetCurrentTask` 建不出运行时节点 /// ⇒ 任务状态机不产出 TASK_STATE_CHANGED ⇒ `ChallengeManager.StartChallenge` 不被调用 /// ⇒ `currentChallengeId` 恒为 -1 ⇒ `OnSyncRacingResultData` 里 /// `ChallengeConfig.GetConfig(-1)` = nil ⇒ **不弹结算面板**。 /// /// ⛑ 与 **无关**:赛车预热必须无条件先跑。 /// 回滚 = 置 false(回到「点开始挑战没反应」)。 /// public bool PrimeRacingChallengeTasks { get; init; } = true; /// /// Fill eventPanelInfo.EventsInfo. THIS is what the list renders. Off = the historical /// empty panel. When true every event below becomes one list row named after /// TaskEventConfig.EventNameCN / EventName. /// public bool PublishEvents { get; init; } = true; /// Always-published event ids, regardless of which tasks are in the container. public uint[] EventIds { get; init; } = []; /// Derive events from the published task rows (every task maps back to the events whose /// chain contains it). With the full catalog this yields all 1688 authored events. public bool AutoDeriveEvents { get; init; } = true; /// /// TaskEventStatusData flags: Visible=1 / Acceptable=2 / HasAccepted=4 / RedPoint=8 / /// IsUnderway=16 / IsRiskControl=32 / Conflict=64 / IsRepeat=128. Without Visible(1) the client /// renders nothing. Default 21 = Visible|HasAccepted|IsUnderway — the combination the reference /// branch measured as rendering rows. /// public byte EventStatusFlags { get; init; } = 21; /// /// true = EventInfo.TaskId is the newest step of that event's chain that is actually in the /// container (matches what the player is on); false = always StartTask. Taking the oldest step /// instead is a known bug — the list then disagrees with the task details. /// public bool EventTaskIdFollowsContainer { get; init; } = true; /// Event ids pushed in SubmitEventList (already-turned-in events). public uint[] SubmitEventIds { get; init; } = []; /// Safety cap on how many events go out; 0 = no cap. public int MaxEvents { get; init; } } /// /// Picture storage for the 趴卜 / Bubble wall, pushed to the client as /// SyncOssBucketInfo(64833344). /// /// The posts only ever carry a bare object key — SocialMediaConfig.ImageOSS is /// "Bubble/xxx.png" and nothing else — and turning that into a URL is entirely the client's /// AliOssManager, seeded by this packet. Nothing in GameAssembly.dll holds an OSS domain, so /// without it every picture request has nowhere to go: text renders, images silently never arrive. /// That is why the app looked "text only" even though the posts themselves were correct. /// /// The real endpoint is still unknown. What proves itself instead: point /// at any plain static HTTP host (project scripts /// _fetch_bubble_images.py downloads the artwork into tools/bubble-images/), then read /// the request path out of that host's access log — whichever shape arrives, /// {endpoint}/{key} / {endpoint}/{bucket}/{key} / {bucket}.{endpoint}/{key}, is /// the layout the client builds, and that decides how to split the two fields. /// public sealed class OssPictureSettings { /// Master switch. Off = never push SyncOssBucketInfo (pre-2026-09-24 behaviour). public bool Enabled { get; init; } /// Object-storage region string; unused by a plain HTTP host. public string Region { get; init; } = string.Empty; /// Base URL serving the object keys, e.g. http://127.0.0.1:8099. public string Endpoint { get; init; } = string.Empty; /// Bucket name. Filled in once the access log reveals the URL layout. public string Bucket { get; init; } = string.Empty; } /// /// Gameplay talent trees (天赋, T1-6). The panel itself is client-driven — it reads the tree layout /// from its own config tables and only asks the server to activate a node /// (AskActiveGameplayTalentLayer(gameplayId, talentId, addLayer)). What the server owns is the /// point balance and the set of activated nodes, both published through /// PlayerClientInfo.GameplayTalentInfos at login. /// public sealed class TalentTreeSettings { /// Master switch. When false the login payload carries no talent block and every talent /// request is acked without touching state, which is the pre-T1-6 behaviour. public bool Enabled { get; init; } = true; /// /// Points granted per gameplay tree when a tree is seen for the first time. Persisted afterwards, /// so raising this later only tops up trees the player never touched (see RenewSeedOnLogin). /// public uint PointSeed { get; init; } = 50; /// /// Re-grants on every login, i.e. an infinite sandbox point pool. Off by /// default so spending a tree dry is possible and the balance survives a restart. /// public bool RenewSeedOnLogin { get; init; } /// 0 = charge the node's TalentTreeTalentConfig.CostPoint; anything else overrides it. public uint FlatCost { get; init; } /// Accept an activation even when the tree is out of points (sandbox: nothing is ever blocked). public bool IgnoreCost { get; init; } /// Accept an activation for a talent id the imported table does not know. Without the row /// the cost and the layer cap are unknown, so the default is to reject rather than to guess. public bool AllowUnknownTalents { get; init; } /// /// Push a fresh SyncPlayerInfo after every accepted activation. This is how the client learns the /// new point balance and the new node layer without a re-login; it reuses the exact login frame, /// so it is the only talent S2C with a proven wire. Set false if the extra full payload ever /// becomes a problem. /// public bool PushPlayerInfoOnChange { get; init; } = true; /// /// Master switch for the career talent seeding (职业天赋, 2026-09-24). Off = the pre-seeding /// behaviour, i.e. every SpiritInfo.TalentInfo / SpiritJob.TalentInfo ships empty. /// /// Why it exists: scene hack targets (vending machines VendingMachineNew*, claw machines /// ClawMachineJiGuan_game*) register themselves with talentId 99906204, and /// GadgetManager.lua then asks gTalentTreeMgr:CheckTalentIsLocked(talentId, 11300004) /// (11300004 = the Hacker job class). A locked talent makes CheckHackCondition fail before it /// ever reaches SetHackBtnState, which is exactly the observed symptom: the hack prompt shows up /// greyed out everywhere, while a server-spawned BlackoutController — which has no talentId — /// lights up immediately. 99906204 lives in TalentTreeTalentConfig tree 99900010 together with /// 16 siblings. /// ⛑️ Rollback: set false. The 27 grey prompts come back, nothing else changes. /// public bool SeedCareerTalents { get; init; } = true; /// Talent tree whose nodes are published as activated by . public uint CareerTalentTreeId { get; init; } = 99_900_010; /// Job class the tree belongs to. Only used for logging and for the per-job records. public uint CareerTalentJobClassId { get; init; } = 11_300_004; /// Talent points published alongside the seeded nodes, i.e. the panel balance. public uint CareerTalentPoint { get; init; } = 50; /// Layers published per node for the lock test. 0 = derive from each node's /// TalentTreeTalentConfig.LayerNum +1; any positive value is used verbatim. /// ⛑️ The client requires a strict excess: TalentTreeMgr.lua:1031 returns /// talentInfo.Layer <= talentCfg.LayerNum as "locked", so seeding exactly /// LayerNum keeps the node grey (99906204 has LayerNum 1 ⇒ needs Layer 2). /// An explicit value here must itself exceed the node's LayerNum. public uint CareerTalentLayers { get; init; } /// Restrict the seeding to these node ids instead of the whole tree. Empty = whole tree. /// Use this if the panel ever needs some nodes to stay buyable. public uint[] CareerTalentIds { get; init; } = []; /// Who receives the seeded nodes, kept as two independent switches so a first test can be /// bisected if either wire misbehaves: /// Publish them in every character's SpiritInfo.TalentInfo (the login frame). /// Off leaves that struct empty — the pre-seeding baseline. /// ⛑️ Default false since 2026-09-24: TalentTreeMgr.lua RebuildCache maps /// jobClassId → AvailableJobs[jobId].TalentInfo and only jobClassId == 0 falls back /// to SpiritInfo.TalentInfo. The hack prompt asks with job class 11300004 (Hacker), so /// this character-wide copy never answers that query — keeping it off shrinks the login frame /// and removes an unverified write. Turn it on if something ever queries with job class 0. public bool SeedCareerTalentsOnSpirit { get; init; } /// Additionally publish them per urban job through SpiritJob.TalentInfo /// (SyncSpiritJobInfo). This is the copy that answers /// CheckTalentIsLocked(talentId, jobClassId) for a real job class, so it is the one the /// hack prompts depend on. ⛑️ It only reaches the query if that job class actually has an /// AvailableJob — the client loop skips classes without one. public bool SeedCareerTalentsOnEveryJob { get; init; } = true; /// /// Publish every talent tree instead of the single , each /// into the container the client actually reads it from (TalentTreeMgr.OnStartCurrent): /// /// TalentTreeConfig.GameplayId != 0 → GameplayTalentInfos[gameplayId] /// JobClassId != 0 → that career's SpiritJob.TalentInfo (only visible to /// characters owning the career, because the dictionary is built per AvailableJobs row) /// both 0 → the character's SpiritInfo.TalentInfo, narrowed by SpiritId /// (Kingpin lists the two protagonists; a few rows pin one spirit each) /// /// The three expressions below are the per-container switches — hard rule 105: any login-frame /// struct it starts filling must be independently switchable, without a rebuild. /// false restores the 2026-09-24 single-tree behaviour exactly. /// public bool SeedAllTrees { get; init; } = true; /// Publish the whole-tree set into GameplayTalentInfos when . public bool SeedTreesOnGameplay { get; init; } = true; /// Publish the trees that are keyed by SpiritId at all (Kingpin for the two /// protagonists, plus the six single-spirit rows 99900021..26, whose dump name is the /// placeholder ";"). Off keeps only the un-keyed rows, which then apply to every character. public bool SeedSpiritIdTrees { get; init; } = true; } /// /// T2-32 路人骇入(黑客骇路人手机看信息)。 /// /// 客户端整条链路都是现成的,服务端唯一缺的就是 SyncUnitHackableState(68941159): /// 不发它,C# 的 HackManager.hackUnitTarget 恒为 nil,路人身上不会有骇入按钮, /// 三个 C2S(67070057 / 67747341 / 67093669)一个都不会发。 /// ⇒ 这里只保留一个开关,关掉即回到接入前的状态。 /// public sealed class HackNpcSettings { /// /// NPC 出场时(SendCivilianAsync / SendAmbientCrowdPedAsync 尾部) /// 推一次 SyncUnitHackableState(unitId, true)。 /// /// 与载具那套不同,这里不做距离维护:C# 的 HackTargetSelector 自己按距离挑目标, /// 服务端把全城 NPC 标成可骇也不会多出按钮;而发 false 去取消标记反而要维护一份 /// "已标记"账本(NPC 被回收时容易漏)⇒ 一律只标 true、不取消。 /// public bool MarkSpawnedNpcs { get; init; } = true; /// /// 只给"服务端自己生成"的 NPC 打标记时用的上限(0 = 不限)。 /// 留作保险:万一某个场景一次性刷出上千个 NPC,逐包推送会占住连接 /// (参照 hard rule 38 的教训 —— 登录路径禁止大批量推送)。 /// public int MaxMarkedNpcs { get; init; } // ---------------------------------------------------------------- 手机(按钮的来源) /// /// ⭐ 只推 SyncUnitHackableState 是不够的 —— 面板下边那排按钮另有来源: /// HackerInfoPanelStore.lua 对路人走 HackManager:GetUnitHackBtns(unit)(C#), /// 而六个 NpcHackSkill 里"看信息/听通话"这类的 CanUseInternal 判的是 /// 该单位当前 BelongingUsage 的 HackType(BelongingBelongingUsageConfig): /// 只有 usage 1(右手正常打电话,HackType=2 窃听)/2(耳机打电话)/ 6(右手玩手机, /// HackType=1 偷看短信)这三条会给出按钮,其余 122 条都是 HackType=0。 /// 我们下发的 NPC 要么根本没有 belongings(crowd 行人),要么用的是 POI 的 usage /// (18 拍照 / 86 / 93 / 237 / 238 滑动 / 241 打电话阐释…… 全是 HackType=0) /// ⇒ 面板只剩名字,正对应用户看到的"下面没有选项"。 /// /// 因此这里在打标记的同时把**手机 + 一个可骇 usage** 一并推下去。 /// 关掉即回到只有名字的状态。 /// public bool GivePhoneToNpcs { get; init; } = true; /// 手机的 sceneitem id。BelongingBelongingUsageConfig 三条可骇 usage 的 /// BelongingSceneItem 都只写 80003481(耳机那条还要再叠加 80003544,这里不用)。 public uint PhoneSceneItemId { get; init; } = 80003481; /// /// 轮换的 usage id。按 unitId 取模挑一个,城里于是既有"玩手机"也有"打电话"的人, /// 两种按钮都能见到。空数组 ⇒ 退回 usage 6(玩手机 / 偷看短信)。 /// public uint[] PhoneUsageIds { get; init; } = [6, 1]; /// crowd 通道(SendAmbientCrowdPedAsync)给不给手机。默认给 —— 行人本来什么都没有。 public bool GivePhoneToCrowdPeds { get; init; } = true; /// static / civilian 通道(SendCivilianAsync)给不给手机。 /// ⛑️ 会覆盖该 NPC 的 POI usage(例如拍照动作 18 会被换成玩手机 6), /// POI usage 本身就在可骇三条之内时会自动跳过(见调用点)。 public bool GivePhoneToStaticNpcs { get; init; } = true; /// 发手机的 NPC 上限(0 = 不限)。每条要推两个包,行数多的时候留意 /// hard rule 37/38(登录/进世界路径怕大批量推送)。 public int MaxPhonedNpcs { get; init; } } /// /// Aether city simulation (pedestrians / static NPCs / traffic). The client owns the /// simulation; the server only answers the handful of C2S calls it makes. /// public sealed class AetherSettings { /// /// Raw reply bytes (hex, no spaces) returned for AskMassHideAreas (67937263). /// Kept configurable so the list framing can be retuned without a rebuild until the /// client-side reader shape is confirmed from Player.log. /// Default "FF00000000" = list marker + Int32 count 0 (matches how the client read /// AetherAIInitData.Intersections). /// public string MassHideAreasReplyHex { get; init; } = "FF00000000"; /// /// Which wire framing(s) SyncAetherAIInitDatas (68704070) is emitted with when the client /// enters a scene. One entry per notify sent. Accepted values: /// "top-int32" 0xFF object marker + Int32 list count (recommended) /// "inline-int32" no object marker + Int32 list count (the 09-15 default) /// "top-int7" 0xFF object marker + 7-bit list count /// "boolfirst-int32" HasZoneGraph first, then RaidId, no object marker /// "top-int7-isec-derived" 同 top-int7,且 Intersections 填合成探测路口(派生字段先) /// "top-int7-isec-base" 同 top-int7,且 Intersections 填合成探测路口(基类字段先) /// "top-int7-isec-real" 同 top-int7,且 Intersections 填**真路口**(派生字段先,2026-09-28 主推) /// "top-int7-isec-real-base" 同上但基类字段先(对照组) /// 真路口开关 = (false 即等价现状,一键回滚)。 /// The 4229938 client reported RaidId=16868235 for "inline-int32", which is exactly the /// bytes of RaidId=23300888 shifted by one — proof that its reader consumes one byte /// (the object marker) before the fields. See the 09-16 memory note. /// public List InitVariants { get; init; } = ["top-int32"]; /// /// 给 top-int7-isec-* 变体用:Intersections 填多少条**探测**路口。 /// 0 = 空表(与现状完全一致)。1 = 只发一条,用来看客户端日志有没有反应。 /// /// ★ 为什么有这个键:AetherAIInitData.Intersections 是我们**唯一没填过的官方字段**, /// 客户端的路口/信号灯系统从来没有拿到过初始化数据。而 InstanceId 的真实语义未知, /// 所以先用最小样本探测,确认客户端会响应之后,再投入造 182 条真路口 /// (数据源在 DUMPS/Bin.rar 的 ZebraConnector.json)。 /// public int InitIntersectionProbeCount { get; init; } = 0; /// /// top-int7-isec-real / -real-base 变体是否用**真路口**填 Intersections。 /// false = 发空表(与 top-int7 现状完全等价)—— 这是一键回滚开关。 /// /// ★ 真路口来源(2026-09-28):补丁和工具/DUMPS/readable_world_dump_4229938_final.zip /// - InstanceId ← 04_WORLD_JSON/vfc_1286_e106_*.json 的 interInfos[].intersection /// - ZoneIndex ← 03_ZONEGRAPHS/vfc_1286_e108_*.json(车辆图)里车道所属 Intersection zone /// 落盘在 Ananta.Server/Ananta.App/data/intersections.csv(167 条),由 /// IntersectionCatalog4229938 惰性加载;文件缺失/损坏 → 自动退化为 0 条,不会崩。 /// public bool InitIntersectionsReal { get; init; } = true; /// /// 真路口最多发几条。0 = 全发(当前 167 条)。 /// 想先只发 1 条看客户端有没有反应就设 1(比全量更容易从日志里定位)。 /// public int InitRealIntersectionCount { get; init; } = 0; /// /// 街上行走的行人(AetherCity crowd)。人群是客户端自驱的,服务端只发密度包。 /// 前置:SyncAetherAIInitDatas 必须已带 HasZoneGraph=true(由 vehicles.enabled 那条路径发出, /// vehicles.enabled=false 时人群不会初始化)。 /// public bool CrowdEnabled { get; init; } = true; /// 人群密度倍率。1.0 = 官方默认。 public float CrowdDensity { get; init; } = 1.0f; /// SyncCrowdSpecifiedDensityRefresh 盒子的水平半径(米)。donor 实测 250。 public float CrowdExtendsMeters { get; init; } = 250f; /// 盒子的高度(米)。donor 实测 50。 public float CrowdExtendsHeight { get; init; } = 50f; /// 玩家离开上次中心多少米就重发一次密度盒。donor 实测 35。 public float CrowdRefreshMoveMeters { get; init; } = 35f; // ---- T0-8:让密度随地块 / 时段变 ---- // UrbanDiversityConfig 的 PopulationDensity 与 DegreeOfBusyness 在本城 681 个地块里几乎恒为 0 // (实测只有沙滩区有值),所以密度改由「LandPlotType 系数 × HourlyFlow(时段曲线)」合成。 /// 总开关。关掉就退回全局固定密度(进 S2C 前的行为),用于隔空对照。 public bool CrowdUseLandPlots { get; init; } = true; /// LandPlotType → 密度系数。未命中的类型按 1.0 处理,随时可在此增删条目。 public Dictionary CrowdTypeFactors { get; init; } = new(StringComparer.OrdinalIgnoreCase) { ["商业区"] = 1.00f, ["住宅区"] = 0.70f, ["沙滩区"] = 0.60f, ["公园区"] = 0.50f, ["学院区"] = 0.60f, ["高端居住"] = 0.50f, ["运动区"] = 0.40f, ["工业区"] = 0.35f, }; /// 时段系数下限。午夜 HourlyFlow 会掉到 0,不设下限街上会彻底空场。 public float CrowdHourFloor { get; init; } = 0.15f; /// 找不到地块时的回退系数。默认 1.0 = 不对无人区再打折。 public float CrowdPlotMissFactor { get; init; } = 1.0f; /// 判定「玩家站在哪个地块」时的搜索半径(米)。 public float CrowdPlotSearchMeters { get; init; } = 60f; // ------------------------------------------------------------------ // 自动氛围行人(GameRouter.Ambient.cs) // // ★★ 2026-09-20 纠错:上面那组 crowd 密度包**不是**空实现。 // dump.cs 里 0x9148360 是**基类 virtual 空壳**的地址,每个 S2C 都有这么一行; // 判据要看有没有 `public override` 那一行(RVA 0x127xxxxx = 真实现)。实测真值表: // 有实现:InitDatas / StaticNpcAddData / CrowdAdd / NpcRemove / ManagedLogicAgent / // MoveWandering / AgentForceGo / SetCrowdDensity / CrowdSpecifiedDensityRefresh // 空 stub:ChangeNpcMoveDesiredSpeed(68739456) / DoAetherAgentBehaviorTaskDefine(68705721) // ⇒ 密度包能发但客户端没出人,是**参数/前置条件**问题,不是"发了等于没发"。 // ⇒ 单独下发"期望速度"和"行为任务"这两条 donor 结论是错的(客户端没实现)。 // ------------------------------------------------------------------ /// 进世界后自动在玩家周围铺行人。关掉就只剩面板手动生成。 public bool AmbientPedsEnabled { get; init; } = true; // ------------------------------------------------------------------ // 自动功能 NPC(GameRouter.FunctionalNpc.cs,2026-09-22) // // 跟随式:玩家走到哪,附近的店铺 NPC 就在哪冒出来。用**滞回**而不是单一阈值 —— // 进 130m 铺、出 230m 撤,玩家在街口来回走不会反复生成/销毁(见 §3 规则 26)。 // ------------------------------------------------------------------ /// 进世界后自动在玩家周围铺设功能 NPC(店员 / 售票)。关掉就只剩面板手动生成。 public bool FunctionalNpcsEnabled { get; init; } = true; /// 玩家进入这个半径(米)就把该点位的功能 NPC 放下去。 public float FunctionalNpcEnterMeters { get; init; } = 130f; /// 玩家离开这个半径(米)就撤掉它。必须明显大于 Enter,否则街口会抖动。 public float FunctionalNpcExitMeters { get; init; } = 230f; /// 跟随循环的检查周期(毫秒)。 public int FunctionalNpcTickMs { get; init; } = 1500; /// 每次检查最多新铺几个。铺太猛客户端一次吃太多实体会丢。 public int FunctionalNpcPerTick { get; init; } = 2; /// /// 玩家走远后要不要发 SyncAetherAINpcRemove 撤人。默认 false。 /// ⛑⛑ 2026-09-25 实机已证这条 remove 删不掉 8300 段那具身体(跑步机上的里希一直不消失, /// 见 的说明)。撤不掉、玩家折回时却又重新发一具 ⇒ /// 同一个店员在原地叠 2~3 具(2026-10-01 用户报「健身房里 3 个卢卡斯·高桥」就是这么来的: /// 一局内 place/retire 循环了 3 次,3 具身体同一坐标)。 /// 关掉 ⇒ 只铺不撤:一局里每个点位最多铺一次,客户端那具身体一直站着,永不重复。 /// 想回到「走远就撤」的旧行为就设 true(风险:仍会叠)。 /// public bool FunctionalNpcRetireOnExit { get; init; } = false; // ------------------------------------------------------------------ // 自动地盘敌人(GameRouter.EnemyTerritory.cs,2026-09-23) // // 与功能 NPC 同一套跟随循环,只是坐标来源和生成通道不同: // 坐标 = FactionInfluenceAreaConfig × FactionAreaPointConfig(85 个帮派地盘角点, // EnemyCatalog 里一个坐标都没有,这是 dump 里唯一现成的敌人站位来源); // 生成 = GameAdminBridge.SpawnAgentAsync(forbidAetherAI=true,客户端行为树接管, // 所以它们不像 ambient 行人那样沿人行道走)。 // 半径比功能 NPC 更小(90/160):敌人会主动扑上来,130m 外就出人等于隔街被围。 // ------------------------------------------------------------------ /// 进世界后自动在玩家周围的地盘上铺敌人。关掉就只剩面板手动生成。 public bool EnemyTerritoryEnabled { get; init; } = true; /// 玩家进入这个半径(米)就把该地盘的敌人放下去。 public float EnemyTerritoryEnterMeters { get; init; } = 90f; /// 玩家离开这个半径(米)就撤掉它。必须明显大于 Enter。 public float EnemyTerritoryExitMeters { get; init; } = 160f; /// 跟随循环的检查周期(毫秒)。 public int EnemyTerritoryTickMs { get; init; } = 1500; /// /// 每个地盘点位放几个敌人(1–5)。站位由 EnemyTerritorySpots.json 的 members 提供(人行道点, /// 组内最小间距 1.3 m),所以 3–4 人是「三三两两站在巷口」的观感,2 人显得零星。 /// 上限 5 = 生成脚本写的站位数量(maxGroup),再高也没位置可放。 /// public int EnemyTerritoryPerPoint { get; init; } = 3; /// 场上同时存在的地盘敌人上限。兜底,防止玩家在密集区来回走把数量堆上去。 public int EnemyTerritoryMaxActive { get; init; } = 24; /// /// true = 生成即登记进敌人账本并下令开战(锁定玩家 + FightEdict + Detect),打得掉血; /// false = 只把实体放进世界站桩,玩家走近才会被客户端自己发现。 /// 想让城市"有帮派分子在晃"但不被追着打,就设 false。 /// public bool EnemyTerritoryEngage { get; init; } = true; // ------------------------------------------------------------------ // 敌人移动的节奏(GameRouter.EnemyAiActions.cs) // // ⛑️ 抽帧根因(2026-09-23 实测):SyncEnemyBattleMoveTowardUnit 是「有限位移」—— // 客户端只走 MoveTime 秒就停,然后原地站着等下一条。原来 MoveTime=0.65s 而派发间隔 // 是 1000ms ⇒ 走 0.65s 停 0.35s,肉眼就是「走一步停一下」。 // ⇒ 派发间隔必须 ≥ MoveTime,且两者要贴住(留 ~50ms 余量,小于会被下一条打断)。 // ------------------------------------------------------------------ /// 每条移动命令让客户端走多久(秒)。越大一步走得越远,越接近连续行走。 public float EnemyAiMoveSeconds { get; init; } = 3.0f; /// /// 两条移动命令之间的间隔(毫秒)。必须 >= MoveSeconds*1000,否则下一条会打断上一条。 /// 留 50ms 左右余量最自然(完全连续 / 略微一顿,靠这个值调)。 /// public int EnemyAiMoveIntervalMs { get; init; } = 3050; /// /// ★ 决定性实验开关(2026-09-24):false = 服务端**完全不发**战斗移动命令 /// (SyncEnemyBattleMoveTowardUnit),只发 engage(SyncBattleAiS=true)和攻击。 /// 敌人的接近动作交给客户端自己的行为树 —— 它是 forbidAetherAI=true 生成的, /// 客户端本来就在跑战斗 AI。如果我们每 1 秒补一条有限位移命令反而是在打断它, /// 那关掉这个开关就能看到"原生"的连续追击。 /// ⛑️ 关掉后如果敌人完全不动,说明客户端行为树不自己走路,那就得开回来。 /// public bool EnemyAiServerMoveEnabled { get; init; } = true; /// /// 攻击后把单位冻多久(毫秒):CastBusy 是完全不动,CastInterval 是下次决策的时间。 /// 原来是 6000/6500 —— 打一下站 6.5 秒,这也是"停"的一大来源。 /// public int EnemyAiCastBusyMs { get; init; } = 2200; public int EnemyAiCastIntervalMs { get; init; } = 2600; /// /// 每个敌人的移动/攻击日志打多密:前 3 条必打,之后每 N 条打一次。 /// 原来只打前 3 条,导致实机看不到任何后续节奏(诊断"一步一停"时吃了大亏)。 /// public int EnemyAiVerboseLogEvery { get; init; } = 5; /// /// 玩家不在仇恨范围(30m)时让敌人原地踱步:走一段 → 停一会 → 倒着走回来。 /// 走的是 WalkFront / WalkBack 交替,位移互相抵消,所以不会整体漂向玩家。 /// public bool EnemyAiPatrolEnabled { get; init; } = true; /// 踱步两段之间的停顿区间(毫秒),在区间里随机取值。 public int EnemyAiPatrolPauseMinMs { get; init; } = 1200; public int EnemyAiPatrolPauseMaxMs { get; init; } = 2600; /// /// 踱步单条命令走多久(秒)。比追击更长:一条命令内客户端是连续走的, /// 命令越多起停越多,看起来越碎。2.5s 大概走 4~5 m。 /// public float EnemyAiPatrolMoveSeconds { get; init; } = 2.5f; /// 踱步离出生点最远多少米(保险绳,估算位置超出就强制换向)。 public float EnemyAiPatrolLeashMeters { get; init; } = 6f; /// /// 仇恨脱离半径(米)。必须大于进入半径 30 —— 单阈值会让 engagement 在边界反复开关, /// 而每次开关都要重发 LockTarget/FightEdict/BattleAiS,客户端的动作层被重置、 /// 动画被裁断,表现就是"刚迈一步就僵住"。实测日志里几秒内 True→False→True 抖了 53 次。 /// public float EnemyAiAggroExitMeters { get; init; } = 38f; /// engagement 状态翻转后的最短保持时间(毫秒),二次防抖。 public int EnemyAiEngageHoldMs { get; init; } = 2000; /// /// 仇恨进入半径(米)。原先是 EnemyAiActions.cs 里的硬编码 30f, /// 2026-09-26 提成配置项。 /// /// ⛑⛑ 填 0 = 敌人永不主动锁定玩家(EnemyAiSelectTarget 恒返回 0, /// 不发 LockTarget / FightEdict / BattleAiS)⇒ 敌人一直保持未察觉 ⇒ /// 蹲伏暗杀才有窗口:已在交战中的敌人不可暗杀,这正是 /// ReportCanAssassinateEnemies 恒 0 的原因之一(实测 20:23:36 敌人就 engage 了, /// 而蹲伏在 20:23:30–54)。 /// 想恢复原行为填 30。 /// public float EnemyAiAggroRangeMeters { get; init; } = 30f; /// /// 实验开关:改用 SyncMoveWandering(68745518) 让敌人"自己走起来"—— /// 官方唯一不带目的地的行走包(半径/角度窗口/时间预算,路径由客户端自己挑), /// 行人路线走的就是它,所以是真正连续的行走,而不是一串有限位移命令。 /// ⛑️ 敌人 forbidAetherAI=true,能不能接这个包没验证过;默认 false。 /// 开了之后服务端拿不到真实坐标,AiPosition 仍是估算(游荡位移不计入)。 /// public bool EnemyAiWanderEnabled { get; init; } = false; /// /// 出招前转向用什么包: /// "target"(默认)= SyncUnitSmoothFaceToTarget(68146316),把目标单位交给 /// 客户端,转角由它用真实朝向算 ⇒ 准; /// "smooth" = SyncUnitSmoothFace(68718685),服务端按估算朝向算增量 ⇒ /// 估算过期就会转歪(2026-10-01 实机:敌人跑到玩家面前再转 90° 打空气); /// "off" = 不发转向包(对照用)。 /// 详见 GameRouter.EnemyAiActions.cs 的 TurnEnemyToAsync。 /// public string EnemyAiTurnPacket { get; set; } = "target"; /// wander 单次游荡的距离区间(米)。踱步用,别设太大免得走丢。 public float EnemyAiWanderMinDis { get; init; } = 3f; public float EnemyAiWanderMaxDis { get; init; } = 10f; /// wander 的时间预算(秒)。走完就站住,靠巡逻 tick 续期。 public float EnemyAiWanderSeconds { get; init; } = 14f; // ---- 随从(Companion)—— 2026-10-01 ---- // // ⛑⛑ 取证结论:随从模板用的是**专属伴生行为树**(`AgentAIBehaviorSetsBattleAISBehaviorConfig` // AI=40213535 → BehaviorTree=`Companion_JiaMu_Shuangmawei`(40651922 晴) // AI=40213604 → BehaviorTree=`Companion_Guys`(40155016 盖斯)) // 两行的 Behavior_Battle 都是空串、RelatedSkillIds 都是空表 ⇒ 它们的战斗能力 // **不在 AI 行为集里**,而是写在那棵 Companion 树的 C# 实现里,服务端查不到。 // 所以别指望 `EnemyAiAttacksFor` 给它们配招(它会退回金属吉他手那两下 51900523/51900524)。 // // ⛑⛑ 而 AgentConfig 给的 Camp:晴=1(PlayerDefault)、盖斯=1 ⇒ **随从本来就是玩家阵营**。 // 之前 `campOverride:0` 把它按 UnitCamp.MonsterDefault 发了出去 ⇒ 客户端当它是敌人 // (挂血条、可锁定、Companion 树不接管)—— 这就是"随从像敌人"的头号根因。 // UnitCamp 枚举(dump.cs:1907832):0 MonsterDefault / 1 PlayerDefault / 2 AllNeutral // / 3 AllFriend / 4 AllEnemy。(⛔ 旧记忆说"友方=1"是对的,但说"敌人是 0/4"只在 spawn // 默认路径上成立;真正的判定是 UnitCamp 本身。) /// /// 随从生成时下发的 camp。默认 1 = UnitCamp.PlayerDefault(模板自带值, /// 与捐赠端给机械狗发的同款)。SyncAgentCampInfo 的包体就是这一个字节。 /// 设 4 立刻回到"随从被当敌人"的旧行为(一键回滚用)。 /// public byte CompanionCamp { get; set; } = 1; /// /// 服务端是否用敌人那套四包(BattleAiS / LockTarget / FightEdict / BattleMoveTowardUnit) /// 自己驱动随从跟随+协战,并在贴身时补一个 SyncUseTargetSkill 让它真出招。 /// /// ⛑⛑ 2026-10-01 实机:false 的时候随从完全不动 —— 客户端那棵 Companion 树 /// 并没有因为 SyncAgentCompanionAI+SyncCompanionMaster 就接管 /// (camp 已经发成 1 了也一样)。所以服务端必须自己驱动,默认 true。 /// public bool CompanionServerDrive { get; set; } = true; /// /// 随从出招用的技能池(贴身时按序号轮流发 SyncUseTargetSkill)。 /// /// ⛑⛑ 为什么不是"它本身的攻击模组":晴/盖斯的 AI 行为集里 Behavior_Battle 是空串、 /// RelatedSkillIds 是空表,招式写在客户端那棵 Companion_* 行为树的 C# 里, /// 服务端无从得知(详见 CompanionAssist.cs 类注释)。所以只能借通用池。 /// /// 默认取 EnemyAiCatalog 的 UNARMED 近战池(与 7k-v0.5 同源,能在多数骨架上播): /// 51900500 / 51900501 空手普攻,51900410 / 51900411 空手连击。 /// 若实机看到它"摆姿势不出手"(骨架不对),换成 RANGED 远程池试: /// [51984196, 51992001, 51996871] /// 换技能不用重编译,改这里重启即可。 /// public List CompanionAttacks { get; init; } = [51900500, 51900501, 51900410, 51900411]; /// /// 跟随(身边没有敌人)时是否仍然把 SyncBattleAiS 保持在 true。 /// /// ⛑⛑ 2026-10-01 实机:恒 true 的后果就是随从一直保持举枪的战斗姿态, /// 没敌人也端着枪慢慢挪 —— 玩家看到的"怪"就是它。主线驱动敌人时是 /// value = target != 0(EnemyAiActions.cs:453),没目标就关战斗 AI。 /// 默认 false = 跟随就收枪,正常走路。 /// ⛑ 若改成 false 后随从连路都不走了(说明它只认战斗态的移动指令),设回 true。 /// public bool CompanionBattleAiWhenFollowing { get; set; } = false; /// /// 跟随模式下单条移动命令走多久(秒)。 /// /// ⛑ 为什么要单列:有限位移命令是"走 MoveTime 秒就停",而协战用的 /// EnemyAiMoveSeconds(默认 0.65s)是给敌人**短距离贴脸**调的。随从要跟 /// 十几二十米的话,一条命令只走 1.2 m 就要停一下 ⇒ 玩家看到的就是"一顿一顿慢慢挪"。 /// ⇒ 跟随时取 max(EnemyAiMoveSeconds, 本值),本值默认 3.0s(约 5.5 m)。 /// 调大 = 一次走更远、起停更少(但转向更迟钝);调小 = 更贴身、更碎。 /// public float CompanionFollowMoveSeconds { get; set; } = 3.0f; /// /// 绑定随从后清掉它的上半身持枪瞄准态(SyncEndShootingState + /// SyncEndShootGameplayTag,见 GameRouter.EnemyStance.cs:32)。 /// /// ⛑⛑ 2026-10-01 实机:随从"举着枪慢慢挪"的真凶。主线那条清理只在 profile 判成 /// unarmed 时才发,而随从 profile 是 null(招在行为树里、没进 EnemyAiCatalog) /// ⇒ 永远走不到。瞄准态的 locomotion 是慢速瞄准步 ⇒ 「举枪」和「慢慢」同源。 /// 默认 true。 /// public bool CompanionClearGunAim { get; set; } = true; /// /// 绑定随从后收起它头顶的血条(SyncAddUnitState + HideHp=10266)。 /// /// ⛑⛑ 血条不看阵营:判据在 EnemyHUDCtrl.lua:579 —— /// shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ... /// ⇒ 改 camp 去掉不了血条,只有挂 HideHp 状态才行(per-unit,不动公共 spawn 路径)。 /// 默认 true。 /// public bool CompanionHideHpBar { get; set; } = true; /// /// 里希执勤终端的战术支援(dispatchid=4「冲锋枪2人」)刷出来的两名友军, /// 是否也接入这套随从 AI(跟随 + 协战 + 收血条)。默认 true。 /// public bool CompanionBindArmedSupport { get; set; } = true; /// /// 绑定后推的战斗动画集(SyncBattleAnimSet,AnimationSetConfig id)。 /// /// ⛑⛑ 为什么随从需要它:官方 Companion 协议只有三个包(dump.cs 全量 grep 过: /// SyncCompanionMaster / SyncAgentCompanionAI / /// SyncAgentCompanionAIReactionEventSuccess),没有 follow/move —— /// 跟随本该是客户端那棵树自己的事;它没接管,我们只能用敌人 locomotion 代劳, /// 于是"走什么步、手怎么摆"就取决于这个动画集。 /// 主线 SendEnemyCombatStanceAsync 只在 profile 判成 unarmed 时推,随从 profile 是 /// null ⇒ 一直用默认的持枪/远程集在走慢步。 /// /// 取值(EnemyAiMethods.cs 注释):325 = 十二面空手、327 = 通用胖子空手、 /// 337/338/340 = 远程、0 = 不发(默认,留作对照)。 /// ⇒ 想修「举枪慢步」就填 325(配合 CompanionDisarm=true,即 7K melee 三件套)。 /// public uint CompanionAnimSetTag { get; set; } = 0; /// /// 额外把随从的武器卸掉(SyncAgentChangeWeapon(uid, 0))。默认 false —— /// 它的 InitWeaponId 本来就是 0,这只是个对照开关:清了瞄准态还举枪时才开。 /// public bool CompanionDisarm { get; set; } = false; /// /// 跟随时用 BattleMoveActionType.Run(6) 而不是 WalkFront(2)。 /// /// ⛑⛑ 2026-10-01 实机:跟十几米全用走路,玩家看到的就是"慢慢挪过来"。 /// 枚举在 dump.cs:1913359(Unknown=0 / Idle=1 / WalkFront=2 / WalkBack=3 / /// WalkLeft=4 / WalkRight=5 / **Run=6**)。协战始终用 WalkFront(贴脸不需要跑)。 /// 默认 true;若跟随时它跑得太冲/动作不对,设 false 退回走路。 /// public bool CompanionRunWhenFollowing { get; set; } = true; // ------------------------------------------------------------------ // ★★★★★ 服务端驱动行走 —— 移动任务步进(2026-09-20 移植 donor1 实机配方) // // ⛑️ 位置流路线已证伪(donor1 第 39 轮 + 我们 09-20 实测): // `SyncLogicAgentSyncData`(68838628) 不携带任务 ⇒ 客户端产生不了步态。 // 我们 10Hz 推了 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动; // donor1 AI_HANDOFF 明确把「服务端硬推位置」标为**已证伪路线**(路人第 16~23 轮的老路)。 // // 正确路线 = `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData`(wire tag 39): // 客户端的 LogicBehaviorSchedulerModule 跑任务 —— ZoneGraph 寻路、避障、locomotion 状态。 // donor1 第 51 轮用户亲测完整配方: // wander=False logicSync=WayPoint logicEnter=True command=True // commandPoints=3 commandActionId=400 speed=1.6 + AutoHold/ForceGo // donor1 第 81 轮:`SyncManagedLogicAgent`(LogicHandoff)是**客户端来要路径的前提**, // 状态机被关掉 ⇒ 客户端根本不来问 ⇒ 任务全落空。 // // 路网数据:`Ananta.Server/Ananta.App/data/ped_lanes.csv` + `ped_lane_links.csv` // (88361 条车道 / 244398 点 / 263448 条连接,由 `_ref/_gen_ped_lanes.py` 生成)。 // ------------------------------------------------------------------ /// 服务端沿人行道路网给每个行人派移动任务(TaskWayPointMove)。关掉就回到"客户端自己走"(实测 = 不走)。 public bool AmbientPedsDriven { get; init; } = true; /// /// 任务步进循环的检查周期(毫秒)。默认 500ms(donor1 的 CrowdHoldTickMs)—— /// 任务只在行人"跑完当前段"时才发下一条,不是固定频率流。 /// public int AmbientPedsSyncMs { get; init; } = 500; /// /// 一条移动任务覆盖的路程(米)。1.6 m/s 下 30m ≈ 19s、60m ≈ 37s 步态, /// 下一条任务在"预计走完 + "之后才发。 /// /// ⛑️ 2026-09-23 30 → 60:那段余量是**每次发任务都要空等**的,路程越短、发呆越频繁 /// (30m:每 19s 发呆 5s = 21% 的时间在站着;60m:每 37s 发呆 5s = 12%)。 /// 加长路程**不改时序预估公式**,所以多等的还是那段余量本身,不会提前打断 ⇒ 不会瞬移。 /// public float AmbientPedsStepMetres { get; init; } = 60f; /// /// 任务折线的采样间距(米)。路程加长后必须靠它保住贴合度 —— /// 原来写死 `stepMetres/5`,60m 会变成 12m 一个点,lane 中位数才 3.58m,行人会切弯穿绿化。 /// 固定 6m:60m ⇒ 11 个点,和 30m 时的密度一致。 /// public float AmbientPedsSampleMetres { get; init; } = 6f; /// /// 换道时"不许走回"最近走过的几条车道(0 = 只挡住紧邻的上一条,即 2026-09-23 之前的行为)。 /// /// 离线路网实测(2000 段 / 每步 30m,模拟器 `_pedwalk_sim.py`): /// 1 条:掉头 4.8% / 净位移中位 10.4m / 直线度 0.51 / 打转 41.5% /// 5 条:掉头 9.8% / 净位移中位 13.6m / 直线度 0.77 / 打转 35.6% /// **8 条:掉头 9.0% / 净位移中位 13.6m / 直线度 0.85 / 打转 33.6%** ← 默认 /// ⛑️ 记忆越长走越直,但掉头率也会上去;掉头时**必须清空记忆**,否则掉头率飙到 42%。 /// public int AmbientPedsRouteMemory { get; init; } = 8; /// /// 目标导向行走(默认开):每次派单前给行人挑一个几十米外的终点,用 `PedLaneGraph.PlanRoute` /// 规划整条路径,行人沿路径穿过多个路口 —— 而不是在局部邻域随机游走。 /// /// ⛑️ 为什么必须开:2026-09-23 用户实测"行人只是从一条路的一头走到另一头,那条路很短就很奇怪"。 /// 我们的路网 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,随机游走的活动半径天然被锁在 /// 几条短车道之间。关掉就退回纯随机游走(保留作回退路径)。 /// public bool AmbientPedsJourneyEnabled { get; init; } = true; /// 行程终点相对**玩家**的最小半径(米)。终点在环形区域内取,保证行人不会走出回收圈。 public float AmbientPedsJourneyMinMetres { get; init; } = 45f; /// 行程终点相对**玩家**的最大半径(米)。建议 ≤ `AmbientPedsRadiusMeters` * 0.9。 public float AmbientPedsJourneyMaxMetres { get; init; } = 135f; /// /// 横向偏移的绝对上限(米):真实偏移 = min(车道 HalfWalk, 这里) × 出生时锁定的比例。 /// 目的是不让所有人挤在车道几何中线上(对向行人才不会互相穿模)。 /// /// ⛑️ **设成 0 = 完全不偏移,全员走车道中线** —— 排查"行人卡进街边墙壁"时先拿它排除嫌疑: /// 2026-09-23 用户实测窄路卡墙,根因是目标导向路径用了固定 0.6m 偏移(跟路宽无关)。 /// 卡墙现象若**还有其他来源**(比如车道几何本身贴着建筑),把它设 0 就能确认/排除。 /// public float AmbientPedsLateralMaxMetres { get; init; } = 0.4f; /// 横向可走半径的安全余量(米):HalfWalk = max(0, width/2 - 本值)。 /// 调大 = 行人往车道中线收(越不容易被护栏 / 街边墙卡住);调小 = 横向分布更散。 /// ⛑️ 车道宽度中位只有 2.0 m,本值 ≥1.0 时大多数窄路会退化成全员中线。 /// ⛑️ 本项在车道表加载时算进 `HalfWalk`,**改完必须重启**;`AmbientPedsLateralMaxMetres` 则是每次采样现读。 public float AmbientPedsLateralMarginMetres { get; init; } = 0.9f; /// 出生点的最小车道宽度(米):窄于这个值的车道不用来出生(树池边 / 栏杆边的小径)。 /// 设 0 = 不过滤。⛑️ 车道宽度中位只有 2.0 m(p10=1.5 / min=0.5),默认 1.8 会滤掉约 17% 的车道。 public float AmbientPedsSpawnMinWidthMetres { get; init; } = 1.8f; /// /// 行人模板池的 **id 段白名单**(T0-28):只保留前缀命中任一项的 `NpcFormworkId`。 /// 空数组 = 不过滤(保持 2026-09-26 之前的"全段混入"行为)。 /// /// ⛑️ 取证(2026-09-26,用户实机反馈"商店店员、废弃模型大量混在人群里"): /// `CivilianGroups.json` 去重后有 3318 个模板,横跨 31 个 id 段;拿 `NpcCatalog.json` /// (= AgentConfig 的 ConfigDump,9345 行,带 `Name`) 逐条对名,发现只有 **4013** 段是 /// 纯市民(2358 条,72% 是英文/罗马音人名,其余是普通中文人名)。会穿帮的段: /// 4095/4200/4285 = 店员摊主(咖啡店店员 / 手划船售票员 / 前台店员 / KTV店员) /// 4515 = 刷怪NPC / 特殊病毒病人 / 刷怪(难度一~四) /// 4204 = 剧情与职能(模型店店长 / 怪兽狂 / 空手道王 / 蓝猫车管处 / 礼品铺) /// 4065/4096/4097 = 职业角色(清洁工主理人 / 保安小弟 / 天穹安保突击专员 / 厂长 / 主唱 / 鼓手 / 医生 / 护士甲) /// 想加回多样性就往这里补段(4501 / 4202 / 4201 三段 0 个中文名,看着也是普通市民)。 /// public string[] AmbientPedsFormworkIdPrefixes { get; init; } = ["4013"]; /// /// 行人模板池的 **模型黑名单**(`NpcCatalog.GeneralModelId` 命中任一项就踢出池子)。 /// 这是"刷出一街general假人"的根因 filtration:一个 AgentConfig 行只是换个名字,模型还是同一个。 /// /// ⛑️ 取证(2026-09-26,用户实机"道路上很多 NPC 都是通用临时模型"): /// NpcCatalog 里名字含「通用临时模型」的只有 **15 行**,但它们**只用 4 个 GeneralModelId** /// —— 86160001(少女) / 86160002(成女) / 86160003(少男) / 86160004(成男)。 /// 而按 **模型** 反查,全表有 **470 + 9 + 171 + 30 = 680 个 id** 共用这 4 个假人模型, /// 其中 **522 个在我们的行人池里**(4013 段占 373)。也就是说只按"名字"过滤是漏的: /// 名字都叫 Blair Scott / Phoebe Beck,看着是市民,穿的却是同一具通用模特。 /// ⇒ **必须按 GeneralModelId 过滤,而不是按行名字过滤。** /// 排掉这 4 个模型后 4013 段 2358 → **1985**(够用,且外观终于不一样了)。 /// /// ⛑️ 用户 2026-09-26 定:**只处理这 4 个通用模特**,不要再外扩到「备用 / 空模型 / 空npc / /// 临时」那批(86000322 / 86951719 / 86962003)—— 实测它们在池内牵连数为 **0**,加到黑名单里 /// 只会让后来人看不懂这串数字是哪儿来的。范围收紧,只对确实造成满街假人的那 4 具下手。 /// 设空数组 = 不过滤。 /// public uint[] AmbientPedsFormworkModelBlocklist { get; init; } = [86160001, 86160002, 86160003, 86160004]; /// /// 行人模板池的 **名字关键词黑名单**(`NpcCatalog.Name` 含任一项就踢出池子)。 /// 白名单之外的第二道保险:万一某天真往 4013 段里混进了职能/废弃配置,名字先拦一道。 /// 空数组 = 不过滤。匹配是大小写不敏感的 `Contains`。 /// public string[] AmbientPedsFormworkNameBlocklist { get; init; } = [ "店员", "摊主", "售票员", "老板", "店长", "Keeper", "Owner", "Stall", "委托人", "病人", "刷怪", "受困", "后勤", "车管", "大夫", "护士", "医生", "保安", "清洁", "环卫", "安保", "专员", "主理人", "空余", "占位", "测试", "临时", "备用", "废弃", "Agent", "礼品铺", ]; /// /// 位置回馈校正的下限(米):客户端上报位置与服务端账本偏差小于它就是正常误差(车道采样点有间距),不校正。 /// /// 触发源是 `AskClientNpcFindPath`(67974714) 请求里的 `From` —— 这是**唯一**能拿到散人真实 /// 位置的通道(散人客户端自驱,硬规则 19,服务端没有位置汇报包)。 /// 2026-09-23 用户实测"1 个行人卡在原地不动,打他几下位移后又正常走了" ⇒ 客户端位置与 /// 服务端账本脱节后会永久发散,必须有这条回路把它拉回来。 /// public float AmbientPedsResyncMinMetres { get; init; } = 3f; /// /// 位置回馈校正的上限(米):偏差超过它就**不认**(多半不是我们的单位,或者数据异常)。 /// 没有这道闸会把行人拖着乱跳 —— 比不校正更糟。 /// public float AmbientPedsResyncMaxMetres { get; init; } = 60f; /// 位置回馈的「软校正」容差(米):客户端真实位置离当前路线折线不超过这个值, /// 就只把账本归位 + 跳过已走过的点,**不**清路线重派(问路不等于卡住,见 )。 /// 设 0 = 关掉软校正,退回「一问路就重规划」(会把行人掐成走走停停)。 public float AmbientPedsResyncRouteToleranceMetres { get; init; } = 12f; /// /// 看门狗 ①(服务端侧):连续派单失败达到这个次数就回收重生。 /// /// 派不出任务 = 路网状态坏了(车道不存在 / 规划不出来 / 采样推进失败)。这种卡死 /// **服务端自己就能发现**,不需要客户端回馈 —— 是"行人杵着不动"最需要兜底的一路。 /// ⛑️ 必须是"连续 N 次"而不是一次:偶发一次失败是正常抖动,一次就回收会让行人在 /// 正常游走时凭空消失(比站着不动更难看)。 /// public int AmbientPedsStepFailLimit { get; init; } = 5; /// /// 看门狗 ②(客户端侧):`AmbientPedsStallWindowMs` 窗口内位置回馈校正达到这个次数就回收重生。 /// 反复脱节说明这个单位贴墙 / 卡地形,校正已经救不回来,换个人换个地方。 /// public int AmbientPedsStallResyncLimit { get; init; } = 5; /// 看门狗 ② 的滑动窗口长度(毫秒)。donor round 84 用的就是 30 s。 public int AmbientPedsStallWindowMs { get; init; } = 30000; /// /// 预计走完之后的额外等待(毫秒)。这是"发呆"的唯一来源:行人走完一段路后会站住, /// 直到这里的时间过去才收到下一段任务。 /// ⛑️ 调小可以让发呆变短,但**如果客户端实际比预估慢,新任务会提前到** ⇒ 行人被拽回上一段 /// 终点再走(瞬移)。想要零瞬移就保持默认,靠加长 降频率。 /// public int AmbientPedsStepSlackMs { get; init; } = 5000; /// /// 生成后补发 `SyncLogicAgentEnter`(68719890):把 id 注册进客户端的 logic-agent /// 同步管理器(任务从它派发)。donor1 第 37 轮加、第 51 轮成功配方里默认开。 /// public bool AmbientPedsLogicEnter { get; init; } = true; /// /// `LogicAgentSyncData.MoveId` = `BattleMoveActionType`: /// 0 Unknown / 1 Idle / **2 WalkFront** / 3 WalkBack / 4 WalkLeft / 5 WalkRight / 6 Run。 /// (旧位置流路线的参数,保留作对照;任务路线用的是 AgentMovementMethods.Walk=1。) /// public byte AmbientPedsMoveId { get; init; } = 2; /// /// 用哪条通道建人。 /// "crowd"(默认)= SyncAetherAICrowdAdd(donor_lyco082 实机验证的市民通道)。 /// ★ 这是**唯一带"去游荡"语义**的通道:ClientCrowdInitData 有 /// `TargetLocationReason`(ZoneGraphTargetLocationReason)字段, /// 填 Wander=1 才是"让客户端 Aether AI 自己挑目标走起来"。 /// static 通道根本没有这个字段 ⇒ 人只能站着。 /// "static" = SyncAetherAIStaticNpcAddData + AgentSyncClientInfo(treeName=PedBase) /// + SyncManagedLogicAgent + SyncMoveWandering。 /// 人一定出得来、有动画,但 SyncMoveWandering 走的是 /// `WanderingActionModule`(BaseUnit 管线),而我们的 NPC 是 /// AgentEntity(ECS 管线)⇒ 大概率静默失败(donor 证据)。 /// ⛑️ 名字里就写着"Static"——它就是给站桩 NPC 用的。 /// public string AmbientPedsChannel { get; init; } = "crowd"; /// 维持多少人。客户端 Mass 实体有上限,40 是 donor 验证过的安全值。 public int AmbientPedsTarget { get; init; } = 40; /// 在玩家多少米内布点(从真实行人路网取点)。 public float AmbientPedsRadiusMeters { get; init; } = 150f; /// 行人离玩家超过多少米就移除。必须 > 布点半径,否则刚生成就被回收。 public float AmbientPedsRecycleMeters { get; init; } = 260f; /// 人与人之间最小间距(米),防止叠在一起。 public float AmbientPedsMinGapMeters { get; init; } = 6f; /// 维护循环间隔(毫秒)。 public int AmbientPedsTickMs { get; init; } = 1000; /// 每轮最多补几个(防止一次性发几十个包把客户端噎住)。 public int AmbientPedsTopUpPerTick { get; init; } = 6; // ---- T3-7 真实车流(服务端驱动车辆沿车道跑)------------------------------------------ /// /// 开启车流。客户端侧**没有**车流密度解锁包(人流有 SyncSetCrowdDensity,车流只有 C2S 的 /// GmChangeAetherVehicleDensity),所以车必须由服务端逐台下发 SyncAetherAIVehicleAddData /// 并持续推 SyncAetherAIVehicleLaneDatas 才会出现。 /// public bool TrafficEnabled { get; init; } = true; /// 维持多少台车同时在玩家周围跑。 public int TrafficTarget { get; init; } = 12; /// 在玩家多少米内的车道上生成。 public float TrafficSpawnRadiusMeters { get; init; } = 200f; /// 车离玩家超过多少米就回收。必须 > 生成半径。 public float TrafficDespawnRadiusMeters { get; init; } = 340f; /// /// 车道推进包的下发间隔(毫秒)。 /// ★ 默认 50ms = 20Hz,对齐客户端常量 /// AetherVehicleManager.DELTA_TIME_PER_SERVER_FRAME = 0.05(dump.cs:3884945)。 /// 客户端是按「服务端 20Hz 发点」来设计插值的;用 10Hz 会让点间隔与它内部假设不符, /// 表现为走走停停 + 突然冲刺补距离。缓冲 MAX_PENDING_SYNC_DATA=30 也正好是 1.5 秒。 /// public int TrafficTickMs { get; init; } = 50; /// 补给 / 回收扫描间隔(毫秒)。 public int TrafficReseedMs { get; init; } = 1500; /// 车道限速的整体倍率(车道自带 speed 中位 9.72 m/s ≈ 35 km/h)。 public float TrafficSpeedScale { get; init; } = 1.0f; /// /// VehicleControlType:1 = AetherVehicle(客户端做车道插值,车会自己沿车道跑 —— 默认), /// 2 = RaidVehicle(只挂 Aether 数据、不接管驾驶,等价于 T1-16 骇入用的模式,车会停在 AddData 的 Position)。 /// ★ 排查「街上没车」时的对照开关:改成 2 若车出来了,说明 ControlType=1 这条 Aether 车道通道有问题。 /// public int TrafficControlType { get; init; } = 1; /// /// 车道衔接的接缝容差(米):在「接缝最小」的后继基础上放宽这么多再随机选。 /// 0 = 严格取接缝最小的后继(**实测最优**:>12m 的衔接仅 2.55%,而绝对阈值 8m 是 15%)。 /// 调大 = 路线更多变,但更容易出现「车瞬移一段」。 /// public float TrafficMaxLaneGapMeters { get; init; } = 0f; /// /// 换道接缝的**绝对硬上限**(米)。超过就直接判定"开到尽头"并回收该车,不再硬跳。 /// /// ★ 为什么单独有这个键:TrafficMaxLaneGapMeters 只是"相对容差", /// 但当一条车道的所有后继都被「方向反折」过滤掉时,挑选逻辑会退回全局最小接缝 —— /// 那条可能在几百米外。实测日志 worstGap=394.1m;车道数据本身 /// p99 接缝 347m、max 2965m,>30m 的占 13.92%。 /// ⇒ 没有这个上限,车会定期瞬移(并因为瞬移出 despawn 半径被立刻回收, /// 表现为「车刚生成就没了,不断 despawn+spawn」)。 /// ⛑️ 别设太大:客户端按 0.35 秒的插值窗口平滑位置,30m 的跳 = 约 85 m/s, /// 肉眼看就是「车瞬间飞过去」(2026-09-28 实测反馈)。接缝分布是两极化的 /// (p50=0.00m、p90=53m),所以收紧到 8m 能滤掉大跳而几乎不影响正常衔接。 /// 调小 = 更少瞬移、但车更早被回收;调大 = 车跑更远、但会出现瞬移。 /// public float TrafficMaxLaneHopMeters { get; init; } = 8f; /// /// 给每台车预规划多少段车道(VehicleLaneGraph.PlanRoute,直行优先)。 /// 0 = 关闭预规划,退回「每到一条车道随机挑后继」(旧行为)。 /// /// ★ 这是「车开很短距离就停下/消失」的结构性解:随机挑后继会让车乱拐、掉头、 /// 钻死路、很快被回收;预规划后车沿道路连续跑,观感从"随机乱窜"变成"正常行驶"。 /// 建议 8~16。太小 = 频繁重新规划(开销),太大 = 一次算太久且路径过期。 /// public int TrafficRouteHops { get; init; } = 12; /// /// LaneDatas 时间戳相对当前时刻的提前量(毫秒)。 /// ★ 默认 350ms,对齐客户端常量 AetherVehicleManager.TARGET_DELAY_TIME = 0.35 /// (dump.cs:3884947,即 TARGET_DELAY_FRAME=7 × 50ms)。 /// 客户端的 TimedSpringInterpolationMethodUnmanaged 是「定时弹簧」: /// 每个点带一个 TargetTimeStamp,客户端在到达该时刻前把 DeltaDistance 平滑排空。 /// 时间戳 = 当前时刻(lead=0)时目标时刻**已经过去**,弹簧瞬间排空 → 车冲一下 → /// 然后 deltaDistance=0 停住等下一个包 → 正是「停一下又用很快的速度补一段」。 /// 给一个未来时间戳,弹簧才有时间区间可以平滑铺开。 /// public float TrafficTimestampLeadMs { get; init; } = 350f; /// /// 起步加速度(m/s²)。默认 2.5 ≈ 家用车温和起步(0→35km/h 约 4 秒)。 /// ★ 车「凭空直接起步」就是因为没有这个:原来 spawn 时速度直接 = 限速。 /// public float TrafficAccelMps2 { get; init; } = 2.5f; /// 刹车减速度(m/s²)。默认 5.0(比加速大,符合直觉;急刹感就调低)。 public float TrafficDecelMps2 { get; init; } = 5.0f; /// /// 跟车检测半径(米)。只在这个距离内找前车。 /// ★ 调**小** = 更晚发现前车 → 车开得更冲、更少无故减速,但更容易追尾。 /// 默认 18(原为 30,实测 30 会让车在路口前就莫名减速)。 /// public float TrafficFollowRangeMeters { get; init; } = 18f; /// 跟车最小车距(米,保险杠到保险杠)。 public float TrafficFollowMinGapMeters { get; init; } = 3.0f; /// 跟车的横向半宽(米):前车偏离本车前进方向超过这个就不算挡路。 public float TrafficFollowWidthMeters { get; init; } = 2.6f; /// 交叉让行:最近接近距离小于这个值(米)判定为会撞。 public float TrafficConflictRadiusMeters { get; init; } = 3.5f; /// /// 交叉让行:只看未来多少秒内会撞。 /// ★ 调**小** = 更晚才判定要撞 → 车更敢走、路口更少互相让行卡死,但更容易真撞。 /// 默认 1.8(原为 3.0,实测 3.0 会让对向车流在路口互相等)。 /// public float TrafficConflictHorizonSec { get; init; } = 1.8f; /// /// 交叉让行的横向门槛(米):两车在**彼此**前进方向上的横向偏移都超过这个值 → 只是并行经过, /// 不算冲突。★ 没有这条,双向相邻车道(横向 3.5m)会被误判成要撞,对向车流互相让行卡死。 /// public float TrafficConflictWidthMeters { get; init; } = 2.8f; /// /// 让行速度(m/s)。让行方降到这个速度**继续走**,而不是停下 —— 停下会让对面也进入 /// 让行判定,两边来回抖动成死锁。 /// public float TrafficYieldSpeedMps { get; init; } = 1.5f; /// /// 僵局解除:一台车连续停住这么久(秒)就进入「硬冲」模式,低速强行通过。 /// ★ 没有这个,路口互相让行会形成环形等待(A 让 B、B 让 C、C 让 A),全场堵死。 /// public float TrafficStallReleaseSec { get; init; } = 2.0f; /// 硬冲模式持续多久(秒)。 public float TrafficStallBoostSec { get; init; } = 2.0f; /// 硬冲模式的速度上限(m/s)。低速通过,视觉上比原地不动好。 public float TrafficStallBoostSpeedMps { get; init; } = 4.0f; /// 距车道末端(= 路口/转弯)多少米内开始减速。 public float TrafficJunctionSlowMeters { get; init; } = 12f; /// 路口限速倍率(乘在车道限速上)。 public float TrafficJunctionSpeedFactor { get; init; } = 0.5f; /// /// 服务端自建「红绿灯相位」总开关(false = 一键回到没有信号灯的行为)。 /// /// ★ 为什么必须自建:客户端 AetherIntersectionManager 的放行关系 /// (MassIntersectionPeriodDetail.VehicleLaneMetas)在**客户端专属 blob** 里, /// 服务端只发 TrafficIntersectionPeriodInfo 三个 byte(当前/下一个/铁路相位号)。 /// 我们给的 ZoneStorageDataHandle 客户端读不出真 blob(实测 152/167 报 /// "cannot find static zone info / blobZoneCount: 171")⇒ 客户端不会替我们拦车。 /// ⇒ 只能在服务端按「车道 → 路口 → 进口道腿 → 相位组」自己算放行,红灯方向把目标速度压 0。 /// 代价:灯柱不会亮(那是客户端 GPU 蒙皮资源 honglvdeng.asset),但车的让行行为是原生的。 /// /// 数据表由 _gen_junctions.py 从 vehicle_lanes.csv 端点聚类生成: /// data/junctions.csv = id;cx;cz;groupCount;legCount;laneCount /// data/junction_lanes.csv = laneHandle;junctionId;groupIndex /// public bool TrafficSignalEnabled { get; init; } = true; /// /// 每个相位组的绿灯时长(秒)。 /// ⛑ 原为 9:相位组数 {2组:219, 3组:124, 4组:9} ⇒ 3 组周期 33s、4 组 44s, /// 最坏要等 24~33 秒 —— 2026-09-29 实机就是「有的车一直杵在路口」。缩短到 6。 /// public float TrafficSignalGreenSec { get; init; } = 6f; /// 相位切换之间的**全红清空**时长(秒)。没有它会对冲的车在路口里撞成一团。 public float TrafficSignalClearSec { get; init; } = 1.5f; /// /// 单车红灯等待上限(秒)。等超过这么久就**放行**(当作绿灯)。 /// /// ★ 为什么必须有:周期长度 = 组数 × (绿 + 清空),路口腿数越多等得越久, /// 单靠调 green/clear 无法给所有路口一个统一的上界。 /// 有了它,任何一辆车的红灯等待都 ≤ 这个值,杜绝「杵在路口不动」的观感; /// 交叉冲突仍由既有的 yield()兜底。 /// public float TrafficSignalMaxWaitSec { get; init; } = 8f; /// /// 感应式放行:只有「这个路口真的有别个方向来车」时才按相位让行,否则全绿。 /// /// ⛑⛑ 2026-09-29 实机逼出来的:我们把路网 6861/14171 ≈ **48% 的车道**都装了灯, /// 而场上只有 8 台车撒在 620m 半径 ⇒ 绝大多数路口根本没有冲突方,却被按 /// 「N 组里只有 1 组绿」的概率白拦一半时间(实测 spd avg 掉到 1.9、stop=5/8)。 /// 判据:某路口「有车所在的相位组」≥2 组才算有冲突,此时才看相位钟;否则放行。 /// 关掉它 = 回到「所有路口无脑按相位拦车」。 /// public bool TrafficSignalYieldToTrafficOnly { get; init; } = true; /// 红灯刹停时,在「刹到 0 所需距离」上再加的安全余量(米)。 public float TrafficSignalStopMarginMeters { get; init; } = 2.5f; /// /// 车流出生点距玩家的**最小**距离(米)。与 组成一个环。 /// ⛑ 2026-09-29 新增(原来只有上限,没有下限): /// ① 车会直接刷在玩家脚边,且出生点固定取车道起点(很多在路口里)⇒「不像在车道上」; /// ② 更关键:客户端 AetherVehicleViewManager 在 CANDIDATE_GO_DISTANCE_SQ=400(20m) /// 内会把车流车提升成 GO(完整载具实体),从此不再吃我们的 Aether 车道插值 ⇒ 冻在原地。 /// ⇒ 让玩家身边一直攒着一批不动的车。设成 60 以上可避开这一圈。 /// public float TrafficSpawnMinPlayerDistanceMeters { get; init; } = 60f; /// /// 车流车出生后发一条 SyncAetherAIVehicleForceGo(68832908):forceGo=false。 /// 期望语义 = 「这台车不要被提升成 GO(完整载具实体)」,让它一直吃服务端推的 /// Aether 车道插值 ⇒ 玩家走近时**不会冻住**。 /// /// ⛑ 为什么需要它:客户端 AetherVehicleViewManager 按距离自动提升 GO /// (20m 候选 / 8m 强制 / 同屏上限 8 台),提升后车改由客户端本地接管; /// 我们的车是服务端模拟的、不在客户端本地车流里 ⇒ 提升 = 没司机 = 停在原地。 /// 只靠 躲不开 —— 车会自己开过来。 /// /// ⛑ 语义未证实:`forceGo=false` 也可能被客户端当成「取消强制」而不影响自动提升。 /// 本开关就是为二分这条通道留的:开着没变化 ⇒ 通道无效 ⇒ 改走注入器 /// (hook CheckShouldNotUseGo 恒 true,RVA 0x0B108F00)。 /// 代价(若生效):近处的车保持简化渲染(ECS 整车,无分离的车轮/车顶件/车灯), /// 但至少一直在动。 /// public bool TrafficSuppressGoRender { get; init; } = true; /// /// 车流车出生后发一条 SyncVehicleAutonomousDrivingState(68349264):isStart=true, /// 让**客户端本地驾驶系统**接管这台车。 /// /// ★ 与 的区别:那条是求「别被提升成 GO」(已证伪,10-01 实机 /// 包发出去了照样停);这条是**反过来给它一个本地司机** —— 客户端自带驾驶、避障、交通规则, /// 且本地驾驶本来就面向 GO 完整载具 ⇒ 被提升成 GO 之后照样开。 /// /// ⛑ 客户端**有实现**(GameSceneToClientImpl override,dump.cs:185152 → /// BaseVehicle.SetAutonomousDrivingState),且 VehicleAutonomousDrivingModule /// 是每台车的模块、非玩家车专属 ⇒ 车流车也能用。 /// ⛑ **没有**给自驾设目标点的 S2C(唯一带终点的 SyncVehicleStartAI 68915975 客户端无 override) /// ⇒ 开完之后车往哪开由客户端决定,**未知** —— 本开关就是为了实测这件事(2026-10-01 实验 ①)。 /// 可能结果:①车自己开起来了 ⇒ 车流可整体换管线;②原地不动(缺目标)⇒ 转注入器方案。 /// ⛑ 我们仍在照常推 Aether 车道插值 —— 若两者打架(车抽搐/瞬移),实机会看出来, /// 届时再加「自驾开启后停止推该车」的开关。 /// public bool TrafficAutonomousDrive { get; init; } = true; /// /// ★★ 2026-10-01「方案 A」:车流车改走 Cruise 驱动管线 —— 换掉整套服务端车道模拟。 /// /// 背景(两轮证伪后改道): /// ① 服务端通道证伪 —— SyncAetherAIVehicleForceGo(68832908) 与 /// SyncVehicleAutonomousDrivingState(68349264) 都发过了,实机照样 /// 「车一进镜头就变色停下、移出镜头又开」; /// ② 客户端 hook 证伪 —— 注入器 8 个探针(AetherVehicleViewManager 的 /// Update / UpdatePossibleSwitchGoVehicles / CheckShouldNotUseGo …)24 分钟 /// 全 0 命中 ⇒ 这个类根本没在用,按它设计的 hook 方案作废。 /// ⇒ 与其继续猜客户端,不如换一条我们已验证过的驱动管线: /// SyncAssignVehicleAITask(68549522) + VehicleCruiseParameters, /// 让客户端本地 AI 开车(出租车 / 玩家自驾都走的这条,已实机验证)。 /// /// 开启后每台车的出生流程变成: /// ① SyncAetherAIVehicleAddData 用 ControlType=2(RaidVehicle) + /// LaneHandle=-1(与 DetachTrafficVehicleFromLaneAsync 同款写法) /// ⇒ 关掉车道模拟,把它从 Aether 插值里摘出来; /// ② 发 Cruise 任务(目标点由我们的车道图算)+ SyncChangeVehicleController /// + 主动 ack Running(1)(不 ack 任务卡 Pending,车一动不动 —— 09-28 的坑); /// ③ 该车不再参与服务端每 tick 的车道推进与 LaneDatas 批次。 /// /// ⛑ 关键未知量:Cruise 能不能驱动空车。出租车/自驾都是车里有人,空车没验过。 /// 若开起来车原地不动,两个方向:① 同时开 /// (给它一个"本地司机"状态);② 关 /// (别再跟"提升成 GO"对着干,GO 才是本地 AI 的正常工作形态)。 /// /// ⛑ 代价:服务端拿不到这些车的实际位置(客户端不上报 AI 车的坐标), /// 只能用「上一个目标点」当估算位置 ⇒ 回收/避撞判定是近似的。 /// 回滚:设 false 即回到原来的服务端车道模拟。 /// public bool TrafficCruiseDrive { get; init; } = true; /// /// 下每次规划要走多少段车道(PlanRoute 的 hop 数)。 /// 段数越多 ⇒ 目标点越远 ⇒ 车一口气能开越久(不用等刷新)。太大则目标点可能落在 /// 玩家看不见的远处、车提前被按估算位置回收。12 段 ≈ 数百米。 /// public int TrafficCruiseHops { get; init; } = 12; /// /// 下重新下发目标点的间隔(秒)。 /// 车到达终点后会停住等下一次刷新;但服务端不知道它到了没有(无位置上报), /// 所以只能定时重发:旧 token 先 Abort(4),再从「上一个目标点所在的车道」往后规划。 /// 车没到就重发 ⇒ 新目标仍在它前方,等价续驶;车到了 ⇒ 新目标在它脚下前方,正常续驶。 /// public float TrafficCruiseRefreshSec { get; init; } = 30f; /// /// 下单台车的**强制寿命上限**(秒)。 /// ⛑ 为什么需要:Cruise 车的位置是估算的,按距离回收会不准;不加寿命上限的话 /// 车可能永远达不到回收条件,玩家身边渐渐只剩一批老车、新车补不进来。 /// public float TrafficCruiseMaxLifeSec { get; init; } = 150f; /// /// 下:true = 只把最后一个点当目标发给客户端, /// 让客户端 C# 的 NavigationHelper 自己沿真实路网导航;false = 把整串车道终点都喂进去。 /// /// ⛑ 为什么默认 true:TargetPointList 是逐点直线的,而我们这份车道几何是 /// 「弦」不是样条 —— 整串喂进去会在弯道上切内弯(压马路牙子/穿绿化带)。 /// 只给终点时客户端必须自己做寻路,走的才是真实道路。 /// (与自驾的 cruiseSingleTarget 同一条结论,09-30 已实机验证。) /// public bool TrafficCruiseSingleTarget { get; init; } = true; /// /// 下:**快到终点就提前续发**的提前量(米)。 /// /// ⛑ 为什么需要(2026-10-02 01:4x 用户反馈「一条路上两辆车挤在一起开」的根因链): /// Cruise 的 CruiseType=0(GoToOneTarget) + ConfigFlags=4 会让车**到点就刹停**, /// 然后等我们续发下一个目标 —— 哪怕「收到 status=2 立刻续发」,中间仍有百毫秒级的空窗, /// 车是**停在大马路上的**。别的车开过来 ⇒ 顶住它 ⇒ 客户端 AI 倒车脱困 ⇒ 越堵越死。 /// 有了提前量,车在离终点还有这么远时就拿到新目标 ⇒ 全程不停。 /// ⛑ 太大 ⇒ 车还没跑到就改道(走不到目的地,观感随机);太小 ⇒ 还是会停一下。80m ≈ 3 秒车程。 /// public float TrafficCruiseLeadMeters { get; init; } = 80f; /// /// 下:新目标点与**场上其它车的目标点**的最小间距(米)。 /// 0 = 不检查。用来避免两台车被指到同一个路口 ⇒ 挤在一起。 /// public float TrafficCruiseTargetMinGapMeters { get; init; } = 80f; /// /// 车流出生时,**朝着玩家开**的那批车占多少比例(0~1)。默认 0.5 = 一半迎面、一半同向。 /// /// ⛑⛑ 为什么必须显式控这个比例(2026-10-02 01:4x 用户反馈「对面车道的车很少」): /// 实测服务端下发方向**本来是均匀的**(六个方向桶 13%~25%,各方向寿命也一样), /// 但车流按「距玩家 TrafficDespawnRadiusMeters 内」存活 —— /// · 朝玩家开的车:从 220m 外开进来、擦身而过、很快出圈 ⇒ **在场时间极短**; /// · 背对玩家开的车:一直在圈里 ⇒ 越积越多。 /// ⇒ 玩家一旦移动(实测日志里玩家沿 +X 走了 3km),稳态下看到的几乎全是同向车。 /// 这是**生存偏差**,不是随机数偏了 ⇒ 只能靠「强制一半的车朝玩家开」来补。 /// public float TrafficSpawnFacePlayerRatio { get; init; } = 0.5f; /// /// 出生时**避开已经有车的车道**(2026-10-02 01:5x)。 /// /// ⛑⛑ 为什么光靠 `TrafficSpawnMinGapMeters`(距离) 不够(用户原话): /// 「两辆车开**一条车道**,然后**并列**在一起挤着开」。 /// 距离判定读的是 Cruise 车的**推算位置**(dead reckoning),而车实际可能比推算慢很多 /// (被挡/等灯/减速),推算值领先 ⇒ 距离判定形同虚设 ⇒ 两台车生在同一条车道、几乎同一个点 /// ⇒ 客户端 AI 各走各的,谁也不让谁 ⇒ 并排贴着开、车身互相穿模。 /// /// ⇒ 补一条**车道级**判据:候选车道只要已经有车(车的出生车道)就换一条。 /// ⛑ 车的 `Drive.Lane` 是出生车道且不再更新,所以这是「这条车道最近有车占着」的近似 —— /// 偏保守(车道会一直占到那台车被回收),但方向上正是我们要的「错开」。 /// 找不到空车道时会自动去掉这条约束再来一轮,不会因此刷不出车。 /// public bool TrafficSpawnAvoidSameLane { get; init; } = true; /// /// 下:每台车巡航速度的随机抖动比例(0~0.5)。 /// 默认 0.15 = 每台车在 0.85~1.15 倍之间取一个固定倍率(出生时定下来,全程不变)。 /// /// ⛑ 为什么需要:所有车同一个速度 ⇒ 一旦两台车挨上就会**永远并排贴着开**(相对速度 0, /// 谁也甩不开谁)。给了速度差,快车会自然拉开,慢车被落到后面 ⇒ 车距自己就出来了。 /// ⛑ 用「每台车固定倍率」而不是每次下发随机:否则同一台车忽快忽慢,观感像抽搐。 /// public float TrafficCruiseSpeedJitter { get; init; } = 0.15f; /// /// 下:巡航速度的绝对上限(km/h)。0 = 不限。 /// /// ⛑⛑ 为什么光有 不够(2026-10-02 03:0x): /// 车道限速来自 `vehicle_lanes.csv` 第 9 列,实测分布是 /// **9.72 m/s(35km/h) 占 95.5%**、**16.67 m/s(60km/h) 占 4.5%**(主干道/快速路)。 /// 倍率是一视同仁地乘 ⇒ 主干道那批车会跑到 60×1.15 = **69 km/h**, /// 而城市街道那批是 40 km/h。用户反馈「车流太快」主要就是这 4.5%。 /// ⇒ 有了绝对上限,主干道被压回城市速度,快慢路仍有区分但都不会飞。 /// /// ⛑ 还有个坑:MaxSpeed 是**出生时**按出生车道限速算一次、之后续发一直沿用 /// (车开到限速不同的路上也不变)。上限对这个坑同样有效 —— 不管生在哪种车道都被压住。 /// public float TrafficCruiseMaxSpeedKmh { get; init; } = 35f; /// /// 下:一次目标至少要规划出多少段车道,少于这个数就判 /// 「这条车道是个死路口袋」⇒ 换一条重新规划( 之外的新判据)。 /// /// ⛑⛑ 为什么必须有它(2026-10-01 23:2x 实机根因):车开完第一次的目标后,下一次从 /// 「上次目标所在的车道」往后规划 —— 而那条车道常常是**死路**(我们路网里 286 个孤岛、 /// 大量车道没有合格后继)⇒ 日志里刷出一串 `points=1/1`,且**每台车每次都拿到同一个目标** /// (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。 /// 而且多台车撞在同一个目标点上 ⇒ 后车顶住已停的前车、客户端 AI 做倒车脱困后又停。 /// public int TrafficCruiseMinHops { get; init; } = 4; /// 新车出生点与已有车辆的最小间距(米),避免「刚生成就贴上」。 public float TrafficSpawnMinGapMeters { get; init; } = 25f; // ----------------------------------------------------------------------------------------- // ★★ 路口相位 / 红绿灯(2026-10-02 从捐赠端 PrivateServerPor 移植)—— 让灯柱真的亮起来。 // // 背景:主线以前从没给客户端喂过路口数据(init 的 Intersections 恒为 `[]`、 // 运行期相位包 `SyncAetherAIIntersectionUpdateDa`(68819947) 一次都没发过) // ⇒ 客户端 `AetherIntersectionManager` 建不出 IntersectionDataEntity ⇒ 不 RegisterTrafficLight // ⇒ **灯柱从来没被接管**。`trafficSignalEnabled` 那套只是服务端自己压车速,跟灯柱无关。 // 详见 `modules/红绿灯与路口相位.md`。 // // ⛑ 这三个开关互相独立,回滚粒度: // 只想关灯 ⇒ `intersectionUpdateEnabled=false`(init 仍带路口表); // 全退 ⇒ 两个都 false 且 `initVariants` 回到 `["top-int7"]`。 // ----------------------------------------------------------------------------------------- /// /// SyncAetherAIInitDatas(68704070) 的 Intersections 里带上真路口表(171 个)。 /// 需要配合 initVariants 里选一个 -donor 变体(见 GameAdminBridge)。 /// public bool IntersectionsEnabled { get; init; } = true; /// /// 登录后给每个路口推一次 SyncAetherAIIntersectionUpdateDa(68819947), /// 并 thereafter 每 切一档相位。 /// ★ 这是「灯真的会变色」的那一条;关掉只留静态初始相位。 /// public bool IntersectionUpdateEnabled { get; init; } = true; /// /// 68819947 包里 IntersectionIndex 取什么: /// "zoneIndex"(默认,= CSV 第一列,客户端用它对 IntersectionDataEntity)或 /// "listIndex"(路口在表里第几行,仅当 zoneIndex 对不上时作为对照试)。 /// public string IntersectionIndexSource { get; init; } = "zoneIndex"; /// /// ⛑⛑⛑ **默认 false —— 2026-10-03 14:0x 用客户端报错日志证伪了这个方向,别再打开。** /// 当时以为:客户端拿 ZoneIndex 查 _zoneIndexToBlobIndex,而主线 /// data/intersections.csv 的第二列(59 ⇒ 184)才是"真 zone index", /// 捐赠端把 ZoneIndex 填成 InstanceId 是错的。 /// 实测打脸(客户端 AetherIntersectionManager.BuildIntersectionData 的 /// cannot find static zone info 报错,**171 个路口报了 132 个**): /// · 报错的 132 个 zone 值里,属于 InstanceId 集合的 **0 个**; /// · 没报错的 38 个 zone 值里,属于 InstanceId 集合的 **38/38**。 /// ⇒ 客户端这份 blob 的"zone"就是**以 intersection instance id 为键**的 171 条 /// (blobZoneCount: 171,值域稀疏:111/401/1100/1631 这种都合法), /// 所以 ZoneIndex **就该填 InstanceId**,捐赠端原写法是对的。 /// 主线那张表的第二列(184)确实**是** zonegraph 的 zone index(转储 /// 03_ZONEGRAPHS/full_json/vfc_1286_e108_*.json 的 zones 下标), /// 但那不是客户端路口 blob 用的键 —— 两个"zone index"不是一回事。 /// ⛑ 142/171 个路口有映射(29 个查不到 ⇒ 回退成 InstanceId,恰好是对的)。 /// public bool IntersectionUseTrueZoneIndex { get; init; } = false; /// 相位切换周期(秒)。捐赠端实测值 12。太小 ⇒ 灯闪得乱、车刚起步就红灯。 public double IntersectionPeriodSeconds { get; init; } = 12.0; /// /// 一次最多把多少个路口纳入相位推送(0 = 全部 171 个)。 /// 调小可减少包量(全量是 171 包/周期);按距玩家远近排,**近的优先**。 /// public int IntersectionMaxCount { get; init; } = 0; /// /// 只推距玩家这个半径内的路口(米,0 = 不限)。0 时行为与捐赠端一致(全量)。 /// ⛑ 玩家走远后新路口要靠下一轮补推 —— 循环每 5s 重扫一次,所以设了也不会漏。 /// public float IntersectionRadiusMeters { get; init; } = 0f; /// /// 车流车型池:跳过带 RoofMod(车顶改装件:天窗 / 玻璃顶 / 顶灯)的车型。 /// 排除 7 个车型(81001002/003/008、81003001/004/006/008)。 /// /// ⛑⛑ 2026-09-29 用户更正:本开关的**立论是错的**,现在默认关闭,仅作历史开关保留。 /// 原文写「这 7 个车型的天窗/玻璃会飘在空中」—— 实测不成立: /// · **没有**车顶件的车,玻璃同样会浮空; /// · **有**车顶件的车,也有完全正常的; /// · 而且只发生在**一部分**车上,不是每台。 /// ⇒ 车顶件与「玻璃浮空」之间**没有因果关系**,把它排除掉只是白白少 7 种车型。 /// 真正的嫌疑是 (玻璃是 VehicleGlassDamageModule /// 的独立可破坏挂件,只有完整装配管线才会更新它;Aether 车道模拟只更新车辆 root ⇒ /// 玻璃留在建车时的高度),以及 / /// 这两条同源管线。 /// public bool TrafficSkipRoofModVehicles { get; init; } = false; /// /// 车流车建实体时的 VehicleCreateSourceType(0=Invalid / 1=Task / 2=Summon / 5=AetherAIBorrowVehicle)。 /// ★★ 这是「车玻璃 / 车窗浮在车身上方」的第一嫌疑。 /// 2(Summon) 是「玩家召唤的车」:客户端对它走**完整装配**管线,包含 /// VehicleDamageModule → VehicleGlassDamageModule(玻璃是独立的可破坏挂件: /// glassMR / brokenGlassMR / triggerTrans / rangeTrans,见 dump.cs TypeDefIndex 3995)。 /// 而车流车的位移由 Aether 车道模拟驱动 —— 那条线**只更新车辆 root**,玻璃那套挂件建好后 /// 没人再更新 ⇒ 留在建车时的高度,看起来就是「玻璃浮在车上方」。 /// 捐赠端(PrivateServerPor) 车流车恒用 **0(Invalid)**,走最简 Aether 车路径,没有这套装配, /// 所以它没有这个现象 —— 这就是双端差异的实质。 /// ⛑ 只影响车流车;GM「生成载具」的车仍走 Vehicle.cs 自己的 sourceType。 /// public int TrafficCreateSourceType { get; init; } = 0; /// /// 车流车出生后是否补发 SyncVehicleBrokenCollision(当前/最大 HP)。 /// ★ 第二嫌疑:这个包喂的是车辆损伤系统(TotalBrokenCollisionImpulse / 损伤模块 state), /// 与玻璃挂件同一条管线。当初是为「可骇入」加的(CanHack() 要求 CurrentHp > 0)。 /// 关掉 ⇒ 车流车不能骇入;只作为定位用的二分开关,验完请改回 true。 /// public bool TrafficSendBrokenCollision { get; init; } = true; /// /// 车流车 SyncSpawnVehicle 的 DisableNavigation。 /// ★ 第三嫌疑:我方一直写 true(「位置由车道流决定,别让客户端自己导航」), /// 但捐赠端写 **false**。若客户端的导航同时负责贴地,关掉它可能让车身与玻璃高度不一致。 /// public bool TrafficDisableNavigation { get; init; } = false; /// /// 车流车 SyncSpawnVehicle 的 SeatInfos 是否留空(对齐捐赠端)。 /// 捐赠端车流车 SeatInfos = [];我方按车型填 N 个空座位(SeatState=0=Empty)。 /// 座位挂点同样是「建车时摆一次」的物件,留空可排除它作为浮空源的嫌疑。 /// ⛑ 留空可能让「上车」交互找不到座位,验完请改回 false。 /// public bool TrafficEmptySeatInfos { get; init; } = false; /// /// 车流车型池的车长下限(米)。低于此值的摩托 / 三轮(SHIKAGE·Aero 2.1m、M125-T 2.96m)会被排除。 /// 0 = 不限制。 /// public float TrafficMinVehicleLengthMeters { get; init; } = 3.0f; /// /// 车流车型池的车长上限(米)。高于此值的客车 / 卡车(MM·M300 13.9m、Transporter 8.5m)会被排除。 /// 0 = 不限制。 /// public float TrafficMaxVehicleLengthMeters { get; init; } = 7.0f; /// /// 行走速度(m/s)。 /// ⛑️ 只在 "crowd" 通道有效 —— ClientCrowdInitData 自带 DesiredSpeed 字段(donor 用 1.4)。 /// "static" 通道下 ClientStaticNpcInitData **没有**这个字段,只能靠 /// SyncChangeNpcMoveDesiredSpeed(68739456) 补,但**该 RPC 在客户端是空 stub** /// (dump 里只有 virtual 0x9148360,没有 override)⇒ 发了也没用。 /// public float AmbientPedsDesiredSpeed { get; init; } = 1.6f; /// /// 是否发 SyncChangeNpcMoveDesiredSpeed(68739456)。默认 false —— 客户端是空 stub, /// 留着只是给未来客户端版本/对照实验用。 /// public bool AmbientPedsSendDesiredSpeed { get; init; } = false; /// /// "crowd" 通道的 AgentPersonaConfig id。donor_lyco082 实测 45200091。 /// ⛑️ NpcFormworkId 必须是 AgentConfig id(4013xxxx),填成 persona id 客户端会打 /// `创建AgentUnit失败!AgentConfigId为0!` 并丢掉这个人。 /// public uint AmbientPedsCrowdPersonaId { get; init; } = 45200091; /// /// "crowd" 通道的 ZoneGraphTargetLocationReason(1 字节)。dump 权威枚举: /// 0=None 1=Wander 2=SmartObject 3=Escape 4=StaticNpcReturn /// 5=QuickRun 6=VehicleLanePathWander 7=VehicleLanePathQuickRun /// ⛑️⛑️ **默认改成 1(Wander)** —— 之前填 0(None) 就是"人出现但不走"的直接原因: /// 等于告诉客户端"这个人没有目标位置意图"。donor_lyco082 抄的是 0,所以他们也没走起来。 /// public byte AmbientPedsTargetLocationReason { get; init; } = 1; /// /// "crowd" 通道的 ClientCrowdInitData.ActionId(ushort)。 /// ★★★ donor1 第 38 轮实测语义:这是**行为动作组(ActionGroup)**,不是 POI 动作 id —— /// KNOWLEDGEBASE 证实它索引的是另一张 C# crowd-action 表。 /// donor1 面板实测值:**400 = NPC 默认** / 410 = 同伴 / 23 = 宠物 / 0 = 非法(路人发 0 站着)。 /// ⛑️ 之前填 1(当 POI Walk 用)是误读;09-20 起 400(与 donor1 成功配方 commandActionId=400 一致)。 /// public ushort AmbientPedsCrowdActionId { get; init; } = 400; /// /// "static" 通道的 CPoiActionId(= AgentSyncClientInfo.initPoiActionId)。 /// 同上:1=走路 / 2=idle / 4=跑步。填 0 ⇒ 无动作 ⇒ 站着。 /// ⛑️ 非 0 时会额外发 SyncAgentPlayPOIAction(id, poi, IsActive=true, speed=1)。 /// ⚠️ 实测:`UrbanDiversityPOIActionConfig` 里 id=1(走路) 的 **ApplyRootMotion=False** /// ⇒ POI 动作只是"走的动画状态",**本身不带位移**。位移必须靠移动系统。 /// public uint AmbientPedsPoiActionId { get; init; } = 1; /// /// ⛑️⛑️ "crowd" 通道建完人后**必须补发 SyncManagedLogicAgent(68771737)**。 /// client 端没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。 /// ★★★ donor1 第 81 轮(三重证据):它同时是**客户端来要路径(AskClientNpcFindPath)的前提** —— /// 不发它,状态机关掉、客户端不来问,服务端推多少移动任务都没人接 /// (成功会话 12 条 vs 失败会话 2 条、AskClientNpcFindPath 310 次 vs 0 次)。 /// 2026-09-20:切到 crowd 后 NPC 全 T-pose,就是这个包没发。 /// public bool AmbientPedsCrowdManagedLogicAgent { get; init; } = true; /// /// `AskClientNpcFindPath`(67974714) 是否用真实路网作答。 /// 关闭 ⇒ 回空路径(`FF 01 FF 01`),也就是这条包落地之前的行为。A/B / 回滚开关。 /// public bool NpcPathPlanEnabled { get; init; } = true; /// 一条路径最多给多少个点(donor 的 NpcPathAskMaxPoints)。 public int NpcPathMaxPoints { get; init; } = 64; /// 重采样最小点间距(米,donor 的 MinMetres)。 public float NpcPathMinMetres { get; init; } = 1.5f; /// 规划不出路时,是否改给「最近车道上的脱困点」(1 个点)而不是回空路径。 /// 空路径会让客户端原地站着不动(spawn 后的 From==To、卡在花坛/门框里的单位都只这样问)。 public bool NpcPathEscapeEnabled { get; init; } = true; /// 脱困点沿车道前进的距离(米)。太小挪不出花坛,太大可能跨到别的路口。 public float NpcPathEscapeMetres { get; init; } = 5f; /// /// "crowd" 通道建完人后补发 SyncAgentPlayPOIAction(id, ActionId, active, 1f)。 /// ClientCrowdInitData.ActionId 只是初始值,显式播一次更稳。 /// public bool AmbientPedsCrowdPlayPoiAction { get; init; } = true; /// /// 生成后发 SyncAgentForceGo(id, true) = 68698324(客户端有真实现 0x12753C90)。 /// 语义就是"已生成但站着不动的 agent 用它启动行为树" —— 正对"人出现但不走"。 /// public bool AmbientPedsForceGo { get; init; } = true; /// /// MoveWanderingData.pathTags(NavTag 位掩码)。 /// ⛑️⛑️ 填 0 = `NavTagType.None` = 没有任何"可走区域" ⇒ 寻路必然失败, /// `WanderingActionModule.wanderFailCount` 累到 MaxWanderFailCount(10) 就**永久站住**。 /// 6 = WalkableAndJump(最宽松)。 /// public uint AmbientPedsWanderPathTags { get; init; } = 6; /// /// 游荡扇形窗口(度),对应客户端 WanderingAreaDate.{leftAngle,rightAngle}。 /// ⛑️ 两个都填 0 = 零宽扇形 ⇒ 选不出目标。-180/180 = 全向。 /// public float AmbientPedsWanderLeftAngle { get; init; } = -180f; public float AmbientPedsWanderRightAngle { get; init; } = 180f; /// 单次游荡最短/最远位移(米)。donor 人流用 8..35;固定 NPC 用 0..15。 public float AmbientPedsWanderMinDis { get; init; } = 8f; public float AmbientPedsWanderMaxDis { get; init; } = 35f; /// /// 游荡预算(秒)。MoveWanderingData.MaxTime 是**总时长**,走完客户端就永久站住, /// 所以必须在耗尽前重发。donor:人流 150,固定 NPC 50。 /// public float AmbientPedsWanderSeconds { get; init; } = 150f; /// 提前多少秒重发游荡(要 < WanderSeconds)。填 0 = 不续期。 public float AmbientPedsWanderRearmSeconds { get; init; } = 140f; } /// /// 车辆改装店(ApplyCar / RepairShop)2026-09-25 移植自俄语上游 donor。 /// 回滚 = enabled:false ⇒ AskTeleport 不再 echo、两个 waypoint 只 ack 不推位移、 /// 不再补推 SyncStartSummonVehicle(即逐字节回到移植前的行为)。 /// public sealed class RepairShopSettings { public bool Enabled { get; init; } = true; /// 生成/召唤后补推 SyncStartSummonVehicle(68681875) —— 没有它, /// 客户端 ApplyCarManager.IsMySummonVehicle 不认这辆车, /// 门禁第一关就弹「只能驾驶自己的载具进入」(查全流程找不到别的原因,donor 踩出来的)。 public bool PushSummonStart { get; init; } = true; /// 离场落地点的街道微调(作者给定的 LeaveWaypoint 已经离入口触发区 25m+, /// 正常情况下不需要动;真出现「出门立刻又被拽回店」时再调)。 public float ExitOffsetX { get; init; } = 0f; public float ExitOffsetZ { get; init; } = 0f; /// /// 买过的改装件是否落盘(save/vehicles/<pid>.json)。 /// 默认 false = 与 donor 未接 store 时一致:重启即清空。 /// 想让改装结果跨重启保留就置 true(读取侧在进世界时做)。 /// public bool PersistTuning { get; init; } = false; /// /// 刷车包(SyncLogicVehicleEnter / SyncSpawnVehicle)里要不要带纯数值件 /// (PartTag 13 引擎 / 20 刹车数值 / 21 传动 / 22 车身数值)。 /// /// 默认 false,理由(donor 2026-09-23 实机):这四类件在 VehiclePartConfig 里 /// 没有 PartPath,只有 PartPerfID;把它们混进 Parts 会让真车装配把对应槽位 /// 冲空 ⇒ 全黑 / 透明 / 不可驾驶的车(81005006 带 engine 9995002 + transmission /// 9995404 + body_value 9995602 复现过)。 /// /// 但它们又是唯一的数值通道(客户端本地根本不应用它们,只认服务端的回声), /// 所以留这个开关做实机 A/B:置 true 且车变黑 ⇒ 立刻改回 false。 /// public bool SendStatParts { get; init; } = false; } public sealed class VehicleSettings { public bool Enabled { get; init; } = true; public uint[] FleetIds { get; init; } = []; public float DesiredApproachMeters { get; init; } = 32f; public float MinimumApproachMeters { get; init; } = 24f; public float SearchRadiusMeters { get; init; } = 80f; public int ZoneStorageDataHandle { get; init; } = -1044702623; /// /// 玩家 F1 / 手机叫车(AskSummonVehicle 67012447)时,车不要在脚下凭空出现, /// 而是先刷在附近车道上,再自己开过来(2026-10-03)。配置见 。 /// public SummonDriveInSettings SummonDriveIn { get; init; } = new(); } /// /// 「召唤载具从车道开过来」(2026-10-03)。 /// /// 原生设计确实有这一半:SummonVehicleResult 回执带 TaskToken,客户端 /// SummonVehicleManager 拿它去 RegisterTaskAIStartedEvent(taskToken), /// 命中才走 OnSummonShowStart(TaskBase) 演出(补丁和工具/dump.cs:429056-429086); /// 对称的「车开走回收」在 SummonVehicleRule.vehicleRecycleTime/Range(dump.cs:427762)。 /// ⇒ 官方就是要「车有来有走」。我们以前(和捐赠端 v0.6 一样)直接把车放在玩家右手 5.5m, /// 那条 token 从没被用过。 /// /// ⛑ 改的是生成位置 + 补一个到达任务,管线全部是已实机验证过的那几条: /// SyncAetherAIChangeVehicleControl(2)(出租车血泪:不切回 Raid 就是「轮胎转、车原地」)、 /// SyncVehicleAutonomousDrivingState、SyncAssignVehicleAITask + Cruise。 /// /// 回滚:enabled=false ⇒ 逐字节回到「刷在身边 5.5m」的旧行为,不需要重编译。 /// public sealed class SummonDriveInSettings { /// /// 总开关。false = 车照旧刷在玩家右手 5.5m(2026-10-03 之前的行为)。 /// public bool Enabled { get; init; } = true; /// /// 生成点距玩家的最小距离(米)。太近就看不出「车开过来了」。 /// ⛑ 2026-10-03 18:1x 实机:40–80m 用户反馈「太远了」⇒ 调到 **20–45m**(35km/h ≈ 2–4.5 秒到)。 /// ⛑ 别调太小:低于 ~15m 时车基本是「凭空出现在眼前」,演出就没意义了。 /// public float MinDistanceMeters { get; init; } = 20f; /// 生成点距玩家的最大距离(米)。超出这个距离玩家看不到车开来,只会觉得「叫了不来」。 public float MaxDistanceMeters { get; init; } = 45f; /// /// 停车点(终点)吸附半径(米)。终点取车道上离玩家最近的点——靠边停车,玩家走过去上车。 /// ⛑ 不用玩家脚下当终点:自定义标点吸附失败时客户端一条 Cruise 都不发(2026-09-30 血泪)。 /// public float StopSnapMeters { get; init; } = 60f; /// 开过来的速度(km/h)。市区 35 够看;太快会在我们这份「弦」几何上切弯。 public float SpeedKmh { get; init; } = 35f; /// /// ★★ 允许的「弦偏离真实车道」横向距离(米),**0 = 不过滤**(2026-10-03 之前的行为)。 /// /// 背景:车道 CSV(`vehicle_lanes.csv`)只给了车道弦的两个端点,真实车道是曲线, /// 客户端按 `DistanceAlongLane` 在它本地的曲线上插值。我们沿直线弦取生成点/停车点, /// 弯道时弦中点完全在路面之外 —— 实测一条 70m 弯车道(弦长 62.96m、弧长 70.02m) /// 弦中点偏离真实路面约 **10m** ⇒ 召唤车「卡在人行道 / 花坛里」。 /// /// 全量统计:14171 条车道弧弦比**中位数 1.072 / p90 1.242 / 最大 23.8**(约一半车道明显不直)。 /// /// 给正值(默认 1.5)后:偏离随弦参数 t 在两端趋 0,所以弯车道只会保留靠近端点的 /// 生成/停车位置。⛑ 太小会导致「附近没有合格车道」⇒ 退回旧路径(刷在身边 5.5m)。 /// public float MaxLateralDeviationMeters { get; init; } = 1.5f; /// /// 是否先用车道图 Dijkstra(SolveRoute)算一串点再下发。 /// 解不出路就退回单点(交给客户端 C# NavigationHelper 自己导航),不会失败。 /// public bool UseLaneRoute { get; init; } = true; /// /// 下发 Cruise 前先把车切成 RaidVehicle=2(SyncAetherAIChangeVehicleControl)。 /// ⛑ 出租车就是栽在这里:客户端把车切成 AetherVehicle=1 后,Cruise 的位移每帧被车道覆盖, /// 表现是「轮胎在转,车在原地进行」(GameRouter.Taxi.cs:147-166)。 /// 召唤车是 CreateSourceType=2 完整装配、有 VDE,一样会被 Aether 接管 ⇒ 这条不能关。 /// public bool ForceRaidControl { get; init; } = true; /// /// 找不到合格生成点 / 附近没车道(室内、地下、孤岛)时的兜底: /// true = 退回「刷在身边」;false = 直接回召唤失败(不要这么设,玩家会以为功能坏了)。 /// public bool FallbackToBesidePlayer { get; init; } = true; /// /// 车开到之后收尾:撤掉 Cruise 任务(AssignVehicleControl<VehicleStopParameters>) /// + 推 SyncVehicleAutonomousDrivingState(false)(照抄出租车的 StopTaxiTaskAsync)。 /// /// ⛑⛑ 这是 2026-10-03 实机三个症状的共同解药:客户端 /// SummonVehicleManager 是用 RegisterTaskAIDeletedEvent(token) 等任务被删除, /// 才走 OnSummonShowFinish 结束「召唤演出」。任务一直挂着 ⇒ /// ① 车还在 AI 手里,玩家方向盘无效(车开不动); /// ② 客户端认为这台车还在召唤流程中(下车后再也上不去); /// ③ 上一台车的演出没结束(「叫车小助手忙」,叫不了第二台)。 /// /// 关掉它 ⇒ 回到这三个症状(排障对照用)。 /// public bool EndTaskOnArrival { get; init; } = true; /// /// 车开到之后,把 SyncChangeVehicleController 的 controllerPid 还原成 0。 /// /// ⛑⛑ 这是「车开不动」的真凶(2026-10-03 18:4x):下发 Cruise 时我们照自驾的老规矩 /// 发了 SyncChangeVehicleController(controllerPid = Profile.PlayerPid) —— /// 那是必需的(不锁主控权,玩家的方向盘输入会盖掉 AI 任务,车根本开不过来)。 /// 但到达后没人还原 ⇒ 主控权一直挂在服务端 ⇒ 玩家坐进主驾也只算乘客, /// 踩油门没反应。以前召唤车能开,正因为这条路径从没发过这个包。 /// 实机佐证:18:38:55 坐 seat=0(主驾)一样开不动 ⇒ 不是座位问题。 /// /// 关掉它 ⇒ 回到「车开不动」(排障对照用)。 /// public bool ReleaseControlOnArrival { get; init; } = true; /// /// 车开到之后,服务端补一发 SyncTeleportVehicle(68088911, Reset=true),把车钉在 /// 它现在真正的位置(AskVehicleMove 上报的最新值)。 /// /// ⛑⛑ 为什么必须有(2026-10-03 实机):服务端在生成时下发过 /// SyncAetherAIVehicleAddData,里面写着生成点的坐标,而 /// LaneHandle=-1 ⇒ 这份 Aether 坐标永远不会被更新。车是被 Raid 通道的 /// Cruise 开过来的,客户端两套表示就此分叉:看得见的车在身边,Aether 里那台还在生成点。 /// 玩家一上车,客户端按 Aether 那份数据对齐 ⇒ **车瞬移回生成点**。 /// 位置取自 AskVehicleMove(AI 开车期间客户端每秒都在报),所以这一发在视觉上是 /// 「原地不动」,只是把两边强行对齐。 /// /// 关掉它 ⇒ 回到上车瞬移生成点的行为(排障对照用)。 /// public bool SyncPositionOnArrival { get; init; } = true; /// 每次召唤都打选点日志。排障时才开(正常玩的时候每叫一次车一条)。 public bool LogEverySummon { get; init; } = true; } /// /// 载具路线规划。默认全开是因为「有路线」不可能比「没路线」更糟 —— 客户端当时每秒问一次, /// 而返回形状本来就已被客户端接受(DefaultReturnCatalog 的 complex 677),我们只是把两个 /// 空列表填上真实点。**回滚 = Enabled=false**,不需要重编译。 /// public sealed class VehicleNavigationSettings { public bool Enabled { get; init; } = true; /// 把「车的位置」吸到最近车道的最大距离(米)。开车时车应该在路上,40m 已经很宽松。 public float StartSnapMeters { get; init; } = 40f; /// 把「目的地」吸到最近车道的最大距离(米)。目的地可能是个楼前的点,所以给到 120m。 public float TargetSnapMeters { get; init; } = 120f; /// 下行点集上限。这是一条 INVOKE 的回包,192 点 × 12B ≈ 2.3KB,够画满一条路线。 public int MaxPathPoints { get; init; } = 192; /// 丢掉间距小于该值的相邻点(米)。车道弦本身就有大量重合端点。 public float MinPointSpacing { get; init; } = 1f; /// Dijkstra 扩展上限。14171 条车道正常几百次就收敛,卡住说明图有问题。 public int MaxExpansions { get; init; } = 200_000; /// 是否填 CenterPoints。今天线上请求带的正是 NeedCenterPoints=true。 public bool IncludeCenterPoints { get; init; } = true; /// /// 点序翻转为「终点 → 车」。捐赠端探针断言过这是客户端要的顺序,我方**尚未实测**; /// 若导航线箭头反了(或自驾往反方向开),改成 false 即可。 /// public bool ReversePoints { get; init; } = true; /// /// 是否下发 S2C SyncVehicleAutonomousDrivingState(68349264)—— 也就是「车会不会自己开」。 /// 这是客户端自动驾驶的唯一开关(见 GameRouter.VehicleNav.cs 的取证注释): /// false ⇒ Start/Stop 照常回 Ok,但不推状态包,车永远不会动,等于回到 2026-09-27 之前的行为。 /// 回滚不需要重新编译。 /// public bool AutonomousDrivingEnabled { get; init; } = true; /// /// 巡航速度(米/秒)。写进 VehicleCruiseParameters.MaxSpeed / DefaultSpeed。 /// 12 m/s ≈ 43 km/h,市区够看;太快会在我们这份「弦」几何上切弯。 /// public float CruiseSpeed { get; init; } = 12f; /// 巡航路点上限。AI 任务包不宜过大,64 点够跑几百米的主干道。 public int CruiseMaxPoints { get; init; } = 64; /// 判定「到达终点」的距离(米)。太小车会在终点附近来回蹭。 public float CruiseArrivalMeters { get; init; } = 3f; /// /// 巡航任务的 DrivingFlags(客户端枚举 VehicleTaskDrivingFlags,定义见 /// ux_rpc/payload_types.dump.cs:193)。默认 16443 = 1|2|8|16|32|16384: /// /// 1 DFStopForCars 遇车停 /// 2 DFStopForPeds 遇人停 /// 8 DFSteerAroundStationaryCars 绕开停着的车 /// 16 DFSteerAroundPeds 绕开行人 /// 32 DFSteerAroundObjects 绕开物件 /// 128 DFStopAtLights 等红灯 /// 16384 DFAdjustCruiseSpeedBasedOnRoadSpeed 按道路限速收速 /// /// ⛑ 2026-09-30 之前这里只有 1(只留 DFStopForCars)⇒ 车不会绕开静止车辆/行人/ /// 物件,前面一停就顶死,也不等红灯。现在这组值取自捐赠端 v0.5-2 的 /// BuildAutonomousDriveTaskAtSpeed4229938(1|2|8|16|32|128|16384)。 /// ⛑⛑ **2026-10-02 去掉了 128(DFStopAtLights),默认从 16571 改成 16443。** /// 背景:当天把红绿灯点亮之后,客户端开始按灯拦车,表现是「一批车停在路口不动」。 /// 查证:主线 24 台车全是**客户端 Cruise AI 开**(服务端车流仿真 `n=0` 直接 return, /// `[TRAFFIC-STAT]` 一条都没有 ⇒ `trafficSignalEnabled` 那套根本没参与), /// 所以拦车的是客户端,而 `128` 正是 Cruise 任务的「等红灯」位。 /// 捐赠端同样是这个取向:它有 `aether.vehicleSignalStopEnabled`(默认 **false**)—— /// 「灯要亮,但车别真等」。⇒ 去掉 128 = 灯照常亮、车照常开。 /// 想让车重新等灯:把 128 加回来(16571)。 /// ⛑ 16384 会让道路限速盖掉 CruiseSpeed:GM 拉到 120 但路上限速 60 时车仍只跑 60。 /// 想让 GM 速度硬生效就把它去掉(59 = 1|2|8|16|32)。 /// 回滚:16571 = 等灯(点亮前的行为)/1 = 2026-09-30 之前的旧行为。 /// public int CruiseDrivingFlags { get; init; } = 16443; /// /// 只把终点(一个点)交给客户端的 Cruise 任务,而不是我们算出来的整串车道点。 /// /// 客户端 C# 侧自带导航(LX6.Drive.AI.NavigationHelper.SetTargetPosition(start, target) + /// GetNavigationResult(...)),捐赠端就是这么发的:CruiseType=GoToOneTarget + 单点。 /// ⛑ 我们这份车道几何是「弦」不是样条(_gen_vehicle_lanes.py 压掉了中间点),喂整串点会切内弯。 /// 回滚:改成 false ⇒ 回到「服务端下发最多 个点」的旧行为。 /// public bool CruiseSingleTarget { get; init; } = true; /// 每次请求都打日志(开车时 = 每秒一条)。排障时才开。 public bool LogEveryRequest { get; init; } } /// /// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30)。玩法本体全在客户端 /// (ToiletOstrichGamePanelStore.lua 的音游式三键 QTE:QTE_DURATION=2 / /// PERFECT_WINDOW=0.1 / 满分 100),服务端只做三件事:接住骑乘请求、接住失败上报、 /// 把坐骑生成成「能被骑」而不是「会打人」。 /// /// 马桶鸵鸟 = 标准 Agent(NpcCatalog 5 条同名,GeneralModelId=86963029, /// EnemyClassType=1,faction 18000300「通用怪物」),规范 id /// AgentConfig.ToiletOstrich = 42046401;它已在 AgentSummonCatalog.json 里, /// 并被置顶到面板「生成敌人」下拉框第一位 ⇒ **不需要新的生成通道、也不需要新的面板卡**。 /// public sealed class ToiletQteSettings { /// /// 总开关。默认 true:三个 handler 都在 Enabled4229938MethodIds 里, /// 关它只是「不回头推状态、不回收鸵鸟」,注册与日志都还在(保持可观测)。 /// 想彻底回到「什么都不做」:把 GameRouter.cs 里那三个 mid 从白名单删掉。 /// public bool Enabled { get; init; } = true; /// /// 收到 AskRideAgent 时是否先推一条 SyncRideAgent(rider, ridee, hasRideOn=true) /// (68906846)再回 ack。默认 true(2026-09-30 16:5x 实测后改过来)。 /// /// ★ 实测证据(日志 console-20260930-164759.log):默认 false 时 /// AskRideAgent 回了 0 字节 ⇒ 面板确实弹出来了 /// (客户端日志 sss_panel_detail: S_ToiletOstrichMiniGame),但 /// 没有驯服动画、骑乘后角色不能动、QTE 只出一个键就结束。 /// ⇒ 面板是客户端本地兜底 fire 的,骑乘状态机没被服务端推起来: /// 客户端 C# 的骑乘态由 SyncRideAgent 驱动(状态机 /// MuGenStates.Logic.Ride.ChangeRideState 的 /// rideState:0 未骑乘 / 1 准备上 / 2 上骑乘中 / 3 骑乘中 / 4 准备下 / 5 下骑乘中, /// 位移由 StartRideAgentMotion 负责 ⇒ 没进 3 就没有动画也不会动)。 /// /// ⛑ 顺序固定为「状态包 → ack」,别反过来(见 GameRouter.VehicleNav.cs 的 /// §43.1 包序教训)。下马同理:回收实体前先推一条 hasRideOn=false, /// 否则就是「马桶没了、人还坐在空中」。 /// public bool PushRideState { get; init; } = true; /// /// 骑乘时是否额外推 SyncAddUnitState:坐骑 345 = BeRiddenS(behavior 10345, /// 进入「被骑」移动状态)+ 骑手 346;下马时用 SyncRemoveUnitState 撤掉。 /// 默认 true。 /// /// ★ 这是「驯服动画」和「能骑着跑」的真正开关。出处是 v0.5-2 捐赠端 /// GameRouter.HackerDevices.cs:254 的注释: /// "UnitStateConfig 345 (BeRiddenS, behavior 10345) enters the mount's riding /// movement state; 346 marks the rider. An empty successful return alone does not /// publish either state." /// /// ⛑ 2026-09-30 19:2x 实测:只推 SyncRideAgent 不开这条 ⇒ /// 「QTE 正常,但没有驯服动画,成功也骑不起来」,坐骑从来没进 BeRiddenS。 /// /// 客户端侧佐证(说明 345 语义可信):LX6.Fight.UnitStateMgr.BeRiddenSStateChange、 /// MuGenStates.Logic.Agent.CheckBeRiddenState 都真实存在。 /// public bool PushUnitStates { get; init; } = true; /// /// 是否推 SyncToiletPosture(agentId, isSquatting=false)(68122107,蹲/起姿态)。 /// 默认 false。⛑ 这条在客户端是 CSharpOnly(lua 侧无 reader)⇒ 效果只能在画面上看, /// 别指望 lua 日志。官方对应的是 GM 入口 GmCreateToilet / GmCreateToiletSquatting,我们不走。 /// public bool PushPosture { get; init; } /// /// 收到 AskToiletOstrichFlee(QTE 失败)时是否把这只 unit 从世界里摘掉 /// (发 SyncLogicAgentLeave + 清敌人账本条目)。 /// 默认 false(2026-09-30 19:2x 改过来)。 /// /// 对齐 v0.5-2 捐赠端 GameRouter.ToiletQte.cs:44: /// "retaining the test mount for retry instead of destroying it" —— /// 失败只是下马,马桶留在原地可以再来一次。 /// /// ⛑ 即使这里是 false,解除骑乘(SyncRideAgent + 撤 345/346)照发; /// 这个开关只管「要不要把实体从世界里摘掉」。 /// ⛑⛑ 摘除前仍有一道硬门槛:只摘本服生成的 uid(见 /// GameRouter.ToiletQte.IsServerSpawnedUid)—— 2026-09-30 曾误删过场景自带的 /// uid=19000(hp=1500 的真实敌人)。 /// public bool RetireOnFlee { get; init; } /// /// 从面板生成马桶鸵鸟时是否走「坐骑」而不是「敌人」:engage=false(不登记进敌人账本、 /// 不下令开战)+ forceEnemyRelation=false(不把玩家和它显式标成敌对)。 /// 默认 true:它的用途是被骑 + 玩 QTE,带攻击 AI 的话玩家会先跟它打起来。 /// /// ⚠️ 它仍然在 AgentConfig 的 EnemyClassType=1 桶里,所以面板必须按 /// 「生成敌人」调(hostile=true),camp 也仍是 4 —— 这条只影响「要不要打」。 /// 回滚:改 false ⇒ 与普通敌人逐字节同路。 /// public bool PassiveSpawn { get; init; } = true; /// /// 骑上马桶后是否下发原生坐骑操控桥(加速 / 跳跃)—— 2026-10-02 新增, /// 自 7k-v0.6 捐赠端 GameRouter.ToiletControls.cs 逐字搬运,实现见 /// GameRouter.ToiletControls.cs。默认 true。 /// /// ★ 它做的事:把客户端 GameplayMountPanelStore 的 /// OnRushBtnPress/Release(加速)与 OnJumpBtnPress/Release(跳跃)等原生 HUD 回调 /// 直接转成 3C 事件发给已确认的挂载目标,绕过骑手自身的战斗 / 跑酷输入闸门 /// (骑着马桶时那批按键本来会被判定成人物动作)。 /// /// ⛑⛑ 预期管理:这只保证事件送达。动画、速度、跳跃物理仍然全是客户端原生的, /// 坐骑动画图里没有的过渡(比如马桶鸵鸟没有冲刺状态)送到了也不会动。 /// 捐赠端原话:"event delivery is NOT proof that an incomplete client animal graph /// implements every transition." /// /// ⛑ 客户端文件一个都不改,走的是已有 GATE 通道(SyncDoToLuaMaster 45437825)。 /// 回滚:这里改 false 即可(不再下发 Lua,骑乘状态机与 QTE 完全不受影响)。 /// public bool NativeControls { get; init; } = true; } /// /// 出租车(2026-09-30 新增,GameRouter.Taxi.cs)。 /// /// ★ 计价公式沿用捐赠端 TaxiFare4229938: /// Ceiling(BaseFare + Max(0, distance - FreeMeters) * FarePerMeter)。 /// /// ⛑ 目的地是配置 uint(TaxiNavigationConfig.json 的 Id,共 18 条), /// 不是车辆 uid;接单时会校验目的地的 RaidId 必须等于当前场景, /// 否则会把玩家往别的 raid 送。 /// /// ⛑ 服务端只做四件事:认单 → 派 Cruise → 判到达 → 收车费。 /// 选目的地的 UI、上车提示、到达演出全是客户端原生 TaxiSystemManager 的事。 /// /// /// 里希牢房(2026-09-30)。 /// /// ⛑ 坐标故意不做成配置项:门口 / 牢内那两个点是客户端原生素材 /// (Police_Prison.prefab 79100336 的 Gadget pathId 231849593、 /// PoliceConfig.ArrestNpcPosition[0]),改了就不是那个牢房了,见 /// GameRouter.PolicePrison.cs 的常量注释。真要挪位置得改代码里的常量。 /// public sealed class PolicePrisonSettings { /// 总开关。false ⇒ commit / release 直接返回失败提示。 public bool Enabled { get; init; } = true; /// /// 允许操作的半径(米)—— 玩家必须站在牢房门口这么近才能收押/带出。 /// 捐赠端是硬编码 4 m(distance <= 16 的平方比较)。 /// public float NearMeters { get; init; } = 4f; /// /// 是否要求玩家在主城(ActiveInstanceId == 20001222)。 /// 牢房是主城里的一块地,不在主城就不该有这个门;关掉可以方便调试。 /// public bool RequireNovaScene { get; init; } = true; } public sealed class TaxiSettings { /// 总开关。false = 四个出租车 handler 全部直接回失败码(注册仍在,日志仍在)。 public bool Enabled { get; init; } = true; /// 起步价(含 米)。捐赠端原值 300。 public double BaseFare { get; init; } = 300; /// 起步价包含的里程(米)。捐赠端原值 200。 public double FreeMeters { get; init; } = 200; /// 超出起步里程后每米单价。捐赠端原值 80 * 0.005 = 0.4。 public double FarePerMeter { get; init; } = 0.4; /// 正常速度(km/h)。捐赠端原值 15 m/s ≈ 54。 public float NormalSpeedKmh { get; init; } = 54; /// 「加速」后的速度(km/h)。捐赠端原值 25 m/s = 90。 public float AcceleratedSpeedKmh { get; init; } = 90; /// 判定「已到达」的距离阈值(米)。捐赠端原值 3(判到达用 4 m 平方=16)。 public float ArrivalMeters { get; init; } = 20; // 与捐赠端一致(20m);见 GameRouter.Taxi.cs /// /// 收不到位移上报多久算「卡住」(毫秒)。卡住不结束行程,只提示可换目的地/跳过。 /// 捐赠端原值 45000。 /// public int StalledAfterMs { get; init; } = 45000; /// 单趟最长时长(毫秒),超时强制结算。捐赠端原值 1200000(20 分钟)。 public int MaxTripMs { get; init; } = 1200000; } public sealed class SwitchAnimationSettings { public int SwitchType { get; init; } public string CommonTimelinePrefix { get; init; } = string.Empty; public bool AvoidImmediateRepeat { get; init; } /// /// 切人演出总开关。false = 回到原来的静默原地切人(只发身份移交,不碰 /// SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)。回滚不需要重新编译。 /// public bool Enabled { get; init; } /// /// ⛑⛑⛑ 为切人演出 spawn 演出演员(主演 + 陪衬)到落点,并把它们的 uid 放进 /// SyncSwitchSpiritConfigId 的 spawnedAgentIds。默认 true。 /// /// 官方语义(ClientConfigRepository.cs:862 引的注释): /// "Those rows carry AgentId entries that stock SwitchTeleport expects the server to spawn /// before landing." ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent 建好。 /// /// 这就是「切人演出里那个在演戏的人」:主演取演出行的 FightSpiritIds[0], /// 经 AgentIdForSpirit 映射(嘉姆 15020989→40140032、塔菲 15020992→40140035); /// 陪衬取行的 AgentIds。 /// ⛑ 与「主角单位换皮」无关 —— 换皮那个是给玩家操控的,这些是演戏的身体,演完撤离。 /// 设 false 即回到改动前(演出里依然只有道具和环境在动)。 /// public bool SpawnShowActors { get; set; } = true; /// /// 演完后的收尾握手三包:SyncPlayerSwitchSpiritInSitu + /// SyncManagedSpirit + SyncManagedAgent(顺序照官方)。 /// /// ⛑⛑ 2026-10-03:主线这三个包一次都没发过(NotifyAsync 各 0 处),而 CBT2 官方测试服务端 /// 的切人序列里有它们(`ClientToGameserver.cs:3142-3160`),后两个的原注释是 /// "explicitly transfer control (**fix: frozen after switch**)"。 /// 对应症状:演出中角色不显示(Timeline 的角色 track 拿不到 spiritId)、 /// 播完T-pose 几秒(控制权没交回新角色)。 /// 默认 true;设 false 即回到改动前的旧行为(一键回滚)。 /// public bool PushHandoffPackets { get; set; } = true; /// /// 演出开始之前就把目标角色投放到演出落点(SwitchSpiritConfig 行的 Position), /// 并在 SyncSwitchSpiritConfigId 的 spawnedAgentIds 里带上它。 /// 这是「镜头飞过去时远处真的站着角色」的关键:捐赠端从来不预生成,只在客户端报告演出播完后 /// 才在玩家原位置 materialize,于是镜头飞到行锚点看到的是空地,播完又被拉回原角色。 /// 关掉后仍会落在演出锚点,只是演出期间那里暂时没人(地形未流式加载时可作降级)。 /// public bool SpawnTargetBeforeShow { get; init; } = true; /// /// 等客户端 ReportPreSwitchSpiritFinish 的超时(毫秒)。超时后服务端自己完成移交, /// 但仍然落在演出锚点上(不会退回原位置),保证 roster 不会因为一次丢包永久卡在 pending。 /// public long PreSwitchTimeoutMs { get; init; } = 6000; /// /// 演出落点与玩家当前位置的距离上限(米)。0 = 不限制。 /// 63 个可用锚点里「最近锚点距离」中位数约 636 m、p90 约 1675 m,所以开启演出等于切完人 /// 被传送几百米(官方语义)。想减轻就填一个数字,超限时本次退化为静默原地切人。 /// public float MaxAnchorDistanceMeters { get; init; } /// /// 把 SyncSwitchSpiritConfigId 与 SyncPreSwitchSpirit 一起在 phase 1 发出。 /// 捐赠端注释:晚发会让客户端先退出切人控制器、再为落地 Timeline 重新进入 ⇒ /// 「切完人后还回去了,然后又回来了」的闪跳。 /// public bool PublishConfigIdEarly { get; init; } = true; /// /// ⛑⛑ 已证伪,**默认关闭**(2026-09-24 13:1x 实测)。留着只为记录,别再打开。 /// /// 症状:切完人自己头顶常驻 F(个人信息 / 动作 / 离开)。 /// 根因:客户端 IsMe 在建单位那一刻锁存,之后服务端改不了(Player.log): /// /// 13:08:47 InitModules 100000000000 IsMe True ← 登录 + "[LB] 进入场景 创建主角" /// 13:13:48 InitModules 100000000012 IsMe False ← 切人 + "[LB] 创建角色" /// 13:14:28 InitModules 100000000000 IsMe False ← 切回登录那个 unit,照样 False /// /// /// 两轮服务端修复全部无效,反证如下: /// ① 提前 / 重复发 SyncPlayerCurrentSpirit(isMeFix=reorder、recreate+300ms) /// —— 68830687 在 RPCDeserializeAuto.lua:1260 标了 LuaOnly, /// C# 侧根本不读这个包,改顺序纯属自欺。 /// ② 改 ManagedPid —— 判据被实测推翻:登录走 ExistingUnitProjection /// (RuntimePayloadFactory.cs:557 显式 ManagedPid = 0)却 True; /// 切人走 CharacterUnitProjection(同文件 :644 ManagedPid = Pid)却 False。 /// ③ 登录之所以 True,是客户端 native CreateHero 的功劳(跟着 SyncEnterScene 走), /// 纯服务端推包建不出「主角”。 /// /// 真修法在客户端侧:切完人调 /// InteractBtnMgr:RemoveBtnByType(pid, LX6.Interact.BtnTargetType.Unit) 摘掉按钮, /// 见 Drmk.Proxy/proxy/tools/switch_no_interact.lua(日志前缀 [DRMK-NOF])。 /// /// 副作用:开着时预生成的角色会在演出落地时被 Leave 掉再重建(+300ms 延迟), /// 画面上多一次闪烁,而 F 并不会消失 —— 纯亏。 /// public bool FixIsMeOnHandoff { get; init; } = false; /// /// ⛑⛑ 切人演出开始前预生成的那个目标单位,在移交控制权时先销毁再重建。 /// 默认开,关掉会让部分角色切过去走不动。 /// /// 预生成()建单位时发过 SyncManagedLogicAgent /// ⇒ 该 actor 在客户端被标成「服务端驱动」:本地移动被忽略、只等服务端喂 /// SyncLogicAgentSyncData,而我们从不给玩家喂位置。症状就是 /// 原地有跑步动画但坐标不动,攻击 / 开手机却正常(那些不走移动系统)。 /// 2026-09-24 14:2x 实测:把它当成「IsMe 修复的副作用」一起砍掉后立刻复现该症状。 /// /// ⛑ 它跟头顶的 F 是两件事:F 是 IsMe 锁存,服务端治不了,走客户端侧摘按钮 /// (switch_no_interact.lua)。别再把这两件事捆在一起。 /// public bool RecreatePreSpawnedTargetOnHandoff { get; init; } = true; /// /// 以主角身份重建目标角色时,SyncLogicAgentLeave 之后等多久再发建单位那一组(毫秒)。 /// 0 = 不等。客户端销毁单位是异步的,Leave 与重建挤在同一批包里会被合并掉 —— /// 12:30 实测就是连一条新的 InitModules 都没有(单位没被销毁)。 /// 仅在 打开时有意义。 /// public int RecreateDelayMs { get; init; } = 300; /// /// 切换角色时把旧角色**留在大世界里**(不发 SyncLogicAgentLeave), /// 记录到 WorldEntryState.LeftBehindCharacters。默认开。 /// 留下的角色是「服务端驱动」单位:站着待机、不移动、不吃玩家输入。 /// 关掉 = 回到旧行为(切走后旧角色立刻消失)。 /// public bool KeepLeftBehindCharacters { get; init; } = true; /// /// 切回一个「留在大世界里」的角色时,落点取**它留下时的位置**而不是 /// SwitchSpiritConfig 行的演出锚点(用户原话:下次切换直接切到那个角色的所在地)。 /// 演出照常播:configId 照常选(按离留下位置最近挑,镜头锚点≈角色所在), /// 只有落点坐标被覆盖。默认开;关掉 = 即使角色在大世界里也按演出锚点落地。 /// public bool ReturnToLeftBehindLocation { get; init; } = true; /// /// ★★ 第一次切某个角色(他还没被切走过、LeftBehindCharacters 里没有落点)时, /// 也把他自己的日程点位当落点 —— 而不是原地切换(09-26 00:4x 加,用户要的)。 /// /// ⛑ 为什么原来做不到:落点只有两个来源 —— 「这个角色上次被切走时留在哪」 /// 或「玩家现在站在哪」。LeftBehindCharacters 里没有该角色就直接原地切, /// 于是「有日程的角色明明在城里某个点,切过去却是在我脚下换皮」。 /// 现在补第三个来源:按「现在几点」查他自己的 /// AgentDataSetsActivityConfig(NpcActivityCatalog4229938.Resolve, /// 与常驻摆放 / 切人留场同一个裁决函数,不另写一套)。 /// /// ⛑ 查不到(两个主角 tag 2/3 没有时刻表、该时段活动没编坐标)⇒ 照旧原地切换,不会飞到 (0,0,0)。 /// ⛑ 朝向仍然无表可查(规则 171②)⇒ 沿用玩家切人时的朝向,想调就配 /// gameplay.rosterNpc.activityPoiOverrides[].facing(那条也作用于常驻摆放)。 /// public bool SwitchToScheduleSpot { get; init; } = true; /// /// 的距离上限(米)。0 = 不限(默认)。 /// 官方语义本来就是「切完人被传送几百米」(见 的注释: /// 锚点距离中位数约 636m),所以默认不限制;嫌远就填一个数字,超限时本次退化为原地切换。 /// public float SwitchToScheduleSpotMaxMeters { get; init; } /// /// 按角色分散演出落点:每个角色固定落到「自己的那一行」SwitchSpiritConfig /// (按 templateId 散列选行),而不是「离玩家最近的那行」。 /// 用户 2026-09-24 17:5x:最近锚点模式让所有角色都挤在咖啡馆/电影院门口那一两个点。 /// 默认开。关掉 = 回到最近锚点模式。仅在目标不在大世界(非留下角色)时生效; /// 该模式下 MaxAnchorDistanceMeters 不再限制(分散本来就意味着远)。 /// public bool DisperseAnchorsByCharacter { get; init; } = true; /// /// ⛑⛑⛑ 给「留在大世界里」的旧角色周期性回正位姿(SyncLogicAgentSyncData 68838628)。 /// 默认开,关掉的话留下的角色会掉出世界。 /// /// 2026-09-24 18:1x 实测(packets 日志):留下的角色客户端每 0.1~0.5 s 上报一次 /// AskReportLogicAgentSyncData,Y 从 -0.13 → -293 → -3000(约 60~90 m/s,自由落体)。 /// 根因:这些单位是靠 SyncManagedLogicAgent 标成「服务端驱动」的,客户端停用自己的 /// AI 之后只做物理模拟,而这类 actor 没有地面吸附 ⇒ 一直往下掉。 /// 后果不只是「人没了」:切回这种角色时拿到的是刚从虚空里捞回来的 actor —— 原地有跑步 /// 动画、朝向会转、坐标不动(用户报的「切换的角色都无法移动」)。 /// /// 修法:客户端每上报一次,就把 LeftBehindCharacters 里记的站立位姿回给它。 /// ⛑ 这个包驱动不了步态(donor1 第 39 轮 + 我们 09-20 实测已证伪:10Hz 推了 6280 条、 /// 字节布局逐字节对齐,人依然纹丝不动)—— 但「纹丝不动」正是我们要的效果。 /// public bool FeedLeftBehindPositions { get; init; } = true; /// /// 回正包里的 MoveId(BattleMoveActionType:0 Unknown / 1 Idle / 2 WalkFront / 6 Run)。 /// 站桩用 1(Idle);改成 2 也不会让它走起来(见 的证伪记录)。 /// public byte LeftBehindFeedMoveId { get; init; } = 1; /// 同一个角色两次回正之间的最小间隔(毫秒)。客户端上报是 10Hz 级的,别跟着刷屏。 public int LeftBehindFeedMinIntervalMs { get; init; } = 90; /// /// 切人演出前预生成的那个目标单位,也给它回一次位姿(钉在落点上,免得镜头飞过去的 /// 那 4~5 秒里它也在往下掉)。只发一次,代价可忽略。 /// public bool FeedPreSpawnedTarget { get; init; } = true; /// /// ⛑⛑⛑ 给「留在大世界里」的旧角色周期性发一条**原地路点任务** ///(SyncLogicAgentCommand 68050574 + wire tag 39 = TaskWayPointMove, /// points = [站立点, 站立点],距离 0)。默认开,**这才是止住自由落体的那一味药**。 /// /// 为什么光靠 不够:位置流是「每 N 毫秒硬拉一次」, /// 而下落是 60~90 m/s —— 就算 10Hz,两次拉回之间也已经掉了 6~9 m,视觉上是抽搐, /// 而且拉回只是改坐标,并没让客户端重新接管地面吸附。 /// /// 对照证据:街上那些 830… 开头的 NPC 同样带 SyncManagedLogicAgent, /// 上报 Y 常年稳定在 -0.13~0.01(packets 日志 130+ 个单位无一掉落), /// 差别就是它们一直被喂 TaskWayPointMove ⇒ locomotion 状态机接管 ⇒ 贴地。 /// ⇒ 留下的角色照这条机制发一条「原地任务」,到达即停,之后每 /// 毫秒补一条维持住。 /// public bool LeftBehindWaypointTask { get; init; } = true; /// 同一个留下的角色两次「原地路点任务」之间的间隔(毫秒)。太长会在间隔里重新开始掉。 public int LeftBehindWaypointIntervalMs { get; init; } = 2000; /// 原地路点任务的 Speed(m/s)。路程是 0,所以只影响停下前的那一瞬。 public float LeftBehindWaypointSpeed { get; init; } = 0.5f; /// /// 重建留下的旧角色时要不要发 SyncManagedLogicAgent(把它标成「服务端驱动」)。 /// 默认开:关掉会回到 r6 那版「旧角色 T-pose 站在新角色身边」(客户端销毁了 hero actor, /// 没有完整重建就只剩一个裸 actor)。 /// ⛑ 关掉它同时也等于绕开了自由落体(客户端继续用自己的 AI/地面吸附), /// 所以这是 万一不灵时的第一退路。 /// public bool ManagedForLeftBehind { get; init; } = true; /// /// ⛑⛑⛑ 重建留下的旧角色时补发 SyncAgentForceGo(68698324)(**启动行为树**)。 /// 默认开。这是 Ped 协议里我 09-24 21:5x 才补上的第 5 步 —— 之前只发了 /// Enter + Managed + projection,行为树从没启动过。 /// /// 为什么必须发:21:33 那场实测(packets-20260924-213352.log)证明 /// 和 /// **都没能止住下落** —— 000 的 Y 从 -0.01 一路掉到 -2999.99(21:37:25 → 21:38:16), /// 005 从 -0.08 掉到 -2999.99,全程服务端按 2s 间隔在喂路点任务 + 位姿回正。 /// 差别只有一条:街上那些不掉的路人(GameRouter.Ambient.SendAmbientCrowdPedAsync) /// 在建人之后还要发 SyncAgentForceGo 启动行为树,客户端才会接管 locomotion(贴地)。 /// 任务包是「派给行为树」的,树没起来 ⇒ 没人接 ⇒ 继续自由落体。 /// public bool LeftBehindForceGo { get; init; } = true; /// /// ⛑⛑⛑ 重建留下的旧角色时,要不要先发 SyncLogicAgentLeave 销毁它。 /// **默认 false(不发)** —— 2026-09-24 22:2x 实测:Leave 会把客户端的无缝切换状态机打崩。 /// /// 硬证据(客户端 Player.log,服务端会话 packets-20260924-220911.log): /// /// 22:20:40.291 InitModules 100000000012 IsMe False / [LB] 创建角色 100000000012 /// 22:20:41.728 loading_switch_moving_seamless 2 System.NullReferenceException /// …(每 16 ms 一条,197 条,全部集中在 22:20 这一刀) /// /// 同一场里能动的那一次(c=1,22:17)该报错是 0 条。报错刷屏之后新接管的角色 /// 就再也发不出移动包(24 秒里只有 2 条 AskUnitMoveActionSimple)。 /// ⇒ 元凶是「Leave 掉刚被玩家操控的那个单位」,不是 Managed 也不是 ForceGo。 /// /// 不发 Leave 时客户端自己已经销毁了旧 hero 的 actor(r6 观察),服务端这边直接 /// Enter + projection + PosFacing + POI + ForceGo 重建即可;ForceGo/POI 是 r13 才补的 /// Ped 第 4/5 步,正是它们替代了「销毁重建」在动画状态上的作用。 /// 打开它 = 回到 r7~r13 的老写法(会刷空引用)。 /// public bool LeftBehindRebuildLeave { get; init; } = false; /// /// 重建留下的旧角色时发的 POI 动作 id(SyncAgentPlayPOIAction 68459398 的 /// CPoiActionId)。Ped 那套里 1=走路;留下来的角色要**站着不动**, /// 所以默认填 17(站姿闲置 / 1001)。0 = 不发这个包。 /// ⛑ 别把「动作组号」(400) 填到这里 —— 那是 ClientCrowdInitData.ActionId 的表, /// 两张表(见 Ambient.cs 的 T0-9 注释)。 /// public uint LeftBehindPoiActionId { get; init; } = 17; /// /// ⛑⛑ 留下的角色「闲逛」:周期性用 PedLaneGraph.TryPlanJourney(挑一个**已经在人行道上**的 /// 终点 + 沿路网规划整条折线)给他派一段 TaskWayPointMove,让他真的走起来。 /// 默认 false(先把功能做出来,实机验过再开)。 /// /// 为什么这样就能走:同伴协议下客户端已接管 locomotion,而 TaskWayPointMove 就是派给行为树的 /// (街上那些 830… 开头的 NPC 一直被喂这个包才不掉、才在走,见 )。 /// 止跌用的那条 points = [锚点, 锚点](距离 0 ⇒ 原地),这里换成真实折线即可。 /// /// ⛑ 终点**必须**走 TryPlanJourney,不能用「当前位置 + 随机方向」:环形随机点 92% 落在 /// 建筑/江里,而 PlanRoute 尾部会把最后一个路径点硬拉到那个点 ⇒ 他最后一段是扎进墙里, /// 客户端到不了就一直往墙里推(2026-09-23 行人同款坑)。 /// public bool LeftBehindWander { get; init; } = false; /// 闲逛循环的检查间隔(毫秒)。只是「该不该派新行程」的轮询,**不是**行程时长。 public int LeftBehindWanderPollMs { get; init; } = 500; /// /// 一段行程终点离当前位置的最小 / 最大距离(米)。 /// ⛑ 太大他会走出玩家视野被回收,看起来就是「人凭空消失」。 /// public float LeftBehindWanderMinMetres { get; init; } = 12f; public float LeftBehindWanderMaxMetres { get; init; } = 35f; /// 走的速度(m/s)。客户端真实速度约 1.846(硬规则 109),别填太快。 public float LeftBehindWanderSpeed { get; init; } = 1.2f; /// /// 一段行程预计走完之后的额外余量(毫秒)。 /// ⛑ 太短会在他还没走到位时就派下一段 ⇒ 一步一停(硬规则 108/109 同款症状)。 /// public int LeftBehindWanderSlackMs { get; init; } = 1500; /// /// ⛑⛑ 把客户端上报的真实坐标写回 LeftBehindCharacter.Position。 /// 不写回 ⇒ 他闲逛到哪都白搭:切回时 GameRouter.Switching.cs 读的 /// (directPos = leftBehind!.Position)还是切走那一刻的落点,人会瞬移回去。 /// 默认 true。 /// public bool LeftBehindTrackRealPosition { get; init; } = true; /// /// ★★ 留场角色的「POI 轮换」总开关(2026-09-26 23:4x;里希那组)。 /// /// 开着时,gameplay.rosterNpc.poiOverrides[].leftBehindPoiGroup 里配了组的角色, /// 在「留在大世界」期间会按**洗牌袋**依次播组里的 POI:把组打乱后一条一条取, /// 取完一轮再重新洗牌 ⇒ **随机,但一轮之内不重复**(正是用户要的"每组都播到才重来")。 /// /// ⛑ 与 **互斥,轮换优先**:这个角色配了组 ⇒ 只轮换、 /// 不派行程(走路/跑步 POI 是原位移动,再叠路网行程会互相打断)。 /// 没配组的角色照旧走 wander。 /// public bool LeftBehindPoiRotation { get; init; } = false; /// /// 轮换里每个 POI 停留多久(毫秒)。⛑ POI 表里**没有时长字段**,只能统一给一个值; /// 单个角色要不一样就配 RosterNpcPoiOverride.LeftBehindPoiDwellMs。 /// public int LeftBehindPoiDwellMs { get; init; } = 15000; /// /// 换到带道具的 POI(吃汉堡 / 对讲机 / 平板)时补发 /// SyncAgentBelongings + SyncAgentUseBelongingChanged /// (与 GameRouter.Civilians.cs 出场那套同包同结构)。 /// /// ⛑ 换到**无道具**的 POI 时会发一个空 usage 列表尝试把上一件道具收掉 —— /// 这个语义没实机验过(可能收不掉、也可能把别的 usage 一起清了)。 /// 出问题就置 false(那时带道具的 POI 会是"空手演",但不会乱)。 /// public bool LeftBehindPoiRotationProps { get; init; } = true; /// /// ★★ 留场那具身体**不发骇客手机**(gameplay.hackNpc 那部,usage 6 = 玩手机)。 /// /// 为什么需要:leftBehindAgentUnitChannel=false 时留场走的是 /// SendCivilianAsync(Aether 静态 NPC 通道),那条线末尾固定会 /// MarkNpcHackableOnSpawnAsync 补一部手机 —— 出场 POI 17(站姿闲置)的 /// UsageId = 0 ⇒ 判定"这个 POI 本来不带可骇 usage" ⇒ 发手机。 /// 结果就是**留场角色一边闲逛一边玩手机/打电话**,而且看起来"只有这一种动作" ///(2026-09-27 00:1x 用户实测;日志 `[NPC-HACK] phone unit=0x3000000000xx usage=6`)。 /// 置 true ⇒ 留场身体不拿手机(可骇标记照发,只是没手机道具)。默认 true。 /// public bool LeftBehindSkipPhone { get; init; } = true; /// /// 闲逛时播的 POI 动作 id。0(默认)= 沿用出场那个(LeftBehindNativePoiActionId,17) /// —— 位移本身由 TaskWayPointMove 驱动,POI 只管上半身/动画状态。 /// 想让他明显在"走"就填 1(走路),想"跑"填 4(跑步)+ 把 /// 设成 2、 提到 4~5。 /// ⛑⛑ POI 1/4 单独播是**原地**走/跑(跑步机效应),位移来自移动命令,两者必须配套。 /// public uint LeftBehindWanderPoiActionId { get; init; } = 0; /// /// 闲逛的移动方式(AgentMovementMethods):1 = Walk(默认)、2 = Run。 /// 改成 Run 记得同时给 填 4、速度提到 4~5, /// 否则会用走路的速度演跑步(脚底打滑)。 /// public byte LeftBehindWanderMovement { get; init; } = 1; /// /// ⛑⛑⛑ 留下的旧角色走「同伴协议」:ExistingUnitProjection 之后补发 Ped 的第 4/5 步 /// SyncAgentPlayPOIAction(68459398) + SyncAgentForceGo(68698324), /// 让客户端行为树接管 locomotion(贴地、有动画状态机), /// 并且停掉 / / /// 后台续发循环。默认 true。 /// /// 那三样补丁的存在理由只有一个:这类单位是「服务端驱动」的,客户端停用 AI 后只做物理模拟, /// 没有地面吸附 ⇒ 自由落体(18:1x 实测 Y → -3000)。它们是「每 N 毫秒把坐标硬拉回来」, /// 而 21:33 实测证明**两样加起来也没止住下落**(000/005 全程 2s 喂一次仍掉到 -2999.99)—— /// 因为路点任务是派给行为树的,树没起来就没人接。 /// 街上那些不掉的路人(GameRouter.Ambient.SendAmbientCrowdPedAsync)多发的正是 /// POI + ForceGo 这两步。所以真正该做的是把这两步补齐,而不是继续加码喂坐标。 /// /// 关掉 = 回到老的三件套补丁(喂坐标 + 原地路点 + 后台循环)。判据:留下的角色 /// Y 是否稳定在 -0.13~0.01(不掉)。 /// /// /// ⛑⛑⛑ 把「Leave + 以主角身份重建目标角色」从 phase 2(ReportPreSwitchSpiritFinish 之后) /// 挪到演出开始之前(SyncPreSwitchSpirit 之前)。默认 true。 /// /// 硬证据(2026-09-25 01:31:50~01:32:04,会话 packets-20260925-012445 + 客户端 Player.log): /// /// 01:31:50.011 LiftOffOneShot_CreateChangeLodCam(时间线起,由 SyncPreSwitchSpirit 触发) /// 01:31:53.544 InitModules 100000000000 IsMe False / [LB] 创建角色 ← 我们在时间线里 Leave+重建 /// 01:32:02.6xx Object reference not set … ×4 /// 01:32:04.262 SwitchChar_common_04 2 System.NullReferenceException ×3 /// 01:32:04.308 LiftOffOneShot_TryDestroyChangeLodCam(镜头落回主相机,时间线被打断) /// /// ⇒ 时间线全长约 14 秒,我们在第 3.5 秒拆掉主角单位,第 12.5 秒它抛空引用。 /// 这与 r14 那批 197 条 loading_switch_moving_seamless 空引用是同一件事的两个相位。 /// /// 因此:任何 Leave / 重建都不能发生在演出期间,一律挪到发 PreSwitch 之前。 /// 关掉 = 回到旧顺序(演出中途拆单位)。 /// /// /// ⛔⛔⛔ 2026-09-25 03:2x 实机证伪,默认改回 false。别再打开。 /// /// 换皮后用户回报「角色可以正常移动了,但是模型都没有了 / 只有一张主角的脸」。 /// 判别性实验(同一台机器、同一个 build): /// /// GmSwitchSpiritHere(就地,allowShow=false,走 3 参 CharacterUnitProjection) /// ⇒ 模型正常 /// AskSwitchSpirit(演出,allowShow=true,走 4 参 CharacterUnitProjection + 不 Leave) /// ⇒ 只有一张脸 /// /// ⇒ 根因不是演出/时间线,是换皮本身。两条路的唯一实质差别: /// /// 就地路:unitId = roster.UnitId(= unitIdBase + index), /// Enter + Managed + CharacterUnitProjection(3参) + PosFacing 完整四连发。 /// 换皮路:unitId 恒 = Profile.InitialUnitId(100000000000), /// 只改 templateId,全程不 Leave(怕丢 IsMe)。 /// /// 不 Leave ⇒ 客户端的 BaseUnit 从来没被销毁重建 ⇒ 身体部件不会按新 templateId 重新加载 /// ⇒ FashionPartResLoadCheck 全挂、只剩脸(bodyType/性别前缀还锁在旧角色上)。 /// /// ⛑ 由此得到的取舍(这个 build 上无解,别再两头追): /// IsMe=True 与 模型完整,只能选一个。 /// · 要 IsMe ⇒ 必须不 Leave ⇒ 模型只剩脸。 /// · 要模型 ⇒ 必须 Leave+重建 ⇒ IsMe 掉(头顶常驻 F、按 F 出 AgentInteractPanel)。 /// 原因:`initialUnitId`(100000000000) ≡ `roster.unitIdBase`(100000000000), /// 而 `roster.UnitId = unitIdBase + index` ⇒ 除 index 0 外每个角色的 unitId 都不等于 /// 登录时 CreateHero 那个 ⇒ 5/5 实测 IsMe=False 是号段设计的必然,不是发包姿势问题。 /// IsMe=True 只可能来自客户端自己的 CreateHero,服务端造不出来(见 AskOnlyForClientSwitch 的 B 结论)。 /// /// 关掉 = 每个 roster 行一个 unitId、切人走完整重建(模型正常,代价 IsMe=False)。 /// /// ⛑⛑ 2026-09-25 03:3x 再改回 true —— 与 HeroUnitRebuildOnSwitch 配套: /// 换皮 + Leave 重建,试图同时拿到「能走」与「模型正常」。详见那一条的注释。 /// public bool HeroUnitReuse { get; init; } = true; /// /// ⛑⛑⛑ 换皮时先 Leave 再重建(unitId 仍然是 Profile.InitialUnitId)。默认 true。 /// /// 为什么需要它(2026-09-25 03:3x 用户实机): /// /// heroUnitReuse=true (换皮,不 Leave) → 模型只剩一张脸,但**能走** /// heroUnitReuse=false (独立 unitId,完整四连发)→ 模型正常,但**走不动** /// /// ⇒ 两条路唯一的差别是 unitId:`Profile.InitialUnitId`(100000000000) vs /// `roster.UnitId`(= unitIdBase + index)。⇒ 客户端只对登录时那个 unitId 应用本地移动输入, /// 与 IsMe 是同一件事的两个表现("走不动" ≡ "不是我")。 /// /// 所以真正的矛盾不是「IsMe vs 模型」,而是「不 Leave(模型坏) vs Leave(IsMe/移动掉)」。 /// 这个开关就是两头都要:id 保持不变(保住移动 / 可能也保住 IsMe), /// 但**把单位销毁重建**(让身体部件按新 templateId 重新加载 ⇒ 模型正常)。 /// /// ⛑ 与规则 134 的冲突已确认并接受:那条实测的是「Leave 掉 100000000000 就拿不回 IsMe=True」, /// 测的是 IsMe、不是移动。本次赌的是「id 不变 ⇒ 移动仍然成立」。 /// 若实测还是走不动,说明移动也绑在 BaseUnit 实例上(而不是 id),那就彻底回到 /// `heroUnitReuse=false` 的模型优先档,移动问题另找入口(候选:`SyncControllableAgent`(68376522) /// + `SyncSwitchControl`(68552741) 两件套,黑客召唤物那条已实机验证的原生交操控通道)。 /// public bool HeroUnitRebuildOnSwitch { get; init; } = true; /// /// 切人移交后是否再补发一次 SyncUnitPositionAndFacing 把落点钉住。默认 true。 /// /// ⛑ 用户 03:3x 提出「走不动是不是和绑定了切换角色的坐标有关」,这条就是验证它的开关。 /// 关掉后切人不再回 assert 落点 —— 若角色因此能走,说明 setPositionType=SwitchSpirit(3) /// 会让客户端停在"等待落点完成"的状态机里锁住输入。 /// ⛑ 关掉的风险:新角色可能落到模板默认出生点而不是切人落点。 /// public bool SwitchHandoffPushPose { get; init; } = true; /// /// ⛑⛑⛑ 实验开关:客户端(Z 轮盘 / 换人面板那条 AskSwitchSpirit)发起切人时, /// 服务端只回 ack,一个包都不推(不发 PreSwitch / ConfigId / 不建单位 / 不发 CurrentSpirit)。 /// 默认 true。 /// /// 目的:一次性判掉「客户端到底会不会自己建主角」。 /// 官方链路的最后一棒是客户端在时间线末尾自己 CreateHero;我们一直以为它不会, /// 于是从 09-24 起一路加了七样补丁(预生成 / ExistingUnitProjection / Leave+重建 / 喂坐标 / /// 原地路点 / 后台循环 / 同伴协议 / 换皮)去替它建。这个开关就是把手放开,看它自己行不行。 /// /// 三种结果与判据(客户端 Player.log): /// A 成功 —— 角色变了 + 能跑 + 出现 InitModules … IsMe True + [LB] 进入场景 创建主角 /// ⇒ 客户端自己会建主角 ⇒ 那七样补丁全部可以删。 /// B 没反应 —— 点了没变化、日志没有任何 InitModules /// ⇒ 官方服务端还必须发 PreSwitch/CurrentSpirit ⇒ 只能退回换皮方案(HeroUnitReuse)。 /// C 卡住/报错 —— Invoke 没 ack 或状态机崩 ⇒ 同样退回换皮。 /// /// ⛑ 只作用于客户端发起的路径(allowShow=true);管理面板那条 /// (SwitchSpiritAsync 传 allowShow:false)照旧走换皮,保证实验期间还有可用的切人手段。 /// /// 结论(2026-09-25 实机,用户回报「切人现在根本没反应」)= B: /// 客户端不会自己建主角。日志里没有 InitModules … IsMe True,也没有 [LB] 进入场景 创建主角。 /// ⇒ 服务端必须发 PreSwitch / ConfigId / CurrentSpirit,这一棒甩不掉。 /// ⇒ 开关关回 false,恢复完整协议。留着备用,别再打开当默认。 /// public bool AskOnlyForClientSwitch { get; init; } = false; public bool RecreateHeroBeforeShow { get; init; } = true; public bool LeftBehindCompanionProtocol { get; init; } = true; /// /// ⛑⛑⛑ 原生留场:老角色不再走「spirit 投影」(ExistingUnitProjection,那条路建出来的 /// 单位是服务端驱动的,没有行为树 ⇒ 自由落体掉出世界,Y → -3000),改成 /// 用它自己的 AgentConfig 行刷一个真正的 NPC。默认 true。 /// /// 取证(2026-09-25,见 NpcCatalog.json / FightSpiritConfig.json): /// /// FightSpiritConfig.AgentId ≡ AgentConfig.Id (15/15 CanJoin 行全部命中) /// 15020967 辰宿 -> 40140030 (Chenxiu, persona 45200007, AI 40215025) /// 15021021 里希 -> 40140040 (Richie, persona 45200010, AI 40213425) /// 15021044 李扶风 -> 40155013 (Li Fufeng,persona 45200009, AI 40215029) /// AgentConfig 9345 行里 4014xxxx/40155xxx 共 339 条,全部带 AgentPersonaId / AiSettingId。 /// CivilianGroups 现有 3978 个 formwork 里本来就有 23 个 4014xxxx /// ⇒ 4014xxxx 天然就是合法 NpcFormworkId("必须是 4013xxxx"那条注释是错的)。 /// ⛔ 反倒是 SpawnIds.npcs(4388 条)里一个可玩角色 agent 都没有 —— 这就是为什么 /// 」老角色留场」一直做不出来:我们从没把正确的 formwork id 喂进 NPC 通道。 /// /// /// 这正是用户描述的官方行为:「切换后原角色留在大世界里做自己的事(用跑步机那种)」。 /// 官方就是拿 FightSpiritConfig.AgentId 建一个 NPC,交给 NpcDailySchedule + /// PoiActionCatalog(3978 条 POI 动作)驱动 —— 与满城那 3978 个市民同一条通道。 /// 本开关只是把 formwork id 从「随机市民」换成「这个角色自己的 agent」,零新轮子。 /// /// 关掉 = 回到旧的 spirit 投影(会掉出世界)。 /// public bool LeftBehindAsNativeAgent { get; init; } = true; /// /// 留场时把**旧操控单位**回收掉。默认 true。 /// /// 用户 04:2x 实机:切人后场上出现**两个**旧角色 —— 一个穿自己衣服、没有 POI, /// 一个穿默认服装、有 POI。前者就是 `oldUnitId`(`state.ActiveSpiritUnitId`)那个单位: /// `SwitchSpiritDirectSameScene4229938` 一直没 `Leave` 它,客户端也没主动销毁,它就杵在原地。 /// 后者才是 `LeftBehindAsNativeAgent` 新刷的市民 NPC。 /// ⛑ `LeftBehindRebuildLeave` 做的是 `Leave(companionUnitId)`(那是**还没建**的新 id), /// 不是 Leave 旧单位 —— 别搞混,这是另一个动作。 /// public bool LeftBehindReclaimOldUnit { get; init; } = true; /// /// 给留场的市民 NPC 补发一次 SyncSetSpiritFashions(64102530),让它穿上玩家给这个角色 /// 搭配的衣服。默认 true。 /// /// ⛑ 市民通道(`SyncAetherAIStaticNpcAddData` / `SyncAetherAICrowdAdd`)**只有** /// FashionSuitId(套装 id,写死 11190001),**没有单件衣服列表** —— /// 这是"留场的老角色穿默认衣服"的根本原因。 /// 补救:建完之后补发一次 S2C 换装包,数据源 state.FashionBySpirit[templateId] /// (`FashionRecord4229938`,含 `FashionIds` + `FunctionSuitId`,由 `AskSetSpiritFashions*` 写入)。 /// ⛑ 该包的字段是 `spiritId`,客户端不会把它落到刚刷出来的市民 NPC 身上 —— /// 09-25 用户实测「没有穿上我的搭配」,已作废(见下面的 `LeftBehindDressSyncPacket`)。 /// /// ✅ 现在的做法(本开关控制):在**出场包里**就把衣服带上 —— /// `AgentSyncClientInfo.AIAgentInfo`(`LinkAIAgentInfo`)→ `Fashion`(`LinkAIFashionInfo`) /// → `WearInfo`(`OtherPlayerSpiritWearFashionsInfo`,含**单件** `WearFashionInfoList`)。 /// 这是官方「把某个可玩角色当作 NPC 放进大世界」时唯一带衣服的字段 —— 麻将/保龄球的 /// AI 对手读的 `unit.ClientData.AIAgentInfo` 就是它(`MajiangGame_UI.lua:181` / /// `BowlingCharacter.lua:414`)。数据源同样是 `state.FashionBySpirit[templateId]`。 /// ⛑ 顶层 `FashionSuitId` 只能填**套装** id:存档里 9/9 个角色 `FunctionSuitId` 全是 0 /// (纯散件,4~10 件)⇒ 那条路对"自定义搭配"根本没用,只能走 `WearInfo`。 /// public bool LeftBehindDressWithPlayerFashion { get; init; } = true; /// /// 走哪条通道刷留场 NPC。默认 true = 新的 SyncRaidBattleUnitAgent(68402349) 通道。 /// /// ⛑ 老通道(Aether 静态 NPC,SendCivilianAsync)行为没问题(站得住、有闲置、有 POI), /// 但客户端不读 AIAgentInfo.Fashion.WearInfo,顶层又只有 FashionSuitId(套装) /// ⇒ 09-25 两次实测「包发了 6 件衣服,模型还是默认服装」。 /// SyncRaidBattleUnitAgent 带 FashionIdList(单件列表)才有救。 /// 设为 false 就退回老通道(对照 / 新通道出问题时的回滚)。 /// public bool LeftBehindAgentUnitChannel { get; init; } = true; /// /// A/B 用:老办法 —— 建完 NPC 之后补发一次 SyncSetSpiritFashions(64102530)。 /// 默认 false。 /// /// ⛑ 已实测无效(09-25 用户:「没有穿上我的搭配」):该包按 spiritId 匹配, /// 落不到场上那个市民 NPC。留着只为对照/回滚,别再当主方案。 /// public bool LeftBehindDressSyncPacket { get; init; } = false; /// /// ★ 让留场的老角色真的「过日子」:按 现在这个时刻 从它自己的日程表里挑一条活动, /// 把它刷到那条活动编好的世界坐标上,并在出场包里带上 AgentDataSetsActivityCfgId。 /// 默认 true。 /// /// 链路(官方原生的三张表,零自造): /// /// FightSpiritConfig.NpcCultivationRelatedId -> NpcCultivationConfig.AgentTag /// (服务端副本:NpcCultivationCatalog.json 的 AgentTag 列) /// AgentTag + 现在时刻 -> NpcDailySchedule.json 的 5 段之一 -> ActivityId /// (LifeScheduleTimetableConfig 的四个 HHMM 边界 × AgentDataSetsActivityConfig) /// ActivityId -> NpcActivityCatalog.json -> MapPosition / ActivityPerformance / Raid /// /// 接线点 = 出场包 AgentSyncClientInfo.AgentDataSetsActivityCfgId(填 0 = 只走 ambient /// POI,就是现在「只会站着」的样子)。坐标则是官方给这条活动编好的点 —— Richie 的跑步机在 /// (1191,0,2052)、Taffy 坐着刷手机在 (2047,9,1764),都是表里真有的行,不是我们猜的。 /// /// ⛑ 两个主角没有日程:15020967 辰宿 / 15020968 昭昭 的 AgentTag 是 2 / 3,而 75 行 /// 时刻表是从 tag 4 开始的;它们各只有 1 条「临时点位」活动且 MapPosition 全 0。 /// 这两个(以及任何查不到活动的角色)走原逻辑:留在切人时的坐标、ambient POI。 /// ⛑ 153/284 条活动才有坐标,全部落在两个开放世界 raid(23300888 / 23300999)。 /// 查不到坐标时同样回退,不会把角色刷到 (0,0,0)。 /// public bool LeftBehindDailyLife { get; init; } = true; /// /// 「过日子」时是否要求活动一定带世界坐标。默认 true。 /// /// false = 即使该时段的活动没有 MapPosition,也把 AgentDataSetsActivityCfgId 填上 ///(角色留在原地播那条活动的表演)。真机还没验过无坐标活动会怎样,留个开关好 A/B。 /// public bool LeftBehindDailyLifeRequirePosition { get; init; } = true; /// /// ★★ 游戏时间跨进下一个日程时段后,把「被玩家操控过、切走后赖在原地的」角色 /// 交还常驻化去演新活动,而不是继续钉在他最后被操控的位置。默认 true。 /// /// 为什么要有它:前一条(被操控过 ⇒ 不过日子)解决的是「切走 A 就被瞬移到日程点」, /// 但矫枉过正了 —— 玩家玩了半小时、游戏里也过了好几个时段,A 还杵在原地不动, /// 比瞬移更假。这条就是补回「时间真的会推着他走」那一半(用户 09-26 05:0x 要的)。 /// /// 判据:后台(常驻循环 1.5 s 一跳)拿时段表当下那一段的活动 id(NpcActivityChoice.Timed) /// 跟 LeftBehindCharacter.TimedActivityId(切走时记的同一个量)比,不同 ⇒ 时段变了 ⇒ /// ① SyncLogicAgentLeave 撤掉留场那具身体(留场单位在 retirable 段,规则 165 已验证收得掉) /// ② 从 LeftBehindCharacters 移除 ③ 直接调常驻化的摆放,在新活动坐标落地。 /// /// ⛑⛑⛑ 判据必须是 Timed,不能用 Entry(真正落地的那条活动)。 /// 官方 284 条活动里 131 条是没编坐标的「临时点位」(睡觉 / 休息 / 对策局吃薯片……), /// 傍晚夜里大量命中 ⇒ Resolve 回落到 FirstPositioned,而它与时间无关 /// (同 AgentTag 在本 raid 里 Id 最小的有坐标那条)⇒ 白天和夜里算出的 Entry.Id /// 一模一样 ⇒ 判据恒等 ⇒ 时间跳十几个小时也一次都不触发。 /// 2026-09-26 06:0x 实机翻车:时间 05:45 → 18:00,梅卡妮卡 11400031→11400031、 /// 里希 11400012→11400012,`[LEFTBEHIND-SLICE]` 一次都没打出来。换 Timed 后 /// 梅卡妮卡 11400031→11400034、里希 11400011→11400014,正常触发。 /// ⛑ 两个主角(AgentTag 2/3)没有时刻表 ⇒ 两端都是 0 ⇒ 永远不触发,他们就该一直待在 /// 玩家最后离开的地方 —— 这是对的。 /// /// ⛑ 交还常驻化之后,切回他就不再是「上次离开他的地方」,而是他正在做活动的地点 /// —— 这是预期的:他已经在过日子了。 /// ⛑ 摆放不受 EnterMeters 距离限制(这里直接调摆放函数,不走距离筛选), /// 否则玩家离新日程点一远、人就凭空消失。 /// ⛑ 只有 DailyLife 开着才生效;当前正被操控的角色不参与。 /// /// 回滚:false = 回到「一旦操控过就永远钉在原地」。 /// public bool LeftBehindFollowSchedule { get; init; } = true; /// /// ★★ 玩家**主动推时间**(游戏里的「度过时间」AskPassingTime / GM 与面板改时间)之后, /// 即便还停在同一个日程时段里,也把赖在原地的角色交还常驻化去过日子。默认 true。 /// /// 为什么还要这一条:LeftBehindFollowSchedule 只认「时段翻篇」,而时刻表一段就是 /// 4~7 小时 —— 玩家 06:13 用「度过时间」跳到 06:00,里希仍在 00:00-07:00 这一段 /// ⇒ 判不出来 ⇒ 玩家看到「我明明推了时间,他还杵在那儿」(2026-09-26 06:1x 实机)。 /// 这条抓的是「玩家主动推时间」这个动作,不看时段有没有变。 /// /// 实现:服务端每次处理改时间的请求就把 WorldEntryState.TimeEpoch +1; /// 切走角色时把当时的值记进留场记录,后台比这两个值。 /// ⛑⛑ 不能退化成「直接比 daySecond」—— 实时流逝也是每秒 +1,那样玩家在城里 /// 逛 10 分钟角色就自己跑了,前一条需求(赖在最后被操控的位置)当场失效。 /// /// 回滚:false = 只有时段真的翻篇才放行。 /// public bool LeftBehindFollowOnTimeJump { get; init; } = true; /// /// 原生留场时给老角色播的 POI 动作 id(UrbanDiversityPOIActionConfig,共 3978 条)。 /// 17 = 站姿闲置。想让它真的去"过日子",填别的 id(例如健身房/跑步机那几条)。 /// ⛑ 是 POI 动作 id,不是动作组号(400)—— 两张表,别混。 /// ⛑ 开了 LeftBehindDailyLife 且命中活动时,客户端走生活日程状态,这个 POI 会被盖掉。 /// public uint LeftBehindNativePoiActionId { get; init; } = 17; /// /// ⛑⛑⛑ 切回「已经留在大世界里」的角色(LeftBehindCharacters 里有它)时,走客户端的 /// CloseUpAgentFlow 而不是 LiftOffOneShot 飞越演出。默认 true。 /// /// 判据(客户端 BattleSpiritMgr.lua:19-38,本机 dump.cs:354025): /// /// SyncPlayerCurrentSpirit(pid, templateId, spiritId, isAgentSwitch) /// isAgentSwitch=true → SwitchTeleport_CloseUpPrepare() + CloseUpSync() // CloseUpAgentFlow /// isAgentSwitch=false → CheckSwitchTeleport(spiritId) // LiftOffOneShot /// /// CloseUpAgentFlow 的行为正好是用户要的那句话:WaitUnitLoadAndStartShowing → /// CheckUnitReady → DoCameraSwitchOnUnitReady → OnWaitCamMove → /// OnMoveFinished ⇒ 镜头直接平移到那个角色身上(它在哪就飞到哪,室内也算)。 /// LiftOffOneShot 则是「镜头爬升到高空再落到锚点」,落点由 SyncSwitchSpiritConfigId /// 的 seamlessSwitchPosition 决定 —— 我们切回室内角色时传的是角色真实坐标, /// 但演出用的锚点是 SwitchSpiritConfig 行里那个室外坐标,于是出现 /// 「镜头飞到一个没人的无关位置,然后视角瞬移回角色身上」。 /// /// 开这个开关后,切回在世界里的角色时不再发 SyncPreSwitchSpirit / /// SyncSwitchSpiritConfigId(不发就不会起 LiftOff 状态机,也就不会再撞 /// loading_switch_moving_seamless 那条空引用),改成 /// 「角色已站在原地 → 直接身份移交(isAgentSwitch=true)→ 客户端把镜头运过去」。 /// 首次切到某角色(它还没在世界里)仍然走完整的角色专属演出。 /// public bool CloseUpAgentForInWorld { get; init; } = true; } /// /// 可玩角色常驻大世界(gameplay.rosterNpc)。 /// /// 为什么需要它:官方这些角色本来就在城里晃(趴卜主页的「日程」和大地图的图钉就是给你去找他们用的), /// 而我们原先只在「切走角色」那一刻才造一个 NPC ⇒ 玩家跑到日程坐标那儿是空的。 /// /// 为什么要有进入/离开迟滞:客户端会把远处的单位卸载掉,而不会自己把它们加回来 ///(09-25 实机:从远处走到点位,人是消失的,只有切走再切回才重新出现)。 /// ⇒ 服务端必须自己按距离管理:走近就放出来,走远就撤掉,回来再放一次。 /// 这是 GameRouter.FunctionalNpc.cs 那套已经被验证的做法,不是新轮子。 /// public sealed class RosterNpcSettings { /// 总开关。默认 true。 public bool Enabled { get; init; } = true; /// 是否按日程挑活动(AgentDataSetsActivityCfgId + 活动坐标)。默认 true; /// 关掉就只用 在角色当前位置站着 —— 排障对照用。 public bool DailyLife { get; init; } = true; /// 进入多少米内放出来(并记住)。默认 260。 public float EnterMeters { get; init; } = 260f; /// 离开多少米外撤掉(必须 > 进入值,否则边界上每 tick 反复增删)。默认 420。 public float ExitMeters { get; init; } = 420f; /// 跟随循环间隔。默认 1500 ms。 public int TickMs { get; init; } = 1500; /// 每个 tick 最多放几个(客户端会丢大批量包的尾巴)。默认 2。 public int PerTick { get; init; } = 2; /// 没命中日程活动时的兜底 POI 动作(UrbanDiversityPOIActionConfig)。默认 17 站姿闲置。 public uint PoiActionId { get; init; } = 17; /// /// 用哪条出场通道。默认 false = Aether 静态 NPC(SendCivilianAsync,与满城市民同款, /// 已验证站得住 / 有闲置 / 有 POI)。 /// 设 true 改走 SyncRaidBattleUnitAgent(46 字段版 AgentSyncClientInfo, /// 多带 SpoonAgentId / SpoonIndex)。 /// ⛑ 这条是「表演没播出来」的 A/B 对照用:两条通道的 AgentDataSetsActivityCfgId /// 是不是同一套消费逻辑还没验证过。⚠️ 只在 DailyLife 关闭换装时切它 —— /// 这条通道一旦带上 AIAgentInfo.FightSpiritId 就会换皮只剩脸(规则 141)。 /// public bool AgentUnitChannel { get; init; } = false; /// /// 常驻 NPC 的 unitId 走哪一段。默认 false = 与满城市民同款 /// StaticNpcIdFloor(8300000000000000000 段)。 /// 设 true 改走 GameAdminBridge.NextRetirableNpcUid()(0x300000000000 段, /// 硬规则 165 里那条「SyncLogicAgentLeave 确实收得掉」的段)。 /// ⛑⛑ 这是**排障开关,不用重新编译**:09-25 22:2x 实机发现 /// SyncAetherAINpcRemove 删不掉 8300 段那具身体(跑步机上的里希一直不消失), /// 若补发 SyncLogicAgentLeave(已加,同时发两条)仍然无效,就把这个改成 true /// 再让常驻重新摆一次(配置热加载,摆人才读这个值)。 /// ⛑ 风险:Aether 静态 NPC 通道是否吃这一段还没验过 —— 改完若人直接不出现,改回 false 即可。 /// public bool RetirableUidSegment { get; init; } = false; /// /// 是否要求活动一定带世界坐标。默认 true —— 不带坐标的活动(临时点位)无法落地, /// 这时会退到同 tag 在本场景里最近的一条有坐标活动。 /// public bool RequirePosition { get; init; } = true; /// /// 一个角色在当前时段查不到任何可落地的活动时(没有 AgentTag、没有日程表、活动在别的 /// 场景、或官方压根没编坐标)要不要仍然把他放出来。默认 false = 不放。 /// ⛑⛑ 设成 true 会退化成「谁都往玩家脚边塞」:这类角色的坐标只能取玩家位置 +3m, /// 于是登录和每次传送都会有一圈人贴着你刷出来(09-25 玩家报的那个现象就是它)。 /// 只有想让 OC 占位角色也露个脸时才开,代价是他们不会「过日子」,只是站着。 /// public bool PlaceWithoutActivity { get; init; } = false; /// /// 手工常驻点……(见 )。 /// public RosterNpcStaticSpot[] StaticSpots { get; init; } = []; /// /// 放人前先看目标点位多少米内(3D)有没有别的 NPC 已经站着,有就不放 —— 避免和市民 / /// 功能 NPC / 切人留场的同一角色叠在一起。默认 2 米;设 0 关掉这个检查。 /// ⛑ 用 3D 距离是刻意的:同一栋楼 1 楼和 2 楼各站一个属于正常,只有真贴在一起才算冲突。 /// public float MinSeparationMeters { get; init; } = 2f; /// /// ★ 按角色覆盖出场 POI 动作 / 要不要发活动 id。默认空。 /// /// 为什么需要它:09-25 20:0x 实机确认 —— 里希按日程站到跑步机点上了,推 /// SyncAgentPlayPOIAction(68459398) 的 198「站姿_运动_跑步机跑步」 /// 之后他真的跑起来了(POI 通道有效),但上半身还叠着一层「打电话」 /// —— 那是生活日程的 ambient:出场包带 AgentDataSetsActivityCfgId /// 却没有 loop pose 时客户端的兜底表现(规则 160 那句「没有 loop pose 就只能播 ambient」)。 /// 所以想跑得干净,得让这个角色出场就带 198 且不带活动 id。 /// /// ⛑ 只对列出来的角色生效,其余照旧 —— 别把 52 个角色全改成跑步机。 /// public RosterNpcPoiOverride[] PoiOverrides { get; init; } = []; /// /// ★★ 按活动覆盖出场 POI 动作(推广版,09-25 23:0x 加)。默认空。 /// /// PoiOverrides 是按角色写死的(里希 → 跑步机 198),只能管一个人; /// 想让其余有日程的角色也「真的在做那件事」,得按活动 id 给动作 —— /// 同一个角色一天里换好几个活动(塔菲 13 条:吃汉堡 / 看漫画 / 猫咖…),按角色写死没法覆盖。 /// /// ⛑⛑ 数据来源与局限(务必看清): /// · AgentDataSetsActivityConfig(284 行)只有中文名 + 坐标,没有 POI 动作 id; /// UrbanDiversityPOIActionConfig(3978 行)有中文名 ⇒ 只能**按名字匹配**推出来。 /// 表里那一份是脚本按「核心词完全相等 > 包含 > 2-gram 重叠」自动生成的, /// 必然有猜错的,看到不对就改 poiActionId(配置热加载,不用重启)。 /// · 朝向没有表可查:`SceneitemConfig` 只是模板注册表(无世界坐标), /// world dump 里也没有跑步机那类物件的摆放旋转 ⇒ facing 只能实机调,同里希那次。 /// · 配了 POI 的活动默认 不带 AgentDataSetsActivityCfgId(否则生活日程的 /// ambient 会盖掉 POI,里希「一边打电话一边跑步」就是这个)。 /// public ActivityPoiOverride[] ActivityPoiOverrides { get; init; } = []; } /// /// 一条「活动 → 出场表现」的映射(按活动 id,不是按角色)。 /// 活动 = AgentDataSetsActivityConfig.Id(NpcActivityCatalog.json 里的 Id)。 /// public sealed class ActivityPoiOverride { /// 活动 id(AgentDataSetsActivityConfig.Id,如 11400012 = 里希「跑步机」)。 public uint ActivityId { get; init; } /// /// 出场 POI 动作 id(UrbanDiversityPOIActionConfig,3978 条)。 /// 填 0 = 这条不覆盖,退回默认( + 照常发活动 id)。 /// public uint PoiActionId { get; init; } /// 朝向(度)。null(默认)= 沿用 0。没有表可查,只能实机调。 public float? Facing { get; init; } /// /// 出场包还要不要带 AgentDataSetsActivityCfgId。 /// null(默认)= 本条配了 POI 就不带(让 POI 生效),没配就照常带。 /// public bool? SendActivityCfgId { get; init; } /// 这个活动出场不发骇客手机。null(默认)= 照常发。 public bool? SkipPhone { get; init; } /// /// ★★ 落地坐标微调(米,**世界轴**,不是角色本地轴)。三个都是 null = 不偏移。 /// /// 为什么需要:官方 AgentDataSetsActivityConfig.MapPosition 是策划打的点, /// 跟场景里的家具 / 机器经常差那么几十厘米 —— 人飘在床边上、背对着娃娃机站, /// 这些都是没有表可查的(规则 171②:朝向无表可查),只能实机一点点挪。 /// /// ⛑⛑ 是**世界轴**:OffsetX = +X 方向,OffsetZ = +Z 方向,OffsetY = 上。 /// 判「哪边是右」要看这条活动落地时的朝向(,没配就是 0): /// 朝向 f(度)⇒ 前方 = (sin f, 0, cos f),右手方向 = (cos f, 0, -sin f) /// (规则 181:喂 MathF.Sin/Cos 前必须把度转弧度)。 /// ⇒ 朝向 0 时右 = **+X**;朝向 90 时右 = **−Z**。 /// 改完朝向要重新对一遍偏移,别照抄。 /// /// 生效范围:常驻摆放 / 切人留场(真去演活动那具)/ 主动切到他日程点 —— 三处同一个偏移, /// 否则「走过去看到的她」和「切过去落地的她」会差 0.8 米。 /// 实现见 GameRouter.ApplyActivityOffset。 /// public float? OffsetX { get; init; } public float? OffsetY { get; init; } public float? OffsetZ { get; init; } /// 备注(自动匹配到的候选 / 人工校对的依据),只给人看。 public string? Note { get; init; } } /// /// gameplay.rosterNpc.poiOverrides 的一条:某个角色出场时用哪个 POI 动作、 /// 以及还要不要给他发 AgentDataSetsActivityCfgId。 /// public sealed class RosterNpcPoiOverride { /// 角色(FightSpiritConfig.Id / roster 里的 spiritId)。 public uint SpiritId { get; init; } /// /// 出场 POI 动作 id(UrbanDiversityPOIActionConfig,共 3978 条)。 /// 198 = 站姿_运动_跑步机跑步;17 = 站姿闲置(默认)。 /// ⛑ 跑步机只有两条:198 跑、500997「通用04_跑步机_idle」(MainActions 为空)。 /// public uint PoiActionId { get; init; } = 17; /// /// 出场包还要不要带 AgentDataSetsActivityCfgId。 /// false = 不带(活动坐标照用,但不进生活日程状态 ⇒ 没有 ambient 那层打电话); /// true = 带(走生活日程,POI 会被 ambient 盖掉); /// null(默认)= 沿用 。 /// public bool? SendActivityCfgId { get; init; } /// /// 出场朝向(度,与 GameRouter.Civilians.cs:107 的 facing * MathF.PI / 180 /// 同一套)。null(默认)= 沿用常驻 NPC 现在传的 0。 /// /// ⛑ 跑步机朝向没有配置表可查:`GadgetCatalog.json` 只有 prefab 注册表, /// world dump 里 `wodegym_treadmill` 也只出现在 prefab 列表(有 GraphId/pathId, /// 没有世界摆放与旋转)⇒ 只能实机调。配置有热重载,改完不用重启, /// 但要等角色被重新放置(走远 420m 再走回来)才生效。 /// ⛑ 客户端没有「原地转身」的帧:`SyncAgentForceGo`(68698324) 只有 `(ulong id, bool)`, /// 带不了朝向 ⇒ 朝向只能在出场包里给。 /// public float? Facing { get; init; } /// /// 这个角色不发手机(gameplay.hackNpc 那部)。默认 false = 照常发。 /// /// 为什么需要它:09-25 实机 —— 里希出场带 POI 198 会在跑步机上跑,但上半身一直 /// 「拿着手机/打电话」,要**打他几下**(刺激打断 usage)才会变成正常跑步。 /// 那部手机不是生活日程 ambient,是骇客功能发的:`SendCivilianAsync` 里 /// POI 198 的 UsageId = 0 ⇒ `poiUsageAlreadyHackable = false` ⇒ /// `MarkNpcHackableOnSpawnAsync` 补一部手机 + usage 6(玩手机),和跑步动作叠在一起。 /// 设 true 就让这个角色跳过发手机(可骇标记照旧发,只是没手机道具)。 /// ⛑ 只对列出来的角色生效 —— 用户明确要「每个 NPC 都有手机」,所以默认都发。 /// public bool SkipPhone { get; init; } /// /// ★★ 这个角色「留场期间」轮换的 POI 组(开关 /// SwitchAnimations.LeftBehindPoiRotation)。 /// /// 洗牌袋:整组打乱后依次播,播完一轮重新洗牌 ⇒ 随机 + 一轮内不重复。 /// null / 空数组 = 这个角色不轮换(沿用出场那个 POI,或走 wander)。 /// /// ⛑⛑ 只在这个角色没在生活日程里时才生效(出场包 /// AgentDataSetsActivityCfgId == 0)—— 带着活动 id 时客户端会走 /// 生活日程,ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步"就是这么来的)。 /// public uint[]? LeftBehindPoiGroup { get; init; } /// 这个角色单独指定停留时长(毫秒);null = 用 SwitchAnimations.LeftBehindPoiDwellMs。 public int? LeftBehindPoiDwellMs { get; init; } } /// /// gameplay.rosterNpc.staticSpots 的一条。固定站位,不随时间变、不跟着玩家跑。 /// public sealed class RosterNpcStaticSpot { /// 角色(FightSpiritConfig.Id / roster 里的 spiritId)。 public uint SpiritId { get; init; } /// 只在哪个场景生效。0 = 任何场景都算。 public uint Raid { get; init; } public float X { get; init; } public float Y { get; init; } public float Z { get; init; } /// 给人看的地点名(GM 面板「在干什么」列会显示它)。 public string Note { get; init; } = ""; } /// /// Plate(盘)演出管线试点 —— gameplay.plate。 /// /// 背景:可玩角色按日程站在跑步机 / 娃娃机 / 床旁边,但**不会真的用它们**(只站着玩手机)。 /// 官方的生活 NPC 是场景里 Spoon 图上的一个「盘」:`SpoonAgentUnit` 同时带 /// AgentDataSetsActivityCfgId / ActivityPlateCreator / ActivityEnterPoint, /// 而服务端要把盘推下去客户端才会建它 —— 这就是第四条(表演)管线(硬规则 157)。 /// /// SyncPlateAddList(68554224) 的形状(客户端 C# `UX.Game.PlateInfo`): /// { UniqueId, GraphId, Position, Rotation, GadgetDic, DestructibleDic, AgentDic, /// VehicleDic, DynamicAgentDic, DynamicVehicleDic, StaticNpcDic, RoomDic, FavorNpcActivityId } /// + 包尾一个 uint taskId。 /// ⛑⛑ 客户端这个 reader 是 C# 生成的(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly), /// 我们**没有**它的线格式 ⇒ 下面那几个 *Encoding/*Marker 是给实机 A/B 用的旋钮, /// 每次推送时现读配置,改了不用重编译。 /// public sealed class PlatePilotSettings { /// 总开关。关掉就一行都不发(回到 09-25 之前的状态)。默认 false。 public bool Enabled { get; init; } /// 进世界时自动推一次。建议先 false,用 POST /api/plate/push 手动推着试。 public bool PushOnWorldReady { get; init; } /// 盘实例 id(ulong)。随便给,但同一会话内别重复;换一个 id 就是换一个盘。 public string UniqueId { get; init; } = "830000000001"; /// /// Spoon 盘图 id。跑步机 = 36001819(`Res/Prefab/Gadget/wodegym_treadmill.prefab`, /// occupyCnt=1)。其它候选:娃娃机 36000544 / 单人床 36001042 / 卧推 36001818 / /// 深蹲 36000424 / 仰卧板 36000106。 /// public int GraphId { get; init; } = 36001819; /// 盘的世界坐标(默认 = 里希跑步机活动 11400012 的 MapPosition)。 public float X { get; init; } = 1191.037f; public float Y { get; init; } = 0.053f; public float Z { get; init; } = 2052.112f; /// 欧拉角,度。 public float RotX { get; init; } public float RotY { get; init; } public float RotZ { get; init; } /// 这个盘偏好的活动(`AgentDataSetsActivityConfig.Id`)。里希跑步机 = 11400012。 public uint FavorNpcActivityId { get; init; } = 11400012; /// 任务盘才用;生活表演填 0。 public uint TaskId { get; init; } /// /// NPC 绑定方式:none(只推盘,不绑人)/ staticNpc(StaticNpcDic[slot]=常驻 NPC 的 uid) /// / agent(AgentDic[slot]=spoonAgentId,用角色的 AgentId)。 /// public string NpcBind { get; init; } = "none"; /// 上面那个 Dic 的槽位下标(图里第几个位子)。 public int SlotIndex { get; init; } /// /// 绑谁:NpcBind != none 时用哪个 spiritId 去找人(默认里希 15021021)。 /// 先去常驻 NPC 账本里查它此刻在不在场上,不在就用它的 AgentId 兜底。 /// public uint SpiritId { get; init; } = 15021021; // —— 以下都是线格式旋钮,只为实机 A/B —— /// 包体顶层是否先写一个 0xFF 存在标记(多数 S2C 有,GadgetGrid 那种 Inline 没有)。 public bool RootMarker { get; init; } = true; /// 盘列表前面是否写存在标记(默认 0xFF)。 public bool ListMarker { get; init; } = true; /// 列表 count 的写法:int7(count+1 偏置)/ raw7(count 原值)/ int32。 public string ListCountEncoding { get; init; } = "int7"; /// 每个 PlateInfo 元素前是否写 0xFF(Complex 有,Struct 没有)。 public bool ItemMarker { get; init; } = true; /// 八个字典前面是否写存在标记。空字典通常也要这一字节 + count。 public bool DictMarker { get; init; } = true; /// 字典 count 的写法,同 。 public string DictCountEncoding { get; init; } = "int32"; /// Position / Rotation 前是否各写一个 0xFF(UXVector3 通常是裸 3 个 float)。 public bool VectorMarker { get; init; } } /// /// 生活日程正式通道(gameplay.npcLife)—— 硬规则 160。 /// /// 为什么需要它:09-25 实机确认「角色按日程站到跑步机上,但只是站着玩手机」。 /// 取证(dump 的 06_RUNTIME_CODE)显示官方有三条我们完全没发的 S2C: /// /// SyncFavorNpcSpoonAgentId(64003213): /// (uint agentTag, int spoonAgentId, UXVector3 Position, FavorNpcBusyInfo busyInfo) —— /// 客户端 LifeScheduleNpcModule.AttachNet(net, activityId, spoonAgentId, /// hasAmbientLoopPose, ambientLoopPos, ambientLoopFacing) 就吃这一套; /// SyncAgentTagTimeTableInfo(64400736):(uint agentTag, NpcTimeTableInfo); /// SyncFavorNpcTimeTableInfos(64219939): /// Dictionary<uint, NpcTimeTableInfo>。 /// /// 而 NpcTimeTableInfo 里的 SpoonAgentId / CurrentSpoonAgentId / /// SpoonPosition 我们一直填 0(RuntimePayloadFactory)⇒ 客户端拿到活动 id /// 却不知道人该站进哪个 spoon 节点,只能播 ambient 兜底。 /// /// /// 按地块铺市民(GM 面板 T0-8)时,对候选池里的 PoiActionId 做二次筛选 /// (2026-10-02 新增,配置键 gameplay.civilianPoi)。 /// 数据来源 = UrbanDiversityPOIActionConfig.json 全字段表(3978 条, /// 解析器 UrbanDiversityPoiActionCatalog4229938),消费点 /// GameRouter.Civilians.BuildItemPool。 /// /// ⛑ 两个开关默认全 false ⇒ 不改任何现有行为;只有显式打开才生效。 /// 这也意味着「改坏了」的回滚就是改回 false(不用动代码)。 /// /// ★ 为什么需要它:地块 → 分组的匹配只到「类别」这一层 /// (CivilianDistricts.Categories 11 种 ↔ CivilianGroups.Category 27 种), /// 组内每条是固定的 NpcFormworkId + PoiActionId ⇒ 同一个类别里既有「站着不动」的, /// 也有「带根位移会走开」「需要拿道具」的。全字段表能按动作属性再筛一遍。 /// public sealed class CivilianPoiSettings { /// /// 剔除 ApplyRootMotion = true 的 POI 动作(全表 370 / 3978 条 ≈ 9.3%)。 /// /// ★ 打开的理由:带根位移的动作会让 NPC 真的走出站位 —— 表现为 /// 「铺完过一会儿人就挪走了 / 几个市民叠在一起」。这是 POI 表里唯一能直接判定 /// 「这个动作会让人离开点位」的字段。 /// /// ⛑ 副作用:候选池会变小。实测每个类别剩 13~46 人,剔掉约 9% 不会抽空; /// 真抽空时 BuildItemPool 有「退回未过滤」的兜底。 /// public bool ExcludeRootMotion { get; init; } /// /// 剔除需要拿道具的 POI 动作(ActionTag4 含「道具」,全表 763 / 3978 条 ≈ 19%)。 /// /// ★ 打开的理由:这类动作(端杯子、用电脑、拿话筒)要客户端把道具挂到手上, /// 服务端生成时不带道具 ⇒ 容易出现「空手比划」。 /// public bool ExcludeProps { get; init; } } /// /// 官方「过街等待区 + 槽位」站位源(paths.pedSpawns)的使用设置。 /// /// ★ 这份数据是什么:客户端 MassAI.MassCrowd.CrowdWaitAreaData 里那一套 /// 「路口等待区 + 槽位 + 人行横道」的官方坐标,从 ZoneGraph dump 出来的。 /// 每个等待区挂 3~5 个槽位,槽位自带朝向与 urban_diversity_config_id。 /// /// ⛑⛑ 它不是服务端能驱动的东西:整套是客户端原生 ECS(Lua grep 零命中), /// 客户端自己的 crowd 散人本来就在用。我们只是把自己的 NPC 放到同一批坐标上, /// 好看上去像「这撮人也在等灯」。 /// /// ⛑ 三个开关默认的行为:总开关 false ⇒ 完全不读这张表,现有逻辑一行不动。 /// public sealed class CivilianWaitAreaSettings { /// /// 启用官方等待区站位作为额外的点位来源。默认 false。 /// 打开后,街区铺设会先从等待区取点(不足再退回原有的生态区点位)。 /// public bool Enabled { get; init; } /// /// 同一个等待区最多放几个人。默认 2(与捐赠端 PedSpawnTable.NearSpread 同值)。 /// /// ★ 为什么不能塞满:官方一个等待区只有 3~5 个槽位,全占满就成了 /// 「路口一角戳着一排人」;官方画面是「四角各站两三个」。MaxPerArea 就是那个纪律。 /// public int MaxPerArea { get; init; } = 2; /// 取点半径(米)。默认 250。 public float RadiusMeters { get; init; } = 250f; } public sealed class NpcLifeSettings { /// 总开关。默认 false(保持 09-25 的行为,改坏了随时关回来)。 public bool Enabled { get; init; } /// /// 常驻 NPC 落地后立刻给它推一条 SyncFavorNpcSpoonAgentId。默认 true。 /// public bool PushOnPlace { get; init; } = true; /// /// spoonAgentId 怎么取值:agentId(用角色自己的 AgentId = NpcFormworkId,里希 40140040; /// 与出场包 AgentSyncClientInfo.SpoonAgentId 同值,目前唯一能自圆其说的取法)/ /// fixed(用 )/ none(填 0)。 /// public string SpoonAgentIdMode { get; init; } = "agentId"; /// = fixed 时用这个值。 public int SpoonAgentIdFixed { get; init; } /// /// SyncFavorNpcSpoonAgentId 的线格式旋钮:包尾那个可空的 FavorNpcBusyInfo /// 怎么表示 null。0(默认)= 写一字节 0x00(服务端 C# /// SerializeBase.WriteComplex 写 null 就是 0);255 = 写 0xFF; /// -1 = 整字节不写。 /// ⛑ 客户端 RPCDeserializeBase.ReadComplex 是反编译出来的,看着像「0 = 有值」, /// 与 writer 相反 —— 所以两种都留着,客户端报 64003213 反序列化失败就换另一个值再推。 /// public int BusyInfoMarkerByte { get; init; } // —— 时刻表(64219939 / 64400736)—— 09-25 晚补 —— /// /// ★★ 推 spoon 之前先把时刻表推下去。默认 true。 /// /// ⛑⛑ 为什么这条是硬前提(09-25 19:4x 读客户端 lua 才发现的): /// NpcDaliyManager.lua:143 的 OnSyncFavorNpcSpoonAgentId 第一行就是 /// if table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag]) then return end /// ⇒ 客户端没有这个 agentTag 的时刻表时,64003213 整帧被静默吞掉, /// 不报错、不打日志、什么都不做。我们 19:36 那次推完「零反应」就是这个原因。 /// /// 时刻表虽然登录帧 FavorNpcDailyScheduleInfos 里有,但那一份的 /// SpoonAgentId / CurrentSpoonAgentId / SpoonPosition 全是 0 /// (RuntimePayloadFactory),等于「有日程、没有人占坑」。 /// 所以这里重新推一份带 spoon 号的覆盖上去。 /// public bool PushTimeTable { get; init; } = true; /// /// 只推一个 agentTag 的时刻表(走 SyncAgentTagTimeTableInfo 64400736) /// 还是推全量字典(走 SyncFavorNpcTimeTableInfos 64219939)。默认 false = 全量。 /// 单条包体小、字段少,排障时先用它排掉「字典线格式不对」这个变量。 /// public bool TimeTableSingleTag { get; init; } /// /// 是否把 spoonAgentId 也写进当前时段那一段的 /// NpcScheduleInfo.SpoonAgentId(顶层 CurrentSpoonAgentId 无论如何都写)。 /// 默认 true —— 官方那五个字段命名一致,客户端具体读哪个还没验出来,两个都填最稳。 /// public bool TimeTableSpoonInSlice { get; init; } = true; // —— 兜底:POI 动作(68459398)—— /// /// 兜底通道:常驻 NPC 落地后补一帧 SyncAgentPlayPOIAction(68459398) 让它直接播 /// 某个 POI 动作。0(默认)= 不发。 /// /// ⛑ 这是已验证过的原生通道(GameRouter.Ambient.cs:892 / /// GameRouter.Civilians.cs:492/666 一直在用),不是新轮子: /// 跑步机那条是 UrbanDiversityPOIActionConfig 的 /// 198「站姿_运动_跑步机跑步」(MainActionGroup 400 / MainActions [86190])。 /// ⛑ 但 LeftBehindNativePoiActionId 的注释已经警告过:开了生活日程 /// (AgentDataSetsActivityCfgId 非 0)时客户端走生活日程状态,POI 会被盖掉 /// ⇒ 想验这一条就把 gameplay.rosterNpc.dailyLife 关掉(活动 id 归 0)再推。 /// public uint PoiActionId { get; init; } /// 落地后是否自动发 。默认 false(只在需要时开)。 public bool PushPoiOnPlace { get; init; } } public sealed class CombatSettings { public float MaxHp { get; init; } public float Attack { get; init; } public float Defense { get; init; } public float MoveSpeedMultiplier { get; init; } public float PanelDamage { get; init; } public float PanelDefenseDeduct { get; init; } public uint FightStyleTypeId { get; init; } public uint FightStyleId { get; init; } public int[] DefaultUrbanAbilities { get; init; } = []; public WeaponSettings Weapon { get; init; } = new(); public ResourceSettings Resource { get; init; } = new(); public SkillSettings Skills { get; init; } = new(); public SkillChargeSettings[] Charges { get; init; } = []; /// 敌人受击手感(T2-44b 之后新增,2026-09-23 夜)。 /// 三个值全部可在 private-server.json 里改,改完只重启服务端即可,不需要重新编译。 public EnemyCombatSettings Enemies { get; init; } = new(); } /// 敌人「受打击反馈」的三个旋钮。 /// /// ⛑⛑ PoiseDrainPerHit 是手感的开关,历史沿革(都实测过): /// · 100(等于一击的伤害,2026-09-23 上午 11:3x 那个状态)—— 客户端的架势池就是 100, /// 所以每一下都打空 ⇒ 每一下都进破防/倒地,反馈最强。代价是当时没有退出通道, /// 敌人卡在破防里出不来,看起来就是"打不死"。现在退出通道(ReportUnitStandUp 回执 + /// SyncEnemyDisarmState(false) + 5 s 超时兜底)已经有了,所以这个值现在可以安全地开回来。 /// · 34(kimika 捐赠端值,14:4x 之后用的)—— 三下破防,节奏最像官服。 /// · 0(donor 值,15:2x 之后用的)—— 客户端架势池不动,只靠服务端自己的模型破防, /// 反馈最弱(只有 SyncUnitAttacked + 67149 那一下硬直)。 /// 服务端账本的扣架势用的是同一个值(见 GameRouter.LocalEnemyCombat),两边永远同步, /// 不会出现"客户端已经破防、服务端还要再攒两下"的错位。 /// public sealed class EnemyCombatSettings { /// 每次命中从架势池里扣多少。见类注释:100 = 每下破防(反馈最强),34 = 三下破防,0 = 不破防。 public float PoiseDrainPerHit { get; init; } = 100f; /// /// 敌人最大生命值(服务端权威账本的分母,也是 spawn 时推给客户端 /// SyncUnitAttrs{[1]} 与 SyncUnitHp 的那个值)。 /// /// ⛑⛑ 这是 GM/服务端 spawn 的敌人和地图上原生生成的敌人共用的 /// 唯一血量来源(2026-09-27 改为配置驱动,原来是 /// GameRouter.LocalEnemyMaxHp = 1000f 常量): /// · GM 召唤(GameAdminBridge.SpawnAgentAsync)用同一常量播种 spawn 批次; /// · 地图敌人走「首次受击才登记」路径(SendLocalEnemyHpBaselineAsync), /// 登记的也是这个值。 /// ⇒ 改这一个数,两类敌人同时生效。 /// /// 1000 = 旧默认值(配合 LocalEnemyHitDamage=100 ⇒ 第 10 击死, /// 与 LY-3.3 参考服同一手感)。**1500 = 1.5 倍(2026-09-27 用户要求)⇒ 第 15 击死。** /// ⛑ 读取处有 Math.Max(1f, …) 兜底,配 0/负数不会让血条消失。 /// public float MaxHp { get; init; } = 1000f; /// /// EnemyAiCatalog.json 里没有行的模板,是否按同名变体借一份战斗档案 /// (2026-09-29,地盘敌人受击反馈专项)。 /// /// 背景:目录是 dump 脚本对 AgentConfig.json 的投影,漏了一批换皮 id。 /// 地盘 roster 74 个模板里 20 个无行,**其中 18 个是同名同骨架的变体** /// (40120148 电视脑职员无行 / 40120694 电视脑职员有行且带 unarmed 池)。 /// 无行 ⇒ Find 返回 null ⇒ SendEnemyCombatStanceAsync 直接 return, /// **一个姿态包都不发** ⇒ 客户端没有 SyncBattleAnimSet / SyncAgentChangeWeapon, /// 受击反馈只剩我们补的 67149 一次性硬直 = 「硬直段数明显更少」的根因。 /// /// 开(默认):按 Name 回退(同名 = 同骨架,安全)。关 = 改动前的旧行为,用于 A/B。 /// ⛔ 只按 Name,不按 GroupEnemy(那批的 GroupEnemy 全是 40211001,粒度不够)。 /// public bool ProfileNameFallback { get; init; } = true; /// 命中时额外推一次 67149 硬直动作(SyncEnemyPlayAction)。 /// /// ⛑ 2026-09-26 Wave 3 已在 json 里关掉(补偿层退役的二值实验): /// Wave 1(FirmHurt 中继 + poiseDrain=0)+ Wave 2(melee 模式空手动画集 + /// SyncBattleAnimSet)落地后,受击表现应当全部由客户端原生管线(hurtEffect Impact + /// 原生 SyncUnitAttacked 消费方)产出。json 翻回 true 即恢复补偿硬直,用于 A/B 对照。 /// 若实测反馈消失 ⇒ 原生管线还没接上,翻回 true 并查 [ENEMY-STANCE] / firm= 日志。 /// public bool PushStaggerAction { get; init; } = true; /// 命中时推 SyncUnitHurtEffect(68349329)。客户端自己的受击管线由它驱动。 public bool PushHurtEffect { get; init; } = true; /// /// SyncUnitHurtEffect 的 HurtImpact / HurtLeftOrRightHit /// 查表而不是硬编码(2026-09-26,移植捐赠端 7k-v0.5 /// HurtEffectCatalog4229938 + ResolveHurtPresentation)。 /// /// 开:按 hit.HurtEffectId 去 HurtEffectCatalog.json(1841 行,build 4229938 /// HurtEffectConfig 的投影)里取作者配的 Impact / LeftOrRight / SoundId。 /// 关(false):回到 2026-09-26 之前的行为 —— HurtImpact = 1 / /// HurtLeftOrRightHit = 0 两个常量。 /// /// ⛑ 兜底同捐赠端:Math.Max(1, authored.Impact)。表里有 306 行 Impact 是 0, /// 直接发给客户端就是"完全没反应",比硬编码 1 还差,所以下限夹到 1。 /// 表文件缺失时 fail-open 退化成旧常量(见 HurtEffectCatalog4229938.Load)。 /// public bool HurtEffectCatalogLookup { get; init; } = true; /// /// 枪械命中的表现只升不降(2026-09-26 Wave 1 修订语义):对 PhysicalAttackType 4/5 /// 的精确 FireArmID 预设(55004538 / 55004539 / 55004611-18 / 55004736 / 55004740 / /// 55004741),保留作者行(hurtId / SoundId / LeftOrRight 原样),只把 /// Impact 抬到 ≥ 2(第一可见档)。 /// /// ⛑⛑ 有意偏离捐赠端 7k-v0.5:7K 是把整行替换成 55001201 /// (GameRouter.Enemy.cs:594-600),那会把 55004539 / 55004615 / 55004741 从 /// 作者配的 Impact=4 / SoundId=70400806 打成 Impact=2 / SoundId=0 /// —— 反馈强度和音效双降。拿我们自己的表逐行核对过:这 13 个 id 里 8 行 Impact=1 /// (不可见档,抬到 2 是升)、2 行 Impact=2(不变)、3 行 Impact=4(替换是降)。 /// 只升不降拿到同样的「远程密集硬直」,且不损失任何一行的作者配置。 /// public bool HurtEffectBulletOverride { get; init; } = false; /// /// SyncUnitAttacked.PoiseBasicValue 改发客户端上报的 hit.FirmHurt /// (捐赠端 7k-v0.5 GameRouter.Enemy.cs:447 的取值来源),不再发服务端账本同源的 /// PoiseDrainPerHit。原生战斗管线复活 Wave 1(2026-09-26)。 /// /// ⛑⛑ 实测先行值:logs/console-20260926-105902.log 10 次命中 firm=0.00 /// —— 客户端当前恒报 0,所以开中继 ≈ 发 0(客户端架势池不动、原生破防不再由 /// 服务端包触发,「打着打着忽然定住」的锁死源随之消失)。关掉(false)回到旧行为 = /// 发 PoiseDrainPerHit。若后续 spawn 握手(Wave 2)让客户端算出非 0 的 /// FirmHurt,中继自动接上,不用再改代码。服务端自己的架势账本不受此开关影响 /// (仍按 poiseDrainPerHit 扣)。 /// public bool RelayFirmHurtAsPoise { get; init; } = true; /// /// 敌对单位 spawn 时补骇入标记:AgentSyncClientInfo.HackerBetray = true + /// 在 SyncUnitHp 之后发 SyncUnitHackableState{isHackable=true}(68941159)。 /// 对齐捐赠端 7k-v0.5(GameRouter.Enemy.cs:308 / :357-362;原注释 /// "EnemyRebel.CanUseInternal also requires SpawnInfo.hackerBetray")。 /// 这是 Q4「7K 敌人头顶 HUD 比我们多什么」里的骇入图标,也是原生 spawn 握手的一部分。 /// 只对 hostile 生成生效,市民 / 友方召唤物路径不变。 /// public bool PushHackableState { get; init; } = true; /// /// 敌对单位 spawn 时补飞索/暗杀标记:AgentSyncClientInfo.FeiSuo = true /// (dump.cs:1745249,紧跟 isAttackInSafeMode,我们的 DTO 顺序已核对一致)。 /// /// ⛑⛑ 这是 spawn 契约里唯一从未赋值的敌对相关布尔(恒 false), /// 而暗杀管理器就叫 FeisuoAssassMgr、蹲伏态叫 FeiSuoCrouch, /// 客户端 StealthManager.CrouchAssassinationUnitCheck 的候选集很可能据此筛。 /// 2026-09-26 19:5x 修完「蹲伏被 buff reassert 踢掉」后蹲伏能撑 9 秒(够跑 22 轮 0.4s 检测), /// ReportCanAssassinateEnemies 仍为 0 ⇒ 候选集本身就是空的。 /// /// ⚠️ 语义未 100% 证实(dump 里另有 FeiSuoCrouchLockTarget 指飞索锁目标), /// 故默认开但可一键关。关掉即回到恒 false 的旧行为。 /// public bool PushFeiSuoSpawnFlag { get; init; } = true; /// /// 敌对单位 spawn 的战斗模式(原生战斗管线复活 Wave 2,2026-09-26): /// off(代码默认)= Wave 2 之前的旧行为,一个字节不变; /// template = 解析模板但不发姿态包(7K 对 template 模式本来就跳过 stance); /// melee = 7K 的默认 spawn 模式:有空手技能的模板(2402/3160)卸武器 + /// 空手动画集(AnimationSetConfig 325 十二面空手 / 327 通用胖子空手)+ 攻击集换成空手技能; /// ranged = 有武器且有远程技能(448/3160)才成立,AttackRange=18 + 动画集 337/338/340。 /// /// 生效面:武器(RaidBattleUnitAgent.WeaponId,空手 = 0 是有意的)、技能槽、 /// 攻击 AI 的技能池、spawn 尾部两包 SyncAgentChangeWeapon + SyncBattleAnimSet /// (移植 7K SendEnemyCombatStanceAsync)—— 四处共用 /// EnemyAiCatalog4229938.ResolveSpawnProfile 一份解析,保证三者一致。 /// ⛑ 非法值 fail-safe 落 off(向旧行为倒)。 /// public string SpawnCombatMode { get; init; } = "off"; /// /// melee 模式下是否保留模板自带武器(RaidBattleUnitAgent.WeaponId)。 /// /// ⛑⛑ 2026-10-01 实测(GM 刷 40100010 黑金节奏手):手上没有武器。日志 /// [AGENT] spawned agent=40100010 ... weapon=0 weaponItem=80000126 ... mode=melee/melee /// ⇒ 根因不是客户端不认 WeaponId,而是 SelectCombatProfile 的 melee 分支 /// (移植 7K)把 Weapon 硬写成 0("空手 = 0 是有意的")。受影响的不是少数: /// catalog 3160 条里 1802 条有武器、2402 条属 unarmed 档 ⇒ melee 模式下这批全部被卸。 /// 同一份日志里不在 catalog 的模板(晴 40651922 / 盖斯 40155016)走兜底,反而 /// weapon=98005506(fallback) —— 这也是"有的敌人有武器有的没有"的来源。 /// /// 开(true)= 保留 profile.Weapon(仍为 0 的模板照旧走 98005506 兜底), /// 攻击集与动画集不变(还是空手招 + 325)⇒ 现象是"拿着武器打空手动作"。 /// 关(false)= 回到逐行对齐 7K 的旧行为(weapon=0),一个字节不变。 /// 代码默认 false(fail-safe 向旧行为倒),由 json 显式打开。 /// public bool SpawnKeepWeapon { get; init; } /// /// 敌对单位 spawn 帧 RaidBattleUnitAgent.SourceWeaponId(ulong) 的取值。 /// 0 = 不变(历史行为,恒 0)。 /// /// ⛑⛑ 2026-10-01 探针:spawn 帧的 WeaponId(uint,WeaponConfig 配置 id) 已实测 /// **发非 0 也不挂武器**(40100010 = 98005506;随从走兜底同样 98005506,手上全空)。 /// 而玩家侧武器一律走"实体" —— WeaponUid / destructibleId /// (GameRouter.WeaponDrop.cs:84-96,AskSkillDestructibleCreate 分配真 id, /// 日志 [WEAPON-DROP] create id=…);玩家丢在地上的武器模型是有的 /// (用户 14:1x 确认)⇒ 武器资源不缺,缺的是"敌人拿哪一把"的指认方式。 /// /// 假设:SourceWeaponId 才是"手上那把武器的实体",WeaponId 只是出生配置。 /// 探针用法:游戏里丢一把武器 → console 日志 [WEAPON-DROP] create id= 取 uid → /// 填进本项 → 重启 → 刷敌人,看手上是否出现那把武器。 /// ⛑ 副作用待观察:地上那把可能被"拿走"(实体挂到单位手上)。 /// public ulong SpawnSourceWeaponId { get; init; } /// /// 上面那项的自动模式:spawn 时取本次会话最近一次掉落的武器实体 /// (WorldEntryState.WeaponDrops 里编号最大的那条),覆盖手填的 /// 。 /// /// ⛑⛑ 为什么必须要有它:地上的武器是客户端创建的(AskSkillDestructibleCreate /// 是 C2S,服务端只负责分配 9e12 起的 id 并记账)。服务端一重启 WeaponDrops 就空了, /// 而客户端场景里那个掉落物也大概率不存在了 ⇒ 手填一个旧 uid 去 spawn, /// 实体早已消失,结果必然还是空手 —— 那样无法区分"字段没用"还是"实体早没了"。 /// 自动模式保证用的是"刚刚丢下的那把",实验才可解释。 /// /// 用法:开本项之后,一次会话内「先丢一把武器 → 立刻刷敌人」即可, /// 不用再抄 uid、不用为了改 uid 重启。 /// public bool SpawnSourceWeaponAuto { get; init; } /// /// 每次 SyncUnitAttacked 之前重断言 SyncUnitAttrs{[1]=MaxHp} /// (对齐捐赠端 7k-v0.5 GameRouter.Enemy.cs:425-431,原注释: /// "Model/occur initialization can replace attributes after the spawn snapshot. /// Reassert the denominator before damage/HUD events, without resetting HP.")。 /// /// ⛑⛑ 伤害数字(飘字)的头号剩余嫌疑(2026-09-26):原生飘字是 C# 侧消费 /// SyncUnitAttacked 产生的(§35.5 已定),它需要拿到单位的 MaxHp 当分母。 /// 我们只在 spawn 时发一次 attrs,若模型/occur 重建把它冲掉(7K 注释明说会发生), /// 客户端 MaxHp=0 ⇒ 飘字算不出来。7K 每击前都重断言,我们没有 —— 这是逐字段 /// diff 后唯一还没对齐的伤害链差异。代码默认 false(旧行为),json 开。 /// public bool ReassertMaxHpBeforeHit { get; init; } /// /// 死亡时推 SyncAddUnitState(68026128) 把单位打进 UnitStateConfig.DeadS(state=2)。 /// /// 这是「死后继续挨打 / 倒地被后续连招顶掉」的根因开关。捐赠端 7k-v0.5 /// GameRouter.Enemy.cs:496-506 在 SyncEnemyPrepareDie + SyncUnitHp(0) 之后 /// 必发这一包,源码注释写得很直白: /// "HP alone only changes the client data/UI and does not enter UnitDeathBehaviorModule." /// 也就是只把血打到 0 是不够的——客户端的单位还活着,它原生的受击管线照常跑, /// 于是玩家后续的每一次命中都能重新抢到动作槽,把刚播起来的倒地/死亡动画顶掉。 /// 服务端这边早就有闸门(dead && !shouldStartDeath 直接 return,一个包都不发), /// 但那挡的是我们发的包,挡不住客户端自己演。 /// /// ⛑ 与 fastpatch 的死亡闸门(__DRMK_Dead)不是一回事:那个闸门挂在 hurtHook 里, /// 而我们走的是 "SyncUnitHurtEffect left to native",闸门根本不在链路上(硬规则)。 /// 能让客户端停手的只有"这个单位已经是 DeadS"这个状态本身。 /// /// 关掉(false)即回到 2026-09-25 23:27 的既有行为,用于 A/B 对照。 /// public bool PushDeadUnitState { get; init; } = true; /// /// 伤害包 SyncUnitAttacked 里补全攻击者身份: /// SourceTemplateId 填当前角色模板、HitIndex 填 hit.Id。 /// /// ⛑⛑ 这是对齐,不是已验证的修复。依据只有一处事实:捐赠端 7k-v0.5 /// GameRouter.Enemy.cs:441/446 两个字段都填真实值,我们两个都填 0。 /// 0 是明确的"无效",填真值不可能比填 0 更差,但它是否就是伤害数字缺失的原因, /// 目前没有硬证据——捐赠端日志不打印这两个字段,也没有 diff 可比对。 /// /// 真正的第三候选是 HUD 绑定:见 /// (2026-09-23 的结论,怀疑它会把单位换到另一套战斗模块,HUD 绑的是原来那套)。 /// 那条不用编译,改配置重启即可 A/B,建议先试那个。 /// /// false = 保持 2026-09-26 之前的行为(两个字段都 0)。 /// public bool FillDamageSourceIdentity { get; init; } = true; /// /// ⛑⛑⛑ 伤害包的 attackerId 在客户端上报值为 0 时,回退到「当前操控单位的 unit id」, /// 而不是 Profile.PlayerPid(2026-09-26 伤害数字攻坚·离线取证)。 /// /// 硬证据链(离线反汇编 GameAssembly.dll,IMAGE_BASE 0x180000000): /// 1. LX6.Utils.GuiUtils.HurtFloat(RVA 0x14824610)是画飘字的唯一入口; /// 全 .text(第 7 节,285MB)扫 xref 后它只有 1 个调用点: /// 0x1276261B,位于 SyncUnitAttacked(68299892) 原生消费函数 0x1275F320 内。 /// 2. 该函数里唯一的飘字闸门是 0x12760FF5 CanShowHurtFloat(DamageData.CreationId), /// CreationId == 0 时在 0x12760FA5 直接 je 放行 —— 我们本来就发 0,闸门天然通过。 /// 3. 真正的最后一道在 HurtFloat 内部:unit.IsMe || attacker.IsMe || realAttacker.IsMe /// 三者全 false ⇒ 只打一条 Warn(LogCategory.HurtFloatText = 371) 然后返回,不画飘字。 /// 4. attacker 由 attackerId 经 GetUnit → GetNpcByPid → Unit_GetByPid /// 三段解析(0x1275F48D / 0x1275F4AF / 0x1275F882),解析不到 ⇒ null ⇒ 这条判据恒 false。 /// 5. Profile.PlayerPid = json player.pid(我们 100 / 捐赠端 666)是账号 pid, /// 不在 unit id 空间,三段必空。捐赠端 7k-v0.5 GameRouter.Enemy.cs:400-402 /// 回退的是 world?.ActiveSpiritUnitId ?? Profile.InitialUnitId,所以它能过。 /// /// false = 回到 2026-09-26 之前的旧行为(回退到 Profile.PlayerPid),用于 A/B 对照。 /// public bool AttackerIdFallbackToSpiritUnit { get; init; } = true; /// /// ⛑⛑⛑ 血条默认收起,第一次命中才显示(2026-09-26 02:1x)。 /// /// 根因(客户端判据 EnemyHUDCtrl.lua:551-583 RefreshHpVisible): /// /// canShowHp = cfg.ShowHPBar // AgentQuoteConfig,客户端压缩包,服务端改不了 /// if (canShowHp and cfg.ShowHPBarUnderAttack) or IsFakePerson then /// canShowHp = dataSet.showHpOrUnderAttack // 被打了才 true,10 s 后自动回 false /// end /// shouldShowHp = not isDead and not isBuffHideNameBar and not realInVisiable /// and not isGrabByMind /// and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) ← 我们能动的就是这个 /// and not UnitStateMgr:HasState(unit, UnitStateConfig.NearDeath) /// and gBattleMgr.dataSet.showEnemyHp /// /// 也就是说:血条跟 detectionState 一点关系都没有。detectionState 只绑到 /// OnShowDetectionEffect(:85),驱动的是潜行警戒条。 /// ⇒ 那套改的是警戒条,动不了血条(这就是实测"血条还是一直挂着"的原因)。 /// 唯一 per-unit、且服务端能写的判据是 UnitStateConfig.HideHp = 10266: /// spawn 时 SyncAddUnitState(10266) 收起,第一次命中 SyncRemoveUnitState(10266) 放出。 /// 先例:GameRouter.HackerSummon.cs:341(机械狗顶着敌人血条出戏,就是这么关掉的)。 /// /// 关(false):回到"一生成血条就在那"。 /// ⛑ 只对 server-spawned 的敌人生效 —— client-spawned 的本地单位是客户端自己建的, /// 我们第一次知道它存在就已经是挨打的时候了,没机会提前挂 HideHp。 /// ⛑ 另一个天然差异:模板若配了 ShowHPBarUnderAttack,客户端自己会在 /// GameConfig.HPBarShowTime = 10 秒后收起血条;我们这条路径是"打过就一直显示"。 /// public bool HideHpBarUntilHit { get; init; } = true; /// /// 敌人警戒状态机(2026-09-26,移植捐赠端 7k-v0.5 GameRouter.EnemyWarning.cs)。 /// /// 开:spawn 时只注册 EnemyDetectState.Idle(1) —— 客户端 HUD(血条 + 挂它上面的 /// 警戒条)绑在 unitDataSet.detectionState 上,Idle 时**不显示**; /// 玩家走进 才升到 Fight(4),血条才出现; /// 走远又退回 Idle。状态不变不发包(2 s 节流),单轮最多刷 8 个。 /// /// 关(false):回到 2026-09-26 之前的行为 —— engage 时直接发 Fight(4), /// 敌人一生成血条就在那(这正是用户说的"我们的血条默认就显示")。 /// /// ⛑ 只对 server-spawned 的敌人(GM 生成那条路)生效。client-spawned 的本地单位 /// 是"第一次命中才进账本",本来就是击中才显示,不需要这套。 /// public bool WarningStateMachine { get; init; } = true; /// /// 警戒半径(米)。玩家在这个距离内 → Fight(4),之外 → Idle(1)。 /// 捐赠端是硬编码 25(EnemyFollowDistanceSquared(enemy, state) > 25f * 25f 就回落 Idle)。 /// public float WarningRadiusMeters { get; init; } = 25f; /// /// ⛑⛑⛑ AgentSyncClientInfo.beHitType(UX.Game.PlotAgentBeHitType)—— 受击反馈 /// 由谁结算的总开关,只在 spawn 那一刻发出,改完要重新生成单位才生效。 /// /// · 2 CanBeHitWithDamage(默认):单位可被命中,但伤害交给服务端结算。 /// 客户端自己不扣血、不致死,全靠服务端回 SyncUnitAttacked 才看得见反应 /// ⇒ 我们的 HP 台账 / 架势条 / 67149 硬直 / 88300 倒地 / 死亡链全部成立。 /// · 1 CanBeHitNoDamage(同伴那条路):可被命中,客户端本地直接播受击表现, /// 不等服务端。手感更"原生",但代价是服务端很可能拿不到命中上报—— /// 2026-09-25 用户实测「留场同伴打不死」就是这么来的:单位压根没进过我们的台账。 /// · 0:不可命中,任何受击特效都挂不上(09-15 就栽过一次)。 /// /// ⛑ 改成 1 之前先想清楚要哪个:这条字段是「原生受击」与「能打死」的二选一, /// 不是强弱档位。改完必查三件事:还有没有 [ENEMY] hit 日志、还能不能打死、 /// 是不是出现了"打两下抖两次"的双重反馈。 /// /// ★★ 2026-09-25 实测(推翻了上面的二选一假设,别再按它做决定): /// 敌人改成 1 之后 —— 依然能打死、[ENEMY] 日志照旧、 /// 受击表现和 2 没有任何可察觉的区别。 /// ⇒ 对本 build 的敌人来说这个字段不是受击表现的来源,两边等价。 /// ⇒ 顺带否掉一个流行解释:「留场同伴手感更好」不是因为同伴 beHitType=1, /// 差别在单位本身(英雄模型的受击动画资源齐全),不在这个字段。 /// 值保持 2(与服务端的伤害/架势/死亡链语义一致),不要再为"手感"去动它。 /// public byte BeHitType { get; init; } = 2; /// /// 给服务端生成的敌人手上塞个道具并让它正在用 —— 即 r37 那条 /// SyncAgentBelongings + SyncAgentUseBelongingChanged 的组合。 /// /// 目的是让敌人不再是攥着空气的空港口模型:usage 6 =「右手玩手机」在客户端会真的把手 /// 机模型挂到骨架右手上、并叠一层上半身姿态。默认值(80003481 手机 / usage 6)与 /// gameplay.hackNpc 给路人发的是同一套,只改敌人的时候不会动到城区 NPC。 /// /// ⛑ 服务端的活儿做完这一步就结束了 —— 道具模型怎么挂到骨架上、挂件 resource 怎么流送, /// 全是客户端自己的事;服务端既不能换它的姿势也不能改它的挂载点,只能决定"给不给"。 /// /// ⛑⛑ 2026-09-25 实测:默认关。 /// 开着的样子 = 敌人手里挂着手机模型,但不会玩 —— 待机没有 POI 姿态、 /// 上半身不动,只是多了一个穿模风险的挂件,视觉收益为零。 /// 根因:usage 只在「生活日程 / POI」那条管线里才会真的把姿态播出来 /// (见 SyncAgentPlayPOIAction 与 AgentDataSetsActivityCfgId); /// 敌人走的是战斗行为树,不与那条管线接通 ⇒ 光给 belongings 只等于给了个静态模型。 /// 想让敌人真的"玩",要先把敌人的行为树挂上 POI/日程,不是改这个开关。 /// public bool GiveProp { get; init; } = false; /// 手里的道具 id(SceneitemConfig / belongings 域)。默认 80003481 = 手机。 public uint PropSceneItemId { get; init; } = 80003481; /// /// BelongingBelongingUsageConfig 的 usage id。默认 6 = 右手玩手机。 /// 换成 1 = 右手正常打电话;其余多为拍照 / 喝咖啡之类,挑的时候注意 Boss 会不会因此不抬手攻击。 /// public uint PropUsageId { get; init; } = 6; } public sealed class WeaponSettings { public uint TemplateId { get; init; } public ulong InstanceId { get; init; } /// When true, every account-armory weapon is pushed (SyncArmoryAddWeapon) at login /// and after each scene load, so the whole catalog is unlocked in the weapon wheel/backpack. public bool UnlockAll { get; init; } } public sealed class ResourceSettings { public uint Id { get; init; } public float Max { get; init; } } public sealed class SkillSettings { public uint Common { get; init; } public uint PressCommon { get; init; } public uint HeavyCommon { get; init; } public uint Dodge { get; init; } public uint DodgeAttack { get; init; } public uint GrappleAttack { get; init; } public uint Active { get; init; } public uint Unique { get; init; } internal IEnumerable All() { yield return Common; yield return PressCommon; yield return HeavyCommon; yield return Dodge; yield return DodgeAttack; yield return GrappleAttack; yield return Active; yield return Unique; } internal uint ByName(string name) => name.ToLowerInvariant() switch { "common" => Common, "presscommon" => PressCommon, "heavycommon" => HeavyCommon, "dodge" => Dodge, "dodgeattack" => DodgeAttack, "grappleattack" => GrappleAttack, "active" => Active, "unique" => Unique, _ => throw new InvalidDataException($"Unknown gameplay.combat.charges skill name '{name}'.") }; } public sealed class SkillChargeSettings { public string Skill { get; init; } = string.Empty; public uint Current { get; init; } public uint Max { get; init; } public float Period { get; init; } } public sealed class WebTraversalSettings { public uint MaleProtagonistId { get; init; } public uint FemaleProtagonistId { get; init; } public uint PersistentGrappleBuffId { get; init; } public int GrappleRearmDelayMs { get; init; } public uint[] SharedBuffIds { get; init; } = []; public uint[] MaleExtraBuffIds { get; init; } = []; public uint[] FemaleExtraBuffIds { get; init; } = []; public uint[] ForbidStateIds { get; init; } = []; /// Capability buffs granted to EVERY controllable spirit, protagonists included. /// /// Ported from the donor build (2026-09-23). Before this the non-protagonist branch of /// WebTraversal.CapabilityBuffIds returned Array.Empty, so switching to Dila / /// Seymour / ... dropped the whole exploration kit and left only the one buff /// CharacterCapabilities knows for that template. /// /// ⛑ Keep this list to buffs whose BuffConfig.IsHidden is true: a non-hidden entry renders a /// permanent HUD icon (the same effect documents). public uint[] CommonCapabilityBuffIds { get; init; } = []; /// Extra capability buffs appended to for one /// spirit only, keyed by template id. /// /// Deliberately empty by default: our CharacterCapabilities already resolves per-spirit /// unlocks from UrbanBadgeConfig (data-driven, and it corrects two ids the donor build /// hard-codes wrong). Fill this only to override that for a single template. public Dictionary SpiritCapabilityBuffIds { get; init; } = []; /// Publish the resident build/web buffs (currently only the grapple-dive damage /// buff) with the login capability set. /// /// That buff is the ONLY entry in the whole capability set whose BuffConfig.IsHidden is /// false, and the client renders a HUD icon for exactly that predicate — so leaving this /// on (the historical behaviour) pins a permanent buff icon on the HUD. Turning it off /// removes the icon but also removes the +500% grapple-dive damage it carries. public bool PushResidentBuildBuffs { get; init; } = true; /// 给主角下发原生飞索能力门 SyncPaokuLimitChange(68974941)。 /// /// 飞索 buff()只解决「能不能放」,不解决 /// 「C 键与白圈在不在」:后者由 AbilityLimitConfig.HasFeisuoAbility(3013) 门控, /// 客户端 PlayerParkourLimitModule 收到后才会挂 State_HasFeisuoAbility(2318)。 /// ⛑ 关掉这个开关即回到改动前的状态(只有 buff、C 键无反应)—— 这就是回滚方式。 public bool PushFeisuoAbilityGate { get; init; } = true; /// AbilityLimitConfig 的能力 id —— 3013 = HasFeisuoAbility /// (dump.cs:1026235,用我们自己的客户端 dump 验证过,不依赖捐赠端)。 /// 填 0 = 用内置默认 3013。 public uint FeisuoAbilityLimitId { get; init; } /// 回应蹲伏暗杀裁决:收到 ReportCanAssassinateEnemies(67161256) 后 /// 回 SyncEnemyCanAssassinate(68218494)。 /// /// 客户端自己会算「谁在我背后」(StealthManager 每 0.4s 一次),但授权在服务端: /// 不发这一条,canAssassinate 恒 false,EnemyInteractCore 不挂交互、 /// HUD 不出暗杀提示也不亮按钮。 /// ⛑ 关掉即回滚到改动前(HUD 无暗杀提示)。 public bool AnswerCrouchAssassination { get; init; } = true; /// 下发暗杀能力门 AbilityLimitConfig.UnlockAssassin(5051) /// → State_UnlockAssassin(1333),随 SyncPaokuLimitChange 一起发。 /// /// ⛑⛑ 这是暗杀 HUD 的前置门,比裁决回包更靠前:不发它,客户端 /// StealthManager.CrouchAssassinationUpdate 根本不跑, /// ReportCanAssassinateEnemies 一次都不会发(实机:19 次蹲伏 / 0 次上报)。 /// 关掉即回滚到「蹲得了但永远没有暗杀提示」。 public bool PushAssassinationAbilityGate { get; init; } = true; /// AbilityLimitConfig.UnlockAssassin 的 id —— 5051(dump.cs:1026213)。 /// 填 0 = 用内置默认 5051。 public uint AssassinationAbilityLimitId { get; init; } /// 给璎珑(Yinglong / 15021040)下发专属跑酷能力门 SyncPaokuLimitChange(68974941)。 /// /// 门 = AbilityLimitConfig.KillingPrototype(3040),Desc「开启樱珑八步赶蝉」, /// 挂的 tag 是 State_CanKilling(1306)。这就是「虐杀原形跑酷」:地面疾跑/跳跃/边缘 /// (574/575/577)与爬墙(2502/2503)共用同一套动作态(ParkourState 10062「璎珑跑酷」)。 /// ⛑ 关掉即回滚到改动前 —— 只剩基础爬墙大跳与空中冲刺。 public bool PushKillingAbilityGate { get; init; } = true; /// AbilityLimitConfig.KillingPrototype 的 id —— 3040。填 0 = 用内置默认 3040。 public uint KillingAbilityLimitId { get; init; } /// 唯一拿到跑酷门的角色模板 id —— 15021040 璎珑。填 0 = 用内置默认 15021040。 /// /// ⛑ 只给这一个角色发:其余角色的 ABP 未必带 MovementModeType.Yinglong(10) 的动画数据, /// 挂了 tag 也可能没表现。 public uint KillingSpiritId { get; init; } /// 角色 → 专属能力门列表(AbilityLimitConfig.Id),键是角色模板 id。 /// /// 上面三个 killing* 单值开关的通用化版本:一个角色可以同时带多张门 /// (例:"15020990": [5040, 5052] = 连跳 + 大跳)。 /// 优先级:字典命中就用字典,没命中才回落 killing*。 /// 留空字典 = 完全保持旧行为 ⇒ 可以直接清空来回滚这一层。 public Dictionary SpiritAbilityGates { get; init; } = []; /// ★ 跑酷状态 → 临时能力门(键 = ParkourStateConfig.Id,值 = AbilityLimitConfig.Id 列表)。 /// /// 与 (常驻门)的区别:严格跟状态 —— /// 客户端上报 OnParkourStateChange(67897801) 命中某个键 ⇒ SyncPaokuLimitChange /// isAdd=true 下发;一旦离开这个 state ⇒ 立刻 isAdd=false 撤回。 /// 只在 0→1 / 1→0 的边沿发一次(WorldEntryState.ActiveParkourGateStates 记账), /// 不会跟着 1.2 次/秒的上报刷屏。 /// /// 当前用途:ParkourState 10081「八步赶蝉jump」(= 璎珑虐杀原形跑酷,见 TOPIC §11.10 /// 实机证据:全量 604 份日志里只在下发 3040 之后的两个会话出现过)⇒ 大跳 5052。 /// 例:"10081": [5052]。留空字典 = 整层关闭 ⇒ 可直接清空回滚。 public Dictionary ParkourStateGates { get; init; } = []; /// parkourStateGates 的撤销策略: /// /// "onLeave" —— 严格跟状态:离开该 ParkourState 立刻 isAdd=false 撤回。 /// "session"(默认)—— 首次触发后本次会话一直保留(切角色换单位才重置)。 /// /// ⛑ 为什么默认不是 onLeave:2026-10-02 实机证明 10081「八步赶蝉jump」是瞬时态(服务端日志 /// add→remove 间隔只有 1 秒,甚至同一秒内来回),严格跟状态会让门几乎立刻被撤 ⇒ /// 表现是「第一次触发后大跳正常,第二次就没了」。session 模式下只 add 一次、永不撤, /// 语义仍是「跑酷触发之后才可以用」,且不会抖动。 public string ParkourStateGateRelease { get; init; } = "session"; /// ★ 主动播种 SyncEnemyCanAssassinate(68218494) —— 不等客户端上报。 /// /// 硬规则 192:客户端的候选筛选是自举死锁。FeiSuoCrouchManager.CheckCanCrouchAss /// → PreCheckCanAss(0x131F6700) 要求目标的 BattleDataModule.canAssassinate /// (私有字段偏移 0xB1)== true,而该字段全 .text 唯一写入点是 /// SyncEnemyCanAssassinate 的接收函数(0x127D102F mov byte [rax+0xB1], bl, /// set_CanAssassinate 零调用者)。可这条 S2C 名义上是对 /// ReportCanAssassinateEnemies(67161256) 的回应,而上报又必须先过 canAssassinate /// ⇒ 没有任何敌人天生带这个标记,循环永远启动不了(实机:19 次蹲伏 / 0 次上报)。 /// /// 官方靠 Spoon 剧本动作 L18.Spoon.EnemySetCanAssassinate(dump.cs:3194822) 播种, /// 我们没有 Spoon 系统 ⇒ 用这条主动下发替代。 /// ⛑ 关掉即回到「等上报才回包」的死锁态。 public bool PushAssassinationSeed { get; init; } = true; /// 播种半径(米):只有玩家这个距离内的敌人才会被授予 canAssassinate。 /// 客户端自己的 CheckCanCrouchAss 还有更严的距离/视野中心/遮挡门,这里只做粗筛。 public float AssassinationSeedRadiusMeters { get; init; } = 12f; /// 播种扫描间隔(毫秒)。挂在敌人 AI worker 的 100 ms tick 上,内部自己节流。 /// 每个敌人每次蹲伏只播种一次(离开半径后重新进入才会再播种)⇒ 调小也不会刷包。 public int AssassinationSeedIntervalMs { get; init; } = 500; } public sealed class UiSettings { /// White text rendered next to UID. This is the user-facing watermark setting. public UidLabelSettings UidLabel { get; init; } = new(); public bool RemoveStockConfidentialLabel { get; init; } public CutscenePanelSettings CutscenePanel { get; init; } = new(); } public sealed class CutscenePanelSettings { public bool Enabled { get; init; } = true; public string Host { get; init; } = "127.0.0.1"; public int Port { get; init; } = 17666; } public sealed class UidLabelSettings { public bool Enabled { get; init; } public string Text { get; init; } = string.Empty; } public sealed class LoggingSettings { /// Project-relative directory for console and packet logs. public string Directory { get; init; } = string.Empty; public ConsoleLoggingSettings Console { get; init; } = new(); public PacketLoggingSettings Packets { get; init; } = new(); } public sealed class ConsoleLoggingSettings { public bool Enabled { get; init; } } public sealed class PacketLoggingSettings { public bool Enabled { get; init; } public bool IncludeHex { get; init; } public bool IncludeDecoded { get; init; } /// Maximum packet body bytes written as hex; 0 means unlimited. public int MaxBodyBytes { get; init; } } public sealed class PathSettings { /// Directory containing client JSON configs such as FightSpiritConfig.json. public string ClientConfigs { get; init; } = string.Empty; public string ZoneGraphLanes { get; init; } = string.Empty; /// /// ★★ 路口 / 红绿灯相位表(2026-10-02 从捐赠端 PrivateServerPor 搬来)。 /// 格式(CSV,首行表头): /// zone_index,x,y,z,period_count,current_period_index,next_period_index,rail_period_index, /// open_lanes,period_open_lanes,period_light_flags /// · period_open_lanes = 0:|1:403;404;405(| 分相位、; 分车道) /// · period_light_flags = 0:2,2|1:4,4 —— 客户端 blob 里每相位每灯片组的状态位 /// (MassTrafficLightStateFlags:PedestrianGo=2 / VehicleGoStraight=4 / Left=8 / Right=16); /// **本仓不解析它**(灯色由客户端按 period index 自己算),留着只为对账。 /// 数据 171 行,lane id 与 vehicle_lanes.csv 同源(已验证 29/29 命中)。 /// public string Intersections { get; init; } = "Ananta.Server/Ananta.App/data/zonegraph_intersections.csv"; /// /// ★★ 官方 ZoneGraph「行人过街等待区 + 槽位」表(2026-10-02 从捐赠端 PrivateServerPor 搬来)。 /// CSV 列:wait_area_index,slot_index,x,y,z,forward_x,forward_z,urban_diversity_config_id, /// 数据 4070 行。真身是客户端 MassAI.MassCrowd.CrowdWaitAreaData(含 CrossLaneHandle /// 人行横道)—— **纯客户端原生 ECS,服务端发不进去**;本表只用来让服务端自建 NPC /// 站在官方人群本来会站的地方,并按「同一等待区最多 N 个槽位」的纪律散布(见 /// PedSpawnTable4229938.NearSpread)。 /// ⛑ 缺失不报错,退化为空表(Available=false)。 /// public string PedSpawns { get; init; } = "Ananta.Server/Ananta.App/data/zonegraph_ped_spawns.csv"; /// Directory with extracted world data (Gadget.json, SceneItem.json, /// Destructible.json, gadget_cells.json, gadget_placements.json). public string WorldData { get; init; } = string.Empty; /// Disposable generated fastpatch staging directory. public string RuntimeFastpatch { get; init; } = ".runtime/fastpatch"; } internal static class PrivateServerConfigStore { private static PrivateServerConfig? _current; private static string? _configPath; internal static PrivateServerConfig Current => _current ?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before protocol/gameplay code."); internal static string ConfigPath => _configPath ?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution."); private static string? _projectRoot; /// Subscribers that want to hear about hot reloads (console/log). internal static Action? OnReload; /// /// Re-reads the config file and swaps in place — no restart needed. /// /// Why this exists: tuning things like the metro seat counts is an experiment loop (change a /// number, look at the game, change it again), and a full restart per attempt turns every knob /// into a five-minute edit. Behaviour that reads config through PrivateServerConfigStore.Current /// on every use (that is how MetroGadgetSettings and friends are written) picks the new /// values up immediately; anything that cached a value at startup keeps its old one, which is /// the intended trade-off — do not hot-swap what you cannot re-derive. /// internal static bool Reload(out string message) { try { var (config, path) = PrivateServerConfigLoader.Load(); _current = config; _configPath = Path.GetFullPath(path); _projectRoot = FindProjectRoot(_configPath); message = $"已热重载 {_configPath}"; OnReload?.Invoke(message); Console.WriteLine($"[CONFIG] {message}"); return true; } catch (Exception ex) { message = $"热重载失败:{ex.Message}"; Console.WriteLine($"[CONFIG] {message}"); return false; } } /// /// Watches private-server.json and reloads shortly after it is written. Editors do several /// writes per save, hence the debounce. /// internal static void EnableAutoReload() { if (_watchTimer is not null) return; try { var path = ConfigPath; _watchTimer = new Timer(_ => DebouncedReloadTick(), null, Timeout.Infinite, Timeout.Infinite); _watcher = new FileSystemWatcher(Path.GetDirectoryName(path)!, Path.GetFileName(path)) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, EnableRaisingEvents = true, }; _watcher.Changed += (_, _) => DebouncedReload(); _watcher.Created += (_, _) => DebouncedReload(); Console.WriteLine($"[CONFIG] 已开启配置文件热重载:{path}"); } catch (Exception ex) { Console.WriteLine($"[CONFIG] 无法开启热重载(不影响正常使用):{ex.Message}"); } } private static FileSystemWatcher? _watcher; private static Timer? _watchTimer; private static void DebouncedReload() { if (_watchTimer is null) return; _watchTimer.Change(TimeSpan.FromMilliseconds(400), Timeout.InfiniteTimeSpan); } private static void DebouncedReloadTick() { Reload(out var message); Console.WriteLine($"[CONFIG] 文件变更 -> {message}"); } internal static string ProjectRoot => _projectRoot ?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution."); internal static void Initialize(PrivateServerConfig config, string configPath) { _current = config; _configPath = Path.GetFullPath(configPath); _projectRoot = FindProjectRoot(_configPath); } private static string FindProjectRoot(string configPath) { var explicitRoot = Environment.GetEnvironmentVariable("DRMK_ROOT"); if (!string.IsNullOrWhiteSpace(explicitRoot)) return Path.GetFullPath(explicitRoot); static string? SearchUp(string? start) { if (string.IsNullOrWhiteSpace(start)) return null; var first = new DirectoryInfo(Path.GetFullPath(start)); // Prefer the solution root. A copied config may also exist under bin/Debug, but that // directory is not the project root and must not become the base for ClientData/.runtime. for (var current = first; current is not null; current = current.Parent) if (File.Exists(Path.Combine(current.FullName, "AnantaPS.sln"))) return current.FullName; // Standalone deployments may not contain the solution file. In that case a config // directory is the best available root marker. for (var current = first; current is not null; current = current.Parent) if (File.Exists(Path.Combine(current.FullName, "config", "private-server.json"))) return current.FullName; return null; } var fromConfig = SearchUp(Path.GetDirectoryName(configPath)); if (fromConfig is not null) return fromConfig; var fromBase = SearchUp(AppContext.BaseDirectory); if (fromBase is not null) return fromBase; var fromCurrent = SearchUp(Directory.GetCurrentDirectory()); if (fromCurrent is not null) return fromCurrent; throw new DirectoryNotFoundException( "Cannot locate the DRMK project root. Set DRMK_ROOT or launch from the project directory."); } internal static string ResolveProjectPath(string configuredPath) => Path.IsPathRooted(configuredPath) ? Path.GetFullPath(configuredPath) : Path.GetFullPath(Path.Combine(ProjectRoot, configuredPath)); } internal static class PrivateServerConfigLoader { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase) }, }; internal static (PrivateServerConfig Config, string Path) Load() { var explicitPath = Environment.GetEnvironmentVariable("DRMK_CONFIG"); var candidates = new List(); if (!string.IsNullOrWhiteSpace(explicitPath)) candidates.Add(explicitPath); candidates.Add(Path.Combine(Directory.GetCurrentDirectory(), "config", "private-server.json")); candidates.Add(Path.Combine(AppContext.BaseDirectory, "config", "private-server.json")); var path = candidates.Select(Path.GetFullPath).FirstOrDefault(File.Exists) ?? throw new FileNotFoundException("Cannot find config/private-server.json. Set DRMK_CONFIG to an explicit path if needed."); var json = File.ReadAllText(path); var config = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidDataException($"Configuration file is empty: {path}"); config.Validate(); return (config, path); } } // ==== R142 自 v1.9 移植(v2.0 上游没有这两个设置类)==== public sealed class PartyCampSettings { /// False = never place anything. Rollback lever, no rebuild needed. public bool Enabled { get; init; } = true; /// /// 锚点到玩家的额外半径余量(米)。默认 0 = 只用 PartyCamp.json 里每个场地自己的 radius。 /// public float RadiusBonus { get; init; } = 0f; /// /// Which S2C channel places the props: /// 0 = SyncGadgetAdd(68843189) one entity per push ← 已验证 /// 1 = SyncGadgetAOIAddAndRemove(68169646) /// 2 = both of the above /// 3 = SyncGadgetAddList(68925813) one push for the whole camp /// 0 is the one proven to build the entity. /// public int Channel { get; init; } = 0; /// /// PackedGadgetInfo.iScale sent with every prop. Must stay 0: 100 produces an entity with the /// right name and a working interact point but transform scale (0,0,1) — no prefab, and the /// interact radius collapses. Same trap as the gym equipment and the prop restock. /// public int IScale { get; init; } = 0; /// Log the placements but push nothing. Dry-run lever for a fresh capture. public bool LogOnly { get; init; } = false; /// /// ★ 2026-09-30:摆放时**跳过**这些 pathId(用来去掉不想出现的场地家具)。 /// 例:舞会那道「离开舞会的门」Gadgets_Party_DanceDoor(514708797) 用户明确不要 ⇒ /// "skipPathIds": [514708797]。默认空 = 一件不少,与改动前完全一致。 /// ⚠ 只作用于 PartyCamp 这条链,不碰市民 / 机关网格 / PropRestock 等其它刷法。 /// public uint[] SkipPathIds { get; init; } = []; } public sealed class MiniGameStallSettings { /// False = 一件都不摆。回滚开关,不需重编译。 public bool Enabled { get; init; } = true; /// 锚点到玩家的额外半径余量(米)。默认 0 = 只用 MiniGameStalls.json 里每个点位自己的 radius。 public float RadiusBonus { get; init; } = 0f; /// /// Which S2C channel places the props: /// 0 = SyncGadgetAdd(68843189) one entity per push ← 已验证能建实体 /// 1 = SyncGadgetAOIAddAndRemove(68169646) /// 2 = both of the above /// 3 = SyncGadgetAddList(68925813) one push for the whole village /// public int Channel { get; init; } = 0; /// /// 摊位实体 PackedGadgetInfo.iScale。必须 **0**(单位值 = scale 1.0)。 /// ⛔ 100 ⇒ transform scale (0,0,1) ⇒ 零尺寸空壳:实体在、模型不在(看不见摊位)。 /// ⛔ 与下面 是**同名不同尺度的两个字段**(铁律 39): /// 这个走 Gadget 通道、基准 1;那个走 Destructible 通道、基准 1000。 /// public int IScale { get; init; } = 0; /// /// ★ 刮刮乐「走近自动叫面板」(2026-10-03 移植自捐赠分支 DRMK-PS_09_HyNB-龙栖村小游戏 R156)。 /// /// 客户端**从不主动发** `AskStartScratchGame`(2349 个 Lua 里 0 命中、9 份 packets 日志里 0 命中), /// 面板的唯一入口是服务端推 `SyncStartScratchGameZone`(68649573) /// (`GameSceneToClientImpl.lua:1131` → `gScratchGameManager:Show(gadgetUid, subType, result)`, /// `result == nil` 就直接 return ⇒ 不出选项)。 /// ⇒ 服务端不主动推,玩家永远只看到「通用交互提示」而没有选项。 /// ⚠️ 默认 false:面板没有退出按钮,自动弹会把玩家困在面板里(捐赠端 R158 定案)。 /// 想恢复「走近自动弹」把配置 `scratchAutoOpen` 改成 true 即可,热重载生效。 /// public bool ScratchAutoOpen { get; init; } = false; /// 触发半径(米):玩家从圈外走进来推一条。 public float ScratchOfferRadius { get; init; } = 4f; /// 迟滞半径(米),须 > ,否则边界上会连推。 public float ScratchRearmRadius { get; init; } = 9f; /// /// 气球 DestructibleInfo.IScale。 /// /// ★★★★★ R109 —— **原值 1000 作废,改 100。** 依据: /// ① 本游戏里**唯一被用户实测确认看得见**的同族(保龄球,同一个 Destructible 通道、 /// 同一份 DestructibleInfo 结构)在 GameRouter.GameGround.cs:712/735 用的是 **100**; /// 气球用 1000、从来没人看见过 —— 这是目前唯一一个「能跑 vs 不能跑」的参数差。 /// ② 注入脚本 gym_tools/balloon_fix.js:3518-3526 自己记着一条实测教训: /// placeInfo 里对应这一格的量不是「缩放倍率」而是**尺寸索引/枚举**, /// **写 1000 是越界枚举 ⇒ 引擎把 localScale 算成退化值 ⇒ /// 症状恰是「hold 拿到了、坐标对、有碰撞有音效、就是看不见」** —— 与用户现象逐字吻合。 /// ③ 「必须 1000」的原始出处是对照包 Lyco,但**该包的作者在代码里自注 /// `ture 无 spawn 能力`**(GameRouter.Balloon.cs:41)⇒ 它根本没真正 spawn 过气球, /// 1000 从未被验证。用户也明说「Lyco 没有说打气球可以玩」。 /// ⇒ 旧注释「1000 = scale 1.0(与 WorldContent 现行约定一致)」与「真机跑通」两句均**作废**。 /// ⛔ 与 Gadget 通道的 PackedGadgetInfo.iScale(必须 0)仍是同名不同尺度,别混。 /// public int BalloonIScale { get; init; } = BalloonWire.DefaultIScale; /// /// ★★★★★ R109 —— **诊断用扫描**:非空时,**按列**给这一局的 12 个气球上不同的 IScale, /// 于是**一次重启就能同时试 4 个候选值**,用户只要报告「哪一列看得见」即可定值。 /// · 下标 = 列号(0..3,从玩家左手边数起),元素 = 该列用的 IScale。 /// · 空(默认)= 关闭扫描,全部用 (正常玩法必须关)。 /// 为什么值得:每次改 IScale 都要重启服务端,4 个候选值 = 4 次重启;扫描把它压成 1 次。 /// 读数:气球墙是 4 列 × 3 行,列序 = 生成序(ordinal/rows)。 /// public int[] BalloonIScaleSweep { get; init; } = System.Array.Empty(); /// /// ★★★★★ R122 —— **开新一局前,把上一局没打掉的气球从客户端显式删掉。**(默认 true) /// /// 病因(2026-09-28 07:56 真机实测,用户原话「如果我在第一轮没有把气球打完, /// 那么那些气球并不会被销毁,所以在第二轮的时候还是会出问题」): /// /// R1(07:56:08)spawn 16 颗,玩家只打爆 14 颗,剩 2 颗(id 段 …001D/001E); /// R1 局末 07:56:48 Timeout(40s) → 服务端 UnprotectDestructibles 撤保护, /// 07:56:50 的心跳推格 bytes 从 3514 掉回 1642(= 整格重置,不再带 AddInfos); /// 但那 2 颗球在客户端并没有消失:R2(07:56:59 起)里 07:57:15 / 07:57:22 /// 客户端仍然为 …001D / …001E 发来破坏报文 ⇒ 服务端只能打 /// [BALLOON] ignore break(账本已换新段,不认识它们)。 /// /// ⇒ 残留球与**新一局的球落在同一个球位**上(同一面 16 个 balloonWallSpots), /// 子弹先命中残留球的碰撞体 ⇒ 报的是旧 id ⇒ 服务端忽略 ⇒ /// 新球打不掉、也不加分,正是用户说的「第二轮还是会出问题」。 /// /// 修法(两条正交,本开关同时控制): /// /// spawn 前把 SceneItemGrid.Protected 里所有 0xBA11 段的**历史** id 摘掉 /// (否则 5 s 心跳会一直把它们塞回 AddInfos 保活); /// 本次 AOI 报文的 aoiInfo.RemoveIds 填这批旧 id —— 这是 /// DestructibleGridAOIIncrease.removeIds 字段的本职(与 addUniqueIds 成对; /// 见 GameRouter.GameGround.cs:655 的通道说明),客户端据此删除对应 hold。 /// /// 关掉它 = 回到 R121 行为(残留跨局存活),用于 A/B 对照或排障。 /// 判活日志:[BALLOON] purge stale n=<k> ids=[…](k=0 = 上一局打干净了,正常)。 /// public bool BalloonRemoveStaleIds { get; init; } = true; /// /// ★★★★★ R110 —— 气球墙的**锚点来源**。取值 "stall" / "player"。 /// /// "player"(R89~R109 的旧行为)= 玩家上报位姿 + 沿其朝向前方 6 m。 /// "stall"(R110 起默认)= **官方摊位机关自己的位姿**(取自 /// ClientData/4229938/Configs/MiniGameStalls.json,逐条解自关卡块)。 /// /// ★ 为什么改(R110 三维几何实证,龙栖村气球摊): /// /// 官方摊位 Gadget_DaQiQiu 在 (-4750.50,168.60,-2827.60) yaw=315.0; /// 玩家上报位姿 (-4749.00,168.50,-2830.30) yaw=313.3 ⇒ 摊位在玩家正前方 2.97 m; /// 旧算法「玩家前方 6 m」⇒ 气球墙**中心**落在 (-4753.36,-2826.18), /// 比摊位机关**再往前 3.19 m** ⇒ 整面墙落在摊位/背景板**之后**, /// 且只从地面 1.2 m 起 ⇒ 被摊位结构完全遮住 = 「什么都看不见」。 /// /// ⇒ 服务端载荷 / 客户端注入 / 美术资源 / hold / transform 全部正确、物件确实存在, /// 只是**几何上被挡住**。这与用户长期现象「有碰撞有音效坐标对,就是看不见」一致。 /// /// ⛔ 不用客户端 GadgetConfig.json 的 id 空间;本表 pathId 是服务端 catalog 那一套。 /// public string BalloonWallAnchor { get; init; } = "stall"; /// /// 锚点定姿后沿**该锚点**的朝向再前移多少米(默认 0 = 就摆在摊位机关本身处)。 /// 仅在 为空时生效。 /// public float BalloonWallForward { get; init; } = 0f; /// /// ★★★★★ R110 —— **锚点按列扫描**(诊断用)。每个元素形如 "stall:-1.5" / "player:6", /// 下标 = 列号(0..3,从玩家左手边起),冒号后 = 沿该锚点朝向的前移米数。 /// 空(默认)= 全部用 + 。 /// /// 为什么值得:改锚点要重启服务端。按列给不同锚点 ⇒ **一次重启同时试多个摆位**, /// 用户只要报告「第几列看得见」。含一个 player:6 列即为旧行为对照组。 /// public string[] BalloonWallAnchorSweep { get; init; } = System.Array.Empty(); /// 在摊位表里按名字子串找气球摊(默认 DaQiQiu,也覆盖 Gadget_DaQiQiu_Position)。 public string BalloonWallStallMatch { get; init; } = "DaQiQiu"; /// 锚点查找的告警半径(m)。超过它仍会用最近的命中点,只在日志里标出来。 public float BalloonWallStallSearchRadius { get; init; } = 12f; // ===================== R111:摆位参数全部配置驱动 ===================== // // 为什么这么改:`PrivateServerConfigStore` 在 `Ananta.App/Program.cs:11` 调了 // `EnableAutoReload()`(FileSystemWatcher + 400 ms 防抖)⇒ **本文件是热重载的**, // 而 `SpawnBalloonsAsync` 每次进区都重新读 `PrivateServerConfigStore.Current` // ⇒ 改这些数 ⇒ **退出气球区再进一次即可生效,不用重启服务端**。 // ⇒ 摆位调参从「一次重启」压成「一次进出区」。 // // 病因(R110/R111 真机): // ① 旧 `BalloonWallAnchorSweep` 让**每列 forward 不同**(-1.5/0/+1.5/+3.0) // ⇒ 那不是一面墙,是一条**斜穿摊位**的斜排 ⇒ 后 3 列插进摊位内部。 // ② 用户报「气球墙和摊位架子卡在一起了」+「打不爆」= 同一病因: // 球在摊位结构里 ⇒ 看着穿模、子弹打在摊位上。 // ③ 横列距 1.2 m × 4 列 + 球半径 ⇒ 宽 4.6 m,两端插进摊位立柱; // 顶排 1.2+2×0.7 = 2.6 m ⇒ 插进摊位屋梁。 // ⇒ 本轮:**所有列共用同一个 forward(平墙)**,并把宽/高收进摊位开口里。 /// /// stall 锚点下沿**摊位自身朝向**的前移量(m)。负 = 往玩家方向拉出摊位。 /// 空 sweep 时**所有列共用**这一个值(平墙)。默认 -1.5(≈ 摊位正面/帘幕平面)。 /// public float BalloonWallStallForward { get; init; } = -1.5f; /// 墙的横向列距(m)。R111 从 1.2 收到 0.9,避免两端插进摊位立柱。 public float BalloonWallLateralStep { get; init; } = 0.9f; /// 墙最下一排的高度(m,相对摊位原点)。R111 从 1.2 改 1.25(贴着柜台面之上)。 public float BalloonWallBaseHeight { get; init; } = 1.25f; /// 排距(m)。R111 从 0.7 收到 0.5,避免顶排插进摊位屋梁。 public float BalloonWallHeightStep { get; init; } = 0.5f; /// 排数(默认 3)。列数 = balloonCount / rows。 public int BalloonWallRows { get; init; } = 3; /// /// ★★★★★ R115 —— **官方球位表**(世界坐标,逐点硬编码)。每个元素形如 "x,y,z", /// 下标 = spawn 序号 - 1(即 col 外层 / row 内层的展开顺序:先 col0 的全部 row,再 col1…)。 /// /// 取值来源(**不是推算**):R115 运行时探针倒出摊位 prefab 子树 /// Gadget_DaQiQiu_Position_9500000000000/ItemViewNode/QiQiuBan/…, /// 其中 BalloonBoard_Square2(active=true,16 个 BalloonPointXY 子节点) /// 就是官方那面气球墙 —— 节点世界坐标即官方球位。 /// /// 与「几何算法」的关系:本数组非空且全部可解析 ⇒ **几何算法(forward/right/lateral/ /// baseHeight/heightStep)整段停用**,位置一律取本表。空 ⇒ 退回几何算法(R110/R111 行为)。 /// /// 实测形状:4 列 × 4 行 = 16 点;列距 0.4 m、行距 ≈0.348 m; /// 整面墙只有 1.2 m 宽 × 1.05 m 高(相对摊位锚点 (-4750.5,168.6,-2827.6), /// 沿 yaw=315° 的 right 方向展开、离地 0.775–1.82 m)。 /// 旧几何算法给出的是 4.6 m 宽 × 2.6 m 高的散布 ⇒ 这就是「球不在官方位置」的直接原因。 /// public string[] BalloonWallSpots { get; init; } = System.Array.Empty(); /// /// ★★★★★ R116 —— **官方球位整体外移(米)**,方向 = 球位质心 → 玩家(水平)。 /// /// 用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」「球变成一面墙,都凑在一起」。 /// 官方那 16 个点是**贴在板上**的,而板正面就是摊位的装饰招牌/帘布(`p_map01_level_sister_tile_board` /// 与 `Lianzi`,探针实测与球位同在 y 169.4–170.4)。把整组点沿板法线(≈朝玩家)外移,球就浮在 /// 帘布前面。 /// /// 热重载:改本值 ⇒ 退出气球区再进一次即生效。0 = 官方原始坐标。 /// public float BalloonWallSpotPull { get; init; } = 0.35f; /// /// ★★★★★ R118(2026-09-27)—— **官方 16 个球位的「间距倍数」**(1.0 = 官方原样)。 /// /// 用户实测:「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。 /// 官方 `BalloonBoard_Square2` 的 4×4 点确实很挤:列距 0.400 m / 行距 0.348 m, /// 整面墙只有 1.2 m × 1.045 m;而球包围盒是 0.185 × 0.3566 × 0.185 /// (`_r109_authoring.json`)⇒ 上下几乎贴住。 /// /// 算法:以 16 点的**质心**为轴,按本倍率等比放大 x/y/z(只"拉开",不改中心)。 /// /// 热重载:改本值 ⇒ 退出气球区再进一次即生效。1.0 = 官方原始间距。 /// public float BalloonWallSpotSpread { get; init; } = 1.5f; /// /// ★★★★★ R127(2026-09-28)—— **每局重新随机排列 16 个球位**(打散"整齐方阵")。 /// /// 用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。 /// 官方 BalloonBoard_Square2 的 16 个点是 4×4 网格 —— 即使 /// 把它们拉开,**排列仍然是有规律的**(同一行/列一眼看穿)。 /// /// 开启时:每局 spawn 在**同一片区域**内重新随机撒点 —— /// 区域由 现有点集的「主轴(2D PCA)× Y 范围」定义, /// 深度(沿主轴法线)保持不变,所以球始终「上下 + 左右铺开、不往前后排」; /// 并用 做拒绝采样,保证球不互相穿插。 /// /// ⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61:JSON 里多出来的键没有对应属性会被丢弃)。 /// public bool BalloonWallRandomize { get; init; } /// /// ★ R127 —— 球位重排时的**最小间距(米)**,球包围盒约 0.185 × 0.357 × 0.185 ⇒ 默认 0.42。 /// 只在 开启时使用。 /// R140:三段禁带切开后可用区域变小(≈3.13 m²),16 球 ⇒ 0.42 会导致拒绝采样失败, /// 故配置下调到 0.34(球包围盒高 0.357,视觉上仍互不穿插)。 /// public float BalloonWallMinGap { get; init; } = 0.42f; /// /// ★★★★★ R140(2026-09-28)—— **球位禁带**(球不许落在「三块帘子之间的木隔断」上)。 /// /// 用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。 /// 探针实测:三块帘子 Lianzi×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m) /// ⇒ 接缝在 local x ≈ −1.614 与 +0.003;而 R139 写进 /// 的四个「列」正好是**帘子边界** /// (local x = −3.232 / −1.615 / +0.002 / +1.619)⇒ 第 2、3 列整列压在接缝上。 /// /// 每个元素形如 "x,z"(禁带**中心**的世界坐标,取帘子深度处 z=1.315 的接缝点); /// 落点按主轴(2D PCA)投影成 t 区间后,落在 /// 「中心 ± 」内的采样一律拒绝。 /// /// ⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61)。 /// public string[] BalloonWallForbiddenSpans { get; init; } = System.Array.Empty(); /// /// ★ R140 —— 每个禁带的**半宽(米,沿主轴投影)**,默认 0.27。 /// 实测木隔断约 0.22 m 宽 + 球半径 0.093 m ⇒ 0.27 已含余量。 /// public float BalloonWallForbiddenHalfWidth { get; init; } = 0.27f; /// /// ★★★★★ R116 —— **一局时长(秒)**,对应官方 PoiGameConfig.Balloon_CountDown = 40。 /// /// 用户实测/提问:「打完了不应该是结算游戏吗」「这种小游戏肯定会限制只能打几枪吧, /// 不然不限制谁都能打完,没有挑战性」。官方玩法的约束就是这 40 秒:到点服务端主动结束 /// (final score + GameOver + Dispose,走 FinishBalloonRoundAsync), /// 客户端按总分排 endType(>50 GoodEnd / >25 NormalEnd,`BalloonGameStore.OnGameEnd` /// 会在 1.5 s 后自己关面板)。 /// /// 0 或负数 = 关闭限时(不推荐:会回到「谁都能慢慢打完」)。 /// public int BalloonRoundSeconds { get; init; } = 40; /// /// ★ R116 —— 玩具枪弹匣发数(默认 40,与 BalloonWire.BalloonWeaponMagazineAmmo 一致)。 /// 面板的弹药控件读的就是这个值(`BalloonGameStore.RefreshBalloonWeaponAmmo` /// → `weapon.MagazineAmmo`)。⚠ 枪本身的 cfg(80300134 打中有赏_玩具枪)是 /// `Durability = -1`(无限)⇒ **硬性「只能打 N 枪」由客户端配表决定,服务端改不动**, /// 这里只能改面板上显示的那个数。 /// public int BalloonWeaponAmmo { get; init; } = 40; /// /// ★ R111 诊断 —— **按列换 (cfg,pathId)**。每个元素形如 "80001468:895168017", /// 下标 = 列号(列数 > 元素数时,最后一列的值套用到其余列)。空 = 用 /// BalloonWire.BalloonSceneItemCfgIds / PathIds 的 5 色派对气球。 /// /// 用途:一次进出区同时对比两种美术 —— 例如左两列发派对气球 /// (prop_xsb_balloon001*,体积 0.614、teardrop 形),右两列发龙栖村原生 /// prop_lqc_balloon002*(体积 0.0048、与官方玩法气球同档)。 /// 用户只要报告「哪边像摊位上的球 / 哪边打得爆」。 /// public string[] BalloonCfgSweep { get; init; } = System.Array.Empty(); /// /// ★★★ R89 — 气球 PathId 取哪一组。 /// false(默认)= 用对照包的五色 PathId(BalloonSceneItemPathIds,逐色不同) /// true = 全部退回 R88o 的 670915924(plastic_balloon) 单一值 /// 为什么留这个开关:对照包自己标注那 5 个 id 是「待复核值」;一旦真机出现 /// 「有碰撞无模型」,改这里即可一键 A/B,不必重编译。 /// public bool BalloonUseSharedPathId { get; init; } = false; /// /// ★★★ R89 — 本轮流行的气球数(对照包 = 12:4 列 × 3 行)。 /// R88o 用的 20(= Balloon_RightNum 10 × 2)来自 ClientConfig,是**棋盘上限**而非本局数量。 /// public int BalloonCount { get; init; } = 12; /// /// ★★★ R89 — 进气球区时给玩家发气枪(80300134)。 /// 关掉即回到 R88o 行为(无枪 ⇒ 面板 `attempt to index local 'weapon'` 起不来)。 /// public bool BalloonGrantWeapon { get; init; } = true; /// /// ★★★ R89 — 气球实例 id 是否用对照包的固定 48 位区间(0xBA11_0000_0000 + 序号)。 /// false(默认)= 固定区间(≤48 位,经 Lua double 回传不丢精度) /// true = 退回 R88o 的 920000000001 + batch*count 递增批次 /// public bool BalloonUseLegacyInstanceIds { get; init; } = false; /// 只记录不推送。用于首次试跑新坐标。 public bool LogOnly { get; init; } = false; } // ═══════════════════════════════════════════════════════════════════════════════════════ // 赛车检查点门(2026-10-03 移植自捐赠分支 DRMK-PS_09_HyNB-赛车) // 由服务端摆放 26 个官方检查点(成对镜像 = 52 个 gadget),过点即撤。 // 完整字段语义见类内注释;全部是「不用重编译」的热旋钮。 // ═══════════════════════════════════════════════════════════════════════════════════════ public sealed class RacingGateSettings { /// False = 一道门都不摆。回滚开关,不用重编译。 public bool Enabled { get; init; } = true; /// 坐标表路径(相对服务端根)。客户端 R247 探针写这个文件。 public string TablePath { get; init; } = "config/racing-gates.json"; /// /// 放置通道。0 = SyncGadgetAdd(68843189) —— 健身器材实测「实体建得出、小游戏可玩」的那条。 /// 3 = SyncGadgetAddList(68925813)(AnantaGymLoader 用的那条)。 /// public int Channel { get; init; } = 0; /// /// PackedGadgetInfo.iScale。⛔ **必须 0** —— /// 0 = 客户端 1:1 正常渲染(loader 与 400 条 blob 记录用的值); /// 100(GM 面板默认)= 出来是 transform scale (0,0,1) 的**零缩放空壳、没有 prefab** /// (这正是保龄球球瓶「有物理有音效但看不见」的根因,见 GymEquipmentSettings.IScale)。 /// public int IScale { get; init; } = 0; /// /// 拿不到坐标表时的「验证信标」:在玩家前方 米处摆若干道门。 /// 用来把「通道对不对」和「坐标对不对」两个变量解耦 —— 看得见信标 ⇒ 通道没问题,只差坐标。 /// public bool BeaconEnabled { get; init; } = true; /// 信标距离(米),会按 1× / 2× / 3× 摆三道。 public float BeaconAhead { get; init; } = 40f; /// 只打日志、不推包。用来干跑一份新采集。 public bool LogOnly { get; init; } = false; /// /// ★ R254 —— 「成对镜像」。true = 每个检查点摆 2 个 gadget(同一坐标、yaw 相差 180°)。 /// /// 为什么必须成对(R254 实机截图,用户「门显示了但是只显示了右边的」+ /// 「门应该是在整个道路的左右两边」):Jobrace_gongmen 的一个实例只是 /// 半扇拱门 —— 一根立柱从路面升起、顶端向内弯折,另一半是空的。 /// 官方一扇门 = 两个实例:一个 yaw=θ、一个 yaw=θ+180°,两半合成完整拱门, /// 拱门中心才落在路中线上(这也正是用户看到的「位置偏右」的来源:只有右半,自然偏)。 /// /// 关掉它 = 回到 R253 行为(每点一个、只有半扇)。回滚开关,不用重编译。 /// public bool PairMirror { get; init; } = false; /// /// 成对之后的**额外左右位移**(米,沿路径法向;正 = +normal 那侧)。 /// 默认 0:两半同坐标镜像 ⇒ 拱门正好骑在路中线上。 /// 只有当官方拱门确实比路面宽/窄、需要整体挪动时才调它。 /// public float PairOffset { get; init; } = 0f; /// /// ★★ R254 —— **强制覆盖表里的 pathId**。0 = 不覆盖(用表里的值)。 /// /// 为什么必须有它:racing-gates.json 是**客户端 R247 采集探针写的**, /// 而那个探针每进一次赛车就**整表重写**,写进去的是**客户端那套 gadget id** /// (Racing_challenge01_CheckPoint = 884611825 / …CheckPointEnd = 271608686)。 /// 那两件资源**从未随包** ⇒ 摆出来隐形。 /// R253 我把表手工改成 64493331(Jobrace_gongmen),用户跑完一局后**表又被探针覆盖回去了** /// —— 于是下一局必然退回「隐形门」。所以 pathId 不能交给客户端探针决定,必须由服务端钉死。 /// /// 当前值:64493331 = Jobrace_gongmen(竞速·拱门)。 /// public int PathIdOverride { get; init; } /// /// ★★★ R255 —— **使用服务端内置的「官方赛道检查点表」**,忽略客户端采集的 /// 里的坐标。默认 true。 /// /// 为什么必须内置(R255 定案,2026-10-02):赛道 track 3(Nova `23300888`, /// `RacingPathName = RT_888_001`)的**官方检查点位置是权威数据、且离线可读** —— /// 它们烘焙在关卡块 `vfc_819` 的明文导出 JSON 里: /// /// Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab /// {"trackIndex":0,"pointCount":259,"isClosed":false, /// "points":[{"x":..,"y":..,"z":..,"checkpointIndex":k,"distanceToNextCheckpoint":..}, ...]} /// /// checkpointIndex 值域 0..25 ⇒ 含终点共 26 个检查点(与用户「跟着视频数的 26 个」一致)。 /// 检查点 k 的位置 = **第 k 组的第一个点**(已由 /// distanceToNextCheckpoint 自证:cp0 组末点 s=215.07, d_last=5.93 ⇒ 221.00, /// 而 cp1 组起点 s=220.99,吻合)。 /// /// 为什么不能继续用采集表:R225 的探针走的是 /// LX6.Utils.LuaUtils.LoadRaceTrackData → RaceTrackExportData.ExtractFilteredTrackPoints(), /// 那条链**只返回 x/y/z、丢掉 checkpointIndex**,而且对点列做了**不等距重采样** /// (官方 259 点 / 3382.44 m,探针拿到 256 点 / 3225 m ⇒ 在它上面做「弧长等分」必然错位)。 /// R225 当时的常量就是 R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值(自认是猜的)。 /// /// 更糟的是那张采集表还是**客户端探针每局重写**的(见 ), /// 所以坐标也不能交给它决定 —— 服务端必须自带权威表。 /// /// 关掉它 = 回到「读 / 信标」的 R254 行为。回滚开关,不用重编译。 /// public bool UseOfficialTrack { get; init; } = true; /// /// 门的朝向修正(度),叠加在内置官方表的 yaw 上。默认 0。 /// /// ★ R291 起它是**真旋钮**(此前是空操作):ExpandGatePairs 的 ±8 m 侧移用 /// **不含本值的基准 yaw**,只有朝向用含它的值 ⇒ 改它 = 每一半**绕自己的根部原地转**, /// 位置完全不动。180 = 把两半的「身体」从朝路外翻成**朝路中央**(合扇)。 /// /// ★ 改它**不用重编译、不用重启服务端**(private-server.json 热重载 + 每局开赛重读), /// 下一局即生效。实机看到两臂**朝路外张开**就改成另一个值(0 ↔ 180)。 /// public float YawOffset { get; init; } /// /// ★★★★★ R293 —— 门的朝向**取哪个方向**。默认 "road"。 /// /// /// "road"(默认)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点 /// 折线里含该点那一段的方向。门面法向沿路 ⇒ 门横跨路面, /// 且 ±8 m 成对侧移变成跨路并排(两半合扇)。用户 2026-10-02 定: /// 「检查点可以不用朝向下一个点,就和道路平行就行了」。 /// "next" = R290 遗留的**「指向下一个检查点」弦向**(atan2(Δx,Δz)), /// 只在 A/B 对照或回滚时用。⚠ 直道上两者等价,**弯道上差极大** —— /// 26 点里 9 个偏 > 20°,cp07 偏 **114.8°**(那里会把门拧成「顺路躺」、 /// 两半串成一前一后)。 /// /// /// 写错的值按 "road" 处理。改它**不用重编译、不用重启服务端**,下一局生效。 /// 数据来源与复算脚本见 GameRouter.RacingGates.OfficialTrack3Checkpoints。 /// public string YawMode { get; init; } = "road"; /// /// ★★★★★ R294(2026-10-02)—— **跑过一个检查点就把它那对门撤掉**,默认 true。 /// /// 用户原话:「检查点开过去不会消失,而且比赛结束了所有检查点还在」。 /// /// 为什么要撤:一个门是服务端 spawn 的 gadget,客户端不知道它的存在、不会自己删; /// 而官方的检查点标记是客户端自建、只有一个、跟着当前目标走 /// (CarRaceManager.arrowGO:AddArrow 建、RemoveArrow 销毁) /// ⇒ 官方观感 = 「过点即消失」。我们一局摆 52 个后从不回收,于是全程 26 扇门一直杵在赛道上。 /// /// 撤哪几个:客户端过点上报 AskSetTaskCounterValue(index=k), /// 而 index **恰好等于**服务端检查点下标(客户端 taskCounterList 就是我们 Lua 喂的 /// R255_OFFICIAL_CP)⇒ 撤第 k 个点附近(20 m 半径内)的那一对。 /// ⛔ **不能按 pathId 全撤** —— 52 个门同一个 pathId,会把没跑到的一起抹掉。 /// /// 跑完全程(index == 26)或比赛结束 ⇒ **全清**。 /// 关掉它 = 回到 R293 的「一局只摆不清」。**改它不用重编译、不用重启服务端**,下一局生效。 /// public bool RemoveOnPass { get; init; } = true; /// /// R311 · 撤门宽限期(毫秒,默认 12000) —— 一对门**被上报通过后**至少要活满这个时长才允许撤。 /// /// 为什么需要:用户「不是所有门都没有了啊,还是前两个」—— /// 只有前两个检查点的拱门看不到。console-20261003-015034.log 逐行时序: /// 一局 52 个门在 01:53:50 同一秒全摆完,而客户端按 /// BabyDriver_CheckPointRange = 22(m) 上报「已过点」⇒ 服务端**上报即撤**: /// cp00 +5 s / cp01 +9 s / cp02 +13 s / cp03 +16 s … /// ⇒ 前两个门在「客户端把 Jobrace_gongmen prefab 加载完并实例化」之前 /// 就被 remove 抵消掉了;从第三个起那一对活过了冷启动窗口,所以看得见。 /// /// 做法:过点上报时若距摆门不足本时长 ⇒ **不撤**,交给一个后台任务到点补撤 /// (⛔ 不能在回执路径里 await Task.Delay:AskSetTaskCounterValue 的回执会被客户端判超时)。 /// /// 0 = 关(回到 R294 的「上报即撤」)。**改它不用重编译、不用重启**,下一局生效。 /// public int GateMinLifetimeMs { get; init; } = 12000; /// /// R312(2026-10-03)· **前 N 个门延迟撤** —— 用户指定规则原话: /// 「前两个门设置为**到第三个门之后再消失**,第三个门后的所有门都维持现状」。 /// 含义:`checkpointIndex < GateKeepFirstNCount` 的那几个点,上报通过时**不撤门**, /// 只登记挂起;等玩家通过第 `GateKeepFirstNCount` 个点(默认 2 ⇒ 即第三个门 `cp02`)时, /// 把挂起的和当前这一对**一起撤掉**。这是把「生存期」锚到**玩家的真实进度**上, /// 而不是靠猜 prefab 冷加载要几秒(R311 的 12 s 宽限期实测没消除用户的现象)。 /// `0` = 关闭本条规则,完全回到 R311 的「纯宽限期」行为。默认 2。 /// public int GateKeepFirstNCount { get; init; } = 2; } public sealed class DebugSettings { public bool Enabled { get; init; } = true; public string Host { get; init; } = "127.0.0.1"; public int Port { get; init; } = 5809; }