Files
Ananta-ps/internal/config/private_server_config.go

6375 lines
404 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// <para>
// list sends an empty List&lt;ulong&gt; (7 bytes on the wire: 5-byte
// notification header + FF 01). That is the only shape that works on a cold
// login.
// </para>
// <para>
// 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.
// </para>
// <para>
// pid restores the legacy shape for bisecting against an older capture;
// none suppresses the notify entirely.
// </para>
//
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_&lt;IndoorId&gt;)—— 服务端按 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_&lt;N&gt; 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 秒后再摆就全部正常出现(用户实机确认)。
// <para>判据 = 客户端 SyncChangeIndoor 的上报时刻(WorldEntryState.ClientIndoorReportMs):
// 失败那次上报与摆放同一秒,成功那次相隔 12 秒。0 = 关掉这个守卫(回到旧行为)。</para>
// ⛑ 别改用 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,以当前所在格为中心)时才会被显式卸载。
// 以前它是空头支票 —— 见下面那段保留的历史注释。
// <para>历史(⛔ 2026-09-28 实测):当时这个值只被 Validate() 的范围校验读一次,
// 两个网格通道的 removeGridInfo 恒为空,改它不会有任何效果(硬规则 212)。
// donor 文档 §1/§3/§6 给了完整语义 —— 吃这个值的叫 **卸载边界**:
// added = window − prevLoaded;keep = prevLoaded 中 ≤ unloadRadius 的那些;
// removed = prevLoaded − keep;next = window ∪ keep。</para>
// ⛑ 回滚真卸载:realUnload = false(回到「只增不减」的旧行为)。
//
UnloadRadiusCells int32 `json:"unloadRadiusCells"`
//
// ★ 2026-10-01 起**真的生效**:true = 走出窗口但仍留在 unloadRadiusCells
// 边界内的格子不下发卸载,也不重新加载(donor §3「保留环」)。
// false = 出窗口即卸载(等价于 unload = radius,物件来回重加载的风险更高)。
// <para>历史(⛔ 2026-09-28 实测):这个键当年是**纯空操作** —— 唯一引用在
// GameRouter.GadgetGrid.cs 的
// if (cfg.RetainVisitedCells &amp;&amp; !cfg.LoadAllSectors) budget = Math.Max(budget, 1);
// 而 budget 本来就是 Math.Max(1, MaxCellsPerPush),那行恒等于原值(已删)。</para>
//
RetainVisitedCells bool `json:"retainVisitedCells"`
//
// ★ donor《content-streaming-implementation》§3 —— 真正下发 removeGridInfo。
// <para>关掉它 = 回到 2026-09-28 之前的行为:格子只增不减,走过的永不卸载
// (客户端驻留对象数一路涨,2026-09-27 §10.9.3 内存项第 3 条)。</para>
// ⛑ 排查顺序:怀疑卸载把身边物件删掉了 ⇒ 先改 realUnloadLogOnly=true
// (只打「本来要卸哪些格子」,一条报文都不发),确认卸载集合是窗外的环形再放开。
//
RealUnload bool `json:"realUnload"`
// 只记录将要卸载的格子,不下发报文、也不从已声明集合里摘除(完全等价于旧行为)。见 RealUnload。
RealUnloadLogOnly bool `json:"realUnloadLogOnly"`
//
// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳(两个窗口不相交)时
// 清空已声明集合并按「首次进窗口」重铺。判据 = 格子 Chebyshev 距离 &gt; 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 &gt; 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 &gt; 本值,以当前所在格为中心)的格子会被显式卸载。
// ⛔ 历史:见 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 时只清服务端集合、不发卸载报文。
// <para>病因(已用 console-20261003-235851.log 坐实):客户端
// SyncGadgetGridAOIAddDecrease 只在「格子从不在 AOI 到在 AOI」这一次跃变上实例化物件;
// 首推时客户端 GadgetBlob 还没常驻 ⇒ SyncGadgetGridAOIAddByBlob 静默 return,
// 之后 heartbeatMs=5000 的整窗重声明补不回来(4.5 分钟 ~50 次重推,洞不愈合)。
// 玩家「跑远再跑回来」之所以好,唯一作用就是走出 unloadRadiusCells 触发一次真·卸载,
// 回来时格子变成新格 ⇒ 重新实例化。这里在服务端自己造一次同样的跃变,玩家不用跑。</para>
// <para>⛑ 只在每个会话做一次(SettleDone);远跳 reseed 已经做过时也算完成。</para>
//
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 &gt; 0 and not GenericPlaceholderIconId).
// <para>
// 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.)
// </para>
//
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 为空时才播,
// 所以已经在官方服拿过渔具的存档不会被覆盖。
// <para>为什么值得做:捐赠文档 §8 说 GearDict 在客户端 lua / C# 都无人读取、
// 「往里塞数据零可见效果」—— 那条结论说的是 字典内容不被消费,
// 不等于 PlayerFishingInfo 这个结构体空着无所谓。客户端初始化链
// (FishingGameFishMgr.container / FishingGameEffectMgr.objPool /
// FishingGameRod.animator)实测三连 nil,本文档作者的环境里播种是开着的。
// ⇒ 这是一次性把"服务端少给了一块"这个变量消掉的开关,关掉即可回到原状。</para>
//
FishingSeedAllGear bool `json:"fishingSeedAllGear"`
//
// ★ 第 50 轮(自 donor v2.1 移植,2026-09-29)—— **室内层判定门**(`gameplay.indoorVisibilityGate`)。
// <para>作用:摆放派对场地家具(PartyCamp)时,若某件家具落在
// 「VisibleOutside = false」的室内占地里、而玩家不在其中 ⇒ **本件不摆**。
// 标本:舞会场地(danceOnly)的两件常驻机关(Gadgets_Party_DanceDoor 514708797 /
// Gadgets_Party_LightControl 1341214683)在 IndoorConfig#150 "Spiko Hotel" 的占地里,
// 我们却把它们摆到露天 y≈80 ⇒ 现场「道具在半空、地板不在」。</para>
// <para>⛑ 本门**只**作用于 PartyCamp 这一条链,不碰市民 / 机关网格 / 场景物等其它刷法
// (donor 实测:露营点 / nova-spawn / 保龄球馆 / 公寓车库入口都不在任何室内矩形内)。</para>
// <para>回滚:gameplay.indoorVisibilityGate = false。</para>
//
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;本值 &lt;=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 <i>and</i> 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 的驻留行为通道。取值:
// <list type="bullet">
// <item>"waypoint"(**默认**)—— 服务端路点巡游(SyncLogicAgentCommand +
// TaskWayPointMove(tag 39),`Ambient.cs:24-26` 称其为
// 「**唯一能产生走路步态的通道**」,街上行人在走的就是它)。</item>
// <item>"wander" —— SyncLogicAgentEnter + SyncAgentForceGo
// + SyncMoveWandering 并周期续期。**2026-09-26 实测:对 Aether agent 无效**,
// 保留只为对照(见下方 ★★ 记录)。</item>
// <item>"none"(**第 47 轮起默认**)—— 停走档:只发
// SyncLogicAgentEnter + SyncAgentForceGo(把行为树起起来),
// 不发任何 locomotion 指令 ⇒ 角色停在各自那个半径 2.5 m 的圆周站位上、
// 保持朝向圆心(SpawnPartyNpcsAsync 的 angle + π),并保持 POI 坐姿。
// ⚠ 第 47 轮把 Enter/ForceGo 提到了模式判断**之前** —— 在这之前选 "none"
// 会把这两步一起丢掉(行为树没起来 ⇒ 连 POI 坐姿都保不住)。</item>
// </list>
// ★★ 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)的落点锚用哪张表**。取值:
// <list type="bullet">
// <item>"danceSpot"(默认)—— 用官方舞池点
// PartyDanceSpotConfig.DanceSpot + 朝向 DanceRotation
// (13030003 新启舞会 = (2605.33, 80, 1269.74),DanceRotation = -84.1142578)。
// 读表实现见 ClientData\Client4229938\PartyDanceSpotCatalog4229938.cs。</item>
// <item>"partySpot" —— 退回 PartyConfig.PartySpot(派对场地锚;
// 13030003 = (2574.24, 80.135, 1271),与 PartyCamp.json 的
// danceOnly.pos 是同一个点、也是舞会大门 Gadgets_Party_DanceDoor
// (2569.63) 所在处)。</item>
// </list>
// ⚠ 两者不是同一个点(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)。
// <para>
// 落点位于「VisibleOutside = false」的室内占地里时,照常落位,
// 但把官方字段 AgentSyncClientInfo.IndoorId(+ indoorList)声明给客户端
// ⇒ 客户端自己按室内物处理:室外不渲染、走进那间室内才可见。
// </para>
// <para>
// 为什么需要它:第 50 轮的 IndoorVisibilityGate 是「拦下不落位」,
// 判据只比 x/z 投影 —— 用户当时 x = 2613.6、房间 Spiko Hotel 东边界 2610
// ⇒ 只差 3.6 m,往西一步就翻成「放行」,人又飞回空中。
// 声明 IndoorId 才是官方语义:舞会的 NPC 本来就属于那间室内,只是我们以前
// 当露天物摆(IndoorId = 0)。三处构造点在本次改动前全部填空/0。
// </para>
// <para>
// 与 IndoorVisibilityGate 的关系:本项为 true 时,
// 那道门自动失效(不再拦截,改为声明);把本项设 false 即回到第 50 轮行为
// (门按 IndoorVisibilityGate 生效)。
// </para>
// <para>
// ⚠ 声明只作用于 SendCivilianAsync 这条静态 NPC 通道(含派对/市民/路人),
// 不碰机关(SyncGadgetAdd)与场地家具(PartyCamp)—— 后者仍按
// IndoorVisibilityGate 过滤,本轮刻意不动(避免误伤已收尾的功能)。
// </para>
//
NpcIndoorDeclaration bool `json:"npcIndoorDeclaration"`
//
// ★ 第 47 轮:派对 NPC 圆周的**半径**(米)。默认 2.0
// (第 34 轮起的原硬编码值 = 2.5;第 47 轮用户现场定为 **2**)。
// 想围得更紧就继续调小;太小会互相穿模(实测 &lt; 1.2 开始互相挤)。
//
PartyNpcSpawnRadius float32 `json:"partyNpcSpawnRadius"`
//
// ★ 第 53 轮:派对 NPC 的**服装来源**。默认 "dresscode"。
// <para>
// 支持的值:
// <list type="bullet">
// <item>"dresscode"(默认)—— 按官方配表给每个角色穿**它自己的**套装:
// ① 客户端真的送来了 Rents[spiritId](只有舞会那条路送得出来)就照它穿;
// ② 否则查 FashionSpiritDressCodeConfig(SpiritId + TagList ∋ PartyConfig.SuitTag,
// 取 RecommendPriority 最小者 = 原皮),拿不到再放宽 tag;
// ③ 都查不到 ⇒ 不指定 ⇒ 客户端回落 11190001。</item>
// <item>"off" —— 完全不干预,保持第 53 轮之前的行为(SuitId = 0 ⇒ 所有角色
// 都落 11190001「Graffiti Set」= Bansy 的涂鸦套装)。留作对照 / 回滚。</item>
// </list>
// </para>
// <para>
// 为什么默认开:用户现场「派对功能可以自订邀请角色的外观服装,但是选择后开始露营显示的还是
// 默认服装」。实测已证**露营这条路上客户端根本送不出玩家选的服装**
// (PartyConfig#13030000.FashionsCanRent 是空数组 ⇒ 换装面板的租借列表恒空 ⇒
// 三条抓包报文 30 字节全同、都是 {Rents={}, UseDefaultFashion=true})
// ⇒ 服务端**只能**自己按配表决定,否则永远是同一套默认衣服。
// </para>
// <para>
// ⚠ 数据表在 ClientData/4229938/Configs/ 下:FashionSpiritDressCodeConfig.json、
// FashionRentConfig.json、FashionSuitConfig.json(后者只用于日志里的人名/套装名)。
// 缺表不会崩:PartyFashionCatalog4229938 全程 try/catch,空表 ⇒ 回落 SuitId = 0。
// </para>
//
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 新增)。
// <para>症状:手机 app 买房成功、所有权也落地了,但走进房子是毛坯房,
// 一件家具都没有(用户实测:旧物仓公寓 / 素光小筑)。</para>
// <para>根因:客户端进房子后会发 AskLoadHouseData(63109556) 问
// 「这栋房子长什么样」,我们一直回的是权威兜底表那份 57 字节全空的
// HouseBuildingClientData(FloorBuildInfoDict 是空表)⇒ 客户端认为
// 这房子没摆任何家具。日志铁证:logs/packets-20260930-010341.log 里
// AskLoadHouseData 4 次(houseId = 11200003 / 11200005 / 11200004 / 11200006),
// 每次都是 body=4b → RETURN error=0 body=57b。</para>
// <para>打开后 handler 会按
// houseId → HouseConfig.BuildId → HouseBuildConfig.FurnitureTemplateIds →
// HouseFurnitureTemplateConfig 把房子自带的那套家具生成进
// FloorBuildInfoDict。回滚 = false(回到 57 字节空房)。</para>
//
HouseFurniture HouseFurnitureSettings `json:"houseFurniture"`
//
// ★ 车库 / 停车 —— gameplay.houseGarage(2026-09-30 新增)。
// <para>症状:走到自家车位按 F「调取载具」能开选车面板(纯客户端 UI),
// 选完车后按 F「驶离车库」毫无反应;车也不出现在车位;车库大门不开。</para>
// <para>根因:车库这一族 9 条 RPC 在服务端一直是空 body 兜底,且两个关键 S2C
// SyncHouseParking(64277819) / SyncHouseParkingEntityBinding(64060781)
// 服务端从来不发。客户端 GarageManager.LeaveGarage 第一步就读
// currentParkingInfo.ParkingVehicleUid(只能由 SyncHouseParkingEntityBinding 填)
// ⇒ 为空 ⇒ FatalLog + return ⇒ 连 AskTeleportFromHouseGarage 都发不出去。</para>
// <para>打开后:AskHouseParking / AskHouseMoveParkingSpace 会记账并回推
// SyncHouseParking(客户端据此播进库演出 + 自己发 AskCreateHouseParkingVehicles);
// 后者让服务端 SpawnDirectAsync 出一辆车并回推
// SyncHouseParkingEntityBinding(填 ParkingVehicleUid);
// AskTeleportFromHouseGarage 开门 + 结束出库流程。回滚 = false
// (回到 9 条空 body 兜底,与改动前逐字节一致)。</para>
//
HouseGarage HouseGarageSettings `json:"houseGarage"`
}
//
// 一个「实机实测的电梯落点」覆盖项(gameplay.mapEntrance.jiguanLandingOverrides[])。
// <h3>为什么需要它</h3>
// 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 的绝对坐标。
// <para>为什么需要:客户端出库事务(AskTeleport config=7 beforeResName=loading_garage_out*)
// 自己算的落点是 HouseConfig.GarageOutWaypoint(Spoon 航点 id,服务端没有
// Spoon 位置表);而且 ExitTimeline 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查
// 都不做,afterPosition 直接留全零 ⇒ 过场结束会被甩到世界原点。
// 服务端唯一现成的绝对坐标落点就是 VisitorExit,实测同层且就在房门外。
// 如果实测落点不合适,在这里按 houseId 覆盖,改完即时生效。</para>
// <code>
// "houseGarage": {
// "exitLandingOverrides": [
// { "houseId": 11200006, "x": 1104.8, "y": 8.53, "z": 2391.8, "facing": 0 }
// ]
// }
// </code>
//
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)。
// <para>数据源两张官方表(ClientData/4229938/Configs/,2026-09-30 从
// E:\ConfigDump\ConfigDump_v3\ 原样拷入):</para>
// <code>
// HouseBuildConfig.json 4 行 buildId → IndoorId / Floors / FurnitureTemplateIds
// HouseFurnitureTemplateConfig.json 322 行 家具模板树(叶子带绝对世界坐标)
// </code>
// <para>4 套房各自的模板:绿屿公馆 14001(147 件)/ 旧物仓公寓 10013(36 件)/
// 观澜北岸 14000(86 件)/ 素光小筑 13000(44 件)。</para>
//
type HouseFurnitureSettings struct {
// 总开关。false = 回到 57 字节空房(逐字节等于本次改动前)。
Enabled bool `json:"enabled"`
//
// 家具机关(可互动家具)开关 —— 2026-10-01 新增,默认开。
// <para>false = AskCreateHouseFurnitureGadgets(63581446) 回到恒空映射
// ([0xFF,0x01],逐字节等于本次改动前)⇒ 椅子坐不了、衣柜开不了、电视点不亮。</para>
// <para>背景:家具模型由 FloorBuildInfoDict 下发(已上线),但客户端还会再问一次
// 服务端要「机关实例 id」(HouseGadgetManager.CreateGadgetsForFurnitures)。
// 判据:请求的 id 个数逐套精确等于该房「HouseFurnitureConfig.PathId != 0」的家具数
// (绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1)。详见
// GameRouter.HouseFurnitureGadget.cs。</para>
//
Gadgets bool `json:"gadgets"`
//
// 要不要真的用 SyncGadgetAdd(68843189) 把机关实体摆进世界 —— 默认开。
// <para>这是实测分流开关,不是功能开关。客户端行为有两种可能:</para>
// <list type="number">
// <item>true(默认,H1):机关实体得服务端造,客户端
// OnGadgetSlotCompLoaded 的 gGadgetManager:GetEntitySearchByInstanceId(gadgetId)
// 才找得到它。</item>
// <item>false(H2):机关 GameObject 本来就在家具 prefab 里
// (RegisterFurnitureLuaSlotReplace 用的就是 houseFurniture.gadgetGameObject),
// 服务端只需发一个唯一非 0 id,客户端 TrySetGadgetInstanceId 自己点亮。</item>
// </list>
// <para>判据:H1 若把椅子 / 灯渲染成两遍(模型重了一层)就是猜错了 ⇒ 改成 false。
// 机关 prefab 取自 HouseFurnitureConfig.PathId,位置取模板节点的世界坐标 +
// GadgetOffset,uniqueId = placedId(与返回的 gadgetInstanceId 同源)。</para>
// <para>⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。</para>
//
SpawnGadgetEntities bool `json:"spawnGadgetEntities"`
//
// 摆机关实体走哪条推送通道 —— 默认 add。
// <para>为什么会有这个开关:2026-10-01 上机实测,add(SyncGadgetAdd)
// 在服务端日志上一切正常(44 条 [GADGET] add,id/pathId/坐标全对),
// 但客户端 Player-prev1.log 里 GadgetInstanceRoot 下只有 blob 自带的
// NewLiftTest_…,没有任何家具机关实体 ⇒ 家具模型不出现(非编辑态客户端会
// 隐藏自己那份 luaSlotReplaceGo,专等服务端实体补位)。</para>
// <para>这正是 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 走的那条。</para>
// <para>取值:</para>
// <list type="bullet">
// <item>add(默认)—— 逐件 SyncGadgetAdd(68843189)。</item>
// <item>addList —— 一次 SyncGadgetAddList(68925813),N 件一个包。</item>
// <item>both —— 两条都发(诊断用)。判据:若椅子/灯渲染成两遍,
// 说明两条通道都生效、真正的问题是别的;若只出现一遍,那出现的那条就是唯一可用通道。</item>
// </list>
// <para>⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。</para>
//
Channel string `json:"channel"`
//
// ★★ 2026-10-01:每次网格 AOI 推送之后把家具机关实体补发一遍 —— 默认开。
// <para>病因:SyncGadgetGridAOIDecrease 的 addGridInfo 在客户端是
// 「当前 AOI 快照」语义(GameRouter.GadgetGrid.cs 的注释早有记录)。世界摆件撤掉后
// 客户端能从自己的 blob 再铺出来;家具机关是纯服务端造的、blob 里没有 ⇒ 一撤就永久消失。</para>
// <para>实测(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 就是它。
// ⇒ 症状就是用户说的「沙发椅子已经生成了的没法交互」。</para>
// <para>false = 回到「建出来 20 秒后被网格快照撤掉」的行为。
// ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译。</para>
//
RespawnAfterGridPush bool `json:"respawnAfterGridPush"`
//
// FloorBuildInfoDict 的键取什么。
// <para>已定案(2026-09-30):键 = 楼层号,1 基,地面层 = 1,取值 1..Floors。
// 默认 floor 就是正解,正常情况下不用动。</para>
// <para>证据(全部来自客户端自己的文件):</para>
// <list type="bullet">
// <item>HouseBuildConfig.FloorsName = [{floor:1,__RAW__name:"1F"}];
// 素光小筑 [{1,"1F"},{2,"2F"}],长度恰等于 Floors。</item>
// <item>BuildBound / CameraBound / CameraTopViewPoint 都用 floor: 1,2。</item>
// <item>HomeBuildPanelStore_Floor.lua:SelectOption(initFloor - 1)、
// houseCamera.floor = index + 1 ⇒ 游戏侧 1 基,只有 UI 选项 0 基;
// GetFloorByPlayerPosition 兜底 return 1。</item>
// <item>HouseUtils.lua 的 floor = floor or 0 只是参数缺失兜底,0 是非法值
// (会走 "无法找到楼层[0]的家具信息")。</item>
// <item>⚠️ LuaGmUtils.lua:166 的
// for floorKey, indoorBuildInfo in pairs(buildData.FloorBuildInfoDict)
// 会对每个键都加载一遍家具(去重表建在键循环体内)⇒ 多写键 = 家具被创建多遍。
// 所以不能「把所有候选键都写上碰运气」,默认必须是单一正确键。</item>
// </list>
// <para>取值:</para>
// <list type="bullet">
// <item>floor(默认,正解)—— 挂 1..Floors。</item>
// <item>indoor —— 只挂 IndoorId。诊断用:GmSyncHouseIndoorBuildInfo
// 那条 GM 链路确实按 indoorId 组织,若登录链路被证伪可切过去试。</item>
// <item>all —— 挂 {1..Floors} ∪ {IndoorId}。诊断用,一定会重复创建家具,
// 只用于确认「客户端到底取哪个键」,别长期开。</item>
// </list>
// <para>⛑ 这个值每次请求现读 ⇒ 改 config/private-server.json 后
// 不用重启,出房子再进去一次即可换一种试。</para>
//
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 反查 ⇒ 填起点 = 原地。
// <list type="bullet">
// <item>"dest"(默认)= 落点那一层的门 id。</item>
// <item>"source" = 起点门 id。留作 A/B / 回滚,已知症状是「原地不动」。</item>
// <item>"none" = 填 0。2026-09-30 四修加的 A/B:
// 客户端 CommonFuncTeleportFlowInfo.OnSyncTeleport() → ReParse_AfterData() 在
// MapEntranceId != 0 时会去 MapentranceConfig / Spoon 航点反查落点,
// 而本地没有 Spoon 图(见 JiguanElevatorCatalog4229938 的说明)⇒ 反查可能落空。
// 填 0 就是逼客户端只用 Position。改配置即时生效,用来判断「反查落空」这个猜想。</item>
// </list>
//
JiguanEntranceIdMode string `json:"jiguanEntranceIdMode"`
//
// 电梯落点兜底(2026-09-30 四修)。
// <h3>为什么还需要它</h3>
// 前三修已经把服务端这一侧做到了「逐字节正确」: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。
// 例(把绿屿高楼层的落点从「门标记」挪到玩家实测能站稳的那一格):
// <code>
// "mapEntrance": {
// "jiguanLandingOverrides": [
// { "destEntranceId": 343, "x": 2277.67, "y": 160.73, "z": 1411.35 }
// ]
// }
// </code>
// 只改 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 &lt;= 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):
// <list type="bullet">
// <item>TalentTreeConfig.GameplayId != 0 → GameplayTalentInfos[gameplayId]</item>
// <item>JobClassId != 0 → that career's SpiritJob.TalentInfo (only visible to
// characters owning the career, because the dictionary is built per AvailableJobs row)</item>
// <item>both 0 → the character's SpiritInfo.TalentInfo, narrowed by SpiritId
// (Kingpin lists the two protagonists; a few rows pin one spirit each)</item>
// </list>
// 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"`
//
// 两条移动命令之间的间隔(毫秒)。必须 &gt;= 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"`
// 行人离玩家超过多少米就移除。必须 &gt; 布点半径,否则刚生成就被回收。
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"`
// 车离玩家超过多少米就回收。必须 &gt; 生成半径。
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 = 严格取接缝最小的后继(**实测最优**:&gt;12m 的衔接仅 2.55%,而绝对阈值 8m 是 15%)。
// 调大 = 路线更多变,但更容易出现「车瞬移一段」。
//
TrafficMaxLaneGapMeters float32 `json:"trafficMaxLaneGapMeters"`
//
// 换道接缝的**绝对硬上限**(米)。超过就直接判定"开到尽头"并回收该车,不再硬跳。
// ★ 为什么单独有这个键:TrafficMaxLaneGapMeters 只是"相对容差",
// 但当一条车道的所有后继都被「方向反折」过滤掉时,挑选逻辑会退回全局最小接缝 ——
// 那条可能在几百米外。实测日志 worstGap=394.1m;车道数据本身
// p99 接缝 347m、max 2965m,&gt;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 &gt; 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"`
// 提前多少秒重发游荡(要 &lt; 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/&lt;pid&gt;.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)。
// <para>原生设计确实有这一半:SummonVehicleResult 回执带 TaskToken,客户端
// SummonVehicleManager 拿它去 RegisterTaskAIStartedEvent(taskToken),
// 命中才走 OnSummonShowStart(TaskBase) 演出(补丁和工具/dump.cs:429056-429086);
// 对称的「车开走回收」在 SummonVehicleRule.vehicleRecycleTime/Range(dump.cs:427762)。
// ⇒ 官方就是要「车有来有走」。我们以前(和捐赠端 v0.6 一样)直接把车放在玩家右手 5.5m,
// 那条 token 从没被用过。</para>
// <para>⛑ 改的是生成位置 + 补一个到达任务,管线全部是已实机验证过的那几条:
// SyncAetherAIChangeVehicleControl(2)(出租车血泪:不切回 Raid 就是「轮胎转、车原地」)、
// SyncVehicleAutonomousDrivingState、SyncAssignVehicleAITask + Cruise。</para>
// <para>回滚:enabled=false ⇒ 逐字节回到「刷在身边 5.5m」的旧行为,不需要重编译。</para>
//
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&lt;VehicleStopParameters&gt;)
// + 推 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:
// <code>
// 1 DFStopForCars 遇车停
// 2 DFStopForPeds 遇人停
// 8 DFSteerAroundStationaryCars 绕开停着的车
// 16 DFSteerAroundPeds 绕开行人
// 32 DFSteerAroundObjects 绕开物件
// 128 DFStopAtLights 等红灯
// 16384 DFAdjustCruiseSpeedBasedOnRoadSpeed 按道路限速收速
// </code>
// ⛑ 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),服务端只做三件事:接住骑乘请求、接住失败上报、
// 把坐骑生成成「能被骑」而不是「会打人」。
// <para>马桶鸵鸟 = 标准 Agent(NpcCatalog 5 条同名,GeneralModelId=86963029,
// EnemyClassType=1,faction 18000300「通用怪物」),规范 id
// AgentConfig.ToiletOstrich = 42046401;它已在 AgentSummonCatalog.json 里,
// 并被置顶到面板「生成敌人」下拉框第一位 ⇒ **不需要新的生成通道、也不需要新的面板卡**。</para>
//
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 实测后改过来)。
// <para>★ 实测证据(日志 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 就没有动画也不会动)。</para>
// <para>⛑ 顺序固定为「状态包 → ack」,别反过来(见 GameRouter.VehicleNav.cs 的
// §43.1 包序教训)。下马同理:回收实体前先推一条 hasRideOn=false,
// 否则就是「马桶没了、人还坐在空中」。</para>
//
PushRideState bool `json:"pushRideState"`
//
// 骑乘时是否额外推 SyncAddUnitState:坐骑 345 = BeRiddenS(behavior 10345,
// 进入「被骑」移动状态)+ 骑手 346;下马时用 SyncRemoveUnitState 撤掉。
// 默认 true。
// <para>★ 这是「驯服动画」和「能骑着跑」的真正开关。出处是 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."</para>
// <para>⛑ 2026-09-30 19:2x 实测:只推 SyncRideAgent 不开这条 ⇒
// 「QTE 正常,但没有驯服动画,成功也骑不起来」,坐骑从来没进 BeRiddenS。</para>
// <para>客户端侧佐证(说明 345 语义可信):LX6.Fight.UnitStateMgr.BeRiddenSStateChange、
// MuGenStates.Logic.Agent.CheckBeRiddenState 都真实存在。</para>
//
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 改过来)。
// <para>对齐 v0.5-2 捐赠端 GameRouter.ToiletQte.cs:44:
// "retaining the test mount for retry instead of destroying it" ——
// 失败只是下马,马桶留在原地可以再来一次。</para>
// <para>⛑ 即使这里是 false,解除骑乘(SyncRideAgent + 撤 345/346)照发;
// 这个开关只管「要不要把实体从世界里摘掉」。
// ⛑⛑ 摘除前仍有一道硬门槛:只摘本服生成的 uid(见
// GameRouter.ToiletQte.IsServerSpawnedUid)—— 2026-09-30 曾误删过场景自带的
// uid=19000(hp=1500 的真实敌人)。</para>
//
RetireOnFlee bool `json:"retireOnFlee"`
//
// 从面板生成马桶鸵鸟时是否走「坐骑」而不是「敌人」:engage=false(不登记进敌人账本、
// 不下令开战)+ forceEnemyRelation=false(不把玩家和它显式标成敌对)。
// 默认 true:它的用途是被骑 + 玩 QTE,带攻击 AI 的话玩家会先跟它打起来。
// <para>⚠️ 它仍然在 AgentConfig 的 EnemyClassType=1 桶里,所以面板必须按
// 「生成敌人」调(hostile=true),camp 也仍是 4 —— 这条只影响「要不要打」。</para>
// 回滚:改 false ⇒ 与普通敌人逐字节同路。
//
PassiveSpawn bool `json:"passiveSpawn"`
//
// 骑上马桶后是否下发原生坐骑操控桥(加速 / 跳跃)—— 2026-10-02 新增,
// 自 7k-v0.6 捐赠端 GameRouter.ToiletControls.cs 逐字搬运,实现见
// GameRouter.ToiletControls.cs。默认 true。
// <para>★ 它做的事:把客户端 GameplayMountPanelStore 的
// OnRushBtnPress/Release(加速)与 OnJumpBtnPress/Release(跳跃)等原生 HUD 回调
// 直接转成 3C 事件发给已确认的挂载目标,绕过骑手自身的战斗 / 跑酷输入闸门
// (骑着马桶时那批按键本来会被判定成人物动作)。</para>
// <para>⛑⛑ 预期管理:这只保证事件送达。动画、速度、跳跃物理仍然全是客户端原生的,
// 坐骑动画图里没有的过渡(比如马桶鸵鸟没有冲刺状态)送到了也不会动。
// 捐赠端原话:"event delivery is NOT proof that an incomplete client animal graph
// implements every transition."</para>
// <para>⛑ 客户端文件一个都不改,走的是已有 GATE 通道(SyncDoToLuaMaster 45437825)。
// 回滚:这里改 false 即可(不再下发 Lua,骑乘状态机与 QTE 完全不受影响)。</para>
//
NativeControls bool `json:"nativeControls"`
}
//
// 出租车(2026-09-30 新增,GameRouter.Taxi.cs)。
// <para>★ 计价公式沿用捐赠端 TaxiFare4229938:
// Ceiling(BaseFare + Max(0, distance - FreeMeters) * FarePerMeter)。</para>
// <para>⛑ 目的地是配置 uint(TaxiNavigationConfig.json 的 Id,共 18 条),
// 不是车辆 uid;接单时会校验目的地的 RaidId 必须等于当前场景,
// 否则会把玩家往别的 raid 送。</para>
// <para>⛑ 服务端只做四件事:认单 → 派 Cruise → 判到达 → 收车费。
// 选目的地的 UI、上车提示、到达演出全是客户端原生 TaxiSystemManager 的事。</para>
//
//
// 里希牢房(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 &lt;= 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):
// <code>
// 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
// </code>
// 两轮服务端修复全部无效,反证如下:
// ① 提前 / 重复发 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):
// <code>
// 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 这一刀)
// </code>
// 同一场里能动的那一次(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):
// <code>
// 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(镜头落回主相机,时间线被打断)
// </code>
// ⇒ 时间线全长约 14 秒,我们在第 3.5 秒拆掉主角单位,第 12.5 秒它抛空引用。
// 这与 r14 那批 197 条 loading_switch_moving_seamless 空引用是同一件事的两个相位。
// 因此:任何 Leave / 重建都不能发生在演出期间,一律挪到发 PreSwitch 之前。
// 关掉 = 回到旧顺序(演出中途拆单位)。
//
//
// ⛔⛔⛔ 2026-09-25 03:2x 实机证伪,默认改回 false。别再打开。
// 换皮后用户回报「角色可以正常移动了,但是模型都没有了 / 只有一张主角的脸」。
// 判别性实验(同一台机器、同一个 build):
// <code>
// GmSwitchSpiritHere(就地,allowShow=false,走 3 参 CharacterUnitProjection)
// ⇒ 模型正常
// AskSwitchSpirit(演出,allowShow=true,走 4 参 CharacterUnitProjection + 不 Leave)
// ⇒ 只有一张脸
// </code>
// ⇒ 根因不是演出/时间线,是换皮本身。两条路的唯一实质差别:
// <code>
// 就地路:unitId = roster.UnitId(= unitIdBase + index),
// Enter + Managed + CharacterUnitProjection(3参) + PosFacing 完整四连发。
// 换皮路:unitId 恒 = Profile.InitialUnitId(100000000000),
// 只改 templateId,全程不 Leave(怕丢 IsMe)。
// </code>
// 不 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 用户实机):
// <code>
// heroUnitReuse=true (换皮,不 Leave) → 模型只剩一张脸,但**能走**
// heroUnitReuse=false (独立 unitId,完整四连发)→ 模型正常,但**走不动**
// </code>
// ⇒ 两条路唯一的差别是 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):
// <code>
// 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 通道。
// </code>
// 这正是用户描述的官方行为:「切换后原角色留在大世界里做自己的事(用跑步机那种)」。
// 官方就是拿 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。
// 链路(官方原生的三张表,零自造):
// <code>
// FightSpiritConfig.NpcCultivationRelatedId -> NpcCultivationConfig.AgentTag
// (服务端副本:NpcCultivationCatalog.json 的 AgentTag 列)
// AgentTag + 现在时刻 -> NpcDailySchedule.json 的 5 段之一 -> ActivityId
// (LifeScheduleTimetableConfig 的四个 HHMM 边界 × AgentDataSetsActivityConfig)
// ActivityId -> NpcActivityCatalog.json -> MapPosition / ActivityPerformance / Raid
// </code>
// 接线点 = 出场包 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):
// <code>
// SyncPlayerCurrentSpirit(pid, templateId, spiritId, isAgentSwitch)
// isAgentSwitch=true → SwitchTeleport_CloseUpPrepare() + CloseUpSync() // CloseUpAgentFlow
// isAgentSwitch=false → CheckSwitchTeleport(spiritId) // LiftOffOneShot
// </code>
// 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"`
// 离开多少米外撤掉(必须 &gt; 进入值,否则边界上每 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 行)有中文名 ⇒ 只能**按名字匹配**推出来。
// 表里那一份是脚本按「核心词完全相等 &gt; 包含 &gt; 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:
// <list type="bullet">
// <item>SyncFavorNpcSpoonAgentId(64003213):
// (uint agentTag, int spoonAgentId, UXVector3 Position, FavorNpcBusyInfo busyInfo) ——
// 客户端 LifeScheduleNpcModule.AttachNet(net, activityId, spoonAgentId,
// hasAmbientLoopPose, ambientLoopPos, ambientLoopFacing) 就吃这一套;</item>
// <item>SyncAgentTagTimeTableInfo(64400736):(uint agentTag, NpcTimeTableInfo);</item>
// <item>SyncFavorNpcTimeTableInfos(64219939):
// Dictionary&lt;uint, NpcTimeTableInfo&gt;。</item>
// </list>
// 而 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。
// <para>⛑ 两个开关默认全 false ⇒ 不改任何现有行为;只有显式打开才生效。
// 这也意味着「改坏了」的回滚就是改回 false(不用动代码)。</para>
// <para>★ 为什么需要它:地块 → 分组的匹配只到「类别」这一层
// (CivilianDistricts.Categories 11 种 ↔ CivilianGroups.Category 27 种),
// 组内每条是固定的 NpcFormworkId + PoiActionId ⇒ 同一个类别里既有「站着不动」的,
// 也有「带根位移会走开」「需要拿道具」的。全字段表能按动作属性再筛一遍。</para>
//
type CivilianPoiSettings struct {
//
// 剔除 ApplyRootMotion = true 的 POI 动作(全表 370 / 3978 条 ≈ 9.3%)。
// <para>★ 打开的理由:带根位移的动作会让 NPC 真的走出站位 —— 表现为
// 「铺完过一会儿人就挪走了 / 几个市民叠在一起」。这是 POI 表里唯一能直接判定
// 「这个动作会让人离开点位」的字段。</para>
// <para>⛑ 副作用:候选池会变小。实测每个类别剩 13~46 人,剔掉约 9% 不会抽空;
// 真抽空时 BuildItemPool 有「退回未过滤」的兜底。</para>
//
ExcludeRootMotion bool `json:"excludeRootMotion"`
//
// 剔除需要拿道具的 POI 动作(ActionTag4 含「道具」,全表 763 / 3978 条 ≈ 19%)。
// <para>★ 打开的理由:这类动作(端杯子、用电脑、拿话筒)要客户端把道具挂到手上,
// 服务端生成时不带道具 ⇒ 容易出现「空手比划」。</para>
//
ExcludeProps bool `json:"excludeProps"`
}
//
// 官方「过街等待区 + 槽位」站位源(paths.pedSpawns)的使用设置。
// <para>★ 这份数据是什么:客户端 MassAI.MassCrowd.CrowdWaitAreaData 里那一套
// 「路口等待区 + 槽位 + 人行横道」的官方坐标,从 ZoneGraph dump 出来的。
// 每个等待区挂 3~5 个槽位,槽位自带朝向与 urban_diversity_config_id。</para>
// <para>⛑⛑ 它不是服务端能驱动的东西:整套是客户端原生 ECS(Lua grep 零命中),
// 客户端自己的 crowd 散人本来就在用。我们只是把自己的 NPC 放到同一批坐标上,
// 好看上去像「这撮人也在等灯」。</para>
// <para>⛑ 三个开关默认的行为:总开关 false ⇒ 完全不读这张表,现有逻辑一行不动。</para>
//
type CivilianWaitAreaSettings struct {
//
// 启用官方等待区站位作为额外的点位来源。默认 false。
// 打开后,街区铺设会先从等待区取点(不足再退回原有的生态区点位)。
//
Enabled bool `json:"enabled"`
//
// 同一个等待区最多放几个人。默认 2(与捐赠端 PedSpawnTable.NearSpread 同值)。
// <para>★ 为什么不能塞满:官方一个等待区只有 3~5 个槽位,全占满就成了
// 「路口一角戳着一排人」;官方画面是「四角各站两三个」。MaxPerArea 就是那个纪律。</para>
//
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 第一行就是
// <code>if table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag]) then return end</code>
// ⇒ 客户端没有这个 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 &amp;&amp; !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):
// <code>
// 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
// </code>
// 也就是说:血条跟 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 的撤销策略:
// <list type="bullet">
// <item>"onLeave" —— 严格跟状态:离开该 ParkourState 立刻 isAdd=false 撤回。</item>
// <item>"session"(默认)—— 首次触发后本次会话一直保留(切角色换单位才重置)。</item>
// </list>
// ⛑ 为什么默认不是 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"`
// 迟滞半径(米),须 &gt; 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)
// <para>病因(2026-09-28 07:56 真机实测,用户原话「如果我在第一轮没有把气球打完,
// 那么那些气球并不会被销毁,所以在第二轮的时候还是会出问题」):</para>
// <list type="number">
// <item>R1(07:56:08)spawn 16 颗,玩家只打爆 14 颗,剩 2 颗(id 段 …001D/001E);</item>
// <item>R1 局末 07:56:48 Timeout(40s) → 服务端 UnprotectDestructibles 撤保护,
// 07:56:50 的心跳推格 bytes 从 3514 掉回 1642(= 整格重置,不再带 AddInfos);</item>
// <item>但那 2 颗球在客户端并没有消失:R2(07:56:59 起)里 07:57:15 / 07:57:22
// 客户端仍然为 …001D / …001E 发来破坏报文 ⇒ 服务端只能打
// [BALLOON] ignore break(账本已换新段,不认识它们)。</item>
// </list>
// <para>⇒ 残留球与**新一局的球落在同一个球位**上(同一面 16 个 balloonWallSpots),
// 子弹先命中残留球的碰撞体 ⇒ 报的是旧 id ⇒ 服务端忽略 ⇒
// 新球打不掉、也不加分,正是用户说的「第二轮还是会出问题」。</para>
// <para>修法(两条正交,本开关同时控制):</para>
// <list type="number">
// <item>spawn 前把 SceneItemGrid.Protected 里所有 0xBA11 段的**历史** id 摘掉
// (否则 5 s 心跳会一直把它们塞回 AddInfos 保活);</item>
// <item>本次 AOI 报文的 aoiInfo.RemoveIds 填这批旧 id —— 这是
// DestructibleGridAOIIncrease.removeIds 字段的本职(与 addUniqueIds 成对;
// 见 GameRouter.GameGround.cs:655 的通道说明),客户端据此删除对应 hold。</item>
// </list>
// <para>关掉它 = 回到 R121 行为(残留跨局存活),用于 A/B 对照或排障。</para>
// <para>判活日志:[BALLOON] purge stale n=&lt;k&gt; ids=[…](k=0 = 上一局打干净了,正常)。</para>
//
BalloonRemoveStaleIds bool `json:"balloonRemoveStaleIds"`
//
// ★★★★★ R110 —— 气球墙的**锚点来源**。取值 "stall" / "player"。
// <para>"player"(R89~R109 的旧行为)= 玩家上报位姿 + 沿其朝向前方 6 m。</para>
// <para>"stall"(R110 起默认)= **官方摊位机关自己的位姿**(取自
// ClientData/4229938/Configs/MiniGameStalls.json,逐条解自关卡块)。</para>
// <para>★ 为什么改(R110 三维几何实证,龙栖村气球摊):</para>
// <list type="number">
// <item>官方摊位 Gadget_DaQiQiu 在 (-4750.50,168.60,-2827.60) yaw=315.0;</item>
// <item>玩家上报位姿 (-4749.00,168.50,-2830.30) yaw=313.3 ⇒ 摊位在玩家正前方 2.97 m;</item>
// <item>旧算法「玩家前方 6 m」⇒ 气球墙**中心**落在 (-4753.36,-2826.18),
// 比摊位机关**再往前 3.19 m** ⇒ 整面墙落在摊位/背景板**之后**,
// 且只从地面 1.2 m 起 ⇒ 被摊位结构完全遮住 = 「什么都看不见」。</item>
// </list>
// <para>⇒ 服务端载荷 / 客户端注入 / 美术资源 / hold / transform 全部正确、物件确实存在,
// 只是**几何上被挡住**。这与用户长期现象「有碰撞有音效坐标对,就是看不见」一致。</para>
// ⛔ 不用客户端 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。
// <para>为什么值得:改锚点要重启服务端。按列给不同锚点 ⇒ **一次重启同时试多个摆位**,
// 用户只要报告「第几列看得见」。含一个 player:6 列即为旧行为对照组。</para>
//
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…)。
// <para>取值来源(**不是推算**):R115 运行时探针倒出摊位 prefab 子树
// Gadget_DaQiQiu_Position_9500000000000/ItemViewNode/QiQiuBan/…,
// 其中 BalloonBoard_Square2(active=true,16 个 BalloonPointXY 子节点)
// 就是官方那面气球墙 —— 节点世界坐标即官方球位。</para>
// <para>与「几何算法」的关系:本数组非空且全部可解析 ⇒ **几何算法(forward/right/lateral/
// baseHeight/heightStep)整段停用**,位置一律取本表。空 ⇒ 退回几何算法(R110/R111 行为)。</para>
// <para>实测形状: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 高的散布 ⇒ 这就是「球不在官方位置」的直接原因。</para>
//
BalloonWallSpots []string `json:"balloonWallSpots"`
//
// ★★★★★ R116 —— **官方球位整体外移(米)**,方向 = 球位质心 → 玩家(水平)。
// <para>用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」「球变成一面墙,都凑在一起」。
// 官方那 16 个点是**贴在板上**的,而板正面就是摊位的装饰招牌/帘布(`p_map01_level_sister_tile_board`
// 与 `Lianzi`,探针实测与球位同在 y 169.4–170.4)。把整组点沿板法线(≈朝玩家)外移,球就浮在
// 帘布前面。</para>
// <para>热重载:改本值 ⇒ 退出气球区再进一次即生效。0 = 官方原始坐标。</para>
//
BalloonWallSpotPull float32 `json:"balloonWallSpotPull"`
//
// ★★★★★ R118(2026-09-27)—— **官方 16 个球位的「间距倍数」**(1.0 = 官方原样)。
// <para>用户实测:「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。
// 官方 `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`)⇒ 上下几乎贴住。</para>
// <para>算法:以 16 点的**质心**为轴,按本倍率等比放大 x/y/z(只"拉开",不改中心)。</para>
// <para>热重载:改本值 ⇒ 退出气球区再进一次即生效。1.0 = 官方原始间距。</para>
//
BalloonWallSpotSpread float32 `json:"balloonWallSpotSpread"`
//
// ★★★★★ R127(2026-09-28)—— **每局重新随机排列 16 个球位**(打散"整齐方阵")。
// <para>用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。
// 官方 BalloonBoard_Square2 的 16 个点是 4×4 网格 —— 即使 BalloonWallSpotSpread
// 把它们拉开,**排列仍然是有规律的**(同一行/列一眼看穿)。</para>
// <para>开启时:每局 spawn 在**同一片区域**内重新随机撒点 ——
// 区域由 BalloonWallSpots 现有点集的「主轴(2D PCA)× Y 范围」定义,
// 深度(沿主轴法线)保持不变,所以球始终「上下 + 左右铺开、不往前后排」;
// 并用 BalloonWallMinGap 做拒绝采样,保证球不互相穿插。</para>
// <para>⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61:JSON 里多出来的键没有对应属性会被丢弃)。</para>
//
BalloonWallRandomize bool `json:"balloonWallRandomize"`
//
// ★ R127 —— 球位重排时的**最小间距(米)**,球包围盒约 0.185 × 0.357 × 0.185 ⇒ 默认 0.42。
// 只在 BalloonWallRandomize 开启时使用。
// <para>R140:三段禁带切开后可用区域变小(≈3.13 m²),16 球 ⇒ 0.42 会导致拒绝采样失败,
// 故配置下调到 0.34(球包围盒高 0.357,视觉上仍互不穿插)。</para>
//
BalloonWallMinGap float32 `json:"balloonWallMinGap"`
//
// ★★★★★ R140(2026-09-28)—— **球位禁带**(球不许落在「三块帘子之间的木隔断」上)。
// <para>用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。
// 探针实测:三块帘子 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 列整列压在接缝上。</para>
// <para>每个元素形如 "x,z"(禁带**中心**的世界坐标,取帘子深度处 z=1.315 的接缝点);
// 落点按主轴(2D PCA)投影成 t 区间后,落在
// 「中心 ± BalloonWallForbiddenHalfWidth」内的采样一律拒绝。</para>
// <para>⚠ 新增键 ⇒ 必须重编 + 重启服务端(铁律 61)。</para>
//
BalloonWallForbiddenSpans []string `json:"balloonWallForbiddenSpans"`
//
// ★ R140 —— 每个禁带的**半宽(米,沿主轴投影)**,默认 0.27。
// 实测木隔断约 0.22 m 宽 + 球半径 0.093 m ⇒ 0.27 已含余量。
//
BalloonWallForbiddenHalfWidth float32 `json:"balloonWallForbiddenHalfWidth"`
//
// ★★★★★ R116 —— **一局时长(秒)**,对应官方 PoiGameConfig.Balloon_CountDown = 40。
// <para>用户实测/提问:「打完了不应该是结算游戏吗」「这种小游戏肯定会限制只能打几枪吧,
// 不然不限制谁都能打完,没有挑战性」。官方玩法的约束就是这 40 秒:到点服务端主动结束
// (final score + GameOver + Dispose,走 FinishBalloonRoundAsync),
// 客户端按总分排 endType(&gt;50 GoodEnd / &gt;25 NormalEnd,`BalloonGameStore.OnGameEnd`
// 会在 1.5 s 后自己关面板)。</para>
// <para>0 或负数 = 关闭限时(不推荐:会回到「谁都能慢慢打完」)。</para>
//
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",
// 下标 = 列号(列数 &gt; 元素数时,最后一列的值套用到其余列)。空 = 用
// BalloonWire.BalloonSceneItemCfgIds / PathIds 的 5 色派对气球。
// <para>用途:一次进出区同时对比两种美术 —— 例如左两列发派对气球
// (prop_xsb_balloon001*,体积 0.614、teardrop 形),右两列发龙栖村原生
// prop_lqc_balloon002*(体积 0.0048、与官方玩法气球同档)。
// 用户只要报告「哪边像摊位上的球 / 哪边打得爆」。</para>
//
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 里:
// <code>
// Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab
// {"trackIndex":0,"pointCount":259,"isClosed":false,
// "points":[{"x":..,"y":..,"z":..,"checkpointIndex":k,"distanceToNextCheckpoint":..}, ...]}
// </code>
// 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"。
// <list type="bullet">
// <item>"road"(默认)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点
// 折线里含该点那一段的方向。门面法向沿路 ⇒ 门横跨路面,
// 且 ±8 m 成对侧移变成跨路并排(两半合扇)。用户 2026-10-02 定:
// 「检查点可以不用朝向下一个点,就和道路平行就行了」。</item>
// <item>"next" = R290 遗留的**「指向下一个检查点」弦向**(atan2(Δx,Δz)),
// 只在 A/B 对照或回滚时用。⚠ 直道上两者等价,**弯道上差极大** ——
// 26 点里 9 个偏 &gt; 20°,cp07 偏 **114.8°**(那里会把门拧成「顺路躺」、
// 两半串成一前一后)。</item>
// </list>
// 写错的值按 "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) —— 一对门**被上报通过后**至少要活满这个时长才允许撤。
// <para>为什么需要:用户「不是所有门都没有了啊,还是前两个」——
// 只有前两个检查点的拱门看不到。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 抵消掉了;从第三个起那一对活过了冷启动窗口,所以看得见。</para>
// <para>做法:过点上报时若距摆门不足本时长 ⇒ **不撤**,交给一个后台任务到点补撤
// (⛔ 不能在回执路径里 await Task.Delay:AskSetTaskCounterValue 的回执会被客户端判超时)。</para>
// <para>0 = 关(回到 R294 的「上报即撤」)。**改它不用重编译、不用重启**,下一局生效。</para>
//
GateMinLifetimeMs int32 `json:"gateMinLifetimeMs"`
//
// R312(2026-10-03)· **前 N 个门延迟撤** —— 用户指定规则原话:
// 「前两个门设置为**到第三个门之后再消失**,第三个门后的所有门都维持现状」。
// <para>含义:`checkpointIndex &lt; GateKeepFirstNCount` 的那几个点,上报通过时**不撤门**,
// 只登记挂起;等玩家通过第 `GateKeepFirstNCount` 个点(默认 2 ⇒ 即第三个门 `cp02`)时,
// 把挂起的和当前这一对**一起撤掉**。这是把「生存期」锚到**玩家的真实进度**上,
// 而不是靠猜 prefab 冷加载要几秒(R311 的 12 s 宽限期实测没消除用户的现象)。</para>
// <para>`0` = 关闭本条规则,完全回到 R311 的「纯宽限期」行为。默认 2。</para>
//
GateKeepFirstNCount int32 `json:"gateKeepFirstNCount"`
}