diff --git a/audit/contracts/movement.keyboard-motion.md b/audit/contracts/movement.keyboard-motion.md index 31315662..82df4c75 100644 --- a/audit/contracts/movement.keyboard-motion.md +++ b/audit/contracts/movement.keyboard-motion.md @@ -331,3 +331,10 @@ server_speed_scale = 0.0 if moving_speed > 1100 else float(moving_speed) / 100.0 **Equivalence**:`Constants/units` 行的 `movSpd` 缩放边界差异关闭;合同状态维持 `PARTIAL`(固定 300 像素 Dst/根运动适配层、资源与网络事件完整顺序、坐骑速度细节等 仍未闭合)。 + +### Port round 2026-09-22 — `PythonPlayerEventHandler.cpp` + +- `OnMove`/`OnMoving`/`OnStop`:`net_play.gd::_on_local_moved`/`_on_anim_state` 的 `_was_moving` 边沿与参考一致(PORTED)。 +- `OnWaiting`:参考只在 `CInstanceBase::Transform()` 的 `IsWalking() || IsUsingMovingSkill()` 分支内、本帧位移 ≤1.0 时调用(`InstanceBase.cpp:1922-1930`),100ms 节流,位移基准为 `m_kPPosPrevWaiting`(由 `OnSyncing`/`OnWaiting` 更新)。当前端原有的 `_tick_on_waiting()` 在站立期间周期补发 `FUNC_WAIT`,参考没有该机制,已删除(含 `SEND_WAITING_*` 与 `_last_waiting_sent_t`)。走/跑中本帧无位移的 OnWaiting 路径尚未移植(DIVERGENT)。 +- `CInstanceBase::SHORSE::SetMoveSpeed` 只同步坐骑模型动画速率,不是另一套移动速度公式,归 `world.mount_pet_polymorph`。 +- 测试:`netplay_test.gd` 新增“站立被推开不发包”断言;netplay/keyboard_motion_timeline/movement_parity/player_move/test_wasd_steering_parity/test_no_auto_move_regression/motion_event_warp/playable_combat 均 PASS。 diff --git a/audit/history.jsonl b/audit/history.jsonl index 4f393639..08010956 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -483,3 +483,4 @@ {"time": "2026-09-22T00:00:00Z", "event": "implementation_fix_round", "result": "PARTIAL", "ids": ["movement.keyboard.motion/camera-auto-follow-rotation"], "files": ["project/player_controller.gd", "project/keyboard_motion_timeline_test.gd", "audit/contracts/movement.keyboard-motion.md", "audit/remediation-roadmap.md", "audit/manifest.json", "audit/history.jsonl"], "tests": ["40250 reference source inspection (PASS: PythonPlayerInput.cpp:445-491 CPythonPlayer::NEW_MoveToDirection m_isCmrRot branch folds fDirRot to fSigDirRot/fRotRat and applies CCamera::Roll(-m_fCmrRotSpd*fElapsedTime*fRotRat/90) every frame a direction key is held and the movement gates pass)", "pre-fix keyboard_motion_timeline_test.gd (FAIL by construction: camera.yaw never written by _keyboard_wish/_process, new directional assertions would fail)", "post-fix godot --headless --path project --script keyboard_motion_timeline_test.gd (PASS)", "godot --headless --path project --script test_wasd_steering_parity.gd (PASS)", "godot --headless --path project --script test_no_auto_move_regression.gd (PASS)", "godot --headless --path project --script game_camera_test.gd (PASS)", "godot --headless --path project --script movement_parity_test.gd (PASS)", "godot --headless --path project --script player_move_test.gd (PASS)", "godot --headless --path project --script mouse_controller_test.gd (PASS)", "godot --headless --path project --script test_alignment_parity.gd (PASS)", "git diff --check (PASS)", "audit_ledger.py refresh --write/report --write/validate (PASS)", "cmake --build build-debug -j4 / ctest (BLOCKED: pre-existing stale CMakeCache.txt from a different checkout path, unrelated to this GDScript-only change; not run)", "godot --headless --path project --script player_motion_test.gd (BLOCKED: pre-existing parse error, GDScriptNativeClass.clear_texture_cache() not found in this Godot 4.7.1 build; file untouched this round, last changed in a prior commit)"], "reason": "按 40250 CPythonPlayer::NEW_MoveToDirection 的 m_isCmrRot 分支,在 player_controller.gd 新增 _camera_auto_rotate():按住方向键且通过 frozen/locked/private_shop_open/processing_emotion 门后,每帧按方向相对相机的方位角折算 fRotRat(正前/正后 0,正左右 ±90 最大速率,对角线减半),以 m_fCmrRotSpd=20.0 的速率持续把 camera.yaw 转向新朝向。D3D CCamera::Roll 与 Godot yaw 的转向手性相反,增量对 fRotRat 取正号(参考实现取负号),作为显式记录的平台适配,用『按住方向时相机朝该方向收敛、正前后不转、从不越界』的不变式核验,而非逐位对照 D3D 数值。未修改飞行物、技能、战斗、UI 或根运动/资源事件路径;Paralysis/Faint/Sleep 未接入移动门(新发现,记录于 Remaining,未修复)、根运动/AccumulationMovement、同步/受击门恢复顺序、真实模型姿态测试仍未闭合,合同保持 PARTIAL。"} {"time": "2026-09-22T01:30:00Z", "event": "audit_round", "result": "PARTIAL", "ids": ["movement.keyboard.motion/canact-paralysis-faint-sleep-gate"], "files": ["audit/contracts/movement.keyboard-motion.md", "audit/manifest.json", "audit/history.jsonl"], "tests": ["40250 reference source reachability audit (exhaustive grep -arn across UserInterface/ + GameLib/ for SetParalysis/Paralysis, AFFECT_SLEEP/SetSleep, SetFaint/FaintTest, Stun/RecvStunPacket)"], "reason": "Investigated the movement.keyboard-motion.md Remaining item \"CActorInstance::CanAct() Paralysis/Faint/Sleep movement gates not wired into net_play.gd\" (flagged unfixed by the prior round). Findings: (1) IsParalysis()/m_isParalysis has exactly one setter in the whole reachable 40250 source, CInstanceBase::__Shaman_SetParalysis (InstanceBaseEffect.cpp:821-823), which itself has zero callers anywhere in the tree -- Paralysis is dead code, never true in live gameplay, so not wiring it is correct parity, not a gap. (2) IsSleep()/m_isSleep: the AFFECT_SLEEP case in SetAffect()s switch is commented out (dead); the only live setter is the AFFECT_STUN case (m_GraphicThingInstance.SetSleep(isVisible), ~InstanceBaseEffect.cpp:932-933) -- IsSleep() is functionally just an alias for AFFECT_STUN in practice, which the current clients Entity.stunned field already represents (entity_store.h:119) and net_play.gd::_can_process_network_state() already gates movement on (dead/stunned/knock_down) -- already covered, not a gap. (3) IsFaint()/m_isFaint: only setter is chrFaintTest() (PythonCharacterModule.cpp:1087-1105), a Python-bound debug helper (\"FaintTest\", registered alongside chrtestRestoreRenderMode) operating on GetSelectedInstancePtr(); the available 40250 checkout has no root/ Python UI/quest scripts to prove or disprove live-gameplay reachability -- evidence-blocked, left unresolved per SKILL.md do-not-audit-unreachable-code-as-product-behavior rule, no fix attempted. Byproduct finding while tracing IsStun(): CActorInstance::Stun() is only reachable via RecvStunPacket() (PythonNetworkStreamPhaseGame.cpp:1556-1578), which explicitly routes GC_STUN to Die() for the main character and to Stun() only for remote actors; verified this does NOT create a movement.keyboard.motion discrepancy because CanAct() blocks movement identically on IsDead() and IsStun(), and net_play.gd::_can_process_network_state() already blocks movement identically on dead and stunned -- the movement-blocking outcome is already correct parity regardless of which reference path fires. The Die()-vs-Stun() distinction only affects death-system semantics (m_isRealDead, EXP drop, corpse/respawn flow), which is combat.death.exp_drop/combat.affect_status territory and is already tracked there (combat.affect-status.md Remaining #2, equivalence rows 63/67: native GC_STUN only sets stunned=true with no dedicated clear path) -- not re-logged as a new finding to avoid duplicate tracking. No code changed this round; removed the resolved Remaining bullet from manifest.json and movement.keyboard-motion.md, replaced with investigation evidence and citations. Contract stays PARTIAL (root-motion/resource-event-source/sync-hit-death-gate-stacking/real-model-bone-flip items remain open)."} {"time": "2026-09-22T03:15:00Z", "event": "implementation_fix_round", "result": "PARTIAL", "ids": ["movement.keyboard.motion/server-speed-scale-clamp"], "files": ["project/player_controller.gd", "project/keyboard_motion_timeline_test.gd", "audit/contracts/movement.keyboard-motion.md", "audit/manifest.json", "audit/history.jsonl", "audit/remediation-roadmap.md", "audit/reports/coverage.md"], "tests": ["40250 reference source inspection (PASS: InstanceBaseMovement.cpp CInstanceBase::SetMoveSpeed(UINT uMovSpd) only guards uMovSpd>1100 -> 0; otherwise applies uMovSpd/100.0f unclamped)", "extension/src/net/entity_store.cpp EntityStore::motion_move_speed re-read (PASS: already-fixed remote-entity path mirrors the same >1100 -> 0 / mount_vnum!=0 -> 0 guards, no [0.25,3.0]-style band)", "pre-fix player_controller.gd::set_server_speed (FAIL by construction: clampf(moving_speed/100.0, 0.25, 3.0) silently floors/ceils haste or slow stacks outside the reference's 25..300 movSpd band)", "post-fix godot --headless --path project --script keyboard_motion_timeline_test.gd (PASS, incl. new set_server_speed(10/500/1200/100) assertions)", "godot --headless --path project --script test_wasd_steering_parity.gd (PASS)", "godot --headless --path project --script test_no_auto_move_regression.gd (PASS)", "godot --headless --path project --script game_camera_test.gd (PASS)", "godot --headless --path project --script movement_parity_test.gd (PASS)", "godot --headless --path project --script player_move_test.gd (PASS)", "godot --headless --path project --script mouse_controller_test.gd (PASS)", "godot --headless --path project --script test_alignment_parity.gd (PASS)", "godot --headless --path project --script netplay_test.gd (PASS; unrelated mock set_server_speed in netplay_test.gd has no clamp, unaffected)", "git diff --check (PASS)", "audit_ledger.py refresh --write/report --write/validate (PASS)"], "reason": "在同一合同下追查『根运动 vs 连续速度』假设时发现这是假线索——entity_store.cpp::advance_walk_by_motion 与本地 _process() 的位移积分在种类上同构(都是恒速直线插值);真正的差异是 set_server_speed() 把 moving_speed/100.0 塞进无参考依据的 clampf(0.25, 3.0),而参考实现 CInstanceBase::SetMoveSpeed 和本项目已修复的 EntityStore::motion_move_speed 都只有 moving_speed>1100 -> 0 这一个门。移除该私有夹钳,改为 0.0 if moving_speed > 1100 else moving_speed/100.0,两处调用方(net_world.gd、net_play.gd)均不额外夹钳,改动范围限于 player_controller.gd。新增回归断言覆盖旧边界内外和 1100 冻结门。副产品发现:CInstanceBase::SHORSE::SetMoveSpeed 是坐骑上玩家的独立速度设定,只在 IsMounting() 时生效,与远端 mount_vnum!=0 -> 0 的处理方式不同——本轮未下定论,留作独立候选。未修改根运动/AccumulationMovement 适配层、资源与网络事件完整顺序、同步/受击门叠加或真实模型测试,合同保持 PARTIAL。"} +{"time": "2026-09-22T06:00:00Z", "event": "port_round", "unit": "UserInterface/PythonPlayerEventHandler.cpp", "ported": ["CPythonPlayerEventHandler::OnMove", "CPythonPlayerEventHandler::OnMoving", "CPythonPlayerEventHandler::OnStop"], "deleted": ["net_play.gd _tick_on_waiting idle FUNC_WAIT resend"], "divergent": ["CPythonPlayerEventHandler::OnWaiting (walking-but-stalled path not ported)"], "needs_live": [], "tests": ["project/netplay_test.gd PASS", "project/keyboard_motion_timeline_test.gd PASS", "project/movement_parity_test.gd PASS", "project/player_move_test.gd PASS"]} diff --git a/audit/manifest.json b/audit/manifest.json index 44139399..c3241bf1 100644 --- a/audit/manifest.json +++ b/audit/manifest.json @@ -276,7 +276,7 @@ }, "remaining": [ "固定 300 像素 Dst 已按事件创建并在目标到达时消费;锁定/移动技能期间 held WASD 会在解锁后重试;.msa 根运动和当前连续速度/米制推进的时序差异仍未闭合", - "OnMove/OnMoving/OnWaiting/OnStop 与当前每帧 signal、NetPlay 节流的事件源逐项证明", + "OnWaiting(走/跑动作中本帧无位移时 100ms+1.0 相对 m_kPPosPrevWaiting 发 FUNC_WAIT,InstanceBase.cpp:1930)未移植;站立期补发已删除", "同步、私店、受击、死亡、传送、情绪以及多种锁门叠加顺序;移动技能/普通锁定/私店/情绪的 held WASD 恢复已覆盖基础路径", "真实角色模型/骨骼在快速反向输入时不翻转的模型级测试", "输入事件到移动请求、停止请求和网络包的完整时间线" diff --git a/project/net_play.gd b/project/net_play.gd index fca0c0b1..513d8e12 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -90,15 +90,14 @@ const EntityRules = preload("res://entity_rules.gd") # §5.4 / §8.8 —— RecvTargetPacket 的目标框收包分派(判定树 + CanViewTargetHP)1:1。 const TargetBoard = preload("res://target_board.gd") # §3.2 上行节流表(对齐 `PythonPlayerEventHandler.cpp` 的 6 个回调): -# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving/waiting 两个窗口 +# OnMove —— 首次开走 / 改向:立即发 FUNC_MOVE,并重置 moving 窗口 # OnMoving —— 移动中周期上行:300 ms 节流,发 FUNC_MOVE -# OnWaiting—— 站立中周期上行:100 ms 节流且相对上次上行位移 ≥ 1 cm 才发 FUNC_WAIT +# OnWaiting—— 仅在走/跑动作中本帧无位移时触发(InstanceBase.cpp:1930);未移植, +# 见 audit/port-map/UserInterface/PythonPlayerEventHandler.json # OnStop —— 停下收尾:不节流,发 FUNC_WAIT # OnAttack —— 不节流,发 FUNC_COMBO + motion index(见 _do_attack_swing) # OnUseSkill—— 不节流,发 FUNC_SKILL|motion + arg(§3.8 施法路径) const SEND_MOVING_INTERVAL := 0.3 # OnMoving 最小间隔(秒) -const SEND_WAITING_INTERVAL := 0.1 # OnWaiting 最小间隔(秒) -const SEND_WAITING_MIN_DISP_CM := 1.0 # OnWaiting 触发的最小位移(服务器 cm) # 可点击 / 攻击距离(厘米),照 `InstanceBaseBattle.cpp:294` # `NEW_IsClickableDistanceDestInstance`:默认 150,NPC 500,采集物 100,弓箭走 __GetBowRange。 const CLICK_DIST_DEFAULT_CM := 150.0 @@ -237,7 +236,6 @@ var _view_other_empire_target := true var _lcontrol_down := false var _last_sent_pos := Vector3.ZERO # 上一次上行时的玩家世界坐标 var _last_moving_sent_t := 0.0 # 上一次 OnMoving 上行时刻(秒) -var _last_waiting_sent_t := 0.0 # 上一次 OnWaiting 上行时刻(秒) var _was_moving := false var _attack_cd := 0.0 var _last_click_actor_t := -10.0 @@ -408,7 +406,6 @@ func clear_for_map_change() -> void: _main_dead = false _last_sent_pos = Vector3.ZERO _last_moving_sent_t = 0.0 - _last_waiting_sent_t = 0.0 _was_moving = false _attack_cd = 0.0 _last_click_actor_t = -10.0 @@ -838,12 +835,10 @@ func _on_local_moved(pos: Vector3) -> void: return var now := _now() if not _was_moving: - # OnMove:立即上行,并把 moving/waiting 两个节流窗口都推到「刚发过」, - # 让随后的 OnMoving / OnWaiting 各自等一个完整间隔(PythonPlayerEventHandler.cpp:76)。 + # OnMove:立即上行,并把 moving 节流窗口推到「刚发过」(PythonPlayerEventHandler.cpp:76)。 _send_state(FUNC_MOVE, 0, pos) _was_moving = true _last_moving_sent_t = now - _last_waiting_sent_t = now return # OnMoving:300 ms 节流(:62)。 if now - _last_moving_sent_t < SEND_MOVING_INTERVAL: @@ -870,21 +865,6 @@ func _send_state(func_id: int, arg: int, world_pos: Vector3) -> void: client.move(func_id, arg, _heading_deg(_player_yaw()), int(xy.x), int(xy.y)) _last_sent_pos = world_pos -# OnWaiting —— 站立时周期性把当前位置补发上行,防止本地被击退 / 碰撞推挤后与 -# 服务器不一致(PythonPlayerEventHandler.cpp:38):100 ms 节流且相对上次上行位移 -# ≥ SEND_WAITING_MIN_DISP_CM 才发。 -func _tick_on_waiting() -> void: - if _was_moving or _observer_mode or client == null or not client.is_in_game() or pc == null or pc.player == null: - return - var now := _now() - if now - _last_waiting_sent_t < SEND_WAITING_INTERVAL: - return - var disp_cm := _server_xy(pc.player.position).distance_to(_server_xy(_last_sent_pos)) - if disp_cm < SEND_WAITING_MIN_DISP_CM: - return - _send_state(FUNC_WAIT, 0, pc.player.position) - _last_waiting_sent_t = now - # OnUseSkill —— 施法瞬间的状态上行:FUNC_SKILL|uMotSkill + uArg(不节流, # PythonPlayerEventHandler.cpp:120)。调用点(技能合法性 / CD / charge)属 §3.8 的 # `player_skill.gd`,这里只留 1:1 的上行出口,做法同 §3.4 的预留桩。 @@ -1604,7 +1584,6 @@ func _process(dt: float) -> void: _splash_attack_process(dt) # SPECIAL_ATTACKING:SplashArea -> OnHit _update_auto_attack() _refresh_reserved() - _tick_on_waiting() _flush_victim_list() # 帧末:被击退者 CG_SYNC_POSITION # CPythonPlayer::__Update_AutoAttack()(PythonPlayer.cpp:207) diff --git a/project/netplay_test.gd b/project/netplay_test.gd index 56528d1f..3229d5da 100644 --- a/project/netplay_test.gd +++ b/project/netplay_test.gd @@ -623,6 +623,10 @@ func _run() -> void: _ck(fc.calls.move.size() == 2 and fc.calls.move[1][0] == np.FUNC_MOVE, "OnMoving after 300ms -> FUNC_MOVE") pc.anim_state.emit("wait") # OnStop _ck(fc.calls.move.size() == 3 and fc.calls.move[2][0] == np.FUNC_WAIT, "OnStop -> FUNC_WAIT") + # 站立期间被推开也不补发:40250 的 OnWaiting 只在走/跑动作中触发(InstanceBase.cpp:1930)。 + pc.player.position = Vector3(4, 0, -1) + np._process(0.2) + _ck(fc.calls.move.size() == 3, "idle displacement sends no FUNC_WAIT") fc.calls.move.clear() np.on_use_skill(5, 2) # OnUseSkill _ck(fc.calls.move.size() == 1 and fc.calls.move[0][0] == (np.FUNC_SKILL | 5) and fc.calls.move[0][1] == 2,