Files
mtgodot-poc/audit/contracts/movement.keyboard-motion.md
T
shenleiandClaude Sonnet 5 c5c183da37 fix(movement): remove unreferenced [0.25,3.0] clamp on local player's movSpd scale
CInstanceBase::SetMoveSpeed only guards moving_speed > 1100 -> 0; the local
player's set_server_speed() was additionally floor/ceil-clamping the scale to
an arbitrary [0.25, 3.0] band with no basis in the reference or in this
project's already-fixed remote-entity equivalent (EntityStore::motion_move_speed).
A heavy haste stack or slow debuff on the local player would silently diverge
from what everyone else sees. Removed the extra clamp; kept the moving_speed<=0
early-return guard against pre-spawn/malformed values.

audit: movement.keyboard.motion/server-speed-scale-clamp closed with reference
citation, regression test, and full related-suite pass; contract stays PARTIAL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:31:17 +09:00

334 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 修复后全部
退出码 0Godot 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/根运动适配层、资源与网络事件完整顺序、坐骑速度细节等
仍未闭合)。