using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// Combat authority for CLIENT-SPAWNED enemies. /// /// The enemy this covers is the one produced by the in-game local channel /// (GmUtils.CreateClientAgent / LuaUtils.CreateClientAgentByCfg). That unit belongs to /// the client: it lives in the client's LocalUnitMgr, it carries its own HP / death /// model / animation layer, and this server has no record of it at all. Nothing drives /// that model either — the client's combat sim never applies damage to a local unit, so /// the enemy absorbs every shot without flinching and never dies. Logs confirm the /// player's hits DO arrive here (AskMultipleSkillHit2 with the local unit's uid in /// HitTarget, firearms included), which means the server is the only missing link. /// /// This file closes that link without touching how enemies are created and without /// creating an entity server-side: /// /// 1. LAZY LEDGER. The first time a hit names a local unit, its uid is recorded. No /// spawn packet is sent, the client's unit is left exactly as it is; the server /// only starts remembering "this uid has an HP pool". /// 2. FEEDBACK. Every accepted hit broadcasts the hurt effect, a one-shot stagger and the /// authoritative HP ratio. SyncUnitHurtEffect (68349329) is the important one: the /// client fastpatch defines a Lua impl for it that forwards the payload into /// gCS.BattleManager.LuaTryDoHurtAction, i.e. the client's OWN hurt pipeline, keyed by /// pid. That is what makes the enemy actually flinch - and it is the right lever, /// because the animation state machine owns the Animator and rewrites the pose from its /// own State every frame, so any one-off PlayAction we sent would read as a twitch that /// instantly recovers. SyncUnitHp is resolved through gCS.SceneDataMgr.GetUnit(pid), the /// same global lookup that also serves LocalUnitMgr, which is why server packets can /// reach a client-owned unit at all. /// 3. DEATH. At zero HP the unit runs the down pose -> death beat -> loot drop -> despawn /// chain. The step that matters on screen is the last one, SyncRemoveNpc, and it depends /// on the same fastpatch: the retail build declares that method LuaOnly and ships its /// reader, but never defines an impl, so the dispatcher's name lookup found nothing and /// the packet was dropped on arrival without a log line. The patch's impl removes the /// unit through BaseUnitUtils.DestroyAgentUnit left non-forced, which routes into /// LocalUnit.OnDead and lets it play the death beat and schedule its own delayed destroy /// instead of deleting the unit outright. /// /// The stagger packets (67149 / 88300) ARE the reaction path. SyncEnemyPlayAction /// (68039914) is declared CSharpOnly and RPCDeserializeAuto ships neither a midToReader /// nor a midToName entry for it, so the Lua dispatcher used to return false on the /// missing reader before it ever looked up a handler and the packet reached only the /// native C# impl - which resolves units through registries a LocalUnit is not in. /// The client fastpatch now injects those two map entries at runtime (reader = /// u64 uid, u32 actionId, f32 maxTime, i.e. this file's EnemyPlayActionData4229938) /// plus a Lua impl, so these packets land and drive the pose. Keep sending them. /// /// Two deliberate exclusions keep the two spawn paths from colliding: /// * units this server spawned (admin panel / GM channel) own uid >= ServerSpawnUidFloor and /// are handled by GameAdminBridge — a lazy ledger entry there would double up; /// * the player's own unit ids are never treated as enemies. Note the player entity is /// 0x200000000001 (client-allocated), NOT Profile.InitialUnitId — before the ordinal /// offset was introduced it sat above the floor and was settled as an enemy. /// /// Feature switch: create an empty file named "local-enemy-combat.disabled" next to /// Ananta.App.exe and restart the server to turn the whole path off. /// internal sealed partial class GameRouter { private const string LocalEnemyRuntimeKey = "client4229938-local-enemies"; /// /// Boundary between the two uid spaces. Server-spawned agents are synthesised as /// ServerSpawnUidBase + ServerSpawnUidOrdinalOffset + ordinal /// (GameAdminBridge.SpawnAgentAsync), while every uid the client assigns to its own /// local units sits below that, so the range split is how a hit target is attributed /// to one path or the other. /// /// Measured in packets-latest.log: the player's own entity is 0x200000000001, i.e. the /// client's local units and our agents used to share the very first indices. Ordinal 1 /// then became the player, and this ledger settled the player's HP down to zero, which /// parked the HUD on the knocked-out bar ("昏迷 100%"). The offset now reserves the low /// indices for the client. Keep it in sync with GameAdminBridge — a drift here silently /// re-attributes every spawned agent to the wrong path. /// /// The player unit (100000000000) is below the floor too and is filtered separately. /// private const ulong ServerSpawnUidFloor = GameAdminBridge.ServerSpawnUidFloor; // Local enemies carry their own HP model client-side, but nothing drives it. These // are the server-side numbers that replace it: 1000 HP and 100 per accepted hit, so // the standard test enemy dies on the 10th hit — the same tuning the LY-3.3 reference // server settled on, which keeps hit counts comparable across both. // internal: GameAdminBridge.SpawnAgentAsync seeds its own spawn batch from the same // number so both spawn paths agree on what "full HP" means. // // ⛑⛑ 2026-09-27 改为配置驱动(`gameplay.combat.enemies.maxHp`,默认仍是 1000): // 这是 GM/服务端 spawn 与「地图敌人首次受击登记」两条路共用的唯一血量来源, // 以前调一次要改常量 + 重编译,现在改 json 即可(热重载生效)。 // 用属性而不是 const,保证每次读取都拿最新值;`Math.Max(1f, …)` 兜底配 0/负数。 internal static float LocalEnemyMaxHp => Math.Max(1f, PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.MaxHp); private const float LocalEnemyHitDamage = 100f; // ---- Poise / groggy (stance) tuning ---- // Stance is deliberately a separate pool from HP: the client renders it as its own bar // (SyncEnemyPoiseRate, verified visible in-game 2026-09-23) and draining it is what puts the // enemy into the "虚弱" break where it stops swinging. Numbers taken from the enemy tuning // block of the kimika reference server (maxPoise 100, poiseDamagePerHit 34, groggySeconds 5): // 34 means three clean hits break the stance, so a break lands roughly once per fight // instead of once per hit or never. internal const float LocalEnemyMaxPoise = 100f; /// 每次命中扣多少架势 —— 不再写死,改 private-server.json 的 /// gameplay.combat.enemies.poiseDrainPerHit 即可(100 = 每下破防 / 34 = 三下破防 / /// 0 = 只走服务端模型)。服务端账本和发给客户端的 SyncUnitAttacked.PoiseBasicValue /// 用的是同一个数,所以两边永远同步破防。 /// /// 原来是常量 34f(kimika 捐赠端值)。2026-09-23 夜按用户要求改回"每一下都有明显反应"的 /// 手感,默认值调到 100(= 一击伤害,也就是 11:3x 那个状态的值)。那时候它会导致敌人卡在 /// 破防里出不来("打不死"),但那是因为当时没有退出通道;现在 ReportUnitStandUp /// 回执 + SyncEnemyDisarmState(false) + 5 s 超时兜底都在了,可以安全地开回来。 private static float LocalEnemyPoiseDamagePerHit => PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PoiseDrainPerHit; /// 敌人受击手感的三个旋钮(见 )。 private static EnemyCombatSettings EnemyHit => PrivateServerConfigStore.Current.Gameplay.Combat.Enemies; private const long LocalEnemyGroggyMs = 5000; /// 虚弱窗口内重新顶一次硬直动作的间隔(ms)。 /// /// 2026-09-23 17:1x 实测:破防后敌人只是站着不动,5 s 后照常挥击 —— 玩家读作"没打晕"。 /// 原因:SyncEnemyPlayAction 是一次性动作,67149 播完(不到 1 s)就回站立, /// 而客户端的虚弱 UI 在这个 build 里是死的(SyncEnemyDisarmState 的 C# 体是空的、 /// SyncEnemyPoiseRate 的 Lua handler 守卫写反)。所以服务端必须自己撑住这个姿势: /// 窗口内周期性重推,敌人看起来才是"晕着"而不是"站着发呆"。900 ms 略短于一次 /// 900 → 3600 → 1200 → 3000(2026-09-23 19:2x):1200 是"被顶掉后尽快补回来"定的, /// 实测代价是 5 s 窗口里倒地了三次 —— 88300 是 RemainTime 4 s 的重倒地动作,每次补推都是 /// 一次动作重启(弹起再趴下),玩家读作"连续倒地三次"。3000 让 5 s 窗口最多出现两次倒地: /// 破防那一次,加一次"真被顶掉之后"的补。 /// 前提仍然是:客户端本地的受击反应走 native(SyncUnitHurtEffect 在这个 build 上没有 Lua /// impl,fastpatch 明确 left to native),服务端碰不到,只能事后补。 private const long LocalEnemyGroggyReassertMs = 3000; /// 是否推 SyncEnemyPlayAction(67149) 一次性硬直动作。默认开。 /// /// ⛑⛑ 2026-09-23 15:2x 实测翻案:这一条曾被关掉(理由写反了,见下),结果是「远程打敌人 /// 完全没有受击反应」——因为远程命中的 hurtEffect 恒为 55004611,客户端靠它演不出硬直, /// 67149 是唯一还能让远程命中"看得见"的动作。近战当时看着有硬直,只是因为它 3 下就撞上 /// 我们自己的 poise 破防(groggy → SyncEnemyDisarmState),跟 67149 无关。 /// /// 当初关掉的理由及为何不成立:捐赠端(kimika)命中路径确实不发这个包,但 kimika 能不发是 /// 因为它 spawn 时把 BattleAiS / 6 个技能槽 / Detect / DetectStatus / FightEdict 一次推齐, /// 客户端的 hurt 管线本来就是活的;我们缺那一整套,客户端的受击反应根本没起来,只能靠 /// 67149 显式驱动。 /// /// 「播完没有退出包 ⇒ 卡住」这条也已经翻案:真凶是 SyncUnitAttacked.PoiseBasicValue 填了 /// damage(100),而客户端 poise 池正好也是 100 ⇒ 每一下都直接破防倒地,且当时没有任何 /// 「退出虚弱」的路径。现在 PoiseBasicValue=0(下见 SendLocalEnemyAttackedAsync)+ 破防有 /// EnterLocalEnemyGroggyAsync/ExitLocalEnemyGroggyAsync 完整进出 + 5 个 C2S 上报 handler, /// 卡住的根因已经不在 67149 身上。若复测又出现「打晕后不动」,优先把它改回 false 再验证。 /// ⛑ 用 static readonly 而不是 const:const bool 会让编译器把另一分支判为不可达(CS0162)。 private static readonly bool LocalEnemyPushStaggerAction = true; // 死亡时是否由服务端推 SyncEnemyPlayAction(88300) 倒地动作 —— 2026-09-23 17:4x 起为「否」, // 常量连同那段代码一起移除,理由留在这里,免得下次又把它加回来: // // 客户端 Player.log 证明 fastpatch 的 SyncRemoveNpc hook 每一次都成功播了 88300 // (17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四条 "death PlayAction(88300) ok=true"), // 3 s 后 DestroyAgentUnit 也成功。服务端再补一个 88300 只会造成动作重启 —— hook 自己的注释 // 写着 "the restart is what makes the corpse snap upright before it drops"。 // 想做对照实验:在 LocalEnemyDeathSequenceAsync 的 removeNpc 之后补一段 SyncEnemyPlayAction // (LocalEnemyDownActionId)。 // One-shot animation ids verified against this build: 67149 = stagger, 88300 = down. // 67150/67148 (the other entries in HurtStiffConfig) are NO-OPs on this client, so a // rotation through them would silently drop two of every three reactions. private const uint LocalEnemyStaggerActionId = 67149; private const uint LocalEnemyDownActionId = 88300; /// 击晕(破防)姿势用的动作 = 88300 倒地,不是 67149。 /// /// ⛑⛑ 2026-09-23 18:0x:原来击晕复用 67149(受击硬直),结果玩家看不出任何区别 —— /// 67149 是一次性短硬直,破防后敌人只是「僵一下 / 呆站着」,而玩家要的是倒地 /// ("昏迷/击晕"在这个游戏里就是被打趴)。88300 来自 HurtStiffFirearmDieConfig, /// RemainTime = 4 s,正好覆盖 5 s 虚弱窗口的大半,是唯一已知的倒地动作。 /// 若实测发现敌人看起来像"死了",把它改回 LocalEnemyStaggerActionId 即可, /// 破防账本(Poise / GroggyUntilTicks / 停手闸门)与这个动作 id 完全解耦。 private const uint LocalEnemyGroggyActionId = LocalEnemyDownActionId; /// 击晕(虚弱)期间要不要推倒地动作 88300。2026-09-23 19:4x 起默认 false。 /// /// 三条实测把这条路堵死了: /// * 不推 ⇒ 敌人呆站着(客户端的 SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 build /// 里都是空实现,虚弱 UI 起不来)。 /// * 推 88300 ⇒ 它是 RemainTime 4 s 的重倒地动作,玩家在虚弱窗口里照打,本地受击把它顶掉, /// 服务端补推 = 动作重启 ⇒ 5 s 窗口里"连续倒地三次"。 /// * 推 67149(短硬直)⇒ 和受击动作一模一样,玩家读作"没打晕、只是僵一下"。 /// 用户 2026-09-23 已明确接受"击晕无法倒地、只会呆呆站着",所以关掉,等找到真正的 /// 短眩晕动作 id 再打开。账本(Poise / GroggyUntilTicks / 停手闸门 / DisarmState)与该开关 /// 完全解耦,关掉它只影响"看不看得见姿势"。 private static readonly bool LocalEnemyGroggyPoseEnabled = false; /// UX.Game.EnemyDetectState.None — pushed when a unit dies so the corpse leaves the /// Fight behaviour instead of standing in the combat tree with an empty bar. private const byte LocalEnemyDetectStateNone = 0; /// UX.Game.EnemyDetectState.Fight — the slot that maps onto Behavior_Battle. /// Carried by SyncEnemyDetectStatus(68126766); see SyncEnemyDetectStatusData4229938. /// A spawned enemy sits at None(0) until this is pushed, and the enemy HUD keys the HP /// bar off that state, so the bar cannot bind without it. private const byte LocalEnemyDetectStateFight = 4; /// Per-enemy stagger throttle. Slower than a melee swing and faster than a /// firearm's cycle, so a fast melee combo cannot restart the flinch before it finishes /// while firearms are unaffected. private const int LocalEnemyStiffCooldownMs = 700; /// Delay before the stagger so the hurt VFX (which resets the client's action /// layer) has already been applied. private const int LocalEnemyStiffDelayMs = 120; /// Delay before the down pose so the client's action layer finishes the last /// hit reaction first — a down action sent mid-stagger-restart is dropped, which is /// exactly the "no death animation" symptom. private const int LocalEnemyDeathActionDelayMs = 250; /// /// SyncAddUnitState(68026128) 的 state:把单位打进 UnitStateConfig.DeadS。 /// /// ⛑⛑ 是 2,不是 3。UnitStateAtlasConfig.Death = 3 只是个图集 atlas, /// 发 3 单位不会真的进死亡行为模块(捐赠端 7k-v0.5 源码原注释)。 /// private const uint LocalEnemyDeadUnitState = 2; /// Gap between the down pose and the death beat (SyncEnemyPrepareDie). /// /// The two beats must NOT land in the same frame: SyncEnemyPrepareDie drives the HP bar /// collapse and the corpse setup, so pushing it together with the down action makes the /// bar snap to 0 with no fall and tears the corpse down mid-animation (the remaining half /// of the "no death animation" symptom — the other half was the 1500ms despawn). /// 650ms is the value an independent 4229938 branch arrived at by the same reasoning. private const int LocalEnemyDeathBeatDelayMs = 650; /// Destroy distance handed to the client with the death beat. /// /// Was 0, which the client reads as "destroy as soon as it is off-camera" — combined with /// a 900-unit authored remain distance in the retail DeathSetting, this is what made /// corpses vanish the instant the player turned away. private const float LocalEnemyDelayDestroyDistance = 900f; /// Publish a SyncEnemyPoiseRate frame with every accepted hit. /// /// Started as a one-restart diagnostic on 68696896 (its C# handler IS an empty shell on /// this build, which is why the dump shows no IDMAP entry). It is no longer a probe: the /// client does react — a stance bar appears and a drained pool stops the enemy swinging, /// confirmed in-game 2026-09-23, so the Lua side is the real consumer. Set to false to /// stop shipping it; the HP / lethality pipeline does not depend on it. /// /// ⛑ 2026-09-26 Wave 4:关掉(补偿 UI 清理)。W1 poiseDrainPerHit=0 之后服务端 /// 账本不再扣架势 ⇒ 这根条永远满格,是纯噪音;捐赠端 7k-v0.5 从头到尾不发这个包。 /// 想 A/B 就翻回 true。⛑ 用 static readonly 而不是 const:const false 会让编译器把 /// 另一分支判为不可达(CS0162)。 internal static readonly bool PushPoiseProbe = false; /// 死亡表现播完之后,SyncRemoveNpc 距 SyncEnemyPrepareDie 的间隔。 /// /// 2026-09-23 实测:客户端的死亡动作是由 SyncRemoveNpc 的 Lua hook 播放的(fastpatch /// LuaFiles#LX6#Manager#ClientGameSwitch.lua),不是由服务端的 SyncEnemyPlayAction: /// hook 里先 PlayAction(unit, 88300, group),再 3 s 后 DestroyAgentUnit。它播之前还会 /// __DRMK_SetT(__DRMK_Dead, unit, pid, true) 关掉 hurt 路径 —— 这正是近战需要的: /// 玩家连招不停时,后续命中的本地反应会顶掉倒地动作。 /// 在此之前的死亡序列只对 client-spawned 单位发 SyncRemoveNpc(if (!serverSpawned)), /// GM 生成的敌人走的是 serverSpawned 分支,一次都没发过 ⇒ 那个 hook 从来没跑过。 /// 1700 → 0(2026-09-23 17:1x):此延迟是相对 SyncEnemyPrepareDie 的,实测 1.7 s 的空档里 /// 玩家近战连招照打,本地 hurt 反应把倒地动作顶掉 —— 尸体一直站着播硬直,最后被拆掉时 /// 玩家看到"突然消失"。0 = 死亡序列一开始就发,hurt 闸门在倒地之前就关上。 /// 想做对照实验就把这个值调回 1700(症状应当复现)。 private const int LocalEnemyRemoveNpcDelayMs = 0; /// Time the down pose is allowed to play before the loot drop and despawn. /// /// Was 1500ms, which is shorter than the authored death: HurtStiffFirearmDieConfig /// record 1 gives DieAction 88300 a RemainTime of 4 seconds. Despawning at 1.5s removed /// the corpse while the animation was still running, which reads as "no death animation" /// even when the pose did start. Measured in packets-latest.log: SyncEnemyPlayAction /// 88300 at 21:57:07.295, SyncLogicAgentLeave at 21:57:08.818 — 1.5s, exactly. /// /// 5000 → 3500(2026-09-23 17:1x):现在 SyncRemoveNpc 是死亡序列的第一包,hook 的销毁 /// 定时器(3 s)从这里起算,尸体不需要服务端留到 5 s;3.5 s 兜底拆足够让 3 s 走完。 private const int LocalEnemyDropDelayMs = 3500; /// /// ★ T1-17(2026-09-25):死亡序列推 SyncDetachBattleUnit(68396133) 前,在 /// SyncEnemyPrepareDie + 之后**再**等多久。 /// donor 实测:客户端死亡特效约 3 s 起、5.3 s 完 ⇒ 3500 + 2500 = 6000 ms 正好落在他们用的 /// 「尸体保留 6000 ms」上。**提前 detach 会把死亡特效打断**(他们注释原话)。 /// ⛔ **2026-09-25 19:3x 回滚为 0**:发这个包之后玩家实测「死亡动画没了、只会直接消失」 /// (19:34–19:35 五个 uid 全部走完 detach,紧跟着 logicAgentLeave + clearSpoonEnemies)。 /// 客户端 `Player.log` 对 68396133 零报错、零日志 ⇒ 副作用发生在 C# 侧、无从观测, /// 而收益("死亡特效")**donor 自己也没做出来** ⇒ 收益不抵风险,**不再发**。 /// 想复现就把它改回 2500 重编译(症状应当复现)。 /// ⛑ `static readonly` **不是** `const`:常量会在编译期折叠,写 0 会让下面的 `if` 分支 /// 变成「无法访问的代码」(CS0162 警告)。改成 readonly 之后开关语义不变(改 0 即不再发), /// 但编译干净。 /// private static readonly int LocalEnemyDetachBattleUnitDelayMs = 0; /// 尸体被客户端再次打进硬直时,重推倒地动作的最小间隔。 /// 1500 → 3000 → 1200(2026-09-23 18:4x):3000 是照着 hook 里「2.5 s 内播过就不再重启」 /// 那条守卫定的,本意是退化为纯兜底;但那个守卫同时也挡住了所有补播 —— 玩家近战连招把倒地 /// 顶掉之后,整整 3 s 内没有任何一方再把 88300 顶回去,尸体就一直站着演受击,最后是"直接 /// 消失"。现在把间隔压回 1.2 s(19:4x),并且同步把 hook 的守卫从 2.5 s 降到 0.8 s(见 /// fastpatch 的 SyncRemoveNpc 实现),双方就不再互相抵消。 /// ⛑ 19:2x 曾把这里改成 2.0 s 以配合"周期扫描也补推",那个周期扫描 19:4x 已撤掉 /// (它是"远程放两次倒地"的另一半),于是这份节流表重新只服务 AskAgentEnterStiff 上报 —— /// 近战连招下补得越快,倒地越不容易被吃掉。 /// ⛑ 代价仍是重推 = 动作重启(hook 注释:the restart is what makes the corpse snap upright /// before it drops),但"弹起再趴下"远好过"站着不动然后消失"。 private const int LocalEnemyDeathReassertCooldownMs = 1200; /// 死亡后这段时间内不接受任何补推(ms)。见 ReassertLocalEnemyDeathAsync 里 /// 的实测:致死那一击自带的残留 AskAgentEnterStiff 会把 hook 刚播的 88300 重启成"倒两次"。 /// 必须 < hook 的销毁定时器(3 s),而 > 残留上报的延迟(实测 <1 s)。 private const int LocalEnemyDeathQuietMs = 900; /// 收到客户端 AskAgentEnterStiff 上报后,补推击晕姿势的最小间隔(ms)。 /// 比 LocalEnemyGroggyReassertMs 短:上报是"已经被顶掉了"的即时证据,补得越快越看不出 /// 断档;但不能快到每一下近战都重推(那又变成动作重启,实测 700 ms 就是"连续倒地三次")。 /// 700 → 2000(2026-09-23 19:2x)。 private const int LocalEnemyStiffNudgeMs = 2000; /// Soft cap so a long session cannot grow the ledger without bound. private const int LocalEnemyLedgerCap = 256; internal sealed class LocalEnemyRecord { public ulong Uid; public float MaxHp = LocalEnemyMaxHp; public float Hp = LocalEnemyMaxHp; public int Hits; public long FirstSeenTicks; public long LastStiffTicks; public Vec3 Position; public uint LastSkillId; public uint LastHurtEffectId; public bool Dead; public bool DeathStarted; /// True when this record was created by SpawnAgentAsync rather than /// discovered from a hit report. Server-spawned agents are real battle units the /// client's native code can see, so they are engaged and despawned through the /// agent channel; client-spawned local units need the local-unit removal path /// instead. Template id and locked player unit are kept for logging and re-engage. public bool ServerSpawned; public uint EnemyId; public ulong PlayerUnit; // ---- Poise / groggy (stance) state ---- /// Current stance pool. Drains per hit, not per damage point: a heavy hit and a /// light one cost the same, which is what makes the bar readable and predictable. public float Poise = LocalEnemyMaxPoise; /// TickCount64 until which the enemy is staggered and stops swinging. /// 0 = not groggy. The pool refills when the window closes — see /// ExitLocalEnemyGroggyAsync (GameRouter.EnemyStiff.cs), which is also the place that /// pushes SyncEnemyDisarmState(false) + a full stance bar back to the client. public long GroggyUntilTicks; /// True while the client has told us (AskAgentEnterStiff) the unit is in 硬直. /// Cleared by AskAgentExitStiff / ReportUnitStandUp — see GameRouter.EnemyStiff.cs. public bool StiffActive; /// How many times this enemy has been broken. Logging only. public int GroggyCount; // ---- 2026-10-02 逐字对齐捐赠端 EnemyRecord 的收押/攻击 AI 字段 ---- // 主线原本没有这些(旧 PolicePrison.cs 靠改账本坐标 + AttackAiEnabled=false 顶替)。 // 现在捐赠端 GameRouter.PolicePrison.cs 逐字落地 ⇒ 把这些状态补上,**纯新增、不改既有语义**。 public bool IsPoliceDetained; public bool AttackAiFollowing; public ulong AttackAiFollowTarget; // AttackAiTarget / AttackAiCastSkill / AttackAiCastUntil 主线本来就有 ⇒ 不重复加。 public ulong AttackAiCastTarget; public long HackBusyUntilTicks; public int SceneGeneration; // ---- Alertness / enemy-HUD state (GameRouter.EnemyWarning.cs) ---- /// /// 已经注册过警戒状态机。false = 客户端那边还是 EnemyDetectState.None, /// HUD(血条 + 挂它上面的警戒条)不会绑 —— 这就是"血条默认不显示"。 /// public bool WarningPrimed; /// 最近一次下发的 detectionState(0 None / 1 Idle / 2 Suspect / 3 Alert / 4 Fight)。 public byte WarningState; /// /// 注册后剩余的重试次数。模型/occur 重建会把早期那份 HUD 快照丢掉, /// 所以刚注册的两拍里即使状态没变也要重发(捐赠端 Warmup=2)。 /// public int WarningWarmupRemaining; /// 下一次允许重估/重发的 TickCount64。状态不变就在这里直接跳过,避免刷包。 public long WarningRefreshAt; // ---- 血条显示时机(GameRouter.EnemyHpBar.cs)---- /// /// 服务端给这个单位挂了 UnitStateConfig.HideHp(10266),血条此刻是收起的。 /// 第一次命中时由 RevealLocalEnemyHpBarAsync 撤掉状态并置回 false。 /// public bool HpBarHidden; // ---- Attack-AI state (GameRouter.EnemyAiActions.cs) ---- public bool AttackAiEnabled; public ulong AttackAiTarget; public long AttackAiActionAt; public uint AttackAiCastSkill; public long AttackAiCastUntil; public int AttackAiCastInstance; public int AttackAiCastCount; public byte AttackAiMoveId; public int AttackAiMoveCount; public long LastHurtFeedbackTicks; /// Spawn facing, kept so the turn command can compute a delta angle. public float Facing; /// /// 出生点。踱步的保险绳基准:估算位置离它超过 EnemyAiPatrolLeashMeters 就强制换向, /// 免得估算误差累积把敌人一路推到玩家脸上。 /// public Vec3 HomePosition; /// 踱步方向:true = 下一步朝玩家(WalkFront),false = 下一步背对玩家退(WalkBack)。 public bool PatrolForward = true; /// engagement 状态上次翻转的 TickCount64,用于 EnemyAiEngageHoldMs 防抖。 public long AttackAiEngageChangedAt; /// Estimated, NOT authoritative. The client owns the unit's real position /// after a move command and reports it back without coordinates, so this is walked /// towards the player by per dispatch. /// Only used to decide "close enough to swing yet". public Vec3 AiPosition; } internal sealed class LocalEnemyRuntimeState { public object SyncRoot { get; } = new(); public Dictionary Enemies { get; } = new(); /// TickCount64 deadlines for corpses that still need to be torn down. /// /// SyncEnemyPrepareDie only makes the client PLAY the death; the unit itself is removed by /// SyncClearSpoonEnemies. Entries are added by the death sequence and removed either by the /// client's own report (ReportEnemyPrepareFinish / ReportAgentDiePerfEnd) or by the grace /// timeout — see GameRouter.EnemyStiff.cs. Before this existed the corpse was never removed /// at all, which is the "killed it and it came back, now unkillable" report. public Dictionary PendingDeathClear { get; } = new(); /// TickCount64 of the last time the down pose was re-pushed onto a corpse. /// 尸体被玩家连招再次打进硬直时用来节流(见 ReassertLocalEnemyDeathAsync)。 /// 尸体拆掉时随 PendingDeathClear 一起清理。 public Dictionary LastDeathReassert { get; } = new(); /// 尸体最早允许被拆掉的 TickCount64(= 死亡序列开始 + LocalEnemyMinCorpseMs)。 /// /// 2026-09-23 19:2x:客户端会在死亡动画刚起手时就上报 ReportEnemyPrepareFinish / /// ReportAgentDiePerfEnd,而这两个 handler 原来是收到即拆(SyncClearSpoonEnemies), /// 于是同一套死亡序列会随机表现成两种结果 —— 上报来得早就"直接消失",来得晚才"正常倒地"。 /// 这正是"死亡动作不一定能加载出来"的来源。加了这个下限之后,客户端的报告只能把清理 /// 提前到下限,不能提前到动画播完之前。 public Dictionary DeathEarliest { get; } = new(); /// TickCount64 of the last time the 硬直 pose was re-pushed onto a 虚弱 enemy. /// 虚弱窗口有 5 s,而客户端的 SyncEnemyPlayAction 是一次性动作、播完就站起来, /// 所以窗口内必须周期性重推,敌人看起来才是"被打晕了"而不是"站着发呆" /// (见 ReassertLocalEnemyGroggyAsync)。虚弱结束时清理。 public Dictionary LastGroggyReassert { get; } = new(); /// Guards against starting a second attack-AI worker for the same session. /// One worker drives every spawned enemy; it exits once nothing is left to drive. public bool AttackAiWorkerStarted; /// /// TickCount64 deadlines at which the player's control states get released, queued by /// the attack AI when it dispatches a cast. See EnemyAiUnlockDelayMsEarly/Late. /// public List PendingUnlocks { get; } = new(); /// Auto-release on/off. Toggled from the admin panel; on by default. public bool AutoUnlockEnabled = true; // ---- 2026-10-02 逐字对齐捐赠端 EnemyRuntimeState 补的三个成员 ---- // 捐赠端 GameRouter.Enemy.cs:116 的 `GetEnemyRuntimeState` 返回 `EnemyRuntimeState`; // 主线这套被 `LocalEnemyRuntimeState` 取代,字段命名也不同。这里**只加字段、不改语义**, // 让捐赠端 GameRouter.PolicePrison.cs / LixiPolice.cs 能逐字编译。访问器别名在 // GameRouter.PoliceDonorGlue.cs。 public readonly SemaphoreSlim HackDispatchGate = new(1, 1); public int AttackAiWorkerGeneration; public long AttackAiWorkerId; } internal static LocalEnemyRuntimeState GetLocalEnemyRuntimeState(TcpSession session) { if (session.Items.TryGetValue(LocalEnemyRuntimeKey, out var raw) && raw is LocalEnemyRuntimeState existing) return existing; var created = new LocalEnemyRuntimeState(); session.Items[LocalEnemyRuntimeKey] = created; return created; } // Same opt-out idiom as the infinite-ammo switch: a sentinel file beside the exe, // re-checked at most every 2s so it can be flipped without a rebuild. private static long _localEnemyCheckTicks; private static bool _localEnemyCached = true; internal static bool LocalEnemyCombatEnabled() { var now = Environment.TickCount64; if (now - _localEnemyCheckTicks < 2000) return _localEnemyCached; _localEnemyCheckTicks = now; try { _localEnemyCached = !System.IO.File.Exists( System.IO.Path.Combine(System.AppContext.BaseDirectory, "local-enemy-combat.disabled")); } catch { _localEnemyCached = true; } return _localEnemyCached; } // ------------------------------------------------------------------ // Registration of SERVER-SPAWNED agents (GameAdminBridge.SpawnAgentAsync). // // A server-spawned agent is a real battle unit: the client creates it from // SyncRaidBattleUnitAgent and its native code can see it, so SyncEnemyPlayAction // (CSharpOnly) actually reaches an animation layer — unlike a client-spawned local // unit, where those same packets are dropped because the uid is not in the native // registry. That single difference is why this path is the reliable one for // stagger + death, and why SpawnAgentAsync registers its agents here instead of // relying on the lazy hit-ledger below. // ------------------------------------------------------------------ /// Puts a freshly spawned agent into the HP ledger so its hits are settled. /// Called by GameAdminBridge.SpawnAgentAsync right after the spawn batch. internal static void RegisterSpawnedEnemy( TcpSession session, ulong uid, uint enemyId, Vec3 position, ulong playerUnit, float facing) { var runtime = GetLocalEnemyRuntimeState(session); bool start; lock (runtime.SyncRoot) { runtime.Enemies[uid] = new LocalEnemyRecord { Uid = uid, EnemyId = enemyId, Position = position, AiPosition = position, HomePosition = position, Facing = facing, PlayerUnit = playerUnit, ServerSpawned = true, AttackAiEnabled = true, AttackAiActionAt = Environment.TickCount64 + 1500, FirstSeenTicks = Environment.TickCount64, }; start = !runtime.AttackAiWorkerStarted; if (start) runtime.AttackAiWorkerStarted = true; } session.Log.Info($"[ENEMY] tracked server-spawned agent uid={uid} template={enemyId} hp={LocalEnemyMaxHp:F0} playerUnit={playerUnit} facing={facing:F1}"); if (start) _ = RunEnemyAttackAiAsync(session); // 警戒状态机:先注册成 Idle(1),警戒条**不显示**,等玩家走近由周期刷新推进到 Fight(4)。 // 这里是 void 方法,只能 fire-and-forget;Prime 内部自己 try/catch。 _ = PrimeLocalEnemyWarningAsync(session, uid); // ⛑⛑ 血条是另一条路(2026-09-26 02:1x):警戒状态机动不了它,血条只看 // UnitStateConfig.HideHp。spawn 时挂上 ⇒ 收起,第一次命中由 Reveal 撤掉。 // 见 GameRouter.EnemyHpBar.cs 类注释里的客户端判据。 _ = HideLocalEnemyHpBarAsync(session, uid); } /// /// Hostile activation: lock the player, give the fight edict, keep the weapon and /// raise the detect flag. All four are needed — the behaviour tree only leaves idle /// once it has a target AND the fight edict AND a detection. /// /// Deliberately NOT re-sent on every hit. LY-3.3 proved that re-sending these four /// packets per hit resets the client's enemy action layer, which cut every stagger /// short: firearms survived it (slow enough to recover between shots) while melee /// degenerated into "first swing animates, the rest twitch". Engagement is owned by /// spawn plus the one delayed re-send below. /// internal static async Task EngageSpawnedEnemyAsync(TcpSession session, ulong uid, ulong playerUnit) { if (playerUnit == 0) return; try { await session.NotifyAsync(MethodId.SyncUnitLockTarget, UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = uid, targetId = playerUnit }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyFightEdict, UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = true }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyDisarmState, UxSerializer.Serialize(new SceneMethods.SyncEnemyDisarmState { id = uid, isDisarmed = false }), CancellationToken.None); // Combat behaviour-tree switch. LockTarget + FightEdict alone are not enough to // make an enemy act: the native side flips BaseUnit.set_IsBattleAiS here, and that // is what moves the unit off its idle/ambient tree onto CombatBehaviorTree. The // donor build sends it as the third of its four engagement packets; this port used // to omit it, which is the most likely reason spawned enemies never attacked. await session.NotifyAsync(MethodId.SyncBattleAiS, UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = uid, value = true }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyDetect, UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = uid, Stealth = 0 }), CancellationToken.None); // Alertness state — the sixth engagement packet and the one that was missing. // Everything above arms the behaviour tree, but the unit stays at // EnemyDetectState.None until this lands; EnemyHUDCtrl keys the STEALTH ALERTNESS // METER on unitDataSet.detectionState (EnemyHUDCtrl.lua:85 -> OnShowDetectionEffect), // so a spawned enemy fought, took damage and died with no alertness meter anywhere. // Fight(4) is the slot that maps onto Behavior_Battle. // ⛑⛑ 2026-09-26:警戒状态机接管之后,这里不再发 Fight(4),改由 // GameRouter.EnemyWarning.cs 从 Idle(1) 起步,玩家走近才推进到 Fight(4)。 // ⛑⛑⛑ 订正(02:1x):detectionState 只管警戒条,管不了血条 —— // 血条看的是 cfg.ShowHPBar + UnitStateConfig.HideHp,见 GameRouter.EnemyHpBar.cs。 // 上面五件套(LockTarget / FightEdict / DisarmState / BattleAiS / SyncEnemyDetect) // 一个都不动:它们管的是行为树(敌人会不会打人),跟 HUD 无关。 // 关掉 gameplay.combat.enemies.warningStateMachine 即回到这里发 Fight 的旧行为。 if (!EnemyHit.WarningStateMachine) { await session.NotifyAsync(MethodId.SyncEnemyDetectStatus, UxSerializer.Serialize(new GameMethods.SyncEnemyDetectStatusData4229938 { Id = uid, StealthValue = 0f, State = LocalEnemyDetectStateFight, }), CancellationToken.None); } session.Log.Info($"[ENEMY] engage uid={uid} -> playerUnit={playerUnit} (lock+edict+battleAiS+detect" + (EnemyHit.WarningStateMachine ? ", detectStatus=deferred to warning FSM)" : "+detectStatus=Fight)")); } catch (Exception ex) { session.Log.Info($"[ENEMY] engage skip uid={uid} ({ex.Message})"); } } /// Re-sends the engagement batch 1.5s after spawn: the first batch can be /// consumed before the client finishes initialising the unit. internal static async Task ReEngageSpawnedEnemyAsync(TcpSession session, ulong uid) { try { await Task.Delay(1500); var runtime = GetLocalEnemyRuntimeState(session); ulong playerUnit; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null) return; // already defeated / removed playerUnit = record.PlayerUnit; } if (playerUnit == 0) return; await EngageSpawnedEnemyAsync(session, uid, playerUnit); session.Log.Info($"[ENEMY] re-engage uid={uid} -> playerUnit={playerUnit}"); } catch (Exception ex) { session.Log.Info($"[ENEMY] re-engage skip uid={uid} ({ex.Message})"); } } // ------------------------------------------------------------------ // Hit intake. // ------------------------------------------------------------------ /// /// Damages a client-spawned enemy named by a hit report and drives its reaction. /// /// Called from OnSkillHit BEFORE the weapon-sandbox validation so a local enemy still /// takes damage when the skill is not in the sandbox's allow-list — the same ordering /// rationale the LY-3.3 reference server used, and the reason its enemies stayed /// defeatable while its weapon rules changed. /// internal async Task TrackLocalEnemyHitAsync(RpcContext ctx, SceneMethods.SkillHitData hit) { if (!LocalEnemyCombatEnabled()) return; var uid = hit.HitTarget; if (uid == 0) return; // Two uid spaces share this ledger (see ServerSpawnUidFloor). A server-spawned // agent is settled only when SpawnAgentAsync registered it — an unregistered uid // above the floor belongs to some other system. A client-spawned local unit is // discovered lazily here on its first hit, as before. var serverSpawned = uid >= ServerSpawnUidFloor; var state = GetWorldState(ctx); if (uid == state.ActiveSpiritUnitId || uid == state.WorldEntryControlUnit || uid == Profile.PlayerPid) return; var runtime = GetLocalEnemyRuntimeState(ctx.Session); LocalEnemyRecord record; bool isNew; bool dead; bool shouldStartDeath; bool brokeStance = false; float ratio; lock (runtime.SyncRoot) { isNew = !runtime.Enemies.TryGetValue(uid, out var existing); if (isNew) { if (serverSpawned) return; // spawned by nobody we know — not an agent of ours if (runtime.Enemies.Count >= LocalEnemyLedgerCap) return; existing = new LocalEnemyRecord { Uid = uid, FirstSeenTicks = Environment.TickCount64, Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z), }; runtime.Enemies.Add(uid, existing); } record = existing!; dead = record.Dead; shouldStartDeath = false; if (!dead) { record.Hits++; record.Hp = Math.Max(0f, record.Hp - LocalEnemyHitDamage); record.Position = new Vec3(hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z); record.LastSkillId = hit.SkillId; record.LastHurtEffectId = hit.HurtEffectId; // Suppresses the attack AI briefly (GameRouter.EnemyAiActions.cs): being shot // must not make the enemy swing faster. record.LastHurtFeedbackTicks = Environment.TickCount64; // Stance drains per hit, independent of HP. Reaching zero BREAKS the enemy: it // stops swinging for LocalEnemyGroggyMs (checked in RefreshEnemyActionAsync) and // the pool refills, so the bar is a repeating rhythm rather than a one-way trip. // A stance break does no damage — HP and stance are separate axes on purpose. if (record.Poise > 0f) { record.Poise = Math.Max(0f, record.Poise - LocalEnemyPoiseDamagePerHit); if (record.Poise <= 0f) { record.GroggyUntilTicks = Environment.TickCount64 + LocalEnemyGroggyMs; record.GroggyCount++; brokeStance = true; // Poise STAYS at 0 for the whole window — it used to be reset to full right // here, which made the stance bar snap back to 1.0 in the same frame it // broke and left the client with no zero frame to enter 虚弱 on. The refill // belongs to the exit path (ExitLocalEnemyGroggyAsync), which is also what // pushes SyncEnemyDisarmState(false). } } if (record.Hp <= 0f) { record.Dead = true; dead = true; } } if (dead && !record.DeathStarted) { record.DeathStarted = true; shouldStartDeath = true; } ratio = Math.Clamp(record.Hp / record.MaxHp, 0f, 1f); } if (isNew) { ctx.Session.Log.Info($"[LENEMY] tracked new local unit uid={uid} hp={LocalEnemyMaxHp:F0} (first hit by skill={hit.SkillId} at {record.Position.X:F2},{record.Position.Y:F2},{record.Position.Z:F2})"); // A client-spawned unit is created without any server-side HP table, so the bar // has to be established here, on the first hit — there is no spawn batch of ours // to hang it on (this is the difference from the donor build, which spawns its // enemies server-side and can send the baseline up front). await SendLocalEnemyHpBaselineAsync(ctx, uid, LocalEnemyMaxHp); } // DEATH LATCH. Measured in console-latest.log: after the killing blow the client // keeps reporting hits for another ~5s (40+ packets at ~10/s in the 22:26 run) and // every one of them re-enters the hurt pipeline, which restarts whatever pose the // death sequence just started. Only the lethal hit itself is allowed through. if (dead && !shouldStartDeath) return; // SyncUnitHurtEffect is fed for EVERY tracked unit, server-spawned ones included. // It used to be withheld from server-spawned agents on the theory that native // already reacts to them; that theory was wrong. 68349329 is LuaAndCSharp and is // the packet the client's own hurt pipeline is driven from on this build, and it is // what the LY-3.3 reference server echoes back on every hit (see its // AskPlayHurtEffect handler). Withholding it is the most likely reason both the // flinch and the death pose were missing. The fastpatch no longer overrides it, so // nothing stacks a second reaction. // 受击三件套全部可在 private-server.json 里单独关(gameplay.combat.enemies.*): // poiseDrainPerHit / pushStaggerAction / pushHurtEffect。 bool sendHurtEffect = EnemyHit.PushHurtEffect; var tag = record.ServerSpawned ? "[ENEMY]" : "[LENEMY]"; // SyncUnitAttacked FIRST, on every hit including the lethal one. This is the packet // the client's native combat code actually consumes; HP, stagger and the death beat // are all downstream of it. Without it the client knows the unit's bar moved but // never puts the unit into its hurt state, so nothing reacts and nothing dies on // screen — exactly the "shot it ten times, it just vanished" report. // ⛑⛑ 23:27 行为:致死击同样带满额架势伤害(不做 poiseDrainOverride)。 // 00:2x 那两轮为了让倒地播出来,给致死击传了 0 并在死亡前清虚弱,结果死亡动画 // 确实出来了,但玩家继续攻击时会把它顶掉(用户原话「死亡动画又会被我的普通攻击打断」)。 // ⇒ 23:27 的组合(每击都打满架势,包括致死击)实测是受击 + 死亡都正常的那一版。 // 血条出场:第一次命中撤掉 HideHp(GameRouter.EnemyHpBar.cs)。 // 之后 HpBarHidden 已置 false,这里是 no-op,不会每一下都刷包。 await RevealLocalEnemyHpBarAsync(ctx.Session, uid); await SendLocalEnemyAttackedAsync(ctx, uid, hit, LocalEnemyHitDamage, record.ServerSpawned); // 破防出场:SyncEnemyDisarmState(true) + 架势条 0。放在伤害包之后,顺序与捐赠端一致 // (先掉血/掉架势,再进虚弱)。客户端的起身报告 / 超时会关掉这个窗口 // (GameRouter.EnemyStiff.cs)——在接回执之前,这个窗口永远关不掉,敌人就"卡住"了。 // 致死那一击不再开虚弱窗口:单位马上要走死亡序列,开窗口只会让周期重推的 67149 // 和倒地动作 88300 抢同一个动作槽(死亡序列开头也有一次保险清理)。 if (brokeStance && !dead) await EnterLocalEnemyGroggyAsync(ctx.Session, uid, "poise-break"); if (dead) { if (!shouldStartDeath) return; ctx.Session.Log.Info($"{tag} killed uid={uid} hits={record.Hits} deadlySkill={hit.SkillId} -> down/die/drop/despawn sequence"); if (sendHurtEffect) await SendLocalEnemyHurtEffectAsync(ctx, uid, hit); await SendLocalEnemyHpAsync(ctx, uid, 0f); _ = LocalEnemyDeathSequenceAsync(ctx, uid, hit.SkillId, record.Position); return; } ctx.Session.Log.Info($"{tag} hit uid={uid} hits={record.Hits} dmg={LocalEnemyHitDamage:F0} hp={record.Hp:F0}/{record.MaxHp:F0} ({ratio:P0}) " + $"stance={record.Poise:F0}/{LocalEnemyMaxPoise:F0}{(brokeStance ? " **BREAK**" : "")} skill={hit.SkillId} hurtEffect={hit.HurtEffectId}"); // Stagger first: it is the reaction the player notices, and the one the client's // BattleManager.HasHitState guard can drop. The VFX+HP follow immediately. // NOTE (2026-09-23): off by default, see LocalEnemyPushStaggerAction — the client already // plays its own hit reaction from SyncUnitAttacked + SyncUnitHurtEffect, and this one-shot // action has no exit packet, which is what parked enemies inside their stagger pose. if (LocalEnemyPushStaggerAction && EnemyHit.PushStaggerAction) _ = SendLocalEnemyStaggerAsync(ctx, uid); if (sendHurtEffect) await SendLocalEnemyHurtEffectAsync(ctx, uid, hit); await SendLocalEnemyHpAsync(ctx, uid, record.Hp); } // ------------------------------------------------------------------ // Outgoing reactions. // ------------------------------------------------------------------ /// Authoritative hit notification: SyncUnitAttacked(68299892). /// /// This is the one hit packet the client's NATIVE combat code handles (CSharpOnly, no Lua /// reader), which is why it only ever does anything for a unit the native side can see — /// i.e. a server-spawned agent. It is still sent for client-spawned local units because a /// dropped packet costs nothing and the shape is now known to be correct. /// /// ⛑⛑ PoiseBasicValue 填的是 EnemyCombatSettings.PoiseDrainPerHit,和服务端自己的 /// 架势账本是同一个数,两边同步破防。 /// 历史:这里填过 damage(100),客户端架势池就是 100 ⇒ 每一下都打空 ⇒ 每一下都进虚弱/倒地, /// 而当时没有退出通道(没有 ReportUnitStandUp 回执、没有 SyncEnemyDisarmState(false)、 /// 没有超时兜底),敌人就永久卡在破防里 —— 那正是 2026-09-23 上午"受打击反馈正常但打不死" /// 的完整解释。14:4x 把它改成 0(donor 值)解决了卡住,但也把反馈改没了;现在退出通道齐了, /// 这个值可以按手感自由调:100 = 每下破防(反馈最强)/ 34 = 三下破防 / 0 = 不破防。 /// /// 2026-09-24 00:2x 曾加过「致死那一击 PoiseBasicValue 传 0」(poiseDrainOverride), /// 01:2x 已按用户要求撤掉:它让倒地动画播出来了,但玩家的后续攻击会把倒地顶掉。 /// 23:27 实测正常的一版就是**不加这个特例**。`poiseDrainOverride` 参数保留但现在没有调用点传值, /// 将来若再要单独控制致死击的架势,改调用点即可。 /// Resolve the attacker unit id for a hit. /// /// ⛑⛑⛑ 2026-09-26(伤害数字攻坚·离线取证结论):回退值不能用 Profile.PlayerPid。 /// 客户端画飘字的唯一入口是 LX6.Utils.GuiUtils.HurtFloat(RVA 0x14824610, /// 全 .text 只有一处调用点:SyncUnitAttacked 消费函数 0x1275F320 内的 0x1276261B), /// 它在调用 ShowHurtMsg 之前判一条: /// /// 0x148246CC test r12,r12 / call get_IsMe() // unit /// 0x148246E0 test r15,r15 / call get_IsMe() // attacker /// 0x148246F7 test rbx,rbx / call get_IsMe() // realAttacker /// 0x1482470E je 0x14824744 // 三者全非 IsMe -> 只打 Warn(HurtFloatText=371) 后返回 /// 0x1482472C jmp ShowHurtMsg // 有一方 IsMe -> 画飘字 /// /// 而 attacker 是 SyncUnitAttacked.attackerId 经 /// GetUnit → GetNpcByPid → Unit_GetByPid 三段解析出来的(0x1275F48D/4AF/882), /// 三段全落空 ⇒ attacker = null ⇒ 上面那条 IsMe 判据拿不到 true ⇒ 不画飘字。 /// Profile.PlayerPid 是 json player.pid(我们 = 100,捐赠端 = 666), /// 那是账号 pid,不在 unit id 空间里,三段必然全落空。 /// 捐赠端 7k-v0.5 GameRouter.Enemy.cs:400-402 回退的是 /// world?.ActiveSpiritUnitId ?? Profile.InitialUnitId(unit id 空间),所以它能过。 /// 开关 (默认 true)。 private static ulong ResolveLocalHitAttackerId(RpcContext ctx, SceneMethods.SkillHitData hit) { if (hit.ReleaserId != 0) return hit.ReleaserId; if (!EnemyHit.AttackerIdFallbackToSpiritUnit) return Profile.PlayerPid; var st = GetStateIfExists(ctx.Session); return st is not null && st.ActiveSpiritUnitId != 0 ? st.ActiveSpiritUnitId : Profile.InitialUnitId; } private static async Task SendLocalEnemyAttackedAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit, float damage, bool serverSpawned, float? poiseDrainOverride = null) { // 和服务端账本扣的架势是同一个数 —— 见 LocalEnemyPoiseDamagePerHit。 float poiseDrain = poiseDrainOverride ?? LocalEnemyPoiseDamagePerHit; try { // 2026-09-26(伤害数字攻坚):对齐 7K GameRouter.Enemy.cs:425-431 —— 模型/occur // 重建会覆盖 spawn 快照里的属性,每次伤害/HUD 事件前重断言 MaxHp 分母(不重置 HP)。 // 原生飘字(C# 消费 SyncUnitAttacked)需要这个分母;这是与 7K 逐字段 diff 后 // 伤害链上最后一处未对齐的差异。开关 ReassertMaxHpBeforeHit(json 开)。 if (EnemyHit.ReassertMaxHpBeforeHit) { await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs { unitId = uid, attrs = new Dictionary { [1] = LocalEnemyMaxHp }, }); } await ctx.NotifyAsync(MethodId.SyncUnitAttacked, new GameMethods.SyncUnitAttackedData4229938 { Id = uid, AttackerId = ResolveLocalHitAttackerId(ctx, hit), HpChange = new GameMethods.DamageData4229938 { SourceAmount = damage, FromType = GameMethods.EntityType4229938.Player, // 攻击者模板 id。捐赠端 7k-v0.5(GameRouter.Enemy.cs:441)填的是真实模板, // 我们一直是 0。见 EnemyCombatSettings.FillDamageSourceIdentity 的说明: // 这是对齐,不是已验证的修复。 SourceTemplateId = EnemyHit.FillDamageSourceIdentity ? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId : 0u, SkillId = hit.SkillId, CreationId = 0, TriggerIndex = unchecked((int)hit.TriggerIndex), BuffId = 0, SpecialEffects = GameMethods.DamageEffect4229938.Normal, ClientHitPosition = new SceneMethods.UxVector3( hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z), ElementType = 0, // 同上:捐赠端(:446)填 hit.Id,我们填 0。 HitIndex = EnemyHit.FillDamageSourceIdentity ? hit.Id : 0, // ⛑⛑ 2026-09-24 01:2x 修正:回退到 23:27 = **这里填 poiseDrain(默认 100)**。 // 上一轮(01:1x)我把这里改成 0f、并把 json 的 poiseDrainPerHit 调到 34, // 那其实退过了头 —— 34/0 是 23:23 之前的版本,不是 23:27。 // 23:27 实测「受击反馈 + 死亡动画都正常」的那一版 = poiseDrainPerHit 100 // + 每击(含致死击)都发满额架势。 // // ⛑⛑ 2026-09-26 Wave 1(原生管线复活):默认改发**客户端上报的 hit.FirmHurt** // (捐赠端 7k-v0.5 GameRouter.Enemy.cs:447 的取值来源),开关 // EnemyCombatSettings.RelayFirmHurtAsPoise(默认 true)。实测先行值: // console-20260926-105902.log 里 10 次命中 firm=0.00 ⇒ 中继 ≈ 发 0 = // 客户端架势池不动、原生破防不再被每击强制触发(「忽然定住」的锁死源消失)。 // 开关关掉 = 回到上面 23:27 的旧行为(发 poiseDrain)。 PoiseBasicValue = EnemyHit.RelayFirmHurtAsPoise ? hit.FirmHurt : poiseDrain, HpDecreased = damage, Amount = damage, ShieldDecreased = 0f, // 2026-09-26:对齐 7K(Enemy.cs:450-451)—— 盾字段透传客户端上报值, // 不再写死 -1/0。 ShieldIndex = hit.ShieldDefendIndex, ShieldId = hit.ShieldId, IsHeadShoot = false, }, }); // ⛑ 诊断字段(2026-09-26 加):firm = 客户端上报的 FirmHurt,即捐赠端 // PoiseBasicValue 的取值来源。**已有实测值**:console-20260926-105902.log // 10 次命中全部 firm=0.00(stiff=0 也是恒 0)—— 所以 RelayFirmHurtAsPoise // 开启时 PoiseBasicValue ≈ 0。这行日志继续留着:若 Wave 2 的 spawn 握手让 // 客户端开始报非 0,这里能第一时间看到。 // 2026-09-26(伤害数字攻坚):rel / atk 必须一起打 —— rel=0 就说明走的是回退分支, // 而回退值落在哪个 id 空间(unit id vs 账号 pid)正是飘字 IsMe 判据的关键。 ctx.Session.Log.Info($"[LENEMY] unitAttacked uid={uid} dmg={damage:F0} skill={hit.SkillId} " + $"rel={hit.ReleaserId} atk={ResolveLocalHitAttackerId(ctx, hit)} " + $"serverSpawned={serverSpawned} " + $"hitIdx={hit.Id} srcTpl={(EnemyHit.FillDamageSourceIdentity ? GetStateIfExists(ctx.Session)?.ActiveSpiritTemplateId ?? Profile.InitialSpiritTemplateId : 0u)} " + $"firm={hit.FirmHurt:F2} stiff={hit.StiffId} hurt={hit.HurtEffectId} " + $"pos=({hit.ClientHitPosition.X:F1},{hit.ClientHitPosition.Y:F1},{hit.ClientHitPosition.Z:F1})"); if (PushPoiseProbe) { // Stance pool, published alongside every hit. Verified in-game 2026-09-23: the // client renders this as the enemy's stance bar even though its C# side is an // empty shell on this build, so the Lua impl is the real consumer — the dump // having no IDMAP entry is NOT evidence that the client ignores it. // Ratio is the stance pool, not HP: draining it is what breaks the enemy, and it // refills after every break so the bar is a rhythm rather than a one-way trip. var stanceState = GetLocalEnemyRuntimeState(ctx.Session); float stance = 1f; bool groggy = false; lock (stanceState.SyncRoot) { if (stanceState.Enemies.TryGetValue(uid, out var probe) && probe is not null) { stance = Math.Clamp(probe.Poise / LocalEnemyMaxPoise, 0f, 1f); groggy = Environment.TickCount64 < probe.GroggyUntilTicks; } } await ctx.NotifyAsync(MethodId.SyncEnemyPoiseRate, new GameMethods.SyncEnemyPoiseRateData4229938 { Id = uid, Ratio = stance, }); ctx.Session.Log.Info($"[ENEMY-POISE] uid={uid} stance={stance:F2} groggy={groggy}"); } } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] unit-attacked send failed uid={uid} serverSpawned={serverSpawned}: {ex.Message}"); } } /// Hurt VFX on the target, and - since the client fastpatch defines a Lua impl /// for SyncUnitHurtEffect - the trigger for the actual hit reaction: the fastpatch feeds /// these fields straight into gCS.BattleManager.LuaTryDoHurtAction, so the client runs its /// own hurt pipeline on the locally spawned unit instead of the packet falling through to /// a native handler that cannot see it. `hurtId` is the hit's own HurtEffectId so the /// client plays the effect the weapon authored; 1 = normal impact. /// /// HurtEffectFacePos carries the real impact point, not the surface normal: the client /// reports ClientHitPosNormalDir as (0,0,0) on every shot (decoded byte-for-byte from /// packets-latest.log), so the normal would have placed the reaction at the world origin. /// /// 2026-09-26:`HurtImpact` / `HurtLeftOrRightHit` 不再硬编码,改由 /// 查 HurtEffectCatalog.json。 private static async Task SendLocalEnemyHurtEffectAsync(RpcContext ctx, ulong uid, SceneMethods.SkillHitData hit) { try { var presentation = ResolveLocalHurtPresentation(hit.HurtEffectId); await ctx.NotifyAsync(MethodId.SyncUnitHurtEffect, new GameMethods.SyncUnitHurtEffectData4229938 { HurterId = uid, AttackerId = ResolveLocalHitAttackerId(ctx, hit), // 7K 发的是 presentation.Id。查表未命中时 Resolve 回传原始 hurtEffectId, // 与旧行为逐字节一致。 HurtId = presentation.Id, SkillId = hit.SkillId, HurtEffectFacePos = new SceneMethods.UxVector3( hit.ClientHitPosition.X, hit.ClientHitPosition.Y, hit.ClientHitPosition.Z), HurtImpact = presentation.Impact, HurtLeftOrRightHit = presentation.LeftOrRight, }); ctx.Session.Log.Info($"[LENEMY] hurtEffect uid={uid} hurt={hit.HurtEffectId} " + $"-> id={presentation.Id} impact={presentation.Impact} " + $"side={presentation.LeftOrRight} sound={presentation.SoundId} " + $"catalog={(EnemyHit.HurtEffectCatalogLookup ? "on" : "off")}"); } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] hurt-effect send failed uid={uid}: {ex.Message}"); } } /// 查表关掉时退回的旧常量值(2026-09-26 之前这里写死 1)。 private const uint LocalEnemyHurtImpactLegacy = 1; // 2026-09-26 Wave 1:LocalEnemyBulletHurtPresentation(55001201,7K 枪械整行替换的 // 目标行,表里 Impact=1 / LeftOrRight=0 / SoundId=0)随「整行替换」语义一起移除 —— // 现在的枪械分支是只升不降(保留作者行),不再引用它。行值留在这条注释里备查。 /// /// 按 hit.HurtEffectId 解析出这次命中该发的表现参数。 /// 移植自捐赠端 7k-v0.5 GameRouter.Enemy.cs:590 ResolveHurtPresentation。 /// /// 关键差异(有意为之):捐赠端无条件走枪械重定向,我们把它放在 /// 后面 —— 见那个开关的注释。 /// 2026-09-26 Wave 1 起该分支从「整行替换成 55001201」改成只升不降: /// 保留作者行,只把 Impact 抬到 ≥ 2。7K 的替换会把 3 行 Impact=4 的作者配置 /// (55004539 / 55004615 / 55004741,还带 SoundId=70400806)打成 Impact=2 / 无声。 /// /// ⛑ 下限夹到 1 是必须的:1841 行里有 306 行 Impact = 0,照发就是"完全没反应", /// 比原来的硬编码 1 还差。查表未命中同样兜到 1,保证不劣化。 /// private static HurtEffectEntry4229938 ResolveLocalHurtPresentation(uint hurtEffectId) { if (!EnemyHit.HurtEffectCatalogLookup) return new HurtEffectEntry4229938(hurtEffectId, LocalEnemyHurtImpactLegacy, 0u, 0u); // 捐赠端:build 4229938 里 PhysicalAttackType 4/5 的精确 FireArmID 预设。 // 不重定向火箭/爆炸、近战、黑客或未知 ID。 if (EnemyHit.HurtEffectBulletOverride && hurtEffectId is 55004538 or 55004539 or >= 55004611 and <= 55004618 or 55004736 or 55004740 or 55004741) { // 只升不降(2026-09-26 Wave 1):作者行原样保留,Impact 抬到第一可见档。 // 7K 是整行换成 55001201 —— 见 HurtEffectBulletOverride 的注释。 var authoredGun = HurtEffectCatalog4229938.Find(hurtEffectId); return new HurtEffectEntry4229938( hurtEffectId, Math.Max(2u, authoredGun?.Impact ?? 2u), authoredGun?.LeftOrRight ?? 0u, authoredGun?.SoundId ?? 0u); } var authored = HurtEffectCatalog4229938.Find(hurtEffectId); return new HurtEffectEntry4229938( hurtEffectId, Math.Max(1u, authored?.Impact ?? 1u), authored?.LeftOrRight ?? 0u, authored?.SoundId ?? 0u); } /// /// HP bar baseline, sent once the first time a unit enters the ledger. /// /// Two packets and the order is not optional: /// 1. SyncUnitAttrs with attrs[1] = MaxHp (AttributeNameConfig Id=1 is Max HP). Without /// this the unit has no MaxHp at all and there is nothing for a bar to be a /// fraction of. /// 2. SyncUnitHp with the ABSOLUTE hp, not a ratio. Sending 1.0 for "full" is what made /// the bar missing: the client reads the value against MaxHp, so 1.0 against a /// 1000 MaxHp is a sliver, and against a MaxHp of 0 it is nothing at all. /// /// Unit of measure deliberately matches the ledger (LocalEnemyMaxHp,2026-09-27 起由 /// `gameplay.combat.enemies.maxHp` 驱动,默认 1000 / 现配 1500): the donor /// build settled on exactly the same numbers, so hit counts stay comparable. /// private static async Task SendLocalEnemyHpBaselineAsync(RpcContext ctx, ulong uid, float maxHp) { try { await ctx.NotifyAsync(MethodId.SyncUnitAttrs, new SceneMethods.SyncUnitAttrs { unitId = uid, attrs = new Dictionary { [1] = maxHp }, }); await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = maxHp }); ctx.Session.Log.Info($"[LENEMY] hp baseline uid={uid} maxHp={maxHp:F0}"); } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] hp baseline send failed uid={uid}: {ex.Message}"); } } /// /// Authoritative HP, in absolute units (0 .. MaxHp), NOT a ratio. /// /// Deliberately the LAST packet of a hit: the client's Lua handler for this one touches /// unit.ClientData.MaxHp, and a local unit's ClientData is the one field on this path /// that could be nil. Sending it last means a client-side error there cannot cost the /// player the visible flinch or the hurt VFX. /// private static async Task SendLocalEnemyHpAsync(RpcContext ctx, ulong uid, float hp) { try { await ctx.NotifyAsync(MethodId.SyncUnitHp, new SceneMethods.SyncUnitHp { unitId = uid, hp = hp }); } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] hp send failed uid={uid}: {ex.Message}"); } } /// Throttled one-shot stagger. Delayed so the hurt VFX has landed first, and /// rate-limited per enemy so a fast melee combo cannot cut the flinch short. /// /// Caveat, established from the client's own dispatch tables: this action packet is /// handled by the native C# impl of SyncEnemyPlayAction, not by Lua. 68039914 has no /// midToReader and no midToName entry, and RPCDeserializeBase returns early on a /// missing reader, so the Lua impl (and any fastpatch on top of it) is never reached. /// It is now also consumed by Lua: the fastpatch injects the missing midToReader / /// midToName entries for 68039914 and installs an impl that resolves the unit's /// action group through ActionManager and plays the action. Expect the client's /// hit-state guard to swallow none of them (it is deliberately not reproduced). /// private static async Task SendLocalEnemyStaggerAsync(RpcContext ctx, ulong uid) { try { await Task.Delay(LocalEnemyStiffDelayMs); var runtime = GetLocalEnemyRuntimeState(ctx.Session); bool send; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null) return; // 击晕窗口内不推每击硬直:67149 会把 88300 倒地姿势顶掉,敌人看起来就是 // "一直播受击动画、没有击晕表现"。倒地期间本来也不该有受击反馈。 if (record.GroggyUntilTicks > 0) return; var now = Environment.TickCount64; send = now - record.LastStiffTicks >= LocalEnemyStiffCooldownMs; if (send) record.LastStiffTicks = now; } if (!send) return; await ctx.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938 { Uid = uid, ActionId = LocalEnemyStaggerActionId, MaxTime = 0f, }); } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] stagger send failed uid={uid}: {ex.Message}"); } } /// 把倒地动作重新顶回尸体身上:SyncEnemyPlayAction(88300)。 /// /// 2026-09-23 实测(uid=35184372188842,近战连招致死):hp 归零后 250ms 服务端推了 88300, /// 客户端也确实倒下了;但玩家近战连招没停,死后 3.6 s 客户端又上报了一次 /// AskAgentEnterStiff(16:11:59.670)—— 本地的命中反应把倒地动作顶掉,尸体站着等到 /// 4.2 s 后被 SyncClearSpoonEnemies 拆掉,玩家看到的就是"近战杀死敌人只会消失"。 /// 对照组 uid=35184372188839(远程)打完就停手,倒地完整播完。 /// /// 服务端拦不住客户端本地的命中反应,能做的只有把倒地动作顶回去:每次客户端在尸体上 /// 再进一次硬直,就重推一次 88300。节流 1.5 s(LocalEnemyDeathReassertCooldownMs)。 /// ⛑⛑ 2026-09-23 18:4x 重新打开(上一轮判它为"死通道"是误判,理由见下)。 /// 误判的来路:当时以 Player.log 里 "death pose already playing via playAction, skipping /// restart" 一次都没出现为据,反推"服务端推的 88300 从来没落到客户端"。那条日志只在 /// 服务端先推过 88300(会写 __DRMK_DeathAct)时才可能打,而那一轮我们已经把 /// LocalEnemyPushDownAction 删掉了 —— 服务端压根没发,当然永远不打。是"没发"不是"没收"。 /// 反证在 Player.log 18:23:12:injected 68039914 reader on …LTConfigWrap ok=true /// —— fastpatch 已经把 SyncEnemyPlayAction 缺的 midToReader / midToName 补齐,通道是通的。 /// /// 现在它同时也是对抗"受击顶掉死亡动画"的唯一服务端手段:客户端本地的命中反应走 /// native(SyncUnitHurtEffect 在这个 build 上没有 Lua impl,fastpatch 明确把它 left to /// native),服务端既拦不住也关不掉,只能在事后把倒地动作顶回去。 private static readonly bool LocalEnemyDeathReassertEnabled = true; internal static async Task ReassertLocalEnemyDeathAsync(TcpSession session, ulong uid) { if (!LocalEnemyDeathReassertEnabled) return; var runtime = GetLocalEnemyRuntimeState(session); var now = Environment.TickCount64; lock (runtime.SyncRoot) { if (!runtime.PendingDeathClear.ContainsKey(uid)) return; // 死亡后 LocalEnemyDeathQuietMs 内一律不补推。 // 2026-09-23 19:4x 实测:远程杀死敌人时倒地动作放了两次。原因就是这一段 —— // 致死那一击本身会让客户端在尸体上再报一次 AskAgentEnterStiff(日志里 killed 与 // "stiff reported on a corpse" 常常同一秒),而 fastpatch 的 SyncRemoveNpc hook // 那一刻刚播过 88300。两份 88300 相隔不到 1 s = 一次动作重启 = 玩家看到倒两次。 // 真正的"被顶掉"只可能发生在玩家继续连招之后,所以前 900 ms 的残留上报直接无视。 if (runtime.DeathEarliest.TryGetValue(uid, out var earliest) && now - (earliest - LocalEnemyMinCorpseMs) < LocalEnemyDeathQuietMs) { session.Log.Info($"[ENEMY] down pose re-push skipped uid={uid} (corpse still inside the quiet window)"); return; } if (runtime.LastDeathReassert.TryGetValue(uid, out var last) && now - last < LocalEnemyDeathReassertCooldownMs) return; runtime.LastDeathReassert[uid] = now; } await SendLocalEnemyDownPoseAsync(session, uid, "client re-stiffed a corpse"); } /// 把倒地动作 88300 推给一个已经在走死亡流程的单位(不带节流)。 /// 节流由调用方按自己的节奏做:AskAgentEnterStiff 上报走 /// ReassertLocalEnemyDeathAsync,周期扫描由 SweepLocalEnemyDeathClearsAsync 自己做。 private static async Task SendLocalEnemyDownPoseAsync(TcpSession session, ulong uid, string why) { try { await session.NotifyAsync(MethodId.SyncEnemyPlayAction, UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938 { Uid = uid, ActionId = LocalEnemyDownActionId, MaxTime = 0f, }), CancellationToken.None); session.Log.Info($"[ENEMY] down pose re-push uid={uid} action={LocalEnemyDownActionId} ({why})"); } catch (Exception ex) { session.Log.Warn($"[ENEMY] down pose re-push failed uid={uid}: {ex.Message}"); } } /// 虚弱窗口内把硬直姿势顶回去:SyncEnemyPlayAction(67149)。 /// /// 2026-09-23 17:1x 实测:破防后客户端只是站着(SyncEnemyDisarmState / SyncEnemyPoiseRate 在这个 /// build 里都是空实现),5 s 后照常挥击,玩家读作"打晕没有动画"。一次性动作撑不满 5 s 的窗口, /// 所以由 MaintainLocalEnemyGroggyAsync 按 LocalEnemyGroggyReassertMs 周期重推,直到窗口关闭 /// (ExitLocalEnemyGroggyAsync 会清掉节流记录)。 internal static async Task SendLocalEnemyGroggyPoseAsync(TcpSession session, ulong uid) { try { await session.NotifyAsync(MethodId.SyncEnemyPlayAction, UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938 { Uid = uid, ActionId = LocalEnemyGroggyActionId, MaxTime = LocalEnemyGroggyMs / 1000f, }), CancellationToken.None); session.Log.Info($"[ENEMY] groggy pose re-assert uid={uid} action={LocalEnemyGroggyActionId}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY] groggy pose push failed uid={uid}: {ex.Message}"); } } /// 虚弱窗口内客户端又上报一次硬直时的即时补推(节流 LocalEnemyStiffNudgeMs)。 /// /// 为什么需要它:MaintainLocalEnemyGroggyAsync 只在 AI tick 上按 LocalEnemyGroggyReassertMs /// 周期补推,而"倒地被顶掉"是客户端先知道的 —— 它受击命中后立刻上报 AskAgentEnterStiff。 /// 等周期到点再补,中间那一段敌人就是站着的(玩家读作"打晕没动画 / 只有挨打反应")。 /// 这里收到上报就补一次;与周期补推共用 LastGroggyReassert 节流表,两者不会叠成每帧一次。 internal static async Task ReassertLocalEnemyGroggyAsync(TcpSession session, ulong uid) { if (!LocalEnemyGroggyPoseEnabled) return; var runtime = GetLocalEnemyRuntimeState(session); var now = Environment.TickCount64; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null) return; if (record.GroggyUntilTicks == 0 || now >= record.GroggyUntilTicks) return; if (runtime.LastGroggyReassert.TryGetValue(uid, out var last) && now - last < LocalEnemyStiffNudgeMs) return; runtime.LastGroggyReassert[uid] = now; } await SendLocalEnemyGroggyPoseAsync(session, uid); } /// /// Death sequence for a local unit, in the order the client can actually consume: /// down pose -> death beat (EnemyDieInfo) -> loot drop -> LogicAgentLeave -> SyncRemoveNpc /// probe. Each step is separated so one that is dropped client-side does not take the /// next one with it, and every send is individually guarded. /// /// The last two steps are the removal half. SyncLogicAgentLeave routes through /// LogicAgentMgr and does not know LocalUnitMgr entries, so it is a no-op for this kind of /// unit and is kept only for the case where the target happens to be a real logic agent. /// SyncRemoveNpc is the step that actually lands: the fastpatch installs the Lua impl the /// retail build is missing, and that impl runs the destroy cascade. Both are still sent so /// one test tells them apart in the log. /// private static async Task LocalEnemyDeathSequenceAsync(RpcContext ctx, ulong uid, uint deadlySkillId, Vec3 deathPos) { try { var runtime = GetLocalEnemyRuntimeState(ctx.Session); bool serverSpawned; lock (runtime.SyncRoot) { serverSpawned = runtime.Enemies.TryGetValue(uid, out var dying) && dying is not null && dying.ServerSpawned; // 死在虚弱窗口里时必须立刻把窗口关掉,否则 MaintainLocalEnemyGroggyAsync 会继续 // 周期重推 67149,把这里马上要播的倒地动作顶掉(2026-09-23 17:3x,uid=…836: // 17:30:08 致死、窗口到 17:30:12,结果只有"直接消失")。不推出场包——对一个 // 已经在走死亡流程的单位推 PoiseRate(1)/DisarmState(false) 没有意义。 if (dying is not null && dying.GroggyUntilTicks != 0) { dying.GroggyUntilTicks = 0; ctx.Session.Log.Info($"[ENEMY] uid={uid} groggy window dropped — unit died inside it"); } runtime.LastGroggyReassert.Remove(uid); } // ① 先让客户端把这个单位搬出战斗管线、进入死亡流程。 // ⛑⛑ 顺序是 2026-09-23 17:4x 用客户端 Player.log 定下来的:fastpatch 的 SyncRemoveNpc // hook 每一次都成功播了 88300(17:43:51 / 17:44:27 / 17:44:49 / 17:45:07 四次 // "death PlayAction(88300) ok=true"),3 s 后 DestroyAgentUnit 也成功、单位确实 removed。 // 也就是说 hook 从来没坏过 —— 坏的是它挡不住客户端原生的受击反应: // hook 只能关掉自己注入的那条 Lua hurt 路径(__DRMK_Dead 闸门),而玩家近战连招的 // 受击动作是 C# 原生管线演的("SyncUnitHurtEffect left to native" 那条日志)。 // 能让它停的只有"单位已经死了"这个状态,而建立这个状态的包是 SyncEnemyPrepareDie —— // 之前它排在 SyncRemoveNpc 后面约 900 ms,那段时间里的连招照打,倒地一直被顶掉。 try { // RpcContext.NotifyAsync 只有两参重载(byte[] / 泛型),没有 CancellationToken 版。 await ctx.NotifyAsync(MethodId.SyncEnemyFightEdict, UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = false })); // 警戒状态机:发 None(0) 收起血条 + 清掉账本里的警戒字段。 // 等价于原来那句直接发 None,只是现在连账本一起清(GameRouter.EnemyWarning.cs)。 await ClearLocalEnemyWarningAsync(ctx.Session, uid); // ②′ 2026-09-24 01:1x 回退:这段是 00:2x 加的(死亡前先解除虚弱再走死亡流程), // 同一批改动一起撤掉 —— 用户对这批改动的评价是"死亡动画被我的攻击打断"。 // 23:27 的行为就是死亡序列不额外干预客户端的破防/虚弱状态,只靠 // DeathReassert + DeathQuietMs + MinCorpseMs 挡住死后继续推的受击包。 } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] death disengage failed uid={uid}: {ex.Message}"); } await ctx.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938 { Id = uid, DieInfo = new GameMethods.EnemyDieInfo4229938 { HasDieAnimation = true, HasDieEffect = true, DieEffectId = 0, DelayDestroyDistance = LocalEnemyDelayDestroyDistance, LastHitHurtEffect = 0, DeadlySkillId = deadlySkillId, Killer = Profile.PlayerPid, HasPlayedDeathSkill = false, WeaponDropDestructibleId = 0, DieType = GameMethods.DieType4229938.Damage, WeaponId = 0, }, }); ctx.Session.Log.Info($"[LENEMY] uid={uid} prepareDie sent (DieType=Damage)"); // ③′ 死亡标记:把单位打进 UnitStateConfig.DeadS(2)。 // 捐赠端 7k-v0.5(GameRouter.Enemy.cs:496-506)在 prepareDie + SyncUnitHp(0) 之后必发这一包, // 注释原文:"HP alone only changes the client data/UI and does not enter // UnitDeathBehaviorModule." // ⇒ 只把血打到 0 是不够的:客户端的单位还是"活的",它原生的受击管线照常跑, // 玩家后续的每一次命中都能重新抢到动作槽,把刚播起来的倒地/死亡动作顶掉 // (用户原话「敌人死亡不会自动无视我们的攻击 / 死亡动画会被普通攻击打断」)。 // 服务端这边早有闸门(dead && !shouldStartDeath 直接 return,一个包都不发), // 但那挡的是**我们**发的包,挡不住客户端自己演 —— 能让它停手的只有 // "这个单位已经是 DeadS"这个状态本身。 // 可关:gameplay.combat.enemies.pushDeadUnitState(false = 回到 23:27 既有行为)。 if (EnemyHit.PushDeadUnitState) { await ctx.NotifyAsync(MethodId.SyncAddUnitState, UxSerializer.Serialize( new GameMethods.SyncAddUnitStateArgs4229938 { Id = uid, State = LocalEnemyDeadUnitState, EffectFreezeState = 0u, })); ctx.Session.Log.Info($"[LENEMY] uid={uid} addUnitState sent (DeadS={LocalEnemyDeadUnitState})"); } // Register the corpse for teardown. The client normally answers with // ReportEnemyPrepareFinish / ReportAgentDiePerfEnd, which completes the clear // immediately; the deadline is the fallback so a dropped report cannot leave an // immortal corpse behind (GameRouter.EnemyStiff.cs). lock (runtime.SyncRoot) { runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs; // 尸体最短存活时间:客户端的死亡报告来得再早,也不许在这之前拆掉它。 runtime.DeathEarliest[uid] = Environment.TickCount64 + LocalEnemyMinCorpseMs; } // ② 死亡动作的播放点 + 尸体销毁定时器。hook 里 PlayAction(88300) 之后 3 s DestroyAgentUnit。 // 服务端不再自己推 88300(LocalEnemyPushDownAction = false):hook 的注释明写 // "the restart is what makes the corpse snap upright before it drops" —— 两个 88300 // 相隔几百毫秒就是一次动作重启,倒地被自己打断。 await Task.Delay(LocalEnemyRemoveNpcDelayMs); await ctx.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938 { NpcId = unchecked((int)uid), TaskId = 0, EnemyId = uid, }); ctx.Session.Log.Info($"[ENEMY] uid={uid} removeNpc sent (death-beat trigger, serverSpawned={serverSpawned})"); // SyncRemoveNpc 已经在序列开头发过(见那里关于"命中闸门"的说明),这里不再发。 // 剩余等待按 DropDelay 从头算:hook 的销毁定时器是 3 s,3.5 s 兜底拆尸体刚好在其后。 await Task.Delay(LocalEnemyDropDelayMs); await ctx.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938 { EnemyInstanceId = uid, BindItemsIndex = -1, ItemRotation = new SceneMethods.UxVector3(), Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z), }); // ★ T1-17:死亡后卸载战斗模块。必须等死亡特效播完(3 s 起 / 5.3 s 完)才能发, // 所以排在 prepareDie + DropDelay(3500) 之后再等 LocalEnemyDetachBattleUnitDelayMs。 // ⛑ 只有这条「服务端驱动的完整死亡序列」发;客户端自己上报的那条路径 //(AskEnemyStartFall,本文件另一处)**故意不发** —— 那里在回执之后还要占住 handler, // 多等几秒会把该 session 的后续包排队,收益不抵风险。 if (LocalEnemyDetachBattleUnitDelayMs > 0) { await Task.Delay(LocalEnemyDetachBattleUnitDelayMs); await ctx.NotifyAsync(MethodId.SyncDetachBattleUnit, new SceneMethods.SyncDetachBattleUnit { id = uid }); ctx.Session.Log.Info($"[LENEMY] uid={uid} detachBattleUnit sent"); } await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid)); ctx.Session.Log.Info($"[LENEMY] uid={uid} endItemDrop+logicAgentLeave sent"); // (SyncRemoveNpc 已经统一在上面发过,这里不再按 serverSpawned 分流。) // THE ACTUAL REMOVAL. SyncEnemyPrepareDie only asked the client to play the death; // SyncClearSpoonEnemies is what tears the unit down. Without it the corpse keeps // standing with an empty bar and every later hit on it is ignored (its ledger entry was // dropped here), which is exactly the "it came back and I cannot kill it" report. // Also drops the ledger entry — idempotent, see CompleteLocalEnemyDeathClearAsync. await CompleteLocalEnemyDeathClearAsync(ctx.Session, uid, "death-sequence"); ctx.Session.Log.Info($"[ENEMY] uid={uid} death sequence complete (clearSpoonEnemies{(serverSpawned ? "" : "+removeNpc")})"); } catch (Exception ex) { ctx.Session.Log.Warn($"[LENEMY] death sequence failed uid={uid}: {ex.Message}"); } } // ------------------------------------------------------------------ // Client-reported combat C2S. These are accepted for observability: nothing here // changes server state except the fall handshake, and each one logs what the client // itself believes happened. All arguments are read defensively. // ------------------------------------------------------------------ /// Defensive read of the single-u64 unit reference used by the fall handshake. /// These three methods carry one ulong argument (verified in-game on this build), so the /// read is a bare u64 rather than a guessed struct; a failure yields 0 and the caller /// simply does not answer. private static ulong TryReadUnitId(UxRpcMessage msg) { try { return msg.GetArgs().EnemyId; } catch { return 0; } } /// /// Raw body of a report whose wire shape is not verifiable on this build. Used instead of /// parsing: guessing a struct here would either throw or silently mis-read, whereas the /// hex dump in the log is itself the evidence needed to define the contract later. /// private static string RawBody(UxRpcMessage msg) { var body = msg.Body; if (body.Length == 0) return ""; var shown = Math.Min(body.Length, 64); return $"{body.Length}b {Convert.ToHexString(body, 0, shown)}" + (body.Length > shown ? "..." : ""); } /// /// Hurt-effect feedback. Recorded raw and NOT re-broadcast: the server already sends the /// hurt effect from the authoritative hit, so echoing this one would double the VFX on /// every melee swing. Whether the client emits this for a local unit at all is exactly /// what the log line answers. /// /// Registered for BOTH packet kinds and answered with an empty OK on invoke. The older /// ANANAS server returned SendEmptySuccessReturn for every one of these combat reports, /// which means the client may send them as invokes expecting a reply; a notify-only /// registration would leave that callback outstanding. [Handler(MethodId.AskPlayHurtEffect)] private async Task LocalEnemyHurtEffectReport(Connection conn, UxRpcMessage msg) { // LY-3.3 parity: the client reports the hurt effect it expects to see on the // target and the server echoes it back as SyncUnitHurtEffect. It was a log-only // stub here, which dropped the one round trip the reference build uses to drive // the visible hit reaction (GameRouter.Enemy.cs:835 in the LY-3.3 source). try { var args = msg.GetArgs(); if (args.HitTarget != 0) { conn.Log.Info($"[LENEMY] AskPlayHurtEffect target={args.HitTarget} hurtEffect={args.HurtEffectId} skill={args.SkillId}"); // 通缉系统挂钩(2026-09-23):玩家打中一个单位 ⇒ 记一次 npc-hit。 // attackerId 传 0(= 按玩家算):这条上报里的 ReleaserId 可能是 pid 而不是 // ActiveSpiritUnitId,拿它去比对会把所有命中都过滤掉。玩家自己挨打的情况由 // RegisterWantedCrimeIncidentAsync 自己按 targetId == playerId 排除。 await RegisterWantedCrimeIncidentAsync( conn.Session, "npc-hit", args.HitTarget, 0, msg.Context.CancellationToken); // 2026-09-26:与 SendLocalEnemyHurtEffectAsync 同源,Impact / LeftOrRight 查表。 var reportPresentation = ResolveLocalHurtPresentation(args.HurtEffectId); await conn.Session.NotifyAsync(MethodId.SyncUnitHurtEffect, UxSerializer.Serialize(new GameMethods.SyncUnitHurtEffectData4229938 { HurterId = args.HitTarget, AttackerId = args.ReleaserId != 0 ? args.ReleaserId : Profile.PlayerPid, HurtId = reportPresentation.Id, SkillId = args.SkillId, HurtEffectFacePos = new SceneMethods.UxVector3( args.ClientHitPosNormalDir.X, args.ClientHitPosNormalDir.Y, args.ClientHitPosNormalDir.Z), HurtImpact = reportPresentation.Impact, HurtLeftOrRightHit = reportPresentation.LeftOrRight, }), CancellationToken.None); } } catch (Exception ex) { conn.Log.Info($"[LENEMY] AskPlayHurtEffect raw (parse failed: {ex.Message}) {RawBody(msg)}"); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// The client's own combat sim announces the killing blow on a unit it owns. When that /// unit is one we track, the down pose and death beat are played immediately: the client /// hides the unit if the server stalls here, so answering late is as bad as not answering. /// /// Registered for BOTH packet kinds: the older ANANAS server answered this handshake with /// an empty OK, so an invoke form has to be replied to even when the unit is untracked. /// [Handler(MethodId.AskEnemyStartFall)] private async Task LocalEnemyStartFall(Connection conn, UxRpcMessage msg) { var uid = TryReadUnitId(msg); var isInvoke = msg.IsInvoke; if (uid == 0) { if (isInvoke) await conn.ReturnEmptyOkAsync(msg); return; } var runtime = GetLocalEnemyRuntimeState(conn.Session); bool tracked; bool own; lock (runtime.SyncRoot) { tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null; own = false; if (tracked) { own = !record!.DeathStarted; record.Dead = true; record.DeathStarted = true; } } if (!tracked) { conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} (not tracked, skipped)"); if (isInvoke) await conn.ReturnEmptyOkAsync(msg); return; } conn.Log.Info($"[LENEMY] AskEnemyStartFall uid={uid} — client death reported, {(own ? "owning" : "re-answering")} the sequence"); // 通缉系统挂钩(2026-09-23):被我们跟踪的单位死了 ⇒ 记一次 npc-kill。 // attackerId 传 0 —— 这条上报里没有攻击者,RegisterWantedCrimeIncidentAsync 会按玩家算。 await RegisterWantedCrimeIncidentAsync( conn.Session, "npc-kill", uid, 0, msg.Context.CancellationToken); await msg.Context.NotifyAsync(MethodId.SyncEnemyPlayAction, new GameMethods.EnemyPlayActionData4229938 { Uid = uid, ActionId = LocalEnemyDownActionId, MaxTime = 0f, }); await Task.Delay(LocalEnemyDeathBeatDelayMs); await msg.Context.NotifyAsync(MethodId.SyncEnemyPrepareDie, new GameMethods.SyncEnemyPrepareDieArgs4229938 { Id = uid, DieInfo = new GameMethods.EnemyDieInfo4229938 { HasDieAnimation = true, HasDieEffect = true, DieEffectId = 0, DelayDestroyDistance = LocalEnemyDelayDestroyDistance, LastHitHurtEffect = 0, DeadlySkillId = 0, Killer = Profile.PlayerPid, HasPlayedDeathSkill = false, WeaponDropDestructibleId = 0, DieType = GameMethods.DieType4229938.Damage, WeaponId = 0, }, }); // 同样登记尸体待拆:这条路径是客户端自己报的死亡,没有走 LocalEnemyDeathSequenceAsync, // 不登记就没人发 SyncClearSpoonEnemies("尸体还在"的同一条根因)。 lock (runtime.SyncRoot) runtime.PendingDeathClear[uid] = Environment.TickCount64 + LocalEnemyDeathClearGraceMs; if (isInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// The client finished the fall. Drop the loot and remove the unit, then forget it. /// Both packet kinds, empty OK on invoke. /// [Handler(MethodId.AskEnemyEndFall)] private async Task LocalEnemyEndFall(Connection conn, UxRpcMessage msg) { var uid = TryReadUnitId(msg); var isInvoke = msg.IsInvoke; if (uid == 0) { if (isInvoke) await conn.ReturnEmptyOkAsync(msg); return; } var runtime = GetLocalEnemyRuntimeState(conn.Session); bool tracked; bool serverSpawned = false; var deathPos = new Vec3(); lock (runtime.SyncRoot) { tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null; if (tracked) { serverSpawned = record!.ServerSpawned; deathPos = record.Position; runtime.Enemies.Remove(uid); } } if (!tracked) { if (isInvoke) await conn.ReturnEmptyOkAsync(msg); return; } var endTag = serverSpawned ? "[ENEMY]" : "[LENEMY]"; conn.Log.Info($"{endTag} AskEnemyEndFall uid={uid} — drop + leave{(serverSpawned ? "" : " + removeNpc")}"); await msg.Context.NotifyAsync(MethodId.SyncEnemyEndItemDrop, new GameMethods.EnemyEndItemDropData4229938 { EnemyInstanceId = uid, BindItemsIndex = -1, ItemRotation = new SceneMethods.UxVector3(), Position = new SceneMethods.UxVector3(deathPos.X, deathPos.Y, deathPos.Z), }); await msg.Context.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(uid)); // SyncRemoveNpc is the local-unit destroy path (see the death sequence above). A // server-spawned agent is a logic agent and is already gone via LogicAgentLeave. if (!serverSpawned) { await msg.Context.NotifyAsync(MethodId.SyncRemoveNpc, new GameMethods.SyncRemoveNpcData4229938 { NpcId = unchecked((int)uid), TaskId = 0, EnemyId = uid, }); } if (isInvoke) await conn.ReturnEmptyOkAsync(msg); } /// Fall handshake acknowledgement point — logged, and always acknowledged. /// Both packet kinds, empty OK on invoke. [Handler(MethodId.AskEnemyFallGround)] private async Task LocalEnemyFallGround(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[LENEMY] AskEnemyFallGround uid={TryReadUnitId(msg)}"); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// /// The client reports a unit casting a skill. Direct evidence that a unit's client-side /// AI is acting, so it is recorded raw (the wire shape is CSharpOnly and unverifiable on /// this build) rather than parsed — a locally generated enemy that fights back shows up /// in the console this way. Both packet kinds, empty OK on invoke. /// [Handler(MethodId.AskEnemyUseClientSkill)] private async Task LocalEnemyUseClientSkill(Connection conn, UxRpcMessage msg) { conn.Log.Info($"[LENEMY] client-AI cast raw {RawBody(msg)}"); if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } }