# movement.keyboard-motion ## Scope 比较键盘输入、WASD 快速切换、移动请求、方向/速度计算、动作选择和停止清理。 ## Reference call chain - `UserInterface/PythonPlayerInputKeyboard.cpp` - `UserInterface/PythonPlayerInput.cpp` - `UserInterface/InstanceBaseMovement.cpp` - `UserInterface/InstanceBaseMotion.cpp` - `GameLib/ActorInstanceMotion.cpp` - `GameLib/ActorInstanceMotionEvent.cpp` - `GameLib/GameUtil.cpp`(`CameraRotationToCharacterRotation`/`CharacterRotationToCameraRotation`) - `EterLib/Camera.cpp`(`CCamera::Roll`/`CalculateRoll`,增量式) - `GameLib/ActorInstanceBattle.cpp`(`CanAct`/`CanMove`) - `GameLib/ActorInstance.cpp`(`IsParalysis`/`IsFaint`/`IsSleep`) ## Current call chain - `project/player_controller.gd` - `project/game_camera.gd` - `project/net_play.gd` - `project/net_world.gd` - `project/test_wasd_steering_parity.gd` - `project/test_no_auto_move_regression.gd` - `project/player_motion_test.gd` - `project/keyboard_motion_timeline_test.gd` ## Audit findings (2026-09-20) 40250 的键盘路径是事件驱动的:`NEW_SetSingleDIKKeyState` → `NEW_SetSingleDirKeyState` 更新四个方向位 → `NEW_SetMultiDirKeyState`。无方向立即 `NEW_Stop`;有方向按 `Up > Down`、`Left > Right` 计算八方向角,再调用 `NEW_MoveToDirection`。`NEW_MoveToDirection` 依次执行取消点地预约、主角存在/私店/锁定/移动技能/同步门、设置 `m_isGoing = FALSE`、设置 advancing rotation、启动 walk/run motion,并把当前位置向量加 300 像素目标。 当前端已按事件维护方向位,W/S 优先级、释放切换、失焦、进场残留和直立旋转均有回归证据;本轮新增 `keyboard_motion_timeline_test.gd`,实际驱动 `InputEventKey` 和 `_process`,覆盖“加载期按键不继承 → W+S 优先 → 释放 W 暴露 S → 释放最后方向停止”的时序。 仍存在明确的实现层差异,不能升级为 STATIC_VERIFIED: - 40250 在按键事件中设置固定 300 像素 `Dst`,随后由 `.msa`/`CActorInstance::AccumulationMovement` 按动作根运动和 `m_fMovSpd` 推进;当前端在 `_process` 中按米制连续速度推进,模型可用时读取 `get_move_motion_speeds`,否则回退 `SPEED_WALK/SPEED_RUN`。方向角可一致,但目标距离、速度、到达时刻和阻挡后的状态不同。 - 40250 的 `CInstanceBase::Update → AccumulationMovement → Transform` 才会触发 `OnMove/OnMoving/OnWaiting/OnStop`;当前端 `moved` 和 `anim_state` 来自每帧预测位置,`NetPlay` 再按 0/300/100ms 节流发 `FUNC_MOVE/FUNC_WAIT`。已有节流测试,但没有证明每个动作资源停止/切换事件与网络状态事件一一对应。 - 40250 `NEW_MoveToDirection` 的 `__IsSyncing`、`isLock`、`IsUsingMovingSkill` 分支是“忽略移动但可能保留原动作”的同步门;当前端 `frozen`/`locked`/`moving_skill` 在 `_process` 早返回,`_goto` 被门挡时只对点地目标建立预约,键盘方向本身不建立预约。被击退、情绪、技能锁和解锁恢复的顺序尚未全部对齐。 - 40250 的相机旋转可在 `NEW_MoveToDirection` 内先改变相机再叠加角色方向;当前端读取 `camera.heading()` 后直接构造前后左右向量,没有实现同一套相机旋转副作用。 - 当前端用 Euler 分量强制保持 `rotation.x/z = 0`,解决了 W/S 快切翻跟头;这是有效的 Godot 平台适配,但还缺“角色根节点/骨骼动画旋转不被姿态资源覆盖”的真实模型测试。 因此现有测试证明的是输入回归和网络节流的已覆盖路径,不是完整 40250 移动/动作/资源链等价。 ## Baseline tests `test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、`netplay_test.gd` 和 `keyboard_motion_timeline_test.gd` 当前通过,证明已覆盖的回归路径没有复现自动移动、W/S 粘键、翻滚和 0/300/100ms 网络节流问题;这不替代完整 40250 分支等价审计。 ## Deep audit round 2026-09-20 本轮重新对照 `NEW_SetSingleDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement`、`RotationProcess` 和当前 `PlayerController`,并重跑输入与动作测试: - 当前 `active` 切换、W/S 优先级、KeyUp 全局接收、失焦清键、`rotation.x/z=0` 和跨 `±PI` 的单向转向均有可重复测试;这解释了此前 W/S 快切翻跟头已经被当前平台适配压住。 - 40250 方向键移动把目标设置为当前位置前方固定 300 像素,再由动作根运动和 `m_fMovSpd` 推进;当前端仍用 `_process` 的米制连续速度推进,并在模型缺失时使用固定速度 fallback。因此“方向一样”不代表到达时刻、动作结束事件和服务端 MOVE/WAIT 边沿一致。 - 当前 `frozen/locked/moving_skill` 在 `_process` 中早返回或只转向;40250 的同步、私店、受击、情绪、移动技能和 `NEW_Stop` 分支会分别决定是否清 `m_isGoing`、是否保留动作及何时恢复。本轮没有端到端证明这些门在解锁后与 40250 同一帧恢复。 - 当前摄像机方向只参与构造移动向量,没有复刻 40250 `NEW_MoveToDirection` 内相机旋转与角色 advancing rotation 的完整副作用;真实角色模型测试 `player_motion_test.gd` 虽打印 PASS,但本轮退出码为 139 并报告 6 个 ObjectDB 泄漏,不能作为干净证据。 - `test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、`keyboard_motion_timeline_test.gd` 本轮退出码 0,覆盖的输入回归通过;因此只保留“输入问题已回归通过”,不把根运动、资源事件、锁门和退出生命周期标记为等价。 结论:WASD 输入状态和翻转回归已稳定,但 40250 的固定目标/根运动、动作事件源、锁门恢复、相机副作用和真实模型退出安全仍有差异,合同保持 `PARTIAL`。 ### Active UserInterface actor-transform review - `InstanceBaseEvent.cpp` 将 `CInstanceBase` 的 event handler 直接绑定到 `CActorInstance`;`InstanceBaseTransform.cpp` 提供 cm 像素位置、rotation/blend direction、LookAt fly target/destination 和当前/advancing rotation 访问。 - `PythonPlayerEventHandler.h` 明确消费 `OnSyncing/Waiting/Moving/Move/Stop/Warp/ClearAffects/SetAffect/ResetAffect/Attack/UseSkill/Hit`,并在 bow fly handler 中把 `OnSetFlyTarget` 与动作帧 `OnShoot` 转成网络副作用。当前端 `PlayerController`/`NetPlay` 有移动/技能信号,但未证明远端/本地都按同一 event handler 顺序消费,自动移动和快速 WASD 仍需状态时序 oracle。 - 本轮完成 InstanceBase/PlayerEventHandler 静态核对,键盘合同继续为 `PARTIAL`。 ## Implementation round 2026-09-20T19:03Z 本轮将键盘方向目标按 `InstanceBaseMovement.cpp::NEW_MoveToDirection` 的固定距离 规则收敛:方向事件现在记录当前角色位置加 300 像素(当前 Godot 单位为 3.0m)的 `_direction_dst`,后续帧只向该目标推进,目标到达后停止,不再每帧从当前位置延长 路径;移动技能仍保留只转向不平移的分支。死亡、受击/锁定门和方向释放会清理该目标。 新增的时间线断言验证了固定目标、到达停止、W/S 优先级、KeyUp 暴露、最后方向释放和 进入游戏不继承输入;`keyboard_motion_timeline_test.gd`、`player_move_test.gd`、 `test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd` 均退出码 0。 这只修复了目标创建/消费语义,不宣称完整根运动等价:当前端仍由 Godot `_process` 按速度推进,尚未接入 40250 `.msa`/`AccumulationMovement` 的真实动作事件、相机副作用、 同步/情绪/受击解锁恢复和完整 MOVE/WAIT 时间线,合同继续为 `PARTIAL`。 ## Equivalence matrix | 项目 | 状态 | 备注 | |---|---|---| | Preconditions | PARTIAL | active/失焦/按键残留、锁门、私店、情绪和锁门 held WASD 已覆盖;同步、受击、死亡和传送门未全覆盖 | | Branch structure | PARTIAL | W/S、A/D、八方向、释放顺序和 `m_isCmrRot` 相机跟随已覆盖;Paralysis/Faint/Sleep 已调查——Paralysis 死代码、Sleep 已被 `stunned` 门覆盖、Faint 证据不足,均非差异 | | Algorithms/formulas | PARTIAL | 方向优先级、归一化和相机跟随的 fold-to-±90/rate 公式一致(`movement.keyboard.motion/camera-auto-follow-rotation`);固定 300 像素 Dst 与根运动仍被连续速度替代 | | State transition order | PARTIAL | 输入状态、锁门/移动技能恢复、PlayerController、NetPlay 入口已定位;资源事件与网络事件的完整顺序未证实 | | Constants/units | PARTIAL | 角度/旋转阈值和 0/300/100ms 节流已记录;`movSpd` 缩放边界已与 `CInstanceBase::SetMoveSpeed` 对齐(`movement.keyboard.motion/server-speed-scale-clamp`);米/厘米和目标距离仍不同 | | Timing/event sources | PARTIAL | 当前是 `_process` 预测 + signal;参考是 Actor motion event 驱动 | | Resource/data sources | PARTIAL | 当前模型速度可从动作资源读取但有 fallback;`.msa` event/AccumulationMovement 尚未一一对照 | | Protocol side effects | PARTIAL | `FUNC_MOVE/FUNC_WAIT` 发送和节流有测试;阻止/重复/相机副作用仍未闭合 | | Interruption/failure/cleanup | PARTIAL | stop、失焦、换图、锁定/移动技能恢复已覆盖;受击/情绪/传送和完整解锁矩阵仍需测试 | ## Remaining - 用真实 `camera.heading()`、模型动作速度和网络 fake 记录同一方向序列的输入事件、移动目标、停止请求、动画状态和包时间线。 - 补充死亡/传送/受击以及私店、情绪、移动技能和普通锁定的多门叠加顺序;各单门 held WASD 恢复已覆盖基础路径。 - 继续对照 `.msa` 的 motion event、`AccumulationMovement` 根运动和当前 `get_move_motion_speeds` fallback,决定是否需要根运动适配层。 - 真实角色模型在 W/S 快切时根节点/骨骼姿态不翻转的模型级测试仍缺。 - ~~`CanAct()` 的 Paralysis/Faint/Sleep 移动门~~ **已调查关闭(2026-09-22, `movement.keyboard.motion/canact-paralysis-faint-sleep-gate`,见下方调查轮次)**: Paralysis 是可达代码中的死代码(唯一 setter 零调用点);Sleep 在实际运行中是 AFFECT_STUN 的别名,已被当前端 `stunned` 移动门覆盖;Faint 的可达性在现有 40250 检出材料下无法证明或证伪,evidence-blocked。三者均非需要修复的移动等价差异。 - ~~本地玩家 `set_server_speed()` 对 `moving_speed/100.0` 施加了参考代码没有的 `clampf(..., 0.25, 3.0)` 边界~~ **已修复(2026-09-22, `movement.keyboard.motion/server-speed-scale-clamp`,见下方实现轮次)**:现在 与 `CInstanceBase::SetMoveSpeed` 和已修复的 `EntityStore::motion_move_speed` 一样,只在 `moving_speed > 1100` 时归零,其余按 `moving_speed/100.0` 直接换算。 ### Audit round 2026-09-21 本轮重新执行 `keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd` 和 `test_no_auto_move_regression.gd`,三项均通过;W/S 优先级、KeyUp 暴露被按住方向、最后 方向释放停止、进入游戏不继承加载期按键、角色 X/Z 姿态归零、跨 `±PI` 转向和技能结束后 不自动恢复移动均有回归证据。 沿 `ActorInstanceBattle.cpp::ComboAttack`、`ActorInstanceMotion.cpp::AccumulationMovement` 和 `PythonPlayerEventHandler::OnMove/OnMoving/OnWaiting/OnStop` 再次核对后,没有把这些 输入测试升级为完整等价:当前端仍用连续速度预测替代固定 300 像素 Dst + 根运动,动作资源 事件与移动网络事件也不是同一来源;同步/受击/情绪/移动技能解锁恢复、相机旋转副作用和 真实模型根节点测试仍是下一轮差异项。合同保持 `PARTIAL`,本轮未改动实现。 ### Implementation fix round 2026-09-21T11:45Z 本轮按 40250 `CPythonPlayer::Update` 在 `m_isDirKey` 下每帧重试 `NEW_SetMultiDirKeyState(m_isLeft,m_isRight,m_isUp,m_isDown)` 的语义修复了当前端的 锁门恢复差异:方向键仍按住时,`frozen/locked` 不再丢弃方向状态;移动技能接管移动时 会清除旧的平移目标但保留 held WASD,技能结束或锁定解除后的首个可移动更新重新创建 固定 300cm 目标。方向释放、死亡和停止仍会清除 pending 状态,避免旧目标在技能结束后 莫名续走。 `keyboard_motion_timeline_test.gd` 新增锁定恢复和移动技能恢复断言;与 `test_wasd_steering_parity.gd`、`test_no_auto_move_regression.gd`、 `player_motion_test.gd` 一起退出码均为 0。真实模型测试同时改为清理 native model/animation 节点并清空测试用的 GR2/MSA 缓存,确认本轮测试不再出现 ObjectDB 泄漏和 exit 139。 这轮只闭合了 held WASD 在锁门/移动技能边沿的状态恢复,不宣称完整移动等价:当前端仍 用 Godot 连续速度预测替代 `.msa`/`AccumulationMovement` 根运动,`OnMove/OnMoving/` `OnWaiting/OnStop` 事件源、同步/受击/传送边界、相机副作用和完整网络时间线 仍保持 `PARTIAL`。 ### Implementation fix round 2026-09-21T12:05Z 本轮补齐了 40250 `__CanMove` 的两个此前缺失的主角状态门: `CPythonPlayer::IsOpenPrivateShop()` 与 `__IsProcessingEmotion()`。当前端新增 `PlayerController.set_private_shop_open()` / `set_processing_emotion()`;门开启时会 清理点地预约和旧方向目标,但保留 WASD 的方向位,和 `NEW_SetSingleDirKeyState` 保留 `m_isDirKey`、`CPythonPlayer::Update` 解锁后重新调用 `NEW_SetMultiDirKeyState` 的行为一致。`NetPlay._on_entity_info()` 只对主角读取 `shop_sign` / `acting_emotion`,因此查看或打开其他角色的商店不会锁住本地输入。 `keyboard_motion_timeline_test.gd` 新增摆摊中、情绪处理中“无平移且保留按键”,以及 状态解除后第一帧恢复移动的断言;本轮 targeted tests 通过。私店/情绪两个移动门已闭合, 但根运动、动作事件源、同步/受击/传送边界、相机副作用和完整网络时间线仍保持 `PARTIAL`。 ## Audit round 2026-09-21T04:45Z — keyboard/root-motion boundary 本轮重新执行 `keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd`、 `test_no_auto_move_regression.gd` 和 `player_motion_test.gd`,全部退出码为 0: - 进入游戏不继承加载期按键、W/S 优先与释放切换、最后方向停止、技能结束不续走、快速 WASD 不翻跟头、跨 `±PI` 单向转向和真实模型动作装配均保持通过。 - 继续对照 `NEW_SetMultiDirKeyState`、`NEW_MoveToDirection`、`AccumulationMovement` 和 `OnMove/OnMoving/OnWaiting/OnStop` 后,确认当前端仍是 Godot `_process` 连续速度预测,不能用输入测试替代 40250 `.msa` 根运动/动作事件源证据。 结论:本轮未修改实现;输入回归保持稳定,合同继续 `PARTIAL`。下一轮需要用真实动作资源和 fake network timeline 对拍固定 300cm 目标、根运动、停止事件和 MOVE/WAIT 包边沿。 ### Implementation fix round 2026-09-22 — `movement.keyboard.motion/camera-auto-follow-rotation` 本轮处理的具体分支:40250 相机旋转副作用(此前 Remaining 与 Branch structure 行标注 的 `没有实现同一套相机旋转副作用`)。 **参考实现**(`UserInterface/PythonPlayerInput.cpp:445-491` `CPythonPlayer::NEW_MoveToDirection`): `IsOpenPrivateShop()`/`isLock() && !IsUsingMovingSkill()` 提前返回(不跑相机分支)之后, 若 `m_isCmrRot`(硬编码为 true,从未被关闭): 把方向角 `fDirRot`(0=前 90=左 180=后 270=右,来自 `NEW_GetMultiKeyDirRotation` → `NEW_GetMouseDirRotation` → `GetDegreeFromPosition` 的屏幕空间角)折成有符号 `fSigDirRot ∈ (-180,180]`,再折叠到 `fRotRat ∈ [-90,90]`(正前/正后为 0,正左/右为 ±90 最大速率,对角线线性减半),套公式 `fRotDeg = -m_fCmrRotSpd(20.0) * fElapsedTime * fRotRat / 90`,通过 `CCamera::Roll(fRotDeg)` (D3D 增量式 roll,非绝对值)应用;这一步与是否已建立平移目标无关,且 `CPythonPlayer::Update` 每帧对仍按住的方向键重试整条链路,所以只要方向键按住、且通过门检查,相机就持续转动。 **当前实现(修复前)**:`player_controller.gd::_keyboard_wish()` 只读 `camera.heading()` 构造世界方向向量,从不写回 `camera.yaw`——完全没有这个副作用。 **修复**:新增 `PlayerController._camera_auto_rotate(dir, dt)`(`player_controller.gd`), 在 `_process()` 的 `frozen/locked/private_shop_open/processing_emotion` 门之后、其余 移动逻辑之前,用当前帧 `_wasd()` 的方向向量无条件调用(门未通过时函数整体在早退分支 里不会被调用,等价于参考实现的 `IsOpenPrivateShop`/`isLock` 早退)。公式与参考实现的 折算/折叠逻辑逐项相同(`fSigDirRot`/`fRotRat` 折叠、`CAMERA_AUTO_ROTATE_SPEED_DEG=20.0` 对应 `m_fCmrRotSpd` 默认值、按 `dt` 与 `fRotRat/90` 线性缩放)。**平台适配**:D3D `CCamera::Roll` 的正方向与 Godot `yaw` 增大(视线转向左)手性相反,因此增量对 `fRotRat` 取正号而非参考实现的负号;这一符号翻转用不变式核验,而不是逐位对照 D3D 数值—— "持续按住某一方向时,相机朝该方向收敛(正前/正后不转,正左右转速最快,对角线减半), 从不反向或越界",与参考实现的稳态行为一致。移动技能中的转向、以及触屏/摇杆 `mobile_axis` 路径本轮未改动(后者是纯前端手势适配,参考实现没有对应的按键状态机, 留作独立分支,不在本次改动范围内)。 **测试**:`keyboard_motion_timeline_test.gd` 新增六项断言(`GameCamera` 实例挂到 `pc.camera`,不加入场景树,只读写 `yaw`):纯前/后不转、纯左/右等速反向、多帧速率恒定、 对角线为纯左的一半速率、`locked` 门下按住方向键相机不转。修复前四项方向性断言必然 失败(`cam.yaw` 恒为初始值 0),修复后全部通过。 **测试结果**:`keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd`、 `test_no_auto_move_regression.gd`、`game_camera_test.gd`、`movement_parity_test.gd`、 `player_move_test.gd`、`mouse_controller_test.gd`、`test_alignment_parity.gd` 修复后全部 退出码 0(Godot 4.7.1 headless)。`player_motion_test.gd`(旧文件名,非本轮新增)在本环境 用 `godot --headless --path project --script player_motion_test.gd` 单独运行时因脚本自身的 `GDScriptNativeClass.clear_texture_cache()` 静态方法找不到而解析失败——本轮未改动此文件, `git log` 确认最后一次改动早于本会话;这是与本分支无关的预置环境问题,未修复,仅记录。 `build-debug/` 的 `CMakeCache.txt` 记录的是另一个已废弃的检出路径 (`/Users/shen/Work/Code/Metin2/...`),导致 `cmake --build build-debug` 在本环境直接报错, 无法据此重跑 ctest/ASAN 套件;本轮改动只涉及 GDScript,未触及 `extension/` 下任何 C++, 判断为与本分支无关的预置构建环境问题,同样未修复,仅记录供后续轮次或维护者决定是否 重新配置构建目录。 **equivalence 更新**:Branch structure、Algorithms/formulas 两行的相机旋转子项由未闭合 改为已闭合(见上表),`Remaining` 移除相机副作用项、新增 Paralysis/Faint/Sleep 移动门 子项(见上)。合同整体仍为 `PARTIAL`——根运动/资源事件/同步受击门/Paralysis-Faint-Sleep 仍未闭合。 ### 调查轮次 2026-09-22T — `movement.keyboard.motion/canact-paralysis-faint-sleep-gate` 本轮对上条 Remaining 中「`CanAct()` 的 Paralysis/Faint/Sleep 三个移动门未接入」逐一做 可达性追踪(`grep -arn`,因大量 `UserInterface/*.cpp` 注释含 CP949 字节,需要 `-a` 才能 正确匹配),结论:三者均不构成需要修复的移动等价差异,逐项证据如下。 - **`IsParalysis()`/`m_isParalysis`**:全部可达源码中唯一的写入点是 `CInstanceBase::__Shaman_SetParalysis`(`InstanceBaseEffect.cpp:821-823`,直接调用 `m_GraphicThingInstance.SetParalysis(...)`,`m_GraphicThingInstance` 声明为 `CActorInstance`,`InstanceBase.h:999`,确认改的是 `CanAct()` 读的同一个标志位)。 对 `__Shaman_SetParalysis`/`SetParalysis`/`Paralysis` 在整个 `40250/ClientVS22/source` 树(`UserInterface`+`GameLib`+其余全部模块)做穷举 grep,**零调用点**——没有任何脚本、 网络包处理器或技能逻辑调用它。结论:Paralysis 分支在可达代码中是死代码,40250 Windows 客户端实际运行中 `IsParalysis()` 恒为 false。当前端不接入此门是正确等价 (无对应行为可对照),不是差异。 - **`IsSleep()`/`m_isSleep`**:`InstanceBaseEffect.cpp` 的 `SetAffect()` 分支表中 `AFFECT_SLEEP` 分支已被注释掉(死代码);唯一存活的写入路径是 `AFFECT_STUN: m_GraphicThingInstance.SetSleep(isVisible);`(约行 932-933)。即 `IsSleep()` 在实际运行中就是 AFFECT_STUN 的别名,不是独立信号。当前端 `entity_store.h:119` 的 `stunned` 字段本就注释为对应 `AFFECT_STUN` (`InstanceBaseEffect.cpp:932`),`net_play.gd::_can_process_network_state()` 已经在 `dead`/`stunned`/`knock_down` 上阻止移动——即 Sleep 对应的移动门已经被现有 `stunned` 门覆盖,不需要新增单独的 Sleep 门。 - **`IsFaint()`/`m_isFaint`**:唯一写入点是 `chrFaintTest()` (`PythonCharacterModule.cpp:1087-1105`),Python 绑定名 `"FaintTest"` (同文件 1305 行),与另一个调试方法 `chrtestRestoreRenderMode`/`"RestoreRenderMode"` (1072/1319 行)并列在同一张绑定表里,且操作对象是 `GetSelectedInstancePtr()` (更像选人/预览界面而非在线主控角色)。当前可用的 40250 检出只有 8 个 `.py` 文件、没有 root/ 下的任何 UI/任务脚本,因此无法从现有材料证明或证伪是否有在线 脚本会调用 `char.FaintTest()`。结论:证据不足,按 SKILL.md 的「不要在没有可达性证据 时把未验证代码当作产品行为来追」原则,本项目前不作为移动门差异处理,标记为 evidence-blocked。 **过程中新发现(已移交,非本合同范围)**:追踪 `IsStun()` 时发现 `CActorInstance::Stun()` 唯一调用者 `CInstanceBase::Stun()` (`InstanceBaseBattle.cpp:683-688`)本身只被 `RecvStunPacket()` (`PythonNetworkStreamPhaseGame.cpp:1556-1578`)调用,且该函数显式区分: `if (GetMainInstancePtr()==pkInstSel) pkInstSel->Die(); else pkInstSel->Stun();`—— 主角收到 `GC_STUN` 走的是 `Die()`(`InstanceBaseBattle.cpp:691` → `ActorInstance.cpp:365` `m_isRealDead=TRUE`+播放 NAME_DEAD 动作),只有非主角远端 actor 才真正调用 `Stun()`/ 置位 `IsStun()`。当前端 `entity_store.cpp` 的 `GC_STUN` 处理器对任意 vid(含主角)统一 置 `stunned=true`,没有这个主角分流。**但这不是 movement.keyboard.motion 的差异**: `CanAct()` 对 `IsDead()`/`IsStun()` 的移动阻断效果相同,而 `net_play.gd` `_can_process_network_state()` 已经对 `dead`/`stunned` 同等阻止移动——即无论主角 `GC_STUN` 走死亡路径还是眩晕路径,当前端的**移动阻断结果**已经正确等价。 `Die()`/`Stun()` 分流只影响死亡系统本身(`m_isRealDead`、EXP 掉落、尸体/复活流程等), 这是 `combat.death.exp_drop`(或 `combat.affect_status`,见其合同 Remaining #2、 分支矩阵第 63/67 行已经记录 `native GC_STUN` 目前只置 `stunned=true` 缺解除路径)的 范围,不在此重复登记。 **结论**:本轮不改动实现代码——三个候选门中两个(Paralysis/Sleep)是死代码/已被现有 `stunned` 门覆盖,第三个(Faint)证据不足;衍生发现的 GC_STUN 主角分流差异经核实对 移动阻断结果无影响,已移交 `combat.affect_status` 既有条目,不重复登记。`Remaining` 中对应条目标记为已调查关闭。合同状态维持 `PARTIAL`(根运动/资源事件/同步受击门等 仍未闭合),Branch structure 行的 Paralysis/Faint/Sleep 备注移除。 ### Implementation fix round 2026-09-22 — `movement.keyboard.motion/server-speed-scale-clamp` **参考链**:`InstanceBaseMovement.cpp:CInstanceBase::SetMoveSpeed(UINT uMovSpd)`—— `if (uMovSpd > 1100) uMovSpd = 0; m_GraphicThingInstance.SetMoveSpeed(uMovSpd/100.0f);`。 除 1100 冻结门外没有其他上下限。 **当前链(修复前)**:`player_controller.gd::set_server_speed(moving_speed)` 把 `moving_speed/100.0` 塞进 `clampf(..., 0.25, 3.0)`,即 `moving_speed<25` 会被强行拉高到 0.25 倍速、`moving_speed>300` 会被强行压低到 3.0 倍速——这个区间在参考代码、 `InstanceBase.cpp` 或本项目已修复的远端实现 `EntityStore::motion_move_speed` (`extension/src/net/entity_store.cpp`,只有 `moving_speed>1100→0` 和 `mount_vnum!=0→0` 两个门)中都找不到依据,是无参考支持的本地私有夹钳:叠了减速/ 加速状态的本地玩家会和其他人看到的自己不一致。 调查过程中还确认 `advance_walk_by_motion`(远端实体)和本地 `_process()` 的位移积分 在**种类**上是同构的(都是恒速 `speed*dt` 直线插值,都从 `get_move_motion_speeds()` 取 `.msa` 基准速度),排除了「根运动 vs 连续速度」这个更大的假设——真正的差异只在 速度缩放公式的边界,不涉及积分模型本身。另确认 `CInstanceBase::SHORSE::SetMoveSpeed` 是坐骑上玩家的另一套速度设定(`InstanceBase.cpp:40-135`),只在 `IsMounting()` 时生效; 本地玩家的动作模式已经在 `net_play.gd::motion_mode_for()` 里按骑乘切到 `MOTION_MODE_HORSE`,而远端实体对 `mount_vnum!=0` 直接归零处理——这是坐骑速度的独立 细节,本轮不下定论,留给未来一轮单独核实是否构成差异。 **修复**:`project/player_controller.gd::set_server_speed()` 把 ```gdscript server_speed_scale = clampf(float(moving_speed) / 100.0, 0.25, 3.0) ``` 改为 ```gdscript server_speed_scale = 0.0 if moving_speed > 1100 else float(moving_speed) / 100.0 ``` `moving_speed <= 0` 提前 return 的既有保护(防止出生前/畸形值冻结本地预测)保留不变。 两处调用方(`net_world.gd:795-796`、`net_play.gd:921-924`)均不额外夹钳,改动影响面 只限 `player_controller.gd` 本体。 **测试**:新增 `keyboard_motion_timeline_test.gd` 末尾的 `set_server_speed` 断言 (`moving_speed=10→0.1`、`500→5.0`、`1200→0.0`、`100→1.0`,覆盖旧 [0.25,3.0] 边界内外和 1100 冻结门),随同下列既有回归一并执行,全部 PASS: `keyboard_motion_timeline_test.gd`、`test_wasd_steering_parity.gd`、 `test_no_auto_move_regression.gd`、`game_camera_test.gd`、`movement_parity_test.gd`、 `player_move_test.gd`、`mouse_controller_test.gd`、`test_alignment_parity.gd`、 `netplay_test.gd`(`netplay_test.gd` 里的同名 mock `set_server_speed` 是独立测试替身, 不含旧夹钳逻辑,未受影响)。`git diff --check` 无空白错误。 **Equivalence**:`Constants/units` 行的 `movSpd` 缩放边界差异关闭;合同状态维持 `PARTIAL`(固定 300 像素 Dst/根运动适配层、资源与网络事件完整顺序、坐骑速度细节等 仍未闭合)。