6375 lines
404 KiB
Go
6375 lines
404 KiB
Go
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<ulong> (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_<IndoorId>)—— 服务端按 IndoorConfig 的最近条目
|
||
// 推出玩家所在室内,然后推 SyncLoadIndoorSector(68095220) / SyncIndoorSectorControl(68971505)。
|
||
// 默认关闭:线格式是静态推导出来的,先 logOnly 验一轮再开。
|
||
//
|
||
IndoorSectors IndoorSectorSettings `json:"indoorSectors"`
|
||
//
|
||
// 派对场地家具(露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车)。
|
||
// 与健身房器材同因同解:这批 Gadgets_Party_* 只活在客户端关卡 blob 里,
|
||
// grid-AOI 下发不驱动 GadgetBlob ⇒ 必须服务端按官方场地坐标显式摆一次。
|
||
// 见 GameRouter.PartyCamp.cs。回滚:world.content.partyCamp.enabled = false。
|
||
//
|
||
PartyCamp PartyCampSettings `json:"partyCamp"`
|
||
//
|
||
// 龙栖村(raid 23300999)互动摊位机关 + 打气球玩法。
|
||
// 见 GameRouter.MiniGameStall.cs / GameRouter.Balloon.cs。
|
||
// 回滚:world.content.miniGameStalls.enabled = false。
|
||
//
|
||
MiniGameStalls MiniGameStallSettings `json:"miniGameStalls"`
|
||
}
|
||
|
||
//
|
||
// Indoor sector streaming (2026-09-27). The client's sector_high_indoor_<N> roots are
|
||
// pure scene-subtree visibility switches — lights, mirrors, LOD decoration groups. They are a
|
||
// fifth channel, unrelated to the gadget / scene-item grids, and until now nothing on the
|
||
// server pushed them, which is one reason indoor official layouts never appeared.
|
||
//
|
||
type IndoorSectorSettings struct {
|
||
// False = never push. Rollback lever, no rebuild needed.
|
||
Enabled bool `json:"enabled"`
|
||
// True = only log what would be pushed (safe way to validate the id derivation).
|
||
LogOnly bool `json:"logOnly"`
|
||
//
|
||
// 进入判定半径(米)。玩家离某个 IndoorConfig 条目比这更近就算「在室内」。
|
||
// 新启 54 个有坐标条目的最近邻距离中位数 ≈ 100 m、最小 33 m ⇒ 默认 60 m 是折中。
|
||
//
|
||
RadiusMeters float32 `json:"radiusMeters"`
|
||
//
|
||
// 离开判定半径(米)。0 = 用「进入半径 + 15」的迟滞,避免在门口反复 Load/Unload。
|
||
//
|
||
ExitRadiusMeters float32 `json:"exitRadiusMeters"`
|
||
//
|
||
// 是否附带推 SyncIndoorSectorControl(68971505)(显式 true/false 开关)。
|
||
// 注入端的 sectorstate 是两条一起喂的(SyncIndoorSectorControl + 引擎层
|
||
// SetIndoorSectorState),所以默认开。
|
||
//
|
||
PushControl bool `json:"pushControl"`
|
||
//
|
||
// 状态没变时的重声明间隔(毫秒)。0 = 只在状态变化时推一次。
|
||
// 与 gadget / sceneitem 网格同症状:不持续声明,客户端会把窗口丢掉。
|
||
//
|
||
HeartbeatMs int32 `json:"heartbeatMs"`
|
||
//
|
||
// 除扇区 Id(176000xx)外,是否也把裸 IndoorId 推一遍。
|
||
// ★ 2026-09-27 20:1x 实机 A/B 已定案:默认关。
|
||
// 同一轮里推 9 个扇区 Id + 1 个裸 IndoorId,客户端日志:
|
||
// SetIndoorSectorState SectorControlId:0, indoorSectorStates:(17600101,True)…(17600109,True),(76,True)
|
||
// + config not found for id 76 —— 只有裸 IndoorId 查不到,9 个扇区 Id 全被接受。
|
||
// ⇒ 这个字典的 key 就是 WorldStateIndoorSectorConfig.Id,不是 IndoorId。
|
||
// (另一佐证:无扇区配置的 Bowtie Lounge indoorId=44 只推裸 44,客户端同样报 not found。)
|
||
// 留着这个开关只是给以后万一要重做 A/B 用。
|
||
//
|
||
AlsoPushRawIndoorId bool `json:"alsoPushRawIndoorId"`
|
||
//
|
||
// 是否优先采信客户端 SyncChangeIndoor 上报的 indoorConfigId(权威值),
|
||
// 而不是服务端的「几何最近邻」推算。
|
||
// ★ 2026-09-27 20:3x 实机:ACD Nova Branch(76)与 ACD快乐房(129)的
|
||
// IndoorConfig.MiniMapPos 矩形互相重叠,纯几何最近邻在重叠区每几秒就在
|
||
// 76 / 129 之间跳一次 ⇒ 客户端每跳一次就把 9 个扇区的子树建了又删、删了又建。
|
||
// 客户端自己上报的 Enter GpsBound: IndoorId: 76 才是不抖的那个(对得上 76)。
|
||
//
|
||
PreferClientReport bool `json:"preferClientReport"`
|
||
//
|
||
// 客户端上报值的保鲜期(毫秒)。超过这个时间没再上报就退回几何判定
|
||
// (客户端只在进出 bound 时才发 SyncChangeIndoor,站着不动不会持续发)。
|
||
//
|
||
ClientReportGraceMs int32 `json:"clientReportGraceMs"`
|
||
//
|
||
// 换室内前的确认延时(毫秒):候选 id 必须连续稳定这么久才真正切换。
|
||
// 两个来源(客户端上报 / 几何)各自都会瞬时抖到 0 或隔壁场馆,光靠「优先采信」
|
||
// 治不了 —— 必须再叠一层防抖。0 = 不防抖(回到 20:3x 抖动的旧行为)。
|
||
//
|
||
SwitchDelayMs int32 `json:"switchDelayMs"`
|
||
}
|
||
|
||
//
|
||
// Doors as gadgets (T2-11, donor rounds 16C-16I). AskPlayerEnterOrLeaveDoor arrives as a NOTIFY
|
||
// and the server answers by restating the entity through SyncGadgetRefresh(68765074) with the
|
||
// DoorModuleSyncInfo filled in - there is no dedicated door RPC in the client surface.
|
||
//
|
||
type DoorSettings struct {
|
||
// False = log only, never push. Rollback lever, no rebuild needed.
|
||
Enabled bool `json:"enabled"`
|
||
//
|
||
// Also answer the client's own LEAVE reports (ProximityState = NoPlayer). Keep true: the
|
||
// client does not always emit LEAVE, which is why the donor added the timer below.
|
||
//
|
||
PushOnLeave bool `json:"pushOnLeave"`
|
||
//
|
||
// Degrees the leaf swings to while a player is inside it. Panel-owned semantics (round
|
||
// 16I-5): 90 is the hinge idiom and is safe for sliders too. 0 behaves like "no animation".
|
||
//
|
||
OpenAngle float32 `json:"openAngle"`
|
||
//
|
||
// Also restate the door through SyncGadgetRefresh(68765074) / DoorModuleSyncInfo. False by
|
||
// default: that path is accepted but its leaf behaviour tops out at "the leaf disappears",
|
||
// which donor round 16H counted as success. Left here as a comparison lever only.
|
||
//
|
||
PushGadgetRefresh bool `json:"pushGadgetRefresh"`
|
||
//
|
||
// Safety net from donor round 16I-4: N seconds after an ENTER with no LEAVE, push NoPlayer.
|
||
// Without it the leaf never closes because the client alone does not restate the module.
|
||
// 0 = disable the timer.
|
||
//
|
||
AutoCloseSeconds int32 `json:"autoCloseSeconds"`
|
||
}
|
||
|
||
//
|
||
// Scene devices — doors, vending machines, fridges, arcades. An Ask* device INVOKE is only a
|
||
// REQUEST: the client's device-state manager waits for the server to ECHO the transition before
|
||
// it plays the interaction, so replying "ok" without the echo leaves the device stuck.
|
||
// Every switch here is a rollback lever — see GameRouter.SceneDevices.cs.
|
||
//
|
||
type SceneDeviceSettings struct {
|
||
// Master switch. Off = fall back to the generated neutral replies (no echo).
|
||
Enabled bool `json:"enabled"`
|
||
// Echo SyncSceneDeviceStateChange(68165561) on an accepted change-state.
|
||
EchoState bool `json:"echoState"`
|
||
// Echo SyncSceneDeviceValueChange(68332775) on an accepted record-value.
|
||
EchoValue bool `json:"echoValue"`
|
||
//
|
||
// Reply to AskCheckSpoonConditionByType(67709054) — "may I run this device script?".
|
||
// ⛔⛔ **2026-09-28 实机订正(用户验证):这里必须是 false。**
|
||
// 旧注释写「生成的中性默认 0x00=false 会让客户端拒绝跑设备脚本,donor round 13 答 0x01」
|
||
// —— 对**衣柜这一族是反的**,而且 donor 那条从来没有被实测过,只是照抄的推断。
|
||
// 实测经过(塔菲公寓衣柜 uid 14013633775178039662,
|
||
// prefab `SpoonSlot_WardrobeDoor_FashionChange_tafeiapartment`,pathId 371305360):
|
||
// · 回 **0x01(true)**:F 提示一闪即没、换装打不开。
|
||
// `logs/packets-20260928-204201.log` 21:03:18.961 S2C RETURN body=1b hex=**01**
|
||
// (同一条 60b C2S payload 尾部就是衣柜 uid 6E41E7CDAB6D7AC2),
|
||
// `[SPOON] action` 只发 2 次(21:03:59 / 21:04:05)就停。
|
||
// · 回 **0x00(false)**:F 常驻;`logs/console-20260928-224215.log` 22:46:37 `condition -> False`、
|
||
// 22:46:44 `[SPOON] action` 触发,其 60b payload 与对照组 v1.5 成功换装时**逐字节相同**。
|
||
// 对照组 v1.5 根本没有本 handler,走 `DefaultReturnCatalog4229938` 兜底 0x00 ⇒ 衣柜一直正常。
|
||
// ⇒ 结论:**false = 客户端走本地表现、脚本照跑;true = 客户端把交互交给服务端脚本流程,
|
||
// 而我们给不出脚本输出(AskDoSpoonServerActionByType 只回空 OK)⇒ 交互挂死。**
|
||
// 回滚:本值改回 true 即复现「衣柜 F 不显示」。配置键 =
|
||
// world.content.sceneDevices.spoonConditionTrue(配置里已显式写 false)。
|
||
// ⛑ 默认值同步改成 false(旧默认 true 是坑:一旦配置里没有这个键就会复现 bug)。
|
||
//
|
||
SpoonConditionTrue bool `json:"spoonConditionTrue"`
|
||
// Device ids that must never be echoed (client-local flows that regress). Decimal.
|
||
EchoOffIds []string `json:"echoOffIds"`
|
||
}
|
||
|
||
//
|
||
// Lifts. The cab never moves on its own — the client asks, the server has to echo the movement
|
||
// back with SyncMobilePlatformInfo(68705244) or nothing happens at all.
|
||
//
|
||
type ElevatorSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
// How long the ride takes before the arrival push lands (ms).
|
||
TravelMs int32 `json:"travelMs"`
|
||
//
|
||
// LiftDoorMode sent with the ARRIVAL push (dump.cs:1750057 — Normal=0, StayOpen=1, Closed=2).
|
||
// The hall call (F) only produces AskMoveMobilePlatform; there is no "open the door"
|
||
// RPC on the wire, so if the cab parks with Normal the doors stay shut and the player can
|
||
// never step in. StayOpen is the only value that asks the client to hold the doors.
|
||
// Set to 0 to restore the old (doors stay shut) behaviour.
|
||
//
|
||
ArrivalDoorMode byte `json:"arrivalDoorMode"`
|
||
//
|
||
// Push SyncLiftDoorMode(68799106) with ArrivalDoorMode after the cab
|
||
// parks. Without it the doors stay shut: LiftMgr keeps its own per-lift LiftDoorMode and
|
||
// nothing in the hall call asks it to open.
|
||
//
|
||
OpenDoorsOnArrival bool `json:"openDoorsOnArrival"`
|
||
}
|
||
|
||
type GymEquipmentSettings struct {
|
||
// False = never place anything. Rollback lever, no rebuild needed.
|
||
Enabled bool `json:"enabled"`
|
||
//
|
||
// Which S2C channel places the prop:
|
||
// 0 = SyncGadgetAdd(68843189) one entity per push
|
||
// 1 = SyncGadgetAOIAddAndRemove(68169646)
|
||
// 2 = both of the above
|
||
// 3 = SyncGadgetAddList(68925813) one push for the whole venue
|
||
// 0 is the one proven to build the entity (name / position / interact point all correct, and
|
||
// the minigame is playable). ⚠️ It is NOT the channel AnantaGymLoader.exe uses — the loader
|
||
// calls SyncGadgetAddList(68925813) with iScale=0, see the IScale note. Try 3 (with IScale 0)
|
||
// if props still come out invisible on channel 0. Its wire shape is inferred
|
||
// (single-arg RPC ⇒ inline), not yet proven live.
|
||
//
|
||
Channel int32 `json:"channel"`
|
||
//
|
||
// PackedGadgetInfo.iScale sent with every gym prop.
|
||
// 0 is the value AnantaGymLoader.exe writes (agent.js gymspawn2: pk.field("iScale").value = 0)
|
||
// and the value carried by every one of the 400 blob records read out of
|
||
// GadgetBlob.tmpPackedGadgetExtendInfo.basePackedInfo. The client renders it 1:1, which is what
|
||
// self-streamed props (vending machines, cat cafe) end up with.
|
||
// 100 (the GM-panel default) makes gym entities come out with transform scale (0,0,1) —
|
||
// a zero-scale shell with no prefab.
|
||
//
|
||
IScale int32 `json:"iScale"`
|
||
//
|
||
// Extra metres added to every venue's own radius before its equipment is placed. Raise it if
|
||
// the venue's anchor is outside the building; 0 keeps each spot's own radius.
|
||
//
|
||
RadiusBonus float32 `json:"radiusBonus"`
|
||
//
|
||
// ★ 2026-10-01 千泊健身房实测:摆放撞在客户端「切室内」的那一瞬,19 条 SyncGadgetAdd 全部被吞
|
||
// (服务端 19/19 下发成功、客户端零痕迹,场馆当场空着);而场馆每会话只铺一次
|
||
// (GymEquipmentSessionState.Placed)⇒ 一次丢 = 整场会话都空。
|
||
// 同一场馆、同样 19 条包,客户端切室内完成 12 秒后再摆就全部正常出现(用户实机确认)。
|
||
// <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 && !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 距离 > unloadRadiusCells。
|
||
// 关掉它 = 回到「Sent 只增不减,远眺留下一堆幽灵格子」的旧行为。
|
||
//
|
||
ReseedOnFarJump bool `json:"reseedOnFarJump"`
|
||
// When true, request every authored object in each production cell instead of applying the extracted sector filter.
|
||
LoadAllSectors bool `json:"loadAllSectors"`
|
||
//
|
||
// ⛔ 零消费点 —— 全仓没有任何代码读它(连 Validate() 都不看),
|
||
// true / false 对行为没有任何影响。2026-09-28 核实。留字段只为兼容已落盘的 JSON。
|
||
//
|
||
ExcludeTestItems bool `json:"excludeTestItems"`
|
||
//
|
||
// ⛔⛔ 名字骗人 —— 只被 Validate() 校验 1..200,真正的预算是 MaxCellsPerPush。
|
||
// 推送代码用的是 var budget = Math.Max(1, cfg.MaxCellsPerPush),
|
||
// 所以改这个值只会改校验结果,不会改每轮发多少格。见硬规则 212。
|
||
//
|
||
LoadBudgetCells int32 `json:"loadBudgetCells"`
|
||
// ---- 以下为 2026-09-24 新增:68969593 网格流的时序参数(与 gadget 网格同构) ----
|
||
// How often the loop looks at the player's cell (ms).
|
||
TickMs int32 `json:"tickMs"`
|
||
//
|
||
// Grace period after world-ready before the first Loaded push. The client needs its grid
|
||
// data resident first — the injector waits for the same reason. Kept a little later than the
|
||
// gadget grid (12000) so the two streams don't hit the client in the same frame.
|
||
//
|
||
FirstDelayMs int32 `json:"firstDelayMs"`
|
||
//
|
||
// Re-declare the window this often even when the player stands still (ms). 0 = off.
|
||
// ⛔⛔ 2026-09-30 起本通道默认 0(关),理由见 ResendWindowOnMove:
|
||
// 站桩时整窗重推 = 客户端对已加载格子重新 AddByIndex = **重新实例化** ⇒ 玩家看到
|
||
// 「可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位」。
|
||
// 旧的注释「Symptom this fixes: items appear for a second and vanish」已被 AnantaWorldMod
|
||
// 反证(它从不重推,物件也不消失)—— 那条实证其实是**重推本身造成的闪**,因果写反了。
|
||
// 想恢复旧行为:本键 5000 + RefreshMs 120000 + ResendWindowOnMove=true。
|
||
//
|
||
HeartbeatMs int32 `json:"heartbeatMs"`
|
||
//
|
||
// Full re-send of the surrounding window after this long (ms). Safety net.
|
||
// ⛑ 0 = 关闭(GameRouter.SceneItemGrid.cs 里有 `cfg.RefreshMs > 0` 守卫;
|
||
// 没有这个守卫时填 0 会变成「恒真」⇒ 每个 tick 都整窗重推,比心跳还频繁)。
|
||
//
|
||
RefreshMs int32 `json:"refreshMs"`
|
||
// Cells per 68969593 message. One message carries one sector list, never more.
|
||
MaxCellsPerPush int32 `json:"maxCellsPerPush"`
|
||
//
|
||
// Re-send the WHOLE window on a cell crossing instead of only never-seen cells. The client
|
||
// treats addGridInfo as its current AOI snapshot, not as a delta.
|
||
// ⛔⛔ 2026-09-30 起默认 false —— 上面那句「客户端把 addGridInfo 当 AOI 快照」的假设
|
||
// 已被参考实现反证:AnantaWorldMod(new-worldmod/world_mod.js:3525-3550)每 700ms 遍历
|
||
// 窗口,`if (loaded.has(key)) continue;` —— **每格只 AddByIndex 一次,从不重推**,物件照样在。
|
||
// 而我们 true 时每次跨格 / 每 5 s 心跳整窗重推 ⇒ 客户端对已加载格子重新实例化 ⇒
|
||
// **物件每 5 秒闪一次、位移 / 破坏状态 / 念力 hold 全部被重置**(用户 2026-09-30 实测症状)。
|
||
// ⇒ 本键 false + HeartbeatMs=0 + RefreshMs=0 = **严格增量**,
|
||
// 与 AnantaWorldMod 等价:已刷出的格子永不重推。
|
||
// ⛑ 回滚 = 三个键改回 true / 5000 / 120000,不需要动代码。
|
||
//
|
||
ResendWindowOnMove bool `json:"resendWindowOnMove"`
|
||
//
|
||
// Push SyncDestructibleSceneAOIActive(68418115) = true once per session before the
|
||
// first grid push. It belongs to the DESTRUCTIBLE family (that is the family this channel
|
||
// is named after), but hard rule 31 records that pushing it around interiors corrupted
|
||
// interior state, so it stays OFF by default.
|
||
// 排障顺序:物品一个都不出 → 先开这个;室内门/店员消失 → 立刻关回去。
|
||
//
|
||
ArmActiveSwitch bool `json:"armActiveSwitch"`
|
||
//
|
||
// 念动力探针(硬规则 28):用 aoiInfo.addInfos 在玩家正前方凭空摆一个可抓 SceneItem。
|
||
// 这是 T1-22 里唯一能**直接验证**服务端显式摆放链路的手段 —— 箱子出现 ⇒ addInfos 这条
|
||
// 路通,家具不出只是缺坐标;箱子都不出 ⇒ 先修通道,别再往坐标表里填数。默认关。
|
||
// ⛑️ 只摆一次(会话级),否则心跳 / 跨格重声明每隔几秒就叠一个箱子。
|
||
//
|
||
SpawnTestBox bool `json:"spawnTestBox"`
|
||
//
|
||
// 探针用的 SceneitemConfig id。80001877 = 官方测试盒,CanQInteraction=true,
|
||
// 客户端会实例化成真的 SceneItem 并被 MindPowerMgr:GetAimItem() 认成 Q 目标。
|
||
//
|
||
SpawnTestBoxCfgIds []int32 `json:"spawnTestBoxCfgIds"`
|
||
}
|
||
|
||
// Grid window for client-baked gadgets (lifts, doors, vending machines).
|
||
type GadgetStreamSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
RadiusCells int32 `json:"radiusCells"`
|
||
//
|
||
// ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3):
|
||
// 走出本边界(Chebyshev > 本值,以当前所在格为中心)的格子会被显式卸载。
|
||
// ⛔ 历史:见 SceneItemStreamSettings.UnloadRadiusCells —— 2026-09-28 实测时
|
||
// 它只被 Validate() 读一次,本通道的 removeGridInfo 也恒为空(硬规则 212 作废)。
|
||
// ⛑ 回滚真卸载:realUnload = false。
|
||
//
|
||
UnloadRadiusCells int32 `json:"unloadRadiusCells"`
|
||
// ★ 见 SceneItemStreamSettings.RetainVisitedCells —— 2026-10-01 起真的生效:卸载边界内的保留环。
|
||
RetainVisitedCells bool `json:"retainVisitedCells"`
|
||
// ★ 见 SceneItemStreamSettings.RealUnload —— 真正下发 removeGridInfo。默认开;false = 只增不减的旧行为。
|
||
RealUnload bool `json:"realUnload"`
|
||
// ★ 见 SceneItemStreamSettings.RealUnloadLogOnly —— 只记不发。
|
||
RealUnloadLogOnly bool `json:"realUnloadLogOnly"`
|
||
// ★ 见 SceneItemStreamSettings.ReseedOnFarJump —— 传送 / 远跳重新铺窗。默认开。
|
||
ReseedOnFarJump bool `json:"reseedOnFarJump"`
|
||
// When true, request every authored gadget in each production cell instead of applying the extracted sector filter.
|
||
LoadAllSectors bool `json:"loadAllSectors"`
|
||
// ⛔ 见 SceneItemStreamSettings.LoadBudgetCells —— 只被 Validate() 校验,
|
||
// 真正的预算键是 maxCellsPerPush。
|
||
LoadBudgetCells int32 `json:"loadBudgetCells"`
|
||
//
|
||
// Gadget UniqueIds the client must never instantiate — hard rule 25. Gadget 200448
|
||
// ships with a baked sceneItemId of 0; the 4229938 client enters an infinite
|
||
// StateMachineContainer recursion the moment it LoadDynamicItem(200448, 0) and
|
||
// TryGetDjPathHashBySceneItemId fails on "SceneCfg at id 0 is null" — which on a live
|
||
// client looks exactly like a freeze-then-disconnect.
|
||
// The ids are restated on every grid push as addGridInfo.ExceptIds plus
|
||
// aoiInfo.removeIds / aoiInfo.inactiveIds, so the one broken gadget stays
|
||
// dormant while everything else in the same cell still streams normally.
|
||
// (donor reference value: [200448].)
|
||
//
|
||
ExcludedGadgetIds []uint64 `json:"excludedGadgetIds"`
|
||
// ---- Gadget grid streaming (SyncGadgetGridAOIDecrease 68956797) ----
|
||
// Replaces the AnantaGadgetLoader exe: the server tells the client which 40 m cells to load
|
||
// and the client materializes its own authored props from its GadgetBlob.
|
||
// How often the loop looks at the player's cell (ms).
|
||
TickMs int32 `json:"tickMs"`
|
||
//
|
||
// Re-declare the current window this often even when the player stands still (ms). 0 = off
|
||
// (legacy: push only on first load, cell crossings and the periodic refresh).
|
||
// Symptom this fixes: props showed up for a few seconds after a teleport and then vanished —
|
||
// the client appears to drop grid cells the server stops declaring.
|
||
//
|
||
HeartbeatMs int32 `json:"heartbeatMs"`
|
||
//
|
||
// Grace period after world-ready before the first Loaded push. The client needs its grid
|
||
// data resident first — the injector tool waits ~20 s for the same reason.
|
||
//
|
||
FirstDelayMs int32 `json:"firstDelayMs"`
|
||
// Full re-send of the surrounding window after this long (ms). Safety net, not the main path.
|
||
RefreshMs int32 `json:"refreshMs"`
|
||
//
|
||
// ★★ 2026-10-04「入世界守卫」—— 首次整窗推送后等这么久,强制做一次 整窗 remove → add
|
||
// (先下发 removeGridInfo 卸掉全部已声明格子,清空 Sent,下一次 tick 以 reason=1 重新铺窗)。
|
||
// 0 = 关闭。realUnload=false 或 realUnloadLogOnly=true 时只清服务端集合、不发卸载报文。
|
||
// <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 > 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;本值 <=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**)。
|
||
// 想围得更紧就继续调小;太小会互相穿模(实测 < 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 <= 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"`
|
||
//
|
||
// 两条移动命令之间的间隔(毫秒)。必须 >= MoveSeconds*1000,否则下一条会打断上一条。
|
||
// 留 50ms 左右余量最自然(完全连续 / 略微一顿,靠这个值调)。
|
||
//
|
||
EnemyAiMoveIntervalMs int32 `json:"enemyAiMoveIntervalMs"`
|
||
//
|
||
// ★ 决定性实验开关(2026-09-24):false = 服务端**完全不发**战斗移动命令
|
||
// (SyncEnemyBattleMoveTowardUnit),只发 engage(SyncBattleAiS=true)和攻击。
|
||
// 敌人的接近动作交给客户端自己的行为树 —— 它是 forbidAetherAI=true 生成的,
|
||
// 客户端本来就在跑战斗 AI。如果我们每 1 秒补一条有限位移命令反而是在打断它,
|
||
// 那关掉这个开关就能看到"原生"的连续追击。
|
||
// ⛑️ 关掉后如果敌人完全不动,说明客户端行为树不自己走路,那就得开回来。
|
||
//
|
||
EnemyAiServerMoveEnabled bool `json:"enemyAiServerMoveEnabled"`
|
||
//
|
||
// 攻击后把单位冻多久(毫秒):CastBusy 是完全不动,CastInterval 是下次决策的时间。
|
||
// 原来是 6000/6500 —— 打一下站 6.5 秒,这也是"停"的一大来源。
|
||
//
|
||
EnemyAiCastBusyMs int32 `json:"enemyAiCastBusyMs"`
|
||
EnemyAiCastIntervalMs int32 `json:"enemyAiCastIntervalMs"`
|
||
//
|
||
// 每个敌人的移动/攻击日志打多密:前 3 条必打,之后每 N 条打一次。
|
||
// 原来只打前 3 条,导致实机看不到任何后续节奏(诊断"一步一停"时吃了大亏)。
|
||
//
|
||
EnemyAiVerboseLogEvery int32 `json:"enemyAiVerboseLogEvery"`
|
||
//
|
||
// 玩家不在仇恨范围(30m)时让敌人原地踱步:走一段 → 停一会 → 倒着走回来。
|
||
// 走的是 WalkFront / WalkBack 交替,位移互相抵消,所以不会整体漂向玩家。
|
||
//
|
||
EnemyAiPatrolEnabled bool `json:"enemyAiPatrolEnabled"`
|
||
// 踱步两段之间的停顿区间(毫秒),在区间里随机取值。
|
||
EnemyAiPatrolPauseMinMs int32 `json:"enemyAiPatrolPauseMinMs"`
|
||
EnemyAiPatrolPauseMaxMs int32 `json:"enemyAiPatrolPauseMaxMs"`
|
||
//
|
||
// 踱步单条命令走多久(秒)。比追击更长:一条命令内客户端是连续走的,
|
||
// 命令越多起停越多,看起来越碎。2.5s 大概走 4~5 m。
|
||
//
|
||
EnemyAiPatrolMoveSeconds float32 `json:"enemyAiPatrolMoveSeconds"`
|
||
// 踱步离出生点最远多少米(保险绳,估算位置超出就强制换向)。
|
||
EnemyAiPatrolLeashMeters float32 `json:"enemyAiPatrolLeashMeters"`
|
||
//
|
||
// 仇恨脱离半径(米)。必须大于进入半径 30 —— 单阈值会让 engagement 在边界反复开关,
|
||
// 而每次开关都要重发 LockTarget/FightEdict/BattleAiS,客户端的动作层被重置、
|
||
// 动画被裁断,表现就是"刚迈一步就僵住"。实测日志里几秒内 True→False→True 抖了 53 次。
|
||
//
|
||
EnemyAiAggroExitMeters float32 `json:"enemyAiAggroExitMeters"`
|
||
// engagement 状态翻转后的最短保持时间(毫秒),二次防抖。
|
||
EnemyAiEngageHoldMs int32 `json:"enemyAiEngageHoldMs"`
|
||
//
|
||
// 仇恨进入半径(米)。原先是 EnemyAiActions.cs 里的硬编码 30f,
|
||
// 2026-09-26 提成配置项。
|
||
// ⛑⛑ 填 0 = 敌人永不主动锁定玩家(EnemyAiSelectTarget 恒返回 0,
|
||
// 不发 LockTarget / FightEdict / BattleAiS)⇒ 敌人一直保持未察觉 ⇒
|
||
// 蹲伏暗杀才有窗口:已在交战中的敌人不可暗杀,这正是
|
||
// ReportCanAssassinateEnemies 恒 0 的原因之一(实测 20:23:36 敌人就 engage 了,
|
||
// 而蹲伏在 20:23:30–54)。
|
||
// 想恢复原行为填 30。
|
||
//
|
||
EnemyAiAggroRangeMeters float32 `json:"enemyAiAggroRangeMeters"`
|
||
//
|
||
// 实验开关:改用 SyncMoveWandering(68745518) 让敌人"自己走起来"——
|
||
// 官方唯一不带目的地的行走包(半径/角度窗口/时间预算,路径由客户端自己挑),
|
||
// 行人路线走的就是它,所以是真正连续的行走,而不是一串有限位移命令。
|
||
// ⛑️ 敌人 forbidAetherAI=true,能不能接这个包没验证过;默认 false。
|
||
// 开了之后服务端拿不到真实坐标,AiPosition 仍是估算(游荡位移不计入)。
|
||
//
|
||
EnemyAiWanderEnabled bool `json:"enemyAiWanderEnabled"`
|
||
//
|
||
// 出招前转向用什么包:
|
||
// "target"(默认)= SyncUnitSmoothFaceToTarget(68146316),把目标单位交给
|
||
// 客户端,转角由它用真实朝向算 ⇒ 准;
|
||
// "smooth" = SyncUnitSmoothFace(68718685),服务端按估算朝向算增量 ⇒
|
||
// 估算过期就会转歪(2026-10-01 实机:敌人跑到玩家面前再转 90° 打空气);
|
||
// "off" = 不发转向包(对照用)。
|
||
// 详见 GameRouter.EnemyAiActions.cs 的 TurnEnemyToAsync。
|
||
//
|
||
EnemyAiTurnPacket string `json:"enemyAiTurnPacket"`
|
||
// wander 单次游荡的距离区间(米)。踱步用,别设太大免得走丢。
|
||
EnemyAiWanderMinDis float32 `json:"enemyAiWanderMinDis"`
|
||
EnemyAiWanderMaxDis float32 `json:"enemyAiWanderMaxDis"`
|
||
// wander 的时间预算(秒)。走完就站住,靠巡逻 tick 续期。
|
||
EnemyAiWanderSeconds float32 `json:"enemyAiWanderSeconds"`
|
||
// ---- 随从(Companion)—— 2026-10-01 ----
|
||
//
|
||
// ⛑⛑ 取证结论:随从模板用的是**专属伴生行为树**(`AgentAIBehaviorSetsBattleAISBehaviorConfig`
|
||
// AI=40213535 → BehaviorTree=`Companion_JiaMu_Shuangmawei`(40651922 晴)
|
||
// AI=40213604 → BehaviorTree=`Companion_Guys`(40155016 盖斯))
|
||
// 两行的 Behavior_Battle 都是空串、RelatedSkillIds 都是空表 ⇒ 它们的战斗能力
|
||
// **不在 AI 行为集里**,而是写在那棵 Companion 树的 C# 实现里,服务端查不到。
|
||
// 所以别指望 `EnemyAiAttacksFor` 给它们配招(它会退回金属吉他手那两下 51900523/51900524)。
|
||
//
|
||
// ⛑⛑ 而 AgentConfig 给的 Camp:晴=1(PlayerDefault)、盖斯=1 ⇒ **随从本来就是玩家阵营**。
|
||
// 之前 `campOverride:0` 把它按 UnitCamp.MonsterDefault 发了出去 ⇒ 客户端当它是敌人
|
||
// (挂血条、可锁定、Companion 树不接管)—— 这就是"随从像敌人"的头号根因。
|
||
// UnitCamp 枚举(dump.cs:1907832):0 MonsterDefault / 1 PlayerDefault / 2 AllNeutral
|
||
// / 3 AllFriend / 4 AllEnemy。(⛔ 旧记忆说"友方=1"是对的,但说"敌人是 0/4"只在 spawn
|
||
// 默认路径上成立;真正的判定是 UnitCamp 本身。)
|
||
//
|
||
// 随从生成时下发的 camp。默认 1 = UnitCamp.PlayerDefault(模板自带值,
|
||
// 与捐赠端给机械狗发的同款)。SyncAgentCampInfo 的包体就是这一个字节。
|
||
// 设 4 立刻回到"随从被当敌人"的旧行为(一键回滚用)。
|
||
//
|
||
CompanionCamp byte `json:"companionCamp"`
|
||
//
|
||
// 服务端是否用敌人那套四包(BattleAiS / LockTarget / FightEdict / BattleMoveTowardUnit)
|
||
// 自己驱动随从跟随+协战,并在贴身时补一个 SyncUseTargetSkill 让它真出招。
|
||
// ⛑⛑ 2026-10-01 实机:false 的时候随从完全不动 —— 客户端那棵 Companion 树
|
||
// 并没有因为 SyncAgentCompanionAI+SyncCompanionMaster 就接管
|
||
// (camp 已经发成 1 了也一样)。所以服务端必须自己驱动,默认 true。
|
||
//
|
||
CompanionServerDrive bool `json:"companionServerDrive"`
|
||
//
|
||
// 随从出招用的技能池(贴身时按序号轮流发 SyncUseTargetSkill)。
|
||
// ⛑⛑ 为什么不是"它本身的攻击模组":晴/盖斯的 AI 行为集里 Behavior_Battle 是空串、
|
||
// RelatedSkillIds 是空表,招式写在客户端那棵 Companion_* 行为树的 C# 里,
|
||
// 服务端无从得知(详见 CompanionAssist.cs 类注释)。所以只能借通用池。
|
||
// 默认取 EnemyAiCatalog 的 UNARMED 近战池(与 7k-v0.5 同源,能在多数骨架上播):
|
||
// 51900500 / 51900501 空手普攻,51900410 / 51900411 空手连击。
|
||
// 若实机看到它"摆姿势不出手"(骨架不对),换成 RANGED 远程池试:
|
||
// [51984196, 51992001, 51996871]
|
||
// 换技能不用重编译,改这里重启即可。
|
||
//
|
||
CompanionAttacks []uint32 `json:"companionAttacks"`
|
||
//
|
||
// 跟随(身边没有敌人)时是否仍然把 SyncBattleAiS 保持在 true。
|
||
// ⛑⛑ 2026-10-01 实机:恒 true 的后果就是随从一直保持举枪的战斗姿态,
|
||
// 没敌人也端着枪慢慢挪 —— 玩家看到的"怪"就是它。主线驱动敌人时是
|
||
// value = target != 0(EnemyAiActions.cs:453),没目标就关战斗 AI。
|
||
// 默认 false = 跟随就收枪,正常走路。
|
||
// ⛑ 若改成 false 后随从连路都不走了(说明它只认战斗态的移动指令),设回 true。
|
||
//
|
||
CompanionBattleAiWhenFollowing bool `json:"companionBattleAiWhenFollowing"`
|
||
//
|
||
// 跟随模式下单条移动命令走多久(秒)。
|
||
// ⛑ 为什么要单列:有限位移命令是"走 MoveTime 秒就停",而协战用的
|
||
// EnemyAiMoveSeconds(默认 0.65s)是给敌人**短距离贴脸**调的。随从要跟
|
||
// 十几二十米的话,一条命令只走 1.2 m 就要停一下 ⇒ 玩家看到的就是"一顿一顿慢慢挪"。
|
||
// ⇒ 跟随时取 max(EnemyAiMoveSeconds, 本值),本值默认 3.0s(约 5.5 m)。
|
||
// 调大 = 一次走更远、起停更少(但转向更迟钝);调小 = 更贴身、更碎。
|
||
//
|
||
CompanionFollowMoveSeconds float32 `json:"companionFollowMoveSeconds"`
|
||
//
|
||
// 绑定随从后清掉它的上半身持枪瞄准态(SyncEndShootingState +
|
||
// SyncEndShootGameplayTag,见 GameRouter.EnemyStance.cs:32)。
|
||
// ⛑⛑ 2026-10-01 实机:随从"举着枪慢慢挪"的真凶。主线那条清理只在 profile 判成
|
||
// unarmed 时才发,而随从 profile 是 null(招在行为树里、没进 EnemyAiCatalog)
|
||
// ⇒ 永远走不到。瞄准态的 locomotion 是慢速瞄准步 ⇒ 「举枪」和「慢慢」同源。
|
||
// 默认 true。
|
||
//
|
||
CompanionClearGunAim bool `json:"companionClearGunAim"`
|
||
//
|
||
// 绑定随从后收起它头顶的血条(SyncAddUnitState + HideHp=10266)。
|
||
// ⛑⛑ 血条不看阵营:判据在 EnemyHUDCtrl.lua:579 ——
|
||
// shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ...
|
||
// ⇒ 改 camp 去掉不了血条,只有挂 HideHp 状态才行(per-unit,不动公共 spawn 路径)。
|
||
// 默认 true。
|
||
//
|
||
CompanionHideHpBar bool `json:"companionHideHpBar"`
|
||
//
|
||
// 里希执勤终端的战术支援(dispatchid=4「冲锋枪2人」)刷出来的两名友军,
|
||
// 是否也接入这套随从 AI(跟随 + 协战 + 收血条)。默认 true。
|
||
//
|
||
CompanionBindArmedSupport bool `json:"companionBindArmedSupport"`
|
||
//
|
||
// 绑定后推的战斗动画集(SyncBattleAnimSet,AnimationSetConfig id)。
|
||
// ⛑⛑ 为什么随从需要它:官方 Companion 协议只有三个包(dump.cs 全量 grep 过:
|
||
// SyncCompanionMaster / SyncAgentCompanionAI /
|
||
// SyncAgentCompanionAIReactionEventSuccess),没有 follow/move ——
|
||
// 跟随本该是客户端那棵树自己的事;它没接管,我们只能用敌人 locomotion 代劳,
|
||
// 于是"走什么步、手怎么摆"就取决于这个动画集。
|
||
// 主线 SendEnemyCombatStanceAsync 只在 profile 判成 unarmed 时推,随从 profile 是
|
||
// null ⇒ 一直用默认的持枪/远程集在走慢步。
|
||
// 取值(EnemyAiMethods.cs 注释):325 = 十二面空手、327 = 通用胖子空手、
|
||
// 337/338/340 = 远程、0 = 不发(默认,留作对照)。
|
||
// ⇒ 想修「举枪慢步」就填 325(配合 CompanionDisarm=true,即 7K melee 三件套)。
|
||
//
|
||
CompanionAnimSetTag uint32 `json:"companionAnimSetTag"`
|
||
//
|
||
// 额外把随从的武器卸掉(SyncAgentChangeWeapon(uid, 0))。默认 false ——
|
||
// 它的 InitWeaponId 本来就是 0,这只是个对照开关:清了瞄准态还举枪时才开。
|
||
//
|
||
CompanionDisarm bool `json:"companionDisarm"`
|
||
//
|
||
// 跟随时用 BattleMoveActionType.Run(6) 而不是 WalkFront(2)。
|
||
// ⛑⛑ 2026-10-01 实机:跟十几米全用走路,玩家看到的就是"慢慢挪过来"。
|
||
// 枚举在 dump.cs:1913359(Unknown=0 / Idle=1 / WalkFront=2 / WalkBack=3 /
|
||
// WalkLeft=4 / WalkRight=5 / **Run=6**)。协战始终用 WalkFront(贴脸不需要跑)。
|
||
// 默认 true;若跟随时它跑得太冲/动作不对,设 false 退回走路。
|
||
//
|
||
CompanionRunWhenFollowing bool `json:"companionRunWhenFollowing"`
|
||
// ------------------------------------------------------------------
|
||
// ★★★★★ 服务端驱动行走 —— 移动任务步进(2026-09-20 移植 donor1 实机配方)
|
||
//
|
||
// ⛑️ 位置流路线已证伪(donor1 第 39 轮 + 我们 09-20 实测):
|
||
// `SyncLogicAgentSyncData`(68838628) 不携带任务 ⇒ 客户端产生不了步态。
|
||
// 我们 10Hz 推了 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动;
|
||
// donor1 AI_HANDOFF 明确把「服务端硬推位置」标为**已证伪路线**(路人第 16~23 轮的老路)。
|
||
//
|
||
// 正确路线 = `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData`(wire tag 39):
|
||
// 客户端的 LogicBehaviorSchedulerModule 跑任务 —— ZoneGraph 寻路、避障、locomotion 状态。
|
||
// donor1 第 51 轮用户亲测完整配方:
|
||
// wander=False logicSync=WayPoint logicEnter=True command=True
|
||
// commandPoints=3 commandActionId=400 speed=1.6 + AutoHold/ForceGo
|
||
// donor1 第 81 轮:`SyncManagedLogicAgent`(LogicHandoff)是**客户端来要路径的前提**,
|
||
// 状态机被关掉 ⇒ 客户端根本不来问 ⇒ 任务全落空。
|
||
//
|
||
// 路网数据:`Drmk.Server/Drmk.App/data/ped_lanes.csv` + `ped_lane_links.csv`
|
||
// (88361 条车道 / 244398 点 / 263448 条连接,由 `_ref/_gen_ped_lanes.py` 生成)。
|
||
// ------------------------------------------------------------------
|
||
// 服务端沿人行道路网给每个行人派移动任务(TaskWayPointMove)。关掉就回到"客户端自己走"(实测 = 不走)。
|
||
AmbientPedsDriven bool `json:"ambientPedsDriven"`
|
||
//
|
||
// 任务步进循环的检查周期(毫秒)。默认 500ms(donor1 的 CrowdHoldTickMs)——
|
||
// 任务只在行人"跑完当前段"时才发下一条,不是固定频率流。
|
||
//
|
||
AmbientPedsSyncMs int32 `json:"ambientPedsSyncMs"`
|
||
//
|
||
// 一条移动任务覆盖的路程(米)。1.6 m/s 下 30m ≈ 19s、60m ≈ 37s 步态,
|
||
// 下一条任务在"预计走完 + AmbientPedsStepSlackMs"之后才发。
|
||
// ⛑️ 2026-09-23 30 → 60:那段余量是**每次发任务都要空等**的,路程越短、发呆越频繁
|
||
// (30m:每 19s 发呆 5s = 21% 的时间在站着;60m:每 37s 发呆 5s = 12%)。
|
||
// 加长路程**不改时序预估公式**,所以多等的还是那段余量本身,不会提前打断 ⇒ 不会瞬移。
|
||
//
|
||
AmbientPedsStepMetres float32 `json:"ambientPedsStepMetres"`
|
||
//
|
||
// 任务折线的采样间距(米)。路程加长后必须靠它保住贴合度 ——
|
||
// 原来写死 `stepMetres/5`,60m 会变成 12m 一个点,lane 中位数才 3.58m,行人会切弯穿绿化。
|
||
// 固定 6m:60m ⇒ 11 个点,和 30m 时的密度一致。
|
||
//
|
||
AmbientPedsSampleMetres float32 `json:"ambientPedsSampleMetres"`
|
||
//
|
||
// 换道时"不许走回"最近走过的几条车道(0 = 只挡住紧邻的上一条,即 2026-09-23 之前的行为)。
|
||
// 离线路网实测(2000 段 / 每步 30m,模拟器 `_pedwalk_sim.py`):
|
||
// 1 条:掉头 4.8% / 净位移中位 10.4m / 直线度 0.51 / 打转 41.5%
|
||
// 5 条:掉头 9.8% / 净位移中位 13.6m / 直线度 0.77 / 打转 35.6%
|
||
// **8 条:掉头 9.0% / 净位移中位 13.6m / 直线度 0.85 / 打转 33.6%** ← 默认
|
||
// ⛑️ 记忆越长走越直,但掉头率也会上去;掉头时**必须清空记忆**,否则掉头率飙到 42%。
|
||
//
|
||
AmbientPedsRouteMemory int32 `json:"ambientPedsRouteMemory"`
|
||
//
|
||
// 目标导向行走(默认开):每次派单前给行人挑一个几十米外的终点,用 `PedLaneGraph.PlanRoute`
|
||
// 规划整条路径,行人沿路径穿过多个路口 —— 而不是在局部邻域随机游走。
|
||
// ⛑️ 为什么必须开:2026-09-23 用户实测"行人只是从一条路的一头走到另一头,那条路很短就很奇怪"。
|
||
// 我们的路网 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,随机游走的活动半径天然被锁在
|
||
// 几条短车道之间。关掉就退回纯随机游走(保留作回退路径)。
|
||
//
|
||
AmbientPedsJourneyEnabled bool `json:"ambientPedsJourneyEnabled"`
|
||
// 行程终点相对**玩家**的最小半径(米)。终点在环形区域内取,保证行人不会走出回收圈。
|
||
AmbientPedsJourneyMinMetres float32 `json:"ambientPedsJourneyMinMetres"`
|
||
// 行程终点相对**玩家**的最大半径(米)。建议 ≤ `AmbientPedsRadiusMeters` * 0.9。
|
||
AmbientPedsJourneyMaxMetres float32 `json:"ambientPedsJourneyMaxMetres"`
|
||
//
|
||
// 横向偏移的绝对上限(米):真实偏移 = min(车道 HalfWalk, 这里) × 出生时锁定的比例。
|
||
// 目的是不让所有人挤在车道几何中线上(对向行人才不会互相穿模)。
|
||
// ⛑️ **设成 0 = 完全不偏移,全员走车道中线** —— 排查"行人卡进街边墙壁"时先拿它排除嫌疑:
|
||
// 2026-09-23 用户实测窄路卡墙,根因是目标导向路径用了固定 0.6m 偏移(跟路宽无关)。
|
||
// 卡墙现象若**还有其他来源**(比如车道几何本身贴着建筑),把它设 0 就能确认/排除。
|
||
//
|
||
AmbientPedsLateralMaxMetres float32 `json:"ambientPedsLateralMaxMetres"`
|
||
// 横向可走半径的安全余量(米):HalfWalk = max(0, width/2 - 本值)。
|
||
// 调大 = 行人往车道中线收(越不容易被护栏 / 街边墙卡住);调小 = 横向分布更散。
|
||
// ⛑️ 车道宽度中位只有 2.0 m,本值 ≥1.0 时大多数窄路会退化成全员中线。
|
||
// ⛑️ 本项在车道表加载时算进 `HalfWalk`,**改完必须重启**;`AmbientPedsLateralMaxMetres` 则是每次采样现读。
|
||
AmbientPedsLateralMarginMetres float32 `json:"ambientPedsLateralMarginMetres"`
|
||
// 出生点的最小车道宽度(米):窄于这个值的车道不用来出生(树池边 / 栏杆边的小径)。
|
||
// 设 0 = 不过滤。⛑️ 车道宽度中位只有 2.0 m(p10=1.5 / min=0.5),默认 1.8 会滤掉约 17% 的车道。
|
||
AmbientPedsSpawnMinWidthMetres float32 `json:"ambientPedsSpawnMinWidthMetres"`
|
||
//
|
||
// 行人模板池的 **id 段白名单**(T0-28):只保留前缀命中任一项的 `NpcFormworkId`。
|
||
// 空数组 = 不过滤(保持 2026-09-26 之前的"全段混入"行为)。
|
||
// ⛑️ 取证(2026-09-26,用户实机反馈"商店店员、废弃模型大量混在人群里"):
|
||
// `CivilianGroups.json` 去重后有 3318 个模板,横跨 31 个 id 段;拿 `NpcCatalog.json`
|
||
// (= AgentConfig 的 ConfigDump,9345 行,带 `Name`) 逐条对名,发现只有 **4013** 段是
|
||
// 纯市民(2358 条,72% 是英文/罗马音人名,其余是普通中文人名)。会穿帮的段:
|
||
// 4095/4200/4285 = 店员摊主(咖啡店店员 / 手划船售票员 / 前台店员 / KTV店员)
|
||
// 4515 = 刷怪NPC / 特殊病毒病人 / 刷怪(难度一~四)
|
||
// 4204 = 剧情与职能(模型店店长 / 怪兽狂 / 空手道王 / 蓝猫车管处 / 礼品铺)
|
||
// 4065/4096/4097 = 职业角色(清洁工主理人 / 保安小弟 / 天穹安保突击专员 / 厂长 / 主唱 / 鼓手 / 医生 / 护士甲)
|
||
// 想加回多样性就往这里补段(4501 / 4202 / 4201 三段 0 个中文名,看着也是普通市民)。
|
||
//
|
||
AmbientPedsFormworkIdPrefixes []string `json:"ambientPedsFormworkIdPrefixes"`
|
||
//
|
||
// 行人模板池的 **模型黑名单**(`NpcCatalog.GeneralModelId` 命中任一项就踢出池子)。
|
||
// 这是"刷出一街general假人"的根因 filtration:一个 AgentConfig 行只是换个名字,模型还是同一个。
|
||
// ⛑️ 取证(2026-09-26,用户实机"道路上很多 NPC 都是通用临时模型"):
|
||
// NpcCatalog 里名字含「通用临时模型」的只有 **15 行**,但它们**只用 4 个 GeneralModelId**
|
||
// —— 86160001(少女) / 86160002(成女) / 86160003(少男) / 86160004(成男)。
|
||
// 而按 **模型** 反查,全表有 **470 + 9 + 171 + 30 = 680 个 id** 共用这 4 个假人模型,
|
||
// 其中 **522 个在我们的行人池里**(4013 段占 373)。也就是说只按"名字"过滤是漏的:
|
||
// 名字都叫 Blair Scott / Phoebe Beck,看着是市民,穿的却是同一具通用模特。
|
||
// ⇒ **必须按 GeneralModelId 过滤,而不是按行名字过滤。**
|
||
// 排掉这 4 个模型后 4013 段 2358 → **1985**(够用,且外观终于不一样了)。
|
||
// ⛑️ 用户 2026-09-26 定:**只处理这 4 个通用模特**,不要再外扩到「备用 / 空模型 / 空npc /
|
||
// 临时」那批(86000322 / 86951719 / 86962003)—— 实测它们在池内牵连数为 **0**,加到黑名单里
|
||
// 只会让后来人看不懂这串数字是哪儿来的。范围收紧,只对确实造成满街假人的那 4 具下手。
|
||
// 设空数组 = 不过滤。
|
||
//
|
||
AmbientPedsFormworkModelBlocklist []uint32 `json:"ambientPedsFormworkModelBlocklist"`
|
||
//
|
||
// 行人模板池的 **名字关键词黑名单**(`NpcCatalog.Name` 含任一项就踢出池子)。
|
||
// 白名单之外的第二道保险:万一某天真往 4013 段里混进了职能/废弃配置,名字先拦一道。
|
||
// 空数组 = 不过滤。匹配是大小写不敏感的 `Contains`。
|
||
//
|
||
AmbientPedsFormworkNameBlocklist []string `json:"ambientPedsFormworkNameBlocklist"`
|
||
//
|
||
// 位置回馈校正的下限(米):客户端上报位置与服务端账本偏差小于它就是正常误差(车道采样点有间距),不校正。
|
||
// 触发源是 `AskClientNpcFindPath`(67974714) 请求里的 `From` —— 这是**唯一**能拿到散人真实
|
||
// 位置的通道(散人客户端自驱,硬规则 19,服务端没有位置汇报包)。
|
||
// 2026-09-23 用户实测"1 个行人卡在原地不动,打他几下位移后又正常走了" ⇒ 客户端位置与
|
||
// 服务端账本脱节后会永久发散,必须有这条回路把它拉回来。
|
||
//
|
||
AmbientPedsResyncMinMetres float32 `json:"ambientPedsResyncMinMetres"`
|
||
//
|
||
// 位置回馈校正的上限(米):偏差超过它就**不认**(多半不是我们的单位,或者数据异常)。
|
||
// 没有这道闸会把行人拖着乱跳 —— 比不校正更糟。
|
||
//
|
||
AmbientPedsResyncMaxMetres float32 `json:"ambientPedsResyncMaxMetres"`
|
||
// 位置回馈的「软校正」容差(米):客户端真实位置离当前路线折线不超过这个值,
|
||
// 就只把账本归位 + 跳过已走过的点,**不**清路线重派(问路不等于卡住,见 GameRouter.TryResyncAmbientPed)。
|
||
// 设 0 = 关掉软校正,退回「一问路就重规划」(会把行人掐成走走停停)。
|
||
AmbientPedsResyncRouteToleranceMetres float32 `json:"ambientPedsResyncRouteToleranceMetres"`
|
||
//
|
||
// 看门狗 ①(服务端侧):连续派单失败达到这个次数就回收重生。
|
||
// 派不出任务 = 路网状态坏了(车道不存在 / 规划不出来 / 采样推进失败)。这种卡死
|
||
// **服务端自己就能发现**,不需要客户端回馈 —— 是"行人杵着不动"最需要兜底的一路。
|
||
// ⛑️ 必须是"连续 N 次"而不是一次:偶发一次失败是正常抖动,一次就回收会让行人在
|
||
// 正常游走时凭空消失(比站着不动更难看)。
|
||
//
|
||
AmbientPedsStepFailLimit int32 `json:"ambientPedsStepFailLimit"`
|
||
//
|
||
// 看门狗 ②(客户端侧):`AmbientPedsStallWindowMs` 窗口内位置回馈校正达到这个次数就回收重生。
|
||
// 反复脱节说明这个单位贴墙 / 卡地形,校正已经救不回来,换个人换个地方。
|
||
//
|
||
AmbientPedsStallResyncLimit int32 `json:"ambientPedsStallResyncLimit"`
|
||
// 看门狗 ② 的滑动窗口长度(毫秒)。donor round 84 用的就是 30 s。
|
||
AmbientPedsStallWindowMs int32 `json:"ambientPedsStallWindowMs"`
|
||
//
|
||
// 预计走完之后的额外等待(毫秒)。这是"发呆"的唯一来源:行人走完一段路后会站住,
|
||
// 直到这里的时间过去才收到下一段任务。
|
||
// ⛑️ 调小可以让发呆变短,但**如果客户端实际比预估慢,新任务会提前到** ⇒ 行人被拽回上一段
|
||
// 终点再走(瞬移)。想要零瞬移就保持默认,靠加长 AmbientPedsStepMetres 降频率。
|
||
//
|
||
AmbientPedsStepSlackMs int32 `json:"ambientPedsStepSlackMs"`
|
||
//
|
||
// 生成后补发 `SyncLogicAgentEnter`(68719890):把 id 注册进客户端的 logic-agent
|
||
// 同步管理器(任务从它派发)。donor1 第 37 轮加、第 51 轮成功配方里默认开。
|
||
//
|
||
AmbientPedsLogicEnter bool `json:"ambientPedsLogicEnter"`
|
||
//
|
||
// `LogicAgentSyncData.MoveId` = `BattleMoveActionType`:
|
||
// 0 Unknown / 1 Idle / **2 WalkFront** / 3 WalkBack / 4 WalkLeft / 5 WalkRight / 6 Run。
|
||
// (旧位置流路线的参数,保留作对照;任务路线用的是 AgentMovementMethods.Walk=1。)
|
||
//
|
||
AmbientPedsMoveId byte `json:"ambientPedsMoveId"`
|
||
//
|
||
// 用哪条通道建人。
|
||
// "crowd"(默认)= SyncAetherAICrowdAdd(donor_lyco082 实机验证的市民通道)。
|
||
// ★ 这是**唯一带"去游荡"语义**的通道:ClientCrowdInitData 有
|
||
// `TargetLocationReason`(ZoneGraphTargetLocationReason)字段,
|
||
// 填 Wander=1 才是"让客户端 Aether AI 自己挑目标走起来"。
|
||
// static 通道根本没有这个字段 ⇒ 人只能站着。
|
||
// "static" = SyncAetherAIStaticNpcAddData + AgentSyncClientInfo(treeName=PedBase)
|
||
// + SyncManagedLogicAgent + SyncMoveWandering。
|
||
// 人一定出得来、有动画,但 SyncMoveWandering 走的是
|
||
// `WanderingActionModule`(BaseUnit 管线),而我们的 NPC 是
|
||
// AgentEntity(ECS 管线)⇒ 大概率静默失败(donor 证据)。
|
||
// ⛑️ 名字里就写着"Static"——它就是给站桩 NPC 用的。
|
||
//
|
||
AmbientPedsChannel string `json:"ambientPedsChannel"`
|
||
// 维持多少人。客户端 Mass 实体有上限,40 是 donor 验证过的安全值。
|
||
AmbientPedsTarget int32 `json:"ambientPedsTarget"`
|
||
// 在玩家多少米内布点(从真实行人路网取点)。
|
||
AmbientPedsRadiusMeters float32 `json:"ambientPedsRadiusMeters"`
|
||
// 行人离玩家超过多少米就移除。必须 > 布点半径,否则刚生成就被回收。
|
||
AmbientPedsRecycleMeters float32 `json:"ambientPedsRecycleMeters"`
|
||
// 人与人之间最小间距(米),防止叠在一起。
|
||
AmbientPedsMinGapMeters float32 `json:"ambientPedsMinGapMeters"`
|
||
// 维护循环间隔(毫秒)。
|
||
AmbientPedsTickMs int32 `json:"ambientPedsTickMs"`
|
||
// 每轮最多补几个(防止一次性发几十个包把客户端噎住)。
|
||
AmbientPedsTopUpPerTick int32 `json:"ambientPedsTopUpPerTick"`
|
||
// ---- T3-7 真实车流(服务端驱动车辆沿车道跑)------------------------------------------
|
||
//
|
||
// 开启车流。客户端侧**没有**车流密度解锁包(人流有 SyncSetCrowdDensity,车流只有 C2S 的
|
||
// GmChangeAetherVehicleDensity),所以车必须由服务端逐台下发 SyncAetherAIVehicleAddData
|
||
// 并持续推 SyncAetherAIVehicleLaneDatas 才会出现。
|
||
//
|
||
TrafficEnabled bool `json:"trafficEnabled"`
|
||
// 维持多少台车同时在玩家周围跑。
|
||
TrafficTarget int32 `json:"trafficTarget"`
|
||
// 在玩家多少米内的车道上生成。
|
||
TrafficSpawnRadiusMeters float32 `json:"trafficSpawnRadiusMeters"`
|
||
// 车离玩家超过多少米就回收。必须 > 生成半径。
|
||
TrafficDespawnRadiusMeters float32 `json:"trafficDespawnRadiusMeters"`
|
||
//
|
||
// 车道推进包的下发间隔(毫秒)。
|
||
// ★ 默认 50ms = 20Hz,对齐客户端常量
|
||
// AetherVehicleManager.DELTA_TIME_PER_SERVER_FRAME = 0.05(dump.cs:3884945)。
|
||
// 客户端是按「服务端 20Hz 发点」来设计插值的;用 10Hz 会让点间隔与它内部假设不符,
|
||
// 表现为走走停停 + 突然冲刺补距离。缓冲 MAX_PENDING_SYNC_DATA=30 也正好是 1.5 秒。
|
||
//
|
||
TrafficTickMs int32 `json:"trafficTickMs"`
|
||
// 补给 / 回收扫描间隔(毫秒)。
|
||
TrafficReseedMs int32 `json:"trafficReseedMs"`
|
||
// 车道限速的整体倍率(车道自带 speed 中位 9.72 m/s ≈ 35 km/h)。
|
||
TrafficSpeedScale float32 `json:"trafficSpeedScale"`
|
||
//
|
||
// VehicleControlType:1 = AetherVehicle(客户端做车道插值,车会自己沿车道跑 —— 默认),
|
||
// 2 = RaidVehicle(只挂 Aether 数据、不接管驾驶,等价于 T1-16 骇入用的模式,车会停在 AddData 的 Position)。
|
||
// ★ 排查「街上没车」时的对照开关:改成 2 若车出来了,说明 ControlType=1 这条 Aether 车道通道有问题。
|
||
//
|
||
TrafficControlType int32 `json:"trafficControlType"`
|
||
//
|
||
// 车道衔接的接缝容差(米):在「接缝最小」的后继基础上放宽这么多再随机选。
|
||
// 0 = 严格取接缝最小的后继(**实测最优**:>12m 的衔接仅 2.55%,而绝对阈值 8m 是 15%)。
|
||
// 调大 = 路线更多变,但更容易出现「车瞬移一段」。
|
||
//
|
||
TrafficMaxLaneGapMeters float32 `json:"trafficMaxLaneGapMeters"`
|
||
//
|
||
// 换道接缝的**绝对硬上限**(米)。超过就直接判定"开到尽头"并回收该车,不再硬跳。
|
||
// ★ 为什么单独有这个键:TrafficMaxLaneGapMeters 只是"相对容差",
|
||
// 但当一条车道的所有后继都被「方向反折」过滤掉时,挑选逻辑会退回全局最小接缝 ——
|
||
// 那条可能在几百米外。实测日志 worstGap=394.1m;车道数据本身
|
||
// p99 接缝 347m、max 2965m,>30m 的占 13.92%。
|
||
// ⇒ 没有这个上限,车会定期瞬移(并因为瞬移出 despawn 半径被立刻回收,
|
||
// 表现为「车刚生成就没了,不断 despawn+spawn」)。
|
||
// ⛑️ 别设太大:客户端按 0.35 秒的插值窗口平滑位置,30m 的跳 = 约 85 m/s,
|
||
// 肉眼看就是「车瞬间飞过去」(2026-09-28 实测反馈)。接缝分布是两极化的
|
||
// (p50=0.00m、p90=53m),所以收紧到 8m 能滤掉大跳而几乎不影响正常衔接。
|
||
// 调小 = 更少瞬移、但车更早被回收;调大 = 车跑更远、但会出现瞬移。
|
||
//
|
||
TrafficMaxLaneHopMeters float32 `json:"trafficMaxLaneHopMeters"`
|
||
//
|
||
// 给每台车预规划多少段车道(VehicleLaneGraph.PlanRoute,直行优先)。
|
||
// 0 = 关闭预规划,退回「每到一条车道随机挑后继」(旧行为)。
|
||
// ★ 这是「车开很短距离就停下/消失」的结构性解:随机挑后继会让车乱拐、掉头、
|
||
// 钻死路、很快被回收;预规划后车沿道路连续跑,观感从"随机乱窜"变成"正常行驶"。
|
||
// 建议 8~16。太小 = 频繁重新规划(开销),太大 = 一次算太久且路径过期。
|
||
//
|
||
TrafficRouteHops int32 `json:"trafficRouteHops"`
|
||
//
|
||
// LaneDatas 时间戳相对当前时刻的提前量(毫秒)。
|
||
// ★ 默认 350ms,对齐客户端常量 AetherVehicleManager.TARGET_DELAY_TIME = 0.35
|
||
// (dump.cs:3884947,即 TARGET_DELAY_FRAME=7 × 50ms)。
|
||
// 客户端的 TimedSpringInterpolationMethodUnmanaged 是「定时弹簧」:
|
||
// 每个点带一个 TargetTimeStamp,客户端在到达该时刻前把 DeltaDistance 平滑排空。
|
||
// 时间戳 = 当前时刻(lead=0)时目标时刻**已经过去**,弹簧瞬间排空 → 车冲一下 →
|
||
// 然后 deltaDistance=0 停住等下一个包 → 正是「停一下又用很快的速度补一段」。
|
||
// 给一个未来时间戳,弹簧才有时间区间可以平滑铺开。
|
||
//
|
||
TrafficTimestampLeadMs float32 `json:"trafficTimestampLeadMs"`
|
||
//
|
||
// 起步加速度(m/s²)。默认 2.5 ≈ 家用车温和起步(0→35km/h 约 4 秒)。
|
||
// ★ 车「凭空直接起步」就是因为没有这个:原来 spawn 时速度直接 = 限速。
|
||
//
|
||
TrafficAccelMps2 float32 `json:"trafficAccelMps2"`
|
||
// 刹车减速度(m/s²)。默认 5.0(比加速大,符合直觉;急刹感就调低)。
|
||
TrafficDecelMps2 float32 `json:"trafficDecelMps2"`
|
||
//
|
||
// 跟车检测半径(米)。只在这个距离内找前车。
|
||
// ★ 调**小** = 更晚发现前车 → 车开得更冲、更少无故减速,但更容易追尾。
|
||
// 默认 18(原为 30,实测 30 会让车在路口前就莫名减速)。
|
||
//
|
||
TrafficFollowRangeMeters float32 `json:"trafficFollowRangeMeters"`
|
||
// 跟车最小车距(米,保险杠到保险杠)。
|
||
TrafficFollowMinGapMeters float32 `json:"trafficFollowMinGapMeters"`
|
||
// 跟车的横向半宽(米):前车偏离本车前进方向超过这个就不算挡路。
|
||
TrafficFollowWidthMeters float32 `json:"trafficFollowWidthMeters"`
|
||
// 交叉让行:最近接近距离小于这个值(米)判定为会撞。
|
||
TrafficConflictRadiusMeters float32 `json:"trafficConflictRadiusMeters"`
|
||
//
|
||
// 交叉让行:只看未来多少秒内会撞。
|
||
// ★ 调**小** = 更晚才判定要撞 → 车更敢走、路口更少互相让行卡死,但更容易真撞。
|
||
// 默认 1.8(原为 3.0,实测 3.0 会让对向车流在路口互相等)。
|
||
//
|
||
TrafficConflictHorizonSec float32 `json:"trafficConflictHorizonSec"`
|
||
//
|
||
// 交叉让行的横向门槛(米):两车在**彼此**前进方向上的横向偏移都超过这个值 → 只是并行经过,
|
||
// 不算冲突。★ 没有这条,双向相邻车道(横向 3.5m)会被误判成要撞,对向车流互相让行卡死。
|
||
//
|
||
TrafficConflictWidthMeters float32 `json:"trafficConflictWidthMeters"`
|
||
//
|
||
// 让行速度(m/s)。让行方降到这个速度**继续走**,而不是停下 —— 停下会让对面也进入
|
||
// 让行判定,两边来回抖动成死锁。
|
||
//
|
||
TrafficYieldSpeedMps float32 `json:"trafficYieldSpeedMps"`
|
||
//
|
||
// 僵局解除:一台车连续停住这么久(秒)就进入「硬冲」模式,低速强行通过。
|
||
// ★ 没有这个,路口互相让行会形成环形等待(A 让 B、B 让 C、C 让 A),全场堵死。
|
||
//
|
||
TrafficStallReleaseSec float32 `json:"trafficStallReleaseSec"`
|
||
// 硬冲模式持续多久(秒)。
|
||
TrafficStallBoostSec float32 `json:"trafficStallBoostSec"`
|
||
// 硬冲模式的速度上限(m/s)。低速通过,视觉上比原地不动好。
|
||
TrafficStallBoostSpeedMps float32 `json:"trafficStallBoostSpeedMps"`
|
||
// 距车道末端(= 路口/转弯)多少米内开始减速。
|
||
TrafficJunctionSlowMeters float32 `json:"trafficJunctionSlowMeters"`
|
||
// 路口限速倍率(乘在车道限速上)。
|
||
TrafficJunctionSpeedFactor float32 `json:"trafficJunctionSpeedFactor"`
|
||
//
|
||
// 服务端自建「红绿灯相位」总开关(false = 一键回到没有信号灯的行为)。
|
||
// ★ 为什么必须自建:客户端 AetherIntersectionManager 的放行关系
|
||
// (MassIntersectionPeriodDetail.VehicleLaneMetas)在**客户端专属 blob** 里,
|
||
// 服务端只发 TrafficIntersectionPeriodInfo 三个 byte(当前/下一个/铁路相位号)。
|
||
// 我们给的 ZoneStorageDataHandle 客户端读不出真 blob(实测 152/167 报
|
||
// "cannot find static zone info / blobZoneCount: 171")⇒ 客户端不会替我们拦车。
|
||
// ⇒ 只能在服务端按「车道 → 路口 → 进口道腿 → 相位组」自己算放行,红灯方向把目标速度压 0。
|
||
// 代价:灯柱不会亮(那是客户端 GPU 蒙皮资源 honglvdeng.asset),但车的让行行为是原生的。
|
||
// 数据表由 _gen_junctions.py 从 vehicle_lanes.csv 端点聚类生成:
|
||
// data/junctions.csv = id;cx;cz;groupCount;legCount;laneCount
|
||
// data/junction_lanes.csv = laneHandle;junctionId;groupIndex
|
||
//
|
||
TrafficSignalEnabled bool `json:"trafficSignalEnabled"`
|
||
//
|
||
// 每个相位组的绿灯时长(秒)。
|
||
// ⛑ 原为 9:相位组数 {2组:219, 3组:124, 4组:9} ⇒ 3 组周期 33s、4 组 44s,
|
||
// 最坏要等 24~33 秒 —— 2026-09-29 实机就是「有的车一直杵在路口」。缩短到 6。
|
||
//
|
||
TrafficSignalGreenSec float32 `json:"trafficSignalGreenSec"`
|
||
// 相位切换之间的**全红清空**时长(秒)。没有它会对冲的车在路口里撞成一团。
|
||
TrafficSignalClearSec float32 `json:"trafficSignalClearSec"`
|
||
//
|
||
// 单车红灯等待上限(秒)。等超过这么久就**放行**(当作绿灯)。
|
||
// ★ 为什么必须有:周期长度 = 组数 × (绿 + 清空),路口腿数越多等得越久,
|
||
// 单靠调 green/clear 无法给所有路口一个统一的上界。
|
||
// 有了它,任何一辆车的红灯等待都 ≤ 这个值,杜绝「杵在路口不动」的观感;
|
||
// 交叉冲突仍由既有的 yield(TrafficYieldSpeedMps)兜底。
|
||
//
|
||
TrafficSignalMaxWaitSec float32 `json:"trafficSignalMaxWaitSec"`
|
||
//
|
||
// 感应式放行:只有「这个路口真的有别个方向来车」时才按相位让行,否则全绿。
|
||
// ⛑⛑ 2026-09-29 实机逼出来的:我们把路网 6861/14171 ≈ **48% 的车道**都装了灯,
|
||
// 而场上只有 8 台车撒在 620m 半径 ⇒ 绝大多数路口根本没有冲突方,却被按
|
||
// 「N 组里只有 1 组绿」的概率白拦一半时间(实测 spd avg 掉到 1.9、stop=5/8)。
|
||
// 判据:某路口「有车所在的相位组」≥2 组才算有冲突,此时才看相位钟;否则放行。
|
||
// 关掉它 = 回到「所有路口无脑按相位拦车」。
|
||
//
|
||
TrafficSignalYieldToTrafficOnly bool `json:"trafficSignalYieldToTrafficOnly"`
|
||
// 红灯刹停时,在「刹到 0 所需距离」上再加的安全余量(米)。
|
||
TrafficSignalStopMarginMeters float32 `json:"trafficSignalStopMarginMeters"`
|
||
//
|
||
// 车流出生点距玩家的**最小**距离(米)。与 TrafficSpawnRadiusMeters 组成一个环。
|
||
// ⛑ 2026-09-29 新增(原来只有上限,没有下限):
|
||
// ① 车会直接刷在玩家脚边,且出生点固定取车道起点(很多在路口里)⇒「不像在车道上」;
|
||
// ② 更关键:客户端 AetherVehicleViewManager 在 CANDIDATE_GO_DISTANCE_SQ=400(20m)
|
||
// 内会把车流车提升成 GO(完整载具实体),从此不再吃我们的 Aether 车道插值 ⇒ 冻在原地。
|
||
// ⇒ 让玩家身边一直攒着一批不动的车。设成 60 以上可避开这一圈。
|
||
//
|
||
TrafficSpawnMinPlayerDistanceMeters float32 `json:"trafficSpawnMinPlayerDistanceMeters"`
|
||
//
|
||
// 车流车出生后发一条 SyncAetherAIVehicleForceGo(68832908):forceGo=false。
|
||
// 期望语义 = 「这台车不要被提升成 GO(完整载具实体)」,让它一直吃服务端推的
|
||
// Aether 车道插值 ⇒ 玩家走近时**不会冻住**。
|
||
// ⛑ 为什么需要它:客户端 AetherVehicleViewManager 按距离自动提升 GO
|
||
// (20m 候选 / 8m 强制 / 同屏上限 8 台),提升后车改由客户端本地接管;
|
||
// 我们的车是服务端模拟的、不在客户端本地车流里 ⇒ 提升 = 没司机 = 停在原地。
|
||
// 只靠 TrafficSpawnMinPlayerDistanceMeters 躲不开 —— 车会自己开过来。
|
||
// ⛑ 语义未证实:`forceGo=false` 也可能被客户端当成「取消强制」而不影响自动提升。
|
||
// 本开关就是为二分这条通道留的:开着没变化 ⇒ 通道无效 ⇒ 改走注入器
|
||
// (hook CheckShouldNotUseGo 恒 true,RVA 0x0B108F00)。
|
||
// 代价(若生效):近处的车保持简化渲染(ECS 整车,无分离的车轮/车顶件/车灯),
|
||
// 但至少一直在动。
|
||
//
|
||
TrafficSuppressGoRender bool `json:"trafficSuppressGoRender"`
|
||
//
|
||
// 车流车出生后发一条 SyncVehicleAutonomousDrivingState(68349264):isStart=true,
|
||
// 让**客户端本地驾驶系统**接管这台车。
|
||
// ★ 与 TrafficSuppressGoRender 的区别:那条是求「别被提升成 GO」(已证伪,10-01 实机
|
||
// 包发出去了照样停);这条是**反过来给它一个本地司机** —— 客户端自带驾驶、避障、交通规则,
|
||
// 且本地驾驶本来就面向 GO 完整载具 ⇒ 被提升成 GO 之后照样开。
|
||
// ⛑ 客户端**有实现**(GameSceneToClientImpl override,dump.cs:185152 →
|
||
// BaseVehicle.SetAutonomousDrivingState),且 VehicleAutonomousDrivingModule
|
||
// 是每台车的模块、非玩家车专属 ⇒ 车流车也能用。
|
||
// ⛑ **没有**给自驾设目标点的 S2C(唯一带终点的 SyncVehicleStartAI 68915975 客户端无 override)
|
||
// ⇒ 开完之后车往哪开由客户端决定,**未知** —— 本开关就是为了实测这件事(2026-10-01 实验 ①)。
|
||
// 可能结果:①车自己开起来了 ⇒ 车流可整体换管线;②原地不动(缺目标)⇒ 转注入器方案。
|
||
// ⛑ 我们仍在照常推 Aether 车道插值 —— 若两者打架(车抽搐/瞬移),实机会看出来,
|
||
// 届时再加「自驾开启后停止推该车」的开关。
|
||
//
|
||
TrafficAutonomousDrive bool `json:"trafficAutonomousDrive"`
|
||
//
|
||
// ★★ 2026-10-01「方案 A」:车流车改走 Cruise 驱动管线 —— 换掉整套服务端车道模拟。
|
||
// 背景(两轮证伪后改道):
|
||
// ① 服务端通道证伪 —— SyncAetherAIVehicleForceGo(68832908) 与
|
||
// SyncVehicleAutonomousDrivingState(68349264) 都发过了,实机照样
|
||
// 「车一进镜头就变色停下、移出镜头又开」;
|
||
// ② 客户端 hook 证伪 —— 注入器 8 个探针(AetherVehicleViewManager 的
|
||
// Update / UpdatePossibleSwitchGoVehicles / CheckShouldNotUseGo …)24 分钟
|
||
// 全 0 命中 ⇒ 这个类根本没在用,按它设计的 hook 方案作废。
|
||
// ⇒ 与其继续猜客户端,不如换一条我们已验证过的驱动管线:
|
||
// SyncAssignVehicleAITask(68549522) + VehicleCruiseParameters,
|
||
// 让客户端本地 AI 开车(出租车 / 玩家自驾都走的这条,已实机验证)。
|
||
// 开启后每台车的出生流程变成:
|
||
// ① SyncAetherAIVehicleAddData 用 ControlType=2(RaidVehicle) +
|
||
// LaneHandle=-1(与 DetachTrafficVehicleFromLaneAsync 同款写法)
|
||
// ⇒ 关掉车道模拟,把它从 Aether 插值里摘出来;
|
||
// ② 发 Cruise 任务(目标点由我们的车道图算)+ SyncChangeVehicleController
|
||
// + 主动 ack Running(1)(不 ack 任务卡 Pending,车一动不动 —— 09-28 的坑);
|
||
// ③ 该车不再参与服务端每 tick 的车道推进与 LaneDatas 批次。
|
||
// ⛑ 关键未知量:Cruise 能不能驱动空车。出租车/自驾都是车里有人,空车没验过。
|
||
// 若开起来车原地不动,两个方向:① 同时开 TrafficAutonomousDrive
|
||
// (给它一个"本地司机"状态);② 关 TrafficSuppressGoRender
|
||
// (别再跟"提升成 GO"对着干,GO 才是本地 AI 的正常工作形态)。
|
||
// ⛑ 代价:服务端拿不到这些车的实际位置(客户端不上报 AI 车的坐标),
|
||
// 只能用「上一个目标点」当估算位置 ⇒ 回收/避撞判定是近似的。
|
||
// 回滚:设 false 即回到原来的服务端车道模拟。
|
||
//
|
||
TrafficCruiseDrive bool `json:"trafficCruiseDrive"`
|
||
//
|
||
// TrafficCruiseDrive 下每次规划要走多少段车道(PlanRoute 的 hop 数)。
|
||
// 段数越多 ⇒ 目标点越远 ⇒ 车一口气能开越久(不用等刷新)。太大则目标点可能落在
|
||
// 玩家看不见的远处、车提前被按估算位置回收。12 段 ≈ 数百米。
|
||
//
|
||
TrafficCruiseHops int32 `json:"trafficCruiseHops"`
|
||
//
|
||
// TrafficCruiseDrive 下重新下发目标点的间隔(秒)。
|
||
// 车到达终点后会停住等下一次刷新;但服务端不知道它到了没有(无位置上报),
|
||
// 所以只能定时重发:旧 token 先 Abort(4),再从「上一个目标点所在的车道」往后规划。
|
||
// 车没到就重发 ⇒ 新目标仍在它前方,等价续驶;车到了 ⇒ 新目标在它脚下前方,正常续驶。
|
||
//
|
||
TrafficCruiseRefreshSec float32 `json:"trafficCruiseRefreshSec"`
|
||
//
|
||
// TrafficCruiseDrive 下单台车的**强制寿命上限**(秒)。
|
||
// ⛑ 为什么需要:Cruise 车的位置是估算的,按距离回收会不准;不加寿命上限的话
|
||
// 车可能永远达不到回收条件,玩家身边渐渐只剩一批老车、新车补不进来。
|
||
//
|
||
TrafficCruiseMaxLifeSec float32 `json:"trafficCruiseMaxLifeSec"`
|
||
//
|
||
// TrafficCruiseDrive 下:true = 只把最后一个点当目标发给客户端,
|
||
// 让客户端 C# 的 NavigationHelper 自己沿真实路网导航;false = 把整串车道终点都喂进去。
|
||
// ⛑ 为什么默认 true:TargetPointList 是逐点直线的,而我们这份车道几何是
|
||
// 「弦」不是样条 —— 整串喂进去会在弯道上切内弯(压马路牙子/穿绿化带)。
|
||
// 只给终点时客户端必须自己做寻路,走的才是真实道路。
|
||
// (与自驾的 cruiseSingleTarget 同一条结论,09-30 已实机验证。)
|
||
//
|
||
TrafficCruiseSingleTarget bool `json:"trafficCruiseSingleTarget"`
|
||
//
|
||
// TrafficCruiseDrive 下:**快到终点就提前续发**的提前量(米)。
|
||
// ⛑ 为什么需要(2026-10-02 01:4x 用户反馈「一条路上两辆车挤在一起开」的根因链):
|
||
// Cruise 的 CruiseType=0(GoToOneTarget) + ConfigFlags=4 会让车**到点就刹停**,
|
||
// 然后等我们续发下一个目标 —— 哪怕「收到 status=2 立刻续发」,中间仍有百毫秒级的空窗,
|
||
// 车是**停在大马路上的**。别的车开过来 ⇒ 顶住它 ⇒ 客户端 AI 倒车脱困 ⇒ 越堵越死。
|
||
// 有了提前量,车在离终点还有这么远时就拿到新目标 ⇒ 全程不停。
|
||
// ⛑ 太大 ⇒ 车还没跑到就改道(走不到目的地,观感随机);太小 ⇒ 还是会停一下。80m ≈ 3 秒车程。
|
||
//
|
||
TrafficCruiseLeadMeters float32 `json:"trafficCruiseLeadMeters"`
|
||
//
|
||
// TrafficCruiseDrive 下:新目标点与**场上其它车的目标点**的最小间距(米)。
|
||
// 0 = 不检查。用来避免两台车被指到同一个路口 ⇒ 挤在一起。
|
||
//
|
||
TrafficCruiseTargetMinGapMeters float32 `json:"trafficCruiseTargetMinGapMeters"`
|
||
//
|
||
// 车流出生时,**朝着玩家开**的那批车占多少比例(0~1)。默认 0.5 = 一半迎面、一半同向。
|
||
// ⛑⛑ 为什么必须显式控这个比例(2026-10-02 01:4x 用户反馈「对面车道的车很少」):
|
||
// 实测服务端下发方向**本来是均匀的**(六个方向桶 13%~25%,各方向寿命也一样),
|
||
// 但车流按「距玩家 TrafficDespawnRadiusMeters 内」存活 ——
|
||
// · 朝玩家开的车:从 220m 外开进来、擦身而过、很快出圈 ⇒ **在场时间极短**;
|
||
// · 背对玩家开的车:一直在圈里 ⇒ 越积越多。
|
||
// ⇒ 玩家一旦移动(实测日志里玩家沿 +X 走了 3km),稳态下看到的几乎全是同向车。
|
||
// 这是**生存偏差**,不是随机数偏了 ⇒ 只能靠「强制一半的车朝玩家开」来补。
|
||
//
|
||
TrafficSpawnFacePlayerRatio float32 `json:"trafficSpawnFacePlayerRatio"`
|
||
//
|
||
// 出生时**避开已经有车的车道**(2026-10-02 01:5x)。
|
||
// ⛑⛑ 为什么光靠 `TrafficSpawnMinGapMeters`(距离) 不够(用户原话):
|
||
// 「两辆车开**一条车道**,然后**并列**在一起挤着开」。
|
||
// 距离判定读的是 Cruise 车的**推算位置**(dead reckoning),而车实际可能比推算慢很多
|
||
// (被挡/等灯/减速),推算值领先 ⇒ 距离判定形同虚设 ⇒ 两台车生在同一条车道、几乎同一个点
|
||
// ⇒ 客户端 AI 各走各的,谁也不让谁 ⇒ 并排贴着开、车身互相穿模。
|
||
// ⇒ 补一条**车道级**判据:候选车道只要已经有车(车的出生车道)就换一条。
|
||
// ⛑ 车的 `Drive.Lane` 是出生车道且不再更新,所以这是「这条车道最近有车占着」的近似 ——
|
||
// 偏保守(车道会一直占到那台车被回收),但方向上正是我们要的「错开」。
|
||
// 找不到空车道时会自动去掉这条约束再来一轮,不会因此刷不出车。
|
||
//
|
||
TrafficSpawnAvoidSameLane bool `json:"trafficSpawnAvoidSameLane"`
|
||
//
|
||
// TrafficCruiseDrive 下:每台车巡航速度的随机抖动比例(0~0.5)。
|
||
// 默认 0.15 = 每台车在 0.85~1.15 倍之间取一个固定倍率(出生时定下来,全程不变)。
|
||
// ⛑ 为什么需要:所有车同一个速度 ⇒ 一旦两台车挨上就会**永远并排贴着开**(相对速度 0,
|
||
// 谁也甩不开谁)。给了速度差,快车会自然拉开,慢车被落到后面 ⇒ 车距自己就出来了。
|
||
// ⛑ 用「每台车固定倍率」而不是每次下发随机:否则同一台车忽快忽慢,观感像抽搐。
|
||
//
|
||
TrafficCruiseSpeedJitter float32 `json:"trafficCruiseSpeedJitter"`
|
||
//
|
||
// TrafficCruiseDrive 下:巡航速度的绝对上限(km/h)。0 = 不限。
|
||
// ⛑⛑ 为什么光有 TrafficSpeedScale 不够(2026-10-02 03:0x):
|
||
// 车道限速来自 `vehicle_lanes.csv` 第 9 列,实测分布是
|
||
// **9.72 m/s(35km/h) 占 95.5%**、**16.67 m/s(60km/h) 占 4.5%**(主干道/快速路)。
|
||
// 倍率是一视同仁地乘 ⇒ 主干道那批车会跑到 60×1.15 = **69 km/h**,
|
||
// 而城市街道那批是 40 km/h。用户反馈「车流太快」主要就是这 4.5%。
|
||
// ⇒ 有了绝对上限,主干道被压回城市速度,快慢路仍有区分但都不会飞。
|
||
// ⛑ 还有个坑:MaxSpeed 是**出生时**按出生车道限速算一次、之后续发一直沿用
|
||
// (车开到限速不同的路上也不变)。上限对这个坑同样有效 —— 不管生在哪种车道都被压住。
|
||
//
|
||
TrafficCruiseMaxSpeedKmh float32 `json:"trafficCruiseMaxSpeedKmh"`
|
||
//
|
||
// TrafficCruiseDrive 下:一次目标至少要规划出多少段车道,少于这个数就判
|
||
// 「这条车道是个死路口袋」⇒ 换一条重新规划(TrafficCruiseHops 之外的新判据)。
|
||
// ⛑⛑ 为什么必须有它(2026-10-01 23:2x 实机根因):车开完第一次的目标后,下一次从
|
||
// 「上次目标所在的车道」往后规划 —— 而那条车道常常是**死路**(我们路网里 286 个孤岛、
|
||
// 大量车道没有合格后继)⇒ 日志里刷出一串 `points=1/1`,且**每台车每次都拿到同一个目标**
|
||
// (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。
|
||
// 而且多台车撞在同一个目标点上 ⇒ 后车顶住已停的前车、客户端 AI 做倒车脱困后又停。
|
||
//
|
||
TrafficCruiseMinHops int32 `json:"trafficCruiseMinHops"`
|
||
// 新车出生点与已有车辆的最小间距(米),避免「刚生成就贴上」。
|
||
TrafficSpawnMinGapMeters float32 `json:"trafficSpawnMinGapMeters"`
|
||
// -----------------------------------------------------------------------------------------
|
||
// ★★ 路口相位 / 红绿灯(2026-10-02 从捐赠端 PrivateServerPor 移植)—— 让灯柱真的亮起来。
|
||
//
|
||
// 背景:主线以前从没给客户端喂过路口数据(init 的 Intersections 恒为 `[]`、
|
||
// 运行期相位包 `SyncAetherAIIntersectionUpdateDa`(68819947) 一次都没发过)
|
||
// ⇒ 客户端 `AetherIntersectionManager` 建不出 IntersectionDataEntity ⇒ 不 RegisterTrafficLight
|
||
// ⇒ **灯柱从来没被接管**。`trafficSignalEnabled` 那套只是服务端自己压车速,跟灯柱无关。
|
||
// 详见 `modules/红绿灯与路口相位.md`。
|
||
//
|
||
// ⛑ 这三个开关互相独立,回滚粒度:
|
||
// 只想关灯 ⇒ `intersectionUpdateEnabled=false`(init 仍带路口表);
|
||
// 全退 ⇒ 两个都 false 且 `initVariants` 回到 `["top-int7"]`。
|
||
// -----------------------------------------------------------------------------------------
|
||
//
|
||
// SyncAetherAIInitDatas(68704070) 的 Intersections 里带上真路口表(171 个)。
|
||
// 需要配合 initVariants 里选一个 -donor 变体(见 GameAdminBridge)。
|
||
//
|
||
IntersectionsEnabled bool `json:"intersectionsEnabled"`
|
||
//
|
||
// 登录后给每个路口推一次 SyncAetherAIIntersectionUpdateDa(68819947),
|
||
// 并 thereafter 每 IntersectionPeriodSeconds 切一档相位。
|
||
// ★ 这是「灯真的会变色」的那一条;关掉只留静态初始相位。
|
||
//
|
||
IntersectionUpdateEnabled bool `json:"intersectionUpdateEnabled"`
|
||
//
|
||
// 68819947 包里 IntersectionIndex 取什么:
|
||
// "zoneIndex"(默认,= CSV 第一列,客户端用它对 IntersectionDataEntity)或
|
||
// "listIndex"(路口在表里第几行,仅当 zoneIndex 对不上时作为对照试)。
|
||
//
|
||
IntersectionIndexSource string `json:"intersectionIndexSource"`
|
||
//
|
||
// ⛑⛑⛑ **默认 false —— 2026-10-03 14:0x 用客户端报错日志证伪了这个方向,别再打开。**
|
||
// 当时以为:客户端拿 ZoneIndex 查 _zoneIndexToBlobIndex,而主线
|
||
// data/intersections.csv 的第二列(59 ⇒ 184)才是"真 zone index",
|
||
// 捐赠端把 ZoneIndex 填成 InstanceId 是错的。
|
||
// 实测打脸(客户端 AetherIntersectionManager.BuildIntersectionData 的
|
||
// cannot find static zone info 报错,**171 个路口报了 132 个**):
|
||
// · 报错的 132 个 zone 值里,属于 InstanceId 集合的 **0 个**;
|
||
// · 没报错的 38 个 zone 值里,属于 InstanceId 集合的 **38/38**。
|
||
// ⇒ 客户端这份 blob 的"zone"就是**以 intersection instance id 为键**的 171 条
|
||
// (blobZoneCount: 171,值域稀疏:111/401/1100/1631 这种都合法),
|
||
// 所以 ZoneIndex **就该填 InstanceId**,捐赠端原写法是对的。
|
||
// 主线那张表的第二列(184)确实**是** zonegraph 的 zone index(转储
|
||
// 03_ZONEGRAPHS/full_json/vfc_1286_e108_*.json 的 zones 下标),
|
||
// 但那不是客户端路口 blob 用的键 —— 两个"zone index"不是一回事。
|
||
// ⛑ 142/171 个路口有映射(29 个查不到 ⇒ 回退成 InstanceId,恰好是对的)。
|
||
//
|
||
IntersectionUseTrueZoneIndex bool `json:"intersectionUseTrueZoneIndex"`
|
||
// 相位切换周期(秒)。捐赠端实测值 12。太小 ⇒ 灯闪得乱、车刚起步就红灯。
|
||
IntersectionPeriodSeconds float64 `json:"intersectionPeriodSeconds"`
|
||
//
|
||
// 一次最多把多少个路口纳入相位推送(0 = 全部 171 个)。
|
||
// 调小可减少包量(全量是 171 包/周期);按距玩家远近排,**近的优先**。
|
||
//
|
||
IntersectionMaxCount int32 `json:"intersectionMaxCount"`
|
||
//
|
||
// 只推距玩家这个半径内的路口(米,0 = 不限)。0 时行为与捐赠端一致(全量)。
|
||
// ⛑ 玩家走远后新路口要靠下一轮补推 —— 循环每 5s 重扫一次,所以设了也不会漏。
|
||
//
|
||
IntersectionRadiusMeters float32 `json:"intersectionRadiusMeters"`
|
||
//
|
||
// 车流车型池:跳过带 RoofMod(车顶改装件:天窗 / 玻璃顶 / 顶灯)的车型。
|
||
// 排除 7 个车型(81001002/003/008、81003001/004/006/008)。
|
||
// ⛑⛑ 2026-09-29 用户更正:本开关的**立论是错的**,现在默认关闭,仅作历史开关保留。
|
||
// 原文写「这 7 个车型的天窗/玻璃会飘在空中」—— 实测不成立:
|
||
// · **没有**车顶件的车,玻璃同样会浮空;
|
||
// · **有**车顶件的车,也有完全正常的;
|
||
// · 而且只发生在**一部分**车上,不是每台。
|
||
// ⇒ 车顶件与「玻璃浮空」之间**没有因果关系**,把它排除掉只是白白少 7 种车型。
|
||
// 真正的嫌疑是 TrafficCreateSourceType(玻璃是 VehicleGlassDamageModule
|
||
// 的独立可破坏挂件,只有完整装配管线才会更新它;Aether 车道模拟只更新车辆 root ⇒
|
||
// 玻璃留在建车时的高度),以及 TrafficSendBrokenCollision /
|
||
// TrafficDisableNavigation 这两条同源管线。
|
||
//
|
||
TrafficSkipRoofModVehicles bool `json:"trafficSkipRoofModVehicles"`
|
||
//
|
||
// 车流车建实体时的 VehicleCreateSourceType(0=Invalid / 1=Task / 2=Summon / 5=AetherAIBorrowVehicle)。
|
||
// ★★ 这是「车玻璃 / 车窗浮在车身上方」的第一嫌疑。
|
||
// 2(Summon) 是「玩家召唤的车」:客户端对它走**完整装配**管线,包含
|
||
// VehicleDamageModule → VehicleGlassDamageModule(玻璃是独立的可破坏挂件:
|
||
// glassMR / brokenGlassMR / triggerTrans / rangeTrans,见 dump.cs TypeDefIndex 3995)。
|
||
// 而车流车的位移由 Aether 车道模拟驱动 —— 那条线**只更新车辆 root**,玻璃那套挂件建好后
|
||
// 没人再更新 ⇒ 留在建车时的高度,看起来就是「玻璃浮在车上方」。
|
||
// 捐赠端(PrivateServerPor) 车流车恒用 **0(Invalid)**,走最简 Aether 车路径,没有这套装配,
|
||
// 所以它没有这个现象 —— 这就是双端差异的实质。
|
||
// ⛑ 只影响车流车;GM「生成载具」的车仍走 Vehicle.cs 自己的 sourceType。
|
||
//
|
||
TrafficCreateSourceType int32 `json:"trafficCreateSourceType"`
|
||
//
|
||
// 车流车出生后是否补发 SyncVehicleBrokenCollision(当前/最大 HP)。
|
||
// ★ 第二嫌疑:这个包喂的是车辆损伤系统(TotalBrokenCollisionImpulse / 损伤模块 state),
|
||
// 与玻璃挂件同一条管线。当初是为「可骇入」加的(CanHack() 要求 CurrentHp > 0)。
|
||
// 关掉 ⇒ 车流车不能骇入;只作为定位用的二分开关,验完请改回 true。
|
||
//
|
||
TrafficSendBrokenCollision bool `json:"trafficSendBrokenCollision"`
|
||
//
|
||
// 车流车 SyncSpawnVehicle 的 DisableNavigation。
|
||
// ★ 第三嫌疑:我方一直写 true(「位置由车道流决定,别让客户端自己导航」),
|
||
// 但捐赠端写 **false**。若客户端的导航同时负责贴地,关掉它可能让车身与玻璃高度不一致。
|
||
//
|
||
TrafficDisableNavigation bool `json:"trafficDisableNavigation"`
|
||
//
|
||
// 车流车 SyncSpawnVehicle 的 SeatInfos 是否留空(对齐捐赠端)。
|
||
// 捐赠端车流车 SeatInfos = [];我方按车型填 N 个空座位(SeatState=0=Empty)。
|
||
// 座位挂点同样是「建车时摆一次」的物件,留空可排除它作为浮空源的嫌疑。
|
||
// ⛑ 留空可能让「上车」交互找不到座位,验完请改回 false。
|
||
//
|
||
TrafficEmptySeatInfos bool `json:"trafficEmptySeatInfos"`
|
||
//
|
||
// 车流车型池的车长下限(米)。低于此值的摩托 / 三轮(SHIKAGE·Aero 2.1m、M125-T 2.96m)会被排除。
|
||
// 0 = 不限制。
|
||
//
|
||
TrafficMinVehicleLengthMeters float32 `json:"trafficMinVehicleLengthMeters"`
|
||
//
|
||
// 车流车型池的车长上限(米)。高于此值的客车 / 卡车(MM·M300 13.9m、Transporter 8.5m)会被排除。
|
||
// 0 = 不限制。
|
||
//
|
||
TrafficMaxVehicleLengthMeters float32 `json:"trafficMaxVehicleLengthMeters"`
|
||
//
|
||
// 行走速度(m/s)。
|
||
// ⛑️ 只在 "crowd" 通道有效 —— ClientCrowdInitData 自带 DesiredSpeed 字段(donor 用 1.4)。
|
||
// "static" 通道下 ClientStaticNpcInitData **没有**这个字段,只能靠
|
||
// SyncChangeNpcMoveDesiredSpeed(68739456) 补,但**该 RPC 在客户端是空 stub**
|
||
// (dump 里只有 virtual 0x9148360,没有 override)⇒ 发了也没用。
|
||
//
|
||
AmbientPedsDesiredSpeed float32 `json:"ambientPedsDesiredSpeed"`
|
||
//
|
||
// 是否发 SyncChangeNpcMoveDesiredSpeed(68739456)。默认 false —— 客户端是空 stub,
|
||
// 留着只是给未来客户端版本/对照实验用。
|
||
//
|
||
AmbientPedsSendDesiredSpeed bool `json:"ambientPedsSendDesiredSpeed"`
|
||
//
|
||
// "crowd" 通道的 AgentPersonaConfig id。donor_lyco082 实测 45200091。
|
||
// ⛑️ NpcFormworkId 必须是 AgentConfig id(4013xxxx),填成 persona id 客户端会打
|
||
// `创建AgentUnit失败!AgentConfigId为0!` 并丢掉这个人。
|
||
//
|
||
AmbientPedsCrowdPersonaId uint32 `json:"ambientPedsCrowdPersonaId"`
|
||
//
|
||
// "crowd" 通道的 ZoneGraphTargetLocationReason(1 字节)。dump 权威枚举:
|
||
// 0=None 1=Wander 2=SmartObject 3=Escape 4=StaticNpcReturn
|
||
// 5=QuickRun 6=VehicleLanePathWander 7=VehicleLanePathQuickRun
|
||
// ⛑️⛑️ **默认改成 1(Wander)** —— 之前填 0(None) 就是"人出现但不走"的直接原因:
|
||
// 等于告诉客户端"这个人没有目标位置意图"。donor_lyco082 抄的是 0,所以他们也没走起来。
|
||
//
|
||
AmbientPedsTargetLocationReason byte `json:"ambientPedsTargetLocationReason"`
|
||
//
|
||
// "crowd" 通道的 ClientCrowdInitData.ActionId(ushort)。
|
||
// ★★★ donor1 第 38 轮实测语义:这是**行为动作组(ActionGroup)**,不是 POI 动作 id ——
|
||
// KNOWLEDGEBASE 证实它索引的是另一张 C# crowd-action 表。
|
||
// donor1 面板实测值:**400 = NPC 默认** / 410 = 同伴 / 23 = 宠物 / 0 = 非法(路人发 0 站着)。
|
||
// ⛑️ 之前填 1(当 POI Walk 用)是误读;09-20 起 400(与 donor1 成功配方 commandActionId=400 一致)。
|
||
//
|
||
AmbientPedsCrowdActionId uint16 `json:"ambientPedsCrowdActionId"`
|
||
//
|
||
// "static" 通道的 CPoiActionId(= AgentSyncClientInfo.initPoiActionId)。
|
||
// 同上:1=走路 / 2=idle / 4=跑步。填 0 ⇒ 无动作 ⇒ 站着。
|
||
// ⛑️ 非 0 时会额外发 SyncAgentPlayPOIAction(id, poi, IsActive=true, speed=1)。
|
||
// ⚠️ 实测:`UrbanDiversityPOIActionConfig` 里 id=1(走路) 的 **ApplyRootMotion=False**
|
||
// ⇒ POI 动作只是"走的动画状态",**本身不带位移**。位移必须靠移动系统。
|
||
//
|
||
AmbientPedsPoiActionId uint32 `json:"ambientPedsPoiActionId"`
|
||
//
|
||
// ⛑️⛑️ "crowd" 通道建完人后**必须补发 SyncManagedLogicAgent(68771737)**。
|
||
// client 端没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。
|
||
// ★★★ donor1 第 81 轮(三重证据):它同时是**客户端来要路径(AskClientNpcFindPath)的前提** ——
|
||
// 不发它,状态机关掉、客户端不来问,服务端推多少移动任务都没人接
|
||
// (成功会话 12 条 vs 失败会话 2 条、AskClientNpcFindPath 310 次 vs 0 次)。
|
||
// 2026-09-20:切到 crowd 后 NPC 全 T-pose,就是这个包没发。
|
||
//
|
||
AmbientPedsCrowdManagedLogicAgent bool `json:"ambientPedsCrowdManagedLogicAgent"`
|
||
//
|
||
// `AskClientNpcFindPath`(67974714) 是否用真实路网作答。
|
||
// 关闭 ⇒ 回空路径(`FF 01 FF 01`),也就是这条包落地之前的行为。A/B / 回滚开关。
|
||
//
|
||
NpcPathPlanEnabled bool `json:"npcPathPlanEnabled"`
|
||
// 一条路径最多给多少个点(donor 的 NpcPathAskMaxPoints)。
|
||
NpcPathMaxPoints int32 `json:"npcPathMaxPoints"`
|
||
// 重采样最小点间距(米,donor 的 MinMetres)。
|
||
NpcPathMinMetres float32 `json:"npcPathMinMetres"`
|
||
// 规划不出路时,是否改给「最近车道上的脱困点」(1 个点)而不是回空路径。
|
||
// 空路径会让客户端原地站着不动(spawn 后的 From==To、卡在花坛/门框里的单位都只这样问)。
|
||
NpcPathEscapeEnabled bool `json:"npcPathEscapeEnabled"`
|
||
// 脱困点沿车道前进的距离(米)。太小挪不出花坛,太大可能跨到别的路口。
|
||
NpcPathEscapeMetres float32 `json:"npcPathEscapeMetres"`
|
||
//
|
||
// "crowd" 通道建完人后补发 SyncAgentPlayPOIAction(id, ActionId, active, 1f)。
|
||
// ClientCrowdInitData.ActionId 只是初始值,显式播一次更稳。
|
||
//
|
||
AmbientPedsCrowdPlayPoiAction bool `json:"ambientPedsCrowdPlayPoiAction"`
|
||
//
|
||
// 生成后发 SyncAgentForceGo(id, true) = 68698324(客户端有真实现 0x12753C90)。
|
||
// 语义就是"已生成但站着不动的 agent 用它启动行为树" —— 正对"人出现但不走"。
|
||
//
|
||
AmbientPedsForceGo bool `json:"ambientPedsForceGo"`
|
||
//
|
||
// MoveWanderingData.pathTags(NavTag 位掩码)。
|
||
// ⛑️⛑️ 填 0 = `NavTagType.None` = 没有任何"可走区域" ⇒ 寻路必然失败,
|
||
// `WanderingActionModule.wanderFailCount` 累到 MaxWanderFailCount(10) 就**永久站住**。
|
||
// 6 = WalkableAndJump(最宽松)。
|
||
//
|
||
AmbientPedsWanderPathTags uint32 `json:"ambientPedsWanderPathTags"`
|
||
//
|
||
// 游荡扇形窗口(度),对应客户端 WanderingAreaDate.{leftAngle,rightAngle}。
|
||
// ⛑️ 两个都填 0 = 零宽扇形 ⇒ 选不出目标。-180/180 = 全向。
|
||
//
|
||
AmbientPedsWanderLeftAngle float32 `json:"ambientPedsWanderLeftAngle"`
|
||
AmbientPedsWanderRightAngle float32 `json:"ambientPedsWanderRightAngle"`
|
||
// 单次游荡最短/最远位移(米)。donor 人流用 8..35;固定 NPC 用 0..15。
|
||
AmbientPedsWanderMinDis float32 `json:"ambientPedsWanderMinDis"`
|
||
AmbientPedsWanderMaxDis float32 `json:"ambientPedsWanderMaxDis"`
|
||
//
|
||
// 游荡预算(秒)。MoveWanderingData.MaxTime 是**总时长**,走完客户端就永久站住,
|
||
// 所以必须在耗尽前重发。donor:人流 150,固定 NPC 50。
|
||
//
|
||
AmbientPedsWanderSeconds float32 `json:"ambientPedsWanderSeconds"`
|
||
// 提前多少秒重发游荡(要 < WanderSeconds)。填 0 = 不续期。
|
||
AmbientPedsWanderRearmSeconds float32 `json:"ambientPedsWanderRearmSeconds"`
|
||
}
|
||
|
||
//
|
||
// 车辆改装店(ApplyCar / RepairShop)2026-09-25 移植自俄语上游 donor。
|
||
// 回滚 = enabled:false ⇒ AskTeleport 不再 echo、两个 waypoint 只 ack 不推位移、
|
||
// 不再补推 SyncStartSummonVehicle(即逐字节回到移植前的行为)。
|
||
//
|
||
type RepairShopSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
// 生成/召唤后补推 SyncStartSummonVehicle(68681875) —— 没有它,
|
||
// 客户端 ApplyCarManager.IsMySummonVehicle 不认这辆车,
|
||
// 门禁第一关就弹「只能驾驶自己的载具进入」(查全流程找不到别的原因,donor 踩出来的)。
|
||
PushSummonStart bool `json:"pushSummonStart"`
|
||
// 离场落地点的街道微调(作者给定的 LeaveWaypoint 已经离入口触发区 25m+,
|
||
// 正常情况下不需要动;真出现「出门立刻又被拽回店」时再调)。
|
||
ExitOffsetX float32 `json:"exitOffsetX"`
|
||
ExitOffsetZ float32 `json:"exitOffsetZ"`
|
||
//
|
||
// 买过的改装件是否落盘(save/vehicles/<pid>.json)。
|
||
// 默认 false = 与 donor 未接 store 时一致:重启即清空。
|
||
// 想让改装结果跨重启保留就置 true(读取侧在进世界时做)。
|
||
//
|
||
PersistTuning bool `json:"persistTuning"`
|
||
//
|
||
// 刷车包(SyncLogicVehicleEnter / SyncSpawnVehicle)里要不要带纯数值件
|
||
// (PartTag 13 引擎 / 20 刹车数值 / 21 传动 / 22 车身数值)。
|
||
// 默认 false,理由(donor 2026-09-23 实机):这四类件在 VehiclePartConfig 里
|
||
// 没有 PartPath,只有 PartPerfID;把它们混进 Parts 会让真车装配把对应槽位
|
||
// 冲空 ⇒ 全黑 / 透明 / 不可驾驶的车(81005006 带 engine 9995002 + transmission
|
||
// 9995404 + body_value 9995602 复现过)。
|
||
// 但它们又是唯一的数值通道(客户端本地根本不应用它们,只认服务端的回声),
|
||
// 所以留这个开关做实机 A/B:置 true 且车变黑 ⇒ 立刻改回 false。
|
||
//
|
||
SendStatParts bool `json:"sendStatParts"`
|
||
}
|
||
|
||
type VehicleSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
FleetIds []uint32 `json:"fleetIds"`
|
||
DesiredApproachMeters float32 `json:"desiredApproachMeters"`
|
||
MinimumApproachMeters float32 `json:"minimumApproachMeters"`
|
||
SearchRadiusMeters float32 `json:"searchRadiusMeters"`
|
||
ZoneStorageDataHandle int32 `json:"zoneStorageDataHandle"`
|
||
//
|
||
// 玩家 F1 / 手机叫车(AskSummonVehicle 67012447)时,车不要在脚下凭空出现,
|
||
// 而是先刷在附近车道上,再自己开过来(2026-10-03)。配置见 SummonDriveInSettings。
|
||
//
|
||
SummonDriveIn SummonDriveInSettings `json:"summonDriveIn"`
|
||
}
|
||
|
||
//
|
||
// 「召唤载具从车道开过来」(2026-10-03)。
|
||
// <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<VehicleStopParameters>)
|
||
// + 推 SyncVehicleAutonomousDrivingState(false)(照抄出租车的 StopTaxiTaskAsync)。
|
||
// ⛑⛑ 这是 2026-10-03 实机三个症状的共同解药:客户端
|
||
// SummonVehicleManager 是用 RegisterTaskAIDeletedEvent(token) 等任务被删除,
|
||
// 才走 OnSummonShowFinish 结束「召唤演出」。任务一直挂着 ⇒
|
||
// ① 车还在 AI 手里,玩家方向盘无效(车开不动);
|
||
// ② 客户端认为这台车还在召唤流程中(下车后再也上不去);
|
||
// ③ 上一台车的演出没结束(「叫车小助手忙」,叫不了第二台)。
|
||
// 关掉它 ⇒ 回到这三个症状(排障对照用)。
|
||
//
|
||
EndTaskOnArrival bool `json:"endTaskOnArrival"`
|
||
//
|
||
// 车开到之后,把 SyncChangeVehicleController 的 controllerPid 还原成 0。
|
||
// ⛑⛑ 这是「车开不动」的真凶(2026-10-03 18:4x):下发 Cruise 时我们照自驾的老规矩
|
||
// 发了 SyncChangeVehicleController(controllerPid = Profile.PlayerPid) ——
|
||
// 那是必需的(不锁主控权,玩家的方向盘输入会盖掉 AI 任务,车根本开不过来)。
|
||
// 但到达后没人还原 ⇒ 主控权一直挂在服务端 ⇒ 玩家坐进主驾也只算乘客,
|
||
// 踩油门没反应。以前召唤车能开,正因为这条路径从没发过这个包。
|
||
// 实机佐证:18:38:55 坐 seat=0(主驾)一样开不动 ⇒ 不是座位问题。
|
||
// 关掉它 ⇒ 回到「车开不动」(排障对照用)。
|
||
//
|
||
ReleaseControlOnArrival bool `json:"releaseControlOnArrival"`
|
||
//
|
||
// 车开到之后,服务端补一发 SyncTeleportVehicle(68088911, Reset=true),把车钉在
|
||
// 它现在真正的位置(AskVehicleMove 上报的最新值)。
|
||
// ⛑⛑ 为什么必须有(2026-10-03 实机):服务端在生成时下发过
|
||
// SyncAetherAIVehicleAddData,里面写着生成点的坐标,而
|
||
// LaneHandle=-1 ⇒ 这份 Aether 坐标永远不会被更新。车是被 Raid 通道的
|
||
// Cruise 开过来的,客户端两套表示就此分叉:看得见的车在身边,Aether 里那台还在生成点。
|
||
// 玩家一上车,客户端按 Aether 那份数据对齐 ⇒ **车瞬移回生成点**。
|
||
// 位置取自 AskVehicleMove(AI 开车期间客户端每秒都在报),所以这一发在视觉上是
|
||
// 「原地不动」,只是把两边强行对齐。
|
||
// 关掉它 ⇒ 回到上车瞬移生成点的行为(排障对照用)。
|
||
//
|
||
SyncPositionOnArrival bool `json:"syncPositionOnArrival"`
|
||
// 每次召唤都打选点日志。排障时才开(正常玩的时候每叫一次车一条)。
|
||
LogEverySummon bool `json:"logEverySummon"`
|
||
}
|
||
|
||
//
|
||
// 载具路线规划。默认全开是因为「有路线」不可能比「没路线」更糟 —— 客户端当时每秒问一次,
|
||
// 而返回形状本来就已被客户端接受(DefaultReturnCatalog 的 complex 677),我们只是把两个
|
||
// 空列表填上真实点。**回滚 = Enabled=false**,不需要重编译。
|
||
//
|
||
type VehicleNavigationSettings struct {
|
||
Enabled bool `json:"enabled"`
|
||
// 把「车的位置」吸到最近车道的最大距离(米)。开车时车应该在路上,40m 已经很宽松。
|
||
StartSnapMeters float32 `json:"startSnapMeters"`
|
||
// 把「目的地」吸到最近车道的最大距离(米)。目的地可能是个楼前的点,所以给到 120m。
|
||
TargetSnapMeters float32 `json:"targetSnapMeters"`
|
||
// 下行点集上限。这是一条 INVOKE 的回包,192 点 × 12B ≈ 2.3KB,够画满一条路线。
|
||
MaxPathPoints int32 `json:"maxPathPoints"`
|
||
// 丢掉间距小于该值的相邻点(米)。车道弦本身就有大量重合端点。
|
||
MinPointSpacing float32 `json:"minPointSpacing"`
|
||
// Dijkstra 扩展上限。14171 条车道正常几百次就收敛,卡住说明图有问题。
|
||
MaxExpansions int32 `json:"maxExpansions"`
|
||
// 是否填 CenterPoints。今天线上请求带的正是 NeedCenterPoints=true。
|
||
IncludeCenterPoints bool `json:"includeCenterPoints"`
|
||
//
|
||
// 点序翻转为「终点 → 车」。捐赠端探针断言过这是客户端要的顺序,我方**尚未实测**;
|
||
// 若导航线箭头反了(或自驾往反方向开),改成 false 即可。
|
||
//
|
||
ReversePoints bool `json:"reversePoints"`
|
||
//
|
||
// 是否下发 S2C SyncVehicleAutonomousDrivingState(68349264)—— 也就是「车会不会自己开」。
|
||
// 这是客户端自动驾驶的唯一开关(见 GameRouter.VehicleNav.cs 的取证注释):
|
||
// false ⇒ Start/Stop 照常回 Ok,但不推状态包,车永远不会动,等于回到 2026-09-27 之前的行为。
|
||
// 回滚不需要重新编译。
|
||
//
|
||
AutonomousDrivingEnabled bool `json:"autonomousDrivingEnabled"`
|
||
//
|
||
// 巡航速度(米/秒)。写进 VehicleCruiseParameters.MaxSpeed / DefaultSpeed。
|
||
// 12 m/s ≈ 43 km/h,市区够看;太快会在我们这份「弦」几何上切弯。
|
||
//
|
||
CruiseSpeed float32 `json:"cruiseSpeed"`
|
||
// 巡航路点上限。AI 任务包不宜过大,64 点够跑几百米的主干道。
|
||
CruiseMaxPoints int32 `json:"cruiseMaxPoints"`
|
||
// 判定「到达终点」的距离(米)。太小车会在终点附近来回蹭。
|
||
CruiseArrivalMeters float32 `json:"cruiseArrivalMeters"`
|
||
//
|
||
// 巡航任务的 DrivingFlags(客户端枚举 VehicleTaskDrivingFlags,定义见
|
||
// ux_rpc/payload_types.dump.cs:193)。默认 16443 = 1|2|8|16|32|16384:
|
||
// <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 <= 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"`
|
||
// 离开多少米外撤掉(必须 > 进入值,否则边界上每 tick 反复增删)。默认 420。
|
||
ExitMeters float32 `json:"exitMeters"`
|
||
// 跟随循环间隔。默认 1500 ms。
|
||
TickMs int32 `json:"tickMs"`
|
||
// 每个 tick 最多放几个(客户端会丢大批量包的尾巴)。默认 2。
|
||
PerTick int32 `json:"perTick"`
|
||
// 没命中日程活动时的兜底 POI 动作(UrbanDiversityPOIActionConfig)。默认 17 站姿闲置。
|
||
PoiActionId uint32 `json:"poiActionId"`
|
||
//
|
||
// 用哪条出场通道。默认 false = Aether 静态 NPC(SendCivilianAsync,与满城市民同款,
|
||
// 已验证站得住 / 有闲置 / 有 POI)。
|
||
// 设 true 改走 SyncRaidBattleUnitAgent(46 字段版 AgentSyncClientInfo,
|
||
// 多带 SpoonAgentId / SpoonIndex)。
|
||
// ⛑ 这条是「表演没播出来」的 A/B 对照用:两条通道的 AgentDataSetsActivityCfgId
|
||
// 是不是同一套消费逻辑还没验证过。⚠️ 只在 DailyLife 关闭换装时切它 ——
|
||
// 这条通道一旦带上 AIAgentInfo.FightSpiritId 就会换皮只剩脸(规则 141)。
|
||
//
|
||
AgentUnitChannel bool `json:"agentUnitChannel"`
|
||
//
|
||
// 常驻 NPC 的 unitId 走哪一段。默认 false = 与满城市民同款
|
||
// StaticNpcIdFloor(8300000000000000000 段)。
|
||
// 设 true 改走 GameAdminBridge.NextRetirableNpcUid()(0x300000000000 段,
|
||
// 硬规则 165 里那条「SyncLogicAgentLeave 确实收得掉」的段)。
|
||
// ⛑⛑ 这是**排障开关,不用重新编译**:09-25 22:2x 实机发现
|
||
// SyncAetherAINpcRemove 删不掉 8300 段那具身体(跑步机上的里希一直不消失),
|
||
// 若补发 SyncLogicAgentLeave(已加,同时发两条)仍然无效,就把这个改成 true
|
||
// 再让常驻重新摆一次(配置热加载,摆人才读这个值)。
|
||
// ⛑ 风险:Aether 静态 NPC 通道是否吃这一段还没验过 —— 改完若人直接不出现,改回 false 即可。
|
||
//
|
||
RetirableUidSegment bool `json:"retirableUidSegment"`
|
||
//
|
||
// 是否要求活动一定带世界坐标。默认 true —— 不带坐标的活动(临时点位)无法落地,
|
||
// 这时会退到同 tag 在本场景里最近的一条有坐标活动。
|
||
//
|
||
RequirePosition bool `json:"requirePosition"`
|
||
//
|
||
// 一个角色在当前时段查不到任何可落地的活动时(没有 AgentTag、没有日程表、活动在别的
|
||
// 场景、或官方压根没编坐标)要不要仍然把他放出来。默认 false = 不放。
|
||
// ⛑⛑ 设成 true 会退化成「谁都往玩家脚边塞」:这类角色的坐标只能取玩家位置 +3m,
|
||
// 于是登录和每次传送都会有一圈人贴着你刷出来(09-25 玩家报的那个现象就是它)。
|
||
// 只有想让 OC 占位角色也露个脸时才开,代价是他们不会「过日子」,只是站着。
|
||
//
|
||
PlaceWithoutActivity bool `json:"placeWithoutActivity"`
|
||
//
|
||
// 手工常驻点……(见 RosterNpcStaticSpot)。
|
||
//
|
||
StaticSpots []RosterNpcStaticSpot `json:"staticSpots"`
|
||
//
|
||
// 放人前先看目标点位多少米内(3D)有没有别的 NPC 已经站着,有就不放 —— 避免和市民 /
|
||
// 功能 NPC / 切人留场的同一角色叠在一起。默认 2 米;设 0 关掉这个检查。
|
||
// ⛑ 用 3D 距离是刻意的:同一栋楼 1 楼和 2 楼各站一个属于正常,只有真贴在一起才算冲突。
|
||
//
|
||
MinSeparationMeters float32 `json:"minSeparationMeters"`
|
||
//
|
||
// ★ 按角色覆盖出场 POI 动作 / 要不要发活动 id。默认空。
|
||
// 为什么需要它:09-25 20:0x 实机确认 —— 里希按日程站到跑步机点上了,推
|
||
// SyncAgentPlayPOIAction(68459398) 的 198「站姿_运动_跑步机跑步」
|
||
// 之后他真的跑起来了(POI 通道有效),但上半身还叠着一层「打电话」
|
||
// —— 那是生活日程的 ambient:出场包带 AgentDataSetsActivityCfgId
|
||
// 却没有 loop pose 时客户端的兜底表现(规则 160 那句「没有 loop pose 就只能播 ambient」)。
|
||
// 所以想跑得干净,得让这个角色出场就带 198 且不带活动 id。
|
||
// ⛑ 只对列出来的角色生效,其余照旧 —— 别把 52 个角色全改成跑步机。
|
||
//
|
||
PoiOverrides []RosterNpcPoiOverride `json:"poiOverrides"`
|
||
//
|
||
// ★★ 按活动覆盖出场 POI 动作(推广版,09-25 23:0x 加)。默认空。
|
||
// PoiOverrides 是按角色写死的(里希 → 跑步机 198),只能管一个人;
|
||
// 想让其余有日程的角色也「真的在做那件事」,得按活动 id 给动作 ——
|
||
// 同一个角色一天里换好几个活动(塔菲 13 条:吃汉堡 / 看漫画 / 猫咖…),按角色写死没法覆盖。
|
||
// ⛑⛑ 数据来源与局限(务必看清):
|
||
// · AgentDataSetsActivityConfig(284 行)只有中文名 + 坐标,没有 POI 动作 id;
|
||
// UrbanDiversityPOIActionConfig(3978 行)有中文名 ⇒ 只能**按名字匹配**推出来。
|
||
// 表里那一份是脚本按「核心词完全相等 > 包含 > 2-gram 重叠」自动生成的,
|
||
// 必然有猜错的,看到不对就改 poiActionId(配置热加载,不用重启)。
|
||
// · 朝向没有表可查:`SceneitemConfig` 只是模板注册表(无世界坐标),
|
||
// world dump 里也没有跑步机那类物件的摆放旋转 ⇒ facing 只能实机调,同里希那次。
|
||
// · 配了 POI 的活动默认 不带 AgentDataSetsActivityCfgId(否则生活日程的
|
||
// ambient 会盖掉 POI,里希「一边打电话一边跑步」就是这个)。
|
||
//
|
||
ActivityPoiOverrides []ActivityPoiOverride `json:"activityPoiOverrides"`
|
||
}
|
||
|
||
//
|
||
// 一条「活动 → 出场表现」的映射(按活动 id,不是按角色)。
|
||
// 活动 = AgentDataSetsActivityConfig.Id(NpcActivityCatalog.json 里的 Id)。
|
||
//
|
||
type ActivityPoiOverride struct {
|
||
// 活动 id(AgentDataSetsActivityConfig.Id,如 11400012 = 里希「跑步机」)。
|
||
ActivityId uint32 `json:"activityId"`
|
||
//
|
||
// 出场 POI 动作 id(UrbanDiversityPOIActionConfig,3978 条)。
|
||
// 填 0 = 这条不覆盖,退回默认(RosterNpcSettings.PoiActionId + 照常发活动 id)。
|
||
//
|
||
PoiActionId uint32 `json:"poiActionId"`
|
||
// 朝向(度)。null(默认)= 沿用 0。没有表可查,只能实机调。
|
||
Facing *float32 `json:"facing"`
|
||
//
|
||
// 出场包还要不要带 AgentDataSetsActivityCfgId。
|
||
// null(默认)= 本条配了 POI 就不带(让 POI 生效),没配就照常带。
|
||
//
|
||
SendActivityCfgId *bool `json:"sendActivityCfgId"`
|
||
// 这个活动出场不发骇客手机。null(默认)= 照常发。
|
||
SkipPhone *bool `json:"skipPhone"`
|
||
//
|
||
// ★★ 落地坐标微调(米,**世界轴**,不是角色本地轴)。三个都是 null = 不偏移。
|
||
// 为什么需要:官方 AgentDataSetsActivityConfig.MapPosition 是策划打的点,
|
||
// 跟场景里的家具 / 机器经常差那么几十厘米 —— 人飘在床边上、背对着娃娃机站,
|
||
// 这些都是没有表可查的(规则 171②:朝向无表可查),只能实机一点点挪。
|
||
// ⛑⛑ 是**世界轴**:OffsetX = +X 方向,OffsetZ = +Z 方向,OffsetY = 上。
|
||
// 判「哪边是右」要看这条活动落地时的朝向(Facing,没配就是 0):
|
||
// 朝向 f(度)⇒ 前方 = (sin f, 0, cos f),右手方向 = (cos f, 0, -sin f)
|
||
// (规则 181:喂 MathF.Sin/Cos 前必须把度转弧度)。
|
||
// ⇒ 朝向 0 时右 = **+X**;朝向 90 时右 = **−Z**。
|
||
// 改完朝向要重新对一遍偏移,别照抄。
|
||
// 生效范围:常驻摆放 / 切人留场(真去演活动那具)/ 主动切到他日程点 —— 三处同一个偏移,
|
||
// 否则「走过去看到的她」和「切过去落地的她」会差 0.8 米。
|
||
// 实现见 GameRouter.ApplyActivityOffset。
|
||
//
|
||
OffsetX *float32 `json:"offsetX"`
|
||
OffsetY *float32 `json:"offsetY"`
|
||
OffsetZ *float32 `json:"offsetZ"`
|
||
// 备注(自动匹配到的候选 / 人工校对的依据),只给人看。
|
||
Note *string `json:"note"`
|
||
}
|
||
|
||
//
|
||
// gameplay.rosterNpc.poiOverrides 的一条:某个角色出场时用哪个 POI 动作、
|
||
// 以及还要不要给他发 AgentDataSetsActivityCfgId。
|
||
//
|
||
type RosterNpcPoiOverride struct {
|
||
// 角色(FightSpiritConfig.Id / roster 里的 spiritId)。
|
||
SpiritId uint32 `json:"spiritId"`
|
||
//
|
||
// 出场 POI 动作 id(UrbanDiversityPOIActionConfig,共 3978 条)。
|
||
// 198 = 站姿_运动_跑步机跑步;17 = 站姿闲置(默认)。
|
||
// ⛑ 跑步机只有两条:198 跑、500997「通用04_跑步机_idle」(MainActions 为空)。
|
||
//
|
||
PoiActionId uint32 `json:"poiActionId"`
|
||
//
|
||
// 出场包还要不要带 AgentDataSetsActivityCfgId。
|
||
// false = 不带(活动坐标照用,但不进生活日程状态 ⇒ 没有 ambient 那层打电话);
|
||
// true = 带(走生活日程,POI 会被 ambient 盖掉);
|
||
// null(默认)= 沿用 RosterNpcSettings.DailyLife。
|
||
//
|
||
SendActivityCfgId *bool `json:"sendActivityCfgId"`
|
||
//
|
||
// 出场朝向(度,与 GameRouter.Civilians.cs:107 的 facing * MathF.PI / 180
|
||
// 同一套)。null(默认)= 沿用常驻 NPC 现在传的 0。
|
||
// ⛑ 跑步机朝向没有配置表可查:`GadgetCatalog.json` 只有 prefab 注册表,
|
||
// world dump 里 `wodegym_treadmill` 也只出现在 prefab 列表(有 GraphId/pathId,
|
||
// 没有世界摆放与旋转)⇒ 只能实机调。配置有热重载,改完不用重启,
|
||
// 但要等角色被重新放置(走远 420m 再走回来)才生效。
|
||
// ⛑ 客户端没有「原地转身」的帧:`SyncAgentForceGo`(68698324) 只有 `(ulong id, bool)`,
|
||
// 带不了朝向 ⇒ 朝向只能在出场包里给。
|
||
//
|
||
Facing *float32 `json:"facing"`
|
||
//
|
||
// 这个角色不发手机(gameplay.hackNpc 那部)。默认 false = 照常发。
|
||
// 为什么需要它:09-25 实机 —— 里希出场带 POI 198 会在跑步机上跑,但上半身一直
|
||
// 「拿着手机/打电话」,要**打他几下**(刺激打断 usage)才会变成正常跑步。
|
||
// 那部手机不是生活日程 ambient,是骇客功能发的:`SendCivilianAsync` 里
|
||
// POI 198 的 UsageId = 0 ⇒ `poiUsageAlreadyHackable = false` ⇒
|
||
// `MarkNpcHackableOnSpawnAsync` 补一部手机 + usage 6(玩手机),和跑步动作叠在一起。
|
||
// 设 true 就让这个角色跳过发手机(可骇标记照旧发,只是没手机道具)。
|
||
// ⛑ 只对列出来的角色生效 —— 用户明确要「每个 NPC 都有手机」,所以默认都发。
|
||
//
|
||
SkipPhone bool `json:"skipPhone"`
|
||
//
|
||
// ★★ 这个角色「留场期间」轮换的 POI 组(开关
|
||
// SwitchAnimations.LeftBehindPoiRotation)。
|
||
// 洗牌袋:整组打乱后依次播,播完一轮重新洗牌 ⇒ 随机 + 一轮内不重复。
|
||
// null / 空数组 = 这个角色不轮换(沿用出场那个 POI,或走 wander)。
|
||
// ⛑⛑ 只在这个角色没在生活日程里时才生效(出场包
|
||
// AgentDataSetsActivityCfgId == 0)—— 带着活动 id 时客户端会走
|
||
// 生活日程,ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步"就是这么来的)。
|
||
//
|
||
LeftBehindPoiGroup []uint32 `json:"leftBehindPoiGroup"`
|
||
// 这个角色单独指定停留时长(毫秒);null = 用 SwitchAnimations.LeftBehindPoiDwellMs。
|
||
LeftBehindPoiDwellMs *int32 `json:"leftBehindPoiDwellMs"`
|
||
}
|
||
|
||
//
|
||
// gameplay.rosterNpc.staticSpots 的一条。固定站位,不随时间变、不跟着玩家跑。
|
||
//
|
||
type RosterNpcStaticSpot struct {
|
||
// 角色(FightSpiritConfig.Id / roster 里的 spiritId)。
|
||
SpiritId uint32 `json:"spiritId"`
|
||
// 只在哪个场景生效。0 = 任何场景都算。
|
||
Raid uint32 `json:"raid"`
|
||
X float32 `json:"x"`
|
||
Y float32 `json:"y"`
|
||
Z float32 `json:"z"`
|
||
// 给人看的地点名(GM 面板「在干什么」列会显示它)。
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
//
|
||
// Plate(盘)演出管线试点 —— gameplay.plate。
|
||
// 背景:可玩角色按日程站在跑步机 / 娃娃机 / 床旁边,但**不会真的用它们**(只站着玩手机)。
|
||
// 官方的生活 NPC 是场景里 Spoon 图上的一个「盘」:`SpoonAgentUnit` 同时带
|
||
// AgentDataSetsActivityCfgId / ActivityPlateCreator / ActivityEnterPoint,
|
||
// 而服务端要把盘推下去客户端才会建它 —— 这就是第四条(表演)管线(硬规则 157)。
|
||
// SyncPlateAddList(68554224) 的形状(客户端 C# `UX.Game.PlateInfo`):
|
||
// { UniqueId, GraphId, Position, Rotation, GadgetDic, DestructibleDic, AgentDic,
|
||
// VehicleDic, DynamicAgentDic, DynamicVehicleDic, StaticNpcDic, RoomDic, FavorNpcActivityId }
|
||
// + 包尾一个 uint taskId。
|
||
// ⛑⛑ 客户端这个 reader 是 C# 生成的(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly),
|
||
// 我们**没有**它的线格式 ⇒ 下面那几个 *Encoding/*Marker 是给实机 A/B 用的旋钮,
|
||
// 每次推送时现读配置,改了不用重编译。
|
||
//
|
||
type PlatePilotSettings struct {
|
||
// 总开关。关掉就一行都不发(回到 09-25 之前的状态)。默认 false。
|
||
Enabled bool `json:"enabled"`
|
||
// 进世界时自动推一次。建议先 false,用 POST /api/plate/push 手动推着试。
|
||
PushOnWorldReady bool `json:"pushOnWorldReady"`
|
||
// 盘实例 id(ulong)。随便给,但同一会话内别重复;换一个 id 就是换一个盘。
|
||
UniqueId string `json:"uniqueId"`
|
||
//
|
||
// Spoon 盘图 id。跑步机 = 36001819(`Res/Prefab/Gadget/wodegym_treadmill.prefab`,
|
||
// occupyCnt=1)。其它候选:娃娃机 36000544 / 单人床 36001042 / 卧推 36001818 /
|
||
// 深蹲 36000424 / 仰卧板 36000106。
|
||
//
|
||
GraphId int32 `json:"graphId"`
|
||
// 盘的世界坐标(默认 = 里希跑步机活动 11400012 的 MapPosition)。
|
||
X float32 `json:"x"`
|
||
Y float32 `json:"y"`
|
||
Z float32 `json:"z"`
|
||
// 欧拉角,度。
|
||
RotX float32 `json:"rotX"`
|
||
RotY float32 `json:"rotY"`
|
||
RotZ float32 `json:"rotZ"`
|
||
// 这个盘偏好的活动(`AgentDataSetsActivityConfig.Id`)。里希跑步机 = 11400012。
|
||
FavorNpcActivityId uint32 `json:"favorNpcActivityId"`
|
||
// 任务盘才用;生活表演填 0。
|
||
TaskId uint32 `json:"taskId"`
|
||
//
|
||
// NPC 绑定方式:none(只推盘,不绑人)/ staticNpc(StaticNpcDic[slot]=常驻 NPC 的 uid)
|
||
// / agent(AgentDic[slot]=spoonAgentId,用角色的 AgentId)。
|
||
//
|
||
NpcBind string `json:"npcBind"`
|
||
// 上面那个 Dic 的槽位下标(图里第几个位子)。
|
||
SlotIndex int32 `json:"slotIndex"`
|
||
//
|
||
// 绑谁:NpcBind != none 时用哪个 spiritId 去找人(默认里希 15021021)。
|
||
// 先去常驻 NPC 账本里查它此刻在不在场上,不在就用它的 AgentId 兜底。
|
||
//
|
||
SpiritId uint32 `json:"spiritId"`
|
||
// —— 以下都是线格式旋钮,只为实机 A/B ——
|
||
// 包体顶层是否先写一个 0xFF 存在标记(多数 S2C 有,GadgetGrid 那种 Inline 没有)。
|
||
RootMarker bool `json:"rootMarker"`
|
||
// 盘列表前面是否写存在标记(默认 0xFF)。
|
||
ListMarker bool `json:"listMarker"`
|
||
// 列表 count 的写法:int7(count+1 偏置)/ raw7(count 原值)/ int32。
|
||
ListCountEncoding string `json:"listCountEncoding"`
|
||
// 每个 PlateInfo 元素前是否写 0xFF(Complex 有,Struct 没有)。
|
||
ItemMarker bool `json:"itemMarker"`
|
||
// 八个字典前面是否写存在标记。空字典通常也要这一字节 + count。
|
||
DictMarker bool `json:"dictMarker"`
|
||
// 字典 count 的写法,同 ListCountEncoding。
|
||
DictCountEncoding string `json:"dictCountEncoding"`
|
||
// Position / Rotation 前是否各写一个 0xFF(UXVector3 通常是裸 3 个 float)。
|
||
VectorMarker bool `json:"vectorMarker"`
|
||
}
|
||
|
||
//
|
||
// 生活日程正式通道(gameplay.npcLife)—— 硬规则 160。
|
||
// 为什么需要它:09-25 实机确认「角色按日程站到跑步机上,但只是站着玩手机」。
|
||
// 取证(dump 的 06_RUNTIME_CODE)显示官方有三条我们完全没发的 S2C:
|
||
// <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<uint, NpcTimeTableInfo>。</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 && !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"`
|
||
// 迟滞半径(米),须 > 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=<k> 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(>50 GoodEnd / >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",
|
||
// 下标 = 列号(列数 > 元素数时,最后一列的值套用到其余列)。空 = 用
|
||
// 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 个偏 > 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 < GateKeepFirstNCount` 的那几个点,上报通过时**不撤门**,
|
||
// 只登记挂起;等玩家通过第 `GateKeepFirstNCount` 个点(默认 2 ⇒ 即第三个门 `cp02`)时,
|
||
// 把挂起的和当前这一对**一起撤掉**。这是把「生存期」锚到**玩家的真实进度**上,
|
||
// 而不是靠猜 prefab 冷加载要几秒(R311 的 12 s 宽限期实测没消除用户的现象)。</para>
|
||
// <para>`0` = 关闭本条规则,完全回到 R311 的「纯宽限期」行为。默认 2。</para>
|
||
//
|
||
GateKeepFirstNCount int32 `json:"gateKeepFirstNCount"`
|
||
}
|
||
|