package config import ( "encoding/json" "sync" ) var _ = json.RawMessage{} var _ = sync.Mutex{} // // 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. // type PrivateServerConfig struct { Client ClientSettings `json:"client"` Network NetworkSettings `json:"network"` Admin AdminSettings `json:"admin"` Login LoginSettings `json:"login"` Player PlayerSettings `json:"player"` World WorldSettings `json:"world"` Content ContentSettings `json:"content"` Gameplay GameplaySettings `json:"gameplay"` Ui UiSettings `json:"ui"` Logging LoggingSettings `json:"logging"` Paths PathSettings `json:"paths"` } type ClientSettings struct { Version int32 `json:"version"` ArtifactVersion string `json:"artifactVersion"` ServerId int32 `json:"serverId"` Aid int32 `json:"aid"` RpcMd5 string `json:"rpcMd5"` NoMoreHotfixPatchError int32 `json:"noMoreHotfixPatchError"` } type LoginSettings struct { // // 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. // // RoleListMode string `json:"roleListMode"` } type NetworkSettings struct { BindHost string `json:"bindHost"` AdvertisedHost string `json:"advertisedHost"` LoginPorts []int32 `json:"loginPorts"` GamePort int32 `json:"gamePort"` // // 服务端 → 客户端的传输层 keepalive 间隔(毫秒),帧 mode=0x03。 // 默认 0 = 关闭(和 2026-10-01 之前的行为完全一致)。 // 联机/多会话场景才需要:一个卡死的客户端不该把别人的会话一起拖死。 // 🛑 回滚 = 设回 0,不用重新编译。修改后立即生效的是**新连接**,已建立的会话沿用旧值。 // SocketHeartbeatMs int32 `json:"socketHeartbeatMs"` // // 单次加密写的上限(毫秒)。超过就把这条连接退役,避免慢/卡死的接收方把写锁占死。 // 0 = 不设上限(旧行为)。 // ⛑ 本地单机跑的话给个宽松值就行;15000ms 足够容纳最大的那一批 SceneItem / PropRestock 推送。 // WriteTimeoutMs int32 `json:"writeTimeoutMs"` Proxy ProxyNetworkSettings `json:"proxy"` } type ProxyNetworkSettings struct { HttpsPort int32 `json:"httpsPort"` HttpPort int32 `json:"httpPort"` LoginListPort int32 `json:"loginListPort"` LoginTcpPort int32 `json:"loginTcpPort"` GameTcpPort int32 `json:"gameTcpPort"` SceneSubPort int32 `json:"sceneSubPort"` UpdateHost string `json:"updateHost"` CertificatePassphrase string `json:"certificatePassphrase"` } type AdminSettings struct { // Embedded localhost admin panel (no auth — bind 127.0.0.1 only). Enabled bool `json:"enabled"` Host string `json:"host"` Port int32 `json:"port"` } type PlayerSettings struct { Pid uint64 `json:"pid"` InitialUnitId uint64 `json:"initialUnitId"` InitialSpiritTemplateId uint32 `json:"initialSpiritTemplateId"` AccountId string `json:"accountId"` UserName string `json:"userName"` DisplayName string `json:"displayName"` LoginToken string `json:"loginToken"` GameToken string `json:"gameToken"` ShareToken string `json:"shareToken"` FpPassToken string `json:"fpPassToken"` Skey string `json:"skey"` LoginUniverseId uint32 `json:"loginUniverseId"` } type WorldSettings struct { RaidId uint32 `json:"raidId"` SceneInstanceId uint64 `json:"sceneInstanceId"` UniverseId uint32 `json:"universeId"` Spawn Vector3Settings `json:"spawn"` Facing float32 `json:"facing"` EntryOpeningEnabled bool `json:"entryOpeningEnabled"` Content WorldContentSettings `json:"content"` } // // 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. // type WorldContentSettings struct { // Master switch for all world-content streaming. Enabled bool `json:"enabled"` // Legacy primary scene name retained for config compatibility. Scene string `json:"scene"` // // 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. // ProductionScenes []string `json:"productionScenes"` SceneItems SceneItemStreamSettings `json:"sceneItems"` Gadgets GadgetStreamSettings `json:"gadgets"` Elevators ElevatorSettings `json:"elevators"` SceneDevices SceneDeviceSettings `json:"sceneDevices"` DynamicGo DynamicGoStreamSettings `json:"dynamicGo"` Doors DoorSettings `json:"doors"` // 赛车检查点门(2026-10-03 移植自捐赠分支)。见 RacingGateSettings。 RacingGates RacingGateSettings `json:"racingGates"` // Gym equipment the server places itself (squat / bench / treadmill / sit-up). GymEquipment GymEquipmentSettings `json:"gymEquipment"` // // 街头篮球 QTE 的篮筐(2026-10-03)。MiniGame prefab 里没有篮筐模型, // 得由服务端刷一个 Gadgets_Common_BasketballRim 到判定点位置。 // ⛑ 偏移/角度放配置里是为了免编译微调:改 json + 面板 reload-config 即可, // 不用再因为挪 2 米就停服重新编译。 // // // 搜打撤(ExtractionShooter,「不要抬头!」)。默认关闭 —— 改 true 且 raid 名单命中才会走 // 进图 / 容器 / 背包那套结构。回滚不需要重新编译。 // Extraction ExtractionShooterSettings `json:"extraction"` // Every other client-blob prop the server restocks by world cell. PropRestock PropRestockSettings `json:"propRestock"` // 家具摆放探针(T1-22 排障)。默认全关,开了才会在玩家脚下摆物件。 DebugPlace DebugPlaceSettings `json:"debugPlace"` // Answer to the client's AskMetroGadgetIds(67809129) — carriage gadget ids. MetroGadgets MetroGadgetSettings `json:"metroGadgets"` // // 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 GadgetSpoonParamSettings. // GadgetSpoonParams GadgetSpoonParamSettings `json:"gadgetSpoonParams"` // // 室内扇区(sector_high_indoor_<IndoorId>)—— 服务端按 IndoorConfig 的最近条目 // 推出玩家所在室内,然后推 SyncLoadIndoorSector(68095220) / SyncIndoorSectorControl(68971505)。 // 默认关闭:线格式是静态推导出来的,先 logOnly 验一轮再开。 // IndoorSectors IndoorSectorSettings `json:"indoorSectors"` // // 派对场地家具(露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车)。 // 与健身房器材同因同解:这批 Gadgets_Party_* 只活在客户端关卡 blob 里, // grid-AOI 下发不驱动 GadgetBlob ⇒ 必须服务端按官方场地坐标显式摆一次。 // 见 GameRouter.PartyCamp.cs。回滚:world.content.partyCamp.enabled = false。 // PartyCamp PartyCampSettings `json:"partyCamp"` // // 龙栖村(raid 23300999)互动摊位机关 + 打气球玩法。 // 见 GameRouter.MiniGameStall.cs / GameRouter.Balloon.cs。 // 回滚:world.content.miniGameStalls.enabled = false。 // MiniGameStalls MiniGameStallSettings `json:"miniGameStalls"` } // // 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. // type IndoorSectorSettings struct { // False = never push. Rollback lever, no rebuild needed. Enabled bool `json:"enabled"` // True = only log what would be pushed (safe way to validate the id derivation). LogOnly bool `json:"logOnly"` // // 进入判定半径(米)。玩家离某个 IndoorConfig 条目比这更近就算「在室内」。 // 新启 54 个有坐标条目的最近邻距离中位数 ≈ 100 m、最小 33 m ⇒ 默认 60 m 是折中。 // RadiusMeters float32 `json:"radiusMeters"` // // 离开判定半径(米)。0 = 用「进入半径 + 15」的迟滞,避免在门口反复 Load/Unload。 // ExitRadiusMeters float32 `json:"exitRadiusMeters"` // // 是否附带推 SyncIndoorSectorControl(68971505)(显式 true/false 开关)。 // 注入端的 sectorstate 是两条一起喂的(SyncIndoorSectorControl + 引擎层 // SetIndoorSectorState),所以默认开。 // PushControl bool `json:"pushControl"` // // 状态没变时的重声明间隔(毫秒)。0 = 只在状态变化时推一次。 // 与 gadget / sceneitem 网格同症状:不持续声明,客户端会把窗口丢掉。 // HeartbeatMs int32 `json:"heartbeatMs"` // // 除扇区 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 用。 // AlsoPushRawIndoorId bool `json:"alsoPushRawIndoorId"` // // 是否优先采信客户端 SyncChangeIndoor 上报的 indoorConfigId(权威值), // 而不是服务端的「几何最近邻」推算。 // ★ 2026-09-27 20:3x 实机:ACD Nova Branch(76)与 ACD快乐房(129)的 // IndoorConfig.MiniMapPos 矩形互相重叠,纯几何最近邻在重叠区每几秒就在 // 76 / 129 之间跳一次 ⇒ 客户端每跳一次就把 9 个扇区的子树建了又删、删了又建。 // 客户端自己上报的 Enter GpsBound: IndoorId: 76 才是不抖的那个(对得上 76)。 // PreferClientReport bool `json:"preferClientReport"` // // 客户端上报值的保鲜期(毫秒)。超过这个时间没再上报就退回几何判定 // (客户端只在进出 bound 时才发 SyncChangeIndoor,站着不动不会持续发)。 // ClientReportGraceMs int32 `json:"clientReportGraceMs"` // // 换室内前的确认延时(毫秒):候选 id 必须连续稳定这么久才真正切换。 // 两个来源(客户端上报 / 几何)各自都会瞬时抖到 0 或隔壁场馆,光靠「优先采信」 // 治不了 —— 必须再叠一层防抖。0 = 不防抖(回到 20:3x 抖动的旧行为)。 // SwitchDelayMs int32 `json:"switchDelayMs"` } // // 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. // type DoorSettings struct { // False = log only, never push. Rollback lever, no rebuild needed. Enabled bool `json:"enabled"` // // 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. // PushOnLeave bool `json:"pushOnLeave"` // // 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". // OpenAngle float32 `json:"openAngle"` // // 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. // PushGadgetRefresh bool `json:"pushGadgetRefresh"` // // 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. // AutoCloseSeconds int32 `json:"autoCloseSeconds"` } // // 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. // type SceneDeviceSettings struct { // Master switch. Off = fall back to the generated neutral replies (no echo). Enabled bool `json:"enabled"` // Echo SyncSceneDeviceStateChange(68165561) on an accepted change-state. EchoState bool `json:"echoState"` // Echo SyncSceneDeviceValueChange(68332775) on an accepted record-value. EchoValue bool `json:"echoValue"` // // 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)。 // SpoonConditionTrue bool `json:"spoonConditionTrue"` // Device ids that must never be echoed (client-local flows that regress). Decimal. EchoOffIds []string `json:"echoOffIds"` } // // 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. // type ElevatorSettings struct { Enabled bool `json:"enabled"` // How long the ride takes before the arrival push lands (ms). TravelMs int32 `json:"travelMs"` // // 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. // ArrivalDoorMode byte `json:"arrivalDoorMode"` // // Push SyncLiftDoorMode(68799106) with ArrivalDoorMode 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. // OpenDoorsOnArrival bool `json:"openDoorsOnArrival"` } type GymEquipmentSettings struct { // False = never place anything. Rollback lever, no rebuild needed. Enabled bool `json:"enabled"` // // 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. // Channel int32 `json:"channel"` // // 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. // IScale int32 `json:"iScale"` // // 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. // RadiusBonus float32 `json:"radiusBonus"` // // ★ 2026-10-01 千泊健身房实测:摆放撞在客户端「切室内」的那一瞬,19 条 SyncGadgetAdd 全部被吞 // (服务端 19/19 下发成功、客户端零痕迹,场馆当场空着);而场馆每会话只铺一次 // (GymEquipmentSessionState.Placed)⇒ 一次丢 = 整场会话都空。 // 同一场馆、同样 19 条包,客户端切室内完成 12 秒后再摆就全部正常出现(用户实机确认)。 // 判据 = 客户端 SyncChangeIndoor 的上报时刻(WorldEntryState.ClientIndoorReportMs): // 失败那次上报与摆放同一秒,成功那次相隔 12 秒。0 = 关掉这个守卫(回到旧行为)。 // ⛑ 别改用 IndoorSectorSessionState.LastPushMs —— 它每次心跳都刷新(连"该场馆无扇区 ⇒ // 什么都不推"也算),拿它当守卫会让器材永远摆不出来。 // SettleAfterIndoorChangeMs int32 `json:"settleAfterIndoorChangeMs"` // 传送流程未结束(state.PendingTeleportId != 0)时不摆放 —— 同一类「客户端正在换场景」的窗口。 SkipDuringTeleport bool `json:"skipDuringTeleport"` // Log the placements but push nothing. Use it to dry-run a fresh capture. LogOnly bool `json:"logOnly"` } // // 搜打撤(ExtractionShooter,本季 Sanhok-free 的「不要抬头!」)的服务端侧总开关。 // 这套内容与 AccountInstanceId / world-content 三条管线无关,是纯服务端状态驱动的: // 服务端自己记住包里有什么、容器里有什么,客户端只有 Ctrl 出来的 view。 // 所以关掉它不会有任何残留 —— 全部状态都在 session.Items 里。 // type ExtractionShooterSettings struct { // False = 所有搜打撤 handler 直接放行 / 跳过,零结构性改动。 Enabled bool `json:"enabled"` // // 被当作搜打撤图的 raid 名单。 // 为什么是名单而不是 RaidType:配置表里**没有** raid ↔ 搜打撤图的外键 // (ExtractionShooterConfig 只给「图 → BirthPoints」),官方文档用落点包围盒判图, // 而服务端这里只需要「当前 raid 要不要挂搜打撤 UI」⇒ 名单最直白。 // 23301277 = 不要抬头!(scene 20001319);23301215 终局之战;23301311 古墓摸金。 // RaidIds []uint32 `json:"raidIds"` // // 退出请求(67504722)是否真的把人扔回主城。 // ⛑ 关掉它 ≠ 关掉整套:等待房和主城也会发这条请求,必须有 // IsExtractionRaid 守卫(handler 里已有),这个开关只是多一层。 // ExitToMainCity bool `json:"exitToMainCity"` // 进图时给每个包下发的初始 token(代币)数。0 = 不下发。 InitialTokenCount uint32 `json:"initialTokenCount"` // // 容器一次刷出来的物件数上限。ExtractionShooterItemContainerConfig 自己有 // QuantityMin/Max,这里是第二次夹紧(用于防止吃满带宽的大箱)。 // MaxRollPer int32 `json:"maxRollPer"` // // 伪造匹配:客户端点「开始」的那四条请求(AskStartSingleMatch 63347492 / // StartBotMatch 63474603 / StartMatchInTeam 63563208 / // StartGameInTeam 63121362)直接切进 PreferredRaid。 // 为什么要有这个:官方的开局要过真实匹配服 / 队伍服,服务端补不出那条链路, // 而客户端点开始之后就在等切图 —— 不接住就是卡在开始界面。 // ⛑ 默认关:这四条是通用请求(别的玩法也走),开着会把所有开局都截到搜打撤去。 // 真要玩的时候在面板勾上(面板改动只在内存里,重启回落到这里)。 // FakeMatch bool `json:"fakeMatch"` // 伪造匹配要去的 raid。默认 23301277(不要抬头!)。 PreferredRaid uint32 `json:"preferredRaid"` // // 退图之前先推一条 SyncRaidSettlement(68995324)。 // 「帧全对但人卡住」里有一整类是缺出场:每伪造一次进场,都要反查客户端有没有对应的 // 出场请求被空 OK 吞掉。结算帧是副本流程的收尾信号 —— 直接拔线回主城的客户端 // 有时会把结算 UI 挂在屏幕上下不来。 // 零战斗数据时这条包恒 32 字节(见 handshake 文档 §6.7)。 // ExitSettlement bool `json:"exitSettlement"` // // 武器轮盘的实体化下发方式:full(默认,每次变动重发整表)/ incremental(只发变化的格) // / off(不下发 = 修复前的行为,轮盘只显示物品、Tab 切不到)。 // 写错的值按 full 处理。 // WheelSync string `json:"wheelSync"` // // 轮盘物品是否随 itemInfo 一起带 WeaponData。带上客户端才能把「背包那一格」和 // 「那件实体化武器」连起来(否则枪的 tooltip 退化成普通物品描述)。关掉用于回退验证。 // WheelWeaponData bool `json:"wheelWeaponData"` } // // 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). // type PropRestockSettings struct { // False = never place anything. Rollback lever. Enabled bool `json:"enabled"` // // 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. // RadiusCells int32 `json:"radiusCells"` // // Which S2C channel places the props: 0 = SyncGadgetAdd(68843189), 1 = AOI // SyncGadgetAOIAddAndRemove(68169646), 3 = SyncGadgetAddList(68925813). // 0 is the proven one. // Channel int32 `json:"channel"` // // 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. // IScale int32 `json:"iScale"` // Log the placements but push nothing. Use it to dry-run a fresh capture. LogOnly bool `json:"logOnly"` } // // 家具摆放探针(T1-22 排障)。走已经验证过的 SpawnGadgetAsync 显式摆放通道, // 在玩家脚下周围摆一圈指定 pathId 的真物件,用来判断「显式摆放到底通不通」。 // 全部参数热重载;想再摆一次把 Gen +1。见 GameRouter.DebugPlace.cs。 // type DebugPlaceSettings struct { // False = 什么都不摆。回滚开关。 Enabled bool `json:"enabled"` // // 要摆的 prefab pathId(来自 Configs/GadgetCatalog.json)。 // 常用:2012164699 = Bench_chairs_p_common_prop_roadsystem_yiht01(公交站长椅)、 // 192285565 = Bench_chairs_p_map01_indoor_prop_mcdonalds_seat001a(麦当劳椅)。 // PathId int32 `json:"pathId"` // 摆几个(沿圆周均匀分布)。 Count int32 `json:"count"` // 离玩家多远(米)。 Radius float32 `json:"radius"` // 相对玩家脚底的高度偏移(米)。 OffsetY float32 `json:"offsetY"` // 通道:0 = SyncGadgetAdd(已验证) / 1 = AOI / 3 = SyncGadgetAddList。 Channel int32 `json:"channel"` // // PackedGadgetInfo.iScale。必须是 0:100 会摆出一个名字对、交互点也有、 // 但缩放是 (0,0,1) 的实体 —— 看不见模型。 // IScale int32 `json:"iScale"` // 只打日志不发包。 LogOnly bool `json:"logOnly"` // 每次 +1 就重新摆一轮(热重载生效,不用重启、不用重编译)。 Gen int32 `json:"gen"` } // // 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). // type GadgetSpoonParamSettings struct { Enabled bool `json:"enabled"` // pathId → params. JSON keys are strings (e.g. "223752202"). ByPathId map[string][]GadgetSpoonParamEntry `json:"byPathId"` } type GadgetSpoonParamEntry struct { // PackedGadgetSpecialParam.markId — 31511937 for the Bee cabinets. MarkId int32 `json:"markId"` // PackedGadgetSpecialParam.value — the string "True", not a boolean. Value string `json:"value"` } // // 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. // type MetroGadgetSettings struct { // False = keep answering the empty list (pre-2026-09-23 behaviour). Rollback lever. Enabled bool `json:"enabled"` // // 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. // Carriages int32 `json:"carriages"` // Fallback only: seat gadget ids handed back per carriage (InnerGadgetIds). SeatsPerCarriage int32 `json:"seatsPerCarriage"` // // 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 DefaultCarriageSeats. // ⛑ 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. // SeatsByCarriageTemplate map[int32]int32 `json:"seatsByCarriageTemplate"` // Seat count used for carriage templates not listed in SeatsByCarriageTemplate. DefaultCarriageSeats int32 `json:"defaultCarriageSeats"` // True = derive one answer entry per carriage from RailTrainConfig. False = flat Carriages x SeatsPerCarriage. UseTrainConfig bool `json:"useTrainConfig"` // // 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. // IdBase uint64 `json:"idBase"` // Also fill OuterGadgetIds (things mounted outside the carriage). Off by default. IncludeOuter bool `json:"includeOuter"` // Log what would be answered but still return the empty list. LogOnly bool `json:"logOnly"` } // // Grid window for baked scene items (lampposts, props, breakables, 可破坏/可投掷物). // 2026-09-24 起这条配置真的接上了实现:服务端按 40m 网格推 // SyncDestructibleGridAOIDecrease(68969593),客户端从自己的 SceneItemBlob 出货 // —— 等价于 AnantaWorldMod 注入器,不再需要 frida。见 GameRouter.SceneItemGrid.cs。 // ⛑️ 之前它只是个空壳:没有代码读它,mid 也用错成 68934309(那条只有 aoiInfo 一个字段)。 // type SceneItemStreamSettings struct { Enabled bool `json:"enabled"` // Window half-size in 40m cells (7x7 window at 3). RadiusCells int32 `json:"radiusCells"` // Tighter ring for cells carrying interior sectors. RadiusInteriorCells int32 `json:"radiusInteriorCells"` // // ★ 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(回到「只增不减」的旧行为)。 // UnloadRadiusCells int32 `json:"unloadRadiusCells"` // // ★ 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),那行恒等于原值(已删)。 // RetainVisitedCells bool `json:"retainVisitedCells"` // // ★ donor《content-streaming-implementation》§3 —— 真正下发 removeGridInfo。 // 关掉它 = 回到 2026-09-28 之前的行为:格子只增不减,走过的永不卸载 // (客户端驻留对象数一路涨,2026-09-27 §10.9.3 内存项第 3 条)。 // ⛑ 排查顺序:怀疑卸载把身边物件删掉了 ⇒ 先改 realUnloadLogOnly=true // (只打「本来要卸哪些格子」,一条报文都不发),确认卸载集合是窗外的环形再放开。 // RealUnload bool `json:"realUnload"` // 只记录将要卸载的格子,不下发报文、也不从已声明集合里摘除(完全等价于旧行为)。见 RealUnload。 RealUnloadLogOnly bool `json:"realUnloadLogOnly"` // // ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳(两个窗口不相交)时 // 清空已声明集合并按「首次进窗口」重铺。判据 = 格子 Chebyshev 距离 > unloadRadiusCells。 // 关掉它 = 回到「Sent 只增不减,远眺留下一堆幽灵格子」的旧行为。 // ReseedOnFarJump bool `json:"reseedOnFarJump"` // When true, request every authored object in each production cell instead of applying the extracted sector filter. LoadAllSectors bool `json:"loadAllSectors"` // // ⛔ 零消费点 —— 全仓没有任何代码读它(连 Validate() 都不看), // true / false 对行为没有任何影响。2026-09-28 核实。留字段只为兼容已落盘的 JSON。 // ExcludeTestItems bool `json:"excludeTestItems"` // // ⛔⛔ 名字骗人 —— 只被 Validate() 校验 1..200,真正的预算是 MaxCellsPerPush。 // 推送代码用的是 var budget = Math.Max(1, cfg.MaxCellsPerPush), // 所以改这个值只会改校验结果,不会改每轮发多少格。见硬规则 212。 // LoadBudgetCells int32 `json:"loadBudgetCells"` // ---- 以下为 2026-09-24 新增:68969593 网格流的时序参数(与 gadget 网格同构) ---- // How often the loop looks at the player's cell (ms). TickMs int32 `json:"tickMs"` // // 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. // FirstDelayMs int32 `json:"firstDelayMs"` // // Re-declare the window this often even when the player stands still (ms). 0 = off. // ⛔⛔ 2026-09-30 起本通道默认 0(关),理由见 ResendWindowOnMove: // 站桩时整窗重推 = 客户端对已加载格子重新 AddByIndex = **重新实例化** ⇒ 玩家看到 // 「可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位」。 // 旧的注释「Symptom this fixes: items appear for a second and vanish」已被 AnantaWorldMod // 反证(它从不重推,物件也不消失)—— 那条实证其实是**重推本身造成的闪**,因果写反了。 // 想恢复旧行为:本键 5000 + RefreshMs 120000 + ResendWindowOnMove=true。 // HeartbeatMs int32 `json:"heartbeatMs"` // // Full re-send of the surrounding window after this long (ms). Safety net. // ⛑ 0 = 关闭(GameRouter.SceneItemGrid.cs 里有 `cfg.RefreshMs > 0` 守卫; // 没有这个守卫时填 0 会变成「恒真」⇒ 每个 tick 都整窗重推,比心跳还频繁)。 // RefreshMs int32 `json:"refreshMs"` // Cells per 68969593 message. One message carries one sector list, never more. MaxCellsPerPush int32 `json:"maxCellsPerPush"` // // 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 + HeartbeatMs=0 + RefreshMs=0 = **严格增量**, // 与 AnantaWorldMod 等价:已刷出的格子永不重推。 // ⛑ 回滚 = 三个键改回 true / 5000 / 120000,不需要动代码。 // ResendWindowOnMove bool `json:"resendWindowOnMove"` // // 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. // 排障顺序:物品一个都不出 → 先开这个;室内门/店员消失 → 立刻关回去。 // ArmActiveSwitch bool `json:"armActiveSwitch"` // // 念动力探针(硬规则 28):用 aoiInfo.addInfos 在玩家正前方凭空摆一个可抓 SceneItem。 // 这是 T1-22 里唯一能**直接验证**服务端显式摆放链路的手段 —— 箱子出现 ⇒ addInfos 这条 // 路通,家具不出只是缺坐标;箱子都不出 ⇒ 先修通道,别再往坐标表里填数。默认关。 // ⛑️ 只摆一次(会话级),否则心跳 / 跨格重声明每隔几秒就叠一个箱子。 // SpawnTestBox bool `json:"spawnTestBox"` // // 探针用的 SceneitemConfig id。80001877 = 官方测试盒,CanQInteraction=true, // 客户端会实例化成真的 SceneItem 并被 MindPowerMgr:GetAimItem() 认成 Q 目标。 // SpawnTestBoxCfgIds []int32 `json:"spawnTestBoxCfgIds"` } // Grid window for client-baked gadgets (lifts, doors, vending machines). type GadgetStreamSettings struct { Enabled bool `json:"enabled"` RadiusCells int32 `json:"radiusCells"` // // ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3): // 走出本边界(Chebyshev > 本值,以当前所在格为中心)的格子会被显式卸载。 // ⛔ 历史:见 SceneItemStreamSettings.UnloadRadiusCells —— 2026-09-28 实测时 // 它只被 Validate() 读一次,本通道的 removeGridInfo 也恒为空(硬规则 212 作废)。 // ⛑ 回滚真卸载:realUnload = false。 // UnloadRadiusCells int32 `json:"unloadRadiusCells"` // ★ 见 SceneItemStreamSettings.RetainVisitedCells —— 2026-10-01 起真的生效:卸载边界内的保留环。 RetainVisitedCells bool `json:"retainVisitedCells"` // ★ 见 SceneItemStreamSettings.RealUnload —— 真正下发 removeGridInfo。默认开;false = 只增不减的旧行为。 RealUnload bool `json:"realUnload"` // ★ 见 SceneItemStreamSettings.RealUnloadLogOnly —— 只记不发。 RealUnloadLogOnly bool `json:"realUnloadLogOnly"` // ★ 见 SceneItemStreamSettings.ReseedOnFarJump —— 传送 / 远跳重新铺窗。默认开。 ReseedOnFarJump bool `json:"reseedOnFarJump"` // When true, request every authored gadget in each production cell instead of applying the extracted sector filter. LoadAllSectors bool `json:"loadAllSectors"` // ⛔ 见 SceneItemStreamSettings.LoadBudgetCells —— 只被 Validate() 校验, // 真正的预算键是 maxCellsPerPush。 LoadBudgetCells int32 `json:"loadBudgetCells"` // // 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].) // ExcludedGadgetIds []uint64 `json:"excludedGadgetIds"` // ---- 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). TickMs int32 `json:"tickMs"` // // 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. // HeartbeatMs int32 `json:"heartbeatMs"` // // 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. // FirstDelayMs int32 `json:"firstDelayMs"` // Full re-send of the surrounding window after this long (ms). Safety net, not the main path. RefreshMs int32 `json:"refreshMs"` // // ★★ 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 已经做过时也算完成。 // SettleReseedMs int32 `json:"settleReseedMs"` // Tighter ring for cells that carry interior sectors (rooms pop in on entry otherwise). RadiusInteriorCells int32 `json:"radiusInteriorCells"` // Cells per 68956797 message. One message carries one sector list, never more. MaxCellsPerPush int32 `json:"maxCellsPerPush"` // // 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). // ResendWindowOnMove bool `json:"resendWindowOnMove"` // // 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. // SceneAoiSwitchEnabled bool `json:"sceneAoiSwitchEnabled"` } // DynamicGo re-seeding after gameplay-ready. type DynamicGoStreamSettings struct { Enabled bool `json:"enabled"` // How many times SyncDynamicGoActiveInfo is re-sent (client headers load async). SeedAttempts int32 `json:"seedAttempts"` // Activate every DynamicGo id found in the build-4229938 probe. ActivateAllKnown bool `json:"activateAllKnown"` // Force explicitly-authored test/temp/debug DynamicGo blockouts inactive. ExcludeTestPlaceholders bool `json:"excludeTestPlaceholders"` } type Vector3Settings struct { X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"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. // type RosterRowOverride struct { SpiritId uint32 `json:"spiritId"` // Panel display name for this row. Empty = keep the FightSpiritConfig name. Label string `json:"label"` } type ContentSettings struct { Roster RosterSettings `json:"roster"` SummonWheel SummonWheelSettings `json:"summonWheel"` ExcludedUnlockSystems []uint32 `json:"excludedUnlockSystems"` IncludePhoneBottomApps bool `json:"includePhoneBottomApps"` } type RosterSettings struct { TemplateIdMin uint32 `json:"templateIdMin"` TemplateIdMaxExclusive uint32 `json:"templateIdMaxExclusive"` ExcludedTemplateIds []uint32 `json:"excludedTemplateIds"` // // 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 ExcludedTemplateIds; // * its icon equals GenericPlaceholderIconId (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). // AdditionalTemplateIds []uint32 `json:"additionalTemplateIds"` // // 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. // RowOverrides []RosterRowOverride `json:"rowOverrides"` // // When true (default) a roster row is dropped unless it has a real head icon // (SHeadIconID > 0 and not GenericPlaceholderIconId). // // 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.) // // RequireIconId bool `json:"requireIconId"` GenericPlaceholderIconId uint32 `json:"genericPlaceholderIconId"` UnitIdBase uint64 `json:"unitIdBase"` DefaultUrbanJob int32 `json:"defaultUrbanJob"` // // 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. // SwitchableRoles []uint32 `json:"switchableRoles"` } // // 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. // type SummonWheelSettings struct { // // 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. SlotOverrides []SummonSlotOverride `json:"slotOverrides"` } type SummonSlotOverride struct { SentId uint32 `json:"sentId"` TargetId uint32 `json:"targetId"` // Optional note; only appears in console logs. Label string `json:"label"` } type GameplaySettings struct { // // ★ 可玩角色「常驻大世界」:进世界后,名单里每个角色按它自己的日程站在城里对应的位置 // (Richie 在跑步机、Garm 在娃娃机 …),不用先切走再切回。 // 机制与 Aether.FunctionalNpcsEnabled 完全同款(跟随循环 + 进入/离开迟滞), // 差别只有坐标来源:这里取 AgentDataSetsActivityConfig.MapPosition。 // RosterNpc RosterNpcSettings `json:"rosterNpc"` // // ★ Plate(盘)演出管线试点 —— gameplay.plate。 // 角色光站在点位上只会发呆玩手机;要让里希真的去跑跑步机,服务端必须推 // SyncPlateAddList(68554224) 把「盘」交给客户端(硬规则 157)。 // ⛑ 客户端 reader 在 C# 侧(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly), // 线格式只能靠试 ⇒ 这几组 *Encoding/*Marker 开关就是给实机 A/B 用的, // 改配置不用重编译(每次推送时现读)。 // Plate PlatePilotSettings `json:"plate"` // // ★ 生活日程「正式通道」—— gameplay.npcLife。 // 比 Plate 更靠前的一层:官方让一个好感 NPC 真的去跑跑步机,靠的是 // SyncFavorNpcSpoonAgentId(64003213)(「这个 agentTag 现在占着几号 spoon 位、站哪儿」) // 以及登录帧 FavorNpcDailyScheduleInfos 里的 // CurrentSpoonAgentId / SpoonPosition / 每段的 SpoonAgentId。 // 我们以前这三处全填 0(见 RuntimePayloadFactory)⇒ 客户端只有活动 id, // 没有「人该站到哪个 spoon 节点上」⇒ 只能播默认 ambient(站着玩手机)。 // ⛑ spoon 位号到底是什么值还没有官方表(客户端 Spoon 资产里),所以做成旋钮逐项试。 // NpcLife NpcLifeSettings `json:"npcLife"` // // ★ 按地块铺市民(T0-8)的 POI 二次筛选(2026-10-02 新增, // 依赖 UrbanDiversityPOIActionConfig.json 全字段表)。 // 两个开关默认全 false = 完全保持今天的行为,打开才会用全字段表过滤候选池。 // CivilianPoi CivilianPoiSettings `json:"civilianPoi"` // // 官方等待区站位(2026-10-02 新增,配置键 gameplay.civilianWaitArea)。 // 数据来源 = paths.pedSpawns(zonegraph_ped_spawns.csv,4070 槽 / N 个等待区), // 消费点 GameRouter.Civilians。⛑ Enabled 默认 false ⇒ 不改现有行为。 // CivilianWaitArea CivilianWaitAreaSettings `json:"civilianWaitArea"` // // ★ 钓鱼播种 —— gameplay.fishingSeedAllGear。 // 登录帧 InfoMinor.FishingInfo.GearDict 是否整套播种 34 件渔具 // (FishingGearConfig.json 全表)。只在 GearDict 为空时才播, // 所以已经在官方服拿过渔具的存档不会被覆盖。 // 为什么值得做:捐赠文档 §8 说 GearDict 在客户端 lua / C# 都无人读取、 // 「往里塞数据零可见效果」—— 那条结论说的是 字典内容不被消费, // 不等于 PlayerFishingInfo 这个结构体空着无所谓。客户端初始化链 // (FishingGameFishMgr.container / FishingGameEffectMgr.objPool / // FishingGameRod.animator)实测三连 nil,本文档作者的环境里播种是开着的。 // ⇒ 这是一次性把"服务端少给了一块"这个变量消掉的开关,关掉即可回到原状。 // FishingSeedAllGear bool `json:"fishingSeedAllGear"` // // ★ 第 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。 // IndoorVisibilityGate bool `json:"indoorVisibilityGate"` // // 播种渔具的 RemainingDurability(gameplay.fishingGearDurability)。 // ⛑ 小游戏本身用的是 lineStaminaMax(1000),不读这个字段 // (捐赠文档 §8「渔具耐久字段无人使用」)。填它只是为了结构体完整、不留 0 值。 // FishingGearDurability int32 `json:"fishingGearDurability"` // // 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. // HackerAbilitySet string `json:"hackerAbilitySet"` // // 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. // HackerTalentBuffIds []uint32 `json:"hackerTalentBuffIds"` // // 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: UrbanJobOverrides. // UrbanJobGrantMode string `json:"urbanJobGrantMode"` // // 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. // UrbanJobLevel string `json:"urbanJobLevel"` // // 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. // UrbanJobRank string `json:"urbanJobRank"` // // 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 UrbanJobOverrides) 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. // JobBadgeBuffs string `json:"jobBadgeBuffs"` // // 潜水(Diving)解锁 —— 让所有角色都能下潜,而不是只有水面游泳。 // · "on"(默认)— 进世界后服务端自动做两件事:① 通过 // SyncRunGmUtils(66042045) 让客户端反射执行 GmUtils 的潜水开关方法 // (默认 EnableDive,可换成 ForceDive 走强制路径); // ② 把 DivingSuitBuffId 挂进该角色的 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 是否被反射命中只能实机验证。 // 若开了仍潜不下去,把 DivingGmMethods 改成 ["ForceDive"] 再试。 // DivingMode string `json:"divingMode"` // // DivingMode="on" 时挂进角色 buff 列表的潜水服 buff。 // BuffConfig 52606233「潜水服buff」= 不会掉氧气,且会更快的恢复氧气。 // DivingSuitBuffId uint32 `json:"divingSuitBuffId"` // // 白靖(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 即可立刻用上, // 不用重新编译。 // BaiJingTimeBindActiveSkill bool `json:"baiJingTimeBindActiveSkill"` // // 排查 BuffConfig.TimeScaleBadgeBuff 真值的应急旋钮:这里列的 buff 会额外挂进 // 白靖的常驻 SyncUnitBuffList,用来做 A/B(改配置即可,不用重新编译)。 // 默认空。候选(按可能性排序,都出自 UberSimConfig 那套网约车/货车玩法): // · 52980302「货车职业徽章19001323(子弹时间持续时间提高)」—— TimeStopPara 的第二条; // · 52980312 / 52980313 / 52980314 —— UberSimConfig.AddAcceptBuffList(接单 buff); // · 52959496「斯安威斯坦」(5 秒状态本体)—— 不太可能(按钮得在没激活时显示), // 但若游戏把它当"能力已解锁"标记也说得通。 // ⛑ 前两个已经是白靖常驻的 52959495 / 52606175,别重复填。 // BaiJingTimeScaleProbeBuffs []uint32 `json:"baiJingTimeScaleProbeBuffs"` // // 梅卡羊生成帧的 SyncUnitOccur.occurId。默认 0(与 GM 面板那次成功生成一致)。 // 70000302 是 AgentConfig 40980002.EnemyOccurId,留作排查"模型透明"时用。 // ⛑ 2026-09-27 实机:GM 面板生成 40980002 时日志是 occur=0(EnemyAiCatalog // 没有这一行,SpawnAgentAsync 只能填 0),结果是「单位在、能出攻击特效, // 但模型透明」⇒ 怀疑出场演出(淡入 / 材质)没跑。改成 70000302 验证。 // MechanikaSheepOccurId uint32 `json:"mechanikaSheepOccurId"` // // 梅卡羊生成帧的 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 也是透明的),解不出树 ⇒ 单位不初始化 ⇒ 不渲染。 // MechanikaSheepTreeName string `json:"mechanikaSheepTreeName"` // // 梅卡羊走哪条生成通道: // · "spawnAgent"(默认)— GameAdminBridge.SpawnAgentAsync。 // uid 落在 0x200000000000+100000 段(GM 面板生成 40980002 实测 35184372188833, // 能出场),包序列与 AgentCatalog 校验都是已验证的那套。 // · "handwritten" — 保留的手写帧(uid 落在 0x300000000000 段)。 // ⛑ 换通道没能解决问题(uid 与 GM 完全相同仍不出),见 MechanikaSheepTreeName。 // ⛑ 2026-09-27:手写帧服务端日志一直是 Ok=True、包也发了,但客户端完全不渲染; // 同一模板走 SpawnAgentAsync 却能出场 ⇒ 怀疑就是 uid 段。默认已切到 spawnAgent, // 留这个开关只为对照。 // MechanikaSheepSpawnPath string `json:"mechanikaSheepSpawnPath"` // // 羊用的 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 米判定用的是自己记的坐标,不受影响。 // MechanikaSheepAgentId uint32 `json:"mechanikaSheepAgentId"` // // 羊的阵营(SyncAgentCampInfo.camp)。默认 0(Unknown / 市民,硬规则 132: // 友方 NPC 一律 0)。改成 4 就是敌对阵营(GM 面板生成 40120651 时就是 4, // 用户实机反馈"会攻击我")。排查"羊不出现"时可试着切 4 做对照。 // MechanikaSheepCamp uint32 `json:"mechanikaSheepCamp"` // // 生成之后要不要补"激活批次"。⛑⛑ 这是 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" — 一个都不发(旧行为,已实机证明不出场)。 // MechanikaSheepActivate string `json:"mechanikaSheepActivate"` // // 生成之后要不要把羊头顶那根血条收掉(默认 true)。 // ⛑ 用户 2026-09-27 实机:「羊头顶上有个血条,不像机械狗这样不显示血条,是不是羊默认不是友方」。 // 排查结论 —— 跟友方/敌对无关,血条第一判据是模板的 AgentConfig.ShowHPBar: // · 40120651「羊」ShowHPBar=True // · 机械狗 40120911 的 ShowHPBar 也=True,但它不显示血条 // ⇒ 差在第二个判据:机械狗走 GameRouter.HackerSummon.cs 的 // SyncAddUnitState(state=10266 /*UnitStateConfig.HideHp*/)。 // 配置表是客户端压缩二进制池,服务端改不了 ShowHPBar ⇒ 只能写 UnitState。 // 阵营那条路已经是对的(MechanikaSheepCamp=0,且从不发 SyncForceEnemyRelation)。 // MechanikaSheepHideHpBar bool `json:"mechanikaSheepHideHpBar"` // // 梅卡妮卡 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。 // MechanikaSheepActiveSkillId uint32 `json:"mechanikaSheepActiveSkillId"` // // 一次 E 放几只羊。默认 3(左前 / 右前 / 背后)。 // ⛑ 落点角度见 MechanikaSheepSpawnAngles;本值 <=0 时取角度数组的长度。 // MechanikaSheepPerCast int32 `json:"mechanikaSheepPerCast"` // // 落点相对玩家朝向的角度(度),默认 [45, -45, 180] = 左前 / 右前 / 背后。 // 落点距离固定 2.5 m(GameRouter.MechanikaSheep.cs 的 MechanikaSheepDropMeters4229938)。 // ⛑ 硬规则 181:这些是度数,代码里会先 × π/180 再喂 sin/cos。 // MechanikaSheepSpawnAngles []int32 `json:"mechanikaSheepSpawnAngles"` // // 场上同时存活的羊上限。默认 0 = 不限制(2026-09-27 用户要求解除上限)。 // ⛑ 不限制意味着连按 E 可以无限堆 —— 若发现卡顿/客户端吃不消,填一个数字即可,不用改代码。 // 切角色 / 切场景时会整批 SyncLogicAgentLeave 召回(StopMechanikaSheep4229938)。 // MechanikaSheepMaxAlive int32 `json:"mechanikaSheepMaxAlive"` // // DivingMode="on" 时依次下发给客户端执行的 GmUtils 方法名。 // 默认只跑 EnableDive;实测无效时改 ForceDive(绕过条件检查), // 水下滤镜是 SetUnderWaterFilter(可与前者并列,例如 ["EnableDive","SetUnderWaterFilter"])。 // DivingGmMethods []string `json:"divingGmMethods"` // // 传给 DivingGmMethods 每个方法的参数列表,默认 ["true"]。 // ⛑⛑ 必须小写:客户端 GmUtils.RunGmUtilsCommand 用 Newtonsoft.Json 解析每个 // 参数字符串,传 "True" 会抛 JsonReaderException(Unexpected character ... value: T) // 并整条指令丢弃 —— 2026-09-22 21:52 实测踩过(日志见 Player.log 的 // "GM指令 EnableDive 参数解析错误 arg=enable argString=True")。 // DivingGmArgs []string `json:"divingGmArgs"` // ⛔ 载具 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 = 重霄食谱. // UrbanJobOverrides map[string][]uint32 `json:"urbanJobOverrides"` // // 单个角色的「当前职业」覆盖,key 同样是 spirit template id("15021039": 4)。 // 与 UrbanJobOverrides 的区别:那个只往 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)别填,会顶掉作者设定。 // UrbanJobCurrentOverrides map[string]uint32 `json:"urbanJobCurrentOverrides"` // // 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). // PoliceTodayCompleteMissionCnt uint32 `json:"policeTodayCompleteMissionCnt"` // // 层 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. // PoliceDispatchEnabled bool `json:"policeDispatchEnabled"` // // 黑客 APP 召唤物(蜘蛛机器人 51938181 / 送货无人机 51938183)。 // 为 true 时,服务端接到这两个 temp-skill 后会在玩家身前刷出对应 agent // (40120908 / 40120909),并推 SyncSwitchControl(68552741) 把玩家放进操控位; // 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → 蜘蛛/无人机操控面板全在客户端。 // 回滚:置 false 后服务端只打一行 [SUMMON] skipped 日志,不刷实体也不接管 —— // 即恢复到 2026-09-23 之前的「点了按钮只有施法动作」状态。 // HackerSummonEnabled bool `json:"hackerSummonEnabled"` // // 巡卫官执勤终端的巡卫犬(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,回到「点了只有施法动作」。 // PoliceDogSummonEnabled bool `json:"policeDogSummonEnabled"` // // 巡卫官执勤终端的战术支援(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、什么也不发生」。 // PoliceArmedSupportEnabled bool `json:"policeArmedSupportEnabled"` // // 直升机搜查的服务端记账(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 里(飞行载具)。 // PoliceHelicopterEnabled bool `json:"policeHelicopterEnabled"` // // 掉落物拾取的两个 C2S 回执(AskPickUpSceneItemTransferToItem 67297154 / // AskEnemyItemPickUp 67453017)是否记账。见 GameRouter.NpcPhoneDrop.cs。 // ⛑ 关掉它也照样回空 OK —— 这两个是 INVOKE,不回执客户端会 await 到超时(硬规则 94)。 // 这个开关只管"记不记日志/记不记账本",不管回不回执;这是故意的,回滚安全性优先于开关干净。 // 顺带说明"路人手机内容"这条线:内容全在客户端本地表 // (RandomChatManager.lua 按 npcId 的标签匹配 35 行 NPCChatAtmosphereNewConfig), // 服务端塞不进文本;要让每个人手机不一样,动的是 npcId(AgentConfig id),不是这里。 // SceneItemPickupEnabled bool `json:"sceneItemPickupEnabled"` // // 直升机搜查(派遣 id=3)落地时,是否真刷一台 VehicleConfig 81007007 // (Helicopter01a_Rope,带索降绳)停在玩家旁边。 // 为什么它是独立开关:官方的直升机搜查面板在 build 4229938 里是半成品 // (面板没有打开入口、SyncPoliceDispatchHelicopter 是空实现、dump.cs 里没有对应 C# 类), // 服务端接再多也只是在后台记账。刷这台载具是"让场景里真的出现一架直升机"的替代方案, // 与官方玩法不是一回事,所以要能单独关掉。 // 回滚:置 false 后点直升机搜查只记账、不刷载具(三条 C2S 回执照旧,客户端不会卡死)。 // PoliceHelicopterVehicleEnabled bool `json:"policeHelicopterVehicleEnabled"` // // 正向「逮捕 NPC」总开关(2026-09-25)。false ⇒ 收到盘查选项 RPC 也只回执、不推 // SyncPoliceBeginArrest(68839124)。回滚用它,不用重新编译。 // PoliceArrestEnabled bool `json:"policeArrestEnabled"` // // SyncPoliceBeginArrest 的 ArrestType。反向链路(玩家被捕)用的是 1 // (GameRouter.Wanted.cs:723),正向默认照抄;演出不对就在这里试 0 / 2。 // PoliceArrestType int32 `json:"policeArrestType"` // 演出结束后是否把被捕的 NPC 用 SyncLogicAgentLeave(68281244) 带走。 PoliceArrestRemoveEnabled bool `json:"policeArrestRemoveEnabled"` // // 逮捕演出播多久再把人撤走(毫秒)。太短会看不到演出,太长城里会一直堆人。 // PoliceArrestRemoveDelayMs int32 `json:"policeArrestRemoveDelayMs"` // // 里希**牢房收押 / 带出**(2026-09-30,GameRouter.PolicePrison.cs)。 // 只做「位置管理 + 账本/AI 托管」,入口是 GM 面板 /api/police/prison; // 不做原生押送过场、不做 F 交互按钮(都要捐赠端那套 GATE Lua 通道,主线没有)。 // PolicePrison PolicePrisonSettings `json:"policePrison"` // // 敌人血条专项(2026-09-23)。三个都是对照开关,默认值 = 当前行为,改 false 就是一次 A/B。 // 背景:kimika 捐赠端的 spawn 序列里没有 SyncAgentCampInfo 和 SyncAttachBattleModule, // 也没有 SyncEnemyDisarmState / SyncUnitLockTarget;而我们四个都发。kimika 那边敌人血条是可 // 以显示的,所以"我们多发"的这几个包是负向候选:特别怀疑 SyncAttachBattleModule 会 // 把单位换到另一套战斗模块上,HUD 绑的是原来那套。反之 kimika 有而我们缺的是技能槽绑定。 // 三条都能一键回滚,改完重启服务端即可(不需要重新编译之外的动作)。 // EnemySpawnCampInfo bool `json:"enemySpawnCampInfo"` // 见 EnemySpawnCampInfo。false = spawn 时不推 SyncAttachBattleModule // (注意:注释称它缺失会导致 T-pose,所以关掉后要同时看模型姿势)。 EnemySpawnAttachBattleModule bool `json:"enemySpawnAttachBattleModule"` // // 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 的约定)。 // EnemySpawnSkillSlots bool `json:"enemySpawnSkillSlots"` // // 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)。 BlackoutControllerAtPlayer bool `json:"blackoutControllerAtPlayer"` // // 为 true 时用 BlackoutBillboardX / BlackoutBillboardY / // BlackoutBillboardZ 这组广告牌坐标;面板「启动」里填了坐标则以面板为准。 // 为 false(2026-09-24 21:3x 用户拍板后的默认值)时退回 // BlackoutControllerAtPlayer 的玩家脚下逻辑。 // ⛑️⛑️ 为什么默认改回 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 外,玩家永远按不到)。 // BlackoutControllerAtBillboard bool `json:"blackoutControllerAtBillboard"` // // 控制器落点 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),或让面板「退回牌子脚下」。 // BlackoutBillboardX float32 `json:"blackoutBillboardX"` // 控制器落点 Y — 官方牌屏幕离招牌原点高 70 m。 // ⛑️ 要在地面骇入就填 0,并配合上面的落脚点 (1992.47, 0, 2313.44)。 BlackoutBillboardY float32 `json:"blackoutBillboardY"` // 控制器落点 Z — 官方牌屏幕中心 2287.694。 BlackoutBillboardZ float32 `json:"blackoutBillboardZ"` // // 「控制器摆了但骇入点不出现」的补发次数(0 = 关闭补发)。 // 服务端每次都报 `controller acked`,但玩家走到落点看不到骇入提示 ⇒ // 客户端把这条 SyncGadgetAOIAddAndRemove 丢掉了(常见于按下启动时玩家还在 // 几百米外,物件被客户端自己的流式/AOI 规则丢掉,之后传送过去也不会再加载)。 // 补发用同一个 instanceId + 同一组坐标,幂等。调到 0 即回到接入前的行为。 // BlackoutRearmMax int32 `json:"blackoutRearmMax"` // 两次补发之间的间隔(秒)。默认 5 秒 × 6 次 = 30 秒的补救窗口。 BlackoutRearmSeconds int32 `json:"blackoutRearmSeconds"` // // 层 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. // PoliceCaseCount uint32 `json:"policeCaseCount"` // // 层 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. // PoliceServiceTally uint32 `json:"policeServiceTally"` // // 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. // PolicePayloadMode string `json:"policePayloadMode"` // // 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. // PoliceFakeFilesUnlock bool `json:"policeFakeFilesUnlock"` // // 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. // NpcShopPayloadMode string `json:"npcShopPayloadMode"` // // 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. // BubblePayloadMode string `json:"bubblePayloadMode"` // // 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. // BubblePostDateStepSeconds uint32 `json:"bubblePostDateStepSeconds"` // // 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. // BubbleMarkAllRead bool `json:"bubbleMarkAllRead"` // // 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. // NpcDailyScheduleEnabled bool `json:"npcDailyScheduleEnabled"` // // 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. // DeliveryEnabled bool `json:"deliveryEnabled"` // // 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. // DeliveryReplyFraming string `json:"deliveryReplyFraming"` // Rows on the take-order board. UberSimConfig authors 2 as the day's first batch. DeliveryBoardSize int32 `json:"deliveryBoardSize"` // // 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. // DeliveryEstimatedFinishSeconds int32 `json:"deliveryEstimatedFinishSeconds"` DeliveryBasePointReward int32 `json:"deliveryBasePointReward"` // // 蓝猫物流「部署 / 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 只有施法动作、没有东西出来」。 // DeliveryDroneSummonEnabled bool `json:"deliveryDroneSummonEnabled"` // // 配送类 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 重启即可,不用重编译。 // DeliverySuccessErrorId int32 `json:"deliverySuccessErrorId"` // // 取货点 / 送货点坐标是否用玩家最后一次上报的位置当锚点。 // 背景:UberSimPickupConfig / UberSimDeliveryConfig 只有名字和标签,一行坐标都没有, // 所以服务端无从得知真正的取送货点。开 = 取货点落在玩家脚下、送货点沿 X/Z 各偏移 // DeliveryDropOffset,地图标记至少落在玩家够得着的地方;关 = 回到全 0(世界原点)。 // 用于 AskQueryTruckPosInfo(63763434) 与看板行的 StartPos/EndPos。 // 回滚:置 false。 // DeliverySpotsAtPlayer bool `json:"deliverySpotsAtPlayer"` // // 送货点相对取货点的偏移(世界单位,X/Z 各加这么远)。默认 120 —— 看得见、走得到, // 又不至于远到要开车。仅 DeliverySpotsAtPlayer 为 true 时生效。 // DeliveryDropOffset float32 `json:"deliveryDropOffset"` // // 接单成功后是否补推 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。 // DeliveryAcceptSyncEnabled bool `json:"deliveryAcceptSyncEnabled"` // ⛔ 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 小时前完成,好让排序和「今日累计」看得出来。 // DeliveryLogSeedCount int32 `json:"deliveryLogSeedCount"` DeliveryRewardPointSum int32 `json:"deliveryRewardPointSum"` DeliveryTotalIncome int32 `json:"deliveryTotalIncome"` // Returned by AskGetTruckSatisfactionAverage and used as the board's average. 5 = five stars. DeliverySatisfaction float32 `json:"deliverySatisfaction"` // // Vehicle preselected for 叫车. 0 lets the client fall back to // UberSimOrderSupportConfig.CallCarCfg.vehicleId ([81003022, 81004024]). // DeliveryDefaultVehicleId uint32 `json:"deliveryDefaultVehicleId"` // // 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. // DeliverySupportBuffIds []uint32 `json:"deliverySupportBuffIds"` // // 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. // TuiteEnabled bool `json:"tuiteEnabled"` // // 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). // TuiteFollowAll bool `json:"tuiteFollowAll"` // // 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. // NpcChatEnabled bool `json:"npcChatEnabled"` // // 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. // NpcChatStoryThreads bool `json:"npcChatStoryThreads"` // // 手机「拨号」:用 Configs/PhoneContacts.json 里的整张通讯录填充登录帧的 // PlayerPhoneInfo.SpiritPhoneInfos[spirit].ContactList。 // 通讯录条目只有号码 + 备注两个字段,名字/头像/台词全在客户端本地 PhoneContactConfig 里按号码回查, // 所以这是纯数据下发,没有 wire 风险。回滚 false = 只剩两个叫车快拨号(2026-09-24 之前的行为)。 // PhoneContactsEnabled bool `json:"phoneContactsEnabled"` // // 把 PhoneContactUnlockConfig 的**全部**行 id(79 条)标成已解锁(module 3 = PhoneContact 的 // FinishedTemplateIdList)。 // 判据在 EventConditionUtils.CheckHasUnlocked:列表里包含该行的 Id 即视为解锁; // 原版靠任务/剧情推进,我们没有任务链,所以整表标完成。未解锁时客户端播 // LockDialogId(号码忙),既不报错也不打日志,是这条线最容易误判成"服务端没发"的地方。 // 回滚 false = 只解锁叫车那两行(Id 1/2),等于回到 2026-09-24 之前。 // PhoneContactsUnlockAll bool `json:"phoneContactsUnlockAll"` // // 连 AutoAddFlag = false 的号码一起下发(诈骗电话 13342865 / 灵异电话 44444444 / // 商店外卖类 17 条)。这些是原版要靠手动拨号或特定任务才出现的彩蛋与商线, // 默认 false 只发会自动进通讯录的那 49 条。 // PhoneContactsIncludeHidden bool `json:"phoneContactsIncludeHidden"` // // Where the 趴卜 post pictures are fetched from (SyncOssBucketInfo 64833344). Off by // default — see OssPictureSettings for what to fill in. // OssPicture OssPictureSettings `json:"ossPicture"` SwitchAnimations SwitchAnimationSettings `json:"switchAnimations"` Combat CombatSettings `json:"combat"` WebTraversal WebTraversalSettings `json:"webTraversal"` Vehicles VehicleSettings `json:"vehicles"` // // 车辆改装店(ApplyCar / RepairShop)进场 —— 2026-09-25 从俄语上游 donor 移植。 // 三个开关都可以在不动代码的情况下回滚:enabled=false 会把整套逻辑退回 // 移植前的行为(AskTeleport 只 ack 不 echo、两个 waypoint RPC 只 ack 不推位移、 // 不再补推 SyncStartSummonVehicle)。 // RepairShop RepairShopSettings `json:"repairShop"` // // 载具路线规划(AskVehicleNavigationPathPoints / …FromPos / …PathLength)—— // 2026-09-24 从第九份捐赠端移植,但图用的是我们自己的车道表。见 // GameRouter.VehicleNav.cs 头注(里面有 unknown-methods.log 的取证数字)。 // 关掉即逐字节回到「空点集 + 长度 0」的旧行为。 // VehicleNavigation VehicleNavigationSettings `json:"vehicleNavigation"` // // 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30)—— 面板与判定全在客户端 // (ToiletOstrichGamePanelStore.lua 的音游式三键,满分 100),服务端只负责 // 「让骑乘坐实」+「接住失败上报」。见 GameRouter.ToiletQte.cs 头注。 // 马桶鸵鸟(规范 id 42046401)是标准 Agent,用既有的服务端直生成通道即可出, // 它已被置顶到面板「生成敌人」下拉框第一位(GameAdminBridge.AgentSummonCatalog)。 // ToiletQte ToiletQteSettings `json:"toiletQte"` Taxi TaxiSettings `json:"taxi"` Aether AetherSettings `json:"aether"` // // 派对 NPC 是否走「预制动作」。默认 true。 // 关掉 = 回到第 37 轮之前的行为(PoiActionId 恒 0,NPC 只有兜底 idle)。 // 只影响**派对系**(91055000 / 91056001 / 91056002)的 NPC,其它玩法与城市市民不受影响。 // PartyNpcPoiEnabled bool `json:"partyNpcPoiEnabled"` // // 露营系(`NpcCultivationGameplayTypeConfig` Id = 91055000 露营派对 / // 91056001 露营引导)NPC 的 POI 动作 id 表,按人轮转。空数组 ⇒ 该玩法回退 // PartyNpcPoiDefaultActionIds。 // ★ 第 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)。 // PartyNpcPoiCampActionIds []uint32 `json:"partyNpcPoiCampActionIds"` // // 舞会(91056002)NPC 的 POI 动作 id 表,按人轮转。 // ★ 第 39 轮换成官方**互动成套族**「站姿_夜店_开火车 01~04」(ActionTag4 = `人物互动`)。 // 第 38 轮的 `[6,40,5,174,52]`(鼓掌/围观/交谈)同样是单人堆,已作废。 // ⚠ 若觉得「开火车」不贴舞会,可换 `[783,784]`(跟着DJ摇摆动作双人A/B,但 tag4 为空)。 // PartyNpcPoiDanceActionIds []uint32 `json:"partyNpcPoiDanceActionIds"` // // 兜底动作表(玩法认不出来 / PartyNpcPoiEnabled 打开但上面两张表为空)。 // 17 = 站姿_通用_站姿闲置00,与现在的隐式兜底等价,但走正规 POI 槽位。 // 配成空数组 ⇒ 完全不发 POI(等价于第 37 轮之前的行为)。 // PartyNpcPoiDefaultActionIds []uint32 `json:"partyNpcPoiDefaultActionIds"` // // ★ 派对 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,城市市民/店员/行人的行为不变。 // PartyNpcPoiSkipHackPhone bool `json:"partyNpcPoiSkipHackPhone"` // // 派对 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)。 // PartyNpcRoamMode string `json:"partyNpcRoamMode"` // 驻留行为是否补发 SyncLogicAgentEnter(68719890)。默认 true。 PartyNpcLogicEnter bool `json:"partyNpcLogicEnter"` // 驻留行为是否补发独立 SyncAgentForceGo(68698324)(启动行为树)。默认 true。 PartyNpcForceGo bool `json:"partyNpcForceGo"` // // 游荡半径下限(米),SyncMoveWandering.MinDis。默认 2。 // ⚠ 营地里有家具与碰撞体(铁律 17:落点必须实测站得住)⇒ 别调大,否则会走丢/穿模。 // PartyNpcRoamMinDis float32 `json:"partyNpcRoamMinDis"` // 游荡半径上限(米),SyncMoveWandering.MaxDis(waypoint 档同时用作巡游半径)。默认 10。 PartyNpcRoamMaxDis float32 `json:"partyNpcRoamMaxDis"` // 单次游荡时长(秒),MaxOnceWanderTime;MaxTime 自动 +5s。默认 25。 PartyNpcRoamSeconds float32 `json:"partyNpcRoamSeconds"` // // 续期间隔(秒)。MaxTime 走完客户端会**永久站住**,所以必须周期性补发。默认 18。 // 必须小于 PartyNpcRoamSeconds,否则中间会出现「站住」的空档。 // PartyNpcRoamRearmSeconds float32 `json:"partyNpcRoamRearmSeconds"` // 续期循环的节拍(毫秒)。默认 2000。 PartyNpcRoamTickMs int32 `json:"partyNpcRoamTickMs"` // // 游荡的 PathTags(客户端 AetherAI 的寻路标签)。默认 6,与 // Aether.AmbientPedsWanderPathTags 同值(那条已在行人群上跑通)。 // ⛑ 这是最容易踩的静默失败点之一(Ambient.cs 文件头)。 // PartyNpcRoamPathTags uint32 `json:"partyNpcRoamPathTags"` // waypoint 档的巡游速度(m/s)。默认 1.0(散步)。 PartyNpcRoamSpeed float32 `json:"partyNpcRoamSpeed"` // waypoint 档每腿的额外余量(毫秒),用来吸收寻路绕行。默认 1500。 PartyNpcRoamLegSlackMs int32 `json:"partyNpcRoamLegSlackMs"` // // ★★★ 第 47 轮:**覆盖派对 NPC 的落点圆心**(世界坐标,3 个 float = X / Y / Z)。 // 默认空数组 = 用官方配表 PartySpot(露营 13030000 = (1288.91, -0.09, 2374.47), // 见 PartySpotCatalog4229938)。 // 用户现场:「他们出现的位置还是之前的,没有出现在我想要的位置」⇒ 给了「按我站的位置 // 1282.93, 0.97, 2374.78」。落点 = 以此点为圆心、PartyNpcSpawnRadius 为半径的圆周, // 人人**面朝圆心**(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 的官方语义在别处照旧,零影响。 // PartyNpcAnchorOverride []float32 `json:"partyNpcAnchorOverride"` // // ★ 第 48 轮:PartyNpcAnchorOverride **只对这一个 partyId 生效** // (默认 13030000 = 露营·新启)。 // WHY:那个覆盖原先对**所有**派对无条件生效 ⇒ 一开舞会(13030003),NPC 会被摆到 // **露营**的那个圆周上,而不是舞会的 PartySpot。 // 要覆盖别的场地(例如舞会)请用 PartyNpcAnchorOverrides,**不要改这个默认值**。 // PartyNpcAnchorOverridePartyId uint32 `json:"partyNpcAnchorOverridePartyId"` // // ★ 第 48 轮:**按 partyId 分别覆盖落点圆心**,键 = partyId 的字符串 // ("13030003": [x, y, z];必须是字符串键 —— System.Text.Json 只原生支持字符串字典键)。 // 优先级**高于** PartyNpcAnchorOverride。同样**只取 X/Z**,y 恒用官方 // PartySpot 的 y(HUD 那个 y 是眼高,见 PartyNpcAnchorOverride 的说明)。 // 例(舞会):"partyNpcAnchorOverrides": { "13030003": [2605.33, 80, 1269.74] } // ⚠ 该键随 config/*.json 热重载;⚠⚠ private-server.json 里**不能写 // 注释** // (Run-All.ps1 用 Windows PowerShell 5.1 的 ConvertFrom-Json 严格解析)。 // PartyNpcAnchorOverrides map[string][]float32 `json:"partyNpcAnchorOverrides"` // // ★★★ 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 严格解析)。 // PartyVenueAtPlayerPartyIds []uint32 `json:"partyVenueAtPlayerPartyIds"` // // 就地开局时,**NPC 落点 y 相对玩家上报 y 的偏移**(米;正 = 抬高)。默认 0。 // 为什么默认 0:实测(2026-09-30)就地开局把 NPC 放在「玩家 y − 1.07」时 // **全员陷进地里只露半个身子** ⇒ NPC 的落点 y 与玩家上报 y 是同一参考系 // (都是角色根),不需要再减眼高。想微调(浮空/下陷)就改这一个键,**热重载生效**。 // ⛑ 家具是**另一套**参考系(比角色根低 ≈1.07 m),别拿这个值去套家具。 // PartyVenueNpcYOffset float32 `json:"partyVenueNpcYOffset"` // // 就地开局时,**以玩家为圆心把 NPC 随机散布**的半径(米)。0 = 关闭(保持官方摆法)。 // 非 0 时角度与距离都随机(距离 ∈ [0.45×R, 1.0×R]),每人朝圆心(= 玩家)。 // 例:"partyVenueScatterRadius": 3.5 ⇒ 四个人散在你周围 1.6~3.5 m 处、都面朝你。 // PartyVenueScatterRadius float32 `json:"partyVenueScatterRadius"` // // 就地开局时,**场地家具离玩家的最远距离**(米,只收距离、保留官方方向)。 // 0 = 不限制(照抄官方相对摆位)。 // 为什么需要:官方舞会里灯光控制台在锚点外 20 m、舞会大门在 4.7 m —— // 搬到玩家脚下后控制台就在 20 m 开外,摸不到 ⇒ 设成 4~6 让它落在身边。 // 例:"partyVenuePropMaxDistance": 5。 // PartyVenuePropMaxDistance float32 `json:"partyVenuePropMaxDistance"` // // 就地开局时,把整片家具**旋转**到「最远那件落在玩家正前方」(默认 true)。 // WHY:官方布局的方位是写死的世界方向(舞会灯光控制台在 +x/+z、约 76°), // 搬到玩家脚下后方向不变 ⇒ 玩家很可能背对着它,实测反馈「看不到控制台」。 // 开启后按玩家朝向整体旋转(相对摆位不变,只是换个朝向)⇒ 控制台永远在你正前方。 // 想保留官方世界方位就设 false。 // PartyVenuePropFacePlayer bool `json:"partyVenuePropFacePlayer"` // // 就地开局时,**场地家具在「玩家 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 落点(那个是 PartyVenueNpcYOffset)。 // PartyVenuePropYOffset float32 `json:"partyVenuePropYOffset"` // // ★ 第 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)。 // 默认取舞池,因为那是官方专门为「舞会」开的表。 // ⚠ 无论这一项怎么设,PartyNpcAnchorOverrides 里的 // "13030003" 都优先(它在解析链最前面)—— 这是想手工挪舞者站位时的入口。 // ⚠ 舞会档的 y 用官方舞池点的 y(80),不再套用「只取 X/Z、y 用 PartySpot」的老规矩 // —— 那条规矩是为露营那个「HUD y 是眼高」的问题设的,舞池点本身就有官方 y。 // PartyNpcDanceAnchor string `json:"partyNpcDanceAnchor"` // // ★★★ 第 52 轮(B 方案):室内归属声明开关(默认 true)。 // // 落点位于「VisibleOutside = false」的室内占地里时,照常落位, // 但把官方字段 AgentSyncClientInfo.IndoorId(+ indoorList)声明给客户端 // ⇒ 客户端自己按室内物处理:室外不渲染、走进那间室内才可见。 // // // 为什么需要它:第 50 轮的 IndoorVisibilityGate 是「拦下不落位」, // 判据只比 x/z 投影 —— 用户当时 x = 2613.6、房间 Spiko Hotel 东边界 2610 // ⇒ 只差 3.6 m,往西一步就翻成「放行」,人又飞回空中。 // 声明 IndoorId 才是官方语义:舞会的 NPC 本来就属于那间室内,只是我们以前 // 当露天物摆(IndoorId = 0)。三处构造点在本次改动前全部填空/0。 // // // 与 IndoorVisibilityGate 的关系:本项为 true 时, // 那道门自动失效(不再拦截,改为声明);把本项设 false 即回到第 50 轮行为 // (门按 IndoorVisibilityGate 生效)。 // // // ⚠ 声明只作用于 SendCivilianAsync 这条静态 NPC 通道(含派对/市民/路人), // 不碰机关(SyncGadgetAdd)与场地家具(PartyCamp)—— 后者仍按 // IndoorVisibilityGate 过滤,本轮刻意不动(避免误伤已收尾的功能)。 // // NpcIndoorDeclaration bool `json:"npcIndoorDeclaration"` // // ★ 第 47 轮:派对 NPC 圆周的**半径**(米)。默认 2.0 // (第 34 轮起的原硬编码值 = 2.5;第 47 轮用户现场定为 **2**)。 // 想围得更紧就继续调小;太小会互相穿模(实测 < 1.2 开始互相挤)。 // PartyNpcSpawnRadius float32 `json:"partyNpcSpawnRadius"` // // ★ 第 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。 // // PartyNpcFashion string `json:"partyNpcFashion"` // // ★ 覆盖下发给客户端的 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(会对玩家起反应、可能「回家」), // 所以**默认不开**;只作为 PartyNpcRoamMode 两条路都失败时的第三手。 // PartyNpcAISettingOverride uint32 `json:"partyNpcAISettingOverride"` // // ★ 覆盖下发给客户端的 AgentSyncClientInfo.treeName。默认 "" = 不覆盖 // (沿用 "PedBase",donor 逐字移植的已验证值)。 // 仅当 PartyNpcAISettingOverride 也填了、且怀疑 treeName 必须与行为集的 // BehaviorTree 名对齐时才需要(市民档对应 "Main_Common_NPC")。 // PartyNpcTreeNameOverride string `json:"partyNpcTreeNameOverride"` Talents TalentTreeSettings `json:"talents"` HackNpc HackNpcSettings `json:"hackNpc"` TaskPanel TaskPanelSettings `json:"taskPanel"` // // 地图入口(大地图上的传送点 / 地铁站图标)—— 2026-09-27 从 kpect 捐赠端移植的 // 「入口四连发 + 点击真传送」,数据用我们自己的 MapentranceConfig(93 行)。 // 见 GameRouter.MapEntrance.cs。 // MapEntrance MapEntranceSettings `json:"mapEntrance"` // // 车载电台的服务端那一半:歌曲解锁在登录帧里已经做完了 // (RuntimePayloadFactory 把 59 首全部塞进 PlayerRadioSongsData.SongInfoDict), // 这里只管 AskGetVehicleRadioContent 返回的「串词内容」(DJ 口播 / 广告 / 新闻)。 // 见 GameRouter.VehicleRadio.cs。 // VehicleRadio VehicleRadioSettings `json:"vehicleRadio"` // // ★ 房产「自带家具」—— 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 字节空房)。 // HouseFurniture HouseFurnitureSettings `json:"houseFurniture"` // // ★ 车库 / 停车 —— 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 兜底,与改动前逐字节一致)。 // HouseGarage HouseGarageSettings `json:"houseGarage"` } // // 一个「实机实测的电梯落点」覆盖项(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 —— 门标记在电梯井里侧,人在外侧)。 // 所以:几何推导给「哪一层 / 哪扇门」,这张表给「那一格精确坐标」。 // 配置是热重载的,量出来一条就能立刻验证,不用重编译、不用重启。 // type JiguanLandingOverride struct { // 落点门 id(MapentranceConfig.Id,也就是 SyncTeleport.MapEntranceId 填的那个)。 DestEntranceId uint32 `json:"destEntranceId"` // 落点 X / Y / Z(世界坐标,米)。 X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"z"` // 朝向(度)。不填 / null ⇒ 沿用落点门自己的 EntranceFacing。 Facing *float32 `json:"facing"` } // // 车库 / 停车(gameplay.houseGarage)。见 // GameplaySettings.HouseGarage 的说明。 // type HouseGarageSettings struct { // 总开关。false = 全部回落到空 body 兜底(逐字节等于本次改动前)。 Enabled bool `json:"enabled"` // // 车辆 spawn 相对玩家的前向偏移(米)。默认 0 = 就落在玩家脚下 // (此时玩家正站在车位上 —— 进库演出刚把玩家放到 ParkingSpaceWaypoints[i])。 // 若车和玩家/墙重叠,可调大让它往前挪一点。配置热重载,改完不用重启。 // SpawnForwardOffset float32 `json:"spawnForwardOffset"` // 出库时把车库大门推开(复用 PushDoorOpen + 缓存的门 gadgetId)。 OpenDoorOnExit bool `json:"openDoorOnExit"` // // 出库落点覆盖表(热重载)。默认空 = 用 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 } // ] // } // // ExitLandingOverrides []HouseExitLandingSettings `json:"exitLandingOverrides"` // 打印逐条车库日志。 VerboseLog bool `json:"verboseLog"` } // 出库落点覆盖的一条(见 HouseGarageSettings.ExitLandingOverrides)。 type HouseExitLandingSettings struct { // HouseConfig.Id。 HouseId uint32 `json:"houseId"` X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"z"` Facing float32 `json:"facing"` } // // 房产自带家具(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 件)。 // type HouseFurnitureSettings struct { // 总开关。false = 回到 57 字节空房(逐字节等于本次改动前)。 Enabled bool `json:"enabled"` // // 家具机关(可互动家具)开关 —— 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。 // Gadgets bool `json:"gadgets"` // // 要不要真的用 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 同源)。 // ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。 // SpawnGadgetEntities bool `json:"spawnGadgetEntities"` // // 摆机关实体走哪条推送通道 —— 默认 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 —— 两条都发(诊断用)。判据:若椅子/灯渲染成两遍, // 说明两条通道都生效、真正的问题是别的;若只出现一遍,那出现的那条就是唯一可用通道。 // // ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。 // Channel string `json:"channel"` // // ★★ 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 秒后被网格快照撤掉」的行为。 // ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译。 // RespawnAfterGridPush bool `json:"respawnAfterGridPush"` // // 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 后 // 不用重启,出房子再进去一次即可换一种试。 // KeyMode string `json:"keyMode"` // // 打印每个家具节点的逐条日志(147 件 × 每件一行)。默认关 —— 只在排查线格式时才开。 // VerboseLog bool `json:"verboseLog"` } // // 地图入口(gameplay.mapEntrance)。 // 客户端的三步走:SyncMapEntrance(64067363) 全量清单 → // UpdateMapEntrance(64239821) 逐点开关 → SyncPrepareMapEntrance(64610549) 逐点准备; // 再加一条 SyncSubwayFare(64660858) 给地铁支付环节一个金额。 // 四帧在此之前一条都没发过(全服 grep 零命中),所以大地图上的传送点/地铁图标 // 根本没有可见性数据。逐项可关,回滚不用重编译。 // type MapEntranceSettings struct { // 总开关:false = 四帧都不发、点入口也不传送,逐字节回到移植前。 Enabled bool `json:"enabled"` // 发 SyncMapEntrance 全量清单(openEntrance / displayableEntrances 两个列表)。 PushSync bool `json:"pushSync"` // 发 UpdateMapEntrance 逐点开关(isOpen/isShow 都设 true)。 PushUpdate bool `json:"pushUpdate"` // // 发 SyncPrepareMapEntrance 逐点准备。 // 客户端 lua 里没有这一帧的消费点(只在 RPCMethodIdToName 出现),但 kpect 的判据是 // 「少了这步,交通层(地铁 / 机场,ShowType=2)的入口不会真正注册进地图」。留开关做 A/B。 // PushPrepare bool `json:"pushPrepare"` // 发 SyncSubwayFare(地铁票价,单位见 SubwayFare)。 PushSubwayFare bool `json:"pushSubwayFare"` // SyncSubwayFare 的金额(u32)。0 = 免费。 SubwayFare uint32 `json:"subwayFare"` // // AskTouchMapEntrance(63284182):点入口是否真的传送。 // 客户端是 void notify(不等回包),最坏情况就是「和以前一样没反应」。 // TouchTeleport bool `json:"touchTeleport"` // // 允许跨 raid 传送(目标入口的 RaidId ≠ 当前 ActiveRaidId 时走 SwitchSceneAsync)。 // 关掉的话跨城点击只记日志不动作,同图传送仍然生效。 // TouchCrossRaid bool `json:"touchCrossRaid"` // // 回显带 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 不回显,逐字节回到本次改动前)。 // EchoPreTeleport bool `json:"echoPreTeleport"` // // 景点 / 电梯传送门(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,逐字节回到本次改动前)。 // JiguanElevator bool `json:"jiguanElevator"` // // 电梯分组半径(米):两条景点门三维距离小于它就当成同一台电梯。 // 为什么需要它:绿屿公馆的竖井跨「大厅 Y=0 ↔ 高楼层 Y=160.6」共 168m, // 光靠距离连不起来,靠的是「同目标 id」和「同坐标」两条规则;而 317/318 这种 // 只有两个门、跨度 38.7m 的短竖井,只能靠半径连。 // 默认 60 已经能覆盖全表且不会误并相邻建筑(最近的跨建筑对是 318↔329 = 126m)。 // JiguanGroupRadius float32 `json:"jiguanGroupRadius"` // // 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。改配置即时生效,用来判断「反查落空」这个猜想。 // // JiguanEntranceIdMode string `json:"jiguanEntranceIdMode"` // // 电梯落点兜底(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 与回滚。 // JiguanHardSet bool `json:"jiguanHardSet"` // // 实机实测的落点覆盖表(热重载)。按落点门 id 匹配,优先于几何推导 // 与 JiguanElevatorCatalog4229938.MeasuredLandings。 // 例(把绿屿高楼层的落点从「门标记」挪到玩家实测能站稳的那一格): // // "mapEntrance": { // "jiguanLandingOverrides": [ // { "destEntranceId": 343, "x": 2277.67, "y": 160.73, "z": 1411.35 } // ] // } // // 只改 X/Y/Z,不填 facing 就沿用门自己的朝向。改完即时生效。 // JiguanLandingOverrides []JiguanLandingOverride `json:"jiguanLandingOverrides"` } // // 车载电台(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 ⇒ 四条表全查不到 // (客户端日志会是四行「配表找不到」)⇒ 只有歌、没有串词。 // type VehicleRadioSettings struct { // 总开关:false = 继续回全零 typed-default(移植前的行为)。 Enabled bool `json:"enabled"` // // 串词播在歌的哪个 cue 上(UX.Game.RadioMarkType:1 = Middle,2 = End)。 // 客户端 OnRadioMusicMark 的判断是反编译出来的(~= 看着像被翻转过的 ==), // 两个值都留着实机试,改配置不重编译。 // MarkType uint32 `json:"markType"` // // 同一类串词的最小间隔秒数。官方静态表 RadioContentConfig 是 600 秒 // (DJChatCd / DJCommentCd / NewsCd / AdCd 全都是 600);设 0 表示不限制。 // ContentCooldownSeconds uint32 `json:"contentCooldownSeconds"` // // 允许抽到的串词类型(UX.Game.RadioContentType:1=Ad 2=News 3=Chat(DJ 口播) // 4=Comment(DJ 口播) 5=SpecialNews)。留空 = 按权重自动抽全部。 // ContentTypes []uint32 `json:"contentTypes"` } // // 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. // type TaskPanelSettings struct { // // Master switch. False (default) ships nothing and leaves the panel to the client — the state // this feature was parked in. // Enabled bool `json:"enabled"` // // Which TaskConfig rows go into taskInfos: "catalog" keeps the historical full dump // (all 4342 rows), "configured" publishes only TaskIds (how far smaller an // authored task list the reference branch uses — one task plus its event). // TaskIdsSource string `json:"taskIdsSource"` // Explicit TaskConfig ids used when TaskIdsSource is "configured". TaskIds []uint32 `json:"taskIds"` // 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. TaskState string `json:"taskState"` PublishRaidRows bool `json:"publishRaidRows"` PublishTitleUnlocks bool `json:"publishTitleUnlocks"` // 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). PublishDebugRows bool `json:"publishDebugRows"` // // R238(2026-10-03 随赛车移植)—— 进世界时**只**发布赛车挑战那几条任务行, // 让客户端的 task manager 在玩家点「开始挑战」之前就拥有 60000147 的运行时节点。 // 因果链(捐赠分支实测):没有节点 ⇒ `gTaskManager:SetCurrentTask` 建不出运行时节点 // ⇒ 任务状态机不产出 TASK_STATE_CHANGED ⇒ `ChallengeManager.StartChallenge` 不被调用 // ⇒ `currentChallengeId` 恒为 -1 ⇒ `OnSyncRacingResultData` 里 // `ChallengeConfig.GetConfig(-1)` = nil ⇒ **不弹结算面板**。 // ⛑ 与 Enabled **无关**:赛车预热必须无条件先跑。 // 回滚 = 置 false(回到「点开始挑战没反应」)。 // PrimeRacingChallengeTasks bool `json:"primeRacingChallengeTasks"` // // 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. // PublishEvents bool `json:"publishEvents"` // Always-published event ids, regardless of which tasks are in the container. EventIds []uint32 `json:"eventIds"` // 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. AutoDeriveEvents bool `json:"autoDeriveEvents"` // // 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. // EventStatusFlags byte `json:"eventStatusFlags"` // // 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. // EventTaskIdFollowsContainer bool `json:"eventTaskIdFollowsContainer"` // Event ids pushed in SubmitEventList (already-turned-in events). SubmitEventIds []uint32 `json:"submitEventIds"` // Safety cap on how many events go out; 0 = no cap. MaxEvents int32 `json:"maxEvents"` } // // 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 // Endpoint 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. // type OssPictureSettings struct { // Master switch. Off = never push SyncOssBucketInfo (pre-2026-09-24 behaviour). Enabled bool `json:"enabled"` // Object-storage region string; unused by a plain HTTP host. Region string `json:"region"` // Base URL serving the object keys, e.g. http://127.0.0.1:8099. Endpoint string `json:"endpoint"` // Bucket name. Filled in once the access log reveals the URL layout. Bucket string `json:"bucket"` } // // 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. // type TalentTreeSettings struct { // 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. Enabled bool `json:"enabled"` // // 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). // PointSeed uint32 `json:"pointSeed"` // // Re-grants PointSeed 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. // RenewSeedOnLogin bool `json:"renewSeedOnLogin"` // 0 = charge the node's TalentTreeTalentConfig.CostPoint; anything else overrides it. FlatCost uint32 `json:"flatCost"` // Accept an activation even when the tree is out of points (sandbox: nothing is ever blocked). IgnoreCost bool `json:"ignoreCost"` // 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. AllowUnknownTalents bool `json:"allowUnknownTalents"` // // 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. // PushPlayerInfoOnChange bool `json:"pushPlayerInfoOnChange"` // // 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. // SeedCareerTalents bool `json:"seedCareerTalents"` // Talent tree whose nodes are published as activated by SeedCareerTalents. CareerTalentTreeId uint32 `json:"careerTalentTreeId"` // Job class the tree belongs to. Only used for logging and for the per-job records. CareerTalentJobClassId uint32 `json:"careerTalentJobClassId"` // Talent points published alongside the seeded nodes, i.e. the panel balance. CareerTalentPoint uint32 `json:"careerTalentPoint"` // 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. CareerTalentLayers uint32 `json:"careerTalentLayers"` // 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. CareerTalentIds []uint32 `json:"careerTalentIds"` // 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. SeedCareerTalentsOnSpirit bool `json:"seedCareerTalentsOnSpirit"` // 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. SeedCareerTalentsOnEveryJob bool `json:"seedCareerTalentsOnEveryJob"` // // Publish every talent tree instead of the single CareerTalentTreeId, 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. // SeedAllTrees bool `json:"seedAllTrees"` // Publish the whole-tree set into GameplayTalentInfos when SeedAllTrees. SeedTreesOnGameplay bool `json:"seedTreesOnGameplay"` // 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. SeedSpiritIdTrees bool `json:"seedSpiritIdTrees"` } // // T2-32 路人骇入(黑客骇路人手机看信息)。 // 客户端整条链路都是现成的,服务端唯一缺的就是 SyncUnitHackableState(68941159): // 不发它,C# 的 HackManager.hackUnitTarget 恒为 nil,路人身上不会有骇入按钮, // 三个 C2S(67070057 / 67747341 / 67093669)一个都不会发。 // ⇒ 这里只保留一个开关,关掉即回到接入前的状态。 // type HackNpcSettings struct { // // NPC 出场时(SendCivilianAsync / SendAmbientCrowdPedAsync 尾部) // 推一次 SyncUnitHackableState(unitId, true)。 // 与载具那套不同,这里不做距离维护:C# 的 HackTargetSelector 自己按距离挑目标, // 服务端把全城 NPC 标成可骇也不会多出按钮;而发 false 去取消标记反而要维护一份 // "已标记"账本(NPC 被回收时容易漏)⇒ 一律只标 true、不取消。 // MarkSpawnedNpcs bool `json:"markSpawnedNpcs"` // // 只给"服务端自己生成"的 NPC 打标记时用的上限(0 = 不限)。 // 留作保险:万一某个场景一次性刷出上千个 NPC,逐包推送会占住连接 // (参照 hard rule 38 的教训 —— 登录路径禁止大批量推送)。 // MaxMarkedNpcs int32 `json:"maxMarkedNpcs"` // ---------------------------------------------------------------- 手机(按钮的来源) // // ⭐ 只推 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** 一并推下去。 // 关掉即回到只有名字的状态。 // GivePhoneToNpcs bool `json:"givePhoneToNpcs"` // 手机的 sceneitem id。BelongingBelongingUsageConfig 三条可骇 usage 的 // BelongingSceneItem 都只写 80003481(耳机那条还要再叠加 80003544,这里不用)。 PhoneSceneItemId uint32 `json:"phoneSceneItemId"` // // 轮换的 usage id。按 unitId 取模挑一个,城里于是既有"玩手机"也有"打电话"的人, // 两种按钮都能见到。空数组 ⇒ 退回 usage 6(玩手机 / 偷看短信)。 // PhoneUsageIds []uint32 `json:"phoneUsageIds"` // crowd 通道(SendAmbientCrowdPedAsync)给不给手机。默认给 —— 行人本来什么都没有。 GivePhoneToCrowdPeds bool `json:"givePhoneToCrowdPeds"` // static / civilian 通道(SendCivilianAsync)给不给手机。 // ⛑️ 会覆盖该 NPC 的 POI usage(例如拍照动作 18 会被换成玩手机 6), // POI usage 本身就在可骇三条之内时会自动跳过(见调用点)。 GivePhoneToStaticNpcs bool `json:"givePhoneToStaticNpcs"` // 发手机的 NPC 上限(0 = 不限)。每条要推两个包,行数多的时候留意 // hard rule 37/38(登录/进世界路径怕大批量推送)。 MaxPhonedNpcs int32 `json:"maxPhonedNpcs"` } // // Aether city simulation (pedestrians / static NPCs / traffic). The client owns the // simulation; the server only answers the handful of C2S calls it makes. // type AetherSettings struct { // // 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). // MassHideAreasReplyHex string `json:"massHideAreasReplyHex"` // // 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" 同上但基类字段先(对照组) // 真路口开关 = InitIntersectionsReal(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. // InitVariants []string `json:"initVariants"` // // 给 top-int7-isec-* 变体用:Intersections 填多少条**探测**路口。 // 0 = 空表(与现状完全一致)。1 = 只发一条,用来看客户端日志有没有反应。 // ★ 为什么有这个键:AetherAIInitData.Intersections 是我们**唯一没填过的官方字段**, // 客户端的路口/信号灯系统从来没有拿到过初始化数据。而 InstanceId 的真实语义未知, // 所以先用最小样本探测,确认客户端会响应之后,再投入造 182 条真路口 // (数据源在 DUMPS/Bin.rar 的 ZebraConnector.json)。 // InitIntersectionProbeCount int32 `json:"initIntersectionProbeCount"` // // 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 // 落盘在 Drmk.Server/Drmk.App/data/intersections.csv(167 条),由 // IntersectionCatalog4229938 惰性加载;文件缺失/损坏 → 自动退化为 0 条,不会崩。 // InitIntersectionsReal bool `json:"initIntersectionsReal"` // // 真路口最多发几条。0 = 全发(当前 167 条)。 // 想先只发 1 条看客户端有没有反应就设 1(比全量更容易从日志里定位)。 // InitRealIntersectionCount int32 `json:"initRealIntersectionCount"` // // 街上行走的行人(AetherCity crowd)。人群是客户端自驱的,服务端只发密度包。 // 前置:SyncAetherAIInitDatas 必须已带 HasZoneGraph=true(由 vehicles.enabled 那条路径发出, // vehicles.enabled=false 时人群不会初始化)。 // CrowdEnabled bool `json:"crowdEnabled"` // 人群密度倍率。1.0 = 官方默认。 CrowdDensity float32 `json:"crowdDensity"` // SyncCrowdSpecifiedDensityRefresh 盒子的水平半径(米)。donor 实测 250。 CrowdExtendsMeters float32 `json:"crowdExtendsMeters"` // 盒子的高度(米)。donor 实测 50。 CrowdExtendsHeight float32 `json:"crowdExtendsHeight"` // 玩家离开上次中心多少米就重发一次密度盒。donor 实测 35。 CrowdRefreshMoveMeters float32 `json:"crowdRefreshMoveMeters"` // ---- T0-8:让密度随地块 / 时段变 ---- // UrbanDiversityConfig 的 PopulationDensity 与 DegreeOfBusyness 在本城 681 个地块里几乎恒为 0 // (实测只有沙滩区有值),所以密度改由「LandPlotType 系数 × HourlyFlow(时段曲线)」合成。 // 总开关。关掉就退回全局固定密度(进 S2C 前的行为),用于隔空对照。 CrowdUseLandPlots bool `json:"crowdUseLandPlots"` // LandPlotType → 密度系数。未命中的类型按 1.0 处理,随时可在此增删条目。 CrowdTypeFactors map[string]float32 `json:"crowdTypeFactors"` // 时段系数下限。午夜 HourlyFlow 会掉到 0,不设下限街上会彻底空场。 CrowdHourFloor float32 `json:"crowdHourFloor"` // 找不到地块时的回退系数。默认 1.0 = 不对无人区再打折。 CrowdPlotMissFactor float32 `json:"crowdPlotMissFactor"` // 判定「玩家站在哪个地块」时的搜索半径(米)。 CrowdPlotSearchMeters float32 `json:"crowdPlotSearchMeters"` // ------------------------------------------------------------------ // 自动氛围行人(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 结论是错的(客户端没实现)。 // ------------------------------------------------------------------ // 进世界后自动在玩家周围铺行人。关掉就只剩面板手动生成。 AmbientPedsEnabled bool `json:"ambientPedsEnabled"` // ------------------------------------------------------------------ // 自动功能 NPC(GameRouter.FunctionalNpc.cs,2026-09-22) // // 跟随式:玩家走到哪,附近的店铺 NPC 就在哪冒出来。用**滞回**而不是单一阈值 —— // 进 130m 铺、出 230m 撤,玩家在街口来回走不会反复生成/销毁(见 §3 规则 26)。 // ------------------------------------------------------------------ // 进世界后自动在玩家周围铺设功能 NPC(店员 / 售票)。关掉就只剩面板手动生成。 FunctionalNpcsEnabled bool `json:"functionalNpcsEnabled"` // 玩家进入这个半径(米)就把该点位的功能 NPC 放下去。 FunctionalNpcEnterMeters float32 `json:"functionalNpcEnterMeters"` // 玩家离开这个半径(米)就撤掉它。必须明显大于 Enter,否则街口会抖动。 FunctionalNpcExitMeters float32 `json:"functionalNpcExitMeters"` // 跟随循环的检查周期(毫秒)。 FunctionalNpcTickMs int32 `json:"functionalNpcTickMs"` // 每次检查最多新铺几个。铺太猛客户端一次吃太多实体会丢。 FunctionalNpcPerTick int32 `json:"functionalNpcPerTick"` // // 玩家走远后要不要发 SyncAetherAINpcRemove 撤人。默认 false。 // ⛑⛑ 2026-09-25 实机已证这条 remove 删不掉 8300 段那具身体(跑步机上的里希一直不消失, // 见 RetirableUidSegment 的说明)。撤不掉、玩家折回时却又重新发一具 ⇒ // 同一个店员在原地叠 2~3 具(2026-10-01 用户报「健身房里 3 个卢卡斯·高桥」就是这么来的: // 一局内 place/retire 循环了 3 次,3 具身体同一坐标)。 // 关掉 ⇒ 只铺不撤:一局里每个点位最多铺一次,客户端那具身体一直站着,永不重复。 // 想回到「走远就撤」的旧行为就设 true(风险:仍会叠)。 // FunctionalNpcRetireOnExit bool `json:"functionalNpcRetireOnExit"` // ------------------------------------------------------------------ // 自动地盘敌人(GameRouter.EnemyTerritory.cs,2026-09-23) // // 与功能 NPC 同一套跟随循环,只是坐标来源和生成通道不同: // 坐标 = FactionInfluenceAreaConfig × FactionAreaPointConfig(85 个帮派地盘角点, // EnemyCatalog 里一个坐标都没有,这是 dump 里唯一现成的敌人站位来源); // 生成 = GameAdminBridge.SpawnAgentAsync(forbidAetherAI=true,客户端行为树接管, // 所以它们不像 ambient 行人那样沿人行道走)。 // 半径比功能 NPC 更小(90/160):敌人会主动扑上来,130m 外就出人等于隔街被围。 // ------------------------------------------------------------------ // 进世界后自动在玩家周围的地盘上铺敌人。关掉就只剩面板手动生成。 EnemyTerritoryEnabled bool `json:"enemyTerritoryEnabled"` // 玩家进入这个半径(米)就把该地盘的敌人放下去。 EnemyTerritoryEnterMeters float32 `json:"enemyTerritoryEnterMeters"` // 玩家离开这个半径(米)就撤掉它。必须明显大于 Enter。 EnemyTerritoryExitMeters float32 `json:"enemyTerritoryExitMeters"` // 跟随循环的检查周期(毫秒)。 EnemyTerritoryTickMs int32 `json:"enemyTerritoryTickMs"` // // 每个地盘点位放几个敌人(1–5)。站位由 EnemyTerritorySpots.json 的 members 提供(人行道点, // 组内最小间距 1.3 m),所以 3–4 人是「三三两两站在巷口」的观感,2 人显得零星。 // 上限 5 = 生成脚本写的站位数量(maxGroup),再高也没位置可放。 // EnemyTerritoryPerPoint int32 `json:"enemyTerritoryPerPoint"` // 场上同时存在的地盘敌人上限。兜底,防止玩家在密集区来回走把数量堆上去。 EnemyTerritoryMaxActive int32 `json:"enemyTerritoryMaxActive"` // // true = 生成即登记进敌人账本并下令开战(锁定玩家 + FightEdict + Detect),打得掉血; // false = 只把实体放进世界站桩,玩家走近才会被客户端自己发现。 // 想让城市"有帮派分子在晃"但不被追着打,就设 false。 // EnemyTerritoryEngage bool `json:"enemyTerritoryEngage"` // ------------------------------------------------------------------ // 敌人移动的节奏(GameRouter.EnemyAiActions.cs) // // ⛑️ 抽帧根因(2026-09-23 实测):SyncEnemyBattleMoveTowardUnit 是「有限位移」—— // 客户端只走 MoveTime 秒就停,然后原地站着等下一条。原来 MoveTime=0.65s 而派发间隔 // 是 1000ms ⇒ 走 0.65s 停 0.35s,肉眼就是「走一步停一下」。 // ⇒ 派发间隔必须 ≥ MoveTime,且两者要贴住(留 ~50ms 余量,小于会被下一条打断)。 // ------------------------------------------------------------------ // 每条移动命令让客户端走多久(秒)。越大一步走得越远,越接近连续行走。 EnemyAiMoveSeconds float32 `json:"enemyAiMoveSeconds"` // // 两条移动命令之间的间隔(毫秒)。必须 >= MoveSeconds*1000,否则下一条会打断上一条。 // 留 50ms 左右余量最自然(完全连续 / 略微一顿,靠这个值调)。 // EnemyAiMoveIntervalMs int32 `json:"enemyAiMoveIntervalMs"` // // ★ 决定性实验开关(2026-09-24):false = 服务端**完全不发**战斗移动命令 // (SyncEnemyBattleMoveTowardUnit),只发 engage(SyncBattleAiS=true)和攻击。 // 敌人的接近动作交给客户端自己的行为树 —— 它是 forbidAetherAI=true 生成的, // 客户端本来就在跑战斗 AI。如果我们每 1 秒补一条有限位移命令反而是在打断它, // 那关掉这个开关就能看到"原生"的连续追击。 // ⛑️ 关掉后如果敌人完全不动,说明客户端行为树不自己走路,那就得开回来。 // EnemyAiServerMoveEnabled bool `json:"enemyAiServerMoveEnabled"` // // 攻击后把单位冻多久(毫秒):CastBusy 是完全不动,CastInterval 是下次决策的时间。 // 原来是 6000/6500 —— 打一下站 6.5 秒,这也是"停"的一大来源。 // EnemyAiCastBusyMs int32 `json:"enemyAiCastBusyMs"` EnemyAiCastIntervalMs int32 `json:"enemyAiCastIntervalMs"` // // 每个敌人的移动/攻击日志打多密:前 3 条必打,之后每 N 条打一次。 // 原来只打前 3 条,导致实机看不到任何后续节奏(诊断"一步一停"时吃了大亏)。 // EnemyAiVerboseLogEvery int32 `json:"enemyAiVerboseLogEvery"` // // 玩家不在仇恨范围(30m)时让敌人原地踱步:走一段 → 停一会 → 倒着走回来。 // 走的是 WalkFront / WalkBack 交替,位移互相抵消,所以不会整体漂向玩家。 // EnemyAiPatrolEnabled bool `json:"enemyAiPatrolEnabled"` // 踱步两段之间的停顿区间(毫秒),在区间里随机取值。 EnemyAiPatrolPauseMinMs int32 `json:"enemyAiPatrolPauseMinMs"` EnemyAiPatrolPauseMaxMs int32 `json:"enemyAiPatrolPauseMaxMs"` // // 踱步单条命令走多久(秒)。比追击更长:一条命令内客户端是连续走的, // 命令越多起停越多,看起来越碎。2.5s 大概走 4~5 m。 // EnemyAiPatrolMoveSeconds float32 `json:"enemyAiPatrolMoveSeconds"` // 踱步离出生点最远多少米(保险绳,估算位置超出就强制换向)。 EnemyAiPatrolLeashMeters float32 `json:"enemyAiPatrolLeashMeters"` // // 仇恨脱离半径(米)。必须大于进入半径 30 —— 单阈值会让 engagement 在边界反复开关, // 而每次开关都要重发 LockTarget/FightEdict/BattleAiS,客户端的动作层被重置、 // 动画被裁断,表现就是"刚迈一步就僵住"。实测日志里几秒内 True→False→True 抖了 53 次。 // EnemyAiAggroExitMeters float32 `json:"enemyAiAggroExitMeters"` // engagement 状态翻转后的最短保持时间(毫秒),二次防抖。 EnemyAiEngageHoldMs int32 `json:"enemyAiEngageHoldMs"` // // 仇恨进入半径(米)。原先是 EnemyAiActions.cs 里的硬编码 30f, // 2026-09-26 提成配置项。 // ⛑⛑ 填 0 = 敌人永不主动锁定玩家(EnemyAiSelectTarget 恒返回 0, // 不发 LockTarget / FightEdict / BattleAiS)⇒ 敌人一直保持未察觉 ⇒ // 蹲伏暗杀才有窗口:已在交战中的敌人不可暗杀,这正是 // ReportCanAssassinateEnemies 恒 0 的原因之一(实测 20:23:36 敌人就 engage 了, // 而蹲伏在 20:23:30–54)。 // 想恢复原行为填 30。 // EnemyAiAggroRangeMeters float32 `json:"enemyAiAggroRangeMeters"` // // 实验开关:改用 SyncMoveWandering(68745518) 让敌人"自己走起来"—— // 官方唯一不带目的地的行走包(半径/角度窗口/时间预算,路径由客户端自己挑), // 行人路线走的就是它,所以是真正连续的行走,而不是一串有限位移命令。 // ⛑️ 敌人 forbidAetherAI=true,能不能接这个包没验证过;默认 false。 // 开了之后服务端拿不到真实坐标,AiPosition 仍是估算(游荡位移不计入)。 // EnemyAiWanderEnabled bool `json:"enemyAiWanderEnabled"` // // 出招前转向用什么包: // "target"(默认)= SyncUnitSmoothFaceToTarget(68146316),把目标单位交给 // 客户端,转角由它用真实朝向算 ⇒ 准; // "smooth" = SyncUnitSmoothFace(68718685),服务端按估算朝向算增量 ⇒ // 估算过期就会转歪(2026-10-01 实机:敌人跑到玩家面前再转 90° 打空气); // "off" = 不发转向包(对照用)。 // 详见 GameRouter.EnemyAiActions.cs 的 TurnEnemyToAsync。 // EnemyAiTurnPacket string `json:"enemyAiTurnPacket"` // wander 单次游荡的距离区间(米)。踱步用,别设太大免得走丢。 EnemyAiWanderMinDis float32 `json:"enemyAiWanderMinDis"` EnemyAiWanderMaxDis float32 `json:"enemyAiWanderMaxDis"` // wander 的时间预算(秒)。走完就站住,靠巡逻 tick 续期。 EnemyAiWanderSeconds float32 `json:"enemyAiWanderSeconds"` // ---- 随从(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 立刻回到"随从被当敌人"的旧行为(一键回滚用)。 // CompanionCamp byte `json:"companionCamp"` // // 服务端是否用敌人那套四包(BattleAiS / LockTarget / FightEdict / BattleMoveTowardUnit) // 自己驱动随从跟随+协战,并在贴身时补一个 SyncUseTargetSkill 让它真出招。 // ⛑⛑ 2026-10-01 实机:false 的时候随从完全不动 —— 客户端那棵 Companion 树 // 并没有因为 SyncAgentCompanionAI+SyncCompanionMaster 就接管 // (camp 已经发成 1 了也一样)。所以服务端必须自己驱动,默认 true。 // CompanionServerDrive bool `json:"companionServerDrive"` // // 随从出招用的技能池(贴身时按序号轮流发 SyncUseTargetSkill)。 // ⛑⛑ 为什么不是"它本身的攻击模组":晴/盖斯的 AI 行为集里 Behavior_Battle 是空串、 // RelatedSkillIds 是空表,招式写在客户端那棵 Companion_* 行为树的 C# 里, // 服务端无从得知(详见 CompanionAssist.cs 类注释)。所以只能借通用池。 // 默认取 EnemyAiCatalog 的 UNARMED 近战池(与 7k-v0.5 同源,能在多数骨架上播): // 51900500 / 51900501 空手普攻,51900410 / 51900411 空手连击。 // 若实机看到它"摆姿势不出手"(骨架不对),换成 RANGED 远程池试: // [51984196, 51992001, 51996871] // 换技能不用重编译,改这里重启即可。 // CompanionAttacks []uint32 `json:"companionAttacks"` // // 跟随(身边没有敌人)时是否仍然把 SyncBattleAiS 保持在 true。 // ⛑⛑ 2026-10-01 实机:恒 true 的后果就是随从一直保持举枪的战斗姿态, // 没敌人也端着枪慢慢挪 —— 玩家看到的"怪"就是它。主线驱动敌人时是 // value = target != 0(EnemyAiActions.cs:453),没目标就关战斗 AI。 // 默认 false = 跟随就收枪,正常走路。 // ⛑ 若改成 false 后随从连路都不走了(说明它只认战斗态的移动指令),设回 true。 // CompanionBattleAiWhenFollowing bool `json:"companionBattleAiWhenFollowing"` // // 跟随模式下单条移动命令走多久(秒)。 // ⛑ 为什么要单列:有限位移命令是"走 MoveTime 秒就停",而协战用的 // EnemyAiMoveSeconds(默认 0.65s)是给敌人**短距离贴脸**调的。随从要跟 // 十几二十米的话,一条命令只走 1.2 m 就要停一下 ⇒ 玩家看到的就是"一顿一顿慢慢挪"。 // ⇒ 跟随时取 max(EnemyAiMoveSeconds, 本值),本值默认 3.0s(约 5.5 m)。 // 调大 = 一次走更远、起停更少(但转向更迟钝);调小 = 更贴身、更碎。 // CompanionFollowMoveSeconds float32 `json:"companionFollowMoveSeconds"` // // 绑定随从后清掉它的上半身持枪瞄准态(SyncEndShootingState + // SyncEndShootGameplayTag,见 GameRouter.EnemyStance.cs:32)。 // ⛑⛑ 2026-10-01 实机:随从"举着枪慢慢挪"的真凶。主线那条清理只在 profile 判成 // unarmed 时才发,而随从 profile 是 null(招在行为树里、没进 EnemyAiCatalog) // ⇒ 永远走不到。瞄准态的 locomotion 是慢速瞄准步 ⇒ 「举枪」和「慢慢」同源。 // 默认 true。 // CompanionClearGunAim bool `json:"companionClearGunAim"` // // 绑定随从后收起它头顶的血条(SyncAddUnitState + HideHp=10266)。 // ⛑⛑ 血条不看阵营:判据在 EnemyHUDCtrl.lua:579 —— // shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ... // ⇒ 改 camp 去掉不了血条,只有挂 HideHp 状态才行(per-unit,不动公共 spawn 路径)。 // 默认 true。 // CompanionHideHpBar bool `json:"companionHideHpBar"` // // 里希执勤终端的战术支援(dispatchid=4「冲锋枪2人」)刷出来的两名友军, // 是否也接入这套随从 AI(跟随 + 协战 + 收血条)。默认 true。 // CompanionBindArmedSupport bool `json:"companionBindArmedSupport"` // // 绑定后推的战斗动画集(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 三件套)。 // CompanionAnimSetTag uint32 `json:"companionAnimSetTag"` // // 额外把随从的武器卸掉(SyncAgentChangeWeapon(uid, 0))。默认 false —— // 它的 InitWeaponId 本来就是 0,这只是个对照开关:清了瞄准态还举枪时才开。 // CompanionDisarm bool `json:"companionDisarm"` // // 跟随时用 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 退回走路。 // CompanionRunWhenFollowing bool `json:"companionRunWhenFollowing"` // ------------------------------------------------------------------ // ★★★★★ 服务端驱动行走 —— 移动任务步进(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)是**客户端来要路径的前提**, // 状态机被关掉 ⇒ 客户端根本不来问 ⇒ 任务全落空。 // // 路网数据:`Drmk.Server/Drmk.App/data/ped_lanes.csv` + `ped_lane_links.csv` // (88361 条车道 / 244398 点 / 263448 条连接,由 `_ref/_gen_ped_lanes.py` 生成)。 // ------------------------------------------------------------------ // 服务端沿人行道路网给每个行人派移动任务(TaskWayPointMove)。关掉就回到"客户端自己走"(实测 = 不走)。 AmbientPedsDriven bool `json:"ambientPedsDriven"` // // 任务步进循环的检查周期(毫秒)。默认 500ms(donor1 的 CrowdHoldTickMs)—— // 任务只在行人"跑完当前段"时才发下一条,不是固定频率流。 // AmbientPedsSyncMs int32 `json:"ambientPedsSyncMs"` // // 一条移动任务覆盖的路程(米)。1.6 m/s 下 30m ≈ 19s、60m ≈ 37s 步态, // 下一条任务在"预计走完 + AmbientPedsStepSlackMs"之后才发。 // ⛑️ 2026-09-23 30 → 60:那段余量是**每次发任务都要空等**的,路程越短、发呆越频繁 // (30m:每 19s 发呆 5s = 21% 的时间在站着;60m:每 37s 发呆 5s = 12%)。 // 加长路程**不改时序预估公式**,所以多等的还是那段余量本身,不会提前打断 ⇒ 不会瞬移。 // AmbientPedsStepMetres float32 `json:"ambientPedsStepMetres"` // // 任务折线的采样间距(米)。路程加长后必须靠它保住贴合度 —— // 原来写死 `stepMetres/5`,60m 会变成 12m 一个点,lane 中位数才 3.58m,行人会切弯穿绿化。 // 固定 6m:60m ⇒ 11 个点,和 30m 时的密度一致。 // AmbientPedsSampleMetres float32 `json:"ambientPedsSampleMetres"` // // 换道时"不许走回"最近走过的几条车道(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%。 // AmbientPedsRouteMemory int32 `json:"ambientPedsRouteMemory"` // // 目标导向行走(默认开):每次派单前给行人挑一个几十米外的终点,用 `PedLaneGraph.PlanRoute` // 规划整条路径,行人沿路径穿过多个路口 —— 而不是在局部邻域随机游走。 // ⛑️ 为什么必须开:2026-09-23 用户实测"行人只是从一条路的一头走到另一头,那条路很短就很奇怪"。 // 我们的路网 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,随机游走的活动半径天然被锁在 // 几条短车道之间。关掉就退回纯随机游走(保留作回退路径)。 // AmbientPedsJourneyEnabled bool `json:"ambientPedsJourneyEnabled"` // 行程终点相对**玩家**的最小半径(米)。终点在环形区域内取,保证行人不会走出回收圈。 AmbientPedsJourneyMinMetres float32 `json:"ambientPedsJourneyMinMetres"` // 行程终点相对**玩家**的最大半径(米)。建议 ≤ `AmbientPedsRadiusMeters` * 0.9。 AmbientPedsJourneyMaxMetres float32 `json:"ambientPedsJourneyMaxMetres"` // // 横向偏移的绝对上限(米):真实偏移 = min(车道 HalfWalk, 这里) × 出生时锁定的比例。 // 目的是不让所有人挤在车道几何中线上(对向行人才不会互相穿模)。 // ⛑️ **设成 0 = 完全不偏移,全员走车道中线** —— 排查"行人卡进街边墙壁"时先拿它排除嫌疑: // 2026-09-23 用户实测窄路卡墙,根因是目标导向路径用了固定 0.6m 偏移(跟路宽无关)。 // 卡墙现象若**还有其他来源**(比如车道几何本身贴着建筑),把它设 0 就能确认/排除。 // AmbientPedsLateralMaxMetres float32 `json:"ambientPedsLateralMaxMetres"` // 横向可走半径的安全余量(米):HalfWalk = max(0, width/2 - 本值)。 // 调大 = 行人往车道中线收(越不容易被护栏 / 街边墙卡住);调小 = 横向分布更散。 // ⛑️ 车道宽度中位只有 2.0 m,本值 ≥1.0 时大多数窄路会退化成全员中线。 // ⛑️ 本项在车道表加载时算进 `HalfWalk`,**改完必须重启**;`AmbientPedsLateralMaxMetres` 则是每次采样现读。 AmbientPedsLateralMarginMetres float32 `json:"ambientPedsLateralMarginMetres"` // 出生点的最小车道宽度(米):窄于这个值的车道不用来出生(树池边 / 栏杆边的小径)。 // 设 0 = 不过滤。⛑️ 车道宽度中位只有 2.0 m(p10=1.5 / min=0.5),默认 1.8 会滤掉约 17% 的车道。 AmbientPedsSpawnMinWidthMetres float32 `json:"ambientPedsSpawnMinWidthMetres"` // // 行人模板池的 **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 个中文名,看着也是普通市民)。 // AmbientPedsFormworkIdPrefixes []string `json:"ambientPedsFormworkIdPrefixes"` // // 行人模板池的 **模型黑名单**(`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 具下手。 // 设空数组 = 不过滤。 // AmbientPedsFormworkModelBlocklist []uint32 `json:"ambientPedsFormworkModelBlocklist"` // // 行人模板池的 **名字关键词黑名单**(`NpcCatalog.Name` 含任一项就踢出池子)。 // 白名单之外的第二道保险:万一某天真往 4013 段里混进了职能/废弃配置,名字先拦一道。 // 空数组 = 不过滤。匹配是大小写不敏感的 `Contains`。 // AmbientPedsFormworkNameBlocklist []string `json:"ambientPedsFormworkNameBlocklist"` // // 位置回馈校正的下限(米):客户端上报位置与服务端账本偏差小于它就是正常误差(车道采样点有间距),不校正。 // 触发源是 `AskClientNpcFindPath`(67974714) 请求里的 `From` —— 这是**唯一**能拿到散人真实 // 位置的通道(散人客户端自驱,硬规则 19,服务端没有位置汇报包)。 // 2026-09-23 用户实测"1 个行人卡在原地不动,打他几下位移后又正常走了" ⇒ 客户端位置与 // 服务端账本脱节后会永久发散,必须有这条回路把它拉回来。 // AmbientPedsResyncMinMetres float32 `json:"ambientPedsResyncMinMetres"` // // 位置回馈校正的上限(米):偏差超过它就**不认**(多半不是我们的单位,或者数据异常)。 // 没有这道闸会把行人拖着乱跳 —— 比不校正更糟。 // AmbientPedsResyncMaxMetres float32 `json:"ambientPedsResyncMaxMetres"` // 位置回馈的「软校正」容差(米):客户端真实位置离当前路线折线不超过这个值, // 就只把账本归位 + 跳过已走过的点,**不**清路线重派(问路不等于卡住,见 GameRouter.TryResyncAmbientPed)。 // 设 0 = 关掉软校正,退回「一问路就重规划」(会把行人掐成走走停停)。 AmbientPedsResyncRouteToleranceMetres float32 `json:"ambientPedsResyncRouteToleranceMetres"` // // 看门狗 ①(服务端侧):连续派单失败达到这个次数就回收重生。 // 派不出任务 = 路网状态坏了(车道不存在 / 规划不出来 / 采样推进失败)。这种卡死 // **服务端自己就能发现**,不需要客户端回馈 —— 是"行人杵着不动"最需要兜底的一路。 // ⛑️ 必须是"连续 N 次"而不是一次:偶发一次失败是正常抖动,一次就回收会让行人在 // 正常游走时凭空消失(比站着不动更难看)。 // AmbientPedsStepFailLimit int32 `json:"ambientPedsStepFailLimit"` // // 看门狗 ②(客户端侧):`AmbientPedsStallWindowMs` 窗口内位置回馈校正达到这个次数就回收重生。 // 反复脱节说明这个单位贴墙 / 卡地形,校正已经救不回来,换个人换个地方。 // AmbientPedsStallResyncLimit int32 `json:"ambientPedsStallResyncLimit"` // 看门狗 ② 的滑动窗口长度(毫秒)。donor round 84 用的就是 30 s。 AmbientPedsStallWindowMs int32 `json:"ambientPedsStallWindowMs"` // // 预计走完之后的额外等待(毫秒)。这是"发呆"的唯一来源:行人走完一段路后会站住, // 直到这里的时间过去才收到下一段任务。 // ⛑️ 调小可以让发呆变短,但**如果客户端实际比预估慢,新任务会提前到** ⇒ 行人被拽回上一段 // 终点再走(瞬移)。想要零瞬移就保持默认,靠加长 AmbientPedsStepMetres 降频率。 // AmbientPedsStepSlackMs int32 `json:"ambientPedsStepSlackMs"` // // 生成后补发 `SyncLogicAgentEnter`(68719890):把 id 注册进客户端的 logic-agent // 同步管理器(任务从它派发)。donor1 第 37 轮加、第 51 轮成功配方里默认开。 // AmbientPedsLogicEnter bool `json:"ambientPedsLogicEnter"` // // `LogicAgentSyncData.MoveId` = `BattleMoveActionType`: // 0 Unknown / 1 Idle / **2 WalkFront** / 3 WalkBack / 4 WalkLeft / 5 WalkRight / 6 Run。 // (旧位置流路线的参数,保留作对照;任务路线用的是 AgentMovementMethods.Walk=1。) // AmbientPedsMoveId byte `json:"ambientPedsMoveId"` // // 用哪条通道建人。 // "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 用的。 // AmbientPedsChannel string `json:"ambientPedsChannel"` // 维持多少人。客户端 Mass 实体有上限,40 是 donor 验证过的安全值。 AmbientPedsTarget int32 `json:"ambientPedsTarget"` // 在玩家多少米内布点(从真实行人路网取点)。 AmbientPedsRadiusMeters float32 `json:"ambientPedsRadiusMeters"` // 行人离玩家超过多少米就移除。必须 > 布点半径,否则刚生成就被回收。 AmbientPedsRecycleMeters float32 `json:"ambientPedsRecycleMeters"` // 人与人之间最小间距(米),防止叠在一起。 AmbientPedsMinGapMeters float32 `json:"ambientPedsMinGapMeters"` // 维护循环间隔(毫秒)。 AmbientPedsTickMs int32 `json:"ambientPedsTickMs"` // 每轮最多补几个(防止一次性发几十个包把客户端噎住)。 AmbientPedsTopUpPerTick int32 `json:"ambientPedsTopUpPerTick"` // ---- T3-7 真实车流(服务端驱动车辆沿车道跑)------------------------------------------ // // 开启车流。客户端侧**没有**车流密度解锁包(人流有 SyncSetCrowdDensity,车流只有 C2S 的 // GmChangeAetherVehicleDensity),所以车必须由服务端逐台下发 SyncAetherAIVehicleAddData // 并持续推 SyncAetherAIVehicleLaneDatas 才会出现。 // TrafficEnabled bool `json:"trafficEnabled"` // 维持多少台车同时在玩家周围跑。 TrafficTarget int32 `json:"trafficTarget"` // 在玩家多少米内的车道上生成。 TrafficSpawnRadiusMeters float32 `json:"trafficSpawnRadiusMeters"` // 车离玩家超过多少米就回收。必须 > 生成半径。 TrafficDespawnRadiusMeters float32 `json:"trafficDespawnRadiusMeters"` // // 车道推进包的下发间隔(毫秒)。 // ★ 默认 50ms = 20Hz,对齐客户端常量 // AetherVehicleManager.DELTA_TIME_PER_SERVER_FRAME = 0.05(dump.cs:3884945)。 // 客户端是按「服务端 20Hz 发点」来设计插值的;用 10Hz 会让点间隔与它内部假设不符, // 表现为走走停停 + 突然冲刺补距离。缓冲 MAX_PENDING_SYNC_DATA=30 也正好是 1.5 秒。 // TrafficTickMs int32 `json:"trafficTickMs"` // 补给 / 回收扫描间隔(毫秒)。 TrafficReseedMs int32 `json:"trafficReseedMs"` // 车道限速的整体倍率(车道自带 speed 中位 9.72 m/s ≈ 35 km/h)。 TrafficSpeedScale float32 `json:"trafficSpeedScale"` // // VehicleControlType:1 = AetherVehicle(客户端做车道插值,车会自己沿车道跑 —— 默认), // 2 = RaidVehicle(只挂 Aether 数据、不接管驾驶,等价于 T1-16 骇入用的模式,车会停在 AddData 的 Position)。 // ★ 排查「街上没车」时的对照开关:改成 2 若车出来了,说明 ControlType=1 这条 Aether 车道通道有问题。 // TrafficControlType int32 `json:"trafficControlType"` // // 车道衔接的接缝容差(米):在「接缝最小」的后继基础上放宽这么多再随机选。 // 0 = 严格取接缝最小的后继(**实测最优**:>12m 的衔接仅 2.55%,而绝对阈值 8m 是 15%)。 // 调大 = 路线更多变,但更容易出现「车瞬移一段」。 // TrafficMaxLaneGapMeters float32 `json:"trafficMaxLaneGapMeters"` // // 换道接缝的**绝对硬上限**(米)。超过就直接判定"开到尽头"并回收该车,不再硬跳。 // ★ 为什么单独有这个键: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 能滤掉大跳而几乎不影响正常衔接。 // 调小 = 更少瞬移、但车更早被回收;调大 = 车跑更远、但会出现瞬移。 // TrafficMaxLaneHopMeters float32 `json:"trafficMaxLaneHopMeters"` // // 给每台车预规划多少段车道(VehicleLaneGraph.PlanRoute,直行优先)。 // 0 = 关闭预规划,退回「每到一条车道随机挑后继」(旧行为)。 // ★ 这是「车开很短距离就停下/消失」的结构性解:随机挑后继会让车乱拐、掉头、 // 钻死路、很快被回收;预规划后车沿道路连续跑,观感从"随机乱窜"变成"正常行驶"。 // 建议 8~16。太小 = 频繁重新规划(开销),太大 = 一次算太久且路径过期。 // TrafficRouteHops int32 `json:"trafficRouteHops"` // // LaneDatas 时间戳相对当前时刻的提前量(毫秒)。 // ★ 默认 350ms,对齐客户端常量 AetherVehicleManager.TARGET_DELAY_TIME = 0.35 // (dump.cs:3884947,即 TARGET_DELAY_FRAME=7 × 50ms)。 // 客户端的 TimedSpringInterpolationMethodUnmanaged 是「定时弹簧」: // 每个点带一个 TargetTimeStamp,客户端在到达该时刻前把 DeltaDistance 平滑排空。 // 时间戳 = 当前时刻(lead=0)时目标时刻**已经过去**,弹簧瞬间排空 → 车冲一下 → // 然后 deltaDistance=0 停住等下一个包 → 正是「停一下又用很快的速度补一段」。 // 给一个未来时间戳,弹簧才有时间区间可以平滑铺开。 // TrafficTimestampLeadMs float32 `json:"trafficTimestampLeadMs"` // // 起步加速度(m/s²)。默认 2.5 ≈ 家用车温和起步(0→35km/h 约 4 秒)。 // ★ 车「凭空直接起步」就是因为没有这个:原来 spawn 时速度直接 = 限速。 // TrafficAccelMps2 float32 `json:"trafficAccelMps2"` // 刹车减速度(m/s²)。默认 5.0(比加速大,符合直觉;急刹感就调低)。 TrafficDecelMps2 float32 `json:"trafficDecelMps2"` // // 跟车检测半径(米)。只在这个距离内找前车。 // ★ 调**小** = 更晚发现前车 → 车开得更冲、更少无故减速,但更容易追尾。 // 默认 18(原为 30,实测 30 会让车在路口前就莫名减速)。 // TrafficFollowRangeMeters float32 `json:"trafficFollowRangeMeters"` // 跟车最小车距(米,保险杠到保险杠)。 TrafficFollowMinGapMeters float32 `json:"trafficFollowMinGapMeters"` // 跟车的横向半宽(米):前车偏离本车前进方向超过这个就不算挡路。 TrafficFollowWidthMeters float32 `json:"trafficFollowWidthMeters"` // 交叉让行:最近接近距离小于这个值(米)判定为会撞。 TrafficConflictRadiusMeters float32 `json:"trafficConflictRadiusMeters"` // // 交叉让行:只看未来多少秒内会撞。 // ★ 调**小** = 更晚才判定要撞 → 车更敢走、路口更少互相让行卡死,但更容易真撞。 // 默认 1.8(原为 3.0,实测 3.0 会让对向车流在路口互相等)。 // TrafficConflictHorizonSec float32 `json:"trafficConflictHorizonSec"` // // 交叉让行的横向门槛(米):两车在**彼此**前进方向上的横向偏移都超过这个值 → 只是并行经过, // 不算冲突。★ 没有这条,双向相邻车道(横向 3.5m)会被误判成要撞,对向车流互相让行卡死。 // TrafficConflictWidthMeters float32 `json:"trafficConflictWidthMeters"` // // 让行速度(m/s)。让行方降到这个速度**继续走**,而不是停下 —— 停下会让对面也进入 // 让行判定,两边来回抖动成死锁。 // TrafficYieldSpeedMps float32 `json:"trafficYieldSpeedMps"` // // 僵局解除:一台车连续停住这么久(秒)就进入「硬冲」模式,低速强行通过。 // ★ 没有这个,路口互相让行会形成环形等待(A 让 B、B 让 C、C 让 A),全场堵死。 // TrafficStallReleaseSec float32 `json:"trafficStallReleaseSec"` // 硬冲模式持续多久(秒)。 TrafficStallBoostSec float32 `json:"trafficStallBoostSec"` // 硬冲模式的速度上限(m/s)。低速通过,视觉上比原地不动好。 TrafficStallBoostSpeedMps float32 `json:"trafficStallBoostSpeedMps"` // 距车道末端(= 路口/转弯)多少米内开始减速。 TrafficJunctionSlowMeters float32 `json:"trafficJunctionSlowMeters"` // 路口限速倍率(乘在车道限速上)。 TrafficJunctionSpeedFactor float32 `json:"trafficJunctionSpeedFactor"` // // 服务端自建「红绿灯相位」总开关(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 // TrafficSignalEnabled bool `json:"trafficSignalEnabled"` // // 每个相位组的绿灯时长(秒)。 // ⛑ 原为 9:相位组数 {2组:219, 3组:124, 4组:9} ⇒ 3 组周期 33s、4 组 44s, // 最坏要等 24~33 秒 —— 2026-09-29 实机就是「有的车一直杵在路口」。缩短到 6。 // TrafficSignalGreenSec float32 `json:"trafficSignalGreenSec"` // 相位切换之间的**全红清空**时长(秒)。没有它会对冲的车在路口里撞成一团。 TrafficSignalClearSec float32 `json:"trafficSignalClearSec"` // // 单车红灯等待上限(秒)。等超过这么久就**放行**(当作绿灯)。 // ★ 为什么必须有:周期长度 = 组数 × (绿 + 清空),路口腿数越多等得越久, // 单靠调 green/clear 无法给所有路口一个统一的上界。 // 有了它,任何一辆车的红灯等待都 ≤ 这个值,杜绝「杵在路口不动」的观感; // 交叉冲突仍由既有的 yield(TrafficYieldSpeedMps)兜底。 // TrafficSignalMaxWaitSec float32 `json:"trafficSignalMaxWaitSec"` // // 感应式放行:只有「这个路口真的有别个方向来车」时才按相位让行,否则全绿。 // ⛑⛑ 2026-09-29 实机逼出来的:我们把路网 6861/14171 ≈ **48% 的车道**都装了灯, // 而场上只有 8 台车撒在 620m 半径 ⇒ 绝大多数路口根本没有冲突方,却被按 // 「N 组里只有 1 组绿」的概率白拦一半时间(实测 spd avg 掉到 1.9、stop=5/8)。 // 判据:某路口「有车所在的相位组」≥2 组才算有冲突,此时才看相位钟;否则放行。 // 关掉它 = 回到「所有路口无脑按相位拦车」。 // TrafficSignalYieldToTrafficOnly bool `json:"trafficSignalYieldToTrafficOnly"` // 红灯刹停时,在「刹到 0 所需距离」上再加的安全余量(米)。 TrafficSignalStopMarginMeters float32 `json:"trafficSignalStopMarginMeters"` // // 车流出生点距玩家的**最小**距离(米)。与 TrafficSpawnRadiusMeters 组成一个环。 // ⛑ 2026-09-29 新增(原来只有上限,没有下限): // ① 车会直接刷在玩家脚边,且出生点固定取车道起点(很多在路口里)⇒「不像在车道上」; // ② 更关键:客户端 AetherVehicleViewManager 在 CANDIDATE_GO_DISTANCE_SQ=400(20m) // 内会把车流车提升成 GO(完整载具实体),从此不再吃我们的 Aether 车道插值 ⇒ 冻在原地。 // ⇒ 让玩家身边一直攒着一批不动的车。设成 60 以上可避开这一圈。 // TrafficSpawnMinPlayerDistanceMeters float32 `json:"trafficSpawnMinPlayerDistanceMeters"` // // 车流车出生后发一条 SyncAetherAIVehicleForceGo(68832908):forceGo=false。 // 期望语义 = 「这台车不要被提升成 GO(完整载具实体)」,让它一直吃服务端推的 // Aether 车道插值 ⇒ 玩家走近时**不会冻住**。 // ⛑ 为什么需要它:客户端 AetherVehicleViewManager 按距离自动提升 GO // (20m 候选 / 8m 强制 / 同屏上限 8 台),提升后车改由客户端本地接管; // 我们的车是服务端模拟的、不在客户端本地车流里 ⇒ 提升 = 没司机 = 停在原地。 // 只靠 TrafficSpawnMinPlayerDistanceMeters 躲不开 —— 车会自己开过来。 // ⛑ 语义未证实:`forceGo=false` 也可能被客户端当成「取消强制」而不影响自动提升。 // 本开关就是为二分这条通道留的:开着没变化 ⇒ 通道无效 ⇒ 改走注入器 // (hook CheckShouldNotUseGo 恒 true,RVA 0x0B108F00)。 // 代价(若生效):近处的车保持简化渲染(ECS 整车,无分离的车轮/车顶件/车灯), // 但至少一直在动。 // TrafficSuppressGoRender bool `json:"trafficSuppressGoRender"` // // 车流车出生后发一条 SyncVehicleAutonomousDrivingState(68349264):isStart=true, // 让**客户端本地驾驶系统**接管这台车。 // ★ 与 TrafficSuppressGoRender 的区别:那条是求「别被提升成 GO」(已证伪,10-01 实机 // 包发出去了照样停);这条是**反过来给它一个本地司机** —— 客户端自带驾驶、避障、交通规则, // 且本地驾驶本来就面向 GO 完整载具 ⇒ 被提升成 GO 之后照样开。 // ⛑ 客户端**有实现**(GameSceneToClientImpl override,dump.cs:185152 → // BaseVehicle.SetAutonomousDrivingState),且 VehicleAutonomousDrivingModule // 是每台车的模块、非玩家车专属 ⇒ 车流车也能用。 // ⛑ **没有**给自驾设目标点的 S2C(唯一带终点的 SyncVehicleStartAI 68915975 客户端无 override) // ⇒ 开完之后车往哪开由客户端决定,**未知** —— 本开关就是为了实测这件事(2026-10-01 实验 ①)。 // 可能结果:①车自己开起来了 ⇒ 车流可整体换管线;②原地不动(缺目标)⇒ 转注入器方案。 // ⛑ 我们仍在照常推 Aether 车道插值 —— 若两者打架(车抽搐/瞬移),实机会看出来, // 届时再加「自驾开启后停止推该车」的开关。 // TrafficAutonomousDrive bool `json:"trafficAutonomousDrive"` // // ★★ 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 能不能驱动空车。出租车/自驾都是车里有人,空车没验过。 // 若开起来车原地不动,两个方向:① 同时开 TrafficAutonomousDrive // (给它一个"本地司机"状态);② 关 TrafficSuppressGoRender // (别再跟"提升成 GO"对着干,GO 才是本地 AI 的正常工作形态)。 // ⛑ 代价:服务端拿不到这些车的实际位置(客户端不上报 AI 车的坐标), // 只能用「上一个目标点」当估算位置 ⇒ 回收/避撞判定是近似的。 // 回滚:设 false 即回到原来的服务端车道模拟。 // TrafficCruiseDrive bool `json:"trafficCruiseDrive"` // // TrafficCruiseDrive 下每次规划要走多少段车道(PlanRoute 的 hop 数)。 // 段数越多 ⇒ 目标点越远 ⇒ 车一口气能开越久(不用等刷新)。太大则目标点可能落在 // 玩家看不见的远处、车提前被按估算位置回收。12 段 ≈ 数百米。 // TrafficCruiseHops int32 `json:"trafficCruiseHops"` // // TrafficCruiseDrive 下重新下发目标点的间隔(秒)。 // 车到达终点后会停住等下一次刷新;但服务端不知道它到了没有(无位置上报), // 所以只能定时重发:旧 token 先 Abort(4),再从「上一个目标点所在的车道」往后规划。 // 车没到就重发 ⇒ 新目标仍在它前方,等价续驶;车到了 ⇒ 新目标在它脚下前方,正常续驶。 // TrafficCruiseRefreshSec float32 `json:"trafficCruiseRefreshSec"` // // TrafficCruiseDrive 下单台车的**强制寿命上限**(秒)。 // ⛑ 为什么需要:Cruise 车的位置是估算的,按距离回收会不准;不加寿命上限的话 // 车可能永远达不到回收条件,玩家身边渐渐只剩一批老车、新车补不进来。 // TrafficCruiseMaxLifeSec float32 `json:"trafficCruiseMaxLifeSec"` // // TrafficCruiseDrive 下:true = 只把最后一个点当目标发给客户端, // 让客户端 C# 的 NavigationHelper 自己沿真实路网导航;false = 把整串车道终点都喂进去。 // ⛑ 为什么默认 true:TargetPointList 是逐点直线的,而我们这份车道几何是 // 「弦」不是样条 —— 整串喂进去会在弯道上切内弯(压马路牙子/穿绿化带)。 // 只给终点时客户端必须自己做寻路,走的才是真实道路。 // (与自驾的 cruiseSingleTarget 同一条结论,09-30 已实机验证。) // TrafficCruiseSingleTarget bool `json:"trafficCruiseSingleTarget"` // // TrafficCruiseDrive 下:**快到终点就提前续发**的提前量(米)。 // ⛑ 为什么需要(2026-10-02 01:4x 用户反馈「一条路上两辆车挤在一起开」的根因链): // Cruise 的 CruiseType=0(GoToOneTarget) + ConfigFlags=4 会让车**到点就刹停**, // 然后等我们续发下一个目标 —— 哪怕「收到 status=2 立刻续发」,中间仍有百毫秒级的空窗, // 车是**停在大马路上的**。别的车开过来 ⇒ 顶住它 ⇒ 客户端 AI 倒车脱困 ⇒ 越堵越死。 // 有了提前量,车在离终点还有这么远时就拿到新目标 ⇒ 全程不停。 // ⛑ 太大 ⇒ 车还没跑到就改道(走不到目的地,观感随机);太小 ⇒ 还是会停一下。80m ≈ 3 秒车程。 // TrafficCruiseLeadMeters float32 `json:"trafficCruiseLeadMeters"` // // TrafficCruiseDrive 下:新目标点与**场上其它车的目标点**的最小间距(米)。 // 0 = 不检查。用来避免两台车被指到同一个路口 ⇒ 挤在一起。 // TrafficCruiseTargetMinGapMeters float32 `json:"trafficCruiseTargetMinGapMeters"` // // 车流出生时,**朝着玩家开**的那批车占多少比例(0~1)。默认 0.5 = 一半迎面、一半同向。 // ⛑⛑ 为什么必须显式控这个比例(2026-10-02 01:4x 用户反馈「对面车道的车很少」): // 实测服务端下发方向**本来是均匀的**(六个方向桶 13%~25%,各方向寿命也一样), // 但车流按「距玩家 TrafficDespawnRadiusMeters 内」存活 —— // · 朝玩家开的车:从 220m 外开进来、擦身而过、很快出圈 ⇒ **在场时间极短**; // · 背对玩家开的车:一直在圈里 ⇒ 越积越多。 // ⇒ 玩家一旦移动(实测日志里玩家沿 +X 走了 3km),稳态下看到的几乎全是同向车。 // 这是**生存偏差**,不是随机数偏了 ⇒ 只能靠「强制一半的车朝玩家开」来补。 // TrafficSpawnFacePlayerRatio float32 `json:"trafficSpawnFacePlayerRatio"` // // 出生时**避开已经有车的车道**(2026-10-02 01:5x)。 // ⛑⛑ 为什么光靠 `TrafficSpawnMinGapMeters`(距离) 不够(用户原话): // 「两辆车开**一条车道**,然后**并列**在一起挤着开」。 // 距离判定读的是 Cruise 车的**推算位置**(dead reckoning),而车实际可能比推算慢很多 // (被挡/等灯/减速),推算值领先 ⇒ 距离判定形同虚设 ⇒ 两台车生在同一条车道、几乎同一个点 // ⇒ 客户端 AI 各走各的,谁也不让谁 ⇒ 并排贴着开、车身互相穿模。 // ⇒ 补一条**车道级**判据:候选车道只要已经有车(车的出生车道)就换一条。 // ⛑ 车的 `Drive.Lane` 是出生车道且不再更新,所以这是「这条车道最近有车占着」的近似 —— // 偏保守(车道会一直占到那台车被回收),但方向上正是我们要的「错开」。 // 找不到空车道时会自动去掉这条约束再来一轮,不会因此刷不出车。 // TrafficSpawnAvoidSameLane bool `json:"trafficSpawnAvoidSameLane"` // // TrafficCruiseDrive 下:每台车巡航速度的随机抖动比例(0~0.5)。 // 默认 0.15 = 每台车在 0.85~1.15 倍之间取一个固定倍率(出生时定下来,全程不变)。 // ⛑ 为什么需要:所有车同一个速度 ⇒ 一旦两台车挨上就会**永远并排贴着开**(相对速度 0, // 谁也甩不开谁)。给了速度差,快车会自然拉开,慢车被落到后面 ⇒ 车距自己就出来了。 // ⛑ 用「每台车固定倍率」而不是每次下发随机:否则同一台车忽快忽慢,观感像抽搐。 // TrafficCruiseSpeedJitter float32 `json:"trafficCruiseSpeedJitter"` // // TrafficCruiseDrive 下:巡航速度的绝对上限(km/h)。0 = 不限。 // ⛑⛑ 为什么光有 TrafficSpeedScale 不够(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 是**出生时**按出生车道限速算一次、之后续发一直沿用 // (车开到限速不同的路上也不变)。上限对这个坑同样有效 —— 不管生在哪种车道都被压住。 // TrafficCruiseMaxSpeedKmh float32 `json:"trafficCruiseMaxSpeedKmh"` // // TrafficCruiseDrive 下:一次目标至少要规划出多少段车道,少于这个数就判 // 「这条车道是个死路口袋」⇒ 换一条重新规划(TrafficCruiseHops 之外的新判据)。 // ⛑⛑ 为什么必须有它(2026-10-01 23:2x 实机根因):车开完第一次的目标后,下一次从 // 「上次目标所在的车道」往后规划 —— 而那条车道常常是**死路**(我们路网里 286 个孤岛、 // 大量车道没有合格后继)⇒ 日志里刷出一串 `points=1/1`,且**每台车每次都拿到同一个目标** // (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。 // 而且多台车撞在同一个目标点上 ⇒ 后车顶住已停的前车、客户端 AI 做倒车脱困后又停。 // TrafficCruiseMinHops int32 `json:"trafficCruiseMinHops"` // 新车出生点与已有车辆的最小间距(米),避免「刚生成就贴上」。 TrafficSpawnMinGapMeters float32 `json:"trafficSpawnMinGapMeters"` // ----------------------------------------------------------------------------------------- // ★★ 路口相位 / 红绿灯(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)。 // IntersectionsEnabled bool `json:"intersectionsEnabled"` // // 登录后给每个路口推一次 SyncAetherAIIntersectionUpdateDa(68819947), // 并 thereafter 每 IntersectionPeriodSeconds 切一档相位。 // ★ 这是「灯真的会变色」的那一条;关掉只留静态初始相位。 // IntersectionUpdateEnabled bool `json:"intersectionUpdateEnabled"` // // 68819947 包里 IntersectionIndex 取什么: // "zoneIndex"(默认,= CSV 第一列,客户端用它对 IntersectionDataEntity)或 // "listIndex"(路口在表里第几行,仅当 zoneIndex 对不上时作为对照试)。 // IntersectionIndexSource string `json:"intersectionIndexSource"` // // ⛑⛑⛑ **默认 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,恰好是对的)。 // IntersectionUseTrueZoneIndex bool `json:"intersectionUseTrueZoneIndex"` // 相位切换周期(秒)。捐赠端实测值 12。太小 ⇒ 灯闪得乱、车刚起步就红灯。 IntersectionPeriodSeconds float64 `json:"intersectionPeriodSeconds"` // // 一次最多把多少个路口纳入相位推送(0 = 全部 171 个)。 // 调小可减少包量(全量是 171 包/周期);按距玩家远近排,**近的优先**。 // IntersectionMaxCount int32 `json:"intersectionMaxCount"` // // 只推距玩家这个半径内的路口(米,0 = 不限)。0 时行为与捐赠端一致(全量)。 // ⛑ 玩家走远后新路口要靠下一轮补推 —— 循环每 5s 重扫一次,所以设了也不会漏。 // IntersectionRadiusMeters float32 `json:"intersectionRadiusMeters"` // // 车流车型池:跳过带 RoofMod(车顶改装件:天窗 / 玻璃顶 / 顶灯)的车型。 // 排除 7 个车型(81001002/003/008、81003001/004/006/008)。 // ⛑⛑ 2026-09-29 用户更正:本开关的**立论是错的**,现在默认关闭,仅作历史开关保留。 // 原文写「这 7 个车型的天窗/玻璃会飘在空中」—— 实测不成立: // · **没有**车顶件的车,玻璃同样会浮空; // · **有**车顶件的车,也有完全正常的; // · 而且只发生在**一部分**车上,不是每台。 // ⇒ 车顶件与「玻璃浮空」之间**没有因果关系**,把它排除掉只是白白少 7 种车型。 // 真正的嫌疑是 TrafficCreateSourceType(玻璃是 VehicleGlassDamageModule // 的独立可破坏挂件,只有完整装配管线才会更新它;Aether 车道模拟只更新车辆 root ⇒ // 玻璃留在建车时的高度),以及 TrafficSendBrokenCollision / // TrafficDisableNavigation 这两条同源管线。 // TrafficSkipRoofModVehicles bool `json:"trafficSkipRoofModVehicles"` // // 车流车建实体时的 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。 // TrafficCreateSourceType int32 `json:"trafficCreateSourceType"` // // 车流车出生后是否补发 SyncVehicleBrokenCollision(当前/最大 HP)。 // ★ 第二嫌疑:这个包喂的是车辆损伤系统(TotalBrokenCollisionImpulse / 损伤模块 state), // 与玻璃挂件同一条管线。当初是为「可骇入」加的(CanHack() 要求 CurrentHp > 0)。 // 关掉 ⇒ 车流车不能骇入;只作为定位用的二分开关,验完请改回 true。 // TrafficSendBrokenCollision bool `json:"trafficSendBrokenCollision"` // // 车流车 SyncSpawnVehicle 的 DisableNavigation。 // ★ 第三嫌疑:我方一直写 true(「位置由车道流决定,别让客户端自己导航」), // 但捐赠端写 **false**。若客户端的导航同时负责贴地,关掉它可能让车身与玻璃高度不一致。 // TrafficDisableNavigation bool `json:"trafficDisableNavigation"` // // 车流车 SyncSpawnVehicle 的 SeatInfos 是否留空(对齐捐赠端)。 // 捐赠端车流车 SeatInfos = [];我方按车型填 N 个空座位(SeatState=0=Empty)。 // 座位挂点同样是「建车时摆一次」的物件,留空可排除它作为浮空源的嫌疑。 // ⛑ 留空可能让「上车」交互找不到座位,验完请改回 false。 // TrafficEmptySeatInfos bool `json:"trafficEmptySeatInfos"` // // 车流车型池的车长下限(米)。低于此值的摩托 / 三轮(SHIKAGE·Aero 2.1m、M125-T 2.96m)会被排除。 // 0 = 不限制。 // TrafficMinVehicleLengthMeters float32 `json:"trafficMinVehicleLengthMeters"` // // 车流车型池的车长上限(米)。高于此值的客车 / 卡车(MM·M300 13.9m、Transporter 8.5m)会被排除。 // 0 = 不限制。 // TrafficMaxVehicleLengthMeters float32 `json:"trafficMaxVehicleLengthMeters"` // // 行走速度(m/s)。 // ⛑️ 只在 "crowd" 通道有效 —— ClientCrowdInitData 自带 DesiredSpeed 字段(donor 用 1.4)。 // "static" 通道下 ClientStaticNpcInitData **没有**这个字段,只能靠 // SyncChangeNpcMoveDesiredSpeed(68739456) 补,但**该 RPC 在客户端是空 stub** // (dump 里只有 virtual 0x9148360,没有 override)⇒ 发了也没用。 // AmbientPedsDesiredSpeed float32 `json:"ambientPedsDesiredSpeed"` // // 是否发 SyncChangeNpcMoveDesiredSpeed(68739456)。默认 false —— 客户端是空 stub, // 留着只是给未来客户端版本/对照实验用。 // AmbientPedsSendDesiredSpeed bool `json:"ambientPedsSendDesiredSpeed"` // // "crowd" 通道的 AgentPersonaConfig id。donor_lyco082 实测 45200091。 // ⛑️ NpcFormworkId 必须是 AgentConfig id(4013xxxx),填成 persona id 客户端会打 // `创建AgentUnit失败!AgentConfigId为0!` 并丢掉这个人。 // AmbientPedsCrowdPersonaId uint32 `json:"ambientPedsCrowdPersonaId"` // // "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,所以他们也没走起来。 // AmbientPedsTargetLocationReason byte `json:"ambientPedsTargetLocationReason"` // // "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 一致)。 // AmbientPedsCrowdActionId uint16 `json:"ambientPedsCrowdActionId"` // // "static" 通道的 CPoiActionId(= AgentSyncClientInfo.initPoiActionId)。 // 同上:1=走路 / 2=idle / 4=跑步。填 0 ⇒ 无动作 ⇒ 站着。 // ⛑️ 非 0 时会额外发 SyncAgentPlayPOIAction(id, poi, IsActive=true, speed=1)。 // ⚠️ 实测:`UrbanDiversityPOIActionConfig` 里 id=1(走路) 的 **ApplyRootMotion=False** // ⇒ POI 动作只是"走的动画状态",**本身不带位移**。位移必须靠移动系统。 // AmbientPedsPoiActionId uint32 `json:"ambientPedsPoiActionId"` // // ⛑️⛑️ "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,就是这个包没发。 // AmbientPedsCrowdManagedLogicAgent bool `json:"ambientPedsCrowdManagedLogicAgent"` // // `AskClientNpcFindPath`(67974714) 是否用真实路网作答。 // 关闭 ⇒ 回空路径(`FF 01 FF 01`),也就是这条包落地之前的行为。A/B / 回滚开关。 // NpcPathPlanEnabled bool `json:"npcPathPlanEnabled"` // 一条路径最多给多少个点(donor 的 NpcPathAskMaxPoints)。 NpcPathMaxPoints int32 `json:"npcPathMaxPoints"` // 重采样最小点间距(米,donor 的 MinMetres)。 NpcPathMinMetres float32 `json:"npcPathMinMetres"` // 规划不出路时,是否改给「最近车道上的脱困点」(1 个点)而不是回空路径。 // 空路径会让客户端原地站着不动(spawn 后的 From==To、卡在花坛/门框里的单位都只这样问)。 NpcPathEscapeEnabled bool `json:"npcPathEscapeEnabled"` // 脱困点沿车道前进的距离(米)。太小挪不出花坛,太大可能跨到别的路口。 NpcPathEscapeMetres float32 `json:"npcPathEscapeMetres"` // // "crowd" 通道建完人后补发 SyncAgentPlayPOIAction(id, ActionId, active, 1f)。 // ClientCrowdInitData.ActionId 只是初始值,显式播一次更稳。 // AmbientPedsCrowdPlayPoiAction bool `json:"ambientPedsCrowdPlayPoiAction"` // // 生成后发 SyncAgentForceGo(id, true) = 68698324(客户端有真实现 0x12753C90)。 // 语义就是"已生成但站着不动的 agent 用它启动行为树" —— 正对"人出现但不走"。 // AmbientPedsForceGo bool `json:"ambientPedsForceGo"` // // MoveWanderingData.pathTags(NavTag 位掩码)。 // ⛑️⛑️ 填 0 = `NavTagType.None` = 没有任何"可走区域" ⇒ 寻路必然失败, // `WanderingActionModule.wanderFailCount` 累到 MaxWanderFailCount(10) 就**永久站住**。 // 6 = WalkableAndJump(最宽松)。 // AmbientPedsWanderPathTags uint32 `json:"ambientPedsWanderPathTags"` // // 游荡扇形窗口(度),对应客户端 WanderingAreaDate.{leftAngle,rightAngle}。 // ⛑️ 两个都填 0 = 零宽扇形 ⇒ 选不出目标。-180/180 = 全向。 // AmbientPedsWanderLeftAngle float32 `json:"ambientPedsWanderLeftAngle"` AmbientPedsWanderRightAngle float32 `json:"ambientPedsWanderRightAngle"` // 单次游荡最短/最远位移(米)。donor 人流用 8..35;固定 NPC 用 0..15。 AmbientPedsWanderMinDis float32 `json:"ambientPedsWanderMinDis"` AmbientPedsWanderMaxDis float32 `json:"ambientPedsWanderMaxDis"` // // 游荡预算(秒)。MoveWanderingData.MaxTime 是**总时长**,走完客户端就永久站住, // 所以必须在耗尽前重发。donor:人流 150,固定 NPC 50。 // AmbientPedsWanderSeconds float32 `json:"ambientPedsWanderSeconds"` // 提前多少秒重发游荡(要 < WanderSeconds)。填 0 = 不续期。 AmbientPedsWanderRearmSeconds float32 `json:"ambientPedsWanderRearmSeconds"` } // // 车辆改装店(ApplyCar / RepairShop)2026-09-25 移植自俄语上游 donor。 // 回滚 = enabled:false ⇒ AskTeleport 不再 echo、两个 waypoint 只 ack 不推位移、 // 不再补推 SyncStartSummonVehicle(即逐字节回到移植前的行为)。 // type RepairShopSettings struct { Enabled bool `json:"enabled"` // 生成/召唤后补推 SyncStartSummonVehicle(68681875) —— 没有它, // 客户端 ApplyCarManager.IsMySummonVehicle 不认这辆车, // 门禁第一关就弹「只能驾驶自己的载具进入」(查全流程找不到别的原因,donor 踩出来的)。 PushSummonStart bool `json:"pushSummonStart"` // 离场落地点的街道微调(作者给定的 LeaveWaypoint 已经离入口触发区 25m+, // 正常情况下不需要动;真出现「出门立刻又被拽回店」时再调)。 ExitOffsetX float32 `json:"exitOffsetX"` ExitOffsetZ float32 `json:"exitOffsetZ"` // // 买过的改装件是否落盘(save/vehicles/<pid>.json)。 // 默认 false = 与 donor 未接 store 时一致:重启即清空。 // 想让改装结果跨重启保留就置 true(读取侧在进世界时做)。 // PersistTuning bool `json:"persistTuning"` // // 刷车包(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。 // SendStatParts bool `json:"sendStatParts"` } type VehicleSettings struct { Enabled bool `json:"enabled"` FleetIds []uint32 `json:"fleetIds"` DesiredApproachMeters float32 `json:"desiredApproachMeters"` MinimumApproachMeters float32 `json:"minimumApproachMeters"` SearchRadiusMeters float32 `json:"searchRadiusMeters"` ZoneStorageDataHandle int32 `json:"zoneStorageDataHandle"` // // 玩家 F1 / 手机叫车(AskSummonVehicle 67012447)时,车不要在脚下凭空出现, // 而是先刷在附近车道上,再自己开过来(2026-10-03)。配置见 SummonDriveInSettings。 // SummonDriveIn SummonDriveInSettings `json:"summonDriveIn"` } // // 「召唤载具从车道开过来」(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」的旧行为,不需要重编译。 // type SummonDriveInSettings struct { // // 总开关。false = 车照旧刷在玩家右手 5.5m(2026-10-03 之前的行为)。 // Enabled bool `json:"enabled"` // // 生成点距玩家的最小距离(米)。太近就看不出「车开过来了」。 // ⛑ 2026-10-03 18:1x 实机:40–80m 用户反馈「太远了」⇒ 调到 **20–45m**(35km/h ≈ 2–4.5 秒到)。 // ⛑ 别调太小:低于 ~15m 时车基本是「凭空出现在眼前」,演出就没意义了。 // MinDistanceMeters float32 `json:"minDistanceMeters"` // 生成点距玩家的最大距离(米)。超出这个距离玩家看不到车开来,只会觉得「叫了不来」。 MaxDistanceMeters float32 `json:"maxDistanceMeters"` // // 停车点(终点)吸附半径(米)。终点取车道上离玩家最近的点——靠边停车,玩家走过去上车。 // ⛑ 不用玩家脚下当终点:自定义标点吸附失败时客户端一条 Cruise 都不发(2026-09-30 血泪)。 // StopSnapMeters float32 `json:"stopSnapMeters"` // 开过来的速度(km/h)。市区 35 够看;太快会在我们这份「弦」几何上切弯。 SpeedKmh float32 `json:"speedKmh"` // // ★★ 允许的「弦偏离真实车道」横向距离(米),**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)。 // MaxLateralDeviationMeters float32 `json:"maxLateralDeviationMeters"` // // 是否先用车道图 Dijkstra(SolveRoute)算一串点再下发。 // 解不出路就退回单点(交给客户端 C# NavigationHelper 自己导航),不会失败。 // UseLaneRoute bool `json:"useLaneRoute"` // // 下发 Cruise 前先把车切成 RaidVehicle=2(SyncAetherAIChangeVehicleControl)。 // ⛑ 出租车就是栽在这里:客户端把车切成 AetherVehicle=1 后,Cruise 的位移每帧被车道覆盖, // 表现是「轮胎在转,车在原地进行」(GameRouter.Taxi.cs:147-166)。 // 召唤车是 CreateSourceType=2 完整装配、有 VDE,一样会被 Aether 接管 ⇒ 这条不能关。 // ForceRaidControl bool `json:"forceRaidControl"` // // 找不到合格生成点 / 附近没车道(室内、地下、孤岛)时的兜底: // true = 退回「刷在身边」;false = 直接回召唤失败(不要这么设,玩家会以为功能坏了)。 // FallbackToBesidePlayer bool `json:"fallbackToBesidePlayer"` // // 车开到之后收尾:撤掉 Cruise 任务(AssignVehicleControl<VehicleStopParameters>) // + 推 SyncVehicleAutonomousDrivingState(false)(照抄出租车的 StopTaxiTaskAsync)。 // ⛑⛑ 这是 2026-10-03 实机三个症状的共同解药:客户端 // SummonVehicleManager 是用 RegisterTaskAIDeletedEvent(token) 等任务被删除, // 才走 OnSummonShowFinish 结束「召唤演出」。任务一直挂着 ⇒ // ① 车还在 AI 手里,玩家方向盘无效(车开不动); // ② 客户端认为这台车还在召唤流程中(下车后再也上不去); // ③ 上一台车的演出没结束(「叫车小助手忙」,叫不了第二台)。 // 关掉它 ⇒ 回到这三个症状(排障对照用)。 // EndTaskOnArrival bool `json:"endTaskOnArrival"` // // 车开到之后,把 SyncChangeVehicleController 的 controllerPid 还原成 0。 // ⛑⛑ 这是「车开不动」的真凶(2026-10-03 18:4x):下发 Cruise 时我们照自驾的老规矩 // 发了 SyncChangeVehicleController(controllerPid = Profile.PlayerPid) —— // 那是必需的(不锁主控权,玩家的方向盘输入会盖掉 AI 任务,车根本开不过来)。 // 但到达后没人还原 ⇒ 主控权一直挂在服务端 ⇒ 玩家坐进主驾也只算乘客, // 踩油门没反应。以前召唤车能开,正因为这条路径从没发过这个包。 // 实机佐证:18:38:55 坐 seat=0(主驾)一样开不动 ⇒ 不是座位问题。 // 关掉它 ⇒ 回到「车开不动」(排障对照用)。 // ReleaseControlOnArrival bool `json:"releaseControlOnArrival"` // // 车开到之后,服务端补一发 SyncTeleportVehicle(68088911, Reset=true),把车钉在 // 它现在真正的位置(AskVehicleMove 上报的最新值)。 // ⛑⛑ 为什么必须有(2026-10-03 实机):服务端在生成时下发过 // SyncAetherAIVehicleAddData,里面写着生成点的坐标,而 // LaneHandle=-1 ⇒ 这份 Aether 坐标永远不会被更新。车是被 Raid 通道的 // Cruise 开过来的,客户端两套表示就此分叉:看得见的车在身边,Aether 里那台还在生成点。 // 玩家一上车,客户端按 Aether 那份数据对齐 ⇒ **车瞬移回生成点**。 // 位置取自 AskVehicleMove(AI 开车期间客户端每秒都在报),所以这一发在视觉上是 // 「原地不动」,只是把两边强行对齐。 // 关掉它 ⇒ 回到上车瞬移生成点的行为(排障对照用)。 // SyncPositionOnArrival bool `json:"syncPositionOnArrival"` // 每次召唤都打选点日志。排障时才开(正常玩的时候每叫一次车一条)。 LogEverySummon bool `json:"logEverySummon"` } // // 载具路线规划。默认全开是因为「有路线」不可能比「没路线」更糟 —— 客户端当时每秒问一次, // 而返回形状本来就已被客户端接受(DefaultReturnCatalog 的 complex 677),我们只是把两个 // 空列表填上真实点。**回滚 = Enabled=false**,不需要重编译。 // type VehicleNavigationSettings struct { Enabled bool `json:"enabled"` // 把「车的位置」吸到最近车道的最大距离(米)。开车时车应该在路上,40m 已经很宽松。 StartSnapMeters float32 `json:"startSnapMeters"` // 把「目的地」吸到最近车道的最大距离(米)。目的地可能是个楼前的点,所以给到 120m。 TargetSnapMeters float32 `json:"targetSnapMeters"` // 下行点集上限。这是一条 INVOKE 的回包,192 点 × 12B ≈ 2.3KB,够画满一条路线。 MaxPathPoints int32 `json:"maxPathPoints"` // 丢掉间距小于该值的相邻点(米)。车道弦本身就有大量重合端点。 MinPointSpacing float32 `json:"minPointSpacing"` // Dijkstra 扩展上限。14171 条车道正常几百次就收敛,卡住说明图有问题。 MaxExpansions int32 `json:"maxExpansions"` // 是否填 CenterPoints。今天线上请求带的正是 NeedCenterPoints=true。 IncludeCenterPoints bool `json:"includeCenterPoints"` // // 点序翻转为「终点 → 车」。捐赠端探针断言过这是客户端要的顺序,我方**尚未实测**; // 若导航线箭头反了(或自驾往反方向开),改成 false 即可。 // ReversePoints bool `json:"reversePoints"` // // 是否下发 S2C SyncVehicleAutonomousDrivingState(68349264)—— 也就是「车会不会自己开」。 // 这是客户端自动驾驶的唯一开关(见 GameRouter.VehicleNav.cs 的取证注释): // false ⇒ Start/Stop 照常回 Ok,但不推状态包,车永远不会动,等于回到 2026-09-27 之前的行为。 // 回滚不需要重新编译。 // AutonomousDrivingEnabled bool `json:"autonomousDrivingEnabled"` // // 巡航速度(米/秒)。写进 VehicleCruiseParameters.MaxSpeed / DefaultSpeed。 // 12 m/s ≈ 43 km/h,市区够看;太快会在我们这份「弦」几何上切弯。 // CruiseSpeed float32 `json:"cruiseSpeed"` // 巡航路点上限。AI 任务包不宜过大,64 点够跑几百米的主干道。 CruiseMaxPoints int32 `json:"cruiseMaxPoints"` // 判定「到达终点」的距离(米)。太小车会在终点附近来回蹭。 CruiseArrivalMeters float32 `json:"cruiseArrivalMeters"` // // 巡航任务的 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 之前的旧行为。 // CruiseDrivingFlags int32 `json:"cruiseDrivingFlags"` // // 只把终点(一个点)交给客户端的 Cruise 任务,而不是我们算出来的整串车道点。 // 客户端 C# 侧自带导航(LX6.Drive.AI.NavigationHelper.SetTargetPosition(start, target) + // GetNavigationResult(...)),捐赠端就是这么发的:CruiseType=GoToOneTarget + 单点。 // ⛑ 我们这份车道几何是「弦」不是样条(_gen_vehicle_lanes.py 压掉了中间点),喂整串点会切内弯。 // 回滚:改成 false ⇒ 回到「服务端下发最多 CruiseMaxPoints 个点」的旧行为。 // CruiseSingleTarget bool `json:"cruiseSingleTarget"` // 每次请求都打日志(开车时 = 每秒一条)。排障时才开。 LogEveryRequest bool `json:"logEveryRequest"` } // // 厕所 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 里, // 并被置顶到面板「生成敌人」下拉框第一位 ⇒ **不需要新的生成通道、也不需要新的面板卡**。 // type ToiletQteSettings struct { // // 总开关。默认 true:三个 handler 都在 Enabled4229938MethodIds 里, // 关它只是「不回头推状态、不回收鸵鸟」,注册与日志都还在(保持可观测)。 // 想彻底回到「什么都不做」:把 GameRouter.cs 里那三个 mid 从白名单删掉。 // Enabled bool `json:"enabled"` // // 收到 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, // 否则就是「马桶没了、人还坐在空中」。 // PushRideState bool `json:"pushRideState"` // // 骑乘时是否额外推 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 都真实存在。 // PushUnitStates bool `json:"pushUnitStates"` // // 是否推 SyncToiletPosture(agentId, isSquatting=false)(68122107,蹲/起姿态)。 // 默认 false。⛑ 这条在客户端是 CSharpOnly(lua 侧无 reader)⇒ 效果只能在画面上看, // 别指望 lua 日志。官方对应的是 GM 入口 GmCreateToilet / GmCreateToiletSquatting,我们不走。 // PushPosture bool `json:"pushPosture"` // // 收到 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 的真实敌人)。 // RetireOnFlee bool `json:"retireOnFlee"` // // 从面板生成马桶鸵鸟时是否走「坐骑」而不是「敌人」:engage=false(不登记进敌人账本、 // 不下令开战)+ forceEnemyRelation=false(不把玩家和它显式标成敌对)。 // 默认 true:它的用途是被骑 + 玩 QTE,带攻击 AI 的话玩家会先跟它打起来。 // ⚠️ 它仍然在 AgentConfig 的 EnemyClassType=1 桶里,所以面板必须按 // 「生成敌人」调(hostile=true),camp 也仍是 4 —— 这条只影响「要不要打」。 // 回滚:改 false ⇒ 与普通敌人逐字节同路。 // PassiveSpawn bool `json:"passiveSpawn"` // // 骑上马桶后是否下发原生坐骑操控桥(加速 / 跳跃)—— 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 完全不受影响)。 // NativeControls bool `json:"nativeControls"` } // // 出租车(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 的常量注释。真要挪位置得改代码里的常量。 // type PolicePrisonSettings struct { // 总开关。false ⇒ commit / release 直接返回失败提示。 Enabled bool `json:"enabled"` // // 允许操作的半径(米)—— 玩家必须站在牢房门口这么近才能收押/带出。 // 捐赠端是硬编码 4 m(distance <= 16 的平方比较)。 // NearMeters float32 `json:"nearMeters"` // // 是否要求玩家在主城(ActiveInstanceId == 20001222)。 // 牢房是主城里的一块地,不在主城就不该有这个门;关掉可以方便调试。 // RequireNovaScene bool `json:"requireNovaScene"` } type TaxiSettings struct { // 总开关。false = 四个出租车 handler 全部直接回失败码(注册仍在,日志仍在)。 Enabled bool `json:"enabled"` // 起步价(含 FreeMeters 米)。捐赠端原值 300。 BaseFare float64 `json:"baseFare"` // 起步价包含的里程(米)。捐赠端原值 200。 FreeMeters float64 `json:"freeMeters"` // 超出起步里程后每米单价。捐赠端原值 80 * 0.005 = 0.4。 FarePerMeter float64 `json:"farePerMeter"` // 正常速度(km/h)。捐赠端原值 15 m/s ≈ 54。 NormalSpeedKmh float32 `json:"normalSpeedKmh"` // 「加速」后的速度(km/h)。捐赠端原值 25 m/s = 90。 AcceleratedSpeedKmh float32 `json:"acceleratedSpeedKmh"` // 判定「已到达」的距离阈值(米)。捐赠端原值 3(判到达用 4 m 平方=16)。 ArrivalMeters float32 `json:"arrivalMeters"` // // 收不到位移上报多久算「卡住」(毫秒)。卡住不结束行程,只提示可换目的地/跳过。 // 捐赠端原值 45000。 // StalledAfterMs int32 `json:"stalledAfterMs"` // 单趟最长时长(毫秒),超时强制结算。捐赠端原值 1200000(20 分钟)。 MaxTripMs int32 `json:"maxTripMs"` } type SwitchAnimationSettings struct { SwitchType int32 `json:"switchType"` CommonTimelinePrefix string `json:"commonTimelinePrefix"` AvoidImmediateRepeat bool `json:"avoidImmediateRepeat"` // // 切人演出总开关。false = 回到原来的静默原地切人(只发身份移交,不碰 // SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)。回滚不需要重新编译。 // Enabled bool `json:"enabled"` // // ⛑⛑⛑ 为切人演出 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 即回到改动前(演出里依然只有道具和环境在动)。 // SpawnShowActors bool `json:"spawnShowActors"` // // 演完后的收尾握手三包: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 即回到改动前的旧行为(一键回滚)。 // PushHandoffPackets bool `json:"pushHandoffPackets"` // // 演出开始之前就把目标角色投放到演出落点(SwitchSpiritConfig 行的 Position), // 并在 SyncSwitchSpiritConfigId 的 spawnedAgentIds 里带上它。 // 这是「镜头飞过去时远处真的站着角色」的关键:捐赠端从来不预生成,只在客户端报告演出播完后 // 才在玩家原位置 materialize,于是镜头飞到行锚点看到的是空地,播完又被拉回原角色。 // 关掉后仍会落在演出锚点,只是演出期间那里暂时没人(地形未流式加载时可作降级)。 // SpawnTargetBeforeShow bool `json:"spawnTargetBeforeShow"` // // 等客户端 ReportPreSwitchSpiritFinish 的超时(毫秒)。超时后服务端自己完成移交, // 但仍然落在演出锚点上(不会退回原位置),保证 roster 不会因为一次丢包永久卡在 pending。 // PreSwitchTimeoutMs int64 `json:"preSwitchTimeoutMs"` // // 演出落点与玩家当前位置的距离上限(米)。0 = 不限制。 // 63 个可用锚点里「最近锚点距离」中位数约 636 m、p90 约 1675 m,所以开启演出等于切完人 // 被传送几百米(官方语义)。想减轻就填一个数字,超限时本次退化为静默原地切人。 // MaxAnchorDistanceMeters float32 `json:"maxAnchorDistanceMeters"` // // 把 SyncSwitchSpiritConfigId 与 SyncPreSwitchSpirit 一起在 phase 1 发出。 // 捐赠端注释:晚发会让客户端先退出切人控制器、再为落地 Timeline 重新进入 ⇒ // 「切完人后还回去了,然后又回来了」的闪跳。 // PublishConfigIdEarly bool `json:"publishConfigIdEarly"` // // ⛑⛑ 已证伪,**默认关闭**(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 并不会消失 —— 纯亏。 // FixIsMeOnHandoff bool `json:"fixIsMeOnHandoff"` // // ⛑⛑ 切人演出开始前预生成的那个目标单位,在移交控制权时先销毁再重建。 // 默认开,关掉会让部分角色切过去走不动。 // 预生成(SpawnTargetBeforeShow)建单位时发过 SyncManagedLogicAgent // ⇒ 该 actor 在客户端被标成「服务端驱动」:本地移动被忽略、只等服务端喂 // SyncLogicAgentSyncData,而我们从不给玩家喂位置。症状就是 // 原地有跑步动画但坐标不动,攻击 / 开手机却正常(那些不走移动系统)。 // 2026-09-24 14:2x 实测:把它当成「IsMe 修复的副作用」一起砍掉后立刻复现该症状。 // ⛑ 它跟头顶的 F 是两件事:F 是 IsMe 锁存,服务端治不了,走客户端侧摘按钮 // (switch_no_interact.lua)。别再把这两件事捆在一起。 // RecreatePreSpawnedTargetOnHandoff bool `json:"recreatePreSpawnedTargetOnHandoff"` // // 以主角身份重建目标角色时,SyncLogicAgentLeave 之后等多久再发建单位那一组(毫秒)。 // 0 = 不等。客户端销毁单位是异步的,Leave 与重建挤在同一批包里会被合并掉 —— // 12:30 实测就是连一条新的 InitModules 都没有(单位没被销毁)。 // 仅在 RecreatePreSpawnedTargetOnHandoff 打开时有意义。 // RecreateDelayMs int32 `json:"recreateDelayMs"` // // 切换角色时把旧角色**留在大世界里**(不发 SyncLogicAgentLeave), // 记录到 WorldEntryState.LeftBehindCharacters。默认开。 // 留下的角色是「服务端驱动」单位:站着待机、不移动、不吃玩家输入。 // 关掉 = 回到旧行为(切走后旧角色立刻消失)。 // KeepLeftBehindCharacters bool `json:"keepLeftBehindCharacters"` // // 切回一个「留在大世界里」的角色时,落点取**它留下时的位置**而不是 // SwitchSpiritConfig 行的演出锚点(用户原话:下次切换直接切到那个角色的所在地)。 // 演出照常播:configId 照常选(按离留下位置最近挑,镜头锚点≈角色所在), // 只有落点坐标被覆盖。默认开;关掉 = 即使角色在大世界里也按演出锚点落地。 // ReturnToLeftBehindLocation bool `json:"returnToLeftBehindLocation"` // // ★★ 第一次切某个角色(他还没被切走过、LeftBehindCharacters 里没有落点)时, // 也把他自己的日程点位当落点 —— 而不是原地切换(09-26 00:4x 加,用户要的)。 // ⛑ 为什么原来做不到:落点只有两个来源 —— 「这个角色上次被切走时留在哪」 // 或「玩家现在站在哪」。LeftBehindCharacters 里没有该角色就直接原地切, // 于是「有日程的角色明明在城里某个点,切过去却是在我脚下换皮」。 // 现在补第三个来源:按「现在几点」查他自己的 // AgentDataSetsActivityConfig(NpcActivityCatalog4229938.Resolve, // 与常驻摆放 / 切人留场同一个裁决函数,不另写一套)。 // ⛑ 查不到(两个主角 tag 2/3 没有时刻表、该时段活动没编坐标)⇒ 照旧原地切换,不会飞到 (0,0,0)。 // ⛑ 朝向仍然无表可查(规则 171②)⇒ 沿用玩家切人时的朝向,想调就配 // gameplay.rosterNpc.activityPoiOverrides[].facing(那条也作用于常驻摆放)。 // SwitchToScheduleSpot bool `json:"switchToScheduleSpot"` // // SwitchToScheduleSpot 的距离上限(米)。0 = 不限(默认)。 // 官方语义本来就是「切完人被传送几百米」(见 MaxAnchorDistanceMeters 的注释: // 锚点距离中位数约 636m),所以默认不限制;嫌远就填一个数字,超限时本次退化为原地切换。 // SwitchToScheduleSpotMaxMeters float32 `json:"switchToScheduleSpotMaxMeters"` // // 按角色分散演出落点:每个角色固定落到「自己的那一行」SwitchSpiritConfig // (按 templateId 散列选行),而不是「离玩家最近的那行」。 // 用户 2026-09-24 17:5x:最近锚点模式让所有角色都挤在咖啡馆/电影院门口那一两个点。 // 默认开。关掉 = 回到最近锚点模式。仅在目标不在大世界(非留下角色)时生效; // 该模式下 MaxAnchorDistanceMeters 不再限制(分散本来就意味着远)。 // DisperseAnchorsByCharacter bool `json:"disperseAnchorsByCharacter"` // // ⛑⛑⛑ 给「留在大世界里」的旧角色周期性回正位姿(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 条、 // 字节布局逐字节对齐,人依然纹丝不动)—— 但「纹丝不动」正是我们要的效果。 // FeedLeftBehindPositions bool `json:"feedLeftBehindPositions"` // // 回正包里的 MoveId(BattleMoveActionType:0 Unknown / 1 Idle / 2 WalkFront / 6 Run)。 // 站桩用 1(Idle);改成 2 也不会让它走起来(见 FeedLeftBehindPositions 的证伪记录)。 // LeftBehindFeedMoveId byte `json:"leftBehindFeedMoveId"` // 同一个角色两次回正之间的最小间隔(毫秒)。客户端上报是 10Hz 级的,别跟着刷屏。 LeftBehindFeedMinIntervalMs int32 `json:"leftBehindFeedMinIntervalMs"` // // 切人演出前预生成的那个目标单位,也给它回一次位姿(钉在落点上,免得镜头飞过去的 // 那 4~5 秒里它也在往下掉)。只发一次,代价可忽略。 // FeedPreSpawnedTarget bool `json:"feedPreSpawnedTarget"` // // ⛑⛑⛑ 给「留在大世界里」的旧角色周期性发一条**原地路点任务** // (SyncLogicAgentCommand 68050574 + wire tag 39 = TaskWayPointMove, // points = [站立点, 站立点],距离 0)。默认开,**这才是止住自由落体的那一味药**。 // 为什么光靠 FeedLeftBehindPositions 不够:位置流是「每 N 毫秒硬拉一次」, // 而下落是 60~90 m/s —— 就算 10Hz,两次拉回之间也已经掉了 6~9 m,视觉上是抽搐, // 而且拉回只是改坐标,并没让客户端重新接管地面吸附。 // 对照证据:街上那些 830… 开头的 NPC 同样带 SyncManagedLogicAgent, // 上报 Y 常年稳定在 -0.13~0.01(packets 日志 130+ 个单位无一掉落), // 差别就是它们一直被喂 TaskWayPointMove ⇒ locomotion 状态机接管 ⇒ 贴地。 // ⇒ 留下的角色照这条机制发一条「原地任务」,到达即停,之后每 // LeftBehindWaypointIntervalMs 毫秒补一条维持住。 // LeftBehindWaypointTask bool `json:"leftBehindWaypointTask"` // 同一个留下的角色两次「原地路点任务」之间的间隔(毫秒)。太长会在间隔里重新开始掉。 LeftBehindWaypointIntervalMs int32 `json:"leftBehindWaypointIntervalMs"` // 原地路点任务的 Speed(m/s)。路程是 0,所以只影响停下前的那一瞬。 LeftBehindWaypointSpeed float32 `json:"leftBehindWaypointSpeed"` // // 重建留下的旧角色时要不要发 SyncManagedLogicAgent(把它标成「服务端驱动」)。 // 默认开:关掉会回到 r6 那版「旧角色 T-pose 站在新角色身边」(客户端销毁了 hero actor, // 没有完整重建就只剩一个裸 actor)。 // ⛑ 关掉它同时也等于绕开了自由落体(客户端继续用自己的 AI/地面吸附), // 所以这是 LeftBehindWaypointTask 万一不灵时的第一退路。 // ManagedForLeftBehind bool `json:"managedForLeftBehind"` // // ⛑⛑⛑ 重建留下的旧角色时补发 SyncAgentForceGo(68698324)(**启动行为树**)。 // 默认开。这是 Ped 协议里我 09-24 21:5x 才补上的第 5 步 —— 之前只发了 // Enter + Managed + projection,行为树从没启动过。 // 为什么必须发:21:33 那场实测(packets-20260924-213352.log)证明 // LeftBehindWaypointTask 和 FeedLeftBehindPositions // **都没能止住下落** —— 000 的 Y 从 -0.01 一路掉到 -2999.99(21:37:25 → 21:38:16), // 005 从 -0.08 掉到 -2999.99,全程服务端按 2s 间隔在喂路点任务 + 位姿回正。 // 差别只有一条:街上那些不掉的路人(GameRouter.Ambient.SendAmbientCrowdPedAsync) // 在建人之后还要发 SyncAgentForceGo 启动行为树,客户端才会接管 locomotion(贴地)。 // 任务包是「派给行为树」的,树没起来 ⇒ 没人接 ⇒ 继续自由落体。 // LeftBehindForceGo bool `json:"leftBehindForceGo"` // // ⛑⛑⛑ 重建留下的旧角色时,要不要先发 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 的老写法(会刷空引用)。 // LeftBehindRebuildLeave bool `json:"leftBehindRebuildLeave"` // // 重建留下的旧角色时发的 POI 动作 id(SyncAgentPlayPOIAction 68459398 的 // CPoiActionId)。Ped 那套里 1=走路;留下来的角色要**站着不动**, // 所以默认填 17(站姿闲置 / 1001)。0 = 不发这个包。 // ⛑ 别把「动作组号」(400) 填到这里 —— 那是 ClientCrowdInitData.ActionId 的表, // 两张表(见 Ambient.cs 的 T0-9 注释)。 // LeftBehindPoiActionId uint32 `json:"leftBehindPoiActionId"` // // ⛑⛑ 留下的角色「闲逛」:周期性用 PedLaneGraph.TryPlanJourney(挑一个**已经在人行道上**的 // 终点 + 沿路网规划整条折线)给他派一段 TaskWayPointMove,让他真的走起来。 // 默认 false(先把功能做出来,实机验过再开)。 // 为什么这样就能走:同伴协议下客户端已接管 locomotion,而 TaskWayPointMove 就是派给行为树的 // (街上那些 830… 开头的 NPC 一直被喂这个包才不掉、才在走,见 LeftBehindWaypointTask)。 // 止跌用的那条 points = [锚点, 锚点](距离 0 ⇒ 原地),这里换成真实折线即可。 // ⛑ 终点**必须**走 TryPlanJourney,不能用「当前位置 + 随机方向」:环形随机点 92% 落在 // 建筑/江里,而 PlanRoute 尾部会把最后一个路径点硬拉到那个点 ⇒ 他最后一段是扎进墙里, // 客户端到不了就一直往墙里推(2026-09-23 行人同款坑)。 // LeftBehindWander bool `json:"leftBehindWander"` // 闲逛循环的检查间隔(毫秒)。只是「该不该派新行程」的轮询,**不是**行程时长。 LeftBehindWanderPollMs int32 `json:"leftBehindWanderPollMs"` // // 一段行程终点离当前位置的最小 / 最大距离(米)。 // ⛑ 太大他会走出玩家视野被回收,看起来就是「人凭空消失」。 // LeftBehindWanderMinMetres float32 `json:"leftBehindWanderMinMetres"` LeftBehindWanderMaxMetres float32 `json:"leftBehindWanderMaxMetres"` // 走的速度(m/s)。客户端真实速度约 1.846(硬规则 109),别填太快。 LeftBehindWanderSpeed float32 `json:"leftBehindWanderSpeed"` // // 一段行程预计走完之后的额外余量(毫秒)。 // ⛑ 太短会在他还没走到位时就派下一段 ⇒ 一步一停(硬规则 108/109 同款症状)。 // LeftBehindWanderSlackMs int32 `json:"leftBehindWanderSlackMs"` // // ⛑⛑ 把客户端上报的真实坐标写回 LeftBehindCharacter.Position。 // 不写回 ⇒ 他闲逛到哪都白搭:切回时 GameRouter.Switching.cs 读的 // (directPos = leftBehind!.Position)还是切走那一刻的落点,人会瞬移回去。 // 默认 true。 // LeftBehindTrackRealPosition bool `json:"leftBehindTrackRealPosition"` // // ★★ 留场角色的「POI 轮换」总开关(2026-09-26 23:4x;里希那组)。 // 开着时,gameplay.rosterNpc.poiOverrides[].leftBehindPoiGroup 里配了组的角色, // 在「留在大世界」期间会按**洗牌袋**依次播组里的 POI:把组打乱后一条一条取, // 取完一轮再重新洗牌 ⇒ **随机,但一轮之内不重复**(正是用户要的"每组都播到才重来")。 // ⛑ 与 LeftBehindWander **互斥,轮换优先**:这个角色配了组 ⇒ 只轮换、 // 不派行程(走路/跑步 POI 是原位移动,再叠路网行程会互相打断)。 // 没配组的角色照旧走 wander。 // LeftBehindPoiRotation bool `json:"leftBehindPoiRotation"` // // 轮换里每个 POI 停留多久(毫秒)。⛑ POI 表里**没有时长字段**,只能统一给一个值; // 单个角色要不一样就配 RosterNpcPoiOverride.LeftBehindPoiDwellMs。 // LeftBehindPoiDwellMs int32 `json:"leftBehindPoiDwellMs"` // // 换到带道具的 POI(吃汉堡 / 对讲机 / 平板)时补发 // SyncAgentBelongings + SyncAgentUseBelongingChanged // (与 GameRouter.Civilians.cs 出场那套同包同结构)。 // ⛑ 换到**无道具**的 POI 时会发一个空 usage 列表尝试把上一件道具收掉 —— // 这个语义没实机验过(可能收不掉、也可能把别的 usage 一起清了)。 // 出问题就置 false(那时带道具的 POI 会是"空手演",但不会乱)。 // LeftBehindPoiRotationProps bool `json:"leftBehindPoiRotationProps"` // // ★★ 留场那具身体**不发骇客手机**(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。 // LeftBehindSkipPhone bool `json:"leftBehindSkipPhone"` // // 闲逛时播的 POI 动作 id。0(默认)= 沿用出场那个(LeftBehindNativePoiActionId,17) // —— 位移本身由 TaskWayPointMove 驱动,POI 只管上半身/动画状态。 // 想让他明显在"走"就填 1(走路),想"跑"填 4(跑步)+ 把 // LeftBehindWanderMovement 设成 2、LeftBehindWanderSpeed 提到 4~5。 // ⛑⛑ POI 1/4 单独播是**原地**走/跑(跑步机效应),位移来自移动命令,两者必须配套。 // LeftBehindWanderPoiActionId uint32 `json:"leftBehindWanderPoiActionId"` // // 闲逛的移动方式(AgentMovementMethods):1 = Walk(默认)、2 = Run。 // 改成 Run 记得同时给 LeftBehindWanderPoiActionId 填 4、速度提到 4~5, // 否则会用走路的速度演跑步(脚底打滑)。 // LeftBehindWanderMovement byte `json:"leftBehindWanderMovement"` // // ⛑⛑⛑ 留下的旧角色走「同伴协议」:ExistingUnitProjection 之后补发 Ped 的第 4/5 步 // SyncAgentPlayPOIAction(68459398) + SyncAgentForceGo(68698324), // 让客户端行为树接管 locomotion(贴地、有动画状态机), // 并且停掉 FeedLeftBehindPositions / LeftBehindWaypointTask / // 后台续发循环。默认 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 重建,试图同时拿到「能走」与「模型正常」。详见那一条的注释。 // HeroUnitReuse bool `json:"heroUnitReuse"` // // ⛑⛑⛑ 换皮时先 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) 两件套,黑客召唤物那条已实机验证的原生交操控通道)。 // HeroUnitRebuildOnSwitch bool `json:"heroUnitRebuildOnSwitch"` // // 切人移交后是否再补发一次 SyncUnitPositionAndFacing 把落点钉住。默认 true。 // ⛑ 用户 03:3x 提出「走不动是不是和绑定了切换角色的坐标有关」,这条就是验证它的开关。 // 关掉后切人不再回 assert 落点 —— 若角色因此能走,说明 setPositionType=SwitchSpirit(3) // 会让客户端停在"等待落点完成"的状态机里锁住输入。 // ⛑ 关掉的风险:新角色可能落到模板默认出生点而不是切人落点。 // SwitchHandoffPushPose bool `json:"switchHandoffPushPose"` // // ⛑⛑⛑ 实验开关:客户端(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,恢复完整协议。留着备用,别再打开当默认。 // AskOnlyForClientSwitch bool `json:"askOnlyForClientSwitch"` RecreateHeroBeforeShow bool `json:"recreateHeroBeforeShow"` LeftBehindCompanionProtocol bool `json:"leftBehindCompanionProtocol"` // // ⛑⛑⛑ 原生留场:老角色不再走「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 投影(会掉出世界)。 // LeftBehindAsNativeAgent bool `json:"leftBehindAsNativeAgent"` // // 留场时把**旧操控单位**回收掉。默认 true。 // 用户 04:2x 实机:切人后场上出现**两个**旧角色 —— 一个穿自己衣服、没有 POI, // 一个穿默认服装、有 POI。前者就是 `oldUnitId`(`state.ActiveSpiritUnitId`)那个单位: // `SwitchSpiritDirectSameScene4229938` 一直没 `Leave` 它,客户端也没主动销毁,它就杵在原地。 // 后者才是 `LeftBehindAsNativeAgent` 新刷的市民 NPC。 // ⛑ `LeftBehindRebuildLeave` 做的是 `Leave(companionUnitId)`(那是**还没建**的新 id), // 不是 Leave 旧单位 —— 别搞混,这是另一个动作。 // LeftBehindReclaimOldUnit bool `json:"leftBehindReclaimOldUnit"` // // 给留场的市民 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`。 // LeftBehindDressWithPlayerFashion bool `json:"leftBehindDressWithPlayerFashion"` // // 走哪条通道刷留场 NPC。默认 true = 新的 SyncRaidBattleUnitAgent(68402349) 通道。 // ⛑ 老通道(Aether 静态 NPC,SendCivilianAsync)行为没问题(站得住、有闲置、有 POI), // 但客户端不读 AIAgentInfo.Fashion.WearInfo,顶层又只有 FashionSuitId(套装) // ⇒ 09-25 两次实测「包发了 6 件衣服,模型还是默认服装」。 // SyncRaidBattleUnitAgent 带 FashionIdList(单件列表)才有救。 // 设为 false 就退回老通道(对照 / 新通道出问题时的回滚)。 // LeftBehindAgentUnitChannel bool `json:"leftBehindAgentUnitChannel"` // // A/B 用:老办法 —— 建完 NPC 之后补发一次 SyncSetSpiritFashions(64102530)。 // 默认 false。 // ⛑ 已实测无效(09-25 用户:「没有穿上我的搭配」):该包按 spiritId 匹配, // 落不到场上那个市民 NPC。留着只为对照/回滚,别再当主方案。 // LeftBehindDressSyncPacket bool `json:"leftBehindDressSyncPacket"` // // ★ 让留场的老角色真的「过日子」:按 现在这个时刻 从它自己的日程表里挑一条活动, // 把它刷到那条活动编好的世界坐标上,并在出场包里带上 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)。 // LeftBehindDailyLife bool `json:"leftBehindDailyLife"` // // 「过日子」时是否要求活动一定带世界坐标。默认 true。 // false = 即使该时段的活动没有 MapPosition,也把 AgentDataSetsActivityCfgId 填上 // (角色留在原地播那条活动的表演)。真机还没验过无坐标活动会怎样,留个开关好 A/B。 // LeftBehindDailyLifeRequirePosition bool `json:"leftBehindDailyLifeRequirePosition"` // // ★★ 游戏时间跨进下一个日程时段后,把「被玩家操控过、切走后赖在原地的」角色 // 交还常驻化去演新活动,而不是继续钉在他最后被操控的位置。默认 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 = 回到「一旦操控过就永远钉在原地」。 // LeftBehindFollowSchedule bool `json:"leftBehindFollowSchedule"` // // ★★ 玩家**主动推时间**(游戏里的「度过时间」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 = 只有时段真的翻篇才放行。 // LeftBehindFollowOnTimeJump bool `json:"leftBehindFollowOnTimeJump"` // // 原生留场时给老角色播的 POI 动作 id(UrbanDiversityPOIActionConfig,共 3978 条)。 // 17 = 站姿闲置。想让它真的去"过日子",填别的 id(例如健身房/跑步机那几条)。 // ⛑ 是 POI 动作 id,不是动作组号(400)—— 两张表,别混。 // ⛑ 开了 LeftBehindDailyLife 且命中活动时,客户端走生活日程状态,这个 POI 会被盖掉。 // LeftBehindNativePoiActionId uint32 `json:"leftBehindNativePoiActionId"` // // ⛑⛑⛑ 切回「已经留在大世界里」的角色(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)→ 客户端把镜头运过去」。 // 首次切到某角色(它还没在世界里)仍然走完整的角色专属演出。 // CloseUpAgentForInWorld bool `json:"closeUpAgentForInWorld"` } // // 可玩角色常驻大世界(gameplay.rosterNpc)。 // 为什么需要它:官方这些角色本来就在城里晃(趴卜主页的「日程」和大地图的图钉就是给你去找他们用的), // 而我们原先只在「切走角色」那一刻才造一个 NPC ⇒ 玩家跑到日程坐标那儿是空的。 // 为什么要有进入/离开迟滞:客户端会把远处的单位卸载掉,而不会自己把它们加回来 // (09-25 实机:从远处走到点位,人是消失的,只有切走再切回才重新出现)。 // ⇒ 服务端必须自己按距离管理:走近就放出来,走远就撤掉,回来再放一次。 // 这是 GameRouter.FunctionalNpc.cs 那套已经被验证的做法,不是新轮子。 // type RosterNpcSettings struct { // 总开关。默认 true。 Enabled bool `json:"enabled"` // 是否按日程挑活动(AgentDataSetsActivityCfgId + 活动坐标)。默认 true; // 关掉就只用 PoiActionId 在角色当前位置站着 —— 排障对照用。 DailyLife bool `json:"dailyLife"` // 进入多少米内放出来(并记住)。默认 260。 EnterMeters float32 `json:"enterMeters"` // 离开多少米外撤掉(必须 > 进入值,否则边界上每 tick 反复增删)。默认 420。 ExitMeters float32 `json:"exitMeters"` // 跟随循环间隔。默认 1500 ms。 TickMs int32 `json:"tickMs"` // 每个 tick 最多放几个(客户端会丢大批量包的尾巴)。默认 2。 PerTick int32 `json:"perTick"` // 没命中日程活动时的兜底 POI 动作(UrbanDiversityPOIActionConfig)。默认 17 站姿闲置。 PoiActionId uint32 `json:"poiActionId"` // // 用哪条出场通道。默认 false = Aether 静态 NPC(SendCivilianAsync,与满城市民同款, // 已验证站得住 / 有闲置 / 有 POI)。 // 设 true 改走 SyncRaidBattleUnitAgent(46 字段版 AgentSyncClientInfo, // 多带 SpoonAgentId / SpoonIndex)。 // ⛑ 这条是「表演没播出来」的 A/B 对照用:两条通道的 AgentDataSetsActivityCfgId // 是不是同一套消费逻辑还没验证过。⚠️ 只在 DailyLife 关闭换装时切它 —— // 这条通道一旦带上 AIAgentInfo.FightSpiritId 就会换皮只剩脸(规则 141)。 // AgentUnitChannel bool `json:"agentUnitChannel"` // // 常驻 NPC 的 unitId 走哪一段。默认 false = 与满城市民同款 // StaticNpcIdFloor(8300000000000000000 段)。 // 设 true 改走 GameAdminBridge.NextRetirableNpcUid()(0x300000000000 段, // 硬规则 165 里那条「SyncLogicAgentLeave 确实收得掉」的段)。 // ⛑⛑ 这是**排障开关,不用重新编译**:09-25 22:2x 实机发现 // SyncAetherAINpcRemove 删不掉 8300 段那具身体(跑步机上的里希一直不消失), // 若补发 SyncLogicAgentLeave(已加,同时发两条)仍然无效,就把这个改成 true // 再让常驻重新摆一次(配置热加载,摆人才读这个值)。 // ⛑ 风险:Aether 静态 NPC 通道是否吃这一段还没验过 —— 改完若人直接不出现,改回 false 即可。 // RetirableUidSegment bool `json:"retirableUidSegment"` // // 是否要求活动一定带世界坐标。默认 true —— 不带坐标的活动(临时点位)无法落地, // 这时会退到同 tag 在本场景里最近的一条有坐标活动。 // RequirePosition bool `json:"requirePosition"` // // 一个角色在当前时段查不到任何可落地的活动时(没有 AgentTag、没有日程表、活动在别的 // 场景、或官方压根没编坐标)要不要仍然把他放出来。默认 false = 不放。 // ⛑⛑ 设成 true 会退化成「谁都往玩家脚边塞」:这类角色的坐标只能取玩家位置 +3m, // 于是登录和每次传送都会有一圈人贴着你刷出来(09-25 玩家报的那个现象就是它)。 // 只有想让 OC 占位角色也露个脸时才开,代价是他们不会「过日子」,只是站着。 // PlaceWithoutActivity bool `json:"placeWithoutActivity"` // // 手工常驻点……(见 RosterNpcStaticSpot)。 // StaticSpots []RosterNpcStaticSpot `json:"staticSpots"` // // 放人前先看目标点位多少米内(3D)有没有别的 NPC 已经站着,有就不放 —— 避免和市民 / // 功能 NPC / 切人留场的同一角色叠在一起。默认 2 米;设 0 关掉这个检查。 // ⛑ 用 3D 距离是刻意的:同一栋楼 1 楼和 2 楼各站一个属于正常,只有真贴在一起才算冲突。 // MinSeparationMeters float32 `json:"minSeparationMeters"` // // ★ 按角色覆盖出场 POI 动作 / 要不要发活动 id。默认空。 // 为什么需要它:09-25 20:0x 实机确认 —— 里希按日程站到跑步机点上了,推 // SyncAgentPlayPOIAction(68459398) 的 198「站姿_运动_跑步机跑步」 // 之后他真的跑起来了(POI 通道有效),但上半身还叠着一层「打电话」 // —— 那是生活日程的 ambient:出场包带 AgentDataSetsActivityCfgId // 却没有 loop pose 时客户端的兜底表现(规则 160 那句「没有 loop pose 就只能播 ambient」)。 // 所以想跑得干净,得让这个角色出场就带 198 且不带活动 id。 // ⛑ 只对列出来的角色生效,其余照旧 —— 别把 52 个角色全改成跑步机。 // PoiOverrides []RosterNpcPoiOverride `json:"poiOverrides"` // // ★★ 按活动覆盖出场 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,里希「一边打电话一边跑步」就是这个)。 // ActivityPoiOverrides []ActivityPoiOverride `json:"activityPoiOverrides"` } // // 一条「活动 → 出场表现」的映射(按活动 id,不是按角色)。 // 活动 = AgentDataSetsActivityConfig.Id(NpcActivityCatalog.json 里的 Id)。 // type ActivityPoiOverride struct { // 活动 id(AgentDataSetsActivityConfig.Id,如 11400012 = 里希「跑步机」)。 ActivityId uint32 `json:"activityId"` // // 出场 POI 动作 id(UrbanDiversityPOIActionConfig,3978 条)。 // 填 0 = 这条不覆盖,退回默认(RosterNpcSettings.PoiActionId + 照常发活动 id)。 // PoiActionId uint32 `json:"poiActionId"` // 朝向(度)。null(默认)= 沿用 0。没有表可查,只能实机调。 Facing *float32 `json:"facing"` // // 出场包还要不要带 AgentDataSetsActivityCfgId。 // null(默认)= 本条配了 POI 就不带(让 POI 生效),没配就照常带。 // SendActivityCfgId *bool `json:"sendActivityCfgId"` // 这个活动出场不发骇客手机。null(默认)= 照常发。 SkipPhone *bool `json:"skipPhone"` // // ★★ 落地坐标微调(米,**世界轴**,不是角色本地轴)。三个都是 null = 不偏移。 // 为什么需要:官方 AgentDataSetsActivityConfig.MapPosition 是策划打的点, // 跟场景里的家具 / 机器经常差那么几十厘米 —— 人飘在床边上、背对着娃娃机站, // 这些都是没有表可查的(规则 171②:朝向无表可查),只能实机一点点挪。 // ⛑⛑ 是**世界轴**:OffsetX = +X 方向,OffsetZ = +Z 方向,OffsetY = 上。 // 判「哪边是右」要看这条活动落地时的朝向(Facing,没配就是 0): // 朝向 f(度)⇒ 前方 = (sin f, 0, cos f),右手方向 = (cos f, 0, -sin f) // (规则 181:喂 MathF.Sin/Cos 前必须把度转弧度)。 // ⇒ 朝向 0 时右 = **+X**;朝向 90 时右 = **−Z**。 // 改完朝向要重新对一遍偏移,别照抄。 // 生效范围:常驻摆放 / 切人留场(真去演活动那具)/ 主动切到他日程点 —— 三处同一个偏移, // 否则「走过去看到的她」和「切过去落地的她」会差 0.8 米。 // 实现见 GameRouter.ApplyActivityOffset。 // OffsetX *float32 `json:"offsetX"` OffsetY *float32 `json:"offsetY"` OffsetZ *float32 `json:"offsetZ"` // 备注(自动匹配到的候选 / 人工校对的依据),只给人看。 Note *string `json:"note"` } // // gameplay.rosterNpc.poiOverrides 的一条:某个角色出场时用哪个 POI 动作、 // 以及还要不要给他发 AgentDataSetsActivityCfgId。 // type RosterNpcPoiOverride struct { // 角色(FightSpiritConfig.Id / roster 里的 spiritId)。 SpiritId uint32 `json:"spiritId"` // // 出场 POI 动作 id(UrbanDiversityPOIActionConfig,共 3978 条)。 // 198 = 站姿_运动_跑步机跑步;17 = 站姿闲置(默认)。 // ⛑ 跑步机只有两条:198 跑、500997「通用04_跑步机_idle」(MainActions 为空)。 // PoiActionId uint32 `json:"poiActionId"` // // 出场包还要不要带 AgentDataSetsActivityCfgId。 // false = 不带(活动坐标照用,但不进生活日程状态 ⇒ 没有 ambient 那层打电话); // true = 带(走生活日程,POI 会被 ambient 盖掉); // null(默认)= 沿用 RosterNpcSettings.DailyLife。 // SendActivityCfgId *bool `json:"sendActivityCfgId"` // // 出场朝向(度,与 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)`, // 带不了朝向 ⇒ 朝向只能在出场包里给。 // Facing *float32 `json:"facing"` // // 这个角色不发手机(gameplay.hackNpc 那部)。默认 false = 照常发。 // 为什么需要它:09-25 实机 —— 里希出场带 POI 198 会在跑步机上跑,但上半身一直 // 「拿着手机/打电话」,要**打他几下**(刺激打断 usage)才会变成正常跑步。 // 那部手机不是生活日程 ambient,是骇客功能发的:`SendCivilianAsync` 里 // POI 198 的 UsageId = 0 ⇒ `poiUsageAlreadyHackable = false` ⇒ // `MarkNpcHackableOnSpawnAsync` 补一部手机 + usage 6(玩手机),和跑步动作叠在一起。 // 设 true 就让这个角色跳过发手机(可骇标记照旧发,只是没手机道具)。 // ⛑ 只对列出来的角色生效 —— 用户明确要「每个 NPC 都有手机」,所以默认都发。 // SkipPhone bool `json:"skipPhone"` // // ★★ 这个角色「留场期间」轮换的 POI 组(开关 // SwitchAnimations.LeftBehindPoiRotation)。 // 洗牌袋:整组打乱后依次播,播完一轮重新洗牌 ⇒ 随机 + 一轮内不重复。 // null / 空数组 = 这个角色不轮换(沿用出场那个 POI,或走 wander)。 // ⛑⛑ 只在这个角色没在生活日程里时才生效(出场包 // AgentDataSetsActivityCfgId == 0)—— 带着活动 id 时客户端会走 // 生活日程,ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步"就是这么来的)。 // LeftBehindPoiGroup []uint32 `json:"leftBehindPoiGroup"` // 这个角色单独指定停留时长(毫秒);null = 用 SwitchAnimations.LeftBehindPoiDwellMs。 LeftBehindPoiDwellMs *int32 `json:"leftBehindPoiDwellMs"` } // // gameplay.rosterNpc.staticSpots 的一条。固定站位,不随时间变、不跟着玩家跑。 // type RosterNpcStaticSpot struct { // 角色(FightSpiritConfig.Id / roster 里的 spiritId)。 SpiritId uint32 `json:"spiritId"` // 只在哪个场景生效。0 = 任何场景都算。 Raid uint32 `json:"raid"` X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"z"` // 给人看的地点名(GM 面板「在干什么」列会显示它)。 Note string `json:"note"` } // // 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 用的旋钮, // 每次推送时现读配置,改了不用重编译。 // type PlatePilotSettings struct { // 总开关。关掉就一行都不发(回到 09-25 之前的状态)。默认 false。 Enabled bool `json:"enabled"` // 进世界时自动推一次。建议先 false,用 POST /api/plate/push 手动推着试。 PushOnWorldReady bool `json:"pushOnWorldReady"` // 盘实例 id(ulong)。随便给,但同一会话内别重复;换一个 id 就是换一个盘。 UniqueId string `json:"uniqueId"` // // Spoon 盘图 id。跑步机 = 36001819(`Res/Prefab/Gadget/wodegym_treadmill.prefab`, // occupyCnt=1)。其它候选:娃娃机 36000544 / 单人床 36001042 / 卧推 36001818 / // 深蹲 36000424 / 仰卧板 36000106。 // GraphId int32 `json:"graphId"` // 盘的世界坐标(默认 = 里希跑步机活动 11400012 的 MapPosition)。 X float32 `json:"x"` Y float32 `json:"y"` Z float32 `json:"z"` // 欧拉角,度。 RotX float32 `json:"rotX"` RotY float32 `json:"rotY"` RotZ float32 `json:"rotZ"` // 这个盘偏好的活动(`AgentDataSetsActivityConfig.Id`)。里希跑步机 = 11400012。 FavorNpcActivityId uint32 `json:"favorNpcActivityId"` // 任务盘才用;生活表演填 0。 TaskId uint32 `json:"taskId"` // // NPC 绑定方式:none(只推盘,不绑人)/ staticNpc(StaticNpcDic[slot]=常驻 NPC 的 uid) // / agent(AgentDic[slot]=spoonAgentId,用角色的 AgentId)。 // NpcBind string `json:"npcBind"` // 上面那个 Dic 的槽位下标(图里第几个位子)。 SlotIndex int32 `json:"slotIndex"` // // 绑谁:NpcBind != none 时用哪个 spiritId 去找人(默认里希 15021021)。 // 先去常驻 NPC 账本里查它此刻在不在场上,不在就用它的 AgentId 兜底。 // SpiritId uint32 `json:"spiritId"` // —— 以下都是线格式旋钮,只为实机 A/B —— // 包体顶层是否先写一个 0xFF 存在标记(多数 S2C 有,GadgetGrid 那种 Inline 没有)。 RootMarker bool `json:"rootMarker"` // 盘列表前面是否写存在标记(默认 0xFF)。 ListMarker bool `json:"listMarker"` // 列表 count 的写法:int7(count+1 偏置)/ raw7(count 原值)/ int32。 ListCountEncoding string `json:"listCountEncoding"` // 每个 PlateInfo 元素前是否写 0xFF(Complex 有,Struct 没有)。 ItemMarker bool `json:"itemMarker"` // 八个字典前面是否写存在标记。空字典通常也要这一字节 + count。 DictMarker bool `json:"dictMarker"` // 字典 count 的写法,同 ListCountEncoding。 DictCountEncoding string `json:"dictCountEncoding"` // Position / Rotation 前是否各写一个 0xFF(UXVector3 通常是裸 3 个 float)。 VectorMarker bool `json:"vectorMarker"` } // // 生活日程正式通道(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 ⇒ 同一个类别里既有「站着不动」的, // 也有「带根位移会走开」「需要拿道具」的。全字段表能按动作属性再筛一遍。 // type CivilianPoiSettings struct { // // 剔除 ApplyRootMotion = true 的 POI 动作(全表 370 / 3978 条 ≈ 9.3%)。 // ★ 打开的理由:带根位移的动作会让 NPC 真的走出站位 —— 表现为 // 「铺完过一会儿人就挪走了 / 几个市民叠在一起」。这是 POI 表里唯一能直接判定 // 「这个动作会让人离开点位」的字段。 // ⛑ 副作用:候选池会变小。实测每个类别剩 13~46 人,剔掉约 9% 不会抽空; // 真抽空时 BuildItemPool 有「退回未过滤」的兜底。 // ExcludeRootMotion bool `json:"excludeRootMotion"` // // 剔除需要拿道具的 POI 动作(ActionTag4 含「道具」,全表 763 / 3978 条 ≈ 19%)。 // ★ 打开的理由:这类动作(端杯子、用电脑、拿话筒)要客户端把道具挂到手上, // 服务端生成时不带道具 ⇒ 容易出现「空手比划」。 // ExcludeProps bool `json:"excludeProps"` } // // 官方「过街等待区 + 槽位」站位源(paths.pedSpawns)的使用设置。 // ★ 这份数据是什么:客户端 MassAI.MassCrowd.CrowdWaitAreaData 里那一套 // 「路口等待区 + 槽位 + 人行横道」的官方坐标,从 ZoneGraph dump 出来的。 // 每个等待区挂 3~5 个槽位,槽位自带朝向与 urban_diversity_config_id。 // ⛑⛑ 它不是服务端能驱动的东西:整套是客户端原生 ECS(Lua grep 零命中), // 客户端自己的 crowd 散人本来就在用。我们只是把自己的 NPC 放到同一批坐标上, // 好看上去像「这撮人也在等灯」。 // ⛑ 三个开关默认的行为:总开关 false ⇒ 完全不读这张表,现有逻辑一行不动。 // type CivilianWaitAreaSettings struct { // // 启用官方等待区站位作为额外的点位来源。默认 false。 // 打开后,街区铺设会先从等待区取点(不足再退回原有的生态区点位)。 // Enabled bool `json:"enabled"` // // 同一个等待区最多放几个人。默认 2(与捐赠端 PedSpawnTable.NearSpread 同值)。 // ★ 为什么不能塞满:官方一个等待区只有 3~5 个槽位,全占满就成了 // 「路口一角戳着一排人」;官方画面是「四角各站两三个」。MaxPerArea 就是那个纪律。 // MaxPerArea int32 `json:"maxPerArea"` // 取点半径(米)。默认 250。 RadiusMeters float32 `json:"radiusMeters"` } type NpcLifeSettings struct { // 总开关。默认 false(保持 09-25 的行为,改坏了随时关回来)。 Enabled bool `json:"enabled"` // // 常驻 NPC 落地后立刻给它推一条 SyncFavorNpcSpoonAgentId。默认 true。 // PushOnPlace bool `json:"pushOnPlace"` // // spoonAgentId 怎么取值:agentId(用角色自己的 AgentId = NpcFormworkId,里希 40140040; // 与出场包 AgentSyncClientInfo.SpoonAgentId 同值,目前唯一能自圆其说的取法)/ // fixed(用 SpoonAgentIdFixed)/ none(填 0)。 // SpoonAgentIdMode string `json:"spoonAgentIdMode"` // SpoonAgentIdMode = fixed 时用这个值。 SpoonAgentIdFixed int32 `json:"spoonAgentIdFixed"` // // SyncFavorNpcSpoonAgentId 的线格式旋钮:包尾那个可空的 FavorNpcBusyInfo // 怎么表示 null。0(默认)= 写一字节 0x00(服务端 C# // SerializeBase.WriteComplex 写 null 就是 0);255 = 写 0xFF; // -1 = 整字节不写。 // ⛑ 客户端 RPCDeserializeBase.ReadComplex 是反编译出来的,看着像「0 = 有值」, // 与 writer 相反 —— 所以两种都留着,客户端报 64003213 反序列化失败就换另一个值再推。 // BusyInfoMarkerByte int32 `json:"busyInfoMarkerByte"` // —— 时刻表(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 号的覆盖上去。 // PushTimeTable bool `json:"pushTimeTable"` // // 只推一个 agentTag 的时刻表(走 SyncAgentTagTimeTableInfo 64400736) // 还是推全量字典(走 SyncFavorNpcTimeTableInfos 64219939)。默认 false = 全量。 // 单条包体小、字段少,排障时先用它排掉「字典线格式不对」这个变量。 // TimeTableSingleTag bool `json:"timeTableSingleTag"` // // 是否把 spoonAgentId 也写进当前时段那一段的 // NpcScheduleInfo.SpoonAgentId(顶层 CurrentSpoonAgentId 无论如何都写)。 // 默认 true —— 官方那五个字段命名一致,客户端具体读哪个还没验出来,两个都填最稳。 // TimeTableSpoonInSlice bool `json:"timeTableSpoonInSlice"` // —— 兜底: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)再推。 // PoiActionId uint32 `json:"poiActionId"` // 落地后是否自动发 PoiActionId。默认 false(只在需要时开)。 PushPoiOnPlace bool `json:"pushPoiOnPlace"` } type CombatSettings struct { MaxHp float32 `json:"maxHp"` Attack float32 `json:"attack"` Defense float32 `json:"defense"` MoveSpeedMultiplier float32 `json:"moveSpeedMultiplier"` PanelDamage float32 `json:"panelDamage"` PanelDefenseDeduct float32 `json:"panelDefenseDeduct"` FightStyleTypeId uint32 `json:"fightStyleTypeId"` FightStyleId uint32 `json:"fightStyleId"` DefaultUrbanAbilities []int32 `json:"defaultUrbanAbilities"` Weapon WeaponSettings `json:"weapon"` Resource ResourceSettings `json:"resource"` Skills SkillSettings `json:"skills"` Charges []SkillChargeSettings `json:"charges"` // 敌人受击手感(T2-44b 之后新增,2026-09-23 夜)。 // 三个值全部可在 private-server.json 里改,改完只重启服务端即可,不需要重新编译。 Enemies EnemyCombatSettings `json:"enemies"` } // 敌人「受打击反馈」的三个旋钮。 // ⛑⛑ 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),两边永远同步, // 不会出现"客户端已经破防、服务端还要再攒两下"的错位。 // type EnemyCombatSettings struct { // 每次命中从架势池里扣多少。见类注释:100 = 每下破防(反馈最强),34 = 三下破防,0 = 不破防。 PoiseDrainPerHit float32 `json:"poiseDrainPerHit"` // // 敌人最大生命值(服务端权威账本的分母,也是 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/负数不会让血条消失。 // MaxHp float32 `json:"maxHp"` // // 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,粒度不够)。 // ProfileNameFallback bool `json:"profileNameFallback"` // 命中时额外推一次 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= 日志。 // PushStaggerAction bool `json:"pushStaggerAction"` // 命中时推 SyncUnitHurtEffect(68349329)。客户端自己的受击管线由它驱动。 PushHurtEffect bool `json:"pushHurtEffect"` // // 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)。 // HurtEffectCatalogLookup bool `json:"hurtEffectCatalogLookup"` // // 枪械命中的表现只升不降(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(替换是降)。 // 只升不降拿到同样的「远程密集硬直」,且不损失任何一行的作者配置。 // HurtEffectBulletOverride bool `json:"hurtEffectBulletOverride"` // // 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 扣)。 // RelayFirmHurtAsPoise bool `json:"relayFirmHurtAsPoise"` // // 敌对单位 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 生成生效,市民 / 友方召唤物路径不变。 // PushHackableState bool `json:"pushHackableState"` // // 敌对单位 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 的旧行为。 // PushFeiSuoSpawnFlag bool `json:"pushFeiSuoSpawnFlag"` // // 敌对单位 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(向旧行为倒)。 // SpawnCombatMode string `json:"spawnCombatMode"` // // 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 显式打开。 // SpawnKeepWeapon bool `json:"spawnKeepWeapon"` // // 敌对单位 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 → // 填进本项 → 重启 → 刷敌人,看手上是否出现那把武器。 // ⛑ 副作用待观察:地上那把可能被"拿走"(实体挂到单位手上)。 // SpawnSourceWeaponId uint64 `json:"spawnSourceWeaponId"` // // 上面那项的自动模式:spawn 时取本次会话最近一次掉落的武器实体 // (WorldEntryState.WeaponDrops 里编号最大的那条),覆盖手填的 // SpawnSourceWeaponId。 // ⛑⛑ 为什么必须要有它:地上的武器是客户端创建的(AskSkillDestructibleCreate // 是 C2S,服务端只负责分配 9e12 起的 id 并记账)。服务端一重启 WeaponDrops 就空了, // 而客户端场景里那个掉落物也大概率不存在了 ⇒ 手填一个旧 uid 去 spawn, // 实体早已消失,结果必然还是空手 —— 那样无法区分"字段没用"还是"实体早没了"。 // 自动模式保证用的是"刚刚丢下的那把",实验才可解释。 // 用法:开本项之后,一次会话内「先丢一把武器 → 立刻刷敌人」即可, // 不用再抄 uid、不用为了改 uid 重启。 // SpawnSourceWeaponAuto bool `json:"spawnSourceWeaponAuto"` // // 每次 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 开。 // ReassertMaxHpBeforeHit bool `json:"reassertMaxHpBeforeHit"` // // 死亡时推 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 对照。 // PushDeadUnitState bool `json:"pushDeadUnitState"` // // 伤害包 SyncUnitAttacked 里补全攻击者身份: // SourceTemplateId 填当前角色模板、HitIndex 填 hit.Id。 // ⛑⛑ 这是对齐,不是已验证的修复。依据只有一处事实:捐赠端 7k-v0.5 // GameRouter.Enemy.cs:441/446 两个字段都填真实值,我们两个都填 0。 // 0 是明确的"无效",填真值不可能比填 0 更差,但它是否就是伤害数字缺失的原因, // 目前没有硬证据——捐赠端日志不打印这两个字段,也没有 diff 可比对。 // 真正的第三候选是 HUD 绑定:见 EnemySpawnAttachBattleModule // (2026-09-23 的结论,怀疑它会把单位换到另一套战斗模块,HUD 绑的是原来那套)。 // 那条不用编译,改配置重启即可 A/B,建议先试那个。 // false = 保持 2026-09-26 之前的行为(两个字段都 0)。 // FillDamageSourceIdentity bool `json:"fillDamageSourceIdentity"` // // ⛑⛑⛑ 伤害包的 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 对照。 // AttackerIdFallbackToSpiritUnit bool `json:"attackerIdFallbackToSpiritUnit"` // // ⛑⛑⛑ 血条默认收起,第一次命中才显示(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),驱动的是潜行警戒条。 // ⇒ WarningStateMachine 那套改的是警戒条,动不了血条(这就是实测"血条还是一直挂着"的原因)。 // 唯一 per-unit、且服务端能写的判据是 UnitStateConfig.HideHp = 10266: // spawn 时 SyncAddUnitState(10266) 收起,第一次命中 SyncRemoveUnitState(10266) 放出。 // 先例:GameRouter.HackerSummon.cs:341(机械狗顶着敌人血条出戏,就是这么关掉的)。 // 关(false):回到"一生成血条就在那"。 // ⛑ 只对 server-spawned 的敌人生效 —— client-spawned 的本地单位是客户端自己建的, // 我们第一次知道它存在就已经是挨打的时候了,没机会提前挂 HideHp。 // ⛑ 另一个天然差异:模板若配了 ShowHPBarUnderAttack,客户端自己会在 // GameConfig.HPBarShowTime = 10 秒后收起血条;我们这条路径是"打过就一直显示"。 // HideHpBarUntilHit bool `json:"hideHpBarUntilHit"` // // 敌人警戒状态机(2026-09-26,移植捐赠端 7k-v0.5 GameRouter.EnemyWarning.cs)。 // 开:spawn 时只注册 EnemyDetectState.Idle(1) —— 客户端 HUD(血条 + 挂它上面的 // 警戒条)绑在 unitDataSet.detectionState 上,Idle 时**不显示**; // 玩家走进 WarningRadiusMeters 才升到 Fight(4),血条才出现; // 走远又退回 Idle。状态不变不发包(2 s 节流),单轮最多刷 8 个。 // 关(false):回到 2026-09-26 之前的行为 —— engage 时直接发 Fight(4), // 敌人一生成血条就在那(这正是用户说的"我们的血条默认就显示")。 // ⛑ 只对 server-spawned 的敌人(GM 生成那条路)生效。client-spawned 的本地单位 // 是"第一次命中才进账本",本来就是击中才显示,不需要这套。 // WarningStateMachine bool `json:"warningStateMachine"` // // 警戒半径(米)。玩家在这个距离内 → Fight(4),之外 → Idle(1)。 // 捐赠端是硬编码 25(EnemyFollowDistanceSquared(enemy, state) > 25f * 25f 就回落 Idle)。 // WarningRadiusMeters float32 `json:"warningRadiusMeters"` // // ⛑⛑⛑ 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(与服务端的伤害/架势/死亡链语义一致),不要再为"手感"去动它。 // BeHitType byte `json:"beHitType"` // // 给服务端生成的敌人手上塞个道具并让它正在用 —— 即 r37 那条 // SyncAgentBelongings + SyncAgentUseBelongingChanged 的组合。 // 目的是让敌人不再是攥着空气的空港口模型:usage 6 =「右手玩手机」在客户端会真的把手 // 机模型挂到骨架右手上、并叠一层上半身姿态。默认值(80003481 手机 / usage 6)与 // gameplay.hackNpc 给路人发的是同一套,只改敌人的时候不会动到城区 NPC。 // ⛑ 服务端的活儿做完这一步就结束了 —— 道具模型怎么挂到骨架上、挂件 resource 怎么流送, // 全是客户端自己的事;服务端既不能换它的姿势也不能改它的挂载点,只能决定"给不给"。 // ⛑⛑ 2026-09-25 实测:默认关。 // 开着的样子 = 敌人手里挂着手机模型,但不会玩 —— 待机没有 POI 姿态、 // 上半身不动,只是多了一个穿模风险的挂件,视觉收益为零。 // 根因:usage 只在「生活日程 / POI」那条管线里才会真的把姿态播出来 // (见 SyncAgentPlayPOIAction 与 AgentDataSetsActivityCfgId); // 敌人走的是战斗行为树,不与那条管线接通 ⇒ 光给 belongings 只等于给了个静态模型。 // 想让敌人真的"玩",要先把敌人的行为树挂上 POI/日程,不是改这个开关。 // GiveProp bool `json:"giveProp"` // 手里的道具 id(SceneitemConfig / belongings 域)。默认 80003481 = 手机。 PropSceneItemId uint32 `json:"propSceneItemId"` // // BelongingBelongingUsageConfig 的 usage id。默认 6 = 右手玩手机。 // 换成 1 = 右手正常打电话;其余多为拍照 / 喝咖啡之类,挑的时候注意 Boss 会不会因此不抬手攻击。 // PropUsageId uint32 `json:"propUsageId"` } type WeaponSettings struct { TemplateId uint32 `json:"templateId"` InstanceId uint64 `json:"instanceId"` // 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. UnlockAll bool `json:"unlockAll"` } type ResourceSettings struct { Id uint32 `json:"id"` Max float32 `json:"max"` } type SkillSettings struct { Common uint32 `json:"common"` PressCommon uint32 `json:"pressCommon"` HeavyCommon uint32 `json:"heavyCommon"` Dodge uint32 `json:"dodge"` DodgeAttack uint32 `json:"dodgeAttack"` GrappleAttack uint32 `json:"grappleAttack"` Active uint32 `json:"active"` Unique uint32 `json:"unique"` } type SkillChargeSettings struct { Skill string `json:"skill"` Current uint32 `json:"current"` Max uint32 `json:"max"` Period float32 `json:"period"` } type WebTraversalSettings struct { MaleProtagonistId uint32 `json:"maleProtagonistId"` FemaleProtagonistId uint32 `json:"femaleProtagonistId"` PersistentGrappleBuffId uint32 `json:"persistentGrappleBuffId"` GrappleRearmDelayMs int32 `json:"grappleRearmDelayMs"` SharedBuffIds []uint32 `json:"sharedBuffIds"` MaleExtraBuffIds []uint32 `json:"maleExtraBuffIds"` FemaleExtraBuffIds []uint32 `json:"femaleExtraBuffIds"` ForbidStateIds []uint32 `json:"forbidStateIds"` // 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 PushResidentBuildBuffs documents). CommonCapabilityBuffIds []uint32 `json:"commonCapabilityBuffIds"` // Extra capability buffs appended to CommonCapabilityBuffIds 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. SpiritCapabilityBuffIds map[uint32][]uint32 `json:"spiritCapabilityBuffIds"` // 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. PushResidentBuildBuffs bool `json:"pushResidentBuildBuffs"` // 给主角下发原生飞索能力门 SyncPaokuLimitChange(68974941)。 // 飞索 buff(PersistentGrappleBuffId)只解决「能不能放」,不解决 // 「C 键与白圈在不在」:后者由 AbilityLimitConfig.HasFeisuoAbility(3013) 门控, // 客户端 PlayerParkourLimitModule 收到后才会挂 State_HasFeisuoAbility(2318)。 // ⛑ 关掉这个开关即回到改动前的状态(只有 buff、C 键无反应)—— 这就是回滚方式。 PushFeisuoAbilityGate bool `json:"pushFeisuoAbilityGate"` // AbilityLimitConfig 的能力 id —— 3013 = HasFeisuoAbility // (dump.cs:1026235,用我们自己的客户端 dump 验证过,不依赖捐赠端)。 // 填 0 = 用内置默认 3013。 FeisuoAbilityLimitId uint32 `json:"feisuoAbilityLimitId"` // 回应蹲伏暗杀裁决:收到 ReportCanAssassinateEnemies(67161256) 后 // 回 SyncEnemyCanAssassinate(68218494)。 // 客户端自己会算「谁在我背后」(StealthManager 每 0.4s 一次),但授权在服务端: // 不发这一条,canAssassinate 恒 false,EnemyInteractCore 不挂交互、 // HUD 不出暗杀提示也不亮按钮。 // ⛑ 关掉即回滚到改动前(HUD 无暗杀提示)。 AnswerCrouchAssassination bool `json:"answerCrouchAssassination"` // 下发暗杀能力门 AbilityLimitConfig.UnlockAssassin(5051) // → State_UnlockAssassin(1333),随 SyncPaokuLimitChange 一起发。 // ⛑⛑ 这是暗杀 HUD 的前置门,比裁决回包更靠前:不发它,客户端 // StealthManager.CrouchAssassinationUpdate 根本不跑, // ReportCanAssassinateEnemies 一次都不会发(实机:19 次蹲伏 / 0 次上报)。 // 关掉即回滚到「蹲得了但永远没有暗杀提示」。 PushAssassinationAbilityGate bool `json:"pushAssassinationAbilityGate"` // AbilityLimitConfig.UnlockAssassin 的 id —— 5051(dump.cs:1026213)。 // 填 0 = 用内置默认 5051。 AssassinationAbilityLimitId uint32 `json:"assassinationAbilityLimitId"` // 给璎珑(Yinglong / 15021040)下发专属跑酷能力门 SyncPaokuLimitChange(68974941)。 // 门 = AbilityLimitConfig.KillingPrototype(3040),Desc「开启樱珑八步赶蝉」, // 挂的 tag 是 State_CanKilling(1306)。这就是「虐杀原形跑酷」:地面疾跑/跳跃/边缘 // (574/575/577)与爬墙(2502/2503)共用同一套动作态(ParkourState 10062「璎珑跑酷」)。 // ⛑ 关掉即回滚到改动前 —— 只剩基础爬墙大跳与空中冲刺。 PushKillingAbilityGate bool `json:"pushKillingAbilityGate"` // AbilityLimitConfig.KillingPrototype 的 id —— 3040。填 0 = 用内置默认 3040。 KillingAbilityLimitId uint32 `json:"killingAbilityLimitId"` // 唯一拿到跑酷门的角色模板 id —— 15021040 璎珑。填 0 = 用内置默认 15021040。 // ⛑ 只给这一个角色发:其余角色的 ABP 未必带 MovementModeType.Yinglong(10) 的动画数据, // 挂了 tag 也可能没表现。 KillingSpiritId uint32 `json:"killingSpiritId"` // 角色 → 专属能力门列表(AbilityLimitConfig.Id),键是角色模板 id。 // 上面三个 killing* 单值开关的通用化版本:一个角色可以同时带多张门 // (例:"15020990": [5040, 5052] = 连跳 + 大跳)。 // 优先级:字典命中就用字典,没命中才回落 killing*。 // 留空字典 = 完全保持旧行为 ⇒ 可以直接清空来回滚这一层。 SpiritAbilityGates map[uint32][]uint32 `json:"spiritAbilityGates"` // ★ 跑酷状态 → 临时能力门(键 = ParkourStateConfig.Id,值 = AbilityLimitConfig.Id 列表)。 // 与 SpiritAbilityGates(常驻门)的区别:严格跟状态 —— // 客户端上报 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]。留空字典 = 整层关闭 ⇒ 可直接清空回滚。 ParkourStateGates map[uint32][]uint32 `json:"parkourStateGates"` // parkourStateGates 的撤销策略: // // "onLeave" —— 严格跟状态:离开该 ParkourState 立刻 isAdd=false 撤回。 // "session"(默认)—— 首次触发后本次会话一直保留(切角色换单位才重置)。 // // ⛑ 为什么默认不是 onLeave:2026-10-02 实机证明 10081「八步赶蝉jump」是瞬时态(服务端日志 // add→remove 间隔只有 1 秒,甚至同一秒内来回),严格跟状态会让门几乎立刻被撤 ⇒ // 表现是「第一次触发后大跳正常,第二次就没了」。session 模式下只 add 一次、永不撤, // 语义仍是「跑酷触发之后才可以用」,且不会抖动。 ParkourStateGateRelease string `json:"parkourStateGateRelease"` // ★ 主动播种 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 系统 ⇒ 用这条主动下发替代。 // ⛑ 关掉即回到「等上报才回包」的死锁态。 PushAssassinationSeed bool `json:"pushAssassinationSeed"` // 播种半径(米):只有玩家这个距离内的敌人才会被授予 canAssassinate。 // 客户端自己的 CheckCanCrouchAss 还有更严的距离/视野中心/遮挡门,这里只做粗筛。 AssassinationSeedRadiusMeters float32 `json:"assassinationSeedRadiusMeters"` // 播种扫描间隔(毫秒)。挂在敌人 AI worker 的 100 ms tick 上,内部自己节流。 // 每个敌人每次蹲伏只播种一次(离开半径后重新进入才会再播种)⇒ 调小也不会刷包。 AssassinationSeedIntervalMs int32 `json:"assassinationSeedIntervalMs"` } type UiSettings struct { // White text rendered next to UID. This is the user-facing watermark setting. UidLabel UidLabelSettings `json:"uidLabel"` RemoveStockConfidentialLabel bool `json:"removeStockConfidentialLabel"` CutscenePanel CutscenePanelSettings `json:"cutscenePanel"` } type CutscenePanelSettings struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int32 `json:"port"` } type UidLabelSettings struct { Enabled bool `json:"enabled"` Text string `json:"text"` } type LoggingSettings struct { // Project-relative directory for console and packet logs. Directory string `json:"directory"` Console ConsoleLoggingSettings `json:"console"` Packets PacketLoggingSettings `json:"packets"` } type ConsoleLoggingSettings struct { Enabled bool `json:"enabled"` } type PacketLoggingSettings struct { Enabled bool `json:"enabled"` IncludeHex bool `json:"includeHex"` IncludeDecoded bool `json:"includeDecoded"` // Maximum packet body bytes written as hex; 0 means unlimited. MaxBodyBytes int32 `json:"maxBodyBytes"` } type PathSettings struct { // Directory containing client JSON configs such as FightSpiritConfig.json. ClientConfigs string `json:"clientConfigs"` ZoneGraphLanes string `json:"zoneGraphLanes"` // // ★★ 路口 / 红绿灯相位表(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 命中)。 // Intersections string `json:"intersections"` // // ★★ 官方 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)。 // PedSpawns string `json:"pedSpawns"` // Directory with extracted world data (Gadget.json, SceneItem.json, // Destructible.json, gadget_cells.json, gadget_placements.json). WorldData string `json:"worldData"` // Disposable generated fastpatch staging directory. RuntimeFastpatch string `json:"runtimeFastpatch"` } type PrivateServerConfigStore struct { Current *any `json:"_current"` ConfigPath *string `json:"_configPath"` ProjectRoot *string `json:"_projectRoot"` // Subscribers that want to hear about hot reloads (console/log). OnReload *any `json:"onReload"` Watcher *any `json:"_watcher"` WatchTimer *any `json:"_watchTimer"` } type PrivateServerConfigLoader struct { JsonOptions any `json:"jsonOptions"` } // ==== R142 自 v1.9 移植(v2.0 上游没有这两个设置类)==== type PartyCampSettings struct { // False = never place anything. Rollback lever, no rebuild needed. Enabled bool `json:"enabled"` // // 锚点到玩家的额外半径余量(米)。默认 0 = 只用 PartyCamp.json 里每个场地自己的 radius。 // RadiusBonus float32 `json:"radiusBonus"` // // 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. // Channel int32 `json:"channel"` // // 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. // IScale int32 `json:"iScale"` // Log the placements but push nothing. Dry-run lever for a fresh capture. LogOnly bool `json:"logOnly"` // // ★ 2026-09-30:摆放时**跳过**这些 pathId(用来去掉不想出现的场地家具)。 // 例:舞会那道「离开舞会的门」Gadgets_Party_DanceDoor(514708797) 用户明确不要 ⇒ // "skipPathIds": [514708797]。默认空 = 一件不少,与改动前完全一致。 // ⚠ 只作用于 PartyCamp 这条链,不碰市民 / 机关网格 / PropRestock 等其它刷法。 // SkipPathIds []uint32 `json:"skipPathIds"` } type MiniGameStallSettings struct { // False = 一件都不摆。回滚开关,不需重编译。 Enabled bool `json:"enabled"` // 锚点到玩家的额外半径余量(米)。默认 0 = 只用 MiniGameStalls.json 里每个点位自己的 radius。 RadiusBonus float32 `json:"radiusBonus"` // // 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 // Channel int32 `json:"channel"` // // 摊位实体 PackedGadgetInfo.iScale。必须 **0**(单位值 = scale 1.0)。 // ⛔ 100 ⇒ transform scale (0,0,1) ⇒ 零尺寸空壳:实体在、模型不在(看不见摊位)。 // ⛔ 与下面 BalloonIScale 是**同名不同尺度的两个字段**(铁律 39): // 这个走 Gadget 通道、基准 1;那个走 Destructible 通道、基准 1000。 // IScale int32 `json:"iScale"` // // ★ 刮刮乐「走近自动叫面板」(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 即可,热重载生效。 // ScratchAutoOpen bool `json:"scratchAutoOpen"` // 触发半径(米):玩家从圈外走进来推一条。 ScratchOfferRadius float32 `json:"scratchOfferRadius"` // 迟滞半径(米),须 > ScratchOfferRadius,否则边界上会连推。 ScratchRearmRadius float32 `json:"scratchRearmRadius"` // // 气球 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)仍是同名不同尺度,别混。 // BalloonIScale int32 `json:"balloonIScale"` // // ★★★★★ R109 —— **诊断用扫描**:非空时,**按列**给这一局的 12 个气球上不同的 IScale, // 于是**一次重启就能同时试 4 个候选值**,用户只要报告「哪一列看得见」即可定值。 // · 下标 = 列号(0..3,从玩家左手边数起),元素 = 该列用的 IScale。 // · 空(默认)= 关闭扫描,全部用 BalloonIScale(正常玩法必须关)。 // 为什么值得:每次改 IScale 都要重启服务端,4 个候选值 = 4 次重启;扫描把它压成 1 次。 // 读数:气球墙是 4 列 × 3 行,列序 = 生成序(ordinal/rows)。 // BalloonIScaleSweep []int32 `json:"balloonIScaleSweep"` // // ★★★★★ 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 = 上一局打干净了,正常)。 // BalloonRemoveStaleIds bool `json:"balloonRemoveStaleIds"` // // ★★★★★ 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 那一套。 // BalloonWallAnchor string `json:"balloonWallAnchor"` // // 锚点定姿后沿**该锚点**的朝向再前移多少米(默认 0 = 就摆在摊位机关本身处)。 // 仅在 BalloonWallAnchorSweep 为空时生效。 // BalloonWallForward float32 `json:"balloonWallForward"` // // ★★★★★ R110 —— **锚点按列扫描**(诊断用)。每个元素形如 "stall:-1.5" / "player:6", // 下标 = 列号(0..3,从玩家左手边起),冒号后 = 沿该锚点朝向的前移米数。 // 空(默认)= 全部用 BalloonWallAnchor + BalloonWallForward。 // 为什么值得:改锚点要重启服务端。按列给不同锚点 ⇒ **一次重启同时试多个摆位**, // 用户只要报告「第几列看得见」。含一个 player:6 列即为旧行为对照组。 // BalloonWallAnchorSweep []string `json:"balloonWallAnchorSweep"` // 在摊位表里按名字子串找气球摊(默认 DaQiQiu,也覆盖 Gadget_DaQiQiu_Position)。 BalloonWallStallMatch string `json:"balloonWallStallMatch"` // 锚点查找的告警半径(m)。超过它仍会用最近的命中点,只在日志里标出来。 BalloonWallStallSearchRadius float32 `json:"balloonWallStallSearchRadius"` // ===================== R111:摆位参数全部配置驱动 ===================== // // 为什么这么改:`PrivateServerConfigStore` 在 `Drmk.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(≈ 摊位正面/帘幕平面)。 // BalloonWallStallForward float32 `json:"balloonWallStallForward"` // 墙的横向列距(m)。R111 从 1.2 收到 0.9,避免两端插进摊位立柱。 BalloonWallLateralStep float32 `json:"balloonWallLateralStep"` // 墙最下一排的高度(m,相对摊位原点)。R111 从 1.2 改 1.25(贴着柜台面之上)。 BalloonWallBaseHeight float32 `json:"balloonWallBaseHeight"` // 排距(m)。R111 从 0.7 收到 0.5,避免顶排插进摊位屋梁。 BalloonWallHeightStep float32 `json:"balloonWallHeightStep"` // 排数(默认 3)。列数 = balloonCount / rows。 BalloonWallRows int32 `json:"balloonWallRows"` // // ★★★★★ 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 高的散布 ⇒ 这就是「球不在官方位置」的直接原因。 // BalloonWallSpots []string `json:"balloonWallSpots"` // // ★★★★★ R116 —— **官方球位整体外移(米)**,方向 = 球位质心 → 玩家(水平)。 // 用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」「球变成一面墙,都凑在一起」。 // 官方那 16 个点是**贴在板上**的,而板正面就是摊位的装饰招牌/帘布(`p_map01_level_sister_tile_board` // 与 `Lianzi`,探针实测与球位同在 y 169.4–170.4)。把整组点沿板法线(≈朝玩家)外移,球就浮在 // 帘布前面。 // 热重载:改本值 ⇒ 退出气球区再进一次即生效。0 = 官方原始坐标。 // BalloonWallSpotPull float32 `json:"balloonWallSpotPull"` // // ★★★★★ 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 = 官方原始间距。 // BalloonWallSpotSpread float32 `json:"balloonWallSpotSpread"` // // ★★★★★ R127(2026-09-28)—— **每局重新随机排列 16 个球位**(打散"整齐方阵")。 // 用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。 // 官方 BalloonBoard_Square2 的 16 个点是 4×4 网格 —— 即使 BalloonWallSpotSpread // 把它们拉开,**排列仍然是有规律的**(同一行/列一眼看穿)。 // 开启时:每局 spawn 在**同一片区域**内重新随机撒点 —— // 区域由 BalloonWallSpots 现有点集的「主轴(2D PCA)× Y 范围」定义, // 深度(沿主轴法线)保持不变,所以球始终「上下 + 左右铺开、不往前后排」; // 并用 BalloonWallMinGap 做拒绝采样,保证球不互相穿插。 // ⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61:JSON 里多出来的键没有对应属性会被丢弃)。 // BalloonWallRandomize bool `json:"balloonWallRandomize"` // // ★ R127 —— 球位重排时的**最小间距(米)**,球包围盒约 0.185 × 0.357 × 0.185 ⇒ 默认 0.42。 // 只在 BalloonWallRandomize 开启时使用。 // R140:三段禁带切开后可用区域变小(≈3.13 m²),16 球 ⇒ 0.42 会导致拒绝采样失败, // 故配置下调到 0.34(球包围盒高 0.357,视觉上仍互不穿插)。 // BalloonWallMinGap float32 `json:"balloonWallMinGap"` // // ★★★★★ R140(2026-09-28)—— **球位禁带**(球不许落在「三块帘子之间的木隔断」上)。 // 用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。 // 探针实测:三块帘子 Lianzi×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m) // ⇒ 接缝在 local x ≈ −1.614 与 +0.003;而 R139 写进 // BalloonWallSpots 的四个「列」正好是**帘子边界** // (local x = −3.232 / −1.615 / +0.002 / +1.619)⇒ 第 2、3 列整列压在接缝上。 // 每个元素形如 "x,z"(禁带**中心**的世界坐标,取帘子深度处 z=1.315 的接缝点); // 落点按主轴(2D PCA)投影成 t 区间后,落在 // 「中心 ± BalloonWallForbiddenHalfWidth」内的采样一律拒绝。 // ⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61)。 // BalloonWallForbiddenSpans []string `json:"balloonWallForbiddenSpans"` // // ★ R140 —— 每个禁带的**半宽(米,沿主轴投影)**,默认 0.27。 // 实测木隔断约 0.22 m 宽 + 球半径 0.093 m ⇒ 0.27 已含余量。 // BalloonWallForbiddenHalfWidth float32 `json:"balloonWallForbiddenHalfWidth"` // // ★★★★★ R116 —— **一局时长(秒)**,对应官方 PoiGameConfig.Balloon_CountDown = 40。 // 用户实测/提问:「打完了不应该是结算游戏吗」「这种小游戏肯定会限制只能打几枪吧, // 不然不限制谁都能打完,没有挑战性」。官方玩法的约束就是这 40 秒:到点服务端主动结束 // (final score + GameOver + Dispose,走 FinishBalloonRoundAsync), // 客户端按总分排 endType(>50 GoodEnd / >25 NormalEnd,`BalloonGameStore.OnGameEnd` // 会在 1.5 s 后自己关面板)。 // 0 或负数 = 关闭限时(不推荐:会回到「谁都能慢慢打完」)。 // BalloonRoundSeconds int32 `json:"balloonRoundSeconds"` // // ★ R116 —— 玩具枪弹匣发数(默认 40,与 BalloonWire.BalloonWeaponMagazineAmmo 一致)。 // 面板的弹药控件读的就是这个值(`BalloonGameStore.RefreshBalloonWeaponAmmo` // → `weapon.MagazineAmmo`)。⚠ 枪本身的 cfg(80300134 打中有赏_玩具枪)是 // `Durability = -1`(无限)⇒ **硬性「只能打 N 枪」由客户端配表决定,服务端改不动**, // 这里只能改面板上显示的那个数。 // BalloonWeaponAmmo int32 `json:"balloonWeaponAmmo"` // // ★ R111 诊断 —— **按列换 (cfg,pathId)**。每个元素形如 "80001468:895168017", // 下标 = 列号(列数 > 元素数时,最后一列的值套用到其余列)。空 = 用 // BalloonWire.BalloonSceneItemCfgIds / PathIds 的 5 色派对气球。 // 用途:一次进出区同时对比两种美术 —— 例如左两列发派对气球 // (prop_xsb_balloon001*,体积 0.614、teardrop 形),右两列发龙栖村原生 // prop_lqc_balloon002*(体积 0.0048、与官方玩法气球同档)。 // 用户只要报告「哪边像摊位上的球 / 哪边打得爆」。 // BalloonCfgSweep []string `json:"balloonCfgSweep"` // // ★★★ R89 — 气球 PathId 取哪一组。 // false(默认)= 用对照包的五色 PathId(BalloonSceneItemPathIds,逐色不同) // true = 全部退回 R88o 的 670915924(plastic_balloon) 单一值 // 为什么留这个开关:对照包自己标注那 5 个 id 是「待复核值」;一旦真机出现 // 「有碰撞无模型」,改这里即可一键 A/B,不必重编译。 // BalloonUseSharedPathId bool `json:"balloonUseSharedPathId"` // // ★★★ R89 — 本轮流行的气球数(对照包 = 12:4 列 × 3 行)。 // R88o 用的 20(= Balloon_RightNum 10 × 2)来自 ClientConfig,是**棋盘上限**而非本局数量。 // BalloonCount int32 `json:"balloonCount"` // // ★★★ R89 — 进气球区时给玩家发气枪(80300134)。 // 关掉即回到 R88o 行为(无枪 ⇒ 面板 `attempt to index local 'weapon'` 起不来)。 // BalloonGrantWeapon bool `json:"balloonGrantWeapon"` // // ★★★ R89 — 气球实例 id 是否用对照包的固定 48 位区间(0xBA11_0000_0000 + 序号)。 // false(默认)= 固定区间(≤48 位,经 Lua double 回传不丢精度) // true = 退回 R88o 的 920000000001 + batch*count 递增批次 // BalloonUseLegacyInstanceIds bool `json:"balloonUseLegacyInstanceIds"` // 只记录不推送。用于首次试跑新坐标。 LogOnly bool `json:"logOnly"` } // ═══════════════════════════════════════════════════════════════════════════════════════ // 赛车检查点门(2026-10-03 移植自捐赠分支 DRMK-PS_09_HyNB-赛车) // 由服务端摆放 26 个官方检查点(成对镜像 = 52 个 gadget),过点即撤。 // 完整字段语义见类内注释;全部是「不用重编译」的热旋钮。 // ═══════════════════════════════════════════════════════════════════════════════════════ type RacingGateSettings struct { // False = 一道门都不摆。回滚开关,不用重编译。 Enabled bool `json:"enabled"` // 坐标表路径(相对服务端根)。客户端 R247 探针写这个文件。 TablePath string `json:"tablePath"` // // 放置通道。0 = SyncGadgetAdd(68843189) —— 健身器材实测「实体建得出、小游戏可玩」的那条。 // 3 = SyncGadgetAddList(68925813)(AnantaGymLoader 用的那条)。 // Channel int32 `json:"channel"` // // PackedGadgetInfo.iScale。⛔ **必须 0** —— // 0 = 客户端 1:1 正常渲染(loader 与 400 条 blob 记录用的值); // 100(GM 面板默认)= 出来是 transform scale (0,0,1) 的**零缩放空壳、没有 prefab** // (这正是保龄球球瓶「有物理有音效但看不见」的根因,见 GymEquipmentSettings.IScale)。 // IScale int32 `json:"iScale"` // // 拿不到坐标表时的「验证信标」:在玩家前方 BeaconAhead 米处摆若干道门。 // 用来把「通道对不对」和「坐标对不对」两个变量解耦 —— 看得见信标 ⇒ 通道没问题,只差坐标。 // BeaconEnabled bool `json:"beaconEnabled"` // 信标距离(米),会按 1× / 2× / 3× 摆三道。 BeaconAhead float32 `json:"beaconAhead"` // 只打日志、不推包。用来干跑一份新采集。 LogOnly bool `json:"logOnly"` // // ★ R254 —— 「成对镜像」。true = 每个检查点摆 2 个 gadget(同一坐标、yaw 相差 180°)。 // 为什么必须成对(R254 实机截图,用户「门显示了但是只显示了右边的」+ // 「门应该是在整个道路的左右两边」):Jobrace_gongmen 的一个实例只是 // 半扇拱门 —— 一根立柱从路面升起、顶端向内弯折,另一半是空的。 // 官方一扇门 = 两个实例:一个 yaw=θ、一个 yaw=θ+180°,两半合成完整拱门, // 拱门中心才落在路中线上(这也正是用户看到的「位置偏右」的来源:只有右半,自然偏)。 // 关掉它 = 回到 R253 行为(每点一个、只有半扇)。回滚开关,不用重编译。 // PairMirror bool `json:"pairMirror"` // // 成对之后的**额外左右位移**(米,沿路径法向;正 = +normal 那侧)。 // 默认 0:两半同坐标镜像 ⇒ 拱门正好骑在路中线上。 // 只有当官方拱门确实比路面宽/窄、需要整体挪动时才调它。 // PairOffset float32 `json:"pairOffset"` // // ★★ R254 —— **强制覆盖表里的 pathId**。0 = 不覆盖(用表里的值)。 // 为什么必须有它:racing-gates.json 是**客户端 R247 采集探针写的**, // 而那个探针每进一次赛车就**整表重写**,写进去的是**客户端那套 gadget id** // (Racing_challenge01_CheckPoint = 884611825 / …CheckPointEnd = 271608686)。 // 那两件资源**从未随包** ⇒ 摆出来隐形。 // R253 我把表手工改成 64493331(Jobrace_gongmen),用户跑完一局后**表又被探针覆盖回去了** // —— 于是下一局必然退回「隐形门」。所以 pathId 不能交给客户端探针决定,必须由服务端钉死。 // 当前值:64493331 = Jobrace_gongmen(竞速·拱门)。 // PathIdOverride int32 `json:"pathIdOverride"` // // ★★★ R255 —— **使用服务端内置的「官方赛道检查点表」**,忽略客户端采集的 // TablePath 里的坐标。默认 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 图数量未知,取保守中值(自认是猜的)。 // 更糟的是那张采集表还是**客户端探针每局重写**的(见 PathIdOverride), // 所以坐标也不能交给它决定 —— 服务端必须自带权威表。 // 关掉它 = 回到「读 TablePath / 信标」的 R254 行为。回滚开关,不用重编译。 // UseOfficialTrack bool `json:"useOfficialTrack"` // // 门的朝向修正(度),叠加在内置官方表的 yaw 上。默认 0。 // ★ R291 起它是**真旋钮**(此前是空操作):ExpandGatePairs 的 ±8 m 侧移用 // **不含本值的基准 yaw**,只有朝向用含它的值 ⇒ 改它 = 每一半**绕自己的根部原地转**, // 位置完全不动。180 = 把两半的「身体」从朝路外翻成**朝路中央**(合扇)。 // ★ 改它**不用重编译、不用重启服务端**(private-server.json 热重载 + 每局开赛重读), // 下一局即生效。实机看到两臂**朝路外张开**就改成另一个值(0 ↔ 180)。 // YawOffset float32 `json:"yawOffset"` // // ★★★★★ 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。 // YawMode string `json:"yawMode"` // // ★★★★★ 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 的「一局只摆不清」。**改它不用重编译、不用重启服务端**,下一局生效。 // RemoveOnPass bool `json:"removeOnPass"` // // 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 的「上报即撤」)。**改它不用重编译、不用重启**,下一局生效。 // GateMinLifetimeMs int32 `json:"gateMinLifetimeMs"` // // R312(2026-10-03)· **前 N 个门延迟撤** —— 用户指定规则原话: // 「前两个门设置为**到第三个门之后再消失**,第三个门后的所有门都维持现状」。 // 含义:`checkpointIndex < GateKeepFirstNCount` 的那几个点,上报通过时**不撤门**, // 只登记挂起;等玩家通过第 `GateKeepFirstNCount` 个点(默认 2 ⇒ 即第三个门 `cp02`)时, // 把挂起的和当前这一对**一起撤掉**。这是把「生存期」锚到**玩家的真实进度**上, // 而不是靠猜 prefab 冷加载要几秒(R311 的 12 s 宽限期实测没消除用户的现象)。 // `0` = 关闭本条规则,完全回到 R311 的「纯宽限期」行为。默认 2。 // GateKeepFirstNCount int32 `json:"gateKeepFirstNCount"` }